Merge 761af12203018fe248b5eeff087cd6a861164fed into f986b195e8f8e908d0eeeb35817ccea3a1adb93b

This commit is contained in:
farwhile 2026-09-26 12:04:49 +00:00 committed by GitHub
commit b8e93db2d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
90 changed files with 4689 additions and 1678 deletions

View File

@ -30,8 +30,8 @@ android {
applicationId = "com.bitchat.droid"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 39
versionName = "2.0.2"
versionCode = 38
versionName = "2.0.1"
buildConfigField(
"String",
"GITHUB_RELEASE_CERT_SHA256",

View File

@ -1,6 +1,7 @@
package com.bitchat.android.testhook
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.util.Log
import com.bitchat.android.favorites.FavoritesPersistenceService
@ -15,7 +16,10 @@ import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.PrivateMediaPreparation
import com.bitchat.android.mesh.TransferProgressManager
import com.bitchat.android.mesh.CourierDepositTier
import com.bitchat.android.mesh.CourierStore
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.CourierEnvelope
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.protocol.BitchatPacket
@ -23,8 +27,11 @@ import com.bitchat.android.service.MeshForegroundService
import com.bitchat.android.service.MeshServiceHolder
import com.bitchat.android.service.TransportBridgeService
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.services.ConversationStorageCipher
import com.bitchat.android.services.MessageRouter
import com.bitchat.android.ui.DataManager
import com.bitchat.android.ui.PrivateMediaRecipientResolver
import com.bitchat.android.ui.debug.DebugSettingsManager
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@ -69,6 +76,13 @@ object TestHookDriver {
"announce" -> announce(context)
"broadcast_msg" -> broadcastMsg(context, intent.requiredString("content"), intent.getStringExtra("channel"))
"dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id"))
"router_private_send" -> routerPrivateSend(
context,
intent.requiredString("peer"),
intent.requiredString("content"),
intent.getStringExtra("msg_id")
)
"router_resume" -> routerResume(context)
"dm_recv" -> dmRecv(context, intent)
"msg_recv" -> msgRecv(context, intent)
"favorite_set" -> favoriteSet(
@ -89,7 +103,11 @@ object TestHookDriver {
"ptt_send" -> pttSend(context, intent)
"ptt_recv" -> pttRecv(context, intent)
"raw_send" -> rawSend(context, intent)
"courier_contract" -> courierContract(context)
"cache_peer_identity" -> cachePeerIdentity(context, intent.requiredString("peer"))
"sync_request" -> syncRequest(intent.requiredString("peer"))
"ble" -> setBle(intent.getBooleanExtra("enabled", true))
"wifi_aware" -> setWifiAware(intent.getBooleanExtra("enabled", true))
"inject_peers" -> injectPeers(intent.getStringExtra("peers"))
"state" -> state(context)
"clear_results" -> clearResults(context)
@ -243,6 +261,27 @@ object TestHookDriver {
return ok("dm_send").put("peer", peerID).put("msg_id", id)
}
/**
* Drives the same durable outbox/router path used by private-chat sends instead of the
* lower-level direct mesh API that backs `dm_send`.
*/
private fun routerPrivateSend(context: Context, peerID: String, content: String, msgID: String?): JSONObject {
val mesh = mesh(context)
val nickname = mesh.getPeerNicknames()[peerID] ?: peerID
val id = msgID ?: "testhook-router-${System.currentTimeMillis()}"
val route = MessageRouter.getInstance(context, mesh).sendPrivate(content, peerID, nickname, id)
return ok("router_private_send")
.put("peer", peerID)
.put("msg_id", id)
.put("route", route.name)
}
/** Recreates the durable router after a process restart without adding another message. */
private fun routerResume(context: Context): JSONObject {
MessageRouter.getInstance(context, mesh(context))
return ok("router_resume")
}
private suspend fun dmRecv(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
val fromPeer = intent.getStringExtra("peer")
@ -276,10 +315,11 @@ object TestHookDriver {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
val contains = intent.getStringExtra("contains")
val channel = intent.getStringExtra("channel")
val includeExisting = intent.getBooleanExtra("include_existing", false)
val startTime = System.currentTimeMillis()
val mesh = mesh(context)
val matches: (com.bitchat.android.model.BitchatMessage) -> Boolean = { msg ->
msg.timestamp.time >= startTime &&
(includeExisting || msg.timestamp.time >= startTime) &&
msg.senderPeerID != mesh.myPeerID &&
(contains == null || msg.content.contains(contains)) &&
(channel == null || msg.channel == channel)
@ -653,6 +693,128 @@ object TestHookDriver {
.put("peer", peerID)
}
/**
* Exercises the real courier wire/store implementation on a physical debug build.
* This is intentionally local: the two-phone harness has no third identity to act as
* both recipient and an independent courier, so transport scenarios cannot observe the
* spray budget without weakening the assertion.
*/
private fun courierContract(context: Context): JSONObject {
val filesDir = File(context.cacheDir, "testhook/courier-contract-files")
filesDir.deleteRecursively()
filesDir.mkdirs()
val labContext = LabStorageContext(context, filesDir)
val cipher = LabCipher(0x5a)
val now = System.currentTimeMillis()
val recipientKey = ByteArray(32) { 7 }
val depositorKey = ByteArray(32) { 8 }
val firstCourier = ByteArray(32) { 11 }
val secondCourier = ByteArray(32) { 12 }
val thirdCourier = ByteArray(32) { 13 }
val fourthCourier = ByteArray(32) { 14 }
val tag = CourierEnvelope.recipientTag(recipientKey, CourierEnvelope.epochDay(now))
val store = CourierStore(labContext, cipher) { now }
try {
val firstEnvelope = CourierEnvelope(
recipientTag = tag,
expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(),
ciphertext = ByteArray(32) { (it + 1).toByte() },
copies = 4u,
prekeyID = 0x11223344u
)
require(store.deposit(firstEnvelope, depositorKey, CourierDepositTier.VERIFIED))
val wire = requireNotNull(firstEnvelope.encode())
val decoded = requireNotNull(CourierEnvelope.decode(wire))
val first = store.sprayCopiesFor(firstCourier).single()
val second = store.sprayCopiesFor(secondCourier).single()
val sameCourierEmpty = store.sprayCopiesFor(firstCourier).isEmpty()
val secondCommitted = store.commitSpray(second, secondCourier)
val firstCommitted = store.commitSpray(first, firstCourier)
val thirdCourierEmpty = store.sprayCopiesFor(thirdCourier).isEmpty()
val secondEnvelope = firstEnvelope.copy(ciphertext = ByteArray(32) { (it + 65).toByte() })
require(store.deposit(secondEnvelope, depositorKey, CourierDepositTier.VERIFIED))
val cancelledPreview = store.sprayCopiesFor(fourthCourier).single()
val cancelled = store.cancelSpray(cancelledPreview, fourthCourier)
val retry = store.sprayCopiesFor(fourthCourier).single()
val retryCommitted = store.commitSpray(retry, fourthCourier)
val reloaded = CourierStore(labContext, LabCipher(0x5a)) { now }
val persistedSprayHistory = reloaded.sprayCopiesFor(fourthCourier)
.none { it.ciphertext.contentEquals(secondEnvelope.ciphertext) }
val remainingCopies = reloaded.sprayCopiesFor(thirdCourier)
.firstOrNull { it.ciphertext.contentEquals(secondEnvelope.ciphertext) }
?.copies
?.toInt()
reloaded.wipe()
require(decoded.prekeyID == firstEnvelope.prekeyID)
require(decoded.encode()?.contentEquals(wire) == true)
require(first.prekeyID == firstEnvelope.prekeyID)
require(first.copies == 2u.toUByte())
require(second.copies == 1u.toUByte())
require(sameCourierEmpty)
require(secondCommitted && firstCommitted && thirdCourierEmpty)
require(cancelled && retry.copies == 2u.toUByte() && retryCommitted)
require(persistedSprayHistory && remainingCopies == 1)
return ok("courier_contract")
.put("wire_prekey_id_preserved", true)
.put("stored_prekey_id_preserved", true)
.put("first_reserved_copies", first.copies.toInt())
.put("second_reserved_copies", second.copies.toInt())
.put("same_courier_second_reservation_empty", sameCourierEmpty)
.put("reverse_order_commits", true)
.put("cancel_restored_eligibility", cancelled)
.put("persisted_spray_history", persistedSprayHistory)
.put("remaining_copies_after_restart", remainingCopies)
} finally {
store.wipe()
filesDir.deleteRecursively()
}
}
/** Persist the currently authenticated peer key exactly as the normal UI session observer does. */
private fun cachePeerIdentity(context: Context, peerID: String): JSONObject {
val info = mesh(context).getPeerInfo(peerID)
?: return err("cache_peer_identity", "peer is not known")
val noiseKey = info.noisePublicKey
?: return err("cache_peer_identity", "peer Noise key is unavailable")
val noiseKeyHex = noiseKey.toHex()
val identityManager = SecureIdentityStateManager(context)
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
identityManager.cacheNoiseFingerprint(noiseKeyHex, com.bitchat.android.services.ContactIdentityResolver.fingerprintHex(noiseKey))
info.nickname.takeIf { it.isNotBlank() }?.let { nickname ->
identityManager.cacheFingerprintNickname(
com.bitchat.android.services.ContactIdentityResolver.fingerprintHex(noiseKey),
nickname
)
}
return ok("cache_peer_identity").put("peer", peerID)
}
private fun syncRequest(peerID: String): JSONObject {
val manager = MeshServiceHolder.sharedGossipSyncManager
?: return err("sync_request", "gossip sync manager is unavailable")
manager.scheduleInitialSyncToPeer(peerID, 0)
return ok("sync_request").put("peer", peerID)
}
private class LabStorageContext(base: Context, private val labFilesDir: File) : ContextWrapper(base) {
override fun getApplicationContext(): Context = this
override fun getFilesDir(): File = labFilesDir
}
private class LabCipher(private val mask: Int) : ConversationStorageCipher {
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray =
plaintext.map { (it.toInt() xor mask).toByte() }.toByteArray()
override fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray =
encrypt(envelope, associatedData)
override fun destroyKey() = Unit
}
// MARK: - Transport / state
private fun setBle(enabled: Boolean): JSONObject {
@ -661,6 +823,14 @@ object TestHookDriver {
return ok("ble").put("enabled", enabled)
}
private fun setWifiAware(enabled: Boolean): JSONObject {
val previous = com.bitchat.android.wifiaware.WifiAwareController.enabled.value
DebugSettingsManager.getInstance().setWifiAwareEnabled(enabled)
return ok("wifi_aware")
.put("enabled", enabled)
.put("previous_enabled", previous)
}
private fun state(context: Context): JSONObject {
val mesh = mesh(context)
val peersJson = peerInfosJson(mesh, AppStateStore.peers.value)

View File

@ -1,374 +1,414 @@
Relay URL,Latitude,Longitude
auth.nostr1.com,40.7057,-74.0136
nostr-02.uid.ovh,50.9871,2.12554
relayone.soundhsa.com,39.0997,-94.5786
nostr.myshosholoza.co.za:443,52.3676,4.90414
nostr.christiansass.de,54.3213,10.1349
relay.vrtmrz.net:443,43.6532,-79.3832
relay.satsapp.me,50.4754,12.3683
relay.wellorder.net,45.5201,-122.99
relay.ditto.pub,43.6532,-79.3832
ynostr.yael.at,60.1699,24.9384
nostr.tac.lol,47.4748,-122.273
relay.edufeed.org,49.4521,11.0767
nostr.islandarea.net,35.4669,-97.6473
relay.openspecs.uid.ovh,50.9871,2.12554
inbox.scuba323.com,40.8218,-74.45
x.kojira.io,43.6532,-79.3832
relay2.veganostr.com,60.1699,24.9384
nostr-verified.wellorder.net,45.5201,-122.99
nostr.davenov.com,50.1109,8.68213
relay.dreamith.to,43.6532,-79.3832
relay.veganostr.com,60.1699,24.9384
bitcoiner.social,47.6743,-117.112
cache.trustr.ing,43.6548,-79.3885
nostr.data.haus,50.4754,12.3683
relay.binaryrobot.com,43.6532,-79.3832
articles.layer3.news,37.3387,-121.885
nostr.snowbla.de:443,50.4754,12.3683
denimroad.feeds.relay.tools,38.6327,-90.1961
relay.ohstr.com:443,43.6532,-79.3832
relay.nostrfy.org,35.6916,139.768
relay.klabo.world,47.674,-122.122
soloco.nl,43.6532,-79.3832
nexus.libernet.app,43.6532,-79.3832
nostr.hekster.org:443,37.3986,-121.964
relay.primal.net,43.6532,-79.3832
relay.lanacoin-eternity.com,40.8302,-74.1299
zencrown.relay2aeon.quest,52.3061,4.6907
relay.gulugulu.moe:443,43.6532,-79.3832
nostrelay.circum.space:443,52.2245,8.826
relay5.bitransfer.org,43.6532,-79.3832
chat-relay.zap-work.com,43.6532,-79.3832
nostr-relay.zimage.com,34.282,-118.439
relay.i9.eti.br,43.6532,-79.3832
relay.lacrypta.ar,43.6532,-79.3832
relay.satlantis.io,39.0438,-77.4874
nostr.bitcoiner.social:443,47.6743,-117.112
myvoiceourstory.org,37.3598,-121.981
relay.cloistr.xyz,42.337,-77.3178
nostr.liberty.fans,36.8767,-89.5879
relay.zone667.com,60.1699,24.9384
relay.getvia.xyz,60.1699,24.9384
schnorr.me,43.6532,-79.3832
nostr.debate.report,50.1109,8.68213
nostr-relay.corb.net:443,39.6478,-104.988
nip85.nosfabrica.com,39.0997,-94.5786
relay.solife.me,43.6532,-79.3832
relay.cypherflow.ai,48.8575,2.35138
relay.mostr.pub:443,43.6532,-79.3832
relay.tapestry.ninja,40.8054,-74.0241
nostr-01.yakihonne.com,1.32123,103.695
relay.s-w.art,43.6532,-79.3832
nostr.azzamo.net:443,52.2633,21.0283
relay.froth.zone,60.1699,24.9384
relay.kizuna-miki.com,35.694,139.754
relay.bowlafterbowl.com,32.9483,-96.7299
top.testrelay.top,43.6532,-79.3832
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
nostr.wecsats.io,43.6532,-79.3832
relay1.nostrchat.io,60.1699,24.9384
relay.gulugulu.moe,43.6532,-79.3832
aurum.saturnali.net,46.2044,6.14316
nostr.txlyre.website,60.1699,24.9384
nostras.app,38.7946,-106.535
bridge.tagomago.me,42.3601,-71.0589
cs-relay.nostrdev.com:443,50.4754,12.3683
21milionidinostr.duckdns.org,41.8945,12.6493
wisp.djorivaltech.com,43.6532,-79.3832
relay.manneken.brussels,49.4543,11.0746
nostr.oxtr.dev,50.4754,12.3683
nostr.n7ekb.net,47.4941,-122.294
nostr.unkn0wn.world,46.8499,9.53287
pocketrelay.live,40.8302,-74.1299
scores.brainstorm.world,39.0997,-94.5786
relay.guggero.org,46.5971,9.59652
relay.layer.systems,49.0291,8.35695
relay.wisp.talk,49.4543,11.0746
bendernostur.duckdns.org:8443,50.1109,8.68213
dev-relay.nostr.space,43.6532,-79.3832
relay.mwaters.net,50.9871,2.12554
strfry.shock.network,39.0438,-77.4874
n.musicroadmap.com,35.694,139.754
cs-relay.nostrdev.com,50.4754,12.3683
talon.quest,43.6532,-79.3832
vidar.nestoris.online,59.3911,17.8752
nostr.red5d.dev,43.6532,-79.3832
nostr.rtvslawenia.com:443,49.4543,11.0746
nostr.2b9t.xyz,34.0549,-118.243
relay.angor.io,48.1046,11.6002
conduitl2.fly.dev,38.7946,-106.535
relay2.angor.io,48.1046,11.6002
relay.getsafebox.app,43.6532,-79.3832
nostrcity-club.fly.dev:443,38.7946,-106.535
vault.iris.to,43.6532,-79.3832
nostr-01.uid.ovh,50.9871,2.12554
nostr.sathoarder.com:443,48.5734,7.75211
reraw.pbla2fish.cc,43.6532,-79.3832
nostr.pbfs.io,50.4754,12.3683
relay.mypathtofire.de,42.8864,-78.8784
relay.nostrian-conquest.com,41.223,-111.974
relay.nostriot.com,41.5695,-83.9786
nostr.robosats.org,64.1476,-21.9392
relay.getsafebox.app:443,43.6532,-79.3832
dev.relay.stream,43.6532,-79.3832
relay.btcforplebs.com,43.6532,-79.3832
schnorr.me:443,43.6532,-79.3832
nostrue.com,40.8054,-74.0241
relay.aarpia.com,37.3986,-121.964
relay.mccormick.cx:443,52.3563,4.95714
relay.fiatdenier.com,43.7787,-79.5393
freelay.sovbit.host,60.1699,24.9384
nostr.computingcache.com,45.5341,-122.956
relay.piazza.today,48.122,11.589
relay.nostr.net,43.6532,-79.3832
nostr-relay.xbytez.io,50.6924,3.20113
strfry.shock.network:443,39.0438,-77.4874
nostr.girino.org,43.6532,-79.3832
nostr-pub.wellorder.net,45.5201,-122.99
nostr.hifish.org,47.4009,8.57814
relay.ezernode.net,52.6921,6.19372
relay.artiostr.ch,43.6532,-79.3832
relay01.lnfi.network,35.6764,139.65
relay.edufeed.org:443,49.4521,11.0767
relay.loveisbitcoin.com,43.6532,-79.3832
nostr.purpura.cloud,43.6532,-79.3832
relay.atsocy.com,43.6532,-79.3832
relay.conduit.market,38.7946,-106.535
relay.pocketnostr.com,40.8054,-74.0241
relay.cosmicbolt.net,37.3986,-121.964
relay.bornheimer.app,50.1109,8.68213
nostrcity-club.fly.dev,38.7946,-106.535
relay.nostr.dev.br,48.1046,11.6002
relay.nuts.cash,52.3676,4.90414
nostr.rtvslawenia.com,49.4543,11.0746
relay.tdw.lol,41.8781,-87.6298
relay.ohstr.com,43.6532,-79.3832
relay.chatbett.de,40.7128,-74.006
nostr2.girino.org:443,43.6532,-79.3832
relay.dwadziesciajeden.pl,52.2297,21.0122
nostr.spicyz.io,43.6532,-79.3832
relay2.fiatdenier.com,50.1013,8.62643
relay.binaryrobot.com:443,43.6532,-79.3832
nostr.thebiglake.org,32.71,-96.6745
relay.jmoose.rocks,32.8009,-96.8195
nostr.easycryptosend.it,43.6532,-79.3832
relay.aidatanorge.no,43.6532,-79.3832
memlay.v0l.io,53.3498,-6.26031
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
nostr.notribe.net,40.8302,-74.1299
relay.plebeian.market,50.1109,8.68213
nostr.islandarea.net:443,35.4669,-97.6473
ensemble-appear-locking-forming.trycloudflare.com,43.6532,-79.3832
nostr.bitcoiner.social,47.6743,-117.112
relay.samt.st,40.8302,-74.1299
nostr-relay.corb.net,39.6478,-104.988
relay.tdw.gg,34.0549,-118.243
relay.olas.app,60.1699,24.9384
relay.yoinekodo.jp,43.6532,-79.3832
relay.mccormick.cx,52.3563,4.95714
syb.lol,34.0549,-118.243
0x-nostr-relay.fly.dev,38.7946,-106.535
vm-1734.lnvps.cloud,53.3498,-6.26031
relay.sigit.io:443,50.4754,12.3683
node.kommonzenze.de,49.4521,11.0767
staging.yabu.me,35.6092,139.73
strfry.bonsai.com,39.0438,-77.4874
relay.44billion.net,43.6532,-79.3832
nostr.mom,50.4754,12.3683
relay.notoshi.win,13.3622,100.983
nostr.ltd,19.076,72.8777
relay.fountain.fm:443,43.6532,-79.3832
bruh.samt.st,43.6532,-79.3832
relay.mitosystem.net,-23.5558,-46.6396
nostr.myshosholoza.co.za,52.3676,4.90414
nostr.fullstackcash.net,45.5201,-122.99
nostr.na.social,43.6532,-79.3832
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
nostr.88mph.life,52.1941,-2.21905
relay.qstr.app,50.1109,8.68213
relay.lightning.pub,39.0438,-77.4874
relay.orangesync.tech,40.7128,-74.006
nostr-02.yakihonne.com,1.32123,103.695
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
relay.bnos.space,43.6532,-79.3832
temp.iris.to,43.6532,-79.3832
nostr.relay.hedwig.sh,60.1699,24.9384
relay.nostr.space,43.6532,-79.3832
nostr.nestoris.online,55.568,37.5853
yabu.me,35.6092,139.73
nostr.wild-vibes.ts.net,48.8566,2.35222
bcast.seutoba.com.br,43.6532,-79.3832
nostr-dev.wellorder.net,45.5201,-122.99
portal-relay.pareto.space,49.4521,11.0767
hasky.chat,40.7862,-74.0743
relay.lanavault.space,60.1699,24.9384
offchain.pub,39.1585,-94.5728
nostr-relay.cbrx.io,43.6532,-79.3832
relay.nostrmap.net:443,60.1699,24.9384
relay.wavlake.com,41.2619,-95.8608
bitchat.nostr1.com,40.7057,-74.0136
relay.mappingbitcoin.com,43.6532,-79.3832
relay.ru.ac.th,13.7607,100.627
relay.nostr.com,50.1109,8.68213
relay.noeudlibre.fr,50.6924,3.20113
relay.satmaxt.xyz:443,43.6532,-79.3832
relay-dev.gulugulu.moe,43.6532,-79.3832
no.str.cr:443,8.96171,-83.5246
relay.nostrmap.net,60.1699,24.9384
relay.littlebitstudios.com,43.6532,-79.3832
relay-na1.metanomalist.com,43.6532,-79.3832
bucket.coracle.social,37.7775,-122.397
relay.nostr.bond,40.1885,29.061
relay.nostx.io,43.6532,-79.3832
relay.nostrfeed.com,60.1699,24.9384
relay.wavlake.com:443,41.2619,-95.8608
social.amanah.eblessing.co,48.1046,11.6002
relay-dev.gulugulu.moe:443,43.6532,-79.3832
relay.kilombino.com,43.6532,-79.3832
relay.lightning.pub:443,39.0438,-77.4874
relay.bitmacro.cloud,43.6532,-79.3832
relay.solomonstr.com,43.6532,-79.3832
relay.agentry.com,42.8864,-78.8784
relay.decentralia.fr,48.122,11.589
articles.layer3.news:443,37.3387,-121.885
nostr.tagomago.me,42.3601,-71.0589
relay.illuminodes.com,43.6532,-79.3832
nostr.nodesmap.com,59.3327,18.0656
relay.scuba323.com,40.8218,-74.45
nostr.yutakobayashi.com,43.6532,-79.3832
relay.pyramid.li,47.4093,8.46503
testr.nymble.world,40.8054,-74.0241
relay.mostr.pub,43.6532,-79.3832
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
relay.artio.inf.unibe.ch,46.9501,7.43678
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
relay.pprgb.app,52.52,13.405
fanfares.nostr1.com:443,40.7057,-74.0136
relay.lanacoin-eternity.com:443,40.8302,-74.1299
support.flotilla.social,32.9483,-96.7299
relay.agora.social,50.7383,15.0648
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
offchain.pub:443,39.1585,-94.5728
nostr.snowbla.de,50.4754,12.3683
nostr.tabordalab.com,60.1699,24.9384
relay.nostrhub.fr,48.1045,11.6004
relay.chorus.community,50.7529,6.84585
nostr.linky.fit,50.1109,8.68213
relay.islandbitcoin.com,20.3898,78.0903
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
relay.orly.dev,32.7767,-96.797
relay.chorus.community:443,50.7529,6.84585
relay.sigit.io,50.4754,12.3683
relayone.geektank.ai,39.0997,-94.5786
nostr.chaima.info,51.5072,-0.127586
borgar.lol,52.3676,4.90414
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
relay.plebeian.market:443,50.1109,8.68213
relay.hivetalk.org,40.8302,-74.1299
relay.favillakey.io,-27.4705,153.026
relay.bullishbounty.com,43.6532,-79.3832
no.str.cr,8.96171,-83.5246
relay.dyne.org,49.0291,8.35705
adre.su,59.9311,30.3609
relay.mitchelltribe.com,39.0438,-77.4874
relay.duck1123.com,43.6532,-79.3832
relay.staging.plebeian.market,51.5072,-0.127586
nostr.twinkle.lol,51.902,7.6657
nostr.mom:443,50.4754,12.3683
relay.jmoose.rocks:443,32.8009,-96.8195
relay.satsmarkt.club,52.6907,4.8181
relay.nostrcheck.me,43.6532,-79.3832
cyberspace.nostr1.com,40.7057,-74.0136
relay.veganostr.com:443,60.1699,24.9384
relay.nearhood.co.uk,51.5134,-0.0890675
nostr.stakey.net,52.3676,4.90414
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
relay.nostr-check.me,43.6532,-79.3832
bcast.girino.org,43.6532,-79.3832
nostr.wecsats.io:443,43.6532,-79.3832
chat-relay.zap-work.com:443,43.6532,-79.3832
nostr.azzamo.net,52.2633,21.0283
relayone.soundhsa.com:443,39.0997,-94.5786
pymd.kamp.site,38.9072,-77.0369
relay.sharegap.net,43.6532,-79.3832
relay.satmaxt.xyz,43.6532,-79.3832
relay.nmail.li,50.9871,2.12554
fanfares.nostr1.com,40.7057,-74.0136
relay-testnet.k8s.layer3.news,37.3387,-121.885
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
strfry.bonsai.com:443,39.0438,-77.4874
public.crostr.com:443,43.6532,-79.3832
nostrride.io,37.3986,-121.964
relay.routstr.com,59.4016,17.9455
public.crostr.com,43.6532,-79.3832
relayone.geektank.ai:443,39.0997,-94.5786
relay.kaleidoswap.com,50.8476,4.35717
relay.stg.formstr.app,54.6872,25.2797
relay.paulstephenborile.com,49.4543,11.0746
nostr.data.haus:443,50.4754,12.3683
nostriches.club,43.6532,-79.3832
relayrs.notoshi.win,43.6532,-79.3832
relay.bitos.space,43.6532,-79.3832
relay.shadowbip.com,54.352,18.6466
relay.bullishbounty.com:443,43.6532,-79.3832
nostr.planix.org,43.6532,-79.3832
relay02.lnfi.network,35.6764,139.65
relay.liberbitworld.org,43.6532,-79.3832
nostr.sathoarder.com,48.5734,7.75211
nostr.whitenode45.ddns.net,40.55,-74.4758
relay.directsponsor.net,42.8864,-78.8784
nostr.hekster.org,37.3986,-121.964
relay-arg.zombi.cloudrodion.com,1.35208,103.82
relay.degmods.com,50.4754,12.3683
nostr.spaceshell.xyz,43.6532,-79.3832
nostr.chaima.info:443,51.5072,-0.127586
nostr.pbfs.io:443,50.4754,12.3683
dm-test-strfry-generic.samt.st,43.6532,-79.3832
spamspamspamspam.rest,43.6532,-79.3832
nr.yay.so,46.2126,6.1154
relay.minibolt.info,43.6532,-79.3832
relay.mitchelltribe.com:443,39.0438,-77.4874
nostr.janx.com,43.6532,-79.3832
nostrelay.circum.space,52.2245,8.826
relay.earthly.city,34.1749,-118.54
budabit.nostr1.com,40.7057,-74.0136
relay.underorion.se,50.1109,8.68213
relay.paulstephenborile.com:443,49.4543,11.0746
nostr-pr02.redscrypt.org,52.3676,4.90414
relay.novospes.com,43.6532,-79.3832
relay.keykeeper.world,40.7824,-74.0711
relay2.orangesync.tech,40.7128,-74.006
nostr-01.yakihonne.com:443,1.32123,103.695
relay.trotters.cc:443,43.6532,-79.3832
treuzkas.branruz.com,48.8575,2.35138
nostr.21crypto.ch,47.5356,8.73209
relay1.privkey.io,25.7617,-80.1918
buzz.cashu.space,50.1109,8.68213
chorus.mikedilger.com:444,-36.8906,174.794
wheat.happytavern.co,43.6532,-79.3832
nostr.stakey.net:443,52.3676,4.90414
prl.plus,55.7558,37.6173
relay.vrtmrz.net,43.6532,-79.3832
relay.pocketstr.com,40.8302,-74.1299
articles.layer3.news,37.3387,-121.885
relay.nostr.net,43.6532,-79.3832
nostr.easycryptosend.it,43.6532,-79.3832
strfry.bonsai.com,39.0438,-77.4874
relay.olas.app:443,60.1699,24.9384
relay.sharegap.net,43.6532,-79.3832
relay.ditto.pub,43.6532,-79.3832
nostrride.io,37.3986,-121.964
relay.favillakey.io,-27.4705,153.026
nostr-relay.xbytez.io,50.6924,3.20113
relay.nostrmap.net:443,60.1699,24.9384
mostro-p2p.tech,50.1109,8.68213
relay.aidatanorge.no,43.6532,-79.3832
nostr.openhoofd.nl,51.5717,3.70417
relay.chatbett.de,40.7128,-74.006
relay.ohstr.com:443,43.6532,-79.3832
nostr.overmind.lol:443,43.6532,-79.3832
nostr.red5d.dev,43.6532,-79.3832
relay.snort.social,53.3498,-6.26031
relay.flashapp.me,43.6548,-79.3885
nostr.emanuelemiani.it,45.778,8.79414
relay.wisp.talk:443,49.4543,11.0746
vault.iris.to:443,43.6532,-79.3832
relay.staging.plebeian.market:443,51.5072,-0.127586
offchain.bostr.online,43.6532,-79.3832
maxq.descendant.io,43.6532,-79.3832
relay.plebchain.club,43.6532,-79.3832
nostr.2b9t.xyz:443,34.0549,-118.243
relay.ditto.pub:443,43.6532,-79.3832
dev-relay.nostreon.com,60.1699,24.9384
nostr.tbxnetworx.de,47.8226,10.0064
nostr.4rs.nl,49.0291,8.35696
nostr2.girino.org,43.6532,-79.3832
relayrs.notoshi.win:443,43.6532,-79.3832
relay.ltgnet.work:8443,51.0511,-114.075
relay.fountain.fm,43.6532,-79.3832
relay.wasabiwallet.io,43.6532,-79.3832
relay.directsponsor.net,42.8864,-78.8784
relay.bullishbounty.com:443,43.6532,-79.3832
relay.scuba323.com,40.8218,-74.45
nostr.spicyz.io,43.6532,-79.3832
nostr.chaima.info,50.1109,8.68213
nostr.purpura.cloud,43.6532,-79.3832
relay.nostrops.com,32.71,-96.6745
relay.veganostr.com:443,60.1699,24.9384
relay.ohstr.com,43.6532,-79.3832
soloco.nl,43.6532,-79.3832
nostr.plantroon.com:443,50.1013,8.62643
relay.chorus.community:443,48.5333,10.7
relay.fiatdenier.com,43.7787,-79.5393
nostr.whitenode45.ddns.net,40.55,-74.4758
relay.cosmicbolt.net:443,37.3986,-121.964
relay.lanacoin-eternity.com:443,40.8302,-74.1299
relay.cosmicbolt.net,37.3986,-121.964
fanfares.nostr1.com:443,40.7057,-74.0136
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
relay.keykeeper.world,40.7824,-74.0711
relay-testnet.k8s.layer3.news,37.3387,-121.885
dm-test-strfry-generic.samt.st,43.6532,-79.3832
relay.laantungir.net,-19.4692,-42.5315
nostr.sathoarder.com,48.5734,7.75211
relay.mccormick.cx:443,52.3563,4.95714
relay.solife.me,43.6532,-79.3832
relay.fountain.fm:443,43.6532,-79.3832
nostrcity-club.fly.dev,38.7946,-106.535
nostr.mas-family.eu,60.3478,15.7505
no.str.cr,8.96171,-83.5246
relay.wavefunc.live,41.8781,-87.6298
relay.cyberguy.fyi,52.6907,4.8181
adre.su,59.9311,30.3609
relay.erybody.com,41.4513,-81.7021
relay.mrmave.work,43.6532,-79.3832
relay.decentralia.fr,48.122,11.589
relay.mccormick.cx,52.3563,4.95714
nostr.dlcdevkit.com:443,40.0992,-83.1141
nostr-02.yakihonne.com,1.32123,103.695
nostr.21crypto.ch,47.5356,8.73209
slick.mjex.me,39.0418,-77.4744
nostr.spicyz.io:443,43.6532,-79.3832
relay-dev.gulugulu.moe,43.6532,-79.3832
relay.nostx.io,43.6532,-79.3832
relay.wisp.talk,49.4543,11.0746
relay.plebeian.market:443,50.1109,8.68213
relay.mostr.pub:443,43.6532,-79.3832
relay.aarpia.com,37.3986,-121.964
chat-relay.zap-work.com,43.6532,-79.3832
chat-relay.zap-work.com:443,43.6532,-79.3832
relay.mitchelltribe.com:443,39.0438,-77.4874
relay.paulstephenborile.com,49.4543,11.0746
relay.trotters.cc,43.6532,-79.3832
nexus.libernet.app:443,43.6532,-79.3832
syb.lol,34.0549,-118.243
relay.cypherflow.ai,48.8575,2.35138
relay.despera.space,52.1782,20.9926
relay.loveisbitcoin.com,43.6532,-79.3832
relay.rushmi0.win,43.6532,-79.3832
relay.degmods.com,50.4754,12.3683
relay.satlantis.io,39.0438,-77.4874
relay.underorion.se,50.1109,8.68213
nostr.mom:443,50.4754,12.3683
nostr-verified.wellorder.net,45.5201,-122.99
relay.nostreon.com,60.1699,24.9384
nostr.christiansass.de,54.3213,10.1349
rilo.nostria.app:443,43.6532,-79.3832
relay.trotters.cc:443,43.6532,-79.3832
kotukonostr.onrender.com,37.7775,-122.397
relay.nmail.li,50.9871,2.12554
relay.artio.inf.unibe.ch,46.9501,7.43678
nos.lol,50.4754,12.3683
nostr.snowbla.de:443,50.4754,12.3683
relay.openresist.com,43.6532,-79.3832
ribo.us.nostria.app,43.6532,-79.3832
bitchat.nostr1.com,40.7057,-74.0136
vm-1734.lnvps.cloud,53.3498,-6.26031
nostr-relay.xbytez.io:443,50.6924,3.20113
nostr.sathoarder.com:443,48.5734,7.75211
bcast.girino.org,43.6532,-79.3832
ribo.us.nostria.app:443,43.6532,-79.3832
strfry.shock.network,39.0438,-77.4874
relay.staging.plebeian.market:443,51.5072,-0.127586
offchain.pub:443,39.1585,-94.5728
relayone.soundhsa.com,39.0997,-94.5786
relay.libernet.app,43.6532,-79.3832
relay.earthly.city,34.1749,-118.54
relayrs.notoshi.win:443,43.6532,-79.3832
nostr.vulpem.com,49.4543,11.0746
relay.getsafebox.app:443,43.6532,-79.3832
relay.paulstephenborile.com:443,49.4543,11.0746
nostr.unkn0wn.world,46.8499,9.53287
relay.binaryrobot.com,43.6532,-79.3832
relay.wellorder.net,45.5201,-122.99
relayone.geektank.ai:443,39.0997,-94.5786
relay.wasabiwallet.io,43.6532,-79.3832
top.testrelay.top,43.6532,-79.3832
nostr.2b9t.xyz,34.0549,-118.243
relay.nostr.com,50.1109,8.68213
nostr.azzamo.net:443,52.2633,21.0283
nostr.dlcdevkit.com,40.0992,-83.1141
nostr.robosats.org,64.1476,-21.9392
nostr.iskarion.ddns.net,43.3076,-2.95421
relay2.angor.io,48.1046,11.6002
relay.mmwaves.de,48.8575,2.35138
relay.agorist.space:443,52.3734,4.89406
relay.dreamith.to,43.6532,-79.3832
nostr-02.uid.ovh,50.9871,2.12554
nostr.thebiglake.org,32.71,-96.6745
nostr-relay.corb.net,39.6478,-104.988
relay.dyne.org,49.0291,8.35705
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
relay.angor.io,48.1046,11.6002
nostrelay.circum.space,52.2245,8.826
nostr-01.uid.ovh,50.9871,2.12554
maxq.descendant.io,43.6532,-79.3832
offchain.bostr.online,43.6532,-79.3832
relay.binaryrobot.com:443,43.6532,-79.3832
nostr.planix.org,43.6532,-79.3832
tribune-panel-growing-noon.trycloudflare.com,43.6532,-79.3832
prl.plus,55.7628,37.5983
relay.mwaters.net,50.9871,2.12554
dev.relay.stream,43.6532,-79.3832
relay.bornheimer.app,50.1109,8.68213
inbox.scuba323.com,40.8218,-74.45
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
relay.nuts.cash,52.3676,4.90414
bruh.samt.st,43.6532,-79.3832
nostr.carroarmato0.be:443,50.914,3.21378
relay.plebeian.market,50.1109,8.68213
schnorr.me:443,43.6532,-79.3832
nostr.thalheim.io:443,60.1699,24.9384
nostr.88mph.life,52.1941,-2.21905
cache.trustr.ing,43.6548,-79.3885
ec2.f7z.io,60.1699,24.9384
relay.btcforplebs.com,43.6532,-79.3832
nostr.liberty.fans,36.8767,-89.5879
syb.lol:443,34.0549,-118.243
nostr.islandarea.net:443,35.4669,-97.6473
nostr.mom,50.4754,12.3683
nostr.data.haus:443,50.4754,12.3683
strfry.bonsai.com:443,39.0438,-77.4874
nostr.highway15.net,28.0445,-82.6699
relay.wavlake.com:443,41.2619,-95.8608
relay.nostriot.com:443,41.5695,-83.9786
relay.gulugulu.moe:443,43.6532,-79.3832
rele.speyhard.fi,50.1109,8.68213
nostr.hoppe-relay.it.com,42.8864,-78.8784
nostr2.girino.org:443,43.6532,-79.3832
nostr.oxtr.dev,50.4754,12.3683
nostr.bitcoiner.social:443,40.7608,-111.891
nostrrelay.taylorperron.com,45.5029,-73.5723
nostr-01.yakihonne.com:443,1.32123,103.695
nostr.tagomago.me,42.3601,-71.0589
relay.hivetalk.org,40.8302,-74.1299
relay.sigit.io:443,50.4754,12.3683
relay.openresist.com:443,43.6532,-79.3832
cs-relay.nostrdev.com:443,50.4754,12.3683
no.str.cr:443,8.96171,-83.5246
relay.nearhood.co.uk,51.5134,-0.0890675
relay.lanavault.space,60.1699,24.9384
relay.guggero.org,46.5971,9.59652
nexus.libernet.app,43.6532,-79.3832
relay.nostriot.com,41.5695,-83.9786
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
x.kojira.io:443,43.6532,-79.3832
nostr.4rs.nl,49.0291,8.35696
relayone.geektank.ai,39.0997,-94.5786
nostr.debate.report,50.1109,8.68213
relay.gulugulu.moe,43.6532,-79.3832
public.crostr.com,43.6532,-79.3832
offchain.pub,39.1585,-94.5728
relay.tdw.lol,41.8781,-87.6298
relay5.bitransfer.org,43.6532,-79.3832
relay.manneken.brussels,49.4543,11.0746
chorus.pjv.me,45.5201,-122.99
relay.fckstate.net,59.3293,18.0686
treuzkas.branruz.com,48.8575,2.35138
nostr-relay.cbrx.io,43.6532,-79.3832
nostr-relay.nextblockvending.com,47.2343,-119.853
nostr.fullstackcash.net,45.5201,-122.99
relay.primal.net,43.6532,-79.3832
relay.mappingbitcoin.com,43.6532,-79.3832
nostr.mikoshi.de,50.1109,8.68213
nostr.stakey.net,52.3676,4.90414
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
relay.wavlake.com,41.2619,-95.8608
relay.ordoplay.com,50.1109,8.68213
relay.klabo.world,47.674,-122.122
buzz.cashu.space,50.1109,8.68213
relay.nostrhub.fr,48.1045,11.6004
portal-relay.pareto.space,49.0291,8.35696
dev-relay.nostreon.com,60.1699,24.9384
relay.islandbitcoin.com,12.8498,77.6545
testr.nymble.world,40.8054,-74.0241
relayrs.notoshi.win,43.6532,-79.3832
nostr-relay.corb.net:443,39.6478,-104.988
cdn.satellite.earth,40.8302,-74.1299
rilo.nostria.app,43.6532,-79.3832
nostr.pbfs.io,50.4754,12.3683
nostr2.girino.org,43.6532,-79.3832
0x-nostr-relay.fly.dev,38.7946,-106.535
nostr.girino.org,43.6532,-79.3832
relay.layer.systems,49.0291,8.35695
relay.olas.app,60.1699,24.9384
ribo.eu.nostria.app,43.6532,-79.3832
buzz.ac2n-share.kozow.com,45.764,4.83566
auth.nostr1.com,40.7057,-74.0136
relay.bitmacro.cloud,43.6532,-79.3832
relay.qstr.app,50.1109,8.68213
relay.ru.ac.th,13.7607,100.627
ribo.nostria.app:443,43.6532,-79.3832
relay.44billion.net,43.6532,-79.3832
cs-relay.nostrdev.com,50.4754,12.3683
relay.nostrian-conquest.com:443,41.223,-111.974
relay.bullishbounty.com,43.6532,-79.3832
relay.edufeed.org,49.4521,11.0767
porchlight.social,43.6532,-79.3832
nostr.bitcoiner.social,40.7608,-111.891
testnet-relay.samt.st,40.8302,-74.1299
relay.vrtmrz.net,43.6532,-79.3832
nostr.wecsats.io:443,43.6532,-79.3832
relay.satmaxt.xyz:443,43.6532,-79.3832
nostr.snowbla.de,50.4754,12.3683
nostr.rtvslawenia.com:443,49.4543,11.0746
relay.flashapp.me,43.6548,-79.3885
purplerelay.com:443,43.6532,-79.3832
relay.staging.plebeian.market,51.5072,-0.127586
relay.libernet.app:443,43.6532,-79.3832
relay.0xchat.com,43.6532,-79.3832
myvoiceourstory.org,37.3598,-121.981
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
relay.vrtmrz.net:443,43.6532,-79.3832
relay.zone667.com,60.1699,24.9384
nostr.nodesmap.com,59.3327,18.0656
relay.agorist.space,52.3734,4.89406
relayone.soundhsa.com:443,39.0997,-94.5786
relay.satmaxt.xyz,43.6532,-79.3832
relay.zapanimations.com,40.8054,-74.0241
relay.sovereignresonance.org,48.8575,2.35138
nostr.relay.hedwig.sh,60.1699,24.9384
relay-dev.gulugulu.moe:443,43.6532,-79.3832
relay.arx-ccn.com,50.4754,12.3683
nip85.nosfabrica.com,39.0997,-94.5786
nrs-01.darkcloudarcade.com,39.0997,-94.5786
spamspamspamspam.rest,43.6532,-79.3832
nostr.davenov.com,50.1109,8.68213
relay.littlebitstudios.com,43.6532,-79.3832
purplerelay.com,43.6532,-79.3832
nos.lol:443,50.4754,12.3683
relay2.veganostr.com,60.1699,24.9384
relay-rpi.edufeed.org,49.4521,11.0767
relay.lacrypta.ar,43.6532,-79.3832
relay.tdw.gg,34.0549,-118.243
relay.getvia.xyz,60.1699,24.9384
relay.lanacoin-eternity.com,40.8302,-74.1299
conduitl2.fly.dev,38.7946,-106.535
nostr.rtvslawenia.com,49.4543,11.0746
relay.wavefunc.live:443,41.8781,-87.6298
nostr.carroarmato0.be,50.914,3.21378
nostr.pbfs.io:443,50.4754,12.3683
ribo.nostria.app,43.6532,-79.3832
nostr.sovereignservices.xyz,43.6532,-79.3832
relay.mostr.pub,43.6532,-79.3832
schnorr.me,43.6532,-79.3832
x.kojira.io,43.6532,-79.3832
public.obelisk.ar,43.6532,-79.3832
relay.angor.io:443,48.1046,11.6002
nostr.oxtr.dev:443,50.4754,12.3683
nostr.ltd,19.076,72.8777
nostr.tac.lol:443,47.4748,-122.273
nostr.twinkle.lol,51.902,7.6657
relay.s-w.art,43.6532,-79.3832
relay.openfarmtools.org,60.1699,24.9384
nostr.hekster.org,37.3986,-121.964
strfry.apps3.slidestr.net,40.4167,-3.70329
relay.mypathtofire.de,42.8864,-78.8784
nostr.wecsats.io,43.6532,-79.3832
nostr.computingcache.com,34.0356,-118.442
nostr.janx.com,43.6532,-79.3832
bread.nostrsms.com,40.8218,-74.45
relay.chorus.community,48.5333,10.7
testnet-relay.samt.st:443,40.8302,-74.1299
relay.kaleidoswap.com,50.8476,4.35717
yabu.me,35.6092,139.73
nostr-dev.wellorder.net,45.5201,-122.99
relay.layer.systems:443,49.0291,8.35695
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
nostr-relay.zimage.com,34.282,-118.439
relay.fundstr.me,42.3601,-71.0589
nostr.stakey.net:443,52.3676,4.90414
relay.satsmarkt.club,52.6907,4.8181
relay.satsapp.me,50.4754,12.3683
bendernostur.duckdns.org:8443,50.1109,8.68213
relay.illuminodes.com,43.6532,-79.3832
nostr.chaima.info:443,50.1109,8.68213
nostr.hekster.org:443,37.3986,-121.964
nostr.yutakobayashi.com,43.6532,-79.3832
freelay.sovbit.host,60.1699,24.9384
insta-relay.apps3.slidestr.net,40.4167,-3.70329
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
relay.internationalright-wing.org,-22.4692,-48.9875
relay.mmwaves.de:443,48.8575,2.35138
public.crostr.com:443,43.6532,-79.3832
relay.nostrian-conquest.com,41.223,-111.974
relay-rpi.edufeed.org:443,49.4521,11.0767
relay2.angor.io:443,48.1046,11.6002
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
bunk-test.feeds.relay.tools,38.6327,-90.1961
nostr-pr02.redscrypt.org,52.3676,4.90414
nostr.tac.lol,47.4748,-122.273
nostr.overpay.com,29.7449,-95.5343
relay.novospes.com,43.6532,-79.3832
nostrcity-club.fly.dev:443,38.7946,-106.535
relay01.lnfi.network,35.6764,139.65
relay.lightning.pub:443,39.0438,-77.4874
relay.pyramid.li,47.4093,8.46503
relay.nostu.be,40.4167,-3.70329
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
relay.endfiat.money:443,59.3327,18.0656
nostr.spaceshell.xyz,43.6532,-79.3832
21milionidinostr.duckdns.org,45.5192,9.08625
articles.layer3.news:443,37.3387,-121.885
wheat.happytavern.co,43.6532,-79.3832
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
nostr.na.social,43.6532,-79.3832
social.amanah.eblessing.co,48.1046,11.6002
reraw.pbla2fish.cc,43.6532,-79.3832
nostr.girino.org:443,43.6532,-79.3832
staging.yabu.me,35.6092,139.73
nostr.islandarea.net,35.4669,-97.6473
relay.endfiat.money,59.3327,18.0656
relay.mitchelltribe.com,39.0438,-77.4874
nostr.myshosholoza.co.za,52.3676,4.90414
relay.staging.commonshub.brussels,49.4543,11.0746
bridge.tagomago.me,42.3601,-71.0589
relay.agentry.com,42.8864,-78.8784
testnet.samt.st,43.6532,-79.3832
nostr.wild-vibes.ts.net,48.8566,2.35222
relay.getsafebox.app,43.6532,-79.3832
relay.notoshi.win,13.3396,100.93
bitcoinostr.duckdns.org,41.1976,1.11167
relay.sigit.io,50.4754,12.3683
bucket.coracle.social,37.7775,-122.397
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
vault.iris.to:443,43.6532,-79.3832
relay.wisp.talk:443,49.4543,11.0746
relay.mostro.network,40.8302,-74.1299
nostr.l484.com,30.2672,-97.7431
relay.froth.zone,60.1699,24.9384
relay.beamhop.com,43.6532,-79.3832
relay.sincensura.org,43.6532,-79.3832
relay.ditto.pub:443,43.6532,-79.3832
nostr.azzamo.net,52.2633,21.0283
relay.edufeed.org:443,49.4521,11.0767
ribo.eu.nostria.app:443,43.6532,-79.3832
vault.iris.to,43.6532,-79.3832
nostr.plantroon.com,50.1013,8.62643
fanfares.nostr1.com,40.7057,-74.0136
budabit.nostr1.com,40.7057,-74.0136
relay.damus.io,43.6532,-79.3832
strfry.shock.network:443,39.0438,-77.4874
bitcoiner.social,40.7608,-111.891
basspistol.org,49.0291,8.35696
nostr.thalheim.io,60.1699,24.9384
relay.conduit.market,38.7946,-106.535
relay.yoinekodo.jp,43.6532,-79.3832
nostrbtc.com,43.6532,-79.3832
relay.lightning.pub,39.0438,-77.4874
nostr-01.yakihonne.com,1.32123,103.695
nostr-pub.wellorder.net,45.5201,-122.99
strfry.ymir.cloud,43.6532,-79.3832
relay.minibolt.info,43.6532,-79.3832
nostr.data.haus,50.4754,12.3683
relay.otrta.me,50.1109,8.68213
relay.samt.st,40.8302,-74.1299
relay.nostr.blockhenge.com,39.0438,-77.4874
relay.minibolt.info:443,43.6532,-79.3832
nostr.2b9t.xyz:443,34.0549,-118.243
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay.veganostr.com,60.1699,24.9384
relay.fountain.fm,43.6532,-79.3832
node.kommonzenze.de,49.4521,11.0767
relay-arg.zombi.cloudrodion.com,1.35208,103.82
nostr.myshosholoza.co.za:443,52.3676,4.90414
temp.iris.to,43.6532,-79.3832
relay.nostrmap.net,60.1699,24.9384
relay.orly.dev,32.7767,-96.797
nostrelay.circum.space:443,52.2245,8.826
nostr.notribe.net,40.8302,-74.1299

1 Relay URL Latitude Longitude
auth.nostr1.com 40.7057 -74.0136
nostr-02.uid.ovh 50.9871 2.12554
relayone.soundhsa.com 39.0997 -94.5786
nostr.myshosholoza.co.za:443 52.3676 4.90414
nostr.christiansass.de 54.3213 10.1349
relay.vrtmrz.net:443 43.6532 -79.3832
relay.satsapp.me 50.4754 12.3683
relay.wellorder.net 45.5201 -122.99
relay.ditto.pub 43.6532 -79.3832
ynostr.yael.at 60.1699 24.9384
nostr.tac.lol 47.4748 -122.273
relay.edufeed.org 49.4521 11.0767
nostr.islandarea.net 35.4669 -97.6473
relay.openspecs.uid.ovh 50.9871 2.12554
inbox.scuba323.com 40.8218 -74.45
x.kojira.io 43.6532 -79.3832
relay2.veganostr.com 60.1699 24.9384
nostr-verified.wellorder.net 45.5201 -122.99
nostr.davenov.com 50.1109 8.68213
relay.dreamith.to 43.6532 -79.3832
relay.veganostr.com 60.1699 24.9384
bitcoiner.social 47.6743 -117.112
cache.trustr.ing 43.6548 -79.3885
nostr.data.haus 50.4754 12.3683
relay.binaryrobot.com 43.6532 -79.3832
articles.layer3.news 37.3387 -121.885
nostr.snowbla.de:443 50.4754 12.3683
denimroad.feeds.relay.tools 38.6327 -90.1961
relay.ohstr.com:443 43.6532 -79.3832
relay.nostrfy.org 35.6916 139.768
relay.klabo.world 47.674 -122.122
soloco.nl 43.6532 -79.3832
nexus.libernet.app 43.6532 -79.3832
nostr.hekster.org:443 37.3986 -121.964
relay.primal.net 43.6532 -79.3832
relay.lanacoin-eternity.com 40.8302 -74.1299
zencrown.relay2aeon.quest 52.3061 4.6907
relay.gulugulu.moe:443 43.6532 -79.3832
nostrelay.circum.space:443 52.2245 8.826
relay5.bitransfer.org 43.6532 -79.3832
chat-relay.zap-work.com 43.6532 -79.3832
nostr-relay.zimage.com 34.282 -118.439
relay.i9.eti.br 43.6532 -79.3832
relay.lacrypta.ar 43.6532 -79.3832
relay.satlantis.io 39.0438 -77.4874
nostr.bitcoiner.social:443 47.6743 -117.112
myvoiceourstory.org 37.3598 -121.981
relay.cloistr.xyz 42.337 -77.3178
nostr.liberty.fans 36.8767 -89.5879
relay.zone667.com 60.1699 24.9384
relay.getvia.xyz 60.1699 24.9384
schnorr.me 43.6532 -79.3832
nostr.debate.report 50.1109 8.68213
nostr-relay.corb.net:443 39.6478 -104.988
nip85.nosfabrica.com 39.0997 -94.5786
relay.solife.me 43.6532 -79.3832
relay.cypherflow.ai 48.8575 2.35138
relay.mostr.pub:443 43.6532 -79.3832
relay.tapestry.ninja 40.8054 -74.0241
nostr-01.yakihonne.com 1.32123 103.695
relay.s-w.art 43.6532 -79.3832
nostr.azzamo.net:443 52.2633 21.0283
relay.froth.zone 60.1699 24.9384
relay.kizuna-miki.com 35.694 139.754
relay.bowlafterbowl.com 32.9483 -96.7299
top.testrelay.top 43.6532 -79.3832
nostr-kyomu-haskell.onrender.com 37.7775 -122.397
nostr.wecsats.io 43.6532 -79.3832
relay1.nostrchat.io 60.1699 24.9384
relay.gulugulu.moe 43.6532 -79.3832
aurum.saturnali.net 46.2044 6.14316
nostr.txlyre.website 60.1699 24.9384
nostras.app 38.7946 -106.535
bridge.tagomago.me 42.3601 -71.0589
cs-relay.nostrdev.com:443 50.4754 12.3683
21milionidinostr.duckdns.org 41.8945 12.6493
wisp.djorivaltech.com 43.6532 -79.3832
relay.manneken.brussels 49.4543 11.0746
nostr.oxtr.dev 50.4754 12.3683
2 nostr.n7ekb.net 47.4941 -122.294
nostr.unkn0wn.world 46.8499 9.53287
pocketrelay.live 40.8302 -74.1299
scores.brainstorm.world 39.0997 -94.5786
relay.guggero.org 46.5971 9.59652
relay.layer.systems 49.0291 8.35695
relay.wisp.talk 49.4543 11.0746
bendernostur.duckdns.org:8443 50.1109 8.68213
dev-relay.nostr.space 43.6532 -79.3832
relay.mwaters.net 50.9871 2.12554
strfry.shock.network 39.0438 -77.4874
n.musicroadmap.com 35.694 139.754
cs-relay.nostrdev.com 50.4754 12.3683
talon.quest 43.6532 -79.3832
vidar.nestoris.online 59.3911 17.8752
nostr.red5d.dev 43.6532 -79.3832
nostr.rtvslawenia.com:443 49.4543 11.0746
nostr.2b9t.xyz 34.0549 -118.243
relay.angor.io 48.1046 11.6002
conduitl2.fly.dev 38.7946 -106.535
relay2.angor.io 48.1046 11.6002
relay.getsafebox.app 43.6532 -79.3832
nostrcity-club.fly.dev:443 38.7946 -106.535
vault.iris.to 43.6532 -79.3832
nostr-01.uid.ovh 50.9871 2.12554
nostr.sathoarder.com:443 48.5734 7.75211
reraw.pbla2fish.cc 43.6532 -79.3832
nostr.pbfs.io 50.4754 12.3683
relay.mypathtofire.de 42.8864 -78.8784
relay.nostrian-conquest.com 41.223 -111.974
relay.nostriot.com 41.5695 -83.9786
nostr.robosats.org 64.1476 -21.9392
relay.getsafebox.app:443 43.6532 -79.3832
dev.relay.stream 43.6532 -79.3832
relay.btcforplebs.com 43.6532 -79.3832
schnorr.me:443 43.6532 -79.3832
nostrue.com 40.8054 -74.0241
relay.aarpia.com 37.3986 -121.964
relay.mccormick.cx:443 52.3563 4.95714
relay.fiatdenier.com 43.7787 -79.5393
freelay.sovbit.host 60.1699 24.9384
nostr.computingcache.com 45.5341 -122.956
relay.piazza.today 48.122 11.589
relay.nostr.net 43.6532 -79.3832
nostr-relay.xbytez.io 50.6924 3.20113
strfry.shock.network:443 39.0438 -77.4874
nostr.girino.org 43.6532 -79.3832
nostr-pub.wellorder.net 45.5201 -122.99
nostr.hifish.org 47.4009 8.57814
relay.ezernode.net 52.6921 6.19372
relay.artiostr.ch 43.6532 -79.3832
relay01.lnfi.network 35.6764 139.65
relay.edufeed.org:443 49.4521 11.0767
relay.loveisbitcoin.com 43.6532 -79.3832
nostr.purpura.cloud 43.6532 -79.3832
relay.atsocy.com 43.6532 -79.3832
relay.conduit.market 38.7946 -106.535
relay.pocketnostr.com 40.8054 -74.0241
relay.cosmicbolt.net 37.3986 -121.964
relay.bornheimer.app 50.1109 8.68213
nostrcity-club.fly.dev 38.7946 -106.535
relay.nostr.dev.br 48.1046 11.6002
relay.nuts.cash 52.3676 4.90414
nostr.rtvslawenia.com 49.4543 11.0746
relay.tdw.lol 41.8781 -87.6298
relay.ohstr.com 43.6532 -79.3832
relay.chatbett.de 40.7128 -74.006
nostr2.girino.org:443 43.6532 -79.3832
relay.dwadziesciajeden.pl 52.2297 21.0122
nostr.spicyz.io 43.6532 -79.3832
relay2.fiatdenier.com 50.1013 8.62643
relay.binaryrobot.com:443 43.6532 -79.3832
nostr.thebiglake.org 32.71 -96.6745
relay.jmoose.rocks 32.8009 -96.8195
nostr.easycryptosend.it 43.6532 -79.3832
relay.aidatanorge.no 43.6532 -79.3832
memlay.v0l.io 53.3498 -6.26031
dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
nostr.notribe.net 40.8302 -74.1299
relay.plebeian.market 50.1109 8.68213
nostr.islandarea.net:443 35.4669 -97.6473
ensemble-appear-locking-forming.trycloudflare.com 43.6532 -79.3832
nostr.bitcoiner.social 47.6743 -117.112
relay.samt.st 40.8302 -74.1299
nostr-relay.corb.net 39.6478 -104.988
relay.tdw.gg 34.0549 -118.243
relay.olas.app 60.1699 24.9384
relay.yoinekodo.jp 43.6532 -79.3832
relay.mccormick.cx 52.3563 4.95714
syb.lol 34.0549 -118.243
0x-nostr-relay.fly.dev 38.7946 -106.535
vm-1734.lnvps.cloud 53.3498 -6.26031
relay.sigit.io:443 50.4754 12.3683
node.kommonzenze.de 49.4521 11.0767
staging.yabu.me 35.6092 139.73
strfry.bonsai.com 39.0438 -77.4874
relay.44billion.net 43.6532 -79.3832
nostr.mom 50.4754 12.3683
relay.notoshi.win 13.3622 100.983
nostr.ltd 19.076 72.8777
relay.fountain.fm:443 43.6532 -79.3832
bruh.samt.st 43.6532 -79.3832
relay.mitosystem.net -23.5558 -46.6396
nostr.myshosholoza.co.za 52.3676 4.90414
nostr.fullstackcash.net 45.5201 -122.99
nostr.na.social 43.6532 -79.3832
nosflare-leefcore.leefcore.workers.dev 43.6532 -79.3832
nostr.88mph.life 52.1941 -2.21905
relay.qstr.app 50.1109 8.68213
relay.lightning.pub 39.0438 -77.4874
relay.orangesync.tech 40.7128 -74.006
nostr-02.yakihonne.com 1.32123 103.695
nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
relay.bnos.space 43.6532 -79.3832
temp.iris.to 43.6532 -79.3832
nostr.relay.hedwig.sh 60.1699 24.9384
relay.nostr.space 43.6532 -79.3832
nostr.nestoris.online 55.568 37.5853
yabu.me 35.6092 139.73
nostr.wild-vibes.ts.net 48.8566 2.35222
bcast.seutoba.com.br 43.6532 -79.3832
nostr-dev.wellorder.net 45.5201 -122.99
portal-relay.pareto.space 49.4521 11.0767
hasky.chat 40.7862 -74.0743
relay.lanavault.space 60.1699 24.9384
offchain.pub 39.1585 -94.5728
nostr-relay.cbrx.io 43.6532 -79.3832
relay.nostrmap.net:443 60.1699 24.9384
relay.wavlake.com 41.2619 -95.8608
bitchat.nostr1.com 40.7057 -74.0136
relay.mappingbitcoin.com 43.6532 -79.3832
relay.ru.ac.th 13.7607 100.627
relay.nostr.com 50.1109 8.68213
relay.noeudlibre.fr 50.6924 3.20113
relay.satmaxt.xyz:443 43.6532 -79.3832
relay-dev.gulugulu.moe 43.6532 -79.3832
no.str.cr:443 8.96171 -83.5246
relay.nostrmap.net 60.1699 24.9384
relay.littlebitstudios.com 43.6532 -79.3832
relay-na1.metanomalist.com 43.6532 -79.3832
bucket.coracle.social 37.7775 -122.397
relay.nostr.bond 40.1885 29.061
relay.nostx.io 43.6532 -79.3832
relay.nostrfeed.com 60.1699 24.9384
relay.wavlake.com:443 41.2619 -95.8608
social.amanah.eblessing.co 48.1046 11.6002
relay-dev.gulugulu.moe:443 43.6532 -79.3832
3 relay.kilombino.com 43.6532 -79.3832
4 relay.lightning.pub:443 relay02.lnfi.network 39.0438 35.6764 -77.4874 139.65
relay.bitmacro.cloud 43.6532 -79.3832
relay.solomonstr.com 43.6532 -79.3832
relay.agentry.com 42.8864 -78.8784
relay.decentralia.fr 48.122 11.589
articles.layer3.news:443 37.3387 -121.885
nostr.tagomago.me 42.3601 -71.0589
relay.illuminodes.com 43.6532 -79.3832
nostr.nodesmap.com 59.3327 18.0656
relay.scuba323.com 40.8218 -74.45
nostr.yutakobayashi.com 43.6532 -79.3832
relay.pyramid.li 47.4093 8.46503
testr.nymble.world 40.8054 -74.0241
relay.mostr.pub 43.6532 -79.3832
infinity-signal-relay.digitalforlifeagency.workers.dev 43.6532 -79.3832
relay.artio.inf.unibe.ch 46.9501 7.43678
relay-can.zombi.cloudrodion.com 43.6532 -79.3832
relay.pprgb.app 52.52 13.405
fanfares.nostr1.com:443 40.7057 -74.0136
relay.lanacoin-eternity.com:443 40.8302 -74.1299
support.flotilla.social 32.9483 -96.7299
relay.agora.social 50.7383 15.0648
relay-us.zombi.cloudrodion.com 40.7862 -74.0743
offchain.pub:443 39.1585 -94.5728
nostr.snowbla.de 50.4754 12.3683
nostr.tabordalab.com 60.1699 24.9384
relay.nostrhub.fr 48.1045 11.6004
relay.chorus.community 50.7529 6.84585
nostr.linky.fit 50.1109 8.68213
relay.islandbitcoin.com 20.3898 78.0903
nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
relay.orly.dev 32.7767 -96.797
relay.chorus.community:443 50.7529 6.84585
relay.sigit.io 50.4754 12.3683
relayone.geektank.ai 39.0997 -94.5786
nostr.chaima.info 51.5072 -0.127586
borgar.lol 52.3676 4.90414
dm-test-strfry-discovery.samt.st 43.6532 -79.3832
relay.plebeian.market:443 50.1109 8.68213
relay.hivetalk.org 40.8302 -74.1299
relay.favillakey.io -27.4705 153.026
relay.bullishbounty.com 43.6532 -79.3832
no.str.cr 8.96171 -83.5246
relay.dyne.org 49.0291 8.35705
adre.su 59.9311 30.3609
relay.mitchelltribe.com 39.0438 -77.4874
relay.duck1123.com 43.6532 -79.3832
relay.staging.plebeian.market 51.5072 -0.127586
nostr.twinkle.lol 51.902 7.6657
nostr.mom:443 50.4754 12.3683
relay.jmoose.rocks:443 32.8009 -96.8195
relay.satsmarkt.club 52.6907 4.8181
relay.nostrcheck.me 43.6532 -79.3832
cyberspace.nostr1.com 40.7057 -74.0136
relay.veganostr.com:443 60.1699 24.9384
relay.nearhood.co.uk 51.5134 -0.0890675
nostr.stakey.net 52.3676 4.90414
nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
relay.nostr-check.me 43.6532 -79.3832
bcast.girino.org 43.6532 -79.3832
nostr.wecsats.io:443 43.6532 -79.3832
chat-relay.zap-work.com:443 43.6532 -79.3832
nostr.azzamo.net 52.2633 21.0283
relayone.soundhsa.com:443 39.0997 -94.5786
pymd.kamp.site 38.9072 -77.0369
relay.sharegap.net 43.6532 -79.3832
relay.satmaxt.xyz 43.6532 -79.3832
relay.nmail.li 50.9871 2.12554
fanfares.nostr1.com 40.7057 -74.0136
relay-testnet.k8s.layer3.news 37.3387 -121.885
relay-fra.zombi.cloudrodion.com 48.8566 2.35222
strfry.bonsai.com:443 39.0438 -77.4874
public.crostr.com:443 43.6532 -79.3832
nostrride.io 37.3986 -121.964
relay.routstr.com 59.4016 17.9455
public.crostr.com 43.6532 -79.3832
relayone.geektank.ai:443 39.0997 -94.5786
relay.kaleidoswap.com 50.8476 4.35717
relay.stg.formstr.app 54.6872 25.2797
relay.paulstephenborile.com 49.4543 11.0746
nostr.data.haus:443 50.4754 12.3683
nostriches.club 43.6532 -79.3832
relayrs.notoshi.win 43.6532 -79.3832
relay.bitos.space 43.6532 -79.3832
relay.shadowbip.com 54.352 18.6466
relay.bullishbounty.com:443 43.6532 -79.3832
nostr.planix.org 43.6532 -79.3832
5 relay.liberbitworld.org 43.6532 -79.3832
6 nostr.sathoarder.com articles.layer3.news 48.5734 37.3387 7.75211 -121.885
7 nostr.whitenode45.ddns.net relay.nostr.net 40.55 43.6532 -74.4758 -79.3832
8 relay.directsponsor.net nostr.easycryptosend.it 42.8864 43.6532 -78.8784 -79.3832
9 nostr.hekster.org strfry.bonsai.com 37.3986 39.0438 -121.964 -77.4874
10 relay-arg.zombi.cloudrodion.com relay.olas.app:443 1.35208 60.1699 103.82 24.9384
11 relay.degmods.com relay.sharegap.net 50.4754 43.6532 12.3683 -79.3832
12 nostr.spaceshell.xyz relay.ditto.pub 43.6532 -79.3832
13 nostr.chaima.info:443 nostrride.io 51.5072 37.3986 -0.127586 -121.964
14 nostr.pbfs.io:443 relay.favillakey.io 50.4754 -27.4705 12.3683 153.026
15 dm-test-strfry-generic.samt.st nostr-relay.xbytez.io 43.6532 50.6924 -79.3832 3.20113
16 spamspamspamspam.rest relay.nostrmap.net:443 43.6532 60.1699 -79.3832 24.9384
17 nr.yay.so mostro-p2p.tech 46.2126 50.1109 6.1154 8.68213
18 relay.minibolt.info relay.aidatanorge.no 43.6532 -79.3832
19 relay.mitchelltribe.com:443 nostr.openhoofd.nl 39.0438 51.5717 -77.4874 3.70417
20 nostr.janx.com relay.chatbett.de 43.6532 40.7128 -79.3832 -74.006
21 nostrelay.circum.space relay.ohstr.com:443 52.2245 43.6532 8.826 -79.3832
22 relay.earthly.city nostr.overmind.lol:443 34.1749 43.6532 -118.54 -79.3832
23 budabit.nostr1.com nostr.red5d.dev 40.7057 43.6532 -74.0136 -79.3832
relay.underorion.se 50.1109 8.68213
relay.paulstephenborile.com:443 49.4543 11.0746
nostr-pr02.redscrypt.org 52.3676 4.90414
relay.novospes.com 43.6532 -79.3832
relay.keykeeper.world 40.7824 -74.0711
relay2.orangesync.tech 40.7128 -74.006
nostr-01.yakihonne.com:443 1.32123 103.695
relay.trotters.cc:443 43.6532 -79.3832
treuzkas.branruz.com 48.8575 2.35138
nostr.21crypto.ch 47.5356 8.73209
relay1.privkey.io 25.7617 -80.1918
buzz.cashu.space 50.1109 8.68213
chorus.mikedilger.com:444 -36.8906 174.794
wheat.happytavern.co 43.6532 -79.3832
nostr.stakey.net:443 52.3676 4.90414
prl.plus 55.7558 37.6173
relay.vrtmrz.net 43.6532 -79.3832
relay.pocketstr.com 40.8302 -74.1299
24 relay.snort.social 53.3498 -6.26031
25 relay.flashapp.me relay.directsponsor.net 43.6548 42.8864 -79.3885 -78.8784
26 nostr.emanuelemiani.it relay.bullishbounty.com:443 45.778 43.6532 8.79414 -79.3832
27 relay.wisp.talk:443 relay.scuba323.com 49.4543 40.8218 11.0746 -74.45
28 vault.iris.to:443 nostr.spicyz.io 43.6532 -79.3832
29 relay.staging.plebeian.market:443 nostr.chaima.info 51.5072 50.1109 -0.127586 8.68213
30 offchain.bostr.online nostr.purpura.cloud 43.6532 -79.3832
31 maxq.descendant.io relay.nostrops.com 43.6532 32.71 -79.3832 -96.6745
32 relay.plebchain.club relay.veganostr.com:443 43.6532 60.1699 -79.3832 24.9384
33 nostr.2b9t.xyz:443 relay.ohstr.com 34.0549 43.6532 -118.243 -79.3832
34 relay.ditto.pub:443 soloco.nl 43.6532 -79.3832
35 dev-relay.nostreon.com nostr.plantroon.com:443 60.1699 50.1013 24.9384 8.62643
36 nostr.tbxnetworx.de relay.chorus.community:443 47.8226 48.5333 10.0064 10.7
37 nostr.4rs.nl relay.fiatdenier.com 49.0291 43.7787 8.35696 -79.5393
38 nostr2.girino.org nostr.whitenode45.ddns.net 43.6532 40.55 -79.3832 -74.4758
39 relayrs.notoshi.win:443 relay.cosmicbolt.net:443 43.6532 37.3986 -79.3832 -121.964
40 relay.ltgnet.work:8443 relay.lanacoin-eternity.com:443 51.0511 40.8302 -114.075 -74.1299
41 relay.fountain.fm relay.cosmicbolt.net 43.6532 37.3986 -79.3832 -121.964
42 relay.wasabiwallet.io fanfares.nostr1.com:443 43.6532 40.7057 -79.3832 -74.0136
43 nostr-kyomu-haskell.onrender.com 37.7775 -122.397
44 relay.keykeeper.world 40.7824 -74.0711
45 relay-testnet.k8s.layer3.news 37.3387 -121.885
46 dm-test-strfry-generic.samt.st 43.6532 -79.3832
47 relay.laantungir.net -19.4692 -42.5315
48 nostr.sathoarder.com 48.5734 7.75211
49 relay.mccormick.cx:443 52.3563 4.95714
50 relay.solife.me 43.6532 -79.3832
51 relay.fountain.fm:443 43.6532 -79.3832
52 nostrcity-club.fly.dev 38.7946 -106.535
53 nostr.mas-family.eu 60.3478 15.7505
54 no.str.cr 8.96171 -83.5246
55 relay.wavefunc.live 41.8781 -87.6298
56 relay.cyberguy.fyi 52.6907 4.8181
57 adre.su 59.9311 30.3609
58 relay.erybody.com 41.4513 -81.7021
59 relay.mrmave.work 43.6532 -79.3832
60 relay.decentralia.fr 48.122 11.589
61 relay.mccormick.cx 52.3563 4.95714
62 nostr.dlcdevkit.com:443 40.0992 -83.1141
63 nostr-02.yakihonne.com 1.32123 103.695
64 nostr.21crypto.ch 47.5356 8.73209
65 slick.mjex.me 39.0418 -77.4744
66 nostr.spicyz.io:443 43.6532 -79.3832
67 relay-dev.gulugulu.moe 43.6532 -79.3832
68 relay.nostx.io 43.6532 -79.3832
69 relay.wisp.talk 49.4543 11.0746
70 relay.plebeian.market:443 50.1109 8.68213
71 relay.mostr.pub:443 43.6532 -79.3832
72 relay.aarpia.com 37.3986 -121.964
73 chat-relay.zap-work.com 43.6532 -79.3832
74 chat-relay.zap-work.com:443 43.6532 -79.3832
75 relay.mitchelltribe.com:443 39.0438 -77.4874
76 relay.paulstephenborile.com 49.4543 11.0746
77 relay.trotters.cc 43.6532 -79.3832
78 nexus.libernet.app:443 43.6532 -79.3832
79 syb.lol 34.0549 -118.243
80 relay.cypherflow.ai 48.8575 2.35138
81 relay.despera.space 52.1782 20.9926
82 relay.loveisbitcoin.com 43.6532 -79.3832
83 relay.rushmi0.win 43.6532 -79.3832
84 relay.degmods.com 50.4754 12.3683
85 relay.satlantis.io 39.0438 -77.4874
86 relay.underorion.se 50.1109 8.68213
87 nostr.mom:443 50.4754 12.3683
88 nostr-verified.wellorder.net 45.5201 -122.99
89 relay.nostreon.com 60.1699 24.9384
90 nostr.christiansass.de 54.3213 10.1349
91 rilo.nostria.app:443 43.6532 -79.3832
92 relay.trotters.cc:443 43.6532 -79.3832
93 kotukonostr.onrender.com 37.7775 -122.397
94 relay.nmail.li 50.9871 2.12554
95 relay.artio.inf.unibe.ch 46.9501 7.43678
96 nos.lol 50.4754 12.3683
97 nostr.snowbla.de:443 50.4754 12.3683
98 relay.openresist.com 43.6532 -79.3832
99 ribo.us.nostria.app 43.6532 -79.3832
100 bitchat.nostr1.com 40.7057 -74.0136
101 vm-1734.lnvps.cloud 53.3498 -6.26031
102 nostr-relay.xbytez.io:443 50.6924 3.20113
103 nostr.sathoarder.com:443 48.5734 7.75211
104 bcast.girino.org 43.6532 -79.3832
105 ribo.us.nostria.app:443 43.6532 -79.3832
106 strfry.shock.network 39.0438 -77.4874
107 relay.staging.plebeian.market:443 51.5072 -0.127586
108 offchain.pub:443 39.1585 -94.5728
109 relayone.soundhsa.com 39.0997 -94.5786
110 relay.libernet.app 43.6532 -79.3832
111 relay.earthly.city 34.1749 -118.54
112 relayrs.notoshi.win:443 43.6532 -79.3832
113 nostr.vulpem.com 49.4543 11.0746
114 relay.getsafebox.app:443 43.6532 -79.3832
115 relay.paulstephenborile.com:443 49.4543 11.0746
116 nostr.unkn0wn.world 46.8499 9.53287
117 relay.binaryrobot.com 43.6532 -79.3832
118 relay.wellorder.net 45.5201 -122.99
119 relayone.geektank.ai:443 39.0997 -94.5786
120 relay.wasabiwallet.io 43.6532 -79.3832
121 top.testrelay.top 43.6532 -79.3832
122 nostr.2b9t.xyz 34.0549 -118.243
123 relay.nostr.com 50.1109 8.68213
124 nostr.azzamo.net:443 52.2633 21.0283
125 nostr.dlcdevkit.com 40.0992 -83.1141
126 nostr.robosats.org 64.1476 -21.9392
127 nostr.iskarion.ddns.net 43.3076 -2.95421
128 relay2.angor.io 48.1046 11.6002
129 relay.mmwaves.de 48.8575 2.35138
130 relay.agorist.space:443 52.3734 4.89406
131 relay.dreamith.to 43.6532 -79.3832
132 nostr-02.uid.ovh 50.9871 2.12554
133 nostr.thebiglake.org 32.71 -96.6745
134 nostr-relay.corb.net 39.6478 -104.988
135 relay.dyne.org 49.0291 8.35705
136 nosflare-leefcore.leefcore.workers.dev 43.6532 -79.3832
137 relay.angor.io 48.1046 11.6002
138 nostrelay.circum.space 52.2245 8.826
139 nostr-01.uid.ovh 50.9871 2.12554
140 maxq.descendant.io 43.6532 -79.3832
141 offchain.bostr.online 43.6532 -79.3832
142 relay.binaryrobot.com:443 43.6532 -79.3832
143 nostr.planix.org 43.6532 -79.3832
144 tribune-panel-growing-noon.trycloudflare.com 43.6532 -79.3832
145 prl.plus 55.7628 37.5983
146 relay.mwaters.net 50.9871 2.12554
147 dev.relay.stream 43.6532 -79.3832
148 relay.bornheimer.app 50.1109 8.68213
149 inbox.scuba323.com 40.8218 -74.45
150 relay-us.zombi.cloudrodion.com 40.7862 -74.0743
151 relay.nuts.cash 52.3676 4.90414
152 bruh.samt.st 43.6532 -79.3832
153 nostr.carroarmato0.be:443 50.914 3.21378
154 relay.plebeian.market 50.1109 8.68213
155 schnorr.me:443 43.6532 -79.3832
156 nostr.thalheim.io:443 60.1699 24.9384
157 nostr.88mph.life 52.1941 -2.21905
158 cache.trustr.ing 43.6548 -79.3885
159 ec2.f7z.io 60.1699 24.9384
160 relay.btcforplebs.com 43.6532 -79.3832
161 nostr.liberty.fans 36.8767 -89.5879
162 syb.lol:443 34.0549 -118.243
163 nostr.islandarea.net:443 35.4669 -97.6473
164 nostr.mom 50.4754 12.3683
165 nostr.data.haus:443 50.4754 12.3683
166 strfry.bonsai.com:443 39.0438 -77.4874
167 nostr.highway15.net 28.0445 -82.6699
168 relay.wavlake.com:443 41.2619 -95.8608
169 relay.nostriot.com:443 41.5695 -83.9786
170 relay.gulugulu.moe:443 43.6532 -79.3832
171 rele.speyhard.fi 50.1109 8.68213
172 nostr.hoppe-relay.it.com 42.8864 -78.8784
173 nostr2.girino.org:443 43.6532 -79.3832
174 nostr.oxtr.dev 50.4754 12.3683
175 nostr.bitcoiner.social:443 40.7608 -111.891
176 nostrrelay.taylorperron.com 45.5029 -73.5723
177 nostr-01.yakihonne.com:443 1.32123 103.695
178 nostr.tagomago.me 42.3601 -71.0589
179 relay.hivetalk.org 40.8302 -74.1299
180 relay.sigit.io:443 50.4754 12.3683
181 relay.openresist.com:443 43.6532 -79.3832
182 cs-relay.nostrdev.com:443 50.4754 12.3683
183 no.str.cr:443 8.96171 -83.5246
184 relay.nearhood.co.uk 51.5134 -0.0890675
185 relay.lanavault.space 60.1699 24.9384
186 relay.guggero.org 46.5971 9.59652
187 nexus.libernet.app 43.6532 -79.3832
188 relay.nostriot.com 41.5695 -83.9786
189 dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
190 x.kojira.io:443 43.6532 -79.3832
191 nostr.4rs.nl 49.0291 8.35696
192 relayone.geektank.ai 39.0997 -94.5786
193 nostr.debate.report 50.1109 8.68213
194 relay.gulugulu.moe 43.6532 -79.3832
195 public.crostr.com 43.6532 -79.3832
196 offchain.pub 39.1585 -94.5728
197 relay.tdw.lol 41.8781 -87.6298
198 relay5.bitransfer.org 43.6532 -79.3832
199 relay.manneken.brussels 49.4543 11.0746
200 chorus.pjv.me 45.5201 -122.99
201 relay.fckstate.net 59.3293 18.0686
202 treuzkas.branruz.com 48.8575 2.35138
203 nostr-relay.cbrx.io 43.6532 -79.3832
204 nostr-relay.nextblockvending.com 47.2343 -119.853
205 nostr.fullstackcash.net 45.5201 -122.99
206 relay.primal.net 43.6532 -79.3832
207 relay.mappingbitcoin.com 43.6532 -79.3832
208 nostr.mikoshi.de 50.1109 8.68213
209 nostr.stakey.net 52.3676 4.90414
210 nostr-rs-relay-qj1h.onrender.com 37.7775 -122.397
211 relay.wavlake.com 41.2619 -95.8608
212 relay.ordoplay.com 50.1109 8.68213
213 relay.klabo.world 47.674 -122.122
214 buzz.cashu.space 50.1109 8.68213
215 relay.nostrhub.fr 48.1045 11.6004
216 portal-relay.pareto.space 49.0291 8.35696
217 dev-relay.nostreon.com 60.1699 24.9384
218 relay.islandbitcoin.com 12.8498 77.6545
219 testr.nymble.world 40.8054 -74.0241
220 relayrs.notoshi.win 43.6532 -79.3832
221 nostr-relay.corb.net:443 39.6478 -104.988
222 cdn.satellite.earth 40.8302 -74.1299
223 rilo.nostria.app 43.6532 -79.3832
224 nostr.pbfs.io 50.4754 12.3683
225 nostr2.girino.org 43.6532 -79.3832
226 0x-nostr-relay.fly.dev 38.7946 -106.535
227 nostr.girino.org 43.6532 -79.3832
228 relay.layer.systems 49.0291 8.35695
229 relay.olas.app 60.1699 24.9384
230 ribo.eu.nostria.app 43.6532 -79.3832
231 buzz.ac2n-share.kozow.com 45.764 4.83566
232 auth.nostr1.com 40.7057 -74.0136
233 relay.bitmacro.cloud 43.6532 -79.3832
234 relay.qstr.app 50.1109 8.68213
235 relay.ru.ac.th 13.7607 100.627
236 ribo.nostria.app:443 43.6532 -79.3832
237 relay.44billion.net 43.6532 -79.3832
238 cs-relay.nostrdev.com 50.4754 12.3683
239 relay.nostrian-conquest.com:443 41.223 -111.974
240 relay.bullishbounty.com 43.6532 -79.3832
241 relay.edufeed.org 49.4521 11.0767
242 porchlight.social 43.6532 -79.3832
243 nostr.bitcoiner.social 40.7608 -111.891
244 testnet-relay.samt.st 40.8302 -74.1299
245 relay.vrtmrz.net 43.6532 -79.3832
246 nostr.wecsats.io:443 43.6532 -79.3832
247 relay.satmaxt.xyz:443 43.6532 -79.3832
248 nostr.snowbla.de 50.4754 12.3683
249 nostr.rtvslawenia.com:443 49.4543 11.0746
250 relay.flashapp.me 43.6548 -79.3885
251 purplerelay.com:443 43.6532 -79.3832
252 relay.staging.plebeian.market 51.5072 -0.127586
253 relay.libernet.app:443 43.6532 -79.3832
254 relay.0xchat.com 43.6532 -79.3832
255 myvoiceourstory.org 37.3598 -121.981
256 nrs-01.darkcloudarcade.com:443 39.0997 -94.5786
257 relay.vrtmrz.net:443 43.6532 -79.3832
258 relay.zone667.com 60.1699 24.9384
259 nostr.nodesmap.com 59.3327 18.0656
260 relay.agorist.space 52.3734 4.89406
261 relayone.soundhsa.com:443 39.0997 -94.5786
262 relay.satmaxt.xyz 43.6532 -79.3832
263 relay.zapanimations.com 40.8054 -74.0241
264 relay.sovereignresonance.org 48.8575 2.35138
265 nostr.relay.hedwig.sh 60.1699 24.9384
266 relay-dev.gulugulu.moe:443 43.6532 -79.3832
267 relay.arx-ccn.com 50.4754 12.3683
268 nip85.nosfabrica.com 39.0997 -94.5786
269 nrs-01.darkcloudarcade.com 39.0997 -94.5786
270 spamspamspamspam.rest 43.6532 -79.3832
271 nostr.davenov.com 50.1109 8.68213
272 relay.littlebitstudios.com 43.6532 -79.3832
273 purplerelay.com 43.6532 -79.3832
274 nos.lol:443 50.4754 12.3683
275 relay2.veganostr.com 60.1699 24.9384
276 relay-rpi.edufeed.org 49.4521 11.0767
277 relay.lacrypta.ar 43.6532 -79.3832
278 relay.tdw.gg 34.0549 -118.243
279 relay.getvia.xyz 60.1699 24.9384
280 relay.lanacoin-eternity.com 40.8302 -74.1299
281 conduitl2.fly.dev 38.7946 -106.535
282 nostr.rtvslawenia.com 49.4543 11.0746
283 relay.wavefunc.live:443 41.8781 -87.6298
284 nostr.carroarmato0.be 50.914 3.21378
285 nostr.pbfs.io:443 50.4754 12.3683
286 ribo.nostria.app 43.6532 -79.3832
287 nostr.sovereignservices.xyz 43.6532 -79.3832
288 relay.mostr.pub 43.6532 -79.3832
289 schnorr.me 43.6532 -79.3832
290 x.kojira.io 43.6532 -79.3832
291 public.obelisk.ar 43.6532 -79.3832
292 relay.angor.io:443 48.1046 11.6002
293 nostr.oxtr.dev:443 50.4754 12.3683
294 nostr.ltd 19.076 72.8777
295 nostr.tac.lol:443 47.4748 -122.273
296 nostr.twinkle.lol 51.902 7.6657
297 relay.s-w.art 43.6532 -79.3832
298 relay.openfarmtools.org 60.1699 24.9384
299 nostr.hekster.org 37.3986 -121.964
300 strfry.apps3.slidestr.net 40.4167 -3.70329
301 relay.mypathtofire.de 42.8864 -78.8784
302 nostr.wecsats.io 43.6532 -79.3832
303 nostr.computingcache.com 34.0356 -118.442
304 nostr.janx.com 43.6532 -79.3832
305 bread.nostrsms.com 40.8218 -74.45
306 relay.chorus.community 48.5333 10.7
307 testnet-relay.samt.st:443 40.8302 -74.1299
308 relay.kaleidoswap.com 50.8476 4.35717
309 yabu.me 35.6092 139.73
310 nostr-dev.wellorder.net 45.5201 -122.99
311 relay.layer.systems:443 49.0291 8.35695
312 relay-can.zombi.cloudrodion.com 43.6532 -79.3832
313 nostr-relay.zimage.com 34.282 -118.439
314 relay.fundstr.me 42.3601 -71.0589
315 nostr.stakey.net:443 52.3676 4.90414
316 relay.satsmarkt.club 52.6907 4.8181
317 relay.satsapp.me 50.4754 12.3683
318 bendernostur.duckdns.org:8443 50.1109 8.68213
319 relay.illuminodes.com 43.6532 -79.3832
320 nostr.chaima.info:443 50.1109 8.68213
321 nostr.hekster.org:443 37.3986 -121.964
322 nostr.yutakobayashi.com 43.6532 -79.3832
323 freelay.sovbit.host 60.1699 24.9384
324 insta-relay.apps3.slidestr.net 40.4167 -3.70329
325 infinity-signal-relay.digitalforlifeagency.workers.dev 43.6532 -79.3832
326 relay.internationalright-wing.org -22.4692 -48.9875
327 relay.mmwaves.de:443 48.8575 2.35138
328 public.crostr.com:443 43.6532 -79.3832
329 relay.nostrian-conquest.com 41.223 -111.974
330 relay-rpi.edufeed.org:443 49.4521 11.0767
331 relay2.angor.io:443 48.1046 11.6002
332 relay-fra.zombi.cloudrodion.com 48.8566 2.35222
333 bunk-test.feeds.relay.tools 38.6327 -90.1961
334 nostr-pr02.redscrypt.org 52.3676 4.90414
335 nostr.tac.lol 47.4748 -122.273
336 nostr.overpay.com 29.7449 -95.5343
337 relay.novospes.com 43.6532 -79.3832
338 nostrcity-club.fly.dev:443 38.7946 -106.535
339 relay01.lnfi.network 35.6764 139.65
340 relay.lightning.pub:443 39.0438 -77.4874
341 relay.pyramid.li 47.4093 8.46503
342 relay.nostu.be 40.4167 -3.70329
343 nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
344 relay.endfiat.money:443 59.3327 18.0656
345 nostr.spaceshell.xyz 43.6532 -79.3832
346 21milionidinostr.duckdns.org 45.5192 9.08625
347 articles.layer3.news:443 37.3387 -121.885
348 wheat.happytavern.co 43.6532 -79.3832
349 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
350 nostr.na.social 43.6532 -79.3832
351 social.amanah.eblessing.co 48.1046 11.6002
352 reraw.pbla2fish.cc 43.6532 -79.3832
353 nostr.girino.org:443 43.6532 -79.3832
354 staging.yabu.me 35.6092 139.73
355 nostr.islandarea.net 35.4669 -97.6473
356 relay.endfiat.money 59.3327 18.0656
357 relay.mitchelltribe.com 39.0438 -77.4874
358 nostr.myshosholoza.co.za 52.3676 4.90414
359 relay.staging.commonshub.brussels 49.4543 11.0746
360 bridge.tagomago.me 42.3601 -71.0589
361 relay.agentry.com 42.8864 -78.8784
362 testnet.samt.st 43.6532 -79.3832
363 nostr.wild-vibes.ts.net 48.8566 2.35222
364 relay.getsafebox.app 43.6532 -79.3832
365 relay.notoshi.win 13.3396 100.93
366 bitcoinostr.duckdns.org 41.1976 1.11167
367 relay.sigit.io 50.4754 12.3683
368 bucket.coracle.social 37.7775 -122.397
369 dm-test-strfry-discovery.samt.st 43.6532 -79.3832
370 vault.iris.to:443 43.6532 -79.3832
371 relay.wisp.talk:443 49.4543 11.0746
372 relay.mostro.network 40.8302 -74.1299
373 nostr.l484.com 30.2672 -97.7431
374 relay.froth.zone 60.1699 24.9384
375 relay.beamhop.com 43.6532 -79.3832
376 relay.sincensura.org 43.6532 -79.3832
377 relay.ditto.pub:443 43.6532 -79.3832
378 nostr.azzamo.net 52.2633 21.0283
379 relay.edufeed.org:443 49.4521 11.0767
380 ribo.eu.nostria.app:443 43.6532 -79.3832
381 vault.iris.to 43.6532 -79.3832
382 nostr.plantroon.com 50.1013 8.62643
383 fanfares.nostr1.com 40.7057 -74.0136
384 budabit.nostr1.com 40.7057 -74.0136
385 relay.damus.io 43.6532 -79.3832
386 strfry.shock.network:443 39.0438 -77.4874
387 bitcoiner.social 40.7608 -111.891
388 basspistol.org 49.0291 8.35696
389 nostr.thalheim.io 60.1699 24.9384
390 relay.conduit.market 38.7946 -106.535
391 relay.yoinekodo.jp 43.6532 -79.3832
392 nostrbtc.com 43.6532 -79.3832
393 relay.lightning.pub 39.0438 -77.4874
394 nostr-01.yakihonne.com 1.32123 103.695
395 nostr-pub.wellorder.net 45.5201 -122.99
396 strfry.ymir.cloud 43.6532 -79.3832
397 relay.minibolt.info 43.6532 -79.3832
398 nostr.data.haus 50.4754 12.3683
399 relay.otrta.me 50.1109 8.68213
400 relay.samt.st 40.8302 -74.1299
401 relay.nostr.blockhenge.com 39.0438 -77.4874
402 relay.minibolt.info:443 43.6532 -79.3832
403 nostr.2b9t.xyz:443 34.0549 -118.243
404 dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
405 relay.veganostr.com 60.1699 24.9384
406 relay.fountain.fm 43.6532 -79.3832
407 node.kommonzenze.de 49.4521 11.0767
408 relay-arg.zombi.cloudrodion.com 1.35208 103.82
409 nostr.myshosholoza.co.za:443 52.3676 4.90414
410 temp.iris.to 43.6532 -79.3832
411 relay.nostrmap.net 60.1699 24.9384
412 relay.orly.dev 32.7767 -96.797
413 nostrelay.circum.space:443 52.2245 8.826
414 nostr.notribe.net 40.8302 -74.1299

View File

@ -112,6 +112,12 @@ open class EncryptionService(private val context: Context) {
fun getStaticPublicKey(): ByteArray? {
return noiseService.getStaticPublicKeyData()
}
fun sealCourierPayload(payload: ByteArray, recipientStaticKey: ByteArray): ByteArray =
noiseService.sealCourierPayload(payload, recipientStaticKey)
fun openCourierPayload(ciphertext: ByteArray): Pair<ByteArray, ByteArray> =
noiseService.openCourierPayload(ciphertext)
/**
* Get our signing public key for Ed25519 signatures (for identity announcements)

View File

@ -11,7 +11,7 @@ import com.bitchat.android.protocol.MessageType
object BLEPacketPaddingPolicy {
fun shouldPadForBLE(type: UByte): Boolean {
return when (MessageType.fromValue(type)) {
MessageType.NOISE_ENCRYPTED, MessageType.NOISE_HANDSHAKE -> true
MessageType.NOISE_ENCRYPTED, MessageType.NOISE_HANDSHAKE, MessageType.COURIER_ENVELOPE -> true
else -> false
}
}

View File

@ -367,6 +367,26 @@ class BluetoothConnectionManager(
)
}
suspend fun sendToPeerAndAwaitAcceptance(peerID: String, routed: RoutedPacket): Boolean {
if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.sendPacketToPeerAndAwaitAcceptance(
routed,
peerID,
serverManager.getGattServer(),
serverManager.getCharacteristic()
)
}
suspend fun sendToPeerAndAwaitCompletion(peerID: String, routed: RoutedPacket): Boolean {
if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.sendPacketToPeerAndAwaitCompletion(
routed,
peerID,
serverManager.getGattServer(),
serverManager.getCharacteristic()
)
}
fun cancelTransfer(transferId: String): Boolean {
return packetBroadcaster.cancelTransfer(transferId)
}

View File

@ -49,6 +49,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Core components - each handling specific responsibilities
private val encryptionService = EncryptionService(context)
private val courierStore = CourierStore(context)
private val bridgeCourierService by lazy {
com.bitchat.android.nostr.BridgeCourierService(context, encryptionService) { envelope ->
handleLocalCourierEnvelope(envelope)
}
}
// My peer identification - derived from persisted Noise identity fingerprint (first 16 hex chars)
val myPeerID: String = encryptionService.getIdentityFingerprint().take(16)
@ -156,10 +162,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
gossipSyncManager = GossipSyncManager(
myPeerID = myPeerID,
scope = serviceScope,
context = context,
configProvider = object : GossipSyncManager.ConfigProvider {
override fun seenCapacity(): Int = try {
com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(500)
} catch (_: Exception) { 500 }
com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(1000)
} catch (_: Exception) { 1000 }
override fun gcsMaxBytes(): Int = try {
com.bitchat.android.ui.debug.DebugPreferenceManager.getGcsMaxFilterBytes(400)
@ -177,6 +184,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
if (isBleTransportEnabled()) {
TransportBridgeService.register("BLE", this)
}
bridgeCourierService.start()
// Inject dynamic direct connection check into PeerManager
// Matches iOS logic: checks if we have an active hardware mapping for this peer
@ -200,6 +208,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
connectionManager.sendPacketToPeer(peerID, packet)
}
override fun sendToPeerAndReport(peerID: String, packet: BitchatPacket): Boolean {
if (!isBleTransportEnabled()) return false
return connectionManager.sendPacketToPeer(peerID, packet)
}
private fun broadcastRoutedPacket(routed: RoutedPacket): Boolean {
if (!isBleTransportEnabled()) return false
val queued = connectionManager.broadcastPacket(routed)
@ -514,6 +527,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
override fun onDeliveryAckReceived(messageID: String, peerID: String) {
try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageID, peerID) } catch (_: Exception) { }
// Status events can arrive while MainActivity has detached the UI delegate.
// Persist first so the next UI collector observes the advancement.
try {
@ -526,6 +540,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
override fun onReadReceiptReceived(messageID: String, peerID: String) {
try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageID, peerID) } catch (_: Exception) { }
try {
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
messageID,
@ -574,6 +589,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean {
return runBlocking { messageHandler.handleNoiseEncrypted(routed) }
}
override fun handleCourierEnvelope(routed: RoutedPacket): Boolean {
return handleCourierEnvelopePacket(routed)
}
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
val result = messageHandler.handleAnnounceWithResult(routed)
@ -599,6 +618,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
}
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
handleCourierAnnounce(routed)
return true
}
@ -608,7 +628,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
try {
val pkt = routed.packet
val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST))
if (isBroadcast && pkt.type == MessageType.MESSAGE.value) {
if (isBroadcast && pkt.type in setOf(MessageType.MESSAGE.value, MessageType.FILE_TRANSFER.value)) {
gossipSyncManager.onPublicPacketSeen(pkt)
}
} catch (_: Exception) { }
@ -809,6 +829,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
* Stop all mesh services
*/
fun stopServices() {
if (bridgeCourierService.isStarted) bridgeCourierService.stop()
if (!isActive) {
Log.w(TAG, "Mesh service not active, ignoring stop request")
return
@ -1442,6 +1463,209 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
return peerManager.getPeerInfo(peerID)
}
fun getPeerInfos(): List<PeerInfo> = peerManager.getAllPeerNicknames().keys.mapNotNull(peerManager::getPeerInfo)
fun sendCourierMessage(
content: String,
messageID: String,
recipientNoiseKey: ByteArray,
courierPeerIDs: List<String>
): List<String> {
val privateMessage = com.bitchat.android.model.PrivateMessagePacket(messageID, content).encode() ?: return emptyList()
val typedPayload = com.bitchat.android.model.NoisePayload(
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE,
privateMessage
).encode()
val sealed = try { encryptionService.sealCourierPayload(typedPayload, recipientNoiseKey) } catch (_: Exception) { return emptyList() }
val now = System.currentTimeMillis()
val couriers = courierPeerIDs.distinct().take(4)
if (couriers.isEmpty()) return emptyList()
return couriers.filter { courierID ->
val envelope = com.bitchat.android.model.CourierEnvelope(
recipientTag = com.bitchat.android.model.CourierEnvelope.recipientTag(
recipientNoiseKey,
com.bitchat.android.model.CourierEnvelope.epochDay(now)
),
expiry = (now + com.bitchat.android.model.CourierEnvelope.MAX_LIFETIME_MS).toULong(),
ciphertext = sealed,
copies = 4u
)
val payload = envelope.encode() ?: return@filter false
val packet = BitchatPacket(
type = MessageType.COURIER_ENVELOPE.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(courierID),
timestamp = now.toULong(),
payload = payload,
ttl = MAX_TTL
)
TransportBridgeService.sendToPeerFromLocalAndReport(courierID, signPacketBeforeBroadcast(packet))
}
}
fun sendBridgeCourierMessage(
content: String,
messageID: String,
recipientNoiseKey: ByteArray,
onAccepted: () -> Unit = {}
): Boolean = bridgeCourierService.deposit(content, messageID, recipientNoiseKey, onAccepted)
private fun handleLocalCourierEnvelope(envelope: com.bitchat.android.model.CourierEnvelope) {
val packet = BitchatPacket(
type = MessageType.COURIER_ENVELOPE.value,
senderID = ByteArray(8),
recipientID = hexStringToByteArray(myPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = envelope.encode() ?: return,
ttl = MAX_TTL
)
handleCourierEnvelopePacket(RoutedPacket(packet, peerID = "bridge"))
}
private fun handleCourierEnvelopePacket(routed: RoutedPacket): Boolean {
val envelope = com.bitchat.android.model.CourierEnvelope.decode(routed.packet.payload) ?: return false
val now = System.currentTimeMillis()
if (envelope.expiry.toLong() <= now) return false
val localKey = encryptionService.getStaticPublicKey() ?: return false
if (envelope.matchesRecipient(localKey, now)) {
val (senderKey, typedPayload) = try { encryptionService.openCourierPayload(envelope.ciphertext) } catch (_: Exception) { return false }
val noisePayload = com.bitchat.android.model.NoisePayload.decode(typedPayload) ?: return false
if (noisePayload.type !in setOf(
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE,
com.bitchat.android.model.NoisePayloadType.DELIVERED
)
) return false
val senderPeerID = com.bitchat.android.services.ContactIdentityResolver.peerIdForNoiseKey(senderKey)
val synthetic = routed.copy(
packet = routed.packet.copy(
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(senderPeerID),
payload = typedPayload,
timestamp = System.currentTimeMillis().toULong()
),
peerID = senderPeerID
)
val delivered = runBlocking { messageHandler.handleOpenedCourierPayload(synthetic) }
if (delivered && noisePayload.type == com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) {
val messageID = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data)?.messageID
if (messageID != null) sendCourierDeliveryAck(messageID, senderKey, routed)
}
return delivered
}
val peerID = routed.peerID ?: return false
if (!DirectCourierDepositPolicy.accepts(
routed,
MAX_TTL,
connectionManager::getCurrentLinkID,
connectionManager.addressPeerMap::get
)
) return false
val depositor = peerManager.getPeerInfo(peerID) ?: return false
val key = depositor.noisePublicKey ?: return false
val favorite = try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(key)?.isMutual == true
} catch (_: Exception) { false }
val tier = if (favorite) CourierDepositTier.FAVORITE
else if (depositor.hasVerifiedAnnouncement) CourierDepositTier.VERIFIED
else return false
return courierStore.deposit(envelope, key, tier)
}
private fun sendCourierDeliveryAck(messageID: String, senderNoiseKey: ByteArray, ingress: RoutedPacket) {
val typedPayload = com.bitchat.android.model.NoisePayload(
com.bitchat.android.model.NoisePayloadType.DELIVERED,
messageID.toByteArray(Charsets.UTF_8)
).encode()
if (ingress.peerID == "bridge") {
bridgeCourierService.depositPayload(typedPayload, senderNoiseKey)
return
}
val courierPeerID = ingress.peerID ?: return
val now = System.currentTimeMillis()
val sealed = try { encryptionService.sealCourierPayload(typedPayload, senderNoiseKey) } catch (_: Exception) { return }
val envelope = com.bitchat.android.model.CourierEnvelope(
recipientTag = com.bitchat.android.model.CourierEnvelope.recipientTag(
senderNoiseKey,
com.bitchat.android.model.CourierEnvelope.epochDay(now)
),
expiry = (now + com.bitchat.android.model.CourierEnvelope.MAX_LIFETIME_MS).toULong(),
ciphertext = sealed,
copies = 4u
)
val packet = BitchatPacket(
type = MessageType.COURIER_ENVELOPE.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(courierPeerID),
timestamp = now.toULong(),
payload = envelope.encode() ?: return,
ttl = MAX_TTL
)
TransportBridgeService.sendToPeerFromLocalAndReport(courierPeerID, signPacketBeforeBroadcast(packet))
}
private fun handleCourierAnnounce(routed: RoutedPacket) {
val peerID = routed.peerID ?: return
val info = peerManager.getPeerInfo(peerID) ?: return
val noiseKey = info.noisePublicKey ?: return
val direct = routed.packet.ttl >= MAX_TTL
val envelopes = if (direct) courierStore.copiesForRecipient(noiseKey) else courierStore.copiesForRemoteHandover(noiseKey)
envelopes.forEach { envelope ->
val packet = BitchatPacket(
type = MessageType.COURIER_ENVELOPE.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(peerID),
timestamp = System.currentTimeMillis().toULong(),
payload = envelope.encode() ?: return@forEach,
ttl = MAX_TTL
)
if (direct) {
serviceScope.launch {
if (connectionManager.sendToPeerAndAwaitCompletion(
peerID,
RoutedPacket(signPacketBeforeBroadcast(packet))
)
) {
courierStore.remove(envelope)
}
}
} else TransportBridgeService.broadcastFromLocal(RoutedPacket(signPacketBeforeBroadcast(packet)))
}
if (direct) {
courierStore.sprayCopiesFor(noiseKey).forEach { envelope ->
val payload = envelope.encode()
if (payload == null) {
courierStore.cancelSpray(envelope, noiseKey)
return@forEach
}
val packet = BitchatPacket(
type = MessageType.COURIER_ENVELOPE.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(peerID),
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
ttl = MAX_TTL
)
serviceScope.launch {
var committed = false
try {
if (connectionManager.sendToPeerAndAwaitCompletion(
peerID,
RoutedPacket(signPacketBeforeBroadcast(packet))
)
) {
committed = courierStore.commitSpray(envelope, noiseKey)
}
} finally {
if (!committed) courierStore.cancelSpray(envelope, noiseKey)
}
}.invokeOnCompletion {
// A coroutine cancelled before its body starts never reaches finally.
courierStore.cancelSpray(envelope, noiseKey)
}
}
}
}
/**
* Update peer information with verification data
*/
@ -1620,19 +1844,21 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
*/
fun clearAllInternalData() {
Log.w(TAG, "Clearing all mesh service internal data")
try {
// Stop services to cease broadcasting old ID immediately
stopServices()
// Clear all managers
fragmentManager.clearAllFragments()
storeForwardManager.clearAllCache()
securityManager.clearAllData()
peerManager.clearAllPeers()
peerManager.clearAllFingerprints()
try { gossipSyncManager.clear() } catch (_: Exception) { }
} catch (e: Exception) {
Log.e(TAG, "Error clearing mesh service internal data: ${e.message}")
val operations = listOf<() -> Unit>(
::stopServices,
fragmentManager::clearAllFragments,
storeForwardManager::clearAllCache,
securityManager::clearAllData,
peerManager::clearAllPeers,
peerManager::clearAllFingerprints,
courierStore::wipe,
bridgeCourierService::stop,
gossipSyncManager::clear
)
operations.forEach { operation ->
try { operation() } catch (e: Exception) {
Log.e(TAG, "Error clearing mesh service internal data: ${e.message}")
}
}
}

View File

@ -22,6 +22,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.channels.actor
import java.util.ArrayDeque
@ -55,6 +56,7 @@ class BluetoothPacketBroadcaster(
private const val MAX_PENDING_BYTES_PER_LINK = 1_048_576
private const val SEND_RETRY_DELAY_MS = 15L
private const val MAX_CALLBACK_RETRIES = 3
private const val SEND_COMPLETION_TIMEOUT_MS = 30_000L
}
// Optional nickname resolver injected by higher layer (peerID -> nickname?)
@ -140,6 +142,7 @@ class BluetoothPacketBroadcaster(
val gatt: BluetoothGatt? = null,
val gattServer: BluetoothGattServer? = null,
val characteristic: BluetoothGattCharacteristic,
val completion: CompletableDeferred<Boolean>? = null,
var callbackFailures: Int = 0
)
@ -204,6 +207,30 @@ class BluetoothPacketBroadcaster(
}
}
suspend fun sendPacketToPeerAndAwaitAcceptance(
routed: RoutedPacket,
targetPeerID: String,
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
): Boolean {
if (!hasPeerConnection(targetPeerID)) return false
return fragmentingSender.sendAndAwaitAcceptance(routed, "BLE peer ${targetPeerID.take(8)}") { packet ->
sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic)
}
}
suspend fun sendPacketToPeerAndAwaitCompletion(
routed: RoutedPacket,
targetPeerID: String,
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
): Boolean {
if (!hasPeerConnection(targetPeerID)) return false
return fragmentingSender.sendAndAwaitAcceptance(routed, "BLE peer ${targetPeerID.take(8)}") { packet ->
sendSinglePacketToPeerAndAwaitCompletion(packet, targetPeerID, gattServer, characteristic)
}
}
fun sendPacketToLink(
routed: RoutedPacket,
deviceAddress: String,
@ -267,6 +294,35 @@ class BluetoothPacketBroadcaster(
return false
}
private suspend fun sendSinglePacketToPeerAndAwaitCompletion(
routed: RoutedPacket,
targetPeerID: String,
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
): Boolean {
val packet = routed.packet
val data = packet.toBinaryData(
padding = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type)
) ?: return false
val completion = CompletableDeferred<Boolean>()
val serverTarget = connectionTracker.getSubscribedDevices()
.firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID }
val queuedOnServer = serverTarget != null &&
notifyDevice(serverTarget, data, gattServer, characteristic, completion)
val queued = if (queuedOnServer) {
true
} else {
val clientTarget = connectionTracker.getConnectedDevices().values
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
?: return false
writeToDeviceConn(clientTarget, data, completion)
}
if (!queued) return false
return withTimeoutOrNull(SEND_COMPLETION_TIMEOUT_MS) { completion.await() } ?: false
}
/**
* Public entry point for broadcasting - submits request to actor for serialization
@ -474,7 +530,8 @@ class BluetoothPacketBroadcaster(
device: BluetoothDevice,
data: ByteArray,
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
characteristic: BluetoothGattCharacteristic?,
completion: CompletableDeferred<Boolean>? = null
): Boolean {
val server = gattServer ?: return false
val char = characteristic ?: return false
@ -484,7 +541,13 @@ class BluetoothPacketBroadcaster(
?: return false
return enqueueSend(
SendKey(device.address, linkID, SendDirection.SERVER_NOTIFICATION),
PendingSend(data.copyOf(), device, gattServer = server, characteristic = char)
PendingSend(
data.copyOf(),
device,
gattServer = server,
characteristic = char,
completion = completion
)
)
}
@ -493,13 +556,20 @@ class BluetoothPacketBroadcaster(
*/
private fun writeToDeviceConn(
deviceConn: BluetoothConnectionTracker.DeviceConnection,
data: ByteArray
data: ByteArray,
completion: CompletableDeferred<Boolean>? = null
): Boolean {
val gatt = deviceConn.gatt ?: return false
val char = deviceConn.characteristic ?: return false
return enqueueSend(
SendKey(deviceConn.device.address, deviceConn.linkID, SendDirection.CLIENT_WRITE),
PendingSend(data.copyOf(), deviceConn.device, gatt = gatt, characteristic = char)
PendingSend(
data.copyOf(),
deviceConn.device,
gatt = gatt,
characteristic = char,
completion = completion
)
)
}
@ -631,6 +701,7 @@ class BluetoothPacketBroadcaster(
}
state.pending.removeFirst()
state.pendingBytes -= head.data.size
head.completion?.complete(status == BluetoothGatt.GATT_SUCCESS)
if (state.pending.isEmpty()) {
sendStates.remove(key)
false
@ -645,8 +716,13 @@ class BluetoothPacketBroadcaster(
fun onLinkDisconnected(deviceAddress: String, linkID: String?) {
synchronized(sendLock) {
sendStates.keys.removeAll { key ->
key.deviceAddress == deviceAddress && (linkID == null || key.linkID == linkID)
val iterator = sendStates.entries.iterator()
while (iterator.hasNext()) {
val (key, state) = iterator.next()
if (key.deviceAddress == deviceAddress && (linkID == null || key.linkID == linkID)) {
state.pending.forEach { it.completion?.complete(false) }
iterator.remove()
}
}
}
}
@ -667,7 +743,12 @@ class BluetoothPacketBroadcaster(
* Shutdown the broadcaster actor gracefully
*/
fun shutdown() {
synchronized(sendLock) { sendStates.clear() }
synchronized(sendLock) {
sendStates.values.forEach { state ->
state.pending.forEach { it.completion?.complete(false) }
}
sendStates.clear()
}
// Close the actor gracefully
broadcasterActor.close()

View File

@ -0,0 +1,241 @@
package com.bitchat.android.mesh
import android.content.Context
import com.bitchat.android.model.CourierEnvelope
import com.bitchat.android.services.AndroidConversationStorageCipher
import com.bitchat.android.services.ConversationStorageCipher
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.io.File
import java.util.Base64
import java.nio.file.StandardCopyOption
enum class CourierDepositTier { FAVORITE, VERIFIED }
/** Bounded persistent mailbag for opaque envelopes deposited by other peers. */
internal class CourierStore(
context: Context,
private val cipher: ConversationStorageCipher = AndroidConversationStorageCipher(KEY_ALIAS),
private val now: () -> Long = System::currentTimeMillis
) {
private data class Stored(
val encoded: String,
val depositorKey: String,
val tier: CourierDepositTier,
var copies: Int,
val sprayedTo: MutableSet<String> = mutableSetOf(),
var lastRemoteHandoverAtMs: Long = 0
)
private data class SprayReservation(
val envelopeKey: String,
val courierKey: String,
val copies: Int
)
companion object {
private const val KEY_ALIAS = "bitchat_courier_store_v1"
private val AAD = "bitchat-courier-store-v1".toByteArray(Charsets.UTF_8)
private const val MAX_ENVELOPES = 40
private const val MAX_VERIFIED_ENVELOPES = 20
private const val MAX_PER_FAVORITE = 5
private const val MAX_PER_VERIFIED = 2
private const val EXPIRY_SLACK_MS = 60 * 60 * 1000L
private const val REMOTE_HANDOVER_COOLDOWN_MS = 10 * 60 * 1000L
}
private val gson = Gson()
private val file = File(context.applicationContext.filesDir, "courier-store.sealed")
private val stored = load()
private val sprayReservations = mutableListOf<SprayReservation>()
@Synchronized
fun deposit(envelope: CourierEnvelope, depositorNoiseKey: ByteArray, tier: CourierDepositTier): Boolean {
pruneExpired()
val nowMs = now()
if (envelope.expiry.toLong() <= nowMs ||
envelope.expiry.toLong() > nowMs + CourierEnvelope.MAX_LIFETIME_MS + EXPIRY_SLACK_MS
) return false
val encodedBytes = envelope.encode() ?: return false
val encoded = Base64.getEncoder().encodeToString(encodedBytes)
if (stored.any { it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true }) return true
val depositor = depositorNoiseKey.toHex()
val perDepositor = if (tier == CourierDepositTier.FAVORITE) MAX_PER_FAVORITE else MAX_PER_VERIFIED
if (stored.count { it.depositorKey == depositor && it.tier == tier } >= perDepositor) return false
if (tier == CourierDepositTier.VERIFIED && stored.count { it.tier == tier } >= MAX_VERIFIED_ENVELOPES) {
stored.removeAt(stored.indexOfFirst { it.tier == CourierDepositTier.VERIFIED })
}
if (stored.size >= MAX_ENVELOPES) {
val verifiedIndex = stored.indexOfFirst { it.tier == CourierDepositTier.VERIFIED }
if (tier == CourierDepositTier.VERIFIED && verifiedIndex < 0) return false
val index = verifiedIndex.takeIf { it >= 0 } ?: 0
stored.removeAt(index)
}
stored += Stored(encoded, depositor, tier, envelope.copies.toInt())
persist()
return true
}
@Synchronized
fun copiesForRecipient(recipientNoiseKey: ByteArray): List<CourierEnvelope> {
pruneExpired()
val nowMs = now()
return stored.mapNotNull { record ->
record.envelope()
?.takeIf { it.matchesRecipient(recipientNoiseKey, nowMs) }
?.copy(copies = 1u)
}
}
@Synchronized
fun remove(envelope: CourierEnvelope): Boolean {
val envelopeKey = envelope.ciphertext.storageKey()
val removed = stored.removeAll {
it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true
}
if (removed) {
sprayReservations.removeAll { it.envelopeKey == envelopeKey }
persist()
}
return removed
}
@Synchronized
fun copiesForRemoteHandover(recipientNoiseKey: ByteArray): List<CourierEnvelope> {
pruneExpired()
val nowMs = now()
val result = mutableListOf<CourierEnvelope>()
stored.forEach { record ->
val envelope = record.envelope() ?: return@forEach
if (envelope.matchesRecipient(recipientNoiseKey, nowMs) &&
nowMs - record.lastRemoteHandoverAtMs >= REMOTE_HANDOVER_COOLDOWN_MS
) {
record.lastRemoteHandoverAtMs = nowMs
result += envelope.copy(copies = 1u)
}
}
if (result.isNotEmpty()) persist()
return result
}
@Synchronized
fun sprayCopiesFor(courierNoiseKey: ByteArray): List<CourierEnvelope> {
pruneExpired()
val key = courierNoiseKey.toHex()
val nowMs = now()
val courierTags = listOf(-1, 0, 1).map {
CourierEnvelope.recipientTag(courierNoiseKey, CourierEnvelope.epochDay(nowMs) + it.toUInt())
}
val result = mutableListOf<CourierEnvelope>()
stored.forEach { record ->
val envelope = record.envelope() ?: return@forEach
val envelopeKey = envelope.ciphertext.storageKey()
if (record.copies <= 1 || record.depositorKey == key || key in record.sprayedTo ||
sprayReservations.any { it.envelopeKey == envelopeKey && it.courierKey == key } ||
courierTags.any { it.contentEquals(envelope.recipientTag) }
) return@forEach
val reserved = sprayReservations
.filter { it.envelopeKey == envelopeKey }
.sumOf { it.copies }
val available = record.copies - reserved
if (available <= 1) return@forEach
val given = available / 2
sprayReservations += SprayReservation(envelopeKey, key, given)
result += envelope.copy(copies = given.toUByte())
}
return result
}
@Synchronized
fun commitSpray(envelope: CourierEnvelope, courierNoiseKey: ByteArray): Boolean {
val key = courierNoiseKey.toHex()
val envelopeKey = envelope.ciphertext.storageKey()
val reservationIndex = sprayReservations.indexOfFirst {
it.envelopeKey == envelopeKey && it.courierKey == key && it.copies == envelope.copies.toInt()
}
if (reservationIndex < 0) return false
val reservation = sprayReservations[reservationIndex]
val record = stored.firstOrNull {
it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true
}
val otherReservedCopies = sprayReservations.withIndex()
.filter { (index, candidate) -> index != reservationIndex && candidate.envelopeKey == envelopeKey }
.sumOf { it.value.copies }
if (record == null || key in record.sprayedTo ||
record.copies - otherReservedCopies <= reservation.copies
) {
sprayReservations.removeAt(reservationIndex)
return false
}
record.copies -= reservation.copies
record.sprayedTo += key
sprayReservations.removeAt(reservationIndex)
persist()
return true
}
@Synchronized
fun cancelSpray(envelope: CourierEnvelope, courierNoiseKey: ByteArray): Boolean {
val envelopeKey = envelope.ciphertext.storageKey()
val courierKey = courierNoiseKey.toHex()
return sprayReservations.removeAll {
it.envelopeKey == envelopeKey && it.courierKey == courierKey && it.copies == envelope.copies.toInt()
}
}
@Synchronized
fun wipe() {
stored.clear()
sprayReservations.clear()
file.delete()
cipher.destroyKey()
}
private fun pruneExpired() {
val nowMs = now().toULong()
if (stored.removeAll { (it.envelope()?.expiry ?: 0u) <= nowMs }) {
val retainedEnvelopeKeys = stored.mapNotNull { it.envelope()?.ciphertext?.storageKey() }.toSet()
sprayReservations.removeAll { it.envelopeKey !in retainedEnvelopeKeys }
persist()
}
}
private fun Stored.envelope(): CourierEnvelope? = try {
CourierEnvelope.decode(Base64.getDecoder().decode(encoded))
} catch (_: Exception) { null }
private fun load(): MutableList<Stored> = try {
if (!file.exists()) return mutableListOf()
val plaintext = cipher.decrypt(file.readBytes(), AAD)
val type = object : TypeToken<MutableList<Stored>>() {}.type
gson.fromJson<MutableList<Stored>>(plaintext.toString(Charsets.UTF_8), type) ?: mutableListOf()
} catch (_: Exception) { mutableListOf() }
private fun persist() {
if (stored.isEmpty()) {
file.delete()
return
}
val encrypted = cipher.encrypt(gson.toJson(stored).toByteArray(Charsets.UTF_8), AAD)
val temporary = File(file.parentFile, "${file.name}.tmp")
temporary.writeBytes(encrypted)
try {
java.nio.file.Files.move(
temporary.toPath(),
file.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING
)
} catch (_: Exception) {
java.nio.file.Files.move(
temporary.toPath(),
file.toPath(),
StandardCopyOption.REPLACE_EXISTING
)
}
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
private fun ByteArray.storageKey(): String = Base64.getEncoder().encodeToString(this)
}

View File

@ -0,0 +1,20 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.RoutedPacket
/** Accepts courier custody only from the authenticated peer on the current direct link. */
internal object DirectCourierDepositPolicy {
fun accepts(
routed: RoutedPacket,
maxTtl: UByte,
currentLinkID: (String) -> String?,
peerForAddress: (String) -> String?
): Boolean {
val peerID = routed.peerID ?: return false
val relayAddress = routed.relayAddress ?: return false
val ingressLinkID = routed.ingressLinkID ?: return false
return routed.packet.ttl == maxTtl &&
currentLinkID(relayAddress) == ingressLinkID &&
peerForAddress(relayAddress) == peerID
}
}

View File

@ -112,6 +112,23 @@ class FragmentingPacketSender(
return true
}
suspend fun sendAndAwaitAcceptance(
routed: RoutedPacket,
description: String,
sendSingle: suspend (RoutedPacket) -> Boolean
): Boolean {
val packets = packetsForTransport(routed) ?: return false
Log.d(logTag, "Sending ${packets.size} packet(s) for $description")
for ((index, packet) in packets.withIndex()) {
val accepted = sendSingle(
routed.copy(packet = packet, transferId = null, preparedPackets = null)
)
if (!accepted) return false
if (index < packets.lastIndex) delay(interFragmentDelayMs)
}
return true
}
fun cancelTransfer(transferId: String): Boolean {
val job = transferJobs.remove(transferId) ?: return false
job.cancel()

View File

@ -468,6 +468,8 @@ class MeshCore(
return runBlocking { messageHandler.handleNoiseEncrypted(routed) }
}
override fun handleCourierEnvelope(routed: RoutedPacket): Boolean = false
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
val result = messageHandler.handleAnnounceWithResult(routed)
if (result !is AnnounceHandlingResult.Accepted) return false
@ -481,7 +483,7 @@ class MeshCore(
try {
val pkt = routed.packet
val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST))
if (isBroadcast && pkt.type == MessageType.MESSAGE.value) {
if (isBroadcast && pkt.type in setOf(MessageType.MESSAGE.value, MessageType.FILE_TRANSFER.value)) {
gossipSyncManager.onPublicPacketSeen(pkt)
}
} catch (_: Exception) { }

View File

@ -41,6 +41,19 @@ interface MeshService {
fun initiateNoiseHandshake(peerID: String)
fun getPeerFingerprint(peerID: String): String?
fun getPeerInfo(peerID: String): PeerInfo?
fun getPeerInfos(): List<PeerInfo> = getPeerNicknames().keys.mapNotNull(::getPeerInfo)
fun sendCourierMessage(
content: String,
messageID: String,
recipientNoiseKey: ByteArray,
courierPeerIDs: List<String>
): List<String> = emptyList()
fun sendBridgeCourierMessage(
content: String,
messageID: String,
recipientNoiseKey: ByteArray,
onAccepted: () -> Unit = {}
): Boolean = false
fun updatePeerInfo(
peerID: String,
nickname: String,

View File

@ -207,6 +207,32 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
return true
}
/** Admit an already authenticated Noise X payload through the normal private-message path. */
suspend fun handleOpenedCourierPayload(routed: RoutedPacket): Boolean {
val packet = routed.packet
val peerID = routed.peerID ?: return false
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return false
if (noisePayload.type == com.bitchat.android.model.NoisePayloadType.DELIVERED) {
val messageID = noisePayload.data.toString(Charsets.UTF_8)
if (messageID.isBlank()) return false
delegate?.onDeliveryAckReceived(messageID, peerID)
return true
}
if (noisePayload.type != com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) return false
val privateMessage = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) ?: return false
val message = BitchatMessage(
id = privateMessage.messageID,
sender = delegate?.getPeerNickname(peerID) ?: "Unknown",
content = privateMessage.content,
timestamp = Date(packet.timestamp.toLong()),
isPrivate = true,
recipientNickname = delegate?.getMyNickname(),
senderPeerID = peerID
)
delegate?.onMessageReceived(message)
return true
}
/**
* Count consecutive decrypt failures from a signature-verified peer that we still hold an
* established session for. After repeated failures the session is stale (the peer completed
@ -453,6 +479,12 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
private suspend fun handleBroadcastMessage(routed: RoutedPacket) {
val packet = routed.packet
val peerID = routed.peerID ?: "unknown"
if (packet.timestamp > Long.MAX_VALUE.toULong()) return
val ageMs = System.currentTimeMillis() - packet.timestamp.toLong()
if (ageMs !in
-com.bitchat.android.sync.GossipSyncManager.PUBLIC_PACKET_FUTURE_SKEW_MS..
com.bitchat.android.sync.GossipSyncManager.PUBLIC_MESSAGE_MAX_AGE_MS
) return
// Enforce: only accept public messages from verified peers we know
val peerInfo = delegate?.getPeerInfo(peerID)

View File

@ -122,6 +122,22 @@ class PacketProcessor(private val myPeerID: String) {
var validPacket = true
val messageType = MessageType.fromValue(packet.type)
val isBroadcast = packet.recipientID == null ||
packet.recipientID.contentEquals(com.bitchat.android.protocol.SpecialRecipients.BROADCAST)
if (isBroadcast && packet.timestamp <= Long.MAX_VALUE.toULong()) {
val ageMs = System.currentTimeMillis() - packet.timestamp.toLong()
val maxAgeMs = when (messageType) {
MessageType.MESSAGE -> com.bitchat.android.sync.GossipSyncManager.PUBLIC_MESSAGE_MAX_AGE_MS
MessageType.FRAGMENT, MessageType.FILE_TRANSFER ->
com.bitchat.android.sync.GossipSyncManager.FRAGMENT_MAX_AGE_MS
else -> null
}
if (maxAgeMs != null && ageMs !in
-com.bitchat.android.sync.GossipSyncManager.PUBLIC_PACKET_FUTURE_SKEW_MS..maxAgeMs
) return
} else if (isBroadcast && packet.timestamp > Long.MAX_VALUE.toULong()) {
return
}
// Verbose logging to debug manager (and chat via ChatViewModel observer)
try {
val mt = messageType?.name ?: packet.type.toString()
@ -146,6 +162,7 @@ class PacketProcessor(private val myPeerID: String) {
when (messageType) {
MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed)
MessageType.NOISE_ENCRYPTED -> validPacket = handleNoiseEncrypted(routed)
MessageType.COURIER_ENVELOPE -> validPacket = delegate?.handleCourierEnvelope(routed) ?: false
MessageType.FILE_TRANSFER -> handleMessage(routed)
else -> {
validPacket = false
@ -295,6 +312,7 @@ interface PacketProcessorDelegate {
// Message type handlers
fun handleNoiseHandshake(routed: RoutedPacket): Boolean
fun handleNoiseEncrypted(routed: RoutedPacket): Boolean
fun handleCourierEnvelope(routed: RoutedPacket): Boolean = false
suspend fun handleAnnounce(routed: RoutedPacket): Boolean
fun handleMessage(routed: RoutedPacket)
fun handleVoiceFrame(routed: RoutedPacket): Boolean = false

View File

@ -3,7 +3,6 @@ package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.sync.PacketIdUtil
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.AuthenticatedNoiseSession
@ -239,9 +238,21 @@ class SecurityManager(private val encryptionService: EncryptionService, private
return encryptionService.getCombinedPublicKeyData()
}
/** Deduplicates by peer and a hash of packet type, sender, timestamp, and full payload. */
/**
* Generate message ID for duplicate detection
*/
private fun generateMessageID(packet: BitchatPacket, peerID: String): String {
return "$peerID-${PacketIdUtil.computeIdHex(packet)}"
return when (MessageType.fromValue(packet.type)) {
MessageType.FRAGMENT -> {
// For fragments, include the payload hash to distinguish different fragments
"${packet.timestamp}-$peerID-${packet.type}-${packet.payload.contentHashCode()}"
}
else -> {
// For other messages, use a truncated payload hash
val payloadHash = packet.payload.sliceArray(0 until minOf(64, packet.payload.size)).contentHashCode()
"${packet.timestamp}-$peerID-$payloadHash"
}
}
}
/**
@ -257,6 +268,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
MessageType.ANNOUNCE,
MessageType.MESSAGE,
MessageType.FILE_TRANSFER,
MessageType.COURIER_ENVELOPE,
MessageType.VOICE_FRAME,
MessageType.LEAVE
)) {

View File

@ -116,6 +116,22 @@ class UnifiedMeshService(
}
}
override fun getPeerInfos(): List<PeerInfo> = bluetooth.getPeerInfos()
override fun sendCourierMessage(
content: String,
messageID: String,
recipientNoiseKey: ByteArray,
courierPeerIDs: List<String>
): List<String> = bluetooth.sendCourierMessage(content, messageID, recipientNoiseKey, courierPeerIDs)
override fun sendBridgeCourierMessage(
content: String,
messageID: String,
recipientNoiseKey: ByteArray,
onAccepted: () -> Unit
): Boolean = bluetooth.sendBridgeCourierMessage(content, messageID, recipientNoiseKey, onAccepted)
override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
when {
isBleReady(recipientPeerID) -> bluetooth.sendReadReceipt(messageID, recipientPeerID, readerNickname)

View File

@ -22,6 +22,9 @@ sealed class DeliveryStatus : Parcelable {
@Parcelize
object Sending : DeliveryStatus()
@Parcelize
object Queued : DeliveryStatus()
@Parcelize
object Sent : DeliveryStatus()
@ -40,6 +43,7 @@ sealed class DeliveryStatus : Parcelable {
fun getDisplayText(): String {
return when (this) {
is Sending -> "Sending..."
is Queued -> "Queued"
is Sent -> "Sent"
is Delivered -> "Delivered to ${this.to}"
is Read -> "Read by ${this.by}"
@ -362,4 +366,3 @@ data class BitchatMessage(
return result
}
}

View File

@ -0,0 +1,115 @@
package com.bitchat.android.model
import java.nio.ByteBuffer
import java.nio.ByteOrder
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/** Opaque store-and-forward envelope, wire-compatible with iOS CourierEnvelope. */
data class CourierEnvelope(
val recipientTag: ByteArray,
val expiry: ULong,
val ciphertext: ByteArray,
val copies: UByte = 1u,
val prekeyID: UInt? = null
) {
companion object {
const val TAG_LENGTH = 16
const val MAX_CIPHERTEXT_BYTES = 16 * 1024
const val MAX_LIFETIME_MS = 24 * 60 * 60 * 1000L
const val MAX_COPIES = 8
private val TAG_DOMAIN = "bitchat-courier-tag-v1".toByteArray(Charsets.UTF_8)
fun epochDay(nowMs: Long): UInt = Math.floorDiv(nowMs, 86_400_000L).toUInt()
fun recipientTag(noiseStaticKey: ByteArray, epochDay: UInt): ByteArray {
require(noiseStaticKey.size == 32)
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(noiseStaticKey, "HmacSHA256"))
mac.update(TAG_DOMAIN)
mac.update(ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(epochDay.toInt()).array())
return mac.doFinal().copyOf(TAG_LENGTH)
}
fun decode(data: ByteArray): CourierEnvelope? {
var offset = 0
var tag: ByteArray? = null
var expiry: ULong? = null
var ciphertext: ByteArray? = null
var copies: UByte = 1u
var prekeyID: UInt? = null
while (offset + 3 <= data.size) {
val type = data[offset].toUByte()
val length = ((data[offset + 1].toInt() and 0xff) shl 8) or
(data[offset + 2].toInt() and 0xff)
offset += 3
if (offset + length > data.size) return null
val value = data.copyOfRange(offset, offset + length)
offset += length
when (type.toInt()) {
1 -> if (length == TAG_LENGTH && tag == null) tag = value else return null
2 -> if (length == 8 && expiry == null) expiry = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).long.toULong() else return null
3 -> if (ciphertext == null) ciphertext = value else return null
4 -> {
if (length != 1 || copies != 1u.toUByte()) return null
copies = value[0].toUByte()
if (copies !in 2u.toUByte()..MAX_COPIES.toUByte()) return null
}
5 -> if (length == 4 && prekeyID == null) {
prekeyID = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).int.toUInt()
} else {
return null
}
}
}
if (offset != data.size) return null
val requiredTag = tag ?: return null
val requiredExpiry = expiry ?: return null
val requiredCiphertext = ciphertext ?: return null
if (requiredCiphertext.isEmpty() || requiredCiphertext.size > MAX_CIPHERTEXT_BYTES) return null
return CourierEnvelope(requiredTag, requiredExpiry, requiredCiphertext, copies, prekeyID)
}
}
fun encode(): ByteArray? {
if (recipientTag.size != TAG_LENGTH || ciphertext.isEmpty() || ciphertext.size > MAX_CIPHERTEXT_BYTES ||
copies !in 1u.toUByte()..MAX_COPIES.toUByte()
) return null
val fields = mutableListOf<Pair<Int, ByteArray>>()
fields += 1 to recipientTag
fields += 2 to ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN).putLong(expiry.toLong()).array()
fields += 3 to ciphertext
if (copies > 1u) fields += 4 to byteArrayOf(copies.toByte())
prekeyID?.let {
fields += 5 to ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(it.toInt()).array()
}
val size = fields.sumOf { 3 + it.second.size }
val buffer = ByteBuffer.allocate(size).order(ByteOrder.BIG_ENDIAN)
fields.forEach { (type, value) ->
buffer.put(type.toByte())
buffer.putShort(value.size.toShort())
buffer.put(value)
}
return buffer.array()
}
fun matchesRecipient(noiseStaticKey: ByteArray, nowMs: Long): Boolean {
val day = epochDay(nowMs)
return listOf(day - 1u, day, day + 1u).any {
recipientTag.contentEquals(recipientTag(noiseStaticKey, it))
}
}
override fun equals(other: Any?): Boolean = other is CourierEnvelope &&
recipientTag.contentEquals(other.recipientTag) && expiry == other.expiry &&
ciphertext.contentEquals(other.ciphertext) && copies == other.copies && prekeyID == other.prekeyID
override fun hashCode(): Int {
var result = recipientTag.contentHashCode()
result = 31 * result + expiry.hashCode()
result = 31 * result + ciphertext.contentHashCode()
result = 31 * result + copies.hashCode()
result = 31 * result + (prekeyID?.hashCode() ?: 0)
return result
}
}

View File

@ -29,23 +29,9 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
companion object {
val NONE = PeerCapabilities(0)
val PREKEYS = PeerCapabilities(1L shl 0)
val WIFI_BULK = PeerCapabilities(1L shl 1)
val GATEWAY = PeerCapabilities(1L shl 2)
val GROUPS = PeerCapabilities(1L shl 3)
val BOARD = PeerCapabilities(1L shl 4)
val VOUCH = PeerCapabilities(1L shl 5)
val MESH_DIAGNOSTICS = PeerCapabilities(1L shl 6)
val BRIDGE = PeerCapabilities(1L shl 7)
/** Noise-encrypted private BitchatFilePacket using payload type 0x20. */
val PRIVATE_MEDIA = PeerCapabilities(1L shl 8)
val PRIVATE_MEDIA_RECEIPTS = PeerCapabilities(1L shl 9)
/** Reserved by iOS; decode it but do not advertise or act on it. */
val NON_DESTRUCTIVE_NOISE_REPLACEMENT = PeerCapabilities(1L shl 10)
/** Capabilities implemented by this Android build. */
val LOCAL_SUPPORTED = PRIVATE_MEDIA

View File

@ -1,6 +1,8 @@
package com.bitchat.android.model
import com.bitchat.android.sync.SyncDefaults
import com.bitchat.android.sync.GCSFilter
import com.bitchat.android.sync.SyncTypeFlags
/**
* REQUEST_SYNC payload using GCS (Golomb-Coded Set) parameters.
@ -8,11 +10,15 @@ import com.bitchat.android.sync.SyncDefaults
* - 0x01: P (uint8) — Golomb-Rice parameter
* - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
* - 0x03: data (opaque) — GR bitstream bytes
* - 0x04: types (compact little-endian SyncTypeFlags) — packet types covered by the filter
* - 0x05: sinceTimestamp (uint64, big-endian) — oldest timestamp covered by the filter
*/
data class RequestSyncPacket(
val p: Int,
val m: Long,
val data: ByteArray
val data: ByteArray,
val types: SyncTypeFlags? = null,
val sinceTimestamp: ULong? = null
) {
fun encode(): ByteArray {
val out = ArrayList<Byte>()
@ -38,6 +44,15 @@ data class RequestSyncPacket(
)
// data
putTLV(0x03, data)
types?.encode()?.let { putTLV(0x04, it) }
sinceTimestamp?.let { timestamp ->
putTLV(
0x05,
ByteArray(8) { index ->
(timestamp shr ((7 - index) * 8) and 0xffu).toByte()
}
)
}
return out.toByteArray()
}
@ -50,6 +65,8 @@ data class RequestSyncPacket(
var p: Int? = null
var m: Long? = null
var payload: ByteArray? = null
var types: SyncTypeFlags? = null
var sinceTimestamp: ULong? = null
while (off + 3 <= data.size) {
val t = (data[off].toInt() and 0xFF); off += 1
@ -69,14 +86,20 @@ data class RequestSyncPacket(
if (v.size > MAX_ACCEPT_FILTER_BYTES) return null
payload = v
}
0x04 -> SyncTypeFlags.decode(v)?.let { types = it }
0x05 -> if (v.size == 8) {
var timestamp = 0uL
v.forEach { byte -> timestamp = (timestamp shl 8) or byte.toUByte().toULong() }
sinceTimestamp = timestamp
}
}
}
val pp = p ?: return null
val mm = m ?: return null
val dd = payload ?: return null
if (pp < 1 || mm <= 0L) return null
return RequestSyncPacket(pp, mm, dd)
if (pp !in 1..GCSFilter.MAX_P || mm <= 0L) return null
return RequestSyncPacket(pp, mm, dd, types, sinceTimestamp)
}
}
}

View File

@ -5,6 +5,7 @@ import android.util.Log
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.PeerFingerprintManager
import com.bitchat.android.noise.southernstorm.protocol.Noise
import com.bitchat.android.noise.southernstorm.protocol.HandshakeState
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.concurrent.ConcurrentHashMap
@ -18,7 +19,10 @@ import java.util.concurrent.ConcurrentHashMap
* - Channel encryption using password-derived keys
* - Peer fingerprint mapping and identity persistence
*/
class NoiseEncryptionService(private val context: Context) {
class NoiseEncryptionService(
private val context: Context,
private val identityStateManager: SecureIdentityStateManager = SecureIdentityStateManager(context)
) {
companion object {
private const val TAG = "NoiseEncryptionService"
@ -42,9 +46,6 @@ class NoiseEncryptionService(private val context: Context) {
// Channel encryption for password-protected channels
private val channelEncryption = NoiseChannelEncryption()
// Identity management for peer ID rotation support
private val identityStateManager: SecureIdentityStateManager
// Centralized fingerprint management - NO LOCAL STORAGE
private val fingerprintManager = PeerFingerprintManager.getInstance()
@ -53,9 +54,6 @@ class NoiseEncryptionService(private val context: Context) {
var onHandshakeRequired: ((String) -> Unit)? = null // peerID needs handshake
init {
// Initialize identity state manager for persistent storage
identityStateManager = SecureIdentityStateManager(context)
// Load or create keys - temporary placeholders
staticIdentityPrivateKey = ByteArray(32)
staticIdentityPublicKey = ByteArray(32)
@ -147,6 +145,40 @@ class NoiseEncryptionService(private val context: Context) {
return sessionManager.getRemoteStaticKey(peerID)
}
fun sealCourierPayload(payload: ByteArray, recipientStaticKey: ByteArray): ByteArray {
require(recipientStaticKey.size == 32 && recipientStaticKey.any { it != 0.toByte() })
val state = HandshakeState("Noise_X_25519_ChaChaPoly_SHA256", HandshakeState.INITIATOR)
return try {
state.getLocalKeyPair().setPrivateKey(staticIdentityPrivateKey, 0)
state.getRemotePublicKey().setPublicKey(recipientStaticKey, 0)
val prologue = "bitchat-courier-v1".toByteArray(Charsets.UTF_8)
state.setPrologue(prologue, 0, prologue.size)
state.start()
val output = ByteArray(payload.size + 128)
output.copyOf(state.writeMessage(output, 0, payload, 0, payload.size))
} finally {
state.destroy()
}
}
fun openCourierPayload(ciphertext: ByteArray): Pair<ByteArray, ByteArray> {
require(ciphertext.size >= 96)
val state = HandshakeState("Noise_X_25519_ChaChaPoly_SHA256", HandshakeState.RESPONDER)
return try {
state.getLocalKeyPair().setPrivateKey(staticIdentityPrivateKey, 0)
val prologue = "bitchat-courier-v1".toByteArray(Charsets.UTF_8)
state.setPrologue(prologue, 0, prologue.size)
state.start()
val payload = ByteArray(ciphertext.size)
val length = state.readMessage(ciphertext, 0, ciphertext.size, payload, 0)
val senderKey = ByteArray(32)
state.getRemotePublicKey().getPublicKey(senderKey, 0)
senderKey to payload.copyOf(length)
} finally {
state.destroy()
}
}
fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? =
sessionManager.getAuthenticatedSession(peerID)

View File

@ -0,0 +1,235 @@
package com.bitchat.android.nostr
import android.content.Context
import android.util.Base64
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.model.CourierEnvelope
import java.util.Collections
import java.util.LinkedHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Minimal relay surface used by the bridge courier.
*
* Keeping this adapter boundary small lets the kind-1401 contract be exercised with a
* deterministic in-memory relay in unit tests, without opening a network connection.
*/
internal interface BridgeCourierRelay {
fun subscribe(
filter: NostrFilter,
id: String,
targetRelayUrls: List<String>,
handler: (NostrEvent) -> Unit
)
fun unsubscribe(id: String)
fun hasConnectedRelay(relayUrls: Collection<String>): Boolean
fun sendEvent(
event: NostrEvent,
relayUrls: List<String>,
onAccepted: () -> Unit
): Boolean
}
internal interface BridgeCourierCipher {
fun staticPublicKey(): ByteArray?
fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray
}
private class NostrBridgeCourierRelay(
private val relayManager: NostrRelayManager
) : BridgeCourierRelay {
override fun subscribe(
filter: NostrFilter,
id: String,
targetRelayUrls: List<String>,
handler: (NostrEvent) -> Unit
) {
relayManager.subscribe(
filter = filter,
id = id,
targetRelayUrls = targetRelayUrls,
handler = handler
)
}
override fun unsubscribe(id: String) {
relayManager.unsubscribe(id)
}
override fun hasConnectedRelay(relayUrls: Collection<String>): Boolean =
relayManager.hasConnectedRelay(relayUrls)
override fun sendEvent(
event: NostrEvent,
relayUrls: List<String>,
onAccepted: () -> Unit
): Boolean = relayManager.sendEvent(event, relayUrls, onAccepted = onAccepted)
}
private class EncryptionBridgeCourierCipher(
private val encryptionService: EncryptionService
) : BridgeCourierCipher {
override fun staticPublicKey(): ByteArray? = encryptionService.getStaticPublicKey()
override fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray =
encryptionService.sealCourierPayload(payload, recipientNoiseKey)
}
/** Parks opaque courier envelopes on default Nostr relays using the iOS kind-1401 contract. */
class BridgeCourierService internal constructor(
private val cipher: BridgeCourierCipher,
private val onEnvelope: (CourierEnvelope) -> Unit,
private val relayManager: BridgeCourierRelay,
private val relayUrls: List<String> = NostrRelayManager.defaultRelays(),
private val clock: () -> Long = System::currentTimeMillis,
private val identityFactory: () -> NostrIdentity = NostrIdentity::generate
) {
constructor(
context: Context,
encryptionService: EncryptionService,
onEnvelope: (CourierEnvelope) -> Unit
) : this(
cipher = EncryptionBridgeCourierCipher(encryptionService),
onEnvelope = onEnvelope,
relayManager = NostrBridgeCourierRelay(
NostrRelayManager.getInstance(context.applicationContext)
)
)
companion object {
private const val KIND = 1401
private const val MAX_ENCODED_BYTES = 20 * 1024
private const val TAG_REFRESH_INTERVAL_MS = 60 * 60 * 1000L
}
private val seenEvents = Collections.synchronizedMap(
object : LinkedHashMap<String, Unit>(512, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Unit>?) = size > 512
}
)
private val subscriptionID = "bridge-courier-drops-${System.identityHashCode(this)}"
@Volatile private var subscribedDay: UInt? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var refreshJob: Job? = null
val isStarted: Boolean get() = subscribedDay != null
@Synchronized
fun start() {
val localKey = cipher.staticPublicKey() ?: ByteArray(0)
if (localKey.size == 32) {
val now = clock()
val day = CourierEnvelope.epochDay(now)
if (subscribedDay == day) return
if (subscribedDay != null) relayManager.unsubscribe(subscriptionID)
val tags = listOf(day - 1u, day, day + 1u).map {
CourierEnvelope.recipientTag(localKey, it).toHex()
}
val filter = NostrFilter.Builder()
.kinds(KIND)
// Builder.since accepts milliseconds and serializes seconds for Nostr relays.
.since(now - CourierEnvelope.MAX_LIFETIME_MS)
.limit(100)
.tag("x", *tags.toTypedArray())
.build()
relayManager.subscribe(
filter = filter,
id = subscriptionID,
targetRelayUrls = relayUrls,
handler = ::handleEvent
)
subscribedDay = day
if (refreshJob?.isActive != true) {
refreshJob = scope.launch {
while (isActive) {
delay(TAG_REFRESH_INTERVAL_MS)
start()
}
}
}
}
}
fun deposit(
content: String,
messageID: String,
recipientNoiseKey: ByteArray,
onAccepted: () -> Unit = {}
): Boolean {
val privateMessage = com.bitchat.android.model.PrivateMessagePacket(messageID, content).encode() ?: return false
val typed = com.bitchat.android.model.NoisePayload(
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE,
privateMessage
).encode()
return depositPayload(typed, recipientNoiseKey, onAccepted)
}
fun depositPayload(
typedPayload: ByteArray,
recipientNoiseKey: ByteArray,
onAccepted: () -> Unit = {}
): Boolean {
start()
if (!relayManager.hasConnectedRelay(relayUrls)) return false
val sealed = try { cipher.seal(typedPayload, recipientNoiseKey) } catch (_: Exception) { return false }
val now = clock()
val envelope = CourierEnvelope(
recipientTag = CourierEnvelope.recipientTag(recipientNoiseKey, CourierEnvelope.epochDay(now)),
expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(),
ciphertext = sealed,
copies = 1u
)
val encoded = envelope.encode() ?: return false
if (encoded.size > MAX_ENCODED_BYTES) return false
val identity = try { identityFactory() } catch (_: Exception) { return false }
val event = identity.signEvent(
NostrEvent(
pubkey = identity.publicKeyHex,
createdAt = (now / 1000).toInt(),
kind = KIND,
tags = listOf(
listOf("x", envelope.recipientTag.toHex()),
listOf("expiration", (envelope.expiry / 1000u).toString())
),
content = Base64.encodeToString(encoded, Base64.NO_WRAP)
)
)
return relayManager.sendEvent(event, relayUrls, onAccepted)
}
@Synchronized
fun stop() {
relayManager.unsubscribe(subscriptionID)
subscribedDay = null
refreshJob?.cancel()
refreshJob = null
}
private fun handleEvent(event: NostrEvent) {
if (event.kind != KIND || !event.isValidSignature()) return
synchronized(seenEvents) { if (seenEvents.put(event.id, Unit) != null) return }
if (event.content.length > ((MAX_ENCODED_BYTES + 2) / 3) * 4) return
val encoded = try { Base64.decode(event.content, Base64.DEFAULT) } catch (_: Exception) { return }
if (encoded.size > MAX_ENCODED_BYTES) return
val envelope = CourierEnvelope.decode(encoded) ?: return
val now = clock()
if (envelope.expiry.toLong() <= now || envelope.expiry.toLong() > now + CourierEnvelope.MAX_LIFETIME_MS + 60 * 60 * 1000L) return
val eventTag = event.tags.firstOrNull { it.size > 1 && it[0] == "x" }?.get(1) ?: return
val expiration = event.tags.firstOrNull { it.size > 1 && it[0] == "expiration" }?.get(1)?.toULongOrNull() ?: return
if (eventTag != envelope.recipientTag.toHex() || expiration != envelope.expiry / 1000u) return
val localKey = cipher.staticPublicKey() ?: return
if (!envelope.matchesRecipient(localKey, now)) return
onEnvelope(envelope)
}
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
}

View File

@ -416,11 +416,6 @@ class LocationNotesManager private constructor() {
Log.v(TAG, "Ignoring non-text-note event: kind=${event.kind}")
return
}
if (!event.isValidSignature()) {
Log.w(TAG, "Rejecting note ${event.id.take(8)}... with invalid signature")
return
}
// Check for geohash tag
val geohashTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }

View File

@ -282,11 +282,6 @@ class NostrClient private constructor(private val context: Context) {
handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit
) {
try {
if (!event.isValidSignature()) {
Log.w(TAG, "🚫 Rejecting geohash event ${event.id.take(8)}... with invalid signature")
return
}
// Check Proof of Work validation for incoming geohash events
val powSettings = PoWPreferenceManager.getCurrentSettings()
if (powSettings.enabled && powSettings.difficulty > 0) {

View File

@ -21,9 +21,7 @@ import java.math.BigInteger
* Includes secp256k1 operations, ECDH, and NIP-44 encryption
*/
object NostrCrypto {
internal const val NIP17_DEFAULT_MAX_PAST_SECONDS = 79_200
private val secureRandom = SecureRandom()
// NIP-44 v2 only
@ -319,9 +317,9 @@ object NostrCrypto {
}
/**
* Random timestamp in the past, defaulting to 22 hours to leave 2 hours of slack inside iOS's 24-hour lookback.
* Random timestamp up to maxPastSeconds in the past (default 2 days)
*/
fun randomizeTimestampUpToPast(maxPastSeconds: Int = NIP17_DEFAULT_MAX_PAST_SECONDS): Int {
fun randomizeTimestampUpToPast(maxPastSeconds: Int = 172800): Int {
val now = (System.currentTimeMillis() / 1000).toInt()
val offset = if (maxPastSeconds > 0) secureRandom.nextInt(maxPastSeconds + 1) else 0
return now - offset

View File

@ -188,6 +188,7 @@ class NostrDirectMessageHandler(
}
NoisePayloadType.DELIVERED -> {
val messageId = String(payload.data, Charsets.UTF_8)
com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageId, conversationID)
withContext(Dispatchers.Main) {
updateDeliveryStatus(
messageId,
@ -197,6 +198,7 @@ class NostrDirectMessageHandler(
}
NoisePayloadType.READ_RECEIPT -> {
val messageId = String(payload.data, Charsets.UTF_8)
com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageId, conversationID)
withContext(Dispatchers.Main) {
updateDeliveryStatus(
messageId,

View File

@ -214,6 +214,7 @@ object NostrKind {
const val FILE_MESSAGE = 15 // NIP-17 file message (unsigned)
const val SEAL = 13 // NIP-17 sealed event
const val GIFT_WRAP = 1059 // NIP-17 gift wrap
const val COURIER_DROP = 1401 // Opaque bridge courier envelope
const val EPHEMERAL_EVENT = 20000 // For geohash channels
const val GEOHASH_PRESENCE = 20001 // For geohash presence heartbeat
}

View File

@ -37,7 +37,7 @@ object NostrProtocol {
val rumorId = rumorBase.computeEventIdHex()
val rumor = rumorBase.copy(id = rumorId)
// 2. Seal the rumor with 2 hours of slack inside iOS's 24-hour lookback.
// 2. Seal the rumor (kind 13) signed by sender, timestamp randomized up to 2 days
val sealedEvent = createSeal(
rumor = rumor,
recipientPubkey = recipientPubkey,

View File

@ -14,6 +14,8 @@ import okhttp3.*
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.min
import kotlin.math.pow
/**
* Manages WebSocket connections to Nostr relays
@ -46,8 +48,12 @@ class NostrRelayManager private constructor() {
"wss://nostr21.com"
)
// Reconnect backoff lives in RelayReconnectPolicy.
// Exponential backoff configuration (same as iOS)
private const val INITIAL_BACKOFF_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.INITIAL_BACKOFF_INTERVAL_MS // 1 second
private const val MAX_BACKOFF_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.MAX_BACKOFF_INTERVAL_MS // 5 minutes
private const val BACKOFF_MULTIPLIER = com.bitchat.android.util.AppConstants.Nostr.BACKOFF_MULTIPLIER
private const val MAX_RECONNECT_ATTEMPTS = com.bitchat.android.util.AppConstants.Nostr.MAX_RECONNECT_ATTEMPTS
// Track gift-wraps we initiated for logging
private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet<String>()
@ -79,6 +85,11 @@ class NostrRelayManager private constructor() {
private val _isConnected = MutableStateFlow<Boolean>(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private data class PendingAcceptance(
val callback: () -> Unit,
val pendingRelays: MutableSet<String>
)
private val eventAcceptanceHandlers = java.util.concurrent.ConcurrentHashMap<String, PendingAcceptance>()
// Internal state
private val relaysList = mutableListOf<Relay>()
@ -446,12 +457,14 @@ class NostrRelayManager private constructor() {
fun sendEvent(
event: NostrEvent,
relayUrls: List<String>? = null,
liveLocationToken: Long? = null
) {
liveLocationToken: Long? = null,
onAccepted: (() -> Unit)? = null
): Boolean {
val targetRelays = (relayUrls ?: relaysList.map { it.url })
.filter { it.isNotBlank() }
.distinct()
if (targetRelays.isEmpty()) return
if (targetRelays.isEmpty()) return false
var enqueued = false
val queued = runNetworkAction(liveLocationToken) {
val queueId = messageQueue.enqueue(
@ -459,6 +472,13 @@ class NostrRelayManager private constructor() {
relayUrls = targetRelays,
liveLocationToken = liveLocationToken
) ?: return@runNetworkAction
enqueued = true
if (onAccepted != null) {
eventAcceptanceHandlers[event.id] = PendingAcceptance(
onAccepted,
targetRelays.map { it.trim().trimEnd('/') }.toMutableSet()
)
}
scope.launch {
if (!isNetworkActionAllowed(liveLocationToken)) return@launch
targetRelays.forEach { relayUrl ->
@ -471,7 +491,12 @@ class NostrRelayManager private constructor() {
}
}
}
if (!queued) return
return queued && enqueued
}
fun hasConnectedRelay(relayUrls: Collection<String>): Boolean {
val targets = relayUrls.map { it.trim().trimEnd('/') }.toSet()
return relaysList.any { it.isConnected && it.url.trim().trimEnd('/') in targets }
}
/**
@ -667,6 +692,7 @@ class NostrRelayManager private constructor() {
// Clear any queued messages waiting to be sent
messageQueue.clear()
eventAcceptanceHandlers.clear()
Log.i(TAG, "Cleared all Nostr subscriptions and routing caches")
} catch (e: Exception) {
@ -960,8 +986,14 @@ class NostrRelayManager private constructor() {
is NostrResponse.Ok -> {
val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId)
if (!response.accepted) {
eventAcceptanceHandlers[response.eventId]?.let { pending ->
pending.pendingRelays.remove(relayUrl.trim().trimEnd('/'))
if (pending.pendingRelays.isEmpty()) eventAcceptanceHandlers.remove(response.eventId, pending)
}
val level = if (wasGiftWrap) Log.WARN else Log.ERROR
Log.println(level, TAG, "Event rejected by relay: ${response.message ?: "no reason"}")
} else {
eventAcceptanceHandlers.remove(response.eventId)?.callback?.invoke()
}
}
@ -1012,15 +1044,36 @@ class NostrRelayManager private constructor() {
if (!desiredConnected.get() ||
!isNetworkActionAllowed(connectionToken)
) return
// Every failure backs off and retries, including a name-resolution
// failure: "unable to resolve host" is what this device reports when it
// simply has no network, so treating it as permanent turns a walk
// through a tunnel into a dead relay layer for the rest of the process.
// Check if this is a DNS error
val errorMessage = error.message?.lowercase() ?: ""
if (errorMessage.contains("hostname could not be found") ||
errorMessage.contains("dns") ||
errorMessage.contains("unable to resolve host")) {
val relay = relaysList.find { it.url == relayUrl }
if (relay?.lastError == null) {
Log.w(TAG, "Nostr relay DNS failure; not retrying")
}
return
}
// Implement exponential backoff for non-DNS errors
val relay = relaysList.find { it.url == relayUrl } ?: return
relay.reconnectAttempts = RelayReconnectPolicy.nextAttempt(relay.reconnectAttempts)
val backoffInterval = RelayReconnectPolicy.backoffMs(relay.reconnectAttempts)
relay.reconnectAttempts++
// Stop attempting after max attempts
if (relay.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
Log.w(TAG, "Max Nostr relay reconnection attempts reached")
return
}
// Calculate backoff interval
val backoffInterval = min(
INITIAL_BACKOFF_INTERVAL * BACKOFF_MULTIPLIER.pow(relay.reconnectAttempts - 1.0),
MAX_BACKOFF_INTERVAL.toDouble()
).toLong()
relay.nextReconnectTime = System.currentTimeMillis() + backoffInterval
Log.d(TAG, "Scheduling Nostr relay reconnection")

View File

@ -46,6 +46,11 @@ class NostrTransport(
// MARK: - Transport Interface Methods
val myPeerID: String get() = senderPeerID
fun canDeliverPromptly(): Boolean = try {
NostrRelayManager.getInstance(context)
.hasConnectedRelay(NostrRelayManager.defaultRelays())
} catch (_: Exception) { false }
fun sendPrivateMessage(
content: String,

View File

@ -1,7 +1,6 @@
package com.bitchat.android.protocol
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.nio.ByteBuffer
import java.nio.ByteOrder
@ -14,6 +13,7 @@ enum class MessageType(val value: UByte) {
ANNOUNCE(0x01u),
MESSAGE(0x02u), // All user messages (private and broadcast)
LEAVE(0x03u),
COURIER_ENVELOPE(0x04u), // Opaque Noise X store-and-forward envelope
NOISE_HANDSHAKE(0x10u), // Noise handshake
NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message
FRAGMENT(0x20u), // Fragmentation for large packets
@ -35,27 +35,6 @@ object SpecialRecipients {
val BROADCAST = ByteArray(8) { 0xFF.toByte() } // All 0xFF = broadcast
}
/**
* Payload as it arrived on the wire, set by [BinaryProtocol.decode].
*
* Signatures cover a re-encoding of the packet, and verification re-encodes too.
* DEFLATE output is not canonical and clients use different encoders
* (java.util.zip.Deflater here, Apple's compression_encode_buffer on iOS), so
* re-compressing can change the preimage and reject a valid packet. Reusing these
* bytes also stops a relay, which re-encodes on TTL decrement, from substituting
* its own encoding.
*
* [forPayload] ties the bytes to the payload they decode to: replace the payload
* and the encoder compresses instead.
*
* Not a data class: generated equals/hashCode over ByteArray compares references.
*/
class WirePayload(
val bytes: ByteArray,
val compressed: Boolean,
val forPayload: ByteArray
)
/**
* Binary packet format - 100% backward compatible with iOS version
*
@ -83,10 +62,7 @@ data class BitchatPacket(
val payload: ByteArray,
var signature: ByteArray? = null, // Changed from val to var for packet signing
var ttl: UByte,
var route: List<ByteArray>? = null, // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient
// Set by BinaryProtocol.decode. Not part of packet identity, so it stays out of
// the parcel, equals and hashCode. Losing it only costs a re-compression.
@IgnoredOnParcel val wirePayload: WirePayload? = null
var route: List<ByteArray>? = null // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient
) : Parcelable {
constructor(
@ -125,8 +101,7 @@ data class BitchatPacket(
payload = payload,
signature = null, // Remove signature for signing
route = route,
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS, // Use fixed TTL=0 for signing to ensure relay compatibility
wirePayload = wirePayload // preimage must use the originator's bytes
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // Use fixed TTL=0 for signing to ensure relay compatibility
)
return BinaryProtocol.encode(unsignedPacket)
}
@ -240,17 +215,7 @@ object BinaryProtocol {
var originalPayloadSize: Int? = null
var isCompressed = false
// Re-encode of a decoded packet: reuse the originator's bytes (see WirePayload).
val wire = packet.wirePayload
if (wire != null && wire.forPayload.contentEquals(packet.payload)) {
if (wire.compressed) {
payload = wire.bytes
originalPayloadSize = packet.payload.size
isCompressed = true
}
// Uncompressed on the wire: keep it that way. shouldCompress agrees
// across clients, but the "only if smaller" check need not.
} else if (CompressionUtil.shouldCompress(payload)) {
if (CompressionUtil.shouldCompress(payload)) {
CompressionUtil.compress(payload)?.let { compressedPayload ->
originalPayloadSize = payload.size
payload = compressedPayload
@ -486,8 +451,6 @@ object BinaryProtocol {
} else null
// Payload
// Kept so the packet can be re-encoded byte-identically (see WirePayload).
var receivedCompressed: ByteArray? = null
val payload = if (isCompressed) {
val lengthFieldBytes = if (version >= 2u.toUByte()) 4 else 2
if (payloadLength.toInt() < lengthFieldBytes) return null
@ -526,8 +489,6 @@ object BinaryProtocol {
val expandedPayload = CompressionUtil.withDecompressionResources(resourceBytes) {
val compressedPayload = ByteArray(compressedSize)
buffer.get(compressedPayload)
// Captured here so the allocation stays inside the reservation.
receivedCompressed = compressedPayload
decompress(compressedPayload, originalSize)
} ?: return null
if (expandedPayload.size != originalSize) {
@ -560,12 +521,7 @@ object BinaryProtocol {
payload = payload,
signature = signature,
ttl = ttl,
route = route,
wirePayload = WirePayload(
bytes = receivedCompressed ?: payload,
compressed = receivedCompressed != null,
forPayload = payload
)
route = route
)
} catch (e: Exception) {

View File

@ -44,6 +44,9 @@ object TransportBridgeService {
* Send a packet to a specific peer via this transport (optional).
*/
fun sendToPeer(peerID: String, packet: BitchatPacket) { }
/** Send directly and report whether the transport accepted the write. */
fun sendToPeerAndReport(peerID: String, packet: BitchatPacket): Boolean = false
}
private val transports = ConcurrentHashMap<String, TransportLayer>()
@ -200,6 +203,19 @@ object TransportBridgeService {
}
}
fun sendToPeerFromLocalAndReport(peerID: String, packet: BitchatPacket): Boolean {
val targets = transports.toMap()
if (targets.isEmpty()) return false
return targets.values.fold(false) { accepted, layer ->
try {
layer.sendToPeerAndReport(peerID, packet) || accepted
} catch (e: Exception) {
Log.e(TAG, "Failed to send local peer packet: ${e.message}")
accepted
}
}
}
private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): PreparedForward? {
if (packet.ttl == 0u.toUByte()) {
Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired")

View File

@ -411,10 +411,11 @@ object AppStateStore {
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
null -> 0
is DeliveryStatus.Sending -> 1
is DeliveryStatus.Sent -> 2
is DeliveryStatus.PartiallyDelivered -> 3
is DeliveryStatus.Delivered -> 4
is DeliveryStatus.Read -> 5
is DeliveryStatus.Queued -> 2
is DeliveryStatus.Sent -> 3
is DeliveryStatus.PartiallyDelivered -> 4
is DeliveryStatus.Delivered -> 5
is DeliveryStatus.Read -> 6
is DeliveryStatus.Failed -> 0
}

View File

@ -72,6 +72,9 @@ object ContactDirectory {
favorite != null -> favorite.peerNoisePublicKey
ContactIdentityResolver.isNoiseKeyHex(peerOrConversationID) ->
ContactIdentityResolver.bytesFromHex(peerOrConversationID)
contactFingerprint != null -> cachedNoiseKeyForFingerprint(contactFingerprint)
ContactIdentityResolver.isMeshPeerId(peerOrConversationID) ->
noiseKeyForAlias(peerOrConversationID)
else -> null
}
val liveMeshPeerID = contactFingerprint?.let { findLiveMeshPeerForFingerprint(it) }
@ -156,6 +159,18 @@ object ContactDirectory {
}
}
/**
* A contact conversation ID contains the SHA-256 fingerprint of the Noise key, whose
* first 16 hex characters are the stable mesh peer ID. Recovering that cached key keeps
* offline routing viable after the outbox has canonicalized a peer ID to `contact_…`.
* Recompute the fingerprint before returning it so stale or mismatched cache entries cannot
* redirect a private message.
*/
private fun cachedNoiseKeyForFingerprint(fingerprint: String): ByteArray? =
cachedNoiseKey(fingerprint.take(16))?.takeIf {
ContactIdentityResolver.fingerprintHex(it).equals(fingerprint, ignoreCase = true)
}
private fun cachedFingerprintNickname(fingerprint: String): String? {
val context = appContext ?: return null
return try {

View File

@ -317,7 +317,6 @@ class ConversationRepository internal constructor(
true
} catch (error: Exception) {
Log.e(TAG, "Unable to synchronously clear private conversations", error)
database.destroyStorage()
_storeState.value = ConversationStoreState.Error(
error.message ?: "Unable to erase conversations"
)
@ -1098,22 +1097,6 @@ internal class ConversationDatabase(
writableDatabase.rawQuery("PRAGMA incremental_vacuum", null).use { }
}
/**
* Last-resort panic cleanup when [clearAll] throws: drop the encrypted database
* files so a later reload cannot resurrect erased conversations (#699).
*/
fun destroyStorage() {
try {
clearAll()
} catch (error: Exception) {
Log.e(TAG, "clearAll failed during destroyStorage; deleting database files", error)
}
try {
close()
} catch (_: Exception) { }
applicationContext.deleteDatabase(databaseName)
}
fun pruneToRetentionLimits(): Set<String> {
val db = writableDatabase
val orphanedMediaPaths = linkedSetOf<String>()
@ -1684,6 +1667,7 @@ internal class ConversationDatabase(
null -> put("delivery_type", 0)
DeliveryStatus.Sending -> put("delivery_type", 1)
DeliveryStatus.Sent -> put("delivery_type", 2)
DeliveryStatus.Queued -> put("delivery_type", 7)
is DeliveryStatus.Delivered -> {
put("delivery_type", 3)
if (includeSensitiveText) put("delivery_text", status.to) else putNull("delivery_text")
@ -1710,10 +1694,11 @@ internal class ConversationDatabase(
null -> 0
is DeliveryStatus.Failed -> 0
DeliveryStatus.Sending -> 1
DeliveryStatus.Sent -> 2
is DeliveryStatus.PartiallyDelivered -> 3
is DeliveryStatus.Delivered -> 4
is DeliveryStatus.Read -> 5
DeliveryStatus.Queued -> 2
DeliveryStatus.Sent -> 3
is DeliveryStatus.PartiallyDelivered -> 4
is DeliveryStatus.Delivered -> 5
is DeliveryStatus.Read -> 6
}
private fun Cursor.toMessage(): BitchatMessage {
@ -1781,6 +1766,7 @@ internal class ConversationDatabase(
reached = nullableInt("delivery_reached") ?: 0,
total = nullableInt("delivery_total") ?: 0
)
7 -> DeliveryStatus.Queued
else -> null
}

View File

@ -0,0 +1,73 @@
package com.bitchat.android.services
import android.content.Context
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.io.File
import java.nio.file.StandardCopyOption
/** Keystore-sealed persistence for private messages awaiting final acknowledgement. */
internal class MessageOutboxStore(
context: Context,
private val cipher: ConversationStorageCipher = AndroidConversationStorageCipher(KEY_ALIAS)
) {
data class Entry(
val content: String,
val nickname: String,
val messageID: String,
val enqueuedAtMs: Long,
var sendAttempts: Int = 0,
var lastAttemptAtMs: Long = 0,
var bridgeDeposited: Boolean = false,
var lastBridgeAttemptAtMs: Long = 0,
val depositedCourierKeys: MutableSet<String> = mutableSetOf()
)
companion object {
private const val KEY_ALIAS = "bitchat_message_outbox_v1"
private val AAD = "bitchat-message-outbox-v1".toByteArray(Charsets.UTF_8)
}
private val gson = Gson()
private val file = File(context.applicationContext.filesDir, "message-outbox.sealed")
@Synchronized
fun load(): MutableMap<String, MutableList<Entry>> = try {
if (!file.exists()) return mutableMapOf()
val plaintext = cipher.decrypt(file.readBytes(), AAD)
val type = object : TypeToken<MutableMap<String, MutableList<Entry>>>() {}.type
gson.fromJson<MutableMap<String, MutableList<Entry>>>(plaintext.toString(Charsets.UTF_8), type)
?: mutableMapOf()
} catch (_: Exception) {
mutableMapOf()
}
@Synchronized
fun save(outbox: Map<String, List<Entry>>) {
if (outbox.values.all { it.isEmpty() }) {
file.delete()
return
}
val plaintext = gson.toJson(outbox).toByteArray(Charsets.UTF_8)
val encrypted = cipher.encrypt(plaintext, AAD)
val temporary = File(file.parentFile, "${file.name}.tmp")
temporary.writeBytes(encrypted)
try {
java.nio.file.Files.move(
temporary.toPath(),
file.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING
)
} catch (e: Exception) {
temporary.delete()
throw IllegalStateException("Failed to persist message outbox", e)
}
}
@Synchronized
fun wipe() {
file.delete()
cipher.destroyKey()
}
}

View File

@ -31,13 +31,6 @@ class MessageRouter private constructor(
DROPPED
}
private data class QueuedMessage(
val content: String,
val nickname: String,
val messageID: String,
val enqueuedAtMs: Long
)
private data class ConversationRetry(
val handshakeAttempts: Int,
val nextHandshakeAttemptAtMs: Long
@ -48,10 +41,15 @@ class MessageRouter private constructor(
private const val OUTBOX_TICK_MS = AppConstants.Router.OUTBOX_TICK_MS
private const val OUTBOX_MESSAGE_TTL_MS = AppConstants.Router.OUTBOX_MESSAGE_TTL_MS
private const val OUTBOX_MAX_PER_PEER = AppConstants.Router.OUTBOX_MAX_PER_PEER
private const val MAX_COURIERS_PER_MESSAGE = AppConstants.Router.MAX_COURIERS_PER_MESSAGE
private const val OUTBOX_MAX_TOTAL = 1_000
private const val OUTBOX_MAX_SEND_ATTEMPTS = 8
private const val BRIDGE_RETRY_COOLDOWN_MS = 30 * 60 * 1000L
private val HANDSHAKE_RETRY_BACKOFF_MS = AppConstants.Router.HANDSHAKE_RETRY_BACKOFF_MS
@Volatile private var INSTANCE: MessageRouter? = null
internal var disableSchedulerForTesting = false
internal var outboxStoreFactory: (Context) -> MessageOutboxStore = ::MessageOutboxStore
fun tryGetInstance(): MessageRouter? = INSTANCE
fun getInstance(context: Context, mesh: MeshService): MessageRouter {
val instance = INSTANCE ?: synchronized(this) {
@ -78,10 +76,19 @@ class MessageRouter private constructor(
INSTANCE?.schedulerScope?.cancel()
INSTANCE = null
}
fun panicClear(context: Context) {
val instance = INSTANCE
if (instance != null) instance.clearAll()
else outboxStoreFactory(context.applicationContext).wipe()
}
}
// Outbox: conversationID -> queued messages, oldest first
private val outbox = ConcurrentHashMap<String, MutableList<QueuedMessage>>()
private val outboxStore = outboxStoreFactory(context)
private val outbox = ConcurrentHashMap<String, MutableList<MessageOutboxStore.Entry>>().apply {
putAll(outboxStore.load())
}
// Per-conversation handshake retry state for queued messages
private val retryState = ConcurrentHashMap<String, ConversationRetry>()
@ -99,9 +106,13 @@ class MessageRouter private constructor(
startOutboxScheduler()
}
@Synchronized
fun clearAll() {
schedulerJob?.cancel()
schedulerJob = null
outbox.clear()
retryState.clear()
outboxStore.wipe()
Log.d(TAG, "Cleared all MessageRouter outbox messages and retry state")
}
@ -134,17 +145,23 @@ class MessageRouter private constructor(
}
val hasMesh = meshTarget?.let { isConnected(mesh, it) } == true
val entry = MessageOutboxStore.Entry(content, recipientNickname, messageID, clock())
enqueue(conversationID, entry)
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)
markAttempt(conversationID, 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
markAttempt(conversationID, messageID)
val prompt = canDeliverViaNostrPromptly()
if (!prompt) attemptCourierDeposit(conversationID, entry)
return if (prompt) RouteResult.NOSTR else RouteResult.QUEUED
} else {
Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}…")
enqueue(conversationID, QueuedMessage(content, recipientNickname, messageID, clock()))
attemptCourierDeposit(conversationID, entry)
Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}…")
if (hasMesh) meshTarget?.let { kickHandshake(conversationID, it, immediate = true) }
return RouteResult.QUEUED
@ -209,12 +226,17 @@ class MessageRouter private constructor(
val resolution = ContactDirectory.resolve(conversationID)
val meshTarget = resolution.meshPeerID
val nostrTarget = resolution.noiseKeyHex ?: conversationID
if (clock() - entry.lastAttemptAtMs < retryDelay(entry.sendAttempts)) continue
if (entry.sendAttempts >= OUTBOX_MAX_SEND_ATTEMPTS) continue
if (meshTarget != null && isReady(mesh, meshTarget)) {
mesh.sendPrivateMessage(entry.content, meshTarget, entry.nickname, entry.messageID)
iterator.remove()
entry.sendAttempts++
entry.lastAttemptAtMs = clock()
} else if (canSendViaNostr(nostrTarget)) {
nostr.sendPrivateMessage(entry.content, nostrTarget, entry.nickname, entry.messageID)
iterator.remove()
entry.sendAttempts++
entry.lastAttemptAtMs = clock()
if (!canDeliverViaNostrPromptly()) attemptCourierDeposit(conversationID, entry)
}
}
if (queued.isEmpty()) {
@ -223,6 +245,7 @@ class MessageRouter private constructor(
retryState.remove(conversationID)
retryState.remove(peerID)
}
persistOutbox()
}
// Flush everything (rarely used)
@ -231,14 +254,120 @@ class MessageRouter private constructor(
}
@Synchronized
private fun enqueue(conversationID: String, entry: QueuedMessage) {
private fun enqueue(conversationID: String, entry: MessageOutboxStore.Entry) {
val queue = outbox.getOrPut(conversationID) { mutableListOf() }
if (queue.any { it.messageID == entry.messageID }) return
queue.add(entry)
while (queue.size > OUTBOX_MAX_PER_PEER) {
val evicted = queue.removeAt(0)
Log.w(TAG, "Outbox full for ${conversationID.take(16)}…; evicting oldest msg_id=${evicted.messageID.take(8)}…")
notifyExpired(evicted.messageID)
}
while (outbox.values.sumOf { it.size } > OUTBOX_MAX_TOTAL) {
val oldest = outbox.entries
.flatMap { (id, entries) -> entries.map { id to it } }
.minByOrNull { it.second.enqueuedAtMs } ?: break
outbox[oldest.first]?.remove(oldest.second)
if (outbox[oldest.first].isNullOrEmpty()) outbox.remove(oldest.first)
notifyExpired(oldest.second.messageID)
}
persistOutbox()
}
@Synchronized
fun onMessageAcknowledged(messageID: String, peerID: String) {
val acknowledgedConversation = ContactDirectory.canonicalConversationId(peerID)
var changed = false
outbox.entries.toList().forEach { (conversationID, queue) ->
if (ContactDirectory.canonicalConversationId(conversationID) != acknowledgedConversation) return@forEach
changed = queue.removeAll { it.messageID == messageID } || changed
if (queue.isEmpty()) {
outbox.remove(conversationID, queue)
retryState.remove(conversationID)
}
}
if (changed) persistOutbox()
}
@Synchronized
private fun markAttempt(conversationID: String, messageID: String) {
outbox[conversationID]?.firstOrNull { it.messageID == messageID }?.sendAttempts =
(outbox[conversationID]?.firstOrNull { it.messageID == messageID }?.sendAttempts ?: 0) + 1
outbox[conversationID]?.firstOrNull { it.messageID == messageID }?.lastAttemptAtMs = clock()
persistOutbox()
}
private fun retryDelay(attempts: Int): Long = when (attempts) {
0 -> 0L
1 -> 30_000L
2 -> 2 * 60_000L
else -> 10 * 60_000L
}
private fun attemptCourierDeposit(conversationID: String, entry: MessageOutboxStore.Entry) {
val resolution = ContactDirectory.resolve(conversationID)
val recipientKey = resolution.noiseKeyHex?.let(ContactIdentityResolver::bytesFromHex) ?: return
if (!entry.bridgeDeposited &&
(entry.lastBridgeAttemptAtMs == 0L || clock() - entry.lastBridgeAttemptAtMs >= BRIDGE_RETRY_COOLDOWN_MS)
) {
val submitted = mesh.sendBridgeCourierMessage(entry.content, entry.messageID, recipientKey) {
synchronized(this) {
val current = outbox[conversationID]?.firstOrNull { it.messageID == entry.messageID }
if (current != null) {
current.bridgeDeposited = true
persistOutbox()
}
}
}
if (submitted) {
entry.lastBridgeAttemptAtMs = clock()
persistOutbox()
}
}
if (entry.depositedCourierKeys.size >= MAX_COURIERS_PER_MESSAGE) return
val candidates = mesh.getPeerInfos()
.asSequence()
.filter { it.isConnected && it.noisePublicKey != null && !it.noisePublicKey!!.contentEquals(recipientKey) }
.filter { peer ->
val favorite = try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared
.getFavoriteStatus(peer.noisePublicKey!!)?.isMutual == true
} catch (_: Exception) { false }
favorite || peer.hasVerifiedAnnouncement
}
.map { peer ->
val favorite = try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared
.getFavoriteStatus(peer.noisePublicKey!!)?.isMutual == true
} catch (_: Exception) { false }
peer to favorite
}
.filter { (peer, _) -> ContactIdentityResolver.noiseKeyHex(peer.noisePublicKey!!) !in entry.depositedCourierKeys }
.sortedByDescending { (_, favorite) -> favorite }
.take(MAX_COURIERS_PER_MESSAGE - entry.depositedCourierKeys.size)
.map { (peer, _) -> peer }
.toList()
if (candidates.isEmpty()) return
val accepted = mesh.sendCourierMessage(
entry.content,
entry.messageID,
recipientKey,
candidates.map { it.id }
).toSet()
candidates.filter { it.id in accepted }.forEach {
entry.depositedCourierKeys += ContactIdentityResolver.noiseKeyHex(it.noisePublicKey!!)
}
if (accepted.isNotEmpty()) persistOutbox()
}
private fun canDeliverViaNostrPromptly(): Boolean = try {
nostr.canDeliverPromptly()
} catch (_: Exception) { false }
private fun persistOutbox() {
try { outboxStore.save(outbox) } catch (e: Exception) {
Log.e(TAG, "Failed to persist sealed outbox: ${e.message}")
}
}
private fun notifyExpired(messageID: String) {
@ -286,6 +415,7 @@ class MessageRouter private constructor(
* follow the MeshForegroundService lifecycle; getInstance restarts the scheduler
* and rebinds the mesh reference when the service comes back.
*/
@Synchronized
fun stopOutboxScheduler() {
schedulerJob?.cancel()
schedulerJob = null
@ -304,6 +434,7 @@ class MessageRouter private constructor(
expireOldEntries(conversationID, nowMs)
val queued = outbox[conversationID] ?: return@forEach
if (queued.isEmpty()) return@forEach
queued.forEach { attemptCourierDeposit(conversationID, it) }
val resolution = ContactDirectory.resolve(conversationID)
val meshTarget = resolution.meshPeerID
@ -338,6 +469,7 @@ class MessageRouter private constructor(
outbox.remove(conversationID, queued)
retryState.remove(conversationID)
}
persistOutbox()
}
private fun canSendViaNostr(peerID: String): Boolean {
@ -385,8 +517,18 @@ class MessageRouter private constructor(
} catch (_: Exception) { null }
noiseHex?.let {
kickHandshakeIfPending(it)
flushOutboxFor(it)
if (ContactDirectory.canonicalConversationId(it) != ContactDirectory.canonicalConversationId(pid)) {
flushOutboxFor(it)
}
}
retryCourierDeposits()
}
}
@Synchronized
private fun retryCourierDeposits() {
outbox.forEach { (conversationID, entries) ->
entries.forEach { attemptCourierDeposit(conversationID, it) }
}
}
@ -399,7 +541,9 @@ class MessageRouter private constructor(
} catch (_: Exception) { null }
noiseHex?.let {
resetRetry(it)
flushOutboxFor(it)
if (ContactDirectory.canonicalConversationId(it) != ContactDirectory.canonicalConversationId(peerID)) {
flushOutboxFor(it)
}
}
}

View File

@ -21,9 +21,12 @@ object GCSFilter {
data class Params(
val p: Int, // Golomb-Rice parameter (>= 1)
val m: Long, // Range M = N * 2^P
val data: ByteArray // Encoded GR bitstream
val data: ByteArray, // Encoded GR bitstream
val includedCount: Int // Number of newest-first input IDs actually encoded
)
const val MAX_P = 32
// Derive P from target FPR; FPR ~= 1 / 2^P
fun deriveP(targetFpr: Double): Int {
val f = targetFpr.coerceIn(0.000001, 0.25)
@ -66,7 +69,12 @@ object GCSFilter {
encoded = encode(mapped, p)
}
return Params(p = p, m = finalM, data = encoded)
return Params(
p = p,
m = finalM,
data = encoded,
includedCount = if (encoded.isEmpty()) 0 else trimmedN
)
}
fun decodeToSortedSet(p: Int, m: Long, data: ByteArray): LongArray {
@ -196,4 +204,3 @@ object GCSFilter {
}
}
}

View File

@ -8,6 +8,9 @@ import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap
import android.content.Context
import java.io.File
import java.nio.file.StandardCopyOption
/**
* Gossip-based synchronization manager using on-demand GCS filters.
@ -17,7 +20,8 @@ import java.util.concurrent.ConcurrentHashMap
class GossipSyncManager(
private val myPeerID: String,
private val scope: CoroutineScope,
private val configProvider: ConfigProvider
private val configProvider: ConfigProvider,
context: Context? = null
) {
interface Delegate {
fun sendPacket(packet: BitchatPacket)
@ -33,6 +37,10 @@ class GossipSyncManager(
companion object {
private const val TAG = "GossipSyncManager"
const val PUBLIC_MESSAGE_MAX_AGE_MS = 6 * 60 * 60 * 1000L
const val FRAGMENT_MAX_AGE_MS = 15 * 60 * 1000L
const val PUBLIC_PACKET_FUTURE_SKEW_MS = 10 * 60 * 1000L
private const val ARCHIVE_FILE = "gossip-public-history.bin"
}
var delegate: Delegate? = null
@ -44,11 +52,15 @@ class GossipSyncManager(
// Stored packets for sync:
// - broadcast messages: keep up to seenCapacity() most recent, keyed by packetId
private val messages = LinkedHashMap<String, BitchatPacket>()
private val fragments = LinkedHashMap<String, BitchatPacket>()
private val archiveFile = context?.applicationContext?.filesDir?.let { File(it, ARCHIVE_FILE) }
private var restoringArchive = false
// - announcements: only keep latest per sender peerID
private val latestAnnouncementByPeer = ConcurrentHashMap<String, Pair<String, BitchatPacket>>()
private var periodicJob: Job? = null
private var cleanupJob: Job? = null
init { restoreArchive() }
fun start() {
periodicJob?.cancel()
periodicJob = scope.launch(Dispatchers.IO) {
@ -84,7 +96,9 @@ class GossipSyncManager(
synchronized(messages) {
messages.clear()
}
synchronized(fragments) { fragments.clear() }
latestAnnouncementByPeer.clear()
archiveFile?.delete()
Log.d(TAG, "Cleared all gossip sync messages and announcements")
}
@ -106,13 +120,20 @@ class GossipSyncManager(
// Only ANNOUNCE or broadcast MESSAGE
val mt = MessageType.fromValue(packet.type)
val isBroadcastMessage = (mt == MessageType.MESSAGE && (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)))
val isBroadcastFile = mt == MessageType.FILE_TRANSFER &&
(packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
val isAnnouncement = (mt == MessageType.ANNOUNCE)
if (!isBroadcastMessage && !isAnnouncement) return
val isFragment = (mt == MessageType.FRAGMENT || isBroadcastFile) &&
(packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
if (!isBroadcastMessage && !isAnnouncement && !isFragment) return
val idBytes = PacketIdUtil.computeIdBytes(packet)
val id = idBytes.joinToString("") { b -> "%02x".format(b) }
if (isBroadcastMessage) {
val now = System.currentTimeMillis()
val age = now - packet.timestamp.toLong()
if (age !in -PUBLIC_PACKET_FUTURE_SKEW_MS..PUBLIC_MESSAGE_MAX_AGE_MS) return
synchronized(messages) {
messages[id] = packet
// Enforce capacity (remove oldest when exceeded)
@ -122,6 +143,17 @@ class GossipSyncManager(
if (it.hasNext()) { it.next(); it.remove() } else break
}
}
if (!restoringArchive) persistArchive()
} else if (isFragment) {
val age = System.currentTimeMillis() - packet.timestamp.toLong()
if (age !in -PUBLIC_PACKET_FUTURE_SKEW_MS..FRAGMENT_MAX_AGE_MS) return
synchronized(fragments) {
fragments[id] = packet
while (fragments.size > configProvider.seenCapacity().coerceAtLeast(1)) {
val iterator = fragments.entries.iterator()
if (iterator.hasNext()) { iterator.next(); iterator.remove() } else break
}
}
} else if (isAnnouncement) {
// Ignore stale announcements older than STALE_PEER_TIMEOUT
val now = System.currentTimeMillis()
@ -143,22 +175,27 @@ class GossipSyncManager(
}
private fun sendRequestSync() {
val payload = buildGcsPayload()
val packet = BitchatPacket(
type = MessageType.REQUEST_SYNC.value,
senderID = hexStringToByteArray(myPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbors only
)
// Sign and broadcast
val signed = delegate?.signPacketForBroadcast(packet) ?: packet
delegate?.sendPacket(signed)
listOf(
SyncTypeFlags.PUBLIC_MESSAGES,
SyncTypeFlags.FRAGMENT,
SyncTypeFlags.FILE_TRANSFER
).forEach { types ->
val payload = buildGcsPayload(types)
val packet = BitchatPacket(
type = MessageType.REQUEST_SYNC.value,
senderID = hexStringToByteArray(myPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbors only
)
val signed = delegate?.signPacketForBroadcast(packet) ?: packet
delegate?.sendPacket(signed)
}
}
private fun sendRequestSyncToPeer(peerID: String) {
val payload = buildGcsPayload()
val types = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES)
val payload = buildGcsPayload(types)
val packet = BitchatPacket(
type = MessageType.REQUEST_SYNC.value,
@ -175,6 +212,8 @@ class GossipSyncManager(
}
fun handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
val requestedTypes = request.types ?: SyncTypeFlags.PUBLIC_MESSAGES
val sinceTimestamp = request.sinceTimestamp
// Decode GCS into sorted set for membership checks
val sorted = GCSFilter.decodeToSortedSet(request.p, request.m, request.data)
fun mightContain(id: ByteArray): Boolean {
@ -183,26 +222,41 @@ class GossipSyncManager(
return GCSFilter.contains(sorted, nonZeroV)
}
// 1) Announcements: send latest per peerID if remote doesn't have them
for ((_, pair) in latestAnnouncementByPeer.entries) {
val (id, pkt) = pair
val idBytes = hexToBytes(id)
if (!mightContain(idBytes)) {
// Send original packet unchanged to requester only (keep local TTL)
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
delegate?.sendPacketToPeer(fromPeerID, toSend)
Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}")
// Announcements are exempt from the cursor: only the latest per peer is retained,
// and peers need their signing keys before they can verify other sync responses.
if (requestedTypes.contains(MessageType.ANNOUNCE)) {
for ((_, pair) in latestAnnouncementByPeer.entries) {
val (id, pkt) = pair
val idBytes = hexToBytes(id)
if (!mightContain(idBytes)) {
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
delegate?.sendPacketToPeer(fromPeerID, toSend)
Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}")
}
}
}
// 2) Broadcast messages: send all they lack
val toSendMsgs = synchronized(messages) { messages.values.toList() }
for (pkt in toSendMsgs) {
if (requestedTypes.contains(MessageType.MESSAGE)) {
val toSendMsgs = synchronized(messages) { messages.values.toList() }
for (pkt in toSendMsgs) {
if (sinceTimestamp != null && pkt.timestamp < sinceTimestamp) continue
val idBytes = PacketIdUtil.computeIdBytes(pkt)
if (!mightContain(idBytes)) {
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
delegate?.sendPacketToPeer(fromPeerID, toSend)
Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}")
}
}
}
val toSendFragments = synchronized(fragments) { fragments.values.toList() }
for (pkt in toSendFragments) {
val type = MessageType.fromValue(pkt.type) ?: continue
if (!requestedTypes.contains(type)) continue
if (sinceTimestamp != null && pkt.timestamp < sinceTimestamp) continue
val idBytes = PacketIdUtil.computeIdBytes(pkt)
if (!mightContain(idBytes)) {
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
delegate?.sendPacketToPeer(fromPeerID, toSend)
Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}")
delegate?.sendPacketToPeer(fromPeerID, pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS))
}
}
}
@ -232,16 +286,23 @@ class GossipSyncManager(
return out
}
private fun buildGcsPayload(): ByteArray {
// Collect candidates: latest announcement per peer + recent broadcast messages
internal fun buildGcsPayload(types: SyncTypeFlags): ByteArray {
// Collect only the packet types represented by this filter.
val list = ArrayList<BitchatPacket>()
// announcements
for ((_, pair) in latestAnnouncementByPeer) {
list.add(pair.second)
if (types.contains(MessageType.ANNOUNCE)) {
for ((_, pair) in latestAnnouncementByPeer) {
list.add(pair.second)
}
}
// messages
synchronized(messages) {
list.addAll(messages.values)
if (types.contains(MessageType.MESSAGE)) {
synchronized(messages) {
list.addAll(messages.values)
}
}
synchronized(fragments) {
list.addAll(fragments.values.filter { packet ->
MessageType.fromValue(packet.type)?.let(types::contains) == true
})
}
// sort by timestamp desc, then take up to min(seenCapacity, fit capacity)
list.sortByDescending { it.timestamp.toLong() }
@ -254,15 +315,24 @@ class GossipSyncManager(
val takeN = minOf(nMax, cap, list.size)
if (takeN <= 0) {
val p0 = GCSFilter.deriveP(fpr)
return RequestSyncPacket(p = p0, m = 1, data = ByteArray(0)).encode()
return RequestSyncPacket(p = p0, m = 1, data = ByteArray(0), types = types).encode()
}
val ids = list.take(takeN).map { pkt -> PacketIdUtil.computeIdBytes(pkt) }
val included = list.take(takeN)
val ids = included.map { pkt -> PacketIdUtil.computeIdBytes(pkt) }
val params = GCSFilter.buildFilter(ids, maxBytes, fpr)
val mVal = if (params.m <= 0L) 1 else params.m
return RequestSyncPacket(p = params.p, m = mVal, data = params.data).encode()
val covered = params.includedCount
val sinceTimestamp = if (covered in 1 until list.size) included[covered - 1].timestamp else null
return RequestSyncPacket(
p = params.p,
m = mVal,
data = params.data,
types = types,
sinceTimestamp = sinceTimestamp
).encode()
}
// Periodically remove stale announcements and all their messages
// Announcements age out quickly; public history remains independently sync-able for six hours.
private fun pruneStaleAnnouncements() {
val now = System.currentTimeMillis()
val stalePeers = mutableListOf<String>()
@ -276,26 +346,17 @@ class GossipSyncManager(
}
}
if (stalePeers.isEmpty()) return
// Remove announcements and their messages
var totalPrunedMsgs = 0
var changed = false
for (peerID in stalePeers) {
// Count messages to be pruned for logging
val toRemove = mutableListOf<String>()
synchronized(messages) {
for ((id, message) in messages) {
val sender = message.senderID.joinToString("") { b -> "%02x".format(b) }
if (sender == peerID) toRemove.add(id)
}
}
totalPrunedMsgs += toRemove.size
// Reuse existing removal which also clears announcement entry
removeAnnouncementForPeer(peerID)
changed = latestAnnouncementByPeer.remove(peerID) != null || changed
}
Log.d(TAG, "Pruned ${stalePeers.size} stale announcements and $totalPrunedMsgs messages")
synchronized(messages) {
changed = messages.entries.removeAll { now - it.value.timestamp.toLong() > PUBLIC_MESSAGE_MAX_AGE_MS } || changed
}
synchronized(fragments) {
fragments.entries.removeAll { now - it.value.timestamp.toLong() > FRAGMENT_MAX_AGE_MS }
}
if (changed) persistArchive()
}
// Explicitly remove stored announcement for a given peer (hex ID)
@ -305,26 +366,63 @@ class GossipSyncManager(
Log.d(TAG, "Removed stored announcement for peer $peerID")
}
// Collect IDs to remove first to avoid modifying collection while iterating
val idsToRemove = mutableListOf<String>()
synchronized(messages) {
for ((id, message) in messages) {
val sender = message.senderID.joinToString("") { b -> "%02x".format(b) }
if (sender == key) {
idsToRemove.add(id)
}
}
private fun restoreArchive() {
val file = archiveFile ?: return
if (!file.exists()) return
try {
restoringArchive = true
val input = java.io.DataInputStream(file.inputStream().buffered())
val count = input.readInt().coerceIn(0, configProvider.seenCapacity())
repeat(count) {
val length = input.readInt()
if (length <= 0 || length > com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + 256) return@repeat
val packet = com.bitchat.android.protocol.BinaryProtocol.decode(input.readNBytes(length)) ?: return@repeat
onPublicPacketSeen(packet)
}
}
// Now remove the collected IDs
synchronized(messages) {
for (id in idsToRemove) {
messages.remove(id)
}
}
if (idsToRemove.isNotEmpty()) {
Log.d(TAG, "Pruned ${idsToRemove.size} messages with senders without announcements")
input.close()
} catch (_: Exception) {
synchronized(messages) { messages.clear() }
} finally {
restoringArchive = false
}
}
@Synchronized
private fun persistArchive() {
val file = archiveFile ?: return
try {
val packets = synchronized(messages) { messages.values.toList() }
val temporary = File(file.parentFile, "${file.name}.tmp")
java.io.DataOutputStream(temporary.outputStream().buffered()).use { output ->
output.writeInt(packets.size)
packets.forEach { packet ->
val encoded = com.bitchat.android.protocol.BinaryProtocol.encode(packet, padding = false) ?: return@forEach
output.writeInt(encoded.size)
output.write(encoded)
}
}
try {
java.nio.file.Files.move(
temporary.toPath(),
file.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING
)
} catch (_: Exception) {
// Some Android filesystems do not support ATOMIC_MOVE. Preserve the durable
// public-history guarantee with a regular replacement move before giving up.
try {
java.nio.file.Files.move(
temporary.toPath(),
file.toPath(),
StandardCopyOption.REPLACE_EXISTING
)
} catch (_: Exception) {
temporary.delete()
}
}
} catch (_: Exception) { }
}
}

View File

@ -0,0 +1,68 @@
package com.bitchat.android.sync
import com.bitchat.android.protocol.MessageType
/** Compact little-endian bitfield matching iOS SyncTypeFlags. */
@JvmInline
value class SyncTypeFlags private constructor(val rawValue: ULong) {
companion object {
private const val KNOWN_TYPE_MASK: ULong = 0xffu
val ANNOUNCE = fromMessageTypes(MessageType.ANNOUNCE)
val MESSAGE = fromMessageTypes(MessageType.MESSAGE)
val FRAGMENT = fromMessageTypes(MessageType.FRAGMENT)
val FILE_TRANSFER = fromMessageTypes(MessageType.FILE_TRANSFER)
val PUBLIC_MESSAGES = fromMessageTypes(MessageType.ANNOUNCE, MessageType.MESSAGE)
val FRAGMENTS_AND_FILES = fromMessageTypes(MessageType.FRAGMENT, MessageType.FILE_TRANSFER)
fun fromRawValue(rawValue: ULong): SyncTypeFlags = SyncTypeFlags(rawValue and KNOWN_TYPE_MASK)
fun fromMessageTypes(vararg types: MessageType): SyncTypeFlags {
var rawValue = 0uL
types.forEach { type ->
bitIndex(type)?.let { bit -> rawValue = rawValue or (1uL shl bit) }
}
return fromRawValue(rawValue)
}
fun decode(data: ByteArray): SyncTypeFlags? {
if (data.size !in 1..8) return null
var rawValue = 0uL
data.forEachIndexed { index, byte ->
rawValue = rawValue or (byte.toUByte().toULong() shl (index * 8))
}
return fromRawValue(rawValue)
}
private fun bitIndex(type: MessageType): Int? = when (type) {
MessageType.ANNOUNCE -> 0
MessageType.MESSAGE -> 1
MessageType.LEAVE -> 2
MessageType.NOISE_HANDSHAKE -> 3
MessageType.NOISE_ENCRYPTED -> 4
MessageType.FRAGMENT -> 5
MessageType.REQUEST_SYNC -> 6
MessageType.FILE_TRANSFER -> 7
MessageType.COURIER_ENVELOPE,
MessageType.VOICE_FRAME -> null
}
}
fun contains(type: MessageType): Boolean {
val bit = bitIndex(type) ?: return false
return (rawValue and (1uL shl bit)) != 0uL
}
fun union(other: SyncTypeFlags): SyncTypeFlags = fromRawValue(rawValue or other.rawValue)
fun encode(): ByteArray? {
if (rawValue == 0uL) return null
var remaining = rawValue
val bytes = ArrayList<Byte>(8)
while (remaining != 0uL && bytes.size < 8) {
bytes += (remaining and 0xffu).toByte()
remaining = remaining shr 8
}
return bytes.toByteArray()
}
}

View File

@ -1,6 +1,5 @@
package com.bitchat.android.ui
import com.bitchat.android.geohash.ChannelID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.security.MessageDigest
@ -17,8 +16,7 @@ class ChannelManager(
private val state: ChatState,
private val messageManager: MessageManager,
private val dataManager: DataManager,
private val coroutineScope: CoroutineScope,
private val onSwitchToMeshLocation: () -> Unit = {}
private val coroutineScope: CoroutineScope
) {
// Channel encryption and security
@ -111,8 +109,6 @@ class ChannelManager(
fun switchToChannel(channel: String?) {
state.setCurrentChannel(channel)
state.setSelectedPrivateChatPeer(null)
state.setSelectedLocationChannel(ChannelID.Mesh)
onSwitchToMeshLocation()
// Clear unread count
channel?.let { ch ->

View File

@ -140,17 +140,7 @@ class ChatViewModel(
private val conversationListPreferences =
com.bitchat.android.services.ConversationListPreferences.getInstance(getApplication())
private val messageManager = MessageManager(state)
private val channelManager = ChannelManager(
state,
messageManager,
dataManager,
viewModelScope,
onSwitchToMeshLocation = {
com.bitchat.android.geohash.LocationChannelManager
.getInstance(getApplication())
.select(com.bitchat.android.geohash.ChannelID.Mesh)
}
)
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
// Create Noise session delegate for clean dependency injection
private val noiseSessionDelegate = object : NoiseSessionDelegate {
@ -1020,6 +1010,11 @@ class ChatViewModel(
messageId,
com.bitchat.android.model.DeliveryStatus.Sent
)
} else if (route == com.bitchat.android.services.MessageRouter.RouteResult.QUEUED) {
messageManager.updateMessageDeliveryStatus(
messageId,
com.bitchat.android.model.DeliveryStatus.Queued
)
}
}
onAccepted(accepted)
@ -1483,7 +1478,7 @@ class ChatViewModel(
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear()
} catch (_: Exception) { }
try {
com.bitchat.android.services.MessageRouter.tryGetInstance()?.clearAll()
com.bitchat.android.services.MessageRouter.panicClear(getApplication())
} catch (_: Exception) { }
// Clear all cryptographic data

View File

@ -283,10 +283,11 @@ class MessageManager(private val state: ChatState) {
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
null -> 0
is DeliveryStatus.Sending -> 1
is DeliveryStatus.Sent -> 2
is DeliveryStatus.PartiallyDelivered -> 3
is DeliveryStatus.Delivered -> 4
is DeliveryStatus.Read -> 5
is DeliveryStatus.Queued -> 2
is DeliveryStatus.Sent -> 3
is DeliveryStatus.PartiallyDelivered -> 4
is DeliveryStatus.Delivered -> 5
is DeliveryStatus.Read -> 6
is DeliveryStatus.Failed -> 0
}

View File

@ -136,6 +136,7 @@ object AppConstants {
const val OUTBOX_TICK_MS: Long = 2_000L
const val OUTBOX_MESSAGE_TTL_MS: Long = 86_400_000L // 24 hours
const val OUTBOX_MAX_PER_PEER: Int = 100
const val MAX_COURIERS_PER_MESSAGE: Int = 3
val HANDSHAKE_RETRY_BACKOFF_MS: LongArray = longArrayOf(5_000L, 15_000L, 30_000L, 60_000L)
}

View File

@ -240,6 +240,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
sendPacketToPeer(peerID, packet)
}
override fun sendToPeerAndReport(peerID: String, packet: BitchatPacket): Boolean =
sendPacketToPeer(peerID, packet)
/**
* Broadcasts routed packet to currently connected peers.
*/

View File

@ -0,0 +1,37 @@
package com.bitchat.android.crypto
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.noise.NoiseEncryptionService
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
class NoiseCourierTest {
@Test
fun `Noise X seal authenticates sender and rejects tampering`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val prefs = context.getSharedPreferences("noise-courier-${UUID.randomUUID()}", Context.MODE_PRIVATE)
val service = NoiseEncryptionService(
context,
SecureIdentityStateManager(prefs, testOnly = true)
)
val payload = byteArrayOf(1, 0, 1, 65, 1, 1, 66)
val sealed = service.sealCourierPayload(payload, service.getStaticPublicKeyData())
val (senderKey, opened) = service.openCourierPayload(sealed)
assertEquals(payload.size + 96, sealed.size)
assertArrayEquals(service.getStaticPublicKeyData(), senderKey)
assertArrayEquals(payload, opened)
sealed[sealed.lastIndex] = (sealed.last().toInt() xor 1).toByte()
assertThrows(Exception::class.java) { service.openCourierPayload(sealed) }
}
}

View File

@ -83,6 +83,25 @@ class FragmentingPacketSenderTest {
assertTrue(writes > 0)
}
@Test
fun `awaited fragmented send reports a later fragment rejection`() = runBlocking {
val sender = FragmentingPacketSender(
CoroutineScope(Dispatchers.Default + SupervisorJob()),
FragmentManager(),
"test",
interFragmentDelayMs = 0L
)
var writes = 0
val accepted = sender.sendAndAwaitAcceptance(RoutedPacket(packetWithPayload(10_000)), "test") {
writes += 1
writes < 2
}
assertFalse(accepted)
assertTrue(writes >= 2)
}
@Test
fun `fragment count at cap boundary is not rejected`() {
val manager = FragmentManager()

View File

@ -942,61 +942,6 @@ class BinaryProtocolTest {
assertTrue("Payload must match", payload.contentEquals(decoded.payload))
}
/**
* Re-encoding preserves a payload compressed by a different DEFLATE encoder
*
* Verification re-encodes the packet, so a re-encode must reproduce the
* originator's bytes. iOS compresses with Apple's compression_encode_buffer,
* which need not match java.util.zip.Deflater byte for byte.
*
* The payload is wrapped in a raw-DEFLATE "stored" block (BTYPE=00): valid, it
* inflates correctly, and no compressor would emit it, so it stands in for a
* foreign encoder without a second compression library. Compared unpadded so
* the result does not depend on how padding is generated.
*/
@Test
fun `re-encoding preserves a foreign encoder's compressed payload`() {
val payload = "the mesh is up near the north gate. relay running all evening. "
.repeat(4)
.toByteArray()
// Raw DEFLATE stored block: BFINAL=1 BTYPE=00, then LEN and NLEN little-endian.
val stored = ByteArray(5 + payload.size)
stored[0] = 0x01
stored[1] = (payload.size and 0xFF).toByte()
stored[2] = ((payload.size shr 8) and 0xFF).toByte()
stored[3] = (payload.size.inv() and 0xFF).toByte()
stored[4] = ((payload.size.inv() shr 8) and 0xFF).toByte()
payload.copyInto(stored, 5)
val buffer = ByteBuffer.allocate(1024).apply { order(ByteOrder.BIG_ENDIAN) }
buffer.put(2.toByte()) // version = 2
buffer.put(MessageType.MESSAGE.value.toByte()) // type
buffer.put(5.toByte()) // ttl
buffer.putLong(fixedTimestamp.toLong()) // timestamp (8 bytes)
buffer.put(BinaryProtocol.Flags.IS_COMPRESSED.toByte()) // flags
buffer.putInt(stored.size + 4) // payloadLength incl. originalSize
buffer.put(hexToBytes(senderHex)) // senderID (8 bytes)
buffer.putInt(payload.size) // originalSize
buffer.put(stored) // compressed payload
val raw = ByteArray(buffer.position())
buffer.rewind()
buffer.get(raw)
val decoded = BinaryProtocol.decode(raw)
assertNotNull("Foreign-encoded frame must decode", decoded)
assertArrayEquals("Payload must inflate to the original", payload, decoded!!.payload)
val reEncoded = BinaryProtocol.encode(decoded, padding = false)
assertNotNull("Re-encode must succeed", reEncoded)
assertArrayEquals(
"Re-encode must reproduce the originator's bytes, or a valid signature stops verifying",
raw,
reEncoded
)
}
/**
* v2 compression bomb is rejected
*

View File

@ -24,12 +24,11 @@ import org.robolectric.RobolectricTestRunner
import java.util.Date
@RunWith(RobolectricTestRunner::class)
class CommandProcessorTest {
class CommandProcessorTest() {
private val context: Context = ApplicationProvider.getApplicationContext()
@OptIn(ExperimentalCoroutinesApi::class)
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
@OptIn(ExperimentalCoroutinesApi::class)
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val chatState = ChatState(scope = testScope)
private lateinit var commandProcessor: CommandProcessor
@ -64,11 +63,11 @@ class CommandProcessorTest {
val channel = "channel-1"
val result = commandProcessor.processCommand(
command = "/j $channel",
meshService = meshService,
myPeerID = "peer-id",
onSendMessage = { a, b, c -> { } },
viewModel = null
command = "/j $channel",
meshService = meshService,
myPeerID = "peer-id",
onSendMessage = { a, b, c -> { } },
viewModel = null
)
assertEquals(result, true)
@ -164,24 +163,6 @@ class CommandProcessorTest {
assertEquals(0, chatState.getMessagesValue().size)
}
@Test
fun `join command leaves active geohash selection for mesh channel routing`() {
chatState.setSelectedLocationChannel(
ChannelID.Location(GeohashChannel(GeohashChannelLevel.REGION, "9q"))
)
commandProcessor.processCommand(
command = "/join backchannel",
meshService = meshService,
myPeerID = "peer-id",
onSendMessage = { _, _, _ -> },
viewModel = null
)
assertEquals("#backchannel", chatState.getCurrentChannelValue())
assertEquals(ChannelID.Mesh, chatState.selectedLocationChannel.value)
}
@Test
fun `clearSuggestions hides the command suggestion popup`() {
// Typing "/" opens the command popup.
@ -206,6 +187,5 @@ class CommandProcessorTest {
commandProcessor.clearSuggestions()
assertFalse(chatState.getShowMentionSuggestionsValue())
assertTrue(chatState.getMentionSuggestionsValue().isEmpty())
}
}

View File

@ -9,6 +9,7 @@ import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.model.PrivateMessagePacket
import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.sync.SyncTypeFlags
import com.bitchat.android.model.UnknownAnnouncementTLV
import com.bitchat.android.protocol.BinaryProtocol
import com.bitchat.android.protocol.BitchatPacket
@ -203,9 +204,14 @@ class ClientRewriteWireContractTest {
val request = RequestSyncPacket(
p = 19,
m = 0x01020304L,
data = hex("aabb")
data = hex("aabb"),
types = SyncTypeFlags.FRAGMENTS_AND_FILES,
sinceTimestamp = 0x0102030405060708u
)
val wire = hex(
"0100011302000401020304030002aabb" +
"040001a00500080102030405060708"
)
val wire = hex("0100011302000401020304030002aabb")
assertArrayEquals(wire, request.encode())
assertSyncRequestEquals(request, RequestSyncPacket.decode(wire))
@ -297,5 +303,7 @@ class ClientRewriteWireContractTest {
assertEquals(expected.p, actual!!.p)
assertEquals(expected.m, actual.m)
assertArrayEquals(expected.data, actual.data)
assertEquals(expected.types, actual.types)
assertEquals(expected.sinceTimestamp, actual.sinceTimestamp)
}
}

View File

@ -0,0 +1,110 @@
package com.bitchat.android.mesh
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.os.Build
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.mockito.kotlin.timeout
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
class BluetoothPacketBroadcasterCompletionTest {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val tracker = BluetoothConnectionTracker(scope, mock())
private val broadcaster = BluetoothPacketBroadcaster(scope, tracker, null, MY_PEER_ID)
@After
fun tearDown() {
broadcaster.shutdown()
scope.cancel()
}
@Test
fun `queue admission waits for successful GATT completion`() = runBlocking {
val connection = connectedPeer()
val result = async(Dispatchers.Default) {
broadcaster.sendPacketToPeerAndAwaitCompletion(packet(), PEER_ID, null, null)
}
verify(connection.gatt!!, timeout(1_000)).writeCharacteristic(connection.characteristic!!)
assertFalse(result.isCompleted)
broadcaster.onGattClientWriteComplete(DEVICE_ADDRESS, LINK_ID, BluetoothGatt.GATT_SUCCESS)
assertTrue(result.await())
}
@Test
fun `disconnect fails an admitted send before custody can be released`() = runBlocking {
val connection = connectedPeer()
val result = async(Dispatchers.Default) {
broadcaster.sendPacketToPeerAndAwaitCompletion(packet(), PEER_ID, null, null)
}
verify(connection.gatt!!, timeout(1_000)).writeCharacteristic(connection.characteristic!!)
assertFalse(result.isCompleted)
broadcaster.onLinkDisconnected(DEVICE_ADDRESS, LINK_ID)
assertFalse(result.await())
}
private fun connectedPeer(): BluetoothConnectionTracker.DeviceConnection {
val device = mock<BluetoothDevice>()
val gatt = mock<BluetoothGatt>()
val characteristic = mock<BluetoothGattCharacteristic>()
whenever(device.address).thenReturn(DEVICE_ADDRESS)
whenever(gatt.writeCharacteristic(any())).thenReturn(true)
val connection = BluetoothConnectionTracker.DeviceConnection(
device = device,
gatt = gatt,
characteristic = characteristic,
isClient = true,
linkID = LINK_ID
)
tracker.addDeviceConnection(DEVICE_ADDRESS, connection)
tracker.observePeerIfCurrent(DEVICE_ADDRESS, LINK_ID, PEER_ID)
return connection
}
private fun packet() = RoutedPacket(
BitchatPacket(
type = MessageType.COURIER_ENVELOPE.value,
senderID = MY_PEER_ID.hexToBytes(),
recipientID = PEER_ID.hexToBytes(),
timestamp = System.currentTimeMillis().toULong(),
payload = byteArrayOf(1, 2, 3),
ttl = 7u
)
)
private fun String.hexToBytes(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private companion object {
const val MY_PEER_ID = "1111222233334444"
const val PEER_ID = "aaaabbbbccccdddd"
const val DEVICE_ADDRESS = "00:11:22:33:44:55"
const val LINK_ID = "synthetic-link"
}
}

View File

@ -0,0 +1,148 @@
package com.bitchat.android.mesh
import android.os.Build
import com.bitchat.android.model.CourierEnvelope
import com.bitchat.android.services.ConversationStorageCipher
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
class CourierStoreTest {
private val now = 2_000_000L
private val recipient = ByteArray(32) { 7 }
private val favorite = ByteArray(32) { 8 }
private val verified = ByteArray(32) { 9 }
private val cipher = TestCipher(0x5a)
@Test
fun `direct pickup remains retryable until explicitly removed`() {
val store = newStore()
val envelope = envelope(1)
assertTrue(store.deposit(envelope, favorite, CourierDepositTier.FAVORITE))
assertEquals(1, store.copiesForRecipient(recipient).size)
assertEquals(1, store.copiesForRecipient(recipient).size)
assertTrue(store.remove(envelope))
assertTrue(store.copiesForRecipient(recipient).isEmpty())
}
@Test
fun `verified pool evicts oldest verified and never favorites`() {
val store = newStore()
repeat(20) { index ->
assertTrue(store.deposit(envelope(index), ByteArray(32) { index.toByte() }, CourierDepositTier.VERIFIED))
}
val favoriteEnvelope = envelope(100)
assertTrue(store.deposit(favoriteEnvelope, favorite, CourierDepositTier.FAVORITE))
assertTrue(store.deposit(envelope(200), ByteArray(32) { 100 }, CourierDepositTier.VERIFIED))
val copies = reloaded().copiesForRecipient(recipient)
assertEquals(21, copies.size)
assertTrue(copies.any { it.ciphertext.contentEquals(favoriteEnvelope.ciphertext) })
}
@Test
fun `spray history and copy budget survive restart`() {
val store = newStore()
assertTrue(store.deposit(envelope(1, copies = 4u), verified, CourierDepositTier.VERIFIED))
val courier = ByteArray(32) { 11 }
val spray = store.sprayCopiesFor(courier).single()
assertEquals(2u.toUByte(), spray.copies)
assertTrue(store.commitSpray(spray, courier))
assertTrue(reloaded().sprayCopiesFor(courier).isEmpty())
}
@Test
fun `failed spray preview does not consume custody`() {
val store = newStore()
assertTrue(store.deposit(envelope(1, copies = 4u), verified, CourierDepositTier.VERIFIED))
val courier = ByteArray(32) { 12 }
assertEquals(2u.toUByte(), store.sprayCopiesFor(courier).single().copies)
assertEquals(2u.toUByte(), reloaded().sprayCopiesFor(courier).single().copies)
}
@Test
fun `concurrent spray reservations cannot over allocate custody`() {
val store = newStore()
assertTrue(store.deposit(envelope(1, copies = 4u), verified, CourierDepositTier.VERIFIED))
val firstCourier = ByteArray(32) { 12 }
val secondCourier = ByteArray(32) { 13 }
val thirdCourier = ByteArray(32) { 14 }
val first = store.sprayCopiesFor(firstCourier).single()
val second = store.sprayCopiesFor(secondCourier).single()
assertEquals(2u.toUByte(), first.copies)
assertEquals(1u.toUByte(), second.copies)
assertTrue(store.sprayCopiesFor(firstCourier).isEmpty())
assertTrue(store.sprayCopiesFor(thirdCourier).isEmpty())
assertTrue(store.commitSpray(second, secondCourier))
assertTrue(store.commitSpray(first, firstCourier))
assertTrue(reloaded().sprayCopiesFor(thirdCourier).isEmpty())
}
@Test
fun `cancelled spray reservation makes its copies eligible again`() {
val store = newStore()
assertTrue(store.deposit(envelope(1, copies = 4u), verified, CourierDepositTier.VERIFIED))
val courier = ByteArray(32) { 15 }
val first = store.sprayCopiesFor(courier).single()
assertTrue(store.cancelSpray(first, courier))
assertEquals(2u.toUByte(), store.sprayCopiesFor(courier).single().copies)
}
@Test
fun `stored prekey envelope retains its prekey id through spray`() {
val store = newStore()
val envelope = envelope(1, copies = 4u).copy(prekeyID = 0x11223344u)
assertTrue(store.deposit(envelope, verified, CourierDepositTier.VERIFIED))
val spray = store.sprayCopiesFor(ByteArray(32) { 16 }).single()
assertEquals(0x11223344u, spray.prekeyID)
}
@Test
fun `wipe deletes sealed custody and destroys key`() {
val store = newStore()
assertTrue(store.deposit(envelope(1), favorite, CourierDepositTier.FAVORITE))
store.wipe()
assertFalse(File(RuntimeEnvironment.getApplication().filesDir, "courier-store.sealed").exists())
assertTrue(cipher.destroyed)
}
private fun envelope(id: Int, copies: UByte = 1u) = CourierEnvelope(
recipientTag = CourierEnvelope.recipientTag(recipient, CourierEnvelope.epochDay(now)),
expiry = (now + 60_000).toULong(),
ciphertext = byteArrayOf((id ushr 8).toByte(), id.toByte()),
copies = copies
)
private fun newStore(): CourierStore {
File(RuntimeEnvironment.getApplication().filesDir, "courier-store.sealed").delete()
return reloaded()
}
private fun reloaded() = CourierStore(RuntimeEnvironment.getApplication(), cipher) { now }
private class TestCipher(private val mask: Int) : ConversationStorageCipher {
var destroyed = false
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray) =
plaintext.map { (it.toInt() xor mask).toByte() }.toByteArray()
override fun decrypt(envelope: ByteArray, associatedData: ByteArray) = encrypt(envelope, associatedData)
override fun destroyKey() { destroyed = true }
}
}

View File

@ -0,0 +1,32 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DirectCourierDepositPolicyTest {
private val packet = BitchatPacket(
type = MessageType.COURIER_ENVELOPE.value,
senderID = ByteArray(8),
timestamp = 1u,
payload = byteArrayOf(1),
ttl = 7u
)
@Test
fun `accepts current direct ingress from claimed sender`() {
val routed = RoutedPacket(packet, "peer", "address", ingressLinkID = "link")
assertTrue(DirectCourierDepositPolicy.accepts(routed, 7u, { "link" }, { "peer" }))
}
@Test
fun `rejects relayed stale-link and rebound-sender deposits`() {
val direct = RoutedPacket(packet, "peer", "address", ingressLinkID = "link")
assertFalse(DirectCourierDepositPolicy.accepts(direct.copy(packet = packet.copy(ttl = 6u)), 7u, { "link" }, { "peer" }))
assertFalse(DirectCourierDepositPolicy.accepts(direct, 7u, { "replacement" }, { "peer" }))
assertFalse(DirectCourierDepositPolicy.accepts(direct, 7u, { "link" }, { "other" }))
}
}

View File

@ -7,6 +7,7 @@ import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.model.PrivateMessagePacket
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.NoisePeerIdentity
import com.bitchat.android.noise.AuthenticatedNoiseSession
@ -25,9 +26,11 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.argThat
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
@ -435,6 +438,48 @@ class MessageHandlerTest {
assertTrue(handler.handleNoiseEncrypted(RoutedPacket(encryptedPacket(), peerID, "direct-link")))
}
@Test
fun `opened courier private message is admitted as its Noise sender`() = runBlocking {
whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname)
whenever(delegate.getMyNickname()).thenReturn("me")
val payload = NoisePayload(
NoisePayloadType.PRIVATE_MESSAGE,
requireNotNull(PrivateMessagePacket("courier-message", "opaque courier content").encode())
).encode()
assertTrue(
handler.handleOpenedCourierPayload(
RoutedPacket(encryptedPacket().copy(payload = payload), peerID, "courier-ingress")
)
)
verify(delegate).onMessageReceived(argThat {
id == "courier-message" &&
content == "opaque courier content" &&
senderPeerID == peerID &&
sender == nickname &&
recipientNickname == "me"
})
}
@Test
fun `opened courier delivery receipt acknowledges the original sender and rejects blank IDs`() = runBlocking {
val receipt = NoisePayload(NoisePayloadType.DELIVERED, "courier-message".toByteArray()).encode()
assertTrue(
handler.handleOpenedCourierPayload(
RoutedPacket(encryptedPacket().copy(payload = receipt), peerID, "courier-ingress")
)
)
verify(delegate).onDeliveryAckReceived("courier-message", peerID)
val blankReceipt = NoisePayload(NoisePayloadType.DELIVERED, ByteArray(0)).encode()
assertFalse(
handler.handleOpenedCourierPayload(
RoutedPacket(encryptedPacket().copy(payload = blankReceipt), peerID, "courier-ingress")
)
)
verify(delegate, times(1)).onDeliveryAckReceived("courier-message", peerID)
}
private fun encryptedPacket(): BitchatPacket = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,

View File

@ -64,6 +64,26 @@ class PacketProcessorAnnounceSideEffectTest {
assertEquals(PEER_ID, withTimeout(1_000) { delegate.lastSeen.await() })
}
@Test
fun `broadcast message within future skew is handled`() = runBlocking {
val delegate = RecordingDelegate(acceptAnnounce = true)
val processor = processor(delegate)
processor.processPacket(message(timestampOffsetMs = 60_000))
withTimeout(1_000) { delegate.messageHandled.await() }
}
@Test
fun `broadcast message beyond future skew is rejected`() = runBlocking {
val delegate = RecordingDelegate(acceptAnnounce = true)
val processor = processor(delegate)
processor.processPacket(message(timestampOffsetMs = 11 * 60_000L))
assertNull(withTimeoutOrNull(250) { delegate.messageHandled.await() })
}
private fun processor(delegate: RecordingDelegate): PacketProcessor =
PacketProcessor(MY_PEER_ID).also {
it.delegate = delegate
@ -96,12 +116,26 @@ class PacketProcessorAnnounceSideEffectTest {
return RoutedPacket(packet, PEER_ID, "direct-link")
}
private fun message(timestampOffsetMs: Long): RoutedPacket {
val packet = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = PEER_ID.hexToBytes(),
recipientID = SpecialRecipients.BROADCAST,
timestamp = (System.currentTimeMillis() + timestampOffsetMs).toULong(),
payload = byteArrayOf(0x01),
ttl = 7u
)
return RoutedPacket(packet, PEER_ID, "direct-link")
}
private class RecordingDelegate(
private val acceptAnnounce: Boolean,
private val acceptHandshake: Boolean = false
) : PacketProcessorDelegate {
val handled = CompletableDeferred<Unit>()
val handshakeHandled = CompletableDeferred<Unit>()
val messageHandled = CompletableDeferred<Unit>()
val lastSeen = CompletableDeferred<String>()
@Volatile var relayCount = 0
@ -121,7 +155,9 @@ class PacketProcessorAnnounceSideEffectTest {
handled.complete(Unit)
return acceptAnnounce
}
override fun handleMessage(routed: RoutedPacket) = Unit
override fun handleMessage(routed: RoutedPacket) {
messageHandled.complete(Unit)
}
override fun handleLeave(routed: RoutedPacket) = Unit
override fun handleFragment(packet: BitchatPacket): BitchatPacket? = null
override fun handleRequestSync(routed: RoutedPacket) = Unit

View File

@ -598,65 +598,4 @@ class SecurityManagerTest {
private fun String.hexToBytes(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
// Duplicate-detection identity.
/**
* Two distinct packets that agree on their first 64 bytes and share a
* timestamp. The old key hashed only that prefix, with a 32-bit
* `contentHashCode`, so these were "the same packet" and the second was
* silently dropped.
*/
private fun prefixSharingPair(): Pair<BitchatPacket, BitchatPacket> {
val shared = ByteArray(64) { 0x7 }
val first = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = otherPeerID.hexToByteArrayForTest(),
recipientID = myPeerID.hexToByteArrayForTest(),
timestamp = 1_700_000_000_000uL,
payload = shared + byteArrayOf(0x01, 0x02, 0x03),
ttl = 7u
)
val second = first.copy(payload = shared + byteArrayOf(0x0A, 0x0B, 0x0C))
return first to second
}
@Test
fun `packets differing only past the first 64 bytes are not treated as duplicates`() {
val (first, second) = prefixSharingPair()
assertTrue(securityManager.validatePacket(first, otherPeerID))
assertTrue(
"A distinct packet must not be dropped as a duplicate",
securityManager.validatePacket(second, otherPeerID)
)
}
@Test
fun `a genuine replay of the same packet is still rejected`() {
// The other half: strengthening the identity must not weaken replay
// protection, which is the reason this cache exists.
val (first, _) = prefixSharingPair()
assertTrue(securityManager.validatePacket(first, otherPeerID))
assertFalse(
"The identical packet must still be caught",
securityManager.validatePacket(first, otherPeerID)
)
}
@Test
fun `the same packet from two different peers is tracked separately`() {
// Peer scoping is deliberately kept: PacketIdUtil covers the packet's
// own senderID, which is not the peer it was received from once a
// packet has been relayed.
val (first, _) = prefixSharingPair()
assertTrue(securityManager.validatePacket(first, otherPeerID))
assertTrue(securityManager.validatePacket(first, unknownPeerID))
}
private fun String.hexToByteArrayForTest(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}

View File

@ -0,0 +1,111 @@
package com.bitchat.android.model
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class CourierEnvelopeTest {
@Test
fun `encoding matches fixed iOS wire vector`() {
val envelope = CourierEnvelope(
recipientTag = ByteArray(16) { it.toByte() },
expiry = 0x0102030405060708u,
ciphertext = byteArrayOf(0xaa.toByte(), 0xbb.toByte(), 0xcc.toByte()),
copies = 4u,
prekeyID = 0x11223344u
)
assertEquals(
"010010000102030405060708090a0b0c0d0e0f" +
"0200080102030405060708" +
"030003aabbcc" +
"04000104" +
"05000411223344",
envelope.encode()!!.toHex()
)
}
@Test
fun `copies TLV round trips and legacy omission defaults to one`() {
val envelope = CourierEnvelope(
recipientTag = ByteArray(16) { it.toByte() },
expiry = 123456789u,
ciphertext = ByteArray(96) { (it + 1).toByte() },
copies = 4u
)
val decoded = CourierEnvelope.decode(envelope.encode()!!)!!
assertEquals(4u.toUByte(), decoded.copies)
assertArrayEquals(envelope.recipientTag, decoded.recipientTag)
assertArrayEquals(envelope.ciphertext, decoded.ciphertext)
val legacy = envelope.copy(copies = 1u)
assertEquals(1u.toUByte(), CourierEnvelope.decode(legacy.encode()!!)!!.copies)
}
@Test
fun `prekey id survives decode re-encode and copy changes`() {
val envelope = CourierEnvelope(
recipientTag = ByteArray(16) { it.toByte() },
expiry = 123456789u,
ciphertext = ByteArray(96) { (it + 1).toByte() },
copies = 4u,
prekeyID = 0xfedcba98u
)
val decoded = CourierEnvelope.decode(envelope.encode()!!)!!
assertEquals(0xfedcba98u, decoded.prekeyID)
assertArrayEquals(envelope.encode(), decoded.copy(copies = 4u).encode())
assertEquals(0xfedcba98u, decoded.copy(copies = 2u).prekeyID)
}
@Test
fun `invalid or duplicate prekey fields are rejected`() {
val envelope = CourierEnvelope(ByteArray(16), 1u, ByteArray(32) { 1 }, prekeyID = 7u)
val encoded = envelope.encode()!!
val prekeyField = encoded.copyOfRange(encoded.size - 7, encoded.size)
assertNull(CourierEnvelope.decode(encoded + prekeyField))
assertNull(CourierEnvelope.decode(encoded.copyOf(encoded.size - 1)))
}
@Test
fun `tag rotates daily and matches adjacent day for clock skew`() {
val key = ByteArray(32) { 0x2a }
val now = 10L * 86_400_000L
val tag = CourierEnvelope.recipientTag(key, CourierEnvelope.epochDay(now) - 1u)
val envelope = CourierEnvelope(tag, (now + 1_000).toULong(), ByteArray(96) { 1 })
assertEquals(true, envelope.matchesRecipient(key, now))
assertEquals(false, envelope.matchesRecipient(ByteArray(32) { 0x2b }, now))
}
@Test
fun `oversized ciphertext is rejected`() {
val envelope = CourierEnvelope(
ByteArray(16),
1u,
ByteArray(CourierEnvelope.MAX_CIPHERTEXT_BYTES + 1)
)
assertNull(envelope.encode())
}
@Test
fun `invalid copy budgets are rejected instead of normalized`() {
val envelope = CourierEnvelope(ByteArray(16), 1u, ByteArray(32) { 1 })
assertNull(envelope.copy(copies = 0u).encode())
assertNull(envelope.copy(copies = 9u).encode())
val encoded = envelope.copy(copies = 2u).encode()!!
encoded[encoded.lastIndex] = 0
assertNull(CourierEnvelope.decode(encoded))
}
@Test
fun `duplicate required fields are rejected`() {
val envelope = CourierEnvelope(ByteArray(16), 1u, ByteArray(32) { 1 })
val encoded = envelope.encode()!!
assertNull(CourierEnvelope.decode(encoded + encoded.copyOfRange(0, 19)))
}
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
}

View File

@ -12,38 +12,9 @@ class IdentityAnnouncementTest {
private val signingKey = ByteArray(32) { 0x22 }
@Test
fun `capability assignments and encoding match iOS`() {
assertEquals(1L shl 0, PeerCapabilities.PREKEYS.rawValue)
assertEquals(1L shl 1, PeerCapabilities.WIFI_BULK.rawValue)
assertEquals(1L shl 2, PeerCapabilities.GATEWAY.rawValue)
assertEquals(1L shl 3, PeerCapabilities.GROUPS.rawValue)
assertEquals(1L shl 4, PeerCapabilities.BOARD.rawValue)
assertEquals(1L shl 5, PeerCapabilities.VOUCH.rawValue)
assertEquals(1L shl 6, PeerCapabilities.MESH_DIAGNOSTICS.rawValue)
assertEquals(1L shl 7, PeerCapabilities.BRIDGE.rawValue)
assertEquals(1L shl 8, PeerCapabilities.PRIVATE_MEDIA.rawValue)
assertEquals(1L shl 9, PeerCapabilities.PRIVATE_MEDIA_RECEIPTS.rawValue)
assertEquals(1L shl 10, PeerCapabilities.NON_DESTRUCTIVE_NOISE_REPLACEMENT.rawValue)
val assigned = PeerCapabilities((1L shl 11) - 1)
assertArrayEquals(byteArrayOf(0xFF.toByte(), 0x07), assigned.encoded())
assertEquals(assigned, PeerCapabilities.decode(assigned.encoded()))
}
@Test
fun `capability encoding is minimal and preserves low 64 unknown bits`() {
assertArrayEquals(byteArrayOf(0x00), PeerCapabilities.NONE.encoded())
assertArrayEquals(byteArrayOf(0x01), PeerCapabilities.PREKEYS.encoded())
fun `private media capability uses iOS little-endian bytes`() {
assertArrayEquals(byteArrayOf(0x00, 0x01), PeerCapabilities.PRIVATE_MEDIA.encoded())
val future = PeerCapabilities.decode(
byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80.toByte(), 0x55)
)
assertEquals(Long.MIN_VALUE or 1L, future.rawValue)
assertArrayEquals(
byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80.toByte()),
future.encoded()
)
assertTrue(PeerCapabilities.decode(byteArrayOf(0x00, 0x01)).contains(PeerCapabilities.PRIVATE_MEDIA))
}
@Test
@ -100,6 +71,5 @@ class IdentityAnnouncementTest {
.capabilities!!
.contains(PeerCapabilities.PRIVATE_MEDIA)
)
assertEquals(PeerCapabilities.PRIVATE_MEDIA, PeerCapabilities.LOCAL_SUPPORTED)
}
}

View File

@ -0,0 +1,263 @@
package com.bitchat.android.nostr
import com.bitchat.android.model.CourierEnvelope
import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.model.PrivateMessagePacket
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Base64
class BridgeCourierServiceTest {
private val now = 1_750_000_000_000L
private val relays = listOf("wss://bridge-test.invalid")
private val recipientKey = ByteArray(32) { 0x32 }
@Test
fun `subscription queries the last 24 hours in relay seconds`() {
val relay = FakeRelay()
val receiver = BridgeCourierService(
cipher = FakeCipher(recipientKey),
onEnvelope = {},
relayManager = relay,
relayUrls = relays,
clock = { now }
)
try {
receiver.start()
val filter = requireNotNull(relay.subscription).filter
val expectedSince = ((now - CourierEnvelope.MAX_LIFETIME_MS) / 1000).toInt()
assertEquals(expectedSince, filter.since)
assertEquals(listOf(1401), filter.kinds)
assertEquals(100, filter.limit)
} finally {
receiver.stop()
}
}
@Test
fun `deposit creates a signed iOS-compatible event and receiver admits it once`() {
val senderRelay = FakeRelay()
val sender = BridgeCourierService(
cipher = FakeCipher(ByteArray(32) { 0x11 }),
onEnvelope = {},
relayManager = senderRelay,
relayUrls = relays,
clock = { now },
identityFactory = { NostrIdentity.fromSeed("bridge-courier-sender") }
)
var accepted = false
val event = try {
assertTrue(sender.deposit("synthetic bridge payload", "bridge-message", recipientKey) {
accepted = true
})
assertTrue(accepted)
assertEquals(1, senderRelay.sentEvents.size)
senderRelay.sentEvents.single().also {
assertEquals(1401, it.kind)
assertTrue(it.isValidSignature())
assertEquals(relays, senderRelay.lastSendRelays)
}
} finally {
sender.stop()
}
val encoded = Base64.getDecoder().decode(event.content)
val envelope = requireNotNull(CourierEnvelope.decode(encoded))
assertTrue(envelope.matchesRecipient(recipientKey, now))
assertEquals(1u.toUByte(), envelope.copies)
assertEquals(
listOf("x", envelope.recipientTag.toHex()),
event.tags.first { it.firstOrNull() == "x" }
)
assertEquals(
listOf("expiration", (envelope.expiry / 1000u).toString()),
event.tags.first { it.firstOrNull() == "expiration" }
)
val typed = requireNotNull(NoisePayload.decode(envelope.ciphertext))
assertEquals(NoisePayloadType.PRIVATE_MESSAGE, typed.type)
val privateMessage = requireNotNull(PrivateMessagePacket.decode(typed.data))
assertEquals("bridge-message", privateMessage.messageID)
assertEquals("synthetic bridge payload", privateMessage.content)
val receiverRelay = FakeRelay()
val received = mutableListOf<CourierEnvelope>()
val receiver = BridgeCourierService(
cipher = FakeCipher(recipientKey),
onEnvelope = received::add,
relayManager = receiverRelay,
relayUrls = relays,
clock = { now },
identityFactory = { NostrIdentity.fromSeed("bridge-courier-receiver") }
)
try {
receiver.start()
assertNotNull(receiverRelay.subscription)
assertTrue(receiverRelay.subscription!!.filter.matches(event))
assertEquals(relays, receiverRelay.subscription!!.relayUrls)
receiverRelay.emit(event)
receiverRelay.emit(event)
assertEquals(listOf(envelope), received)
} finally {
receiver.stop()
}
assertEquals(1, receiverRelay.unsubscribedIDs.size)
}
@Test
fun `receiver rejects a validly signed event with mismatched metadata and deposit fails closed`() {
val relay = FakeRelay()
val received = mutableListOf<CourierEnvelope>()
val receiver = BridgeCourierService(
cipher = FakeCipher(recipientKey),
onEnvelope = received::add,
relayManager = relay,
relayUrls = relays,
clock = { now },
identityFactory = { NostrIdentity.fromSeed("bridge-courier-receiver") }
)
val envelope = CourierEnvelope(
recipientTag = CourierEnvelope.recipientTag(recipientKey, CourierEnvelope.epochDay(now)),
expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(),
ciphertext = byteArrayOf(1, 2, 3),
copies = 1u
)
val identity = NostrIdentity.fromSeed("bridge-courier-invalid-event")
val mismatchedTag = identity.signEvent(
NostrEvent(
pubkey = identity.publicKeyHex,
createdAt = (now / 1000).toInt(),
kind = 1401,
tags = listOf(
listOf("x", "00".repeat(CourierEnvelope.TAG_LENGTH)),
listOf("expiration", (envelope.expiry / 1000u).toString())
),
content = Base64.getEncoder().encodeToString(requireNotNull(envelope.encode()))
)
)
try {
receiver.start()
relay.emit(mismatchedTag)
assertTrue(received.isEmpty())
val oversizedSender = BridgeCourierService(
cipher = FakeCipher(ByteArray(32) { 0x21 }, ByteArray(CourierEnvelope.MAX_CIPHERTEXT_BYTES + 1)),
onEnvelope = {},
relayManager = relay,
relayUrls = relays,
clock = { now },
identityFactory = { NostrIdentity.fromSeed("bridge-courier-oversized") }
)
try {
assertFalse(oversizedSender.depositPayload(byteArrayOf(7), recipientKey))
relay.connected = false
assertFalse(oversizedSender.depositPayload(byteArrayOf(7), recipientKey))
} finally {
oversizedSender.stop()
}
} finally {
receiver.stop()
}
}
@Test
fun `deposit completion waits for relay acceptance`() {
val relay = FakeRelay().apply { acceptImmediately = false }
val sender = BridgeCourierService(
cipher = FakeCipher(ByteArray(32) { 0x11 }),
onEnvelope = {},
relayManager = relay,
relayUrls = relays,
clock = { now },
identityFactory = { NostrIdentity.fromSeed("bridge-courier-pending") }
)
var accepted = 0
try {
assertTrue(sender.depositPayload(byteArrayOf(1, 2, 3), recipientKey) { accepted++ })
assertEquals(0, accepted)
relay.acceptPendingEvent()
assertEquals(1, accepted)
} finally {
sender.stop()
}
}
private class FakeCipher(
private val staticKey: ByteArray,
private val sealedOverride: ByteArray? = null
) : BridgeCourierCipher {
override fun staticPublicKey(): ByteArray = staticKey
override fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray =
sealedOverride ?: payload.copyOf()
}
private class FakeRelay : BridgeCourierRelay {
data class Subscription(
val filter: NostrFilter,
val id: String,
val relayUrls: List<String>,
val handler: (NostrEvent) -> Unit
)
var connected = true
var acceptImmediately = true
var subscription: Subscription? = null
val sentEvents = mutableListOf<NostrEvent>()
var lastSendRelays: List<String> = emptyList()
val unsubscribedIDs = mutableListOf<String>()
private var pendingAccepted: (() -> Unit)? = null
override fun subscribe(
filter: NostrFilter,
id: String,
targetRelayUrls: List<String>,
handler: (NostrEvent) -> Unit
) {
subscription = Subscription(filter, id, targetRelayUrls, handler)
}
override fun unsubscribe(id: String) {
unsubscribedIDs += id
if (subscription?.id == id) subscription = null
}
override fun hasConnectedRelay(relayUrls: Collection<String>): Boolean = connected
override fun sendEvent(
event: NostrEvent,
relayUrls: List<String>,
onAccepted: () -> Unit
): Boolean {
if (!connected) return false
sentEvents += event
lastSendRelays = relayUrls
if (acceptImmediately) onAccepted() else pendingAccepted = onAccepted
return true
}
fun acceptPendingEvent() {
val callback = requireNotNull(pendingAccepted)
pendingAccepted = null
callback()
}
fun emit(event: NostrEvent) {
subscription?.handler?.invoke(event)
}
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
}

View File

@ -55,7 +55,8 @@ class NostrBackgroundEventProcessorTest {
)
}
withTimeout(5_000) {
// CI can take longer to verify all 300 Schnorr signatures on a cold JVM.
withTimeout(30_000) {
while (AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().size < 300) {
kotlinx.coroutines.yield()
}

View File

@ -3,7 +3,6 @@ package com.bitchat.android.nostr
import com.google.gson.Gson
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NostrProtocolTest {
@ -42,52 +41,6 @@ class NostrProtocolTest {
assertNull(decrypted)
}
@Test
fun createPrivateMessage_reservesSlackInsideIosLookback() {
val sender = NostrIdentity.generate()
val recipient = NostrIdentity.generate()
assertEquals(
IOS_DM_LOOKBACK_SECONDS - TIMESTAMP_SAFETY_SLACK_SECONDS,
NostrCrypto.NIP17_DEFAULT_MAX_PAST_SECONDS
)
repeat(20) {
val beforeCreation = (System.currentTimeMillis() / 1000).toInt()
val giftWrap = NostrProtocol.createPrivateMessage(
content = "bitchat1:test",
recipientPubkey = recipient.publicKeyHex,
senderIdentity = sender
).single()
val afterCreation = (System.currentTimeMillis() / 1000).toInt()
val sealJson = NostrCrypto.decryptNIP44(
ciphertext = giftWrap.content,
senderPublicKeyHex = giftWrap.pubkey,
recipientPrivateKeyHex = recipient.privateKeyHex
)
val seal = gson.fromJson(sealJson, NostrEvent::class.java)
assertTimestampWithinIosLookback("gift wrap", giftWrap.createdAt, beforeCreation, afterCreation)
assertTimestampWithinIosLookback("seal", seal.createdAt, beforeCreation, afterCreation)
}
}
private fun assertTimestampWithinIosLookback(
envelope: String,
createdAt: Int,
beforeCreation: Int,
afterCreation: Int
) {
assertTrue(
"$envelope timestamp must leave 2 hours inside the iOS lookback",
createdAt >= beforeCreation - MAX_OUTBOUND_BACKDATE_SECONDS
)
assertTrue(
"$envelope timestamp must not be in the future",
createdAt <= afterCreation
)
}
private fun forgedGiftWrap(
content: String,
claimedSender: NostrIdentity,
@ -129,11 +82,4 @@ class NostrProtocolTest {
content = giftWrapContent
).sign(wrapPrivateKey)
}
private companion object {
const val IOS_DM_LOOKBACK_SECONDS = 86_400
const val TIMESTAMP_SAFETY_SLACK_SECONDS = 7_200
const val MAX_OUTBOUND_BACKDATE_SECONDS =
IOS_DM_LOOKBACK_SECONDS - TIMESTAMP_SAFETY_SLACK_SECONDS
}
}

View File

@ -6,6 +6,7 @@ import com.bitchat.android.identity.SecureIdentityStateManager
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@ -56,4 +57,16 @@ class ContactDirectoryTest {
assertNull(resolution.displayName)
}
@Test
fun `offline contact resolves a fingerprint-validated cached Noise key`() {
val noiseKey = ByteArray(32) { it.toByte() }
val fingerprint = ContactIdentityResolver.fingerprintHex(noiseKey)
val peerID = ContactIdentityResolver.peerIdForNoiseKey(noiseKey)
identityManager.cachePeerNoiseKey(peerID, ContactIdentityResolver.noiseKeyHex(noiseKey))
val resolution = ContactDirectory.resolve("contact_$fingerprint")
assertTrue(resolution.noisePublicKey!!.contentEquals(noiseKey))
}
}

View File

@ -433,22 +433,6 @@ class ConversationDatabaseTest {
}
}
@Test
fun `destroy storage deletes database files so history cannot reload`() {
database.upsertMessage(
"contact_alice",
setOf("contact_alice"),
"alice",
message("survives-failure", "alice", 1L),
true
)
assertTrue(context.databaseList().contains(databaseName))
database.destroyStorage()
assertFalse(context.databaseList().contains(databaseName))
}
@Test
fun `version one plaintext database migrates without losing history`() {
database.close()

View File

@ -0,0 +1,54 @@
package com.bitchat.android.services
import android.os.Build
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
class MessageOutboxStoreTest {
@Test
fun `sealed outbox round trips and wrong key fails closed`() {
val context = RuntimeEnvironment.getApplication()
val file = File(context.filesDir, "message-outbox.sealed")
file.delete()
val store = MessageOutboxStore(context, TestCipher(0x33))
val entry = MessageOutboxStore.Entry("secret", "peer", "message", 100)
entry.depositedCourierKeys += "courier"
store.save(mapOf("conversation" to listOf(entry)))
assertFalse(file.readBytes().toString(Charsets.UTF_8).contains("secret"))
assertEquals("secret", MessageOutboxStore(context, TestCipher(0x33)).load()["conversation"]?.single()?.content)
assertTrue(MessageOutboxStore(context, TestCipher(0x44)).load().isEmpty())
}
@Test
fun `wipe removes file and destroys key`() {
val context = RuntimeEnvironment.getApplication()
val cipher = TestCipher(0x33)
val store = MessageOutboxStore(context, cipher)
store.save(mapOf("conversation" to listOf(MessageOutboxStore.Entry("secret", "peer", "message", 100))))
store.wipe()
assertFalse(File(context.filesDir, "message-outbox.sealed").exists())
assertTrue(cipher.destroyed)
}
private class TestCipher(private val mask: Int) : ConversationStorageCipher {
var destroyed = false
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray) = byteArrayOf(mask.toByte()) +
plaintext.map { (it.toInt() xor mask).toByte() }.toByteArray()
override fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray {
require(envelope.firstOrNull() == mask.toByte())
return envelope.drop(1).map { (it.toInt() xor mask).toByte() }.toByteArray()
}
override fun destroyKey() { destroyed = true }
}
}

View File

@ -14,6 +14,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.argThat
import org.mockito.kotlin.clearInvocations
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
@ -36,6 +37,7 @@ class MessageRouterTest {
private lateinit var mesh: MeshService
private lateinit var router: MessageRouter
private lateinit var identityManager: SecureIdentityStateManager
private var fakeTime = 1_000_000L
private val expired = mutableListOf<String>()
@ -46,7 +48,7 @@ class MessageRouterTest {
"message-router-test-${UUID.randomUUID()}",
Context.MODE_PRIVATE
)
val identityManager = SecureIdentityStateManager(prefs, testOnly = true)
identityManager = SecureIdentityStateManager(prefs, testOnly = true)
ContactDirectory.identityManagerProvider = { identityManager }
mesh = mock()
@ -56,11 +58,19 @@ class MessageRouterTest {
ContactDirectory.initialize(context) { mesh }
MessageRouter.disableSchedulerForTesting = true
MessageRouter.outboxStoreFactory = { testContext ->
MessageOutboxStore(testContext, object : ConversationStorageCipher {
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray) = plaintext
override fun decrypt(envelope: ByteArray, associatedData: ByteArray) = envelope
override fun destroyKey() = Unit
})
}
MessageRouter.resetForTesting()
fakeTime = 1_000_000L
expired.clear()
router = MessageRouter.getInstance(context, mesh)
router.clearAll()
router.clock = { fakeTime }
router.onMessageExpired = { expired.add(it) }
}
@ -69,6 +79,7 @@ class MessageRouterTest {
fun tearDown() {
MessageRouter.resetForTesting()
MessageRouter.disableSchedulerForTesting = false
MessageRouter.outboxStoreFactory = ::MessageOutboxStore
ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) }
}
@ -174,6 +185,159 @@ class MessageRouterTest {
verify(mesh, never()).initiateNoiseHandshake(any())
}
@Test
fun `direct send remains retained until delivery acknowledgement`() {
peerReady()
router.sendPrivate("direct", peerID, "peer", "msg-direct")
clearInvocations(mesh)
fakeTime += 31_000
router.tickOutbox()
verify(mesh, times(1)).sendPrivateMessage("direct", peerID, "peer", "msg-direct")
router.onMessageAcknowledged("msg-direct", peerID)
clearInvocations(mesh)
router.tickOutbox()
verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull())
}
@Test
fun `queued message survives router recreation`() {
peerOffline()
router.sendPrivate("durable", peerID, "peer", "msg-durable")
MessageRouter.resetForTesting()
router = MessageRouter.getInstance(RuntimeEnvironment.getApplication(), mesh)
router.clock = { fakeTime }
peerReady()
router.onSessionEstablished(peerID)
verify(mesh).sendPrivateMessage("durable", peerID, "peer", "msg-durable")
}
@Test
fun `offline cached contact deposits once with a verified peer courier`() {
val recipientNoiseKey = ByteArray(32) { 0x2A }
val recipientPeerID = ContactIdentityResolver.peerIdForNoiseKey(recipientNoiseKey)
val courierPeerID = "9999aaaabbbbcccc"
val courierNoiseKey = ByteArray(32) { 0x3B }
identityManager.cachePeerNoiseKey(
recipientPeerID,
ContactIdentityResolver.noiseKeyHex(recipientNoiseKey)
)
whenever(mesh.getPeerInfo(recipientPeerID)).thenReturn(
PeerInfo(
id = recipientPeerID,
nickname = "offline contact",
isConnected = false,
isDirectConnection = false,
noisePublicKey = recipientNoiseKey,
signingPublicKey = ByteArray(32) { 0x0A },
isVerifiedNickname = false,
lastSeen = fakeTime
)
)
whenever(mesh.getPeerInfos()).thenReturn(
listOf(
PeerInfo(
id = courierPeerID,
nickname = "verified courier",
isConnected = true,
isDirectConnection = true,
noisePublicKey = courierNoiseKey,
signingPublicKey = ByteArray(32) { 0x0C },
isVerifiedNickname = false,
lastSeen = fakeTime,
hasVerifiedAnnouncement = true
)
)
)
whenever(mesh.sendCourierMessage(any(), any(), any(), any())).thenReturn(listOf(courierPeerID))
val result = router.sendPrivate("courier payload", recipientPeerID, "offline contact", "msg-courier")
assertEquals(MessageRouter.RouteResult.QUEUED, result)
verify(mesh).sendCourierMessage(
eq("courier payload"),
eq("msg-courier"),
argThat { contentEquals(recipientNoiseKey) },
eq(listOf(courierPeerID))
)
router.tickOutbox()
verify(mesh, times(1)).sendCourierMessage(
eq("courier payload"),
eq("msg-courier"),
argThat { contentEquals(recipientNoiseKey) },
eq(listOf(courierPeerID))
)
}
@Test
fun `courier acknowledgement clears an offline contact outbox entry before direct reconnect`() {
val recipientNoiseKey = ByteArray(32) { 0x4A }
val recipientPeerID = ContactIdentityResolver.peerIdForNoiseKey(recipientNoiseKey)
identityManager.cachePeerNoiseKey(
recipientPeerID,
ContactIdentityResolver.noiseKeyHex(recipientNoiseKey)
)
whenever(mesh.getPeerNicknames()).thenReturn(mapOf(recipientPeerID to "offline contact"))
whenever(mesh.getPeerInfo(recipientPeerID)).thenReturn(
PeerInfo(
id = recipientPeerID,
nickname = "offline contact",
isConnected = false,
isDirectConnection = false,
noisePublicKey = recipientNoiseKey,
signingPublicKey = ByteArray(32) { 0x0A },
isVerifiedNickname = false,
lastSeen = fakeTime
)
)
assertEquals(
MessageRouter.RouteResult.QUEUED,
router.sendPrivate("courier payload", recipientPeerID, "offline contact", "msg-courier-ack")
)
router.onMessageAcknowledged("msg-courier-ack", recipientPeerID)
whenever(mesh.getPeerInfo(recipientPeerID)).thenReturn(
PeerInfo(
id = recipientPeerID,
nickname = "offline contact",
isConnected = true,
isDirectConnection = true,
noisePublicKey = recipientNoiseKey,
signingPublicKey = ByteArray(32) { 0x0A },
isVerifiedNickname = false,
lastSeen = fakeTime
)
)
whenever(mesh.hasEstablishedSession(recipientPeerID)).thenReturn(true)
router.tickOutbox()
verify(mesh, never()).sendPrivateMessage(
eq("courier payload"),
eq(recipientPeerID),
any(),
eq("msg-courier-ack")
)
}
@Test
fun `outbox stops transport retries after eight attempts`() {
peerReady()
router.sendPrivate("bounded", peerID, "peer", "msg-bounded")
repeat(10) {
fakeTime += 10 * 60_000L + 1
router.tickOutbox()
}
verify(mesh, times(8)).sendPrivateMessage("bounded", peerID, "peer", "msg-bounded")
}
@Test
fun `scheduler stops with the mesh service and restarts on rebind`() {
MessageRouter.disableSchedulerForTesting = false

View File

@ -18,6 +18,7 @@ class GCSFilterTest {
// Build filter with plenty of bytes (no trimming)
val params = GCSFilter.buildFilter(ids, maxBytes = 400, targetFpr = 0.01)
assertEquals(ids.size, params.includedCount)
val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data)
for (id in ids) {
@ -47,7 +48,7 @@ class GCSFilterTest {
val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data)
// Let's verify that the first trimmedN elements in ids are all matched
val trimmedN = (params.m ushr params.p).toInt()
val trimmedN = params.includedCount
assertTrue("At least some elements should have been encoded", trimmedN > 0)
val retainedIds = ids.take(trimmedN)

View File

@ -0,0 +1,223 @@
package com.bitchat.android.sync
import android.content.ContextWrapper
import android.os.Build
import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import kotlinx.coroutines.test.TestScope
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
import java.nio.file.Files
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
class GossipSyncManagerTest {
private val config = object : GossipSyncManager.ConfigProvider {
override fun seenCapacity() = 100
override fun gcsMaxBytes() = 400
override fun gcsTargetFpr() = 0.01
}
@Test
fun `whole messages retain six hours while fragments retain fifteen minutes`() {
val sent = mutableListOf<BitchatPacket>()
val manager = GossipSyncManager("1111222233334444", TestScope(), config)
manager.delegate = object : GossipSyncManager.Delegate {
override fun sendPacket(packet: BitchatPacket) = Unit
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { sent += packet }
override fun signPacketForBroadcast(packet: BitchatPacket) = packet
}
val now = System.currentTimeMillis()
manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now - 5 * 60 * 60 * 1000L, 1))
manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now - 7 * 60 * 60 * 1000L, 2))
manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now - 10 * 60 * 1000L, 3))
manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now - 20 * 60 * 1000L, 4))
manager.onPublicPacketSeen(packet(MessageType.FILE_TRANSFER, now - 10 * 60 * 1000L, 5))
manager.handleRequestSync(
"peer",
RequestSyncPacket(
p = 1,
m = 1,
data = byteArrayOf(),
types = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES)
)
)
assertEquals(listOf(1, 3, 5), sent.map { it.payload.single().toInt() })
}
@Test
fun `public history tolerates bounded future clock skew`() {
val sent = mutableListOf<BitchatPacket>()
val manager = GossipSyncManager("1111222233334444", TestScope(), config)
manager.delegate = object : GossipSyncManager.Delegate {
override fun sendPacket(packet: BitchatPacket) = Unit
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { sent += packet }
override fun signPacketForBroadcast(packet: BitchatPacket) = packet
}
val now = System.currentTimeMillis()
manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now + 60_000L, 1))
manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now + 60_000L, 2))
manager.onPublicPacketSeen(packet(MessageType.FILE_TRANSFER, now + 60_000L, 3))
manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now + 11 * 60_000L, 4))
manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now + 11 * 60_000L, 5))
manager.handleRequestSync(
"peer",
RequestSyncPacket(
p = 1,
m = 1,
data = byteArrayOf(),
types = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES)
)
)
assertEquals(listOf(1, 2, 3), sent.map { it.payload.single().toInt() })
}
@Test
fun `type scoped fragment request does not replay other packet classes`() {
val sent = mutableListOf<BitchatPacket>()
val manager = GossipSyncManager("1111222233334444", TestScope(), config)
manager.delegate = recordingDelegate(sent)
val now = System.currentTimeMillis()
manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now, 1))
manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now, 2))
manager.onPublicPacketSeen(packet(MessageType.FILE_TRANSFER, now, 3))
manager.handleRequestSync(
"peer",
RequestSyncPacket(
p = 1,
m = 1,
data = byteArrayOf(),
types = SyncTypeFlags.FRAGMENT
)
)
assertEquals(listOf(2), sent.map { it.payload.single().toInt() })
}
@Test
fun `coverage cursor prevents replay of an identical history tail`() {
val tinyFilterConfig = object : GossipSyncManager.ConfigProvider {
override fun seenCapacity() = 100
override fun gcsMaxBytes() = 1
override fun gcsTargetFpr() = 0.01
}
val requester = GossipSyncManager("1111222233334444", TestScope(), tinyFilterConfig)
val responder = GossipSyncManager("5555666677778888", TestScope(), tinyFilterConfig)
val now = System.currentTimeMillis()
val history = listOf(
packet(MessageType.MESSAGE, now - 100, 1),
packet(MessageType.MESSAGE, now - 200, 2),
packet(MessageType.MESSAGE, now - 300, 3)
)
history.forEach {
requester.onPublicPacketSeen(it)
responder.onPublicPacketSeen(it)
}
val request = RequestSyncPacket.decode(
requester.buildGcsPayload(SyncTypeFlags.PUBLIC_MESSAGES)
)!!
val sent = mutableListOf<BitchatPacket>()
responder.delegate = recordingDelegate(sent)
responder.handleRequestSync("peer", request)
assertEquals(SyncTypeFlags.PUBLIC_MESSAGES, request.types)
assertEquals(history.first().timestamp, request.sinceTimestamp)
assertEquals(emptyList<Int>(), sent.map { it.payload.single().toInt() })
}
@Test
fun `legacy request without type metadata remains public message only`() {
val sent = mutableListOf<BitchatPacket>()
val manager = GossipSyncManager("1111222233334444", TestScope(), config)
manager.delegate = recordingDelegate(sent)
val now = System.currentTimeMillis()
manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now, 1))
manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now, 2))
manager.handleRequestSync("peer", RequestSyncPacket(p = 1, m = 1, data = byteArrayOf()))
assertEquals(listOf(1), sent.map { it.payload.single().toInt() })
}
@Test
fun `public history survives manager recreation for post-reconnect sync`() {
val filesDir = Files.createTempDirectory("gossip-sync-test-").toFile()
val context = object : ContextWrapper(RuntimeEnvironment.getApplication()) {
override fun getApplicationContext() = this
override fun getFilesDir(): File = filesDir
}
try {
val now = System.currentTimeMillis()
GossipSyncManager("1111222233334444", TestScope(), config, context)
.onPublicPacketSeen(packet(MessageType.MESSAGE, now, 42))
val sent = mutableListOf<BitchatPacket>()
val restored = GossipSyncManager("1111222233334444", TestScope(), config, context)
restored.delegate = recordingDelegate(sent)
restored.handleRequestSync(
"peer",
RequestSyncPacket(
p = 1,
m = 1,
data = byteArrayOf(),
types = SyncTypeFlags.PUBLIC_MESSAGES
)
)
assertEquals(listOf(42), sent.map { it.payload.single().toInt() })
} finally {
filesDir.deleteRecursively()
}
}
@Test
fun `announcement-only request returns the latest announcement independently of message history`() {
val sent = mutableListOf<BitchatPacket>()
val manager = GossipSyncManager("1111222233334444", TestScope(), config)
manager.delegate = recordingDelegate(sent)
val now = System.currentTimeMillis()
manager.onPublicPacketSeen(packet(MessageType.ANNOUNCE, now - 1_000, 6))
manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now, 7))
manager.handleRequestSync(
"peer",
RequestSyncPacket(
p = 1,
m = 1,
data = byteArrayOf(),
types = SyncTypeFlags.ANNOUNCE
)
)
assertEquals(listOf(6), sent.map { it.payload.single().toInt() })
}
private fun recordingDelegate(sent: MutableList<BitchatPacket>) =
object : GossipSyncManager.Delegate {
override fun sendPacket(packet: BitchatPacket) = Unit
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
sent += packet
}
override fun signPacketForBroadcast(packet: BitchatPacket) = packet
}
private fun packet(type: MessageType, timestamp: Long, marker: Int) = BitchatPacket(
type = type.value,
senderID = ByteArray(8) { 1 },
timestamp = timestamp.toULong(),
payload = byteArrayOf(marker.toByte()),
ttl = 3u
)
}

View File

@ -0,0 +1,36 @@
package com.bitchat.android.sync
import com.bitchat.android.protocol.MessageType
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SyncTypeFlagsTest {
@Test
fun `wire encoding uses compact little endian iOS bit positions`() {
val flags = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES)
assertArrayEquals(byteArrayOf(0xa3.toByte()), flags.encode())
assertEquals(flags, SyncTypeFlags.decode(byteArrayOf(0xa3.toByte())))
assertTrue(flags.contains(MessageType.ANNOUNCE))
assertTrue(flags.contains(MessageType.FILE_TRANSFER))
assertFalse(flags.contains(MessageType.NOISE_HANDSHAKE))
}
@Test
fun `unknown extended bits are normalized away`() {
val decoded = SyncTypeFlags.decode(byteArrayOf(0x03, 0xfc.toByte()))!!
assertEquals(SyncTypeFlags.PUBLIC_MESSAGES, decoded)
assertArrayEquals(byteArrayOf(0x03), decoded.encode())
}
@Test
fun `empty and oversized fields are rejected`() {
assertNull(SyncTypeFlags.decode(byteArrayOf()))
assertNull(SyncTypeFlags.decode(ByteArray(9)))
}
}

View File

@ -29,16 +29,9 @@ Existing TLVs (unchanged):
Other optional extension:
- `0x05` CAPABILITIES: Minimal little-endian feature bitfield. Assigned bits
match iOS: 0 prekeys, 1 Wi-Fi bulk, 2 gateway, 3 private groups, 4 board,
5 vouch, 6 mesh diagnostics, 7 bridge, 8 authenticated Noise private media,
9 private-media receipts, and 10 reserved non-destructive Noise replacement.
Bit 8's exact value is `00 01`. An empty value is valid and means no
advertised capabilities. For this announcement TLV, unknown bits and bytes
beyond the low 64 bits must be tolerated for forward compatibility. This
tolerance does not apply to the Noise-authenticated peer-state payload
`0x21`, whose capability field must be canonical and 1...8 bytes; see
`PRIVATE_MEDIA_V1.md`.
- `0x05` CAPABILITIES: Minimal little-endian feature bitfield. Bit 8 advertises
authenticated Noise private media (`PRIVATE_MEDIA_V1`); its exact value is
`00 01`. An empty value is valid and means no advertised capabilities.
New TLV (optional):
@ -76,12 +69,6 @@ This matches the on‑wire 8‑byte `senderID`/`recipientID` encoding used in th
security-sensitive bit is trusted only after the signed announcement key is
bound to the matching remote static key from a live Noise handshake; see
`PRIVATE_MEDIA_V1.md`.
- Capability bits are independent discovery hints. Implementations advertise
only wire features they implement and must not infer support from platform,
app version, or unrelated transports with similar names.
- Private-media receipts and retry (bit 9) require the current Noise
generation's authenticated `0x21` peer state to prove both bits 8 and 9. An
announcement alone never authorizes that behavior.
- If a `0x04` TLV is present:
- Interpret the value as `N = length / 8` peer IDs (ignore trailing non‑aligned bytes).
- Each 8‑byte chunk is decoded back to a 16‑hex‑char peer ID string (lowercase).

124
docs/PR-977-TEST.md Normal file
View File

@ -0,0 +1,124 @@
# PR-977 test notes
This note records the test coverage and findings added while validating the
expanded store-and-forward work. All test content and physical-device evidence
used synthetic data. Raw device evidence remains local and is not committed.
## Coverage matrix
| Capability | Automated coverage | Physical Mesh Lab coverage | Result |
|---|---|---|---|
| Durable private-message outbox | `MessageOutboxStoreTest`, `MessageRouterTest` | `durable_outbox`: recipient offline, sender process death, then delivery after reconnection | Pass |
| Direct and peer-courier routing | `CourierEnvelopeTest`, `CourierStoreTest`, `DirectCourierDepositPolicyTest`, `MessageRouterTest` | `courier_contract` | Pass for two-phone contracts; three-party delivery remains blocked below |
| Courier Noise X confidentiality and sender authentication | `NoiseCourierTest`, `BridgeCourierServiceTest` | N/A | Pass |
| Courier delivery acknowledgements | `MessageHandlerTest`, `MessageRouterTest` | Three-party courier delivery pending | Automated pass; physical blocked below |
| Courier fragmentation and custody commit | `FragmentingPacketSenderTest`, `CourierStoreTest` | `courier_contract` | Pass |
| Nostr kind-1401 bridge wire contract | `BridgeCourierServiceTest` with a deterministic in-memory relay | N/A | Pass |
| Relay acceptance before bridge completion | `BridgeCourierServiceTest` | N/A | Pass |
| Public-history retention and recovery | `GossipSyncManagerTest` | `sync_auto_recovery` and `sync_recovery` | Pass |
| Public file and fragment recovery | `GossipSyncManagerTest` | `sync_file_recovery` with a synthetic digest | Pass |
| Ordinary mesh regressions | Existing unit suite | `broadcast`, `dm`, `favorite_verification` | Pass |
The deterministic bridge test checks signed kind-1401 events, recipient and
expiry tags, receiver filtering/deduplication, size limits, rejection of
inconsistent metadata, and the rule that the outbox completion callback waits
for relay acceptance. `CourierEnvelopeTest` separately covers rotating recipient
tags. No test connects to public relays.
## Commands completed
- `./gradlew --no-daemon :app:testDebugUnitTest --max-workers=1`
- `./gradlew --no-daemon :wear:testDebugUnitTest --max-workers=1`
- `./gradlew --no-daemon lintDebug clientRewriteContractTest --max-workers=1`
- `./gradlew --no-daemon :app:assembleDebug --max-workers=1`
- `python3 -m py_compile tools/release_gate/mesh_lab.py`
- `git diff --check`
## Physical Mesh Lab results
The completed scenarios used two authorized phones in logical A/B roles and a
current debug build. They passed with synthetic message and file content:
- `broadcast`
- `dm`
- `favorite_verification`
- `courier_contract`
- `durable_outbox`
- `sync_recovery`
- `sync_auto_recovery`
- `sync_file_recovery`
`durable_outbox` exercises the production `MessageRouter` path, queues while
the recipient is actually offline, force-stops the sender, recreates the
router, and verifies receiver-side delivery only after reconnection.
`sync_auto_recovery` intentionally does not call the debug sync-request hook.
`sync_file_recovery` verifies receiver-side digest equality after replaying a
missed public file and its fragments.
## Findings
### Fixed: offline known contacts could skip peer-courier deposit
**Impact:** An offline known contact could resolve to its canonical contact ID
without recovering its cached Noise public key. The router then had no
recipient key and skipped peer-courier deposit entirely.
**Fix:** Contact resolution now recovers only a fingerprint-validated cached
Noise key for that canonical contact. The `ContactDirectoryTest` and
`MessageRouterTest` regressions cover resolution and the resulting single
courier deposit.
### Fixed: public-history archive could be lost on filesystems without atomic move
**Impact:** Persisting the public gossip archive silently discarded the
temporary archive when atomic rename was unsupported, defeating recovery after
restart on those filesystems.
**Fix:** Archive persistence now falls back to a normal replace-existing move
only when atomic move is unavailable. `GossipSyncManagerTest` verifies history
survives manager recreation.
### Fixed: Mesh Lab direct-link assertion produced a false failure
**Impact:** The runner required each endpoint to independently establish a
GATT client connection. One direct GATT link is bidirectional, so a healthy
link could be incorrectly reported as failed after one endpoint restarted.
**Fix:** `ensure_direct_link` now accepts a direct link observed from either
endpoint.
### Test-harness isolation improvement: debug transport toggles alone were insufficient
**Observation:** Disabling debug transports did not always make a counterpart
observe link loss promptly enough for an offline-routing test.
**Change:** Offline scenarios now force-stop the test app and temporarily turn
off the Bluetooth adapter before asserting the recipient is absent. This is a
test-isolation improvement; it is not classified as a production defect.
### Test infrastructure finding: concurrent test worker crashed before assertions completed
**Observation:** The initial combined Gradle invocation ended with a JVM-level
test-worker crash during the app unit-test task, before any test assertion
failure was reported.
**Resolution:** Re-running the app and Wear unit suites serially in fresh,
single-use Gradle processes passed. This is recorded as local test-runtime
instability, not as a product defect. The raw crash diagnostic remains local
and is not committed.
## Remaining coverage limits
- `courier_delivery` is implemented as a three-phone A→B→C scenario: C is
offline for the deposit, A then goes offline, and C returns to receive only
through B. It is **blocked (not run)** because only two authorized phones are
currently attached. A third phone is required; reusing C offline/online is
already part of the scenario.
- No public Nostr relay or desktop courier bridge was used. The bridge has a
deterministic in-memory relay contract test, but live desktop/relay
interoperability remains **not run**.
- Android/iOS live courier interoperability remains **not run**.
No unresolved product defects were found in the completed automated and
two-phone physical coverage.

View File

@ -15,8 +15,9 @@ The remaining implementation work and milestone progress are tracked in
| Outer mesh packet | v1/v2 header widths, big-endian fields, flags, section order, route placement, signature placement, padding, compression, signing bytes | `BinaryProtocolTest`, `ClientRewriteWireContractTest` |
| Chat payload | Flag bits, millisecond timestamp, UTF-8 byte lengths, encrypted-content substitution, optional-field order | `ClientRewriteWireContractTest` |
| Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, live-voice bursts, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `VoiceBurstPacketTest`, `FragmentManagerTest` |
| Store and forward | Courier type `0x04`, rotating HMAC recipient tags, Noise X seals, copy-budget and prekey-ID TLVs, 24-hour expiry, bounded tiered custody | `CourierEnvelopeTest`, `NoiseCourierTest`, `MessageRouterTest` |
| Identity/security | Announcement extensions, capability bitfield endianness, Noise static-key binding, handshake identity binding, signatures | `IdentityAnnouncementTest`, `NoiseSessionManagerIdentityBindingTest`, `ClientRewritePrimitiveContractTest` |
| Sync/routing | Stable packet IDs, GCS bitstream, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` |
| Sync/routing | Stable packet IDs, GCS bitstream, type-scoped filters, bounded-history cursors, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewriteWireContractTest`, `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `GossipSyncManagerTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` |
| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals, 22h outbound envelope randomization | `ClientRewriteNostrContractTest`, `NostrProtocolTest` |
| Application state | Peer unions, canonical private conversations, chronological history, delivery/read behavior, media migration policy | `AppStateStoreTest`, `PrivateChatManagerTest`, `MediaSendingManagerMigrationTest` |
@ -58,8 +59,8 @@ shipping a rewrite, run the following on at least two physical devices:
2. Runtime permission denial/retry for Bluetooth, location, notifications, and
microphone.
3. Foreground-service survival with the screen off and after process recreation.
4. Cross-client Android/iOS exchange for announce, public/private text, delivery
and read receipts, image/audio/file transfer, sync replay, and Nostr fallback.
4. Cross-client Android/iOS exchange for announce, public/private text, courier
deposit/handover, delivery/read receipts, media, six-hour sync replay, and Nostr fallback.
5. Corrupt, duplicated, reordered, delayed, and partially delivered fragments.
6. Identity rotation, verification continuity, downgrade rejection, and recovery
after stale Noise sessions.

View File

@ -298,6 +298,12 @@ python3 tools/release_gate/mesh_lab.py scenario all \
| `dm` | Noise handshake both ways, encrypted DM round trips with content match |
| `favorite_verification` | favorite signal, orange-outline/filled mutual state, and peer fingerprint verification |
| `broadcast` | public mesh message A→B |
| `sync_recovery` | message missed while B's BLE and Wi-Fi Aware transports are disabled is recovered by the gossip sync request after reconnection |
| `sync_auto_recovery` | message missed while B is offline is recovered after restart by the production gossip scheduler, without a test-hook sync request |
| `sync_file_recovery` | broadcast file missed while B is offline is recovered through sync; validates the synthetic file digest and fragment replay |
| `durable_outbox` | a router-queued DM survives sender process death while the recipient is offline, then reaches the recipient after reconnection |
| `courier_delivery` | three-phone sender→courier→recipient handoff: recipient is offline for deposit and sender remains offline for recipient pickup |
| `courier_contract` | on-device courier wire/store contract: prekey TLV retention, split reservations, cancellation, reverse commits, and persisted spray history |
| `ptt_dm` | Noise-encrypted 440 Hz PTT in both directions; asserts real-time capture, zero sequence gaps, decoded PCM duration/energy/continuity, and finalized-note absorption |
| `ptt_broadcast` | signed public 440 Hz PTT with the same bidirectional packet and decoded-audio quality assertions |
| `file` | 1 KB broadcast file, receiver SHA-256 matches fixture |
@ -307,7 +313,7 @@ python3 tools/release_gate/mesh_lab.py scenario all \
| `raw` | raw packet injection is accepted by the mesh |
| `session_recovery` | force-stop B mid-session: identity persists, re-handshake, DMs flow again |
| `identity_reset` | pm clear B mid-session: new identity, rediscovery, handshake, DMs |
| `all` | every scenario above in sequence |
| `all` | every supported two-phone scenario in sequence, plus `courier_delivery` when a third phone is supplied |
Each run writes `<scenario>-evidence.json` to `--out` (digests, timings,
session states, logcat excerpts on failure) and exits non-zero on failure.
@ -326,10 +332,15 @@ python3 tools/release_gate/mesh_lab.py cmd --serial <serial> state # full mesh
See `TestHookDriver.kt` for the full command set (`ping`, `start`, `stop`,
`whoami`, `set_nickname`, `scan`, `peers`, `connect`, `handshake`, `session`,
`announce`, `broadcast_msg`, `dm_send`, `dm_recv`, `msg_recv`, `favorite_set`,
`announce`, `broadcast_msg`, `dm_send`, `router_private_send`, `router_resume`,
`dm_recv`, `msg_recv`, `favorite_set`,
`favorite_status`, `verification_set`, `verification_status`, `file_send`,
`file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `ble`, `state`,
`clear_results`).
`file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `courier_contract`,
`cache_peer_identity`, `sync_request`, `ble`, `wifi_aware`, `state`, `clear_results`).
`msg_recv` accepts `include_existing=true` for deterministic backfill checks when
the packet's original timestamp predates the receive hook; use a unique synthetic
content token with this mode.
### Troubleshooting

View File

@ -1,17 +1,19 @@
# GCS Filter Sync (REQUEST_SYNC)
This document specifies the gossip-based synchronization feature for BitChat, inspired by Plumtree. It ensures eventual consistency of public packets (ANNOUNCE and broadcast MESSAGE) across nodes via periodic sync requests containing a compact Golomb‑Coded Set (GCS) of recently seen packets.
This document specifies the gossip-based synchronization feature for BitChat, inspired by Plumtree. It ensures eventual consistency of public packets across nodes via periodic, type-scoped sync requests containing a compact Golomb‑Coded Set (GCS) of recently seen packets.
## Overview
- Each node maintains a rolling set of public BitChat packets it has seen recently:
- Broadcast messages (MessageType.MESSAGE where recipient is broadcast)
- Identity announcements (MessageType.ANNOUNCE)
- Default retention is 100 recent packets (configurable in the debug sheet). This value is the maximum number of packets that are synchronized per request (across both types combined).
- Broadcast fragments (MessageType.FRAGMENT)
- Broadcast file transfers (MessageType.FILE_TRANSFER)
- Default retention is 100 recent packets per local store (configurable in the debug sheet).
- Nodes do not maintain a rolling Bloom filter. Instead, they compute a GCS filter on demand when sending a REQUEST_SYNC.
- Every 30 seconds, a node sends a REQUEST_SYNC packet to all immediate neighbors (local only; not relayed).
- Additionally, 5 seconds after the first announcement from a newly directly connected peer is detected, a node sends a REQUEST_SYNC only to that peer (unicast; local only).
- The receiver checks which packets are not in the sender’s filter and sends those packets back. For announcements, only the latest announcement per peerID is sent; for broadcast messages, all missing ones are sent.
- Every 30 seconds, Android sends separate public-message, fragment, and file-transfer REQUEST_SYNC rounds to immediate neighbors (local only; not relayed). Keeping the filters type-scoped prevents one high-volume class from crowding another out.
- Additionally, 5 seconds after the first announcement from a newly directly connected peer is detected, a node sends a combined type-scoped REQUEST_SYNC only to that peer (unicast; local only).
- The receiver checks only the packet types named by the request and sends packets absent from the filter. For announcements, only the latest announcement per peerID is sent.
This synchronization is strictly local (not relayed), ensuring only immediate neighbors participate and preventing wide-area flooding while converging content across the mesh.
@ -36,9 +38,7 @@ Implementation: `com.bitchat.android.sync.GCSFilter`.
- Maximum number of elements that fit into the filter is estimated as: N_max ≈ floor((8 * sizeBytes) / (P + 2))
- This estimate is used to cap the set; the actual encoder will trim further if needed to stay within the configured size.
- What goes into the set:
- Combine the following and sort by packet timestamp (descending):
- Broadcast messages (MessageType 1)
- The most recent ANNOUNCE per peer
- Select only the classes named by the request's `types` field, then sort matching packets by timestamp (descending). Android stores broadcast messages, the most recent ANNOUNCE per peer, broadcast fragments, and broadcast file transfers.
- Take at most `min(N_max, maxPacketsPerSync)` items from this ordered list.
- Compute the 16-byte Packet ID (see below), then for hashing use the first 8 bytes of SHA‑256 over the 16‑byte ID.
- Map each hash to [0, M) with M = N * 2^P; sort ascending and encode deltas with Golomb‑Rice parameter P.
@ -56,6 +56,10 @@ MessageType: `REQUEST_SYNC (0x21)`
- 0x01: P (uint8) — Golomb‑Rice parameter
- 0x02: M (uint32) — hash range N * 2^P
- 0x03: data (opaque) — GCS bitstream (MSB‑first bit packing)
- 0x04: types (1–8 byte compact little-endian bitfield) — packet classes represented by this filter
- 0x05: sinceTimestamp (uint64, big-endian) — timestamp of the oldest packet represented when the candidate tail did not fit
`types` uses the cross-client bit mapping: ANNOUNCE=bit 0, MESSAGE=bit 1, LEAVE=bit 2, NOISE_HANDSHAKE=bit 3, NOISE_ENCRYPTED=bit 4, FRAGMENT=bit 5, REQUEST_SYNC=bit 6, and FILE_TRANSFER=bit 7. Implementations ignore bits they do not recognize. When `types` is absent, receivers use the legacy scope of ANNOUNCE plus broadcast MESSAGE.
Notes:
- The GCS bitstream uses MSB‑first packing (bit 7 is the first bit in each byte).
@ -71,7 +75,9 @@ Sender behavior:
Receiver behavior:
- Decode the REQUEST_SYNC payload and reconstruct the sorted set of mapped values using the provided P, M, and bitstream.
- For each locally stored public packet ID:
- Restrict the diff to the packet classes in `types` (or the legacy public-message scope when absent).
- For each locally stored packet ID in that scope:
- If `sinceTimestamp` is present, skip messages, fragments, and files older than it because they were outside the requester's bounded filter rather than missing. ANNOUNCE is exempt so a peer can still obtain current verification keys.
- Compute h64(ID) % M and check if it is in the reconstructed set; if NOT present, send the original packet back with `ttl=0` to the requester only.
- For announcements, send only the latest announcement per (sender peerID).
- For broadcast messages, send all missing ones.
@ -90,6 +96,8 @@ Important: original packets are sent unmodified to preserve original signatures
Included in sync:
- Public broadcast messages: `MessageType.MESSAGE` with BROADCAST recipient (or null recipient).
- Identity announcements: `MessageType.ANNOUNCE`.
- Broadcast fragments: `MessageType.FRAGMENT` with BROADCAST recipient (or null recipient).
- Broadcast files: `MessageType.FILE_TRANSFER` with BROADCAST recipient (or null recipient).
- Both packets produced by other peers and packets produced by the requester itself MUST be represented in the requester’s GCS; the responder MUST track and consider its own produced public packets as candidates to return when they are missing on the requester.
- Announcements included in the GCS MUST be at most 60 seconds old at the time of filter construction; older announcements are excluded by pruning.
@ -130,8 +138,8 @@ The following items require consensus across all implementations to ensure inter
- Packet ID recipe: first 16 bytes of SHA‑256(type | senderID | timestamp | payload).
- GCS hashing function and mapping to [0, M) as specified above (v1), and MSB‑first bit packing for the bitstream.
- Payload encoding: TLV with 16‑bit big‑endian lengths; TLV types 0x01 = P (uint8), 0x02 = M (uint32), 0x03 = data (opaque).
- Packet type and scope: REQUEST_SYNC = 0x21; local-only (not relayed); only ANNOUNCE and broadcast MESSAGE are synchronized; ANNOUNCE de‑dupe is “latest per sender peerID”.
- Payload encoding: TLV with 16‑bit big‑endian lengths; TLV types 0x01 = P (uint8), 0x02 = M (uint32), 0x03 = data (opaque), 0x04 = compact little-endian type flags, and 0x05 = big-endian coverage cursor.
- Packet type and scope: REQUEST_SYNC = 0x21; local-only (not relayed); only requested, supported public packet classes are synchronized; ANNOUNCE de‑dupe is “latest per sender peerID”.
The following are requester‑defined and communicated or local policy (no global agreement required):
@ -142,7 +150,7 @@ The following are requester‑defined and communicated or local policy (no globa
Validation and limits (recommended):
- Reject malformed REQUEST_SYNC payloads (e.g., P < 1, M <= 0, or data length too large for local limits).
- Practical bounds: data length in [0, 1024]; P in [1, 24]; M up to 2^32‑1.
- Practical bounds: data length in [0, 1024]; P in [1, 32]; M up to 2^32‑1.
Versioning:

View File

@ -2147,9 +2147,6 @@
<artifact name="aapt2-9.3.1-15703166-osx.jar">
<sha256 value="1e35bc2ce18c3aae840be2a29659ce50d6043e907a44d98ee1cf375d044fa29c" origin="Generated by Gradle"/>
</artifact>
<artifact name="aapt2-9.3.1-15703166-windows.jar">
<sha256 value="b1006ecec7e5936257e95e97f3eba7ef439d3e44178967cc048f86c9119fb231" origin="Generated by Gradle"/>
</artifact>
<artifact name="aapt2-9.3.1-15703166.pom">
<sha256 value="edb82dcbe738ca78b31612cb307bf99e5f02f25c8125048f933e792fd7778beb" origin="Generated by Gradle"/>
</artifact>
@ -4914,9 +4911,6 @@
</artifact>
</component>
<component group="org.junit" name="junit-bom" version="5.9.2">
<artifact name="junit-bom-5.9.2.module">
<sha256 value="ab137ba5a8e32c9b066bf9126a1c76dd5614b724ba5c0b02549772b5e9f4cf1f" origin="Generated by Gradle"/>
</artifact>
<artifact name="junit-bom-5.9.2.pom">
<sha256 value="2ed07d65845131f5336a86476c9a4056b59d0b58b9815ab3679bb0f36f35f705" origin="Generated by Gradle"/>
</artifact>

View File

@ -166,11 +166,14 @@ class Device:
def reset_bluetooth(self) -> None:
"""Cycle the BT adapter; clears zombie GATT connections from peer restarts."""
_shell(self.serial, "svc bluetooth disable")
self.disable_bluetooth()
time.sleep(2)
_shell(self.serial, "svc bluetooth enable")
time.sleep(3)
def disable_bluetooth(self) -> None:
_shell(self.serial, "svc bluetooth disable")
def enable_bluetooth(self) -> None:
subprocess.run(
[find_adb(), "-s", self.serial, "shell", "svc", "bluetooth", "enable"],
@ -365,6 +368,31 @@ def setup_pair(
wait_for_mutual_discovery(a, b)
def setup_triad(
a: Device,
b: Device,
c: Device,
apk: Path | None,
nickname_a: str,
nickname_b: str,
nickname_c: str,
) -> None:
"""Install and isolate three phones, then ensure every pair can discover each other."""
for device, nickname in ((a, nickname_a), (b, nickname_b), (c, nickname_c)):
device.reset_bluetooth()
device.enable_bluetooth()
if apk is not None:
device.install(apk)
device.clear_app_data()
device.grant_permissions()
device.wake()
device.launch()
device.cmd_ok("start")
device.cmd_ok("set_nickname", name=nickname)
for left, right in ((a, b), (a, c), (b, c)):
wait_for_mutual_discovery(left, right)
def whoami(device: Device) -> dict:
return device.cmd_ok("whoami")
@ -391,6 +419,47 @@ def wait_for_mutual_discovery(a: Device, b: Device) -> None:
fb.result()
def wait_for_peer_absent(device: Device, peer_id: str, timeout_s: int = 90) -> None:
"""Wait until the peer is absent or no longer connected in the visible mesh state."""
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
peers = device.cmd_ok("peers").get("peers", [])
peer = next((item for item in peers if item.get("id") == peer_id), None)
if peer is None or not peer.get("connected", False):
return
time.sleep(2)
raise MeshLabError(f"[{device.alias}] peer {peer_id} remained connected after transport shutdown")
def disable_transports(device: Device) -> bool:
"""Make a phone unreachable through either local mesh transport and return its Wi-Fi setting."""
device.cmd_ok("ble", enabled=False)
return bool(device.cmd_ok("wifi_aware", enabled=False)["previous_enabled"])
def resume_transports(device: Device, wifi_enabled: bool) -> None:
"""Bring a force-stopped phone back as the same identity with its prior Wi-Fi setting."""
device.wake()
device.launch()
device.cmd_ok("start")
device.cmd_ok("ble", enabled=True)
device.cmd_ok("wifi_aware", enabled=wifi_enabled)
def take_device_offline(device: Device) -> bool:
"""Stop the app and radio so the counterpart must observe a real link loss."""
wifi_enabled = disable_transports(device)
device.force_stop()
device.disable_bluetooth()
return wifi_enabled
def bring_device_online(device: Device, wifi_enabled: bool) -> None:
device.enable_bluetooth()
time.sleep(3)
resume_transports(device, wifi_enabled)
# MARK: - scenarios
def scenario_dm(a: Device, b: Device) -> dict:
@ -532,6 +601,306 @@ def scenario_broadcast(a: Device, b: Device) -> dict:
return {"send": send_result, "recv": recv_result}
def scenario_sync_recovery(a: Device, b: Device) -> dict:
"""Miss a public message while B's transports are offline, then receive it via gossip sync."""
id_a = whoami(a)["peer_id"]
id_b = whoami(b)["peer_id"]
token = f"sync-{uuid.uuid4().hex[:8]}"
b.cmd_ok("ble", enabled=False)
wifi_before = b.cmd_ok("wifi_aware", enabled=False)["previous_enabled"]
try:
send_result = a.cmd_ok("broadcast_msg", 30_000, content=f"sync recovery {token}")
# The hook returns after dispatching the send coroutine. Give the sender's
# gossip observer time to retain the packet before taking B offline.
time.sleep(2)
offline_result = b.cmd("msg_recv", 5_000, contains=token, include_existing=True)
if offline_result.get("status") == "ok":
raise MeshLabError(f"message was delivered while receiver transports were disabled: {offline_result}")
# Restart B so the initial sync request is scheduled from a fresh peer
# observation instead of relying on an already-cached relationship.
b.force_stop()
b.wake()
b.launch()
b.cmd_ok("start")
b.cmd_ok("ble", enabled=True)
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
recv = pool.submit(
b.cmd_ok,
"msg_recv",
180_000,
contains=token,
include_existing=True,
)
time.sleep(2)
ensure_direct_link(a, b, id_a, id_b)
b.cmd_ok("sync_request", peer=id_a)
recv_result = recv.result()
assert recv_result["from"] == id_a, recv_result
return {
"send": send_result,
"offline_delivery": {"status": offline_result.get("status", "missing")},
"synced_recv": recv_result,
}
finally:
# Leave the pair usable if an assertion or timeout interrupts the recovery.
try:
b.cmd_ok("ble", enabled=True)
except MeshLabError:
pass
try:
b.cmd_ok("wifi_aware", enabled=wifi_before)
except MeshLabError:
pass
def scenario_sync_auto_recovery(a: Device, b: Device) -> dict:
"""A restarted receiver recovers a missed public message without a test-hook sync request."""
id_a = whoami(a)["peer_id"]
id_b = whoami(b)["peer_id"]
token = f"sync-auto-{uuid.uuid4().hex[:8]}"
wifi_before = disable_transports(b)
try:
send_result = a.cmd_ok("broadcast_msg", 30_000, content=f"automatic sync recovery {token}")
time.sleep(2)
offline_result = b.cmd("msg_recv", 5_000, contains=token, include_existing=True)
if offline_result.get("status") == "ok":
raise MeshLabError(f"message was delivered while receiver transports were disabled: {offline_result}")
b.force_stop()
resume_transports(b, wifi_before)
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
recv = pool.submit(
b.cmd_ok,
"msg_recv",
180_000,
contains=token,
include_existing=True,
)
time.sleep(2)
ensure_direct_link(a, b, id_a, id_b)
# No sync_request command here: this must arrive from the production initial/periodic
# gossip scheduler created by the restarted receiver.
recv_result = recv.result()
assert recv_result["from"] == id_a, recv_result
return {
"send": send_result,
"offline_delivery": {"status": offline_result.get("status", "missing")},
"synced_recv": recv_result,
}
finally:
try:
bring_device_online(b, wifi_before)
except MeshLabError:
pass
def scenario_sync_file_recovery(a: Device, b: Device) -> dict:
"""Sync an offline-missed broadcast transfer, exercising FILE_TRANSFER and FRAGMENT replay."""
id_a = whoami(a)["peer_id"]
id_b = whoami(b)["peer_id"]
fixture = make_fixtures(
Path(tempfile.mkdtemp(prefix="meshlab-sync-file-")),
names=["small_1k.bin"],
)["small_1k.bin"]
name = f"sync-file-{uuid.uuid4().hex[:8]}.bin"
remote = a.push_fixture(fixture["path"], name=name)
b.clear_incoming()
wifi_before = disable_transports(b)
try:
send_result = a.cmd_ok("file_send", 180_000, path=remote)
time.sleep(2)
offline_result = b.cmd("file_recv", 10_000, name_contains=name)
if offline_result.get("status") == "ok":
raise MeshLabError(f"file was delivered while receiver transports were disabled: {offline_result}")
b.force_stop()
resume_transports(b, wifi_before)
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
recv = pool.submit(b.cmd_ok, "file_recv", 240_000, name_contains=name)
time.sleep(2)
ensure_direct_link(a, b, id_a, id_b)
b.cmd_ok("sync_request", peer=id_a)
recv_result = recv.result()
if recv_result["sha256"] != fixture["sha256"]:
raise MeshLabError("synced file digest did not match the synthetic fixture")
return {
"send": send_result,
"offline_delivery": {"status": offline_result.get("status", "missing")},
"synced_recv": {
"name": recv_result["name"],
"bytes": recv_result["bytes"],
"digest_match": True,
},
}
finally:
try:
b.cmd_ok("ble", enabled=True)
except MeshLabError:
pass
try:
b.cmd_ok("wifi_aware", enabled=wifi_before)
except MeshLabError:
pass
def scenario_durable_outbox(a: Device, b: Device) -> dict:
"""Queue a private message offline, kill the sender, and deliver it after both phones return."""
id_a = whoami(a)["peer_id"]
id_b = whoami(b)["peer_id"]
ensure_direct_link(a, b, id_a, id_b)
force_handshake(a, id_b)
force_handshake(b, id_a)
a.cmd_ok("cache_peer_identity", peer=id_b)
token = f"durable-outbox-{uuid.uuid4().hex[:8]}"
message_id = f"outbox-{uuid.uuid4().hex}"
wifi_before = take_device_offline(b)
try:
wait_for_peer_absent(a, id_b)
queued: dict | None = None
for _attempt in range(5):
candidate = a.cmd_ok(
"router_private_send",
peer=id_b,
content=f"durable outbox {token}",
msg_id=message_id,
)
if candidate.get("route") == "QUEUED":
queued = candidate
break
time.sleep(3)
if queued is None:
raise MeshLabError("private message did not enter the durable outbox while recipient was offline")
# The queued entry must survive a complete sender process death, not just an in-memory
# reconnect. B is restored before A so its receiver is ready for the resumed delivery.
a.force_stop()
bring_device_online(b, wifi_before)
a.wake()
a.launch()
a.cmd_ok("start")
a.cmd_ok("router_resume")
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
recv = pool.submit(b.cmd_ok, "dm_recv", 180_000, peer=id_a, contains=token)
time.sleep(2)
ensure_direct_link(a, b, id_a, id_b)
force_handshake(a, id_b)
force_handshake(b, id_a)
recv_result = recv.result()
assert recv_result["from"] == id_a, recv_result
return {
"queued_route": queued["route"],
"sender_process_restarted": True,
"received_after_restart": {"msg_id": recv_result["msg_id"], "from_sender": True},
}
finally:
try:
bring_device_online(b, wifi_before)
except MeshLabError:
pass
def _make_mutual_favorites(a: Device, b: Device, id_a: str, id_b: str) -> None:
"""Establish a trusted direct A↔B relationship for a courier deposit."""
ensure_direct_link(a, b, id_a, id_b)
force_handshake(a, id_b)
force_handshake(b, id_a)
a.cmd_ok("favorite_set", peer=id_b, enabled=True)
b.cmd_ok("favorite_set", peer=id_a, enabled=True)
deadline = time.monotonic() + 45
while time.monotonic() < deadline:
a_status = a.cmd_ok("favorite_status", peer=id_b)
b_status = b.cmd_ok("favorite_status", peer=id_a)
if a_status.get("is_mutual") and b_status.get("is_mutual"):
return
time.sleep(1)
raise MeshLabError("courier depositor and courier did not establish a mutual favorite relationship")
def scenario_courier_delivery(a: Device, b: Device, c: Device) -> dict:
"""A deposits to B while C is offline; C returns only after A is unavailable."""
id_a = whoami(a)["peer_id"]
id_b = whoami(b)["peer_id"]
id_c = whoami(c)["peer_id"]
_make_mutual_favorites(a, b, id_a, id_b)
ensure_direct_link(a, c, id_a, id_c)
a.cmd_ok("cache_peer_identity", peer=id_c)
token = f"courier-delivery-{uuid.uuid4().hex[:8]}"
message_id = f"courier-{uuid.uuid4().hex}"
wifi_c = take_device_offline(c)
wifi_a: bool | None = None
try:
wait_for_peer_absent(a, id_c)
ensure_direct_link(a, b, id_a, id_b)
queued: dict | None = None
for _attempt in range(5):
candidate = a.cmd_ok(
"router_private_send",
peer=id_c,
content=f"courier delivery {token}",
msg_id=message_id,
)
if candidate.get("route") == "QUEUED":
queued = candidate
break
time.sleep(3)
if queued is None:
raise MeshLabError("offline recipient message did not enter the courier-capable outbox")
# A must be unavailable when C returns. This prevents the sender's direct outbox retry
# from masking whether B actually retained and handed over the courier envelope.
wifi_a = take_device_offline(a)
wait_for_peer_absent(b, id_a)
bring_device_online(c, wifi_c)
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
recv = pool.submit(c.cmd_ok, "dm_recv", 240_000, peer=id_a, contains=token)
time.sleep(2)
ensure_direct_link(b, c, id_b, id_c)
c.cmd_ok("announce")
recv_result = recv.result()
assert recv_result["from"] == id_a, recv_result
return {
"queued_route": queued["route"],
"sender_offline_before_recipient_return": True,
"received_from_original_sender": True,
"received_message_id": recv_result["msg_id"],
}
finally:
try:
bring_device_online(c, wifi_c)
except MeshLabError:
pass
if wifi_a is not None:
try:
bring_device_online(a, wifi_a)
except MeshLabError:
pass
def scenario_courier_contract(a: Device, b: Device) -> dict:
"""Run the real courier wire/store contract on device A's debug build."""
result = a.cmd_ok("courier_contract", 60_000)
expected = {
"wire_prekey_id_preserved": True,
"stored_prekey_id_preserved": True,
"first_reserved_copies": 2,
"second_reserved_copies": 1,
"same_courier_second_reservation_empty": True,
"reverse_order_commits": True,
"cancel_restored_eligibility": True,
"persisted_spray_history": True,
"remaining_copies_after_restart": 1,
}
for field, value in expected.items():
if result.get(field) != value:
raise MeshLabError(f"courier contract field {field}={result.get(field)!r}, expected {value!r}")
return result
def _ptt_one_way(
sender: Device,
receiver: Device,
@ -728,26 +1097,26 @@ def ensure_direct_link(a: Device, b: Device, id_a: str, id_b: str) -> None:
wait_for_peer(a, id_b, timeout_s=120)
wait_for_peer(b, id_a, timeout_s=120)
for device, peer, announcer in ((a, id_b, b), (b, id_a, a)):
connected = False
last: dict = {}
for _attempt in range(4):
last = device.cmd("connect", timeout_ms=45_000, peer=peer)
if last.get("status") == "ok" and last.get("direct"):
connected = True
break
# Already acceptable if the mesh formed a direct link on its own.
peers = device.cmd_ok("peers").get("peers", [])
match = next((p for p in peers if p.get("id") == peer), None)
if match and match.get("direct"):
connected = True
break
# A GATT link carries traffic both ways. Requiring a second client connection from
# the other endpoint turns a healthy single direct link into a false test failure,
# especially after one phone has just restarted and has not rebuilt its address map.
return
# Already acceptable if either endpoint formed a direct link on its own.
for observer, observed_peer in ((device, peer), (announcer, whoami(device)["peer_id"])):
peers = observer.cmd_ok("peers").get("peers", [])
match = next((p for p in peers if p.get("id") == observed_peer), None)
if match and match.get("direct"):
return
try:
announcer.cmd_ok("announce")
except MeshLabError:
pass
time.sleep(4)
if not connected:
raise MeshLabError(f"[{device.alias}] no direct link to {peer}: connect={last}")
raise MeshLabError(f"no direct link formed between {a.alias} and {b.alias}: connect={last}")
def force_handshake(device: Device, peer_id: str, attempts: int = 5, per_attempt_s: int = 20) -> dict:
@ -869,6 +1238,11 @@ SCENARIOS = {
"dm": scenario_dm,
"favorite_verification": scenario_favorite_verification,
"broadcast": scenario_broadcast,
"sync_recovery": scenario_sync_recovery,
"sync_auto_recovery": scenario_sync_auto_recovery,
"sync_file_recovery": scenario_sync_file_recovery,
"durable_outbox": scenario_durable_outbox,
"courier_contract": scenario_courier_contract,
"ptt_dm": scenario_ptt_dm,
"ptt_broadcast": scenario_ptt_broadcast,
# Broadcast transfers are receiver-capped at 256 fragments (~120 KB); only
@ -893,6 +1267,11 @@ SCENARIOS = {
"identity_reset": scenario_identity_reset,
}
# End-to-end peer courier delivery needs distinct sender, courier, and recipient identities.
TRIAD_SCENARIOS = {
"courier_delivery": scenario_courier_delivery,
}
# Scenarios supported when device B is a watch (file scenarios are receive-only: phone sends,
# the watch must receive with matching digests).
WATCH_SCENARIOS = [
@ -909,9 +1288,16 @@ WATCH_SCENARIOS = [
]
def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict:
def run_scenario(
name: str,
a: Device,
b: Device,
out: Path | None,
c: Device | None = None,
) -> dict:
started = time.time()
evidence: dict[str, object] = {"scenario": name, "devices": [a.alias, b.alias]}
devices = [a, b] + ([c] if c is not None else [])
evidence: dict[str, object] = {"scenario": name, "devices": [device.alias for device in devices]}
try:
supported = WATCH_SCENARIOS if isinstance(b, WatchDevice) else list(SCENARIOS)
if name == "all":
@ -922,9 +1308,20 @@ def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict:
results[n] = sub.get("results", {"error": sub.get("error", "unknown")})
if sub["status"] != "pass":
failures.append(n)
if c is not None:
sub = run_scenario("courier_delivery", a, b, out, c)
results["courier_delivery"] = sub.get("results", {"error": sub.get("error", "unknown")})
if sub["status"] != "pass":
failures.append("courier_delivery")
evidence["results"] = results
if failures:
raise MeshLabError(f"sub-scenarios failed: {', '.join(failures)}")
elif name in TRIAD_SCENARIOS:
if c is None:
raise MeshLabError(f"scenario '{name}' requires --serial-c")
if isinstance(b, WatchDevice):
raise MeshLabError(f"scenario '{name}' requires three phones")
evidence["results"] = TRIAD_SCENARIOS[name](a, b, c)
elif name not in supported:
raise MeshLabError(f"scenario '{name}' is not supported on device '{b.alias}'")
else:
@ -933,7 +1330,7 @@ def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict:
except (MeshLabError, AssertionError) as error:
evidence["status"] = "fail"
evidence["error"] = str(error)
evidence["logcat"] = {d.alias: d.logcat_dump() for d in (a, b)}
evidence["logcat"] = {d.alias: d.logcat_dump() for d in devices}
evidence["duration_s"] = round(time.time() - started, 1)
if out is not None:
out.mkdir(parents=True, exist_ok=True)
@ -950,16 +1347,19 @@ def build_parser() -> argparse.ArgumentParser:
setup = commands.add_parser("setup", help="install, grant, launch, nickname, discover")
setup.add_argument("--serial-a", required=True)
setup.add_argument("--serial-b")
setup.add_argument("--serial-c", help="third phone for a three-party scenario")
setup.add_argument("--serial-watch", help="watch serial; used as device B (overrides --serial-b)")
setup.add_argument("--apk", type=Path, default=None)
setup.add_argument("--watch-apk", type=Path, default=None)
setup.add_argument("--nickname-a", default="alice")
setup.add_argument("--nickname-b", default="bob")
setup.add_argument("--nickname-c", default="charlie")
scenario = commands.add_parser("scenario", help="run a test scenario on two devices")
scenario.add_argument("name", choices=[*SCENARIOS.keys(), "all"])
scenario = commands.add_parser("scenario", help="run a test scenario on two phones, or three for courier delivery")
scenario.add_argument("name", choices=[*SCENARIOS.keys(), *TRIAD_SCENARIOS.keys(), "all"])
scenario.add_argument("--serial-a", required=True)
scenario.add_argument("--serial-b")
scenario.add_argument("--serial-c", help="third phone; required for courier_delivery")
scenario.add_argument("--serial-watch", help="watch serial; used as device B (overrides --serial-b)")
scenario.add_argument("--out", type=Path, default=None, help="evidence output directory")
@ -972,30 +1372,36 @@ def build_parser() -> argparse.ArgumentParser:
return parser
def _resolve_devices(args: argparse.Namespace) -> tuple[Device, Device]:
def _resolve_devices(args: argparse.Namespace) -> tuple[Device, Device, Device | None]:
"""Device A is always the phone; device B is a watch when --serial-watch is given."""
a = Device(args.serial_a, "alpha")
if getattr(args, "serial_watch", None):
return a, WatchDevice(args.serial_watch)
if getattr(args, "serial_c", None):
raise MeshLabError("--serial-c cannot be combined with --serial-watch")
return a, WatchDevice(args.serial_watch), None
if not getattr(args, "serial_b", None):
raise MeshLabError("either --serial-b or --serial-watch is required")
return a, Device(args.serial_b, "beta")
c = Device(args.serial_c, "charlie") if getattr(args, "serial_c", None) else None
return a, Device(args.serial_b, "beta"), c
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.command == "setup":
a, b = _resolve_devices(args)
a, b, c = _resolve_devices(args)
nickname_b = "watch" if isinstance(b, WatchDevice) and args.nickname_b == "bob" else args.nickname_b
setup_pair(
a, b, args.apk, args.nickname_a, nickname_b,
apk_b=args.watch_apk if isinstance(b, WatchDevice) else None,
)
if c is not None:
setup_triad(a, b, c, args.apk, args.nickname_a, nickname_b, args.nickname_c)
else:
setup_pair(
a, b, args.apk, args.nickname_a, nickname_b,
apk_b=args.watch_apk if isinstance(b, WatchDevice) else None,
)
print(json.dumps({"status": "ok", "step": "setup"}))
elif args.command == "scenario":
a, b = _resolve_devices(args)
evidence = run_scenario(args.name, a, b, args.out)
a, b, c = _resolve_devices(args)
evidence = run_scenario(args.name, a, b, args.out, c)
print(json.dumps(evidence, indent=2, default=str))
return 0 if evidence["status"] == "pass" else 1
elif args.command == "cmd":

View File

@ -19,8 +19,8 @@ android {
targetSdk = libs.versions.targetSdk.get().toInt()
// Wear releases use a separate high range because Play requires every artifact in
// one application ID to have a unique version code across all form factors.
versionCode = 1_000_000_004
versionName = "0.1.3"
versionCode = 1_000_000_002
versionName = "0.1.1"
vectorDrawables {
useSupportLibrary = true

View File

@ -18,6 +18,9 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -27,6 +30,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -35,7 +39,7 @@ import androidx.core.content.ContextCompat
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.Text
import androidx.wear.compose.material3.OutlinedButton
import androidx.wear.compose.material3.TextButton
import com.bitchat.watch.mesh.WearMeshService
import com.bitchat.watch.notification.WearNotificationCoordinator
import com.bitchat.watch.service.WearMeshForegroundService
@ -46,7 +50,6 @@ import com.bitchat.watch.ui.PeopleScreen
import com.bitchat.watch.ui.UserDetailScreen
import com.bitchat.watch.ui.VerificationCodeScreen
import com.bitchat.watch.ui.WearChatState
import com.bitchat.watch.ui.WearFormScreen
import com.bitchat.watch.ui.sendPrivateMessage
import com.bitchat.watch.ui.sendPublicMessage
import com.bitchat.watch.ui.theme.BitchatWearTheme
@ -457,110 +460,104 @@ internal fun WearNavHost(
@Composable
fun NotificationPermissionScreen(onResult: (Boolean) -> Unit, onSkip: () -> Unit) {
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
onResult(granted)
}
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> onResult(granted) }
WearFormScreen {
item {
Text(
text = "Message alerts",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
item {
Text(
text = "Alerts for encrypted direct messages",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp, bottom = 10.dp),
)
}
item {
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
onResult(true)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Message alerts",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Alerts for encrypted direct messages",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp, bottom = 10.dp)
)
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
onResult(true)
}
) {
Text("Enable")
}
) {
Text("Enable")
}
item {
OutlinedButton(onClick = onSkip) {
Text("Not now")
}
TextButton(onClick = onSkip) {
Text("Not now")
}
}
}
@Composable
fun PermissionRequestScreen(onGranted: () -> Unit) {
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
onGranted()
}
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { onGranted() }
WearFormScreen {
item {
Text(
text = "bitchat",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
item {
Text(
text = "Needs Bluetooth to mesh with nearby devices",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp, bottom = 12.dp),
)
}
item {
Button(
onClick = {
launcher.launch(MainActivity.requiredPermissions().toTypedArray())
}
) {
Text("Grant access", textAlign = TextAlign.Center)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "bitchat",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Needs Bluetooth to mesh with nearby devices",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp, bottom = 12.dp)
)
Button(onClick = {
launcher.launch(MainActivity.requiredPermissions().toTypedArray())
}) {
Text("Grant access")
}
}
}
@Composable
fun BluetoothEnableScreen(onEnabled: () -> Unit) {
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
onEnabled()
}
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { onEnabled() }
WearFormScreen {
item {
Text(
text = "Bluetooth is off",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
)
}
item {
Button(
onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) },
modifier = Modifier.padding(top = 10.dp),
) {
Text("Turn on")
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Bluetooth is off",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface
)
Button(
onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) },
modifier = Modifier.padding(top = 10.dp)
) {
Text("Turn on")
}
}
}

View File

@ -13,7 +13,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@ -37,7 +36,6 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@ -76,7 +74,7 @@ fun ChatActionBar(
) {
Box(
modifier = Modifier
.size(48.dp)
.size(38.dp)
.clip(CircleShape)
.background(palette.inputButton)
.clickable { onKeyboard() },
@ -91,7 +89,7 @@ fun ChatActionBar(
}
Box(
modifier = Modifier
.size(48.dp)
.size(38.dp)
.clip(CircleShape)
.background(
when {
@ -152,7 +150,7 @@ fun VoiceRecordOverlay(
hoveringCancel: Boolean,
proximity: Float,
magnetPull: Offset,
onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit,
onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit
) {
val palette = LocalBitchatPalette.current
// The cancel morph, choreographed for feel:
@ -161,127 +159,101 @@ fun VoiceRecordOverlay(
// - the button leans toward the approaching finger (magnetic pull), chasing it with a
// smooth spring so it lags and settles naturally
// - scale blooms with a soft bounce on activation — no rotation, no wobble
val cancelScale by
androidx.compose.animation.core.animateFloatAsState(
targetValue = if (hoveringCancel) 1.32f else 1f + 0.1f * proximity,
animationSpec =
androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium,
),
label = "cancelSnap",
)
val pull by
androidx.compose.animation.core.animateOffsetAsState(
targetValue = magnetPull,
animationSpec =
androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium,
),
label = "magnetPull",
)
val cancelColor =
androidx.compose.ui.graphics.lerp(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.error,
if (hoveringCancel) 1f else proximity * 0.85f,
)
val cancelScale by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (hoveringCancel) 1.32f else 1f + 0.1f * proximity,
animationSpec = androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium
),
label = "cancelSnap"
)
val pull by androidx.compose.animation.core.animateOffsetAsState(
targetValue = magnetPull,
animationSpec = androidx.compose.animation.core.spring(
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium
),
label = "magnetPull"
)
val cancelColor = androidx.compose.ui.graphics.lerp(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.error,
if (hoveringCancel) 1f else proximity * 0.85f
)
AnimatedVisibility(
visible = voice.recording,
enter = fadeIn(tween(BitchatMotion.EMPHASIZED_MS)),
exit = fadeOut(tween(BitchatMotion.EMPHASIZED_MS)),
exit = fadeOut(tween(BitchatMotion.EMPHASIZED_MS))
) {
BoxWithConstraints(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.96f))
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
val side =
if (LocalConfiguration.current.isScreenRound) {
roundContentSide(maxWidth.value, maxHeight.value).dp
} else {
minOf(maxWidth, maxHeight) - 24.dp
}
Column(
modifier = Modifier.size(side),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
// Text gets its natural height first. The decorative waveform and cancel icon
// share the remaining room instead of pushing instructions off the display.
BoxWithConstraints(
Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
val iconSize = minOf(52.dp, maxHeight / 1.32f, maxWidth / 1.32f)
Box(
modifier =
Modifier.onGloballyPositioned { coords ->
onCancelBounds(
androidx.compose.ui.geometry.Rect(
coords.localToRoot(
androidx.compose.ui.geometry.Offset.Zero
),
coords.size.toSize(),
)
)
}
.size(iconSize)
.graphicsLayer {
translationX = pull.x
translationY = pull.y
scaleX = cancelScale
scaleY = cancelScale
}
.clip(CircleShape)
.background(cancelColor),
contentAlignment = Alignment.Center,
) {
androidx.compose.animation.Crossfade(
targetState = hoveringCancel,
animationSpec = tween(BitchatMotion.STANDARD_MS),
label = "cancelIcon",
) { cancel ->
Icon(
imageVector = if (cancel) Icons.Filled.Close else Icons.Filled.Mic,
contentDescription = if (cancel) "cancel recording" else null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(iconSize / 2),
Box(
modifier = Modifier
.onGloballyPositioned { coords ->
onCancelBounds(
androidx.compose.ui.geometry.Rect(
coords.localToRoot(androidx.compose.ui.geometry.Offset.Zero),
coords.size.toSize()
)
}
)
}
.size(52.dp)
.graphicsLayer {
translationX = pull.x
translationY = pull.y
scaleX = cancelScale
scaleY = cancelScale
}
.clip(CircleShape)
.background(cancelColor),
contentAlignment = Alignment.Center
) {
androidx.compose.animation.Crossfade(
targetState = hoveringCancel,
animationSpec = tween(BitchatMotion.STANDARD_MS),
label = "cancelIcon"
) { cancel ->
Icon(
imageVector = if (cancel) Icons.Filled.Close else Icons.Filled.Mic,
contentDescription = if (cancel) "cancel recording" else null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(26.dp)
)
}
WaveformBars(
samples = voice.liveSamples,
progress = 1f,
activeColor = MaterialTheme.colorScheme.primary,
inactiveColor = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth().height(12.dp),
)
Text(
text =
(if (voice.isLive) "LIVE " else "") +
"%d:%02d"
.format(
voice.elapsedMs / 1000 / 60,
voice.elapsedMs / 1000 % 60,
) +
"/0:10",
style = ChatVisualTokens.SystemActionStyle,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth(),
)
Text(
text = if (hoveringCancel) "Release to cancel" else "Lift finger to send",
style = ChatVisualTokens.SystemActionStyle,
color =
if (hoveringCancel) MaterialTheme.colorScheme.error
else palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
WaveformBars(
samples = voice.liveSamples,
progress = 1f,
activeColor = MaterialTheme.colorScheme.primary,
inactiveColor = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 16.dp)
.fillMaxWidth()
.height(44.dp)
)
Text(
text = (if (voice.isLive) "LIVE · " else "") + "%d:%02d".format(
voice.elapsedMs / 1000 / 60,
voice.elapsedMs / 1000 % 60
) + " / 0:10",
style = ChatVisualTokens.SenderStyle,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = 10.dp)
)
Text(
text = if (hoveringCancel) "Release to cancel" else "Lift finger to send",
style = ChatVisualTokens.SystemActionStyle,
color = if (hoveringCancel) MaterialTheme.colorScheme.error
else palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 2.dp)
)
}
}
}

View File

@ -8,6 +8,7 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -20,27 +21,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState
import androidx.wear.compose.foundation.lazy.items
@ -60,7 +49,7 @@ import kotlinx.coroutines.flow.first
* The shared chat body for global chat and DM threads, following the classic messenger
* pattern: a TransformingLazyColumn message list (native Wear center-scaling/fade, rotary,
* scrollbar) with the header and action bar as floating overlays that get out of the way
* while scrolling up into history and return on a deliberate reverse scroll; at the newest message
* while scrolling up into history and return on any downward scroll; at the newest message
* they are always visible.
*
* The list's contentPadding is CONSTANT and both overlays are layout-neutral, so showing or
@ -93,41 +82,37 @@ fun ChatScaffold(
previousCount = messages.size
}
// Follow intent is changed only by an actual user scroll away from the newest item or by
// reaching the end again. A new item temporarily makes canScrollForward true before layout;
// treating that transient range change as user intent breaks automatic following.
var scrollIntent by remember { mutableStateOf(ChatScrollIntentState()) }
val density = LocalDensity.current
val scrollConnection = remember(columnState, density) {
object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
// Both Wear rotary and touch dispatch consumed movement here. Positive list
// movement is toward newer messages. Layout changes never enter this path.
scrollIntent = updatedChatScrollIntent(
current = scrollIntent,
deltaDp = -consumed.y / density.density,
isUserInput = source == NestedScrollSource.UserInput,
atNewest = !columnState.canScrollForward
)
return Offset.Zero
}
}
}
// One state drives both overlays: visible at the bottom or when scrolling toward it,
// hidden when scrolling up into history. The 24px (~12dp) threshold is deliberately
// small so the controls answer every flick immediately.
var atNewest by remember { mutableStateOf(true) }
val controlsVisible = remember { mutableStateOf(true) }
LaunchedEffect(columnState) {
var lastPosition = -1
snapshotFlow {
!columnState.canScrollForward && !columnState.isScrollInProgress
}.collect { atNewest ->
if (atNewest) scrollIntent = ChatScrollIntentState()
val first = columnState.layoutInfo.visibleItems.firstOrNull()
Triple(columnState.canScrollForward, first?.index ?: 0, first?.offset ?: 0)
}.collect { (canScrollForward, index, offset) ->
val position = index * 100_000 + offset
atNewest = !canScrollForward
if (!canScrollForward) {
controlsVisible.value = true
} else if (lastPosition >= 0) {
when {
position > lastPosition + 24 -> controlsVisible.value = true
position < lastPosition - 24 -> controlsVisible.value = false
}
}
lastPosition = position
}
}
// Stick to bottom when the user has not intentionally moved into history.
// Stick to bottom: follow new messages while resting at the newest.
// Capture this when the message count changes, before the new layout can temporarily make
// canScrollForward true and report that the user is browsing history.
val followNewest = remember(messages.size) { atNewest }
LaunchedEffect(columnState, messages.size) {
if (messages.isNotEmpty() && scrollIntent.followsNewest) {
if (messages.isNotEmpty() && followNewest) {
val expectedSingleMessageKey = messages.singleOrNull()?.id
scrollToNewestAfterItemsMeasured(
expectedItemCount = messages.size,
@ -146,14 +131,7 @@ fun ChatScaffold(
) {
// scrollBy to the end of the range: animateScrollToItem stops as soon as the
// item is partially visible, which left the last message cropped.
// Do not seize the list from an active drag/crown gesture, or follow an
// append whose measurement completed after the user entered history.
followNewestWhenIdle(
scrolling = snapshotFlow { columnState.isScrollInProgress },
shouldFollow = { scrollIntent.followsNewest }
) {
columnState.scroll { scrollBy(Float.MAX_VALUE) }
}
columnState.scroll { scrollBy(Float.MAX_VALUE) }
}
}
}
@ -165,10 +143,10 @@ fun ChatScaffold(
voice = voice,
onOpenImage = onOpenImage,
columnState = columnState,
controlsVisible = scrollIntent.controlsVisible,
controlsVisible = controlsVisible.value,
header = header,
actionBar = actionBar,
modifier = Modifier.fillMaxSize().nestedScroll(scrollConnection)
modifier = Modifier.fillMaxSize()
)
}
@ -177,33 +155,6 @@ internal data class MeasuredChatLayout(
val singleVisibleItemKey: Any?
)
internal data class ChatScrollIntentState(
val followsNewest: Boolean = true,
val controlsVisible: Boolean = true,
val reversalDp: Float = 0f
)
internal fun updatedChatScrollIntent(
current: ChatScrollIntentState,
deltaDp: Float,
isUserInput: Boolean,
atNewest: Boolean
): ChatScrollIntentState {
if (atNewest) return ChatScrollIntentState()
if (!isUserInput || !deltaDp.isFinite() || deltaDp == 0f) return current
// Hysteresis measures net travel opposite the current controls state, not the sum of
// tiny back-and-forth movements. Keep it across discrete crown ticks and idle periods.
val reversal = (current.reversalDp + if (current.controlsVisible) -deltaDp else deltaDp)
.coerceAtLeast(0f)
val threshold = if (current.controlsVisible) 12f else 24f
val toggle = reversal >= threshold
return current.copy(
followsNewest = current.followsNewest && deltaDp >= 0f,
controlsVisible = if (toggle) !current.controlsVisible else current.controlsVisible,
reversalDp = if (toggle) 0f else reversal
)
}
internal suspend fun scrollToNewestAfterItemsMeasured(
expectedItemCount: Int,
expectedSingleMessageKey: Any?,
@ -218,15 +169,6 @@ internal suspend fun scrollToNewestAfterItemsMeasured(
scrollToEnd()
}
internal suspend fun followNewestWhenIdle(
scrolling: Flow<Boolean>,
shouldFollow: () -> Boolean,
scrollToEnd: suspend () -> Unit
) {
scrolling.first { !it }
if (shouldFollow()) scrollToEnd()
}
@Composable
private fun ChatBody(
messages: List<BitchatMessage>,
@ -243,10 +185,7 @@ private fun ChatBody(
val palette = LocalBitchatPalette.current
val context = LocalContext.current
val transformationSpec = rememberTransformationSpec()
val isScreenRound = LocalConfiguration.current.isScreenRound
val headerClearance = with(LocalDensity.current) {
maxOf(40.dp, 24.dp + (14f * 1.3f).sp.toDp() / 2 + 6.dp)
}
// Slide-to-cancel: while recording, the finger's position is tracked globally; the
// overlay's mic button reports its bounds and becomes the cancel target when the
// finger hovers it (with generous slack so the snap engages on approach).
@ -317,65 +256,16 @@ private fun ChatBody(
}
}
) {
ScreenScaffold(scrollState = columnState) { scaffoldPadding ->
val layoutDirection = LocalLayoutDirection.current
ScreenScaffold(scrollState = columnState) {
TransformingLazyColumn(
state = columnState,
// Keep the list full-screen and geometrically unclipped. Wear's transformation
// spec curves rows along the round display; a soft destination-alpha mask then
// makes them fully transparent at the physical edges instead of cutting glyphs.
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithCache {
val edgeMask = if (isScreenRound) {
// Fade toward the actual circular contour so glyphs become
// transparent before the panel can crop their left or right edge.
Brush.radialGradient(
0f to Color.Black,
CHAT_ROUND_EDGE_OPAQUE_STOP to Color.Black,
1f to Color.Transparent,
center = Offset(size.width / 2f, size.height / 2f),
radius = size.minDimension / 2f
)
} else {
val topOpaqueStop =
(CHAT_HEADER_EDGE_FADE.toPx() / size.height).coerceIn(0f, 1f)
val bottomOpaqueStop =
(1f - CHAT_ACTION_BAR_EDGE_FADE.toPx() / size.height)
.coerceIn(topOpaqueStop, 1f)
Brush.verticalGradient(
0f to Color.Transparent,
topOpaqueStop to Color.Black,
bottomOpaqueStop to Color.Black,
1f to Color.Transparent
)
}
onDrawWithContent {
drawContent()
drawRect(
brush = edgeMask,
blendMode = BlendMode.DstIn
)
}
},
modifier = Modifier.fillMaxSize(),
// Arrangement.Bottom anchors short content to the bottom: the first message
// starts just above the action bar and new messages push history upward.
// The scroll range reserves resting space for the floating controls while the
// full-screen viewport preserves autoscroll and the native transformation focal
// point. Rows may travel behind the overlays only after they have begun the Wear
// edge scale/fade treatment.
// The padding reserves permanent room for the floating header and action
// bar; being constant, it never disturbs an in-flight scroll gesture.
verticalArrangement = Arrangement.Bottom,
// Keep Wear Material's responsive horizontal inset while replacing its vertical
// inset with the overlay clearances used before the shape fix. This avoids both
// duplicated padding and a shortened list viewport.
contentPadding = scaffoldPadding.withVerticalClearance(
layoutDirection = layoutDirection,
top = headerClearance,
bottom = CHAT_ACTION_BAR_CLEARANCE
)
contentPadding = PaddingValues(top = 30.dp, bottom = 64.dp)
) {
if (messages.isEmpty()) {
item {
@ -409,12 +299,7 @@ private fun ChatBody(
// The header stays put and shrinks to its dense form instead of disappearing;
// as an overlay its size animation never touches the list's scroll geometry.
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth(),
contentAlignment = Alignment.TopCenter
) {
Box(modifier = Modifier.align(Alignment.TopCenter)) {
header(controlsVisible)
}
@ -448,10 +333,6 @@ private fun ChatBody(
// Extra finger slack (px, ~28dp at watch density) around the cancel target so the snap
// engages as the finger approaches, not only on exact contact.
private const val CANCEL_HOVER_SLANT_PX = 56f
private val CHAT_ACTION_BAR_CLEARANCE = 64.dp
private val CHAT_HEADER_EDGE_FADE = 36.dp
private val CHAT_ACTION_BAR_EDGE_FADE = 72.dp
private const val CHAT_ROUND_EDGE_OPAQUE_STOP = 0.78f
// Magnetic zone geometry (px at watch density): the button starts reacting at
// MAGNET_OUTER_PX from its center and fully blushes at MAGNET_INNER_PX (~the activation
// boundary); it leans toward the finger by up to MAGNET_PULL_PX.

View File

@ -1,7 +1,9 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@ -116,79 +118,77 @@ private fun ChatHeader(
peerCount: Int,
unreadDms: Int,
expanded: Boolean,
onOpenPeople: () -> Unit,
onOpenPeople: () -> Unit
) {
// Floating title row: full-size at the newest messages, shrinks to its dense form
// while scrolling up into history. Rendered as an overlay, so the animation only
// relayouts this row, never the message list.
val spec =
androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
BitchatMotion.STANDARD_MS
)
val iconSize by
androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 14.dp else 12.dp,
animationSpec = spec,
label = "hdrIcon",
)
val titleSize by
androidx.compose.animation.core.animateFloatAsState(
targetValue = if (expanded) 14f else 12f,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
label = "hdrTitle",
)
val spec = androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
BitchatMotion.STANDARD_MS
)
val iconSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 16.dp else 11.dp, animationSpec = spec, label = "hdrIcon"
)
val titleSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "hdrTitle"
)
val vPadding by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "hdrPad"
)
// The entire header region opens the People screen. When there are unread DMs the
// title gives way so the people and mail icons (with counts) fit side by side on the
// round screen instead of clipping at the edges.
WearChatHeader(
fontSize = titleSize,
onClickLabel = "Open people",
onClick = onOpenPeople,
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenPeople() }
.padding(horizontal = 8.dp, vertical = vPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
if (unreadDms == 0) {
Text(
text = "bitchat",
style = MaterialTheme.typography.titleSmall,
fontSize = titleSize.sp,
lineHeight = (titleSize * 1.3f).sp,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) { titleSize.toSp() },
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false).padding(end = 2.dp),
modifier = Modifier.padding(end = 8.dp)
)
}
Icon(
imageVector = Icons.Filled.People,
contentDescription = "people",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(iconSize),
modifier = Modifier.size(iconSize)
)
Text(
text = if (peerCount > 99) "99+" else "$peerCount",
text = "$peerCount",
style = MaterialTheme.typography.bodySmall,
fontSize = 12.sp,
lineHeight = (titleSize * 1.3f).sp,
maxLines = 1,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
(iconSize.value * 0.85f).dp.toSp()
},
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 2.dp),
modifier = Modifier.padding(start = 2.dp)
)
if (unreadDms > 0) {
Icon(
imageVector = Icons.Filled.MailOutline,
contentDescription = "$unreadDms unread messages",
tint = LocalBitchatPalette.current.accentOrange,
modifier = Modifier.padding(start = 6.dp).size(iconSize),
modifier = Modifier
.padding(start = 6.dp)
.size(iconSize)
)
Text(
text = if (unreadDms > 99) "99+" else "$unreadDms",
text = "$unreadDms",
style = MaterialTheme.typography.bodySmall,
fontSize = 12.sp,
lineHeight = (titleSize * 1.3f).sp,
maxLines = 1,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
(iconSize.value * 0.85f).dp.toSp()
},
color = LocalBitchatPalette.current.accentOrange,
modifier = Modifier.padding(start = 2.dp),
modifier = Modifier.padding(start = 2.dp)
)
}
}
@ -240,7 +240,8 @@ fun MessageItem(
)
Text(
text = " ${formatTime(message.timestamp)}",
style = ChatVisualTokens.TimestampStyle,
style = ChatVisualTokens.SystemActionStyle,
fontSize = 9.sp,
color = palette.textTertiary
)
}

View File

@ -1,8 +1,11 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -17,6 +20,7 @@ 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.focus.focusRequester
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@ -28,8 +32,6 @@ import androidx.wear.compose.foundation.rotary.rotaryScrollable
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.lazy.items
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.Icon
@ -138,58 +140,59 @@ private fun DmHeader(
expanded: Boolean,
isFavorite: Boolean,
isVerified: Boolean,
onClick: () -> Unit,
onClick: () -> Unit
) {
val palette = LocalBitchatPalette.current
// Floating title row: full-size at the newest messages, shrinks to its dense form
// while scrolling up into history. Rendered as an overlay, so the animation only
// relayouts this row, never the message list.
val spec =
androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
BitchatMotion.STANDARD_MS
)
val headerIconSize by
androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 14.dp else 12.dp,
animationSpec = spec,
label = "dmHdrIcon",
)
val headerTitleSize by
androidx.compose.animation.core.animateFloatAsState(
targetValue = if (expanded) 14f else 12f,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
label = "dmHdrTitle",
)
val spec = androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
BitchatMotion.STANDARD_MS
)
val headerIconSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 16.dp else 11.dp, animationSpec = spec, label = "dmHdrIcon"
)
val headerTitleSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "dmHdrTitle"
)
val headerVPadding by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "dmHdrPad"
)
WearChatHeader(
fontSize = headerTitleSize,
onClickLabel = "Open user details",
onClick = onClick,
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
onClickLabel = "Open user details",
onClick = onClick
)
.padding(horizontal = 8.dp, vertical = headerVPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = nickname,
style = MaterialTheme.typography.titleSmall,
fontSize = headerTitleSize.sp,
lineHeight = (headerTitleSize * 1.3f).sp,
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
headerTitleSize.toSp()
},
fontWeight = FontWeight.Bold,
color = colorForPeer(nickname + peerID, palette),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
color = colorForPeer(nickname + peerID, palette)
)
NoiseLockIcon(
state =
if (sessionEstablished) NoiseSessionUiState.Established
else NoiseSessionUiState.Handshaking,
state = if (sessionEstablished) NoiseSessionUiState.Established
else NoiseSessionUiState.Handshaking,
size = headerIconSize,
modifier = Modifier.padding(start = 5.dp),
modifier = Modifier.padding(start = 5.dp)
)
if (isFavorite) {
Icon(
painter = painterResource(R.drawable.ic_spec_star_filled),
contentDescription = "Favorite",
tint = palette.accentOrange,
modifier = Modifier.padding(start = 4.dp).size(headerIconSize),
modifier = Modifier
.padding(start = 4.dp)
.size(headerIconSize)
)
}
if (isVerified) {
@ -197,7 +200,9 @@ private fun DmHeader(
imageVector = Icons.Filled.Verified,
contentDescription = "Verified",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp).size(headerIconSize),
modifier = Modifier
.padding(start = 4.dp)
.size(headerIconSize)
)
}
}

View File

@ -1,7 +1,10 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
@ -44,7 +47,7 @@ fun NicknameSetupScreen(
title: String = "bitchat",
subtitle: String = "Pick a nickname",
confirmLabel: String = "Join the mesh",
onConfirm: (String) -> Unit,
onConfirm: (String) -> Unit
) {
val palette = LocalBitchatPalette.current
// Pre-fill with the cursor at the end of the existing name, not the start.
@ -52,7 +55,7 @@ fun NicknameSetupScreen(
mutableStateOf(
TextFieldValue(
text = initialNickname,
selection = TextRange(initialNickname.length),
selection = TextRange(initialNickname.length)
)
)
}
@ -61,78 +64,71 @@ fun NicknameSetupScreen(
LaunchedEffect(Unit) { focusRequester.requestFocus() }
WearFormScreen {
item {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
item {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp, bottom = 10.dp),
)
}
item {
BasicTextField(
value = name,
onValueChange = { newValue ->
val trimmed = newValue.text.trim().take(24)
name =
if (trimmed == newValue.text) {
newValue
} else {
newValue.copy(text = trimmed, selection = TextRange(trimmed.length))
}
},
singleLine = true,
textStyle =
ChatVisualTokens.MessageBodyStyle.copy(
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = {
keyboardController?.hide()
}
),
modifier =
Modifier.fillMaxWidth()
.focusRequester(focusRequester)
.clip(RoundedCornerShape(18.dp))
.background(palette.inputSurface)
.padding(horizontal = 12.dp, vertical = 8.dp),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.Center) {
if (name.text.isEmpty()) {
Text(
text = "Nickname",
style = ChatVisualTokens.MessageBodyStyle,
color = palette.textTertiary,
)
}
innerTextField()
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = palette.textTertiary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp, bottom = 10.dp)
)
BasicTextField(
value = name,
onValueChange = { newValue ->
val trimmed = newValue.text.trim().take(24)
name = if (trimmed == newValue.text) {
newValue
} else {
newValue.copy(text = trimmed, selection = TextRange(trimmed.length))
}
},
singleLine = true,
textStyle = ChatVisualTokens.MessageBodyStyle.copy(
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
}),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.clip(RoundedCornerShape(18.dp))
.background(palette.inputSurface)
.padding(horizontal = 12.dp, vertical = 8.dp),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.Center) {
if (name.text.isEmpty()) {
Text(
text = "Nickname",
style = ChatVisualTokens.MessageBodyStyle,
color = palette.textTertiary
)
}
},
)
}
item {
Button(
onClick = { if (name.text.isNotBlank()) onConfirm(name.text.trim()) },
enabled = name.text.isNotBlank(),
modifier = Modifier.padding(top = 10.dp),
) {
Text(confirmLabel, textAlign = TextAlign.Center)
innerTextField()
}
}
)
Button(
onClick = { if (name.text.isNotBlank()) onConfirm(name.text.trim()) },
enabled = name.text.isNotBlank(),
modifier = Modifier.padding(top = 10.dp)
) {
Text(confirmLabel)
}
}
}

View File

@ -67,11 +67,10 @@ fun PeopleScreen(onOpenDm: (String) -> Unit, onEditNickname: () -> Unit) {
)
}
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
ScreenScaffold(scrollState = listState) {
ScalingLazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = scaffoldPadding
modifier = Modifier.fillMaxSize()
) {
item {
ListHeader {

View File

@ -134,7 +134,7 @@ fun TextInputScreen(onSend: (String) -> Unit) {
}
)
},
modifier = Modifier.size(48.dp)
modifier = Modifier.size(38.dp)
) {
Icon(
imageVector = Icons.Filled.Mic,
@ -145,7 +145,7 @@ fun TextInputScreen(onSend: (String) -> Unit) {
IconButton(
onClick = { send() },
enabled = text.isNotBlank(),
modifier = Modifier.size(48.dp)
modifier = Modifier.size(38.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,

View File

@ -1,6 +1,7 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -14,10 +15,10 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
@ -44,21 +45,14 @@ fun UserDetailScreen(
WearPeerIdentityState.snapshot(peerID, mesh)
}
val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8)
val listState = rememberScalingLazyListState(initialCenterItemIndex = 0)
val listState = rememberScalingLazyListState()
val palette = LocalBitchatPalette.current
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
val layoutDirection = LocalLayoutDirection.current
ScreenScaffold(scrollState = listState) {
ScalingLazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
autoCentering = null,
contentPadding = scaffoldPadding
.withAdditionalPadding(
layoutDirection = layoutDirection,
horizontal = 10.dp
)
.withVerticalClearance(layoutDirection, top = 28.dp, bottom = 28.dp)
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)
) {
item {
ListHeader {
@ -71,7 +65,8 @@ fun UserDetailScreen(
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = colorForPeer(nickname + peerID, palette),
textAlign = TextAlign.Center
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "User details",
@ -173,13 +168,13 @@ fun UserDetailScreen(
text = if (identity.isVerified) {
"Identity verified"
} else {
"Identity code"
"Verification code"
},
style = ChatVisualTokens.SenderStyle,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Compare identity codes",
text = "Compare cryptographic fingerprints",
style = ChatVisualTokens.SystemActionStyle,
color = palette.textTertiary
)

View File

@ -2,6 +2,7 @@ package com.bitchat.watch.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -13,7 +14,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@ -40,21 +40,14 @@ fun VerificationCodeScreen(peerID: String) {
WearPeerIdentityState.snapshot(peerID, mesh)
}
val myFingerprint = WearPeerIdentityState.myFingerprint(mesh)
val listState = rememberScalingLazyListState(initialCenterItemIndex = 0)
val listState = rememberScalingLazyListState()
val palette = LocalBitchatPalette.current
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
val layoutDirection = LocalLayoutDirection.current
ScreenScaffold(scrollState = listState) {
ScalingLazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
autoCentering = null,
contentPadding = scaffoldPadding
.withAdditionalPadding(
layoutDirection = layoutDirection,
horizontal = 10.dp
)
.withVerticalClearance(layoutDirection, top = 28.dp, bottom = 28.dp)
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)
) {
item {
ListHeader {
@ -127,8 +120,7 @@ fun VerificationCodeScreen(peerID: String) {
"Remove verification"
} else {
"Mark verified"
},
textAlign = TextAlign.Center
}
)
}
}
@ -160,8 +152,8 @@ private fun FingerprintCard(
text = fingerprint?.let(::formatVerificationCode) ?: "Handshake pending",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
lineHeight = 16.sp
fontSize = 10.sp,
lineHeight = 13.sp
),
color = if (fingerprint == null) {
palette.accentOrange
@ -180,6 +172,6 @@ fun formatVerificationCode(fingerprint: String): String {
return fingerprint
.uppercase()
.chunked(4)
.chunked(2)
.chunked(4)
.joinToString("\n") { line -> line.joinToString(" ") }
}

View File

@ -8,7 +8,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
@ -44,7 +43,6 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@ -55,7 +53,6 @@ import androidx.wear.compose.material3.Text
import com.bitchat.android.features.voice.AudioWaveformExtractor
import com.bitchat.android.features.voice.VoiceWaveformCache
import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.roundContentSide
import com.bitchat.watch.ui.theme.LocalBitchatPalette
import kotlinx.coroutines.delay
import java.io.File
@ -95,7 +92,7 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
BoxWithConstraints(
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
@ -104,16 +101,11 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
) {
val bitmap = remember(path) { BitmapFactory.decodeFile(path) }
if (bitmap != null) {
val imageModifier = if (LocalConfiguration.current.isScreenRound) {
Modifier.size(roundContentSide(maxWidth.value, maxHeight.value).dp)
} else {
Modifier.fillMaxSize()
}
Image(
painter = BitmapPainter(bitmap.asImageBitmap()),
contentDescription = "image fullscreen",
contentScale = ContentScale.Fit,
modifier = imageModifier
modifier = Modifier.fillMaxSize()
)
}
Icon(
@ -122,7 +114,7 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp)
.padding(top = 24.dp)
.size(20.dp)
)
}

View File

@ -10,149 +10,20 @@ import org.junit.Test
class ChatAutoScrollTest {
@Test
fun `append waits for active gesture and rechecks history intent`() = runTest {
val scrolling = MutableStateFlow(true)
var followsNewest = true
var scrollCount = 0
val job =
launch(start = CoroutineStart.UNDISPATCHED) {
followNewestWhenIdle(scrolling, { followsNewest }) { scrollCount++ }
}
assertFalse(job.isCompleted)
assertEquals(0, scrollCount)
followsNewest = false
scrolling.value = false
job.join()
assertEquals(0, scrollCount)
}
@Test
fun `append follows after gesture settles when user remains at newest`() = runTest {
val scrolling = MutableStateFlow(true)
var scrollCount = 0
val job =
launch(start = CoroutineStart.UNDISPATCHED) {
followNewestWhenIdle(scrolling, { true }) { scrollCount++ }
}
assertEquals(0, scrollCount)
scrolling.value = false
job.join()
assertEquals(1, scrollCount)
}
private fun move(
state: ChatScrollIntentState,
dp: Float,
user: Boolean = true,
newest: Boolean = false,
) = updatedChatScrollIntent(state, dp, user, newest)
@Test
fun `append and programmatic movement never change intent`() {
val docked = ChatScrollIntentState()
assertEquals(docked, move(docked, 1000f, user = false))
val history = ChatScrollIntentState(false, false)
assertEquals(history, move(history, 1000f, user = false))
}
@Test
fun `first consumed movement away suspends following before controls hide`() {
val state = move(ChatScrollIntentState(), -1f)
assertFalse(state.followsNewest)
assertEquals(true, state.controlsVisible)
assertFalse(move(state, -11f).controlsVisible)
}
@Test
fun `deliberate reversal reveals controls but does not resume following`() {
val history = move(ChatScrollIntentState(), -12f)
val almost = move(history, 23f)
assertFalse(almost.controlsVisible)
val revealed = move(almost, 1f)
assertEquals(true, revealed.controlsVisible)
assertFalse(revealed.followsNewest)
assertEquals(ChatScrollIntentState(), move(revealed, 1f, newest = true))
}
@Test
fun `jitter does not accumulate into repeated toggles`() {
var state = move(ChatScrollIntentState(), -12f)
repeat(100) {
state = move(state, 3f)
state = move(state, -3f)
}
assertFalse(state.controlsVisible)
assertEquals(0f, state.reversalDp)
}
@Test
fun `pauses and discrete crown ticks retain net movement`() {
var state = ChatScrollIntentState()
repeat(4) {
state = move(state, -3f)
state = move(state, 0f, user = false)
}
assertFalse(state.controlsVisible)
repeat(8) {
state = move(state, 3f)
state = move(state, 0f, user = false)
}
assertEquals(true, state.controlsVisible)
assertFalse(state.followsNewest)
}
@Test
fun `fling preserves controls until newest is reached`() {
val history = move(ChatScrollIntentState(), -12f)
assertEquals(history, move(history, 1000f, user = false))
assertEquals(history, move(history, -1000f, user = false))
assertEquals(ChatScrollIntentState(), move(history, 1f, user = false, newest = true))
}
@Test
fun `consumed distance is independent of item boundaries and event chunking`() {
val initial = ChatScrollIntentState()
val oneEvent = move(initial, -12f)
val acrossRows =
listOf(-2f, -3f, -1f, -6f).fold(initial) { state, delta ->
move(state, delta)
}
assertEquals(oneEvent, acrossRows)
}
@Test
fun `pixel distances normalize to the same dp thresholds`() {
for (density in listOf(1f, 1.6875f, 2f, 3f)) {
val history = move(ChatScrollIntentState(), (-12f * density) / density)
assertFalse(history.controlsVisible)
assertEquals(true, move(history, (24f * density) / density).controlsVisible)
}
}
@Test
fun `invalid and unconsumed input is ignored`() {
val initial = ChatScrollIntentState()
assertEquals(initial, move(initial, Float.NaN))
assertEquals(initial, move(initial, Float.POSITIVE_INFINITY))
assertEquals(initial, move(initial, 0f))
}
@Test
fun `newest scroll waits until appended message is measured`() = runTest {
val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null))
var scrollCount = 0
val scrollJob =
launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts,
) {
scrollCount += 1
}
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
}
}
assertFalse(scrollJob.isCompleted)
assertEquals(0, scrollCount)
@ -171,7 +42,7 @@ class ChatAutoScrollTest {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
}
@ -182,31 +53,28 @@ class ChatAutoScrollTest {
@Test
fun `first message waits past stale empty placeholder layout`() = runTest {
val messageKey = "first-message"
val measuredLayouts =
MutableStateFlow(
MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder")
)
val measuredLayouts = MutableStateFlow(
MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder")
)
var scrollCount = 0
val scrollJob =
launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 1,
expectedSingleMessageKey = messageKey,
measuredLayouts = measuredLayouts,
) {
scrollCount += 1
}
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 1,
expectedSingleMessageKey = messageKey,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
}
}
assertFalse(scrollJob.isCompleted)
assertEquals(0, scrollCount)
measuredLayouts.value =
MeasuredChatLayout(
itemCount = 1,
singleVisibleItemKey = messageKey,
)
measuredLayouts.value = MeasuredChatLayout(
itemCount = 1,
singleVisibleItemKey = messageKey
)
scrollJob.join()
assertEquals(1, scrollCount)

View File

@ -32,7 +32,7 @@ class WearPeerIdentityStateTest {
val formatted = formatVerificationCode(fingerprint)
assertEquals(fingerprint.uppercase(), formatted.filterNot(Char::isWhitespace))
assertEquals(8, formatted.lines().size)
assertEquals(List(8) { 2 }, formatted.lines().map { it.split(" ").size })
assertEquals(4, formatted.lines().size)
assertEquals(listOf(4, 4, 4, 4), formatted.lines().map { it.split(" ").size })
}
}