Merge 6a14ff7cfceaf156c8696ed08001bb88ac04cecd into c127eb83ab94c069c32d37530d2faecd381cd2a8

This commit is contained in:
callebtc 2026-09-14 13:09:58 -07:00 committed by GitHub
commit 3d59fe8581
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
204 changed files with 17888 additions and 582 deletions

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

@ -126,6 +126,12 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="bitchat" android:host="verify" />
<data android:scheme="bitchat" android:host="geohash" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>

View File

@ -73,6 +73,20 @@ class BitchatApplication : Application() {
// Initialize mesh service preferences
try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
// Bridge policy is process-scoped so rendezvous and courier delivery
// continue while the activity is backgrounded.
try {
com.bitchat.android.services.bridge.MeshBridgeService.initialize(this)
com.bitchat.android.mesh.BridgeMeshPort.install(
com.bitchat.android.services.bridge.MeshBridgeService
)
} catch (_: Exception) { }
com.bitchat.android.services.bridge.MeshGatewayService.initialize(this)
com.bitchat.android.groups.GroupRuntime.getInstance(this)
com.bitchat.android.services.PrivateMediaOutbox.initialize(this)
com.bitchat.android.model.PeerCapabilities.setPhoneFeaturesEnabled(true)
// Proactively start the foreground service to keep mesh alive
try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { }

View File

@ -705,6 +705,7 @@ class MainActivity : OrientationAwareActivity() {
// Handle any notification intent
handleNotificationIntent(intent)
handleVerificationIntent(intent)
handleShareIntent(intent)
// Small delay to ensure mesh service is fully initialized
delay(500)
@ -734,6 +735,7 @@ class MainActivity : OrientationAwareActivity() {
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
handleNotificationIntent(intent)
handleVerificationIntent(intent)
handleShareIntent(intent)
}
}
@ -848,6 +850,21 @@ class MainActivity : OrientationAwareActivity() {
}
}
private fun handleShareIntent(intent: Intent) {
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/") == true) {
intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.takeIf { it.isNotBlank() }?.let {
chatViewModel.receiveSharedText(it)
intent.removeExtra(Intent.EXTRA_TEXT)
}
}
if (intent.action == Intent.ACTION_VIEW) {
val cell = intent.data?.toString()?.let(com.bitchat.android.services.ChannelInvitation::decode) ?: return
val channel = com.bitchat.android.ui.channelForManualGeohash(cell) ?: return
LocationChannelManager.getInstance(applicationContext).selectManual(channel)
intent.data = null
}
}
private fun handleVerificationIntent(intent: Intent) {
val uri = intent.data ?: return
if (uri.scheme != "bitchat" || uri.host != "verify") return

View File

@ -0,0 +1,212 @@
package com.bitchat.android.board
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.nostr.LocationNotesManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.security.SecureRandom
/**
* Creates and removes signed board entries while keeping UI-only state out of
* the transport layer.
*/
class BoardManager(
private val store: BoardStore,
private val scope: CoroutineScope,
private val meshProvider: () -> MeshService,
private val geoIdentityProvider: (String) -> BoardSigningIdentity? = { null },
private val notesManager: LocationNotesManager = LocationNotesManager.getInstance(),
private val nowMs: () -> ULong = { System.currentTimeMillis().coerceAtLeast(0).toULong() },
private val random: SecureRandom = SecureRandom(),
private val onUrgentPosts: (geohash: String, posts: List<BoardPostPacket>) -> Unit = { _, _ -> }
) {
private val _unseenScopes = MutableStateFlow<Set<String>>(emptySet())
private val bridgedEventIDs = mutableMapOf<String, String>()
private val handledPostIDs = mutableSetOf<String>()
private val pendingUrgent = mutableMapOf<String, MutableList<BoardPostPacket>>()
private var alertFlushJob: Job? = null
val posts: StateFlow<List<BoardPostPacket>> = store.postsSnapshot
val unseenScopes: StateFlow<Set<String>> = _unseenScopes.asStateFlow()
init {
scope.launch {
store.postArrivals.collect { post ->
handleArrival(post)
}
}
}
fun posts(geohash: String): List<BoardPostPacket> = store.posts(geohash.lowercase())
fun isOwnPost(post: BoardPostPacket): Boolean =
signingIdentityFor(post.geohash)
?.publicKey
?.contentEquals(post.authorSigningKey) == true
fun createPost(
content: String,
geohash: String,
nickname: String?,
urgent: Boolean,
expiryDays: Int
): Boolean {
val trimmed = content.trim()
val contentBytes = trimmed.toByteArray(Charsets.UTF_8)
val normalizedGeohash = geohash.lowercase()
if (contentBytes.size !in 1..BoardWireConstants.CONTENT_MAX_BYTES ||
expiryDays !in 1..7 ||
!isValidGeohash(normalizedGeohash)
) {
return false
}
val mesh = meshProvider()
val identity = signingIdentityFor(normalizedGeohash) ?: return false
val signingKey = identity.publicKey.copyOf()
val postID = ByteArray(BoardWireConstants.POST_ID_LENGTH).also(random::nextBytes)
val createdAt = nowMs()
val expiresAt = createdAt + expiryDays.toULong() * DAY_MS
val authorNickname = truncateUtf8(nickname.orEmpty(), BoardWireConstants.NICKNAME_MAX_BYTES)
val flags: UByte = if (urgent) BoardPostPacket.URGENT_FLAG else 0u
val signingBytes = BoardPostPacket.signingBytes(
postID = postID,
geohash = normalizedGeohash,
content = trimmed,
authorSigningKey = signingKey,
authorNickname = authorNickname,
createdAt = createdAt,
expiresAt = expiresAt,
flags = flags
)
val signature = identity.sign(signingBytes)
?.takeIf { it.size == BoardWireConstants.SIGNATURE_LENGTH }
?: return false
val post = BoardPostPacket(
postID = postID,
geohash = normalizedGeohash,
content = trimmed,
authorSigningKey = signingKey,
authorNickname = authorNickname,
createdAt = createdAt,
expiresAt = expiresAt,
flags = flags,
signature = signature
)
mesh.sendBoardPayload(BoardWireCodec.encode(BoardWire.Post(post)))
if (normalizedGeohash.isNotEmpty()) {
notesManager.publishBoardBridge(
content = trimmed,
geohash = normalizedGeohash,
nickname = authorNickname,
expiresAtSeconds = (expiresAt / 1_000u).coerceAtMost(Int.MAX_VALUE.toULong()).toInt(),
urgent = urgent
) { eventID ->
synchronized(bridgedEventIDs) {
bridgedEventIDs[post.identityKey()] = eventID
}
}
}
return true
}
fun deletePost(post: BoardPostPacket): Boolean {
val identity = signingIdentityFor(post.geohash)
?.takeIf { it.publicKey.contentEquals(post.authorSigningKey) }
?: return false
val deletedAt = nowMs()
val signature = identity.sign(
BoardTombstonePacket.signingBytes(post.postID, deletedAt)
)?.takeIf { it.size == BoardWireConstants.SIGNATURE_LENGTH } ?: return false
val tombstone = BoardTombstonePacket(
postID = post.postID,
authorSigningKey = post.authorSigningKey,
deletedAt = deletedAt,
signature = signature
)
meshProvider().sendBoardPayload(BoardWireCodec.encode(BoardWire.Tombstone(tombstone)))
if (post.geohash.isNotEmpty()) {
val eventID = synchronized(bridgedEventIDs) {
bridgedEventIDs.remove(post.identityKey())
}
if (eventID != null) notesManager.deleteEvent(eventID, post.geohash)
}
return true
}
fun markSeen(scopes: Set<String>) {
if (scopes.isEmpty()) return
_unseenScopes.value = _unseenScopes.value - scopes
}
fun clearTransientState() {
_unseenScopes.value = emptySet()
synchronized(bridgedEventIDs) { bridgedEventIDs.clear() }
handledPostIDs.clear()
pendingUrgent.clear()
alertFlushJob?.cancel()
alertFlushJob = null
}
private fun handleArrival(post: BoardPostPacket) {
if (!handledPostIDs.add(post.identityKey()) || isOwnPost(post)) return
_unseenScopes.value = _unseenScopes.value + post.geohash
val age = nowMs().toLong() -
post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong()
if (!post.isUrgent || age > URGENT_RECENCY_MS) return
pendingUrgent.getOrPut(post.geohash) { mutableListOf() } += post
if (alertFlushJob == null) {
alertFlushJob = scope.launch {
delay(ALERT_COLLAPSE_MS)
val pending = pendingUrgent.mapValues { it.value.toList() }
pendingUrgent.clear()
alertFlushJob = null
pending.forEach { (geohash, posts) -> onUrgentPosts(geohash, posts) }
}
}
}
private fun isValidGeohash(value: String): Boolean =
value.isEmpty() ||
(value.length <= BoardWireConstants.GEOHASH_MAX_LENGTH &&
value.all { it in BoardWireConstants.GEOHASH_ALPHABET })
private fun truncateUtf8(value: String, maxBytes: Int): String {
var result = value
while (result.toByteArray(Charsets.UTF_8).size > maxBytes && result.isNotEmpty()) {
result = result.dropLast(1)
}
return result
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
private fun BoardPostPacket.identityKey(): String =
"${authorSigningKey.toHex()}:${postID.toHex()}"
private fun signingIdentityFor(geohash: String): BoardSigningIdentity? {
if (geohash.isNotEmpty()) {
// Never fall back to the stable mesh identity for a location scope.
return runCatching { geoIdentityProvider(geohash) }.getOrNull()
}
val mesh = meshProvider()
val publicKey = mesh.getSigningPublicKey()
?.takeIf { it.size == BoardWireConstants.SIGNING_KEY_LENGTH }
?: return null
return BoardSigningIdentity(publicKey, mesh::signData)
}
private companion object {
const val DAY_MS: ULong = 86_400_000uL
const val URGENT_RECENCY_MS = 30 * 60 * 1_000L
const val ALERT_COLLAPSE_MS = 4_000L
}
}

View File

@ -0,0 +1,402 @@
package com.bitchat.android.board
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.charset.CodingErrorAction
import java.security.MessageDigest
object BoardWireConstants {
const val POST_ID_LENGTH = 16
const val SIGNING_KEY_LENGTH = 32
const val SIGNATURE_LENGTH = 64
const val TRANSPORT_SENDER_ID_LENGTH = 8
const val CONTENT_MAX_BYTES = 512
const val NICKNAME_MAX_BYTES = 64
const val GEOHASH_MAX_LENGTH = 12
const val MAX_LIFETIME_MS: ULong = 604_800_000uL
const val POST_SIGNING_CONTEXT = "bitchat-board-v1"
const val TOMBSTONE_SIGNING_CONTEXT = "bitchat-board-del-v1"
const val GEOHASH_ALPHABET = "0123456789bcdefghjkmnpqrstuvwxyz"
}
class BoardPostPacket(
val postID: ByteArray,
val geohash: String,
val content: String,
val authorSigningKey: ByteArray,
val authorNickname: String,
val createdAt: ULong,
val expiresAt: ULong,
val flags: UByte,
val signature: ByteArray
) {
val isUrgent: Boolean
get() = (flags.toInt() and URGENT_FLAG.toInt()) != 0
val signingBytes: ByteArray
get() = signingBytes(
postID = postID,
geohash = geohash,
content = content,
authorSigningKey = authorSigningKey,
authorNickname = authorNickname,
createdAt = createdAt,
expiresAt = expiresAt,
flags = flags
)
fun verifySignature(): Boolean =
BoardWireCodec.verify(signature, signingBytes, authorSigningKey)
override fun equals(other: Any?): Boolean =
other is BoardPostPacket &&
postID.contentEquals(other.postID) &&
geohash == other.geohash &&
content == other.content &&
authorSigningKey.contentEquals(other.authorSigningKey) &&
authorNickname == other.authorNickname &&
createdAt == other.createdAt &&
expiresAt == other.expiresAt &&
flags == other.flags &&
signature.contentEquals(other.signature)
override fun hashCode(): Int {
var result = postID.contentHashCode()
result = 31 * result + geohash.hashCode()
result = 31 * result + content.hashCode()
result = 31 * result + authorSigningKey.contentHashCode()
result = 31 * result + authorNickname.hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + expiresAt.hashCode()
result = 31 * result + flags.hashCode()
return 31 * result + signature.contentHashCode()
}
companion object {
const val URGENT_FLAG: UByte = 0x01u
fun signingBytes(
postID: ByteArray,
geohash: String,
content: String,
authorSigningKey: ByteArray,
authorNickname: String,
createdAt: ULong,
expiresAt: ULong,
flags: UByte
): ByteArray = ByteArrayOutputStream().apply {
appendContext(BoardWireConstants.POST_SIGNING_CONTEXT)
write(postID)
appendLengthPrefixed(geohash.toByteArray(Charsets.UTF_8))
appendLengthPrefixed(content.toByteArray(Charsets.UTF_8))
write(authorSigningKey)
appendLengthPrefixed(authorNickname.toByteArray(Charsets.UTF_8))
appendULong(createdAt)
appendULong(expiresAt)
write(flags.toInt())
}.toByteArray()
}
}
class BoardTombstonePacket(
val postID: ByteArray,
val authorSigningKey: ByteArray,
val deletedAt: ULong,
val signature: ByteArray
) {
val signingBytes: ByteArray
get() = signingBytes(postID, deletedAt)
fun verifySignature(): Boolean =
BoardWireCodec.verify(signature, signingBytes, authorSigningKey)
override fun equals(other: Any?): Boolean =
other is BoardTombstonePacket &&
postID.contentEquals(other.postID) &&
authorSigningKey.contentEquals(other.authorSigningKey) &&
deletedAt == other.deletedAt &&
signature.contentEquals(other.signature)
override fun hashCode(): Int {
var result = postID.contentHashCode()
result = 31 * result + authorSigningKey.contentHashCode()
result = 31 * result + deletedAt.hashCode()
return 31 * result + signature.contentHashCode()
}
companion object {
fun signingBytes(postID: ByteArray, deletedAt: ULong): ByteArray =
ByteArrayOutputStream().apply {
appendContext(BoardWireConstants.TOMBSTONE_SIGNING_CONTEXT)
write(postID)
appendULong(deletedAt)
}.toByteArray()
}
}
sealed interface BoardWire {
data class Post(val packet: BoardPostPacket) : BoardWire
data class Tombstone(val packet: BoardTombstonePacket) : BoardWire
fun verifySignature(): Boolean = when (this) {
is Post -> packet.verifySignature()
is Tombstone -> packet.verifySignature()
}
}
/**
* Board payloads authenticate their embedded author key, so their outer mesh
* sender must not expose the device's stable peer ID. The pseudonym remains
* stable only for one board signing identity.
*/
fun BoardWire.transportSenderID(): ByteArray {
val authorKey = when (this) {
is BoardWire.Post -> packet.authorSigningKey
is BoardWire.Tombstone -> packet.authorSigningKey
}
return MessageDigest.getInstance("SHA-256")
.digest(authorKey)
.copyOf(BoardWireConstants.TRANSPORT_SENDER_ID_LENGTH)
}
object BoardWireCodec {
private const val TLV_KIND = 0x01
private const val TLV_POST_ID = 0x02
private const val TLV_GEOHASH = 0x03
private const val TLV_CONTENT = 0x04
private const val TLV_AUTHOR_SIGNING_KEY = 0x05
private const val TLV_AUTHOR_NICKNAME = 0x06
private const val TLV_CREATED_AT = 0x07
private const val TLV_EXPIRES_AT = 0x08
private const val TLV_FLAGS = 0x09
private const val TLV_SIGNATURE = 0x0A
private const val TLV_DELETED_AT = 0x0B
private const val KIND_POST = 0x01
private const val KIND_TOMBSTONE = 0x02
fun encode(wire: BoardWire): ByteArray = ByteArrayOutputStream().apply {
when (wire) {
is BoardWire.Post -> with(wire.packet) {
appendTlv(TLV_KIND, byteArrayOf(KIND_POST.toByte()))
appendTlv(TLV_POST_ID, postID)
appendTlv(TLV_GEOHASH, geohash.toByteArray(Charsets.UTF_8))
appendTlv(TLV_CONTENT, content.toByteArray(Charsets.UTF_8))
appendTlv(TLV_AUTHOR_SIGNING_KEY, authorSigningKey)
appendTlv(TLV_AUTHOR_NICKNAME, authorNickname.toByteArray(Charsets.UTF_8))
appendTlv(TLV_CREATED_AT, createdAt.toBigEndianBytes())
appendTlv(TLV_EXPIRES_AT, expiresAt.toBigEndianBytes())
appendTlv(TLV_FLAGS, byteArrayOf(flags.toByte()))
appendTlv(TLV_SIGNATURE, signature)
}
is BoardWire.Tombstone -> with(wire.packet) {
appendTlv(TLV_KIND, byteArrayOf(KIND_TOMBSTONE.toByte()))
appendTlv(TLV_POST_ID, postID)
appendTlv(TLV_AUTHOR_SIGNING_KEY, authorSigningKey)
appendTlv(TLV_DELETED_AT, deletedAt.toBigEndianBytes())
appendTlv(TLV_SIGNATURE, signature)
}
}
}.toByteArray()
fun decode(data: ByteArray): BoardWire? {
var offset = 0
var kind: Int? = null
var postID: ByteArray? = null
var geohash: String? = null
var content: String? = null
var contentBytes = 0
var authorSigningKey: ByteArray? = null
var authorNickname: String? = null
var nicknameBytes = 0
var createdAt: ULong? = null
var expiresAt: ULong? = null
var flags: UByte? = null
var signature: ByteArray? = null
var deletedAt: ULong? = null
while (offset + 3 <= data.size) {
val type = data[offset].toInt() and 0xFF
offset += 1
val length =
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
offset += 2
if (length > data.size - offset) return null
val value = data.copyOfRange(offset, offset + length)
offset += length
when (type) {
TLV_KIND -> {
if (value.size != 1) return null
kind = value[0].toInt() and 0xFF
}
TLV_POST_ID -> {
if (value.size != BoardWireConstants.POST_ID_LENGTH) return null
postID = value
}
TLV_GEOHASH -> {
if (value.size > BoardWireConstants.GEOHASH_MAX_LENGTH) return null
geohash = decodeUtf8(value) ?: return null
}
TLV_CONTENT -> {
if (value.size > BoardWireConstants.CONTENT_MAX_BYTES) return null
contentBytes = value.size
content = decodeUtf8(value) ?: return null
}
TLV_AUTHOR_SIGNING_KEY -> {
if (value.size != BoardWireConstants.SIGNING_KEY_LENGTH) return null
authorSigningKey = value
}
TLV_AUTHOR_NICKNAME -> {
if (value.size > BoardWireConstants.NICKNAME_MAX_BYTES) return null
nicknameBytes = value.size
authorNickname = decodeUtf8(value) ?: return null
}
TLV_CREATED_AT -> createdAt = value.toULongBigEndian() ?: return null
TLV_EXPIRES_AT -> expiresAt = value.toULongBigEndian() ?: return null
TLV_FLAGS -> {
if (value.size != 1) return null
flags = value[0].toUByte()
}
TLV_SIGNATURE -> {
if (value.size != BoardWireConstants.SIGNATURE_LENGTH) return null
signature = value
}
TLV_DELETED_AT -> deletedAt = value.toULongBigEndian() ?: return null
}
}
val requiredPostID = postID ?: return null
val requiredKey = authorSigningKey ?: return null
val requiredSignature = signature ?: return null
return when (kind) {
KIND_POST -> {
val requiredGeohash = geohash ?: return null
val requiredContent = content ?: return null
val requiredNickname = authorNickname ?: return null
val requiredCreatedAt = createdAt ?: return null
val requiredExpiresAt = expiresAt ?: return null
val requiredFlags = flags ?: return null
if (contentBytes !in 1..BoardWireConstants.CONTENT_MAX_BYTES) return null
if (nicknameBytes > BoardWireConstants.NICKNAME_MAX_BYTES) return null
if (!isValidGeohash(requiredGeohash)) return null
if (requiredExpiresAt <= requiredCreatedAt) return null
if (requiredExpiresAt - requiredCreatedAt > BoardWireConstants.MAX_LIFETIME_MS) return null
BoardWire.Post(
BoardPostPacket(
postID = requiredPostID,
geohash = requiredGeohash,
content = requiredContent,
authorSigningKey = requiredKey,
authorNickname = requiredNickname,
createdAt = requiredCreatedAt,
expiresAt = requiredExpiresAt,
flags = requiredFlags,
signature = requiredSignature
)
)
}
KIND_TOMBSTONE -> BoardWire.Tombstone(
BoardTombstonePacket(
postID = requiredPostID,
authorSigningKey = requiredKey,
deletedAt = deletedAt ?: return null,
signature = requiredSignature
)
)
else -> null
}
}
fun urgentFlag(data: ByteArray): Boolean {
var offset = 0
while (offset + 3 <= data.size) {
val type = data[offset].toInt() and 0xFF
offset += 1
val length =
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
offset += 2
if (length > data.size - offset) return false
if (type == TLV_FLAGS && length == 1) {
return (data[offset].toInt() and BoardPostPacket.URGENT_FLAG.toInt()) != 0
}
offset += length
}
return false
}
internal fun verify(signature: ByteArray, message: ByteArray, publicKey: ByteArray): Boolean =
try {
if (signature.size != BoardWireConstants.SIGNATURE_LENGTH ||
publicKey.size != BoardWireConstants.SIGNING_KEY_LENGTH
) {
false
} else {
Ed25519Signer().run {
init(false, Ed25519PublicKeyParameters(publicKey, 0))
update(message, 0, message.size)
verifySignature(signature)
}
}
} catch (_: Exception) {
false
}
private fun isValidGeohash(value: String): Boolean =
value.isEmpty() ||
(value.length <= BoardWireConstants.GEOHASH_MAX_LENGTH &&
value.all { it in BoardWireConstants.GEOHASH_ALPHABET })
private fun decodeUtf8(value: ByteArray): String? =
try {
Charsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(value))
.toString()
} catch (_: Exception) {
null
}
}
private fun ByteArrayOutputStream.appendTlv(type: Int, value: ByteArray) {
require(value.size <= 0xFFFF)
write(type)
write((value.size ushr 8) and 0xFF)
write(value.size and 0xFF)
write(value)
}
private fun ByteArrayOutputStream.appendContext(context: String) {
val value = context.toByteArray(Charsets.UTF_8).take(255).toByteArray()
write(value.size)
write(value)
}
private fun ByteArrayOutputStream.appendLengthPrefixed(value: ByteArray) {
val limited = value.take(0xFFFF).toByteArray()
write((limited.size ushr 8) and 0xFF)
write(limited.size and 0xFF)
write(limited)
}
private fun ByteArrayOutputStream.appendULong(value: ULong) {
write(value.toBigEndianBytes())
}
private fun ULong.toBigEndianBytes(): ByteArray =
ByteArray(8) { index -> (this shr ((7 - index) * 8)).toByte() }
private fun ByteArray.toULongBigEndian(): ULong? {
if (size != 8) return null
var value = 0uL
for (byte in this) {
value = (value shl 8) or (byte.toULong() and 0xFFuL)
}
return value
}

View File

@ -0,0 +1,59 @@
package com.bitchat.android.board
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import java.security.MessageDigest
/**
* Signing identity used by board payloads.
*
* Location boards use a key derived from the already-unlinkable per-geohash
* Nostr secret. Domain separation keeps the board Ed25519 identity distinct
* from the secp256k1 identity used on relays.
*/
class BoardSigningIdentity(
publicKey: ByteArray,
private val signer: (ByteArray) -> ByteArray?
) {
val publicKey: ByteArray = publicKey.copyOf()
init {
require(publicKey.size == BoardWireConstants.SIGNING_KEY_LENGTH)
}
fun sign(message: ByteArray): ByteArray? = signer(message)?.copyOf()
companion object {
private const val GEO_IDENTITY_CONTEXT = "bitchat-board-geo-identity-v1"
fun fromNostrPrivateKeyHex(privateKeyHex: String): BoardSigningIdentity? =
runCatching {
val nostrSecret = privateKeyHex.hexToByteArray()
.takeIf { it.size == BoardWireConstants.SIGNING_KEY_LENGTH }
?: return@runCatching null
val digest = MessageDigest.getInstance("SHA-256")
digest.update(GEO_IDENTITY_CONTEXT.toByteArray(Charsets.UTF_8))
digest.update(nostrSecret)
fromEd25519Seed(digest.digest())
}.getOrNull()
fun fromEd25519Seed(seed: ByteArray): BoardSigningIdentity {
require(seed.size == BoardWireConstants.SIGNING_KEY_LENGTH)
val privateKey = Ed25519PrivateKeyParameters(seed.copyOf(), 0)
return BoardSigningIdentity(privateKey.generatePublicKey().encoded) { message ->
Ed25519Signer().run {
init(true, privateKey)
update(message, 0, message.size)
generateSignature()
}
}
}
private fun String.hexToByteArray(): ByteArray {
require(length % 2 == 0)
return ByteArray(length / 2) { index ->
substring(index * 2, index * 2 + 2).toInt(16).toByte()
}
}
}
}

View File

@ -0,0 +1,382 @@
package com.bitchat.android.board
import android.content.Context
import android.util.Log
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
import java.util.Base64
enum class BoardIngestResult {
ACCEPTED,
DUPLICATE,
REJECTED
}
enum class BoardIngestSource {
REMOTE,
LOCAL,
RESTORE
}
class BoardStore(
private val file: File? = null,
private val nowMs: () -> ULong = { System.currentTimeMillis().coerceAtLeast(0).toULong() }
) {
object Limits {
const val MAX_POSTS = 200
const val MAX_POSTS_PER_AUTHOR = 5
const val MAX_ORPHAN_TOMBSTONES = 100
const val MAX_ORPHAN_TOMBSTONES_PER_AUTHOR = 5
const val CLOCK_SKEW_MS: ULong = 3_600_000uL
const val ORPHAN_TOMBSTONE_LIFETIME_MS: ULong = BoardWireConstants.MAX_LIFETIME_MS
}
private data class StoredPost(
val post: BoardPostPacket,
val packet: BitchatPacket,
val rawPacket: ByteArray
)
private data class StoredTombstone(
val tombstone: BoardTombstonePacket,
val packet: BitchatPacket,
val rawPacket: ByteArray,
val retainUntil: ULong,
val isOrphan: Boolean
)
private data class PersistedEntry(
val packet: String,
val retainUntil: String?
)
private val lock = Any()
private val posts = mutableListOf<StoredPost>()
private val tombstones = mutableListOf<StoredTombstone>()
private val _postsSnapshot = MutableStateFlow<List<BoardPostPacket>>(emptyList())
private val _postArrivals = MutableSharedFlow<BoardPostPacket>(extraBufferCapacity = 64)
val postsSnapshot: StateFlow<List<BoardPostPacket>> = _postsSnapshot.asStateFlow()
val postArrivals: SharedFlow<BoardPostPacket> = _postArrivals.asSharedFlow()
init {
loadFromDisk()
}
fun ingest(
wire: BoardWire,
packet: BitchatPacket,
source: BoardIngestSource = BoardIngestSource.REMOTE
): BoardIngestResult {
if (packet.type != MessageType.BOARD_POST.value || !wire.verifySignature()) {
return BoardIngestResult.REJECTED
}
val rawPacket = packet.toBinaryData(padding = false) ?: return BoardIngestResult.REJECTED
val now = nowMs()
var arrival: BoardPostPacket? = null
val result = synchronized(lock) {
val outcome = ingestLocked(
wire = wire,
packet = packet,
rawPacket = rawPacket,
now = now,
retainUntilOverride = null
)
if (outcome == BoardIngestResult.ACCEPTED && source != BoardIngestSource.RESTORE) {
persistLocked()
}
if (outcome == BoardIngestResult.ACCEPTED &&
source == BoardIngestSource.REMOTE &&
wire is BoardWire.Post
) {
arrival = wire.packet
}
outcome
}
arrival?.let(_postArrivals::tryEmit)
return result
}
/**
* Ingests a packet received from the mesh and reports whether this exact
* arrival may continue through the live relay path.
*
* A duplicate board payload must not relay again: the payload signature
* does not authenticate mutable outer packet fields such as timestamp, so
* accepting duplicates for relay would let one captured notice be wrapped
* in infinitely many distinct outer packets.
*/
fun ingestRemoteForRelay(wire: BoardWire, packet: BitchatPacket): Boolean =
ingest(wire, packet, BoardIngestSource.REMOTE) == BoardIngestResult.ACCEPTED
fun posts(forGeohash: String): List<BoardPostPacket> = synchronized(lock) {
pruneExpiredLocked(nowMs())
posts.asSequence()
.map { it.post }
.filter { it.geohash == forGeohash }
.sortedWith(
compareByDescending<BoardPostPacket> { it.isUrgent }
.thenByDescending { it.createdAt }
)
.toList()
}
fun syncCandidates(): List<BitchatPacket> = synchronized(lock) {
pruneExpiredLocked(nowMs())
posts.map { it.packet } + tombstones.map { it.packet }
}
fun pruneExpired() = synchronized(lock) {
val changed = pruneExpiredLocked(nowMs())
if (changed) persistLocked()
}
fun wipe() = synchronized(lock) {
posts.clear()
tombstones.clear()
file?.let { check(!it.exists() || it.delete()) }
publishSnapshotLocked()
}
private fun ingestLocked(
wire: BoardWire,
packet: BitchatPacket,
rawPacket: ByteArray,
now: ULong,
retainUntilOverride: ULong?
): BoardIngestResult {
pruneExpiredLocked(now)
return when (wire) {
is BoardWire.Post -> ingestPostLocked(wire.packet, packet, rawPacket, now)
is BoardWire.Tombstone -> ingestTombstoneLocked(
wire.packet,
packet,
rawPacket,
now,
retainUntilOverride
)
}
}
private fun ingestPostLocked(
post: BoardPostPacket,
packet: BitchatPacket,
rawPacket: ByteArray,
now: ULong
): BoardIngestResult {
if (post.expiresAt <= now) return BoardIngestResult.REJECTED
if (post.createdAt > now.saturatedAdd(Limits.CLOCK_SKEW_MS)) {
return BoardIngestResult.REJECTED
}
if (post.expiresAt > now.saturatedAdd(BoardWireConstants.MAX_LIFETIME_MS)
.saturatedAdd(Limits.CLOCK_SKEW_MS)
) {
return BoardIngestResult.REJECTED
}
if (tombstones.any {
it.tombstone.postID.contentEquals(post.postID) &&
it.tombstone.authorSigningKey.contentEquals(post.authorSigningKey)
}
) {
return BoardIngestResult.REJECTED
}
if (posts.any {
it.post.postID.contentEquals(post.postID) &&
it.post.authorSigningKey.contentEquals(post.authorSigningKey)
}
) {
return BoardIngestResult.DUPLICATE
}
posts += StoredPost(post, packet, rawPacket)
enforcePostCapsLocked(post.authorSigningKey)
publishSnapshotLocked()
return BoardIngestResult.ACCEPTED
}
private fun ingestTombstoneLocked(
tombstone: BoardTombstonePacket,
packet: BitchatPacket,
rawPacket: ByteArray,
now: ULong,
retainUntilOverride: ULong?
): BoardIngestResult {
if (tombstones.any {
it.tombstone.postID.contentEquals(tombstone.postID) &&
it.tombstone.authorSigningKey.contentEquals(tombstone.authorSigningKey)
}
) {
return BoardIngestResult.DUPLICATE
}
val maxRetain = minOf(
tombstone.deletedAt.saturatedAdd(Limits.ORPHAN_TOMBSTONE_LIFETIME_MS),
now.saturatedAdd(Limits.ORPHAN_TOMBSTONE_LIFETIME_MS)
.saturatedAdd(Limits.CLOCK_SKEW_MS)
)
val matchingPostIndex = posts.indexOfFirst {
it.post.postID.contentEquals(tombstone.postID) &&
it.post.authorSigningKey.contentEquals(tombstone.authorSigningKey)
}
val retainUntil: ULong
val isOrphan: Boolean
if (matchingPostIndex >= 0) {
val target = posts[matchingPostIndex].post
retainUntil = target.expiresAt
isOrphan = false
posts.removeAt(matchingPostIndex)
publishSnapshotLocked()
} else if (retainUntilOverride != null) {
retainUntil = minOf(retainUntilOverride, maxRetain)
isOrphan = false
} else {
retainUntil = maxRetain
isOrphan = true
}
if (retainUntil <= now) return BoardIngestResult.REJECTED
tombstones += StoredTombstone(
tombstone = tombstone,
packet = packet,
rawPacket = rawPacket,
retainUntil = retainUntil,
isOrphan = isOrphan
)
if (isOrphan) enforceOrphanTombstoneCapsLocked(tombstone.authorSigningKey)
return BoardIngestResult.ACCEPTED
}
private fun enforcePostCapsLocked(author: ByteArray) {
val authorPosts = posts.filter { it.post.authorSigningKey.contentEquals(author) }
evictOldestPostsLocked(authorPosts, Limits.MAX_POSTS_PER_AUTHOR)
evictOldestPostsLocked(posts.toList(), Limits.MAX_POSTS)
}
private fun evictOldestPostsLocked(candidates: List<StoredPost>, keep: Int) {
val victims = candidates.sortedBy { it.post.createdAt }
.take((candidates.size - keep).coerceAtLeast(0))
if (victims.isNotEmpty()) {
posts.removeAll { stored -> victims.any { it === stored } }
}
}
private fun enforceOrphanTombstoneCapsLocked(author: ByteArray) {
val authorOrphans = tombstones.filter {
it.isOrphan && it.tombstone.authorSigningKey.contentEquals(author)
}
removeOldestTombstonesLocked(
authorOrphans,
authorOrphans.size - Limits.MAX_ORPHAN_TOMBSTONES_PER_AUTHOR
)
val allOrphans = tombstones.filter { it.isOrphan }
removeOldestTombstonesLocked(
allOrphans,
allOrphans.size - Limits.MAX_ORPHAN_TOMBSTONES
)
}
private fun removeOldestTombstonesLocked(
candidates: List<StoredTombstone>,
count: Int
) {
if (count <= 0) return
val victims = candidates.take(count)
tombstones.removeAll { stored -> victims.any { it === stored } }
}
private fun pruneExpiredLocked(now: ULong): Boolean {
val postsBefore = posts.size
val tombstonesBefore = tombstones.size
posts.removeAll { it.post.expiresAt <= now }
tombstones.removeAll { it.retainUntil <= now }
if (posts.size != postsBefore) publishSnapshotLocked()
return posts.size != postsBefore || tombstones.size != tombstonesBefore
}
private fun publishSnapshotLocked() {
_postsSnapshot.value = posts.map { it.post }
}
private fun persistLocked() {
val target = file ?: return
val entries = posts.map {
PersistedEntry(
packet = Base64.getEncoder().encodeToString(it.rawPacket),
retainUntil = null
)
} + tombstones.map {
PersistedEntry(
packet = Base64.getEncoder().encodeToString(it.rawPacket),
retainUntil = it.retainUntil.toString()
)
}
runCatching {
if (entries.isEmpty()) {
if (target.exists()) target.delete()
return
}
target.parentFile?.mkdirs()
val temporary = File(target.parentFile, "${target.name}.tmp")
temporary.writeText(Gson().toJson(entries))
if (!temporary.renameTo(target)) {
temporary.copyTo(target, overwrite = true)
temporary.delete()
}
}.onFailure {
Log.e(TAG, "Failed to persist board store: ${it.message}")
}
}
private fun loadFromDisk() {
val target = file ?: return
if (!target.isFile) return
val entries = runCatching {
val type = object : TypeToken<List<PersistedEntry>>() {}.type
Gson().fromJson<List<PersistedEntry>>(target.readText(), type)
}.getOrNull() ?: return
val now = nowMs()
synchronized(lock) {
for (entry in entries) {
val raw = runCatching { Base64.getDecoder().decode(entry.packet) }.getOrNull() ?: continue
val packet = BitchatPacket.fromBinaryData(raw) ?: continue
if (packet.type != MessageType.BOARD_POST.value) continue
val wire = BoardWireCodec.decode(packet.payload) ?: continue
if (!wire.verifySignature()) continue
ingestLocked(
wire = wire,
packet = packet,
rawPacket = raw,
now = now,
retainUntilOverride = entry.retainUntil?.toULongOrNull()
)
}
publishSnapshotLocked()
}
}
companion object {
private const val TAG = "BoardStore"
@Volatile
private var instance: BoardStore? = null
fun getInstance(context: Context): BoardStore =
instance ?: synchronized(this) {
instance ?: BoardStore(
File(context.applicationContext.filesDir, "board/posts.json")
).also { instance = it }
}
}
}
private fun ULong.saturatedAdd(other: ULong): ULong =
if (ULong.MAX_VALUE - this < other) ULong.MAX_VALUE else this + other

View File

@ -0,0 +1,87 @@
package com.bitchat.android.board
import com.bitchat.android.nostr.LocationNotesManager
import kotlin.math.abs
enum class NoticeSource {
MESH,
NOSTR
}
data class UnifiedNotice(
val id: String,
val content: String,
val nickname: String,
val createdAtMs: Long,
val geohash: String,
val urgent: Boolean,
val expiresAtMs: Long?,
val source: NoticeSource,
val boardPost: BoardPostPacket? = null,
val nostrNote: LocationNotesManager.Note? = null
)
object UnifiedNotices {
private const val DEDUPLICATION_WINDOW_MS = 15 * 60 * 1_000L
/**
* Combines exact-scope mesh board posts with relay notes. When a relay
* bridge copy matches a board post, the signed board copy is authoritative.
*/
fun merge(
geohash: String,
boardPosts: List<BoardPostPacket>,
relayNotes: List<LocationNotesManager.Note>
): List<UnifiedNotice> {
val normalized = geohash.lowercase()
val scopedPosts = boardPosts.filter { it.geohash == normalized }
val boardNotices = scopedPosts.map { post ->
UnifiedNotice(
id = "mesh:${post.authorSigningKey.toHex()}:${post.postID.toHex()}",
content = post.content,
nickname = post.authorNickname,
createdAtMs = post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(),
geohash = post.geohash,
urgent = post.isUrgent,
expiresAtMs = post.expiresAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(),
source = NoticeSource.MESH,
boardPost = post
)
}
val relayNotices = relayNotes.asSequence()
.filterNot { note ->
scopedPosts.any { post ->
post.geohash == note.geohash.lowercase() &&
post.content == note.content &&
post.authorNickname.ifBlank { "anon" } ==
note.nickname?.trim()?.takeIf { it.isNotEmpty() }.orEmpty()
.ifEmpty { "anon" } &&
abs(
post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong() -
note.createdAt.toLong() * 1_000L
) <= DEDUPLICATION_WINDOW_MS
}
}
.map { note ->
UnifiedNotice(
id = "nostr:${note.id}",
content = note.content,
nickname = note.nickname.orEmpty(),
createdAtMs = note.createdAt.toLong() * 1_000L,
geohash = note.geohash.lowercase(),
urgent = note.isUrgent,
expiresAtMs = note.expiresAt?.toLong()?.times(1_000L),
source = NoticeSource.NOSTR,
nostrNote = note
)
}
.toList()
return (boardNotices + relayNotices).sortedWith(
compareByDescending<UnifiedNotice> { it.urgent }
.thenByDescending { it.createdAtMs }
)
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
}

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

@ -280,7 +280,7 @@ object FileUtils {
* Recursively delete all media files (incoming and outgoing)
* Used for Panic Mode cleanup
*/
fun clearAllMedia(context: Context) {
fun clearAllMedia(context: Context): Boolean {
try {
// Clear files dir subdirectories (legacy storage and outgoing)
val filesDir = context.filesDir
@ -295,7 +295,7 @@ object FileUtils {
dirsToClear.forEach { subDir ->
val dir = File(filesDir, subDir)
if (dir.exists()) {
dir.deleteRecursively()
check(dir.deleteRecursively())
Log.d(TAG, "Deleted media directory from filesDir: $subDir")
}
}
@ -312,17 +312,19 @@ object FileUtils {
cacheDirsToClear.forEach { subDir ->
val dir = File(cacheDir, subDir)
if (dir.exists()) {
dir.deleteRecursively()
check(dir.deleteRecursively())
Log.d(TAG, "Deleted media directory from cacheDir: $subDir")
}
}
// Also clear entire cache dir as a catch-all
context.cacheDir.deleteRecursively()
check(!context.cacheDir.exists() || context.cacheDir.deleteRecursively())
Log.d(TAG, "Cleared entire cache directory")
return true
} catch (e: Exception) {
Log.e(TAG, "Failed to clear media files", e)
return false
}
}

View File

@ -252,14 +252,22 @@ class LiveVoiceManager private constructor(private val context: Context) {
} ?: return false
val finished = entry.value
val replacement = message.copy(
id = finished.messageID,
id = if (message.isPrivate && com.bitchat.android.model.PrivateMediaMessageIdentity.isStableID(message.id)) message.id else finished.messageID,
timestamp = finished.timestamp,
sender = finished.nickname,
senderPeerID = peerID,
isPrivate = messageScope == LiveVoiceScope.DIRECT_MESSAGE
)
if (messageScope == LiveVoiceScope.DIRECT_MESSAGE) {
AppStateStore.upsertPrivateMessage(peerID, replacement, isVisible(messageScope, peerID))
if (replacement.id != finished.messageID) {
val saved = kotlinx.coroutines.runBlocking {
AppStateStore.addPrivateMessageDurably(peerID, replacement, isVisible(messageScope, peerID))
}
if (!saved) return false
AppStateStore.removePrivateMessage(finished.messageID)
} else {
AppStateStore.upsertPrivateMessage(peerID, replacement, isVisible(messageScope, peerID))
}
} else {
AppStateStore.upsertPublicMessage(replacement)
}

View File

@ -162,9 +162,12 @@ class LocationChannelManager private constructor(private val context: Context) {
/**
* Refresh available channels from current location
*/
fun refreshChannels() {
fun refreshChannels(
forceFresh: Boolean = false,
updatePlaceNames: Boolean = true
) {
if (syncPermissionState() == PermissionState.AUTHORIZED && isLocationServicesEnabled()) {
requestOneShotLocation()
requestOneShotLocation(forceFresh, updatePlaceNames)
}
}
@ -355,7 +358,10 @@ class LocationChannelManager private constructor(private val context: Context) {
// MARK: - Location Operations
private fun requestOneShotLocation() {
private fun requestOneShotLocation(
forceFresh: Boolean = false,
updatePlaceNames: Boolean = true
) {
if (!isLocationServicesEnabled() ||
syncPermissionState() != PermissionState.AUTHORIZED
) {
@ -367,37 +373,53 @@ class LocationChannelManager private constructor(private val context: Context) {
val token = LiveLocationPrivacyGate.captureToken() ?: return
_isLoadingLocation.value = true
if (forceFresh) {
requestFreshLocation(token, updatePlaceNames)
return
}
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
locationProvider.getLastKnownLocation { cached ->
if (!canUseLiveLocation(token)) return@getLastKnownLocation
if (cached != null) {
onLocationUpdated(cached, token)
onLocationUpdated(cached, token, updatePlaceNames)
} else {
LiveLocationPrivacyGate.runIfAllowed(token) {
locationProvider.requestFreshLocation { fresh ->
if (!canUseLiveLocation(token)) return@requestFreshLocation
if (fresh != null) {
onLocationUpdated(fresh, token)
} else {
Log.w(TAG, "Failed to get fresh location")
_isLoadingLocation.value = false
}
}
}
requestFreshLocation(token, updatePlaceNames)
}
}
}
if (!started) _isLoadingLocation.value = false
}
private fun onLocationUpdated(location: Location, token: Long) {
private fun requestFreshLocation(
token: Long,
updatePlaceNames: Boolean
) {
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
locationProvider.requestFreshLocation { fresh ->
if (!canUseLiveLocation(token)) return@requestFreshLocation
if (fresh != null) {
onLocationUpdated(fresh, token, updatePlaceNames)
} else {
Log.w(TAG, "Failed to get fresh location")
_isLoadingLocation.value = false
}
}
}
if (!started) _isLoadingLocation.value = false
}
private fun onLocationUpdated(
location: Location,
token: Long,
updatePlaceNames: Boolean = true
) {
LiveLocationPrivacyGate.runIfAllowed(token) {
if (!_systemLocationEnabled.value || !hasRuntimeLocationPermission()) return@runIfAllowed
_isLoadingLocation.value = false
computeChannels(location, token)
reverseGeocodeIfNeeded(location, token)
if (updatePlaceNames) reverseGeocodeIfNeeded(location, token)
}
}
@ -652,6 +674,12 @@ class LocationChannelManager private constructor(private val context: Context) {
_teleported.value = false
}
/** Remove exact/transient location state without destroying the singleton. */
fun panicReset() {
clearLiveLocationState()
clearPersistedChannel()
}
// MARK: - Location Services State Persistence
/**

View File

@ -0,0 +1,715 @@
package com.bitchat.android.groups
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.model.PeerCapabilities
import java.util.ArrayDeque
import java.util.Date
import java.util.UUID
data class GroupPeerIdentity(
val fingerprint: String,
val signingKey: ByteArray
)
data class GroupCommandResult(
val success: Boolean,
val message: String
)
enum class PeerGroupCapability {
SUPPORTED,
UNSUPPORTED,
UNKNOWN;
companion object {
fun fromPeerState(
capabilities: PeerCapabilities?,
hasVerifiedAnnouncement: Boolean
): PeerGroupCapability = when {
capabilities?.contains(PeerCapabilities.GROUPS) == true -> SUPPORTED
capabilities != null || hasVerifiedAnnouncement -> UNSUPPORTED
else -> UNKNOWN
}
}
}
interface GroupCoordinatorContext {
val groupStore: GroupStore
val nickname: String
val myPeerID: String
val selectedConversationID: String?
fun myNoiseFingerprint(): String
fun mySigningPublicKey(): ByteArray?
fun sign(data: ByteArray): ByteArray?
fun peerIDsForNickname(nickname: String): List<String>
fun isPeerConnected(peerID: String): Boolean
fun peerGroupCapability(peerID: String): PeerGroupCapability
fun peerNickname(peerID: String): String?
fun peerIdentity(peerID: String): GroupPeerIdentity?
fun connectedPeerID(fingerprint: String): String?
fun isFingerprintBlocked(fingerprint: String): Boolean
fun sendGroupInvite(payload: ByteArray, peerID: String)
fun sendGroupKeyUpdate(payload: ByteArray, peerID: String)
fun broadcastGroupMessage(payload: ByteArray)
fun appendGroupMessage(groupPeerID: String, message: BitchatMessage): Boolean
fun markGroupUnread(groupPeerID: String)
fun removeGroupConversation(groupPeerID: String)
fun openGroupConversation(groupPeerID: String)
fun closeGroupConversation()
fun addSystemMessage(message: String)
fun addGroupSystemMessage(groupPeerID: String, message: String)
fun notifyGroupMessage(groupPeerID: String, sender: String, message: String)
}
/**
* Creator-managed private-group state machine matching iOS v1.
*/
class GroupCoordinator(private val context: GroupCoordinatorContext) {
private data class MemberSelector(
val nickname: String,
val identitySuffix: String?
)
private sealed class PendingEvent {
data class Invite(
val peerID: String,
val authenticatedRemoteStaticKey: ByteArray,
val payload: ByteArray
) : PendingEvent()
data class KeyUpdate(
val peerID: String,
val authenticatedRemoteStaticKey: ByteArray,
val payload: ByteArray
) : PendingEvent()
data class Message(
val payload: ByteArray,
val receivedAtMs: Long
) : PendingEvent()
data class PeerAuthenticated(val peerID: String) : PendingEvent()
}
private data class FutureMessage(
val groupID: ByteArray,
val epoch: Long,
val payload: ByteArray,
val queuedAtMs: Long
)
private val lifecycleLock = Any()
private val pendingLock = Any()
private val pendingEvents = ArrayDeque<PendingEvent>()
private val futureMessages = ArrayDeque<FutureMessage>()
@Volatile
private var acceptsInboundEvents = true
@Volatile
private var inboundGeneration = 0L
@Synchronized
fun createGroup(rawName: String): GroupCommandResult {
if (!acceptsInboundEvents) return error("private groups are paused")
if (!context.groupStore.isReady) return loadingError()
val name = rawName.trim()
if (name.isEmpty()) return error("usage: /group create <name>")
if (name.codePointCount(0, name.length) > MAX_GROUP_NAME_LENGTH) {
return error("group name must be $MAX_GROUP_NAME_LENGTH characters or fewer")
}
val fingerprint = context.myNoiseFingerprint()
val signingKey = context.mySigningPublicKey()
if (!FINGERPRINT.matches(fingerprint) || signingKey?.size != 32) {
return error("your cryptographic identity is not ready")
}
val creator = GroupMember(fingerprint, signingKey, context.nickname)
val group = context.groupStore.createGroup(name, creator)
?: return error("could not create group")
context.openGroupConversation(group.peerID)
return success("created private group #${group.name}")
}
@Synchronized
fun inviteMember(rawNickname: String): GroupCommandResult {
if (!acceptsInboundEvents) return error("private groups are paused")
if (!context.groupStore.isReady) return loadingError()
val selector = parseMemberSelector(rawNickname)
?: return error("usage: /group invite <nickname>[#identity-suffix]")
val nickname = selector.nickname
val group = selectedGroup() ?: return error("open a private group first")
if (!isCreator(group)) return error("only the group creator can change members")
val peerID = resolvePeer(selector) ?: return ambiguousPeerError(selector)
if (!context.isPeerConnected(peerID)) return error("$nickname is not connected")
when (context.peerGroupCapability(peerID)) {
PeerGroupCapability.SUPPORTED -> Unit
PeerGroupCapability.UNSUPPORTED ->
return error("$nickname does not support private groups")
PeerGroupCapability.UNKNOWN ->
return error("private-group support for $nickname is not confirmed yet; try again")
}
val identity = context.peerIdentity(peerID)
?: return error("$nickname does not have a verified mesh identity")
if (group.isMember(identity.fingerprint)) return error("$nickname is already a member")
if (group.members.size >= BitchatGroup.MAX_MEMBERS) {
return error("groups are limited to ${BitchatGroup.MAX_MEMBERS} members")
}
val member = GroupMember(
identity.fingerprint,
identity.signingKey,
context.peerNickname(peerID) ?: nickname
)
val (updated, key) = context.groupStore.rotateKey(
group.groupID,
group.members + member
) ?: return error("could not rotate the group key")
val payload = signedStatePayload(updated, key)
?: return error("could not sign the group invite")
context.sendGroupInvite(payload, peerID)
distributeState(payload, updated, setOf(identity.fingerprint))
return success("invited $nickname to #${updated.name}")
}
@Synchronized
fun removeMember(rawNickname: String): GroupCommandResult {
if (!acceptsInboundEvents) return error("private groups are paused")
if (!context.groupStore.isReady) return loadingError()
val selector = parseMemberSelector(rawNickname)
?: return error("usage: /group remove <nickname>[#identity-suffix]")
val nickname = selector.nickname
val group = selectedGroup() ?: return error("open a private group first")
if (!isCreator(group)) return error("only the group creator can change members")
val matchingMembers = group.members.filter {
it.nickname.equals(nickname, ignoreCase = true)
}.let { members ->
selector.identitySuffix?.let { suffix ->
members.filter { it.fingerprint.endsWith(suffix, ignoreCase = true) }
} ?: members
}
val member = matchingMembers.singleOrNull() ?: return when {
matchingMembers.isEmpty() -> error("$nickname is not in this group")
else -> error(
"multiple members are named '$nickname'; use ${memberChoices(matchingMembers)}"
)
}
if (member.fingerprint == group.creatorFingerprint) {
return error("the creator cannot remove themselves")
}
val remaining = group.members.filterNot { it.fingerprint == member.fingerprint }
val (rotated, key) = context.groupStore.rotateKey(group.groupID, remaining)
?: return error("could not rotate the group key")
val payload = signedStatePayload(rotated, key)
?: return error("could not sign the group update")
distributeState(payload, rotated, emptySet())
notifyRemovedMember(member, rotated)
return success("removed ${member.nickname} and rotated the group key")
}
@Synchronized
fun leaveGroup(): GroupCommandResult {
if (!acceptsInboundEvents) return error("private groups are paused")
if (!context.groupStore.isReady) return loadingError()
val group = selectedGroup() ?: return error("open a private group first")
if (isCreator(group) && group.members.size > 1) {
return error("remove all other members before leaving this group")
}
val removed = if (isCreator(group)) {
context.groupStore.removeGroup(group.groupID)
} else {
context.groupStore.departGroup(group.groupID, group.epoch)
}
if (!removed) return error("could not leave group")
synchronized(lifecycleLock) {
dropFutureMessages(group.groupID)
}
context.closeGroupConversation()
context.removeGroupConversation(group.peerID)
return success("left #${group.name}")
}
@Synchronized
fun listGroups(): GroupCommandResult {
if (!context.groupStore.isReady) return loadingError()
val groups = context.groupStore.groups.value
if (groups.isEmpty()) return success("you are not in any private groups")
val fingerprint = context.myNoiseFingerprint()
val lines = groups.joinToString("\n") { group ->
val role = if (group.creatorFingerprint == fingerprint) " (creator)" else ""
"#${group.name}$role — ${group.members.size}/${BitchatGroup.MAX_MEMBERS}"
}
return success("private groups:\n$lines")
}
@Synchronized
fun sendMessage(content: String, groupPeerID: String): Boolean {
if (!acceptsInboundEvents) return false
if (!context.groupStore.isReady) {
context.addGroupSystemMessage(groupPeerID, "private groups are still loading")
return false
}
if (content.isEmpty() ||
content.codePointCount(0, content.length) > MAX_MESSAGE_LENGTH
) {
return false
}
val group = context.groupStore.group(groupPeerID)
val key = group?.let { context.groupStore.key(it.groupID) }
if (group == null || key == null) {
context.addGroupSystemMessage(groupPeerID, "this private group is unavailable")
return false
}
val signingKey = context.mySigningPublicKey()
if (signingKey?.size != 32) {
context.addGroupSystemMessage(groupPeerID, "your signing identity is unavailable")
return false
}
val messageID = UUID.randomUUID().toString()
val timestamp = System.currentTimeMillis()
val payload = try {
GroupCrypto.sealMessage(
content = content,
messageID = messageID,
senderNickname = context.nickname,
senderSigningKey = signingKey,
timestampMs = timestamp,
groupID = group.groupID,
epoch = group.epoch,
key = key,
sign = context::sign
)
} catch (_: Exception) {
context.addGroupSystemMessage(groupPeerID, "could not encrypt group message")
return false
}
val stored = context.appendGroupMessage(
groupPeerID,
BitchatMessage(
id = messageID,
sender = context.nickname,
content = content,
timestamp = Date(timestamp),
isPrivate = true,
recipientNickname = group.name,
senderPeerID = context.myPeerID,
deliveryStatus = DeliveryStatus.Sent
)
)
if (!stored) return false
context.broadcastGroupMessage(payload)
return true
}
@Synchronized
fun handleMessage(payload: ByteArray, receivedAtMs: Long) {
val generation = inboundGeneration
if (!acceptsInboundEvents) return
synchronized(lifecycleLock) {
if (!acceptsInboundEvents || generation != inboundGeneration) return
if (deferIfLoading(PendingEvent.Message(payload.copyOf(), receivedAtMs))) return
processMessage(payload)
}
}
private fun processMessage(payload: ByteArray, queueFutureEpoch: Boolean = true) {
val envelope = GroupMessageEnvelope.decode(payload) ?: return
val group = context.groupStore.group(envelope.groupID) ?: return
if (envelope.epoch != group.epoch) {
if (queueFutureEpoch && envelope.epoch > group.epoch) {
queueFutureMessage(envelope, payload)
}
return
}
val key = context.groupStore.key(group.groupID) ?: return
val plaintext = try {
GroupCrypto.openMessage(envelope, key)
} catch (_: Exception) {
return
}
val member = group.memberWithSigningKey(plaintext.senderSigningKey) ?: return
val ownSigningKey = context.mySigningPublicKey()
if (ownSigningKey != null && plaintext.senderSigningKey.contentEquals(ownSigningKey)) return
if (context.isFingerprintBlocked(member.fingerprint)) return
val now = System.currentTimeMillis()
val timestamp = plaintext.timestampMs.coerceIn(0, now)
val sender = member.nickname.ifBlank { plaintext.senderNickname }
val message = BitchatMessage(
id = plaintext.messageID,
sender = sender,
content = plaintext.content,
timestamp = Date(timestamp),
isPrivate = true,
recipientNickname = group.name,
senderPeerID = member.fingerprint.take(16)
)
if (!context.appendGroupMessage(group.peerID, message)) return
if (context.selectedConversationID != group.peerID) {
context.markGroupUnread(group.peerID)
if (now - timestamp < RECENT_NOTIFICATION_WINDOW_MS) {
context.notifyGroupMessage(group.peerID, "$sender @ ${group.name}", plaintext.content)
}
}
}
@Synchronized
fun handleInvite(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
val generation = inboundGeneration
if (!acceptsInboundEvents) return
synchronized(lifecycleLock) {
if (!acceptsInboundEvents || generation != inboundGeneration) return
if (
deferIfLoading(
PendingEvent.Invite(
peerID,
authenticatedRemoteStaticKey.copyOf(),
payload.copyOf()
)
)
) {
return
}
applyState(peerID, authenticatedRemoteStaticKey, payload, isInvite = true)
}
}
@Synchronized
fun handleKeyUpdate(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
val generation = inboundGeneration
if (!acceptsInboundEvents) return
synchronized(lifecycleLock) {
if (!acceptsInboundEvents || generation != inboundGeneration) return
if (
deferIfLoading(
PendingEvent.KeyUpdate(
peerID,
authenticatedRemoteStaticKey.copyOf(),
payload.copyOf()
)
)
) {
return
}
applyState(peerID, authenticatedRemoteStaticKey, payload, isInvite = false)
}
}
/**
* Replays the current creator-signed state after an authenticated peer
* reconnects. This uses the existing iOS GROUP_KEY_UPDATE payload and
* repairs updates that could not be delivered while the member was offline.
*/
@Synchronized
fun handlePeerAuthenticated(peerID: String) {
val generation = inboundGeneration
if (!acceptsInboundEvents) return
synchronized(lifecycleLock) {
if (!acceptsInboundEvents || generation != inboundGeneration) return
if (deferIfLoading(PendingEvent.PeerAuthenticated(peerID))) return
if (!context.isPeerConnected(peerID)) return
if (context.peerGroupCapability(peerID) != PeerGroupCapability.SUPPORTED) return
val identity = context.peerIdentity(peerID) ?: return
val ownFingerprint = context.myNoiseFingerprint()
context.groupStore.groups.value.forEach { group ->
if (group.creatorFingerprint != ownFingerprint ||
!group.isMember(identity.fingerprint)
) {
return@forEach
}
val key = context.groupStore.key(group.groupID) ?: return@forEach
val payload = signedStatePayload(group, key) ?: return@forEach
context.sendGroupKeyUpdate(payload, peerID)
}
}
}
/**
* Drains packets received during asynchronous store initialization.
*/
@Synchronized
fun onStoreReady() {
val generation = inboundGeneration
if (!acceptsInboundEvents) return
synchronized(lifecycleLock) {
if (!acceptsInboundEvents ||
generation != inboundGeneration ||
!context.groupStore.isReady
) {
return
}
while (true) {
val event = synchronized(pendingLock) {
pendingEvents.pollFirst()
} ?: return
when (event) {
is PendingEvent.Invite -> applyState(
event.peerID,
event.authenticatedRemoteStaticKey,
event.payload,
isInvite = true
)
is PendingEvent.KeyUpdate -> applyState(
event.peerID,
event.authenticatedRemoteStaticKey,
event.payload,
isInvite = false
)
is PendingEvent.Message -> processMessage(event.payload)
is PendingEvent.PeerAuthenticated ->
handlePeerAuthenticated(event.peerID)
}
}
}
}
@Synchronized
fun suspendForPanic() {
synchronized(lifecycleLock) {
acceptsInboundEvents = false
inboundGeneration += 1
synchronized(pendingLock) {
pendingEvents.clear()
}
futureMessages.clear()
}
}
@Synchronized
fun resumeAfterPanic() {
synchronized(lifecycleLock) {
acceptsInboundEvents = true
}
}
private fun applyState(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray,
isInvite: Boolean
) {
val state = GroupStatePayload.decode(payload) ?: return
val senderFingerprint = sha256(authenticatedRemoteStaticKey).toHex()
if (senderFingerprint != state.creatorFingerprint) return
if (!state.verifyCreatorSignature()) return
val ownFingerprint = context.myNoiseFingerprint()
val existing = context.groupStore.group(state.groupID)
// Reject stale state before interpreting a missing-self roster as a
// removal. Otherwise an old, valid removal notice could delete a
// membership restored by a later creator-signed re-invite.
if (existing != null && state.epoch < existing.epoch) return
val departureEpoch = context.groupStore.departureEpoch(state.groupID)
if (departureEpoch != null &&
(!isInvite || state.epoch <= departureEpoch)
) {
return
}
if (state.members.none { it.fingerprint == ownFingerprint }) {
val removed = context.groupStore.removeGroupForState(state.groupID, state.epoch)
?: return
dropFutureMessages(removed.groupID)
if (context.selectedConversationID == removed.peerID) {
context.closeGroupConversation()
}
context.removeGroupConversation(removed.peerID)
context.addSystemMessage("you were removed from #${removed.name}")
return
}
val stored = if (isInvite && departureEpoch != null) {
context.groupStore.acceptInvite(state.asGroup(), state.key)
} else {
context.groupStore.upsert(state.asGroup(), state.key)
}
if (!stored) return
retryFutureMessages(state.groupID)
if (existing == null) {
val inviter = state.members.firstOrNull {
it.fingerprint == state.creatorFingerprint
}?.nickname ?: context.peerNickname(peerID) ?: "unknown"
val notice = "joined #${state.name}, invited by $inviter"
context.addSystemMessage(notice)
context.markGroupUnread(state.asGroup().peerID)
context.notifyGroupMessage(state.asGroup().peerID, inviter, notice)
}
}
private fun signedStatePayload(group: BitchatGroup, key: ByteArray): ByteArray? =
GroupStatePayload.makeSigned(group, key, context::sign)?.encode()
private fun distributeState(
payload: ByteArray,
group: BitchatGroup,
excludedFingerprints: Set<String>
) {
val ownFingerprint = context.myNoiseFingerprint()
group.members.forEach { member ->
if (member.fingerprint == ownFingerprint ||
member.fingerprint in excludedFingerprints
) {
return@forEach
}
context.connectedPeerID(member.fingerprint)?.let { peerID ->
context.sendGroupKeyUpdate(payload, peerID)
}
}
}
private fun notifyRemovedMember(member: GroupMember, rotated: BitchatGroup) {
val peerID = context.connectedPeerID(member.fingerprint) ?: return
val payload = signedStatePayload(rotated, ByteArray(BitchatGroup.KEY_LENGTH)) ?: return
context.sendGroupKeyUpdate(payload, peerID)
}
private fun selectedGroup(): BitchatGroup? =
context.selectedConversationID?.let(context.groupStore::group)
private fun isCreator(group: BitchatGroup): Boolean =
group.creatorFingerprint == context.myNoiseFingerprint()
private fun parseMemberSelector(raw: String): MemberSelector? {
val normalized = raw.trim().removePrefix("@")
if (normalized.isEmpty()) return null
val separator = normalized.lastIndexOf('#')
if (separator < 0) return MemberSelector(normalized, null)
if (separator == 0 || separator == normalized.lastIndex) return null
val suffix = normalized.substring(separator + 1)
if (!IDENTITY_SUFFIX.matches(suffix)) return null
return MemberSelector(
nickname = normalized.substring(0, separator),
identitySuffix = suffix.lowercase()
)
}
private fun resolvePeer(selector: MemberSelector): String? {
val matches = context.peerIDsForNickname(selector.nickname).distinct()
val narrowed = selector.identitySuffix?.let { suffix ->
matches.filter { peerID ->
peerID.endsWith(suffix, ignoreCase = true) ||
context.peerIdentity(peerID)
?.fingerprint
?.endsWith(suffix, ignoreCase = true) == true
}
} ?: matches
return narrowed.singleOrNull()
}
private fun ambiguousPeerError(selector: MemberSelector): GroupCommandResult {
val matches = context.peerIDsForNickname(selector.nickname).distinct()
if (matches.isEmpty() || selector.identitySuffix != null) {
return error("user '${selector.nickname}' was not found")
}
return error(
"multiple users are named '${selector.nickname}'; use " +
matches.joinToString(" or ") { peerID ->
val suffix = context.peerIdentity(peerID)
?.fingerprint
?.takeLast(8)
?: peerID.takeLast(8)
"@${selector.nickname}#$suffix"
}
)
}
private fun memberChoices(members: List<GroupMember>): String =
members.joinToString(" or ") { "@${it.nickname}#${it.fingerprint.takeLast(8)}" }
private fun queueFutureMessage(envelope: GroupMessageEnvelope, payload: ByteArray) {
val now = System.currentTimeMillis()
pruneExpiredFutureMessages(now)
if (futureMessages.any {
it.epoch == envelope.epoch &&
it.groupID.contentEquals(envelope.groupID) &&
it.payload.contentEquals(payload)
}
) {
return
}
if (futureMessages.size >= MAX_FUTURE_MESSAGES) futureMessages.pollFirst()
futureMessages.addLast(
FutureMessage(
envelope.groupID.copyOf(),
envelope.epoch,
payload.copyOf(),
now
)
)
}
private fun retryFutureMessages(groupID: ByteArray) {
val current = context.groupStore.group(groupID) ?: return
val now = System.currentTimeMillis()
val ready = mutableListOf<ByteArray>()
val iterator = futureMessages.iterator()
while (iterator.hasNext()) {
val message = iterator.next()
if (now - message.queuedAtMs > FUTURE_MESSAGE_TTL_MS) {
iterator.remove()
} else if (message.groupID.contentEquals(groupID) &&
message.epoch <= current.epoch
) {
iterator.remove()
if (message.epoch == current.epoch) ready += message.payload
}
}
ready.forEach { processMessage(it, queueFutureEpoch = false) }
}
private fun dropFutureMessages(groupID: ByteArray) {
val iterator = futureMessages.iterator()
while (iterator.hasNext()) {
if (iterator.next().groupID.contentEquals(groupID)) iterator.remove()
}
}
private fun pruneExpiredFutureMessages(now: Long) {
val iterator = futureMessages.iterator()
while (iterator.hasNext()) {
if (now - iterator.next().queuedAtMs > FUTURE_MESSAGE_TTL_MS) iterator.remove()
}
}
private fun deferIfLoading(event: PendingEvent): Boolean =
synchronized(pendingLock) {
if (context.groupStore.isReady) return@synchronized false
if (pendingEvents.size >= MAX_PENDING_EVENTS) pendingEvents.pollFirst()
pendingEvents.addLast(event)
true
}
private fun loadingError() =
error("private groups are still loading; try again")
private fun success(message: String) = GroupCommandResult(true, message)
private fun error(message: String) = GroupCommandResult(false, message)
private fun ByteArray.toHex(): String =
joinToString("") { "%02x".format(it) }
companion object {
private const val MAX_GROUP_NAME_LENGTH = 40
private const val MAX_MESSAGE_LENGTH = 60_000
private const val RECENT_NOTIFICATION_WINDOW_MS = 30_000L
private const val MAX_PENDING_EVENTS = 64
private const val MAX_FUTURE_MESSAGES = 32
private const val FUTURE_MESSAGE_TTL_MS = 2 * 60_000L
private val FINGERPRINT = Regex("^[0-9a-fA-F]{64}$")
private val IDENTITY_SUFFIX = Regex("^[0-9a-fA-F]{4,64}$")
}
}

View File

@ -0,0 +1,663 @@
package com.bitchat.android.groups
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.util.hexEncodedString
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.charset.CodingErrorAction
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Arrays
import java.util.UUID
import org.bouncycastle.crypto.InvalidCipherTextException
import org.bouncycastle.crypto.modes.ChaCha20Poly1305
import org.bouncycastle.crypto.params.AEADParameters
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import org.bouncycastle.crypto.params.KeyParameter
import org.bouncycastle.crypto.signers.Ed25519Signer
data class GroupMember(
val fingerprint: String,
val signingKey: ByteArray,
val nickname: String
) {
override fun equals(other: Any?): Boolean =
this === other ||
(other is GroupMember &&
fingerprint == other.fingerprint &&
signingKey.contentEquals(other.signingKey) &&
nickname == other.nickname)
override fun hashCode(): Int =
31 * (31 * fingerprint.hashCode() + signingKey.contentHashCode()) + nickname.hashCode()
}
data class BitchatGroup(
val groupID: ByteArray,
val name: String,
val epoch: Long,
val members: List<GroupMember>,
val creatorFingerprint: String
) {
val peerID: String get() = GroupIds.peerID(groupID)
val creator: GroupMember? get() = members.firstOrNull { it.fingerprint == creatorFingerprint }
fun isMember(fingerprint: String): Boolean =
members.any { it.fingerprint == fingerprint }
fun memberWithSigningKey(signingKey: ByteArray): GroupMember? =
members.firstOrNull { it.signingKey.contentEquals(signingKey) }
override fun equals(other: Any?): Boolean =
this === other ||
(other is BitchatGroup &&
groupID.contentEquals(other.groupID) &&
name == other.name &&
epoch == other.epoch &&
members == other.members &&
creatorFingerprint == other.creatorFingerprint)
override fun hashCode(): Int {
var result = groupID.contentHashCode()
result = 31 * result + name.hashCode()
result = 31 * result + epoch.hashCode()
result = 31 * result + members.hashCode()
result = 31 * result + creatorFingerprint.hashCode()
return result
}
companion object {
const val MAX_MEMBERS = 16
const val GROUP_ID_LENGTH = 16
const val KEY_LENGTH = 32
const val MAX_EPOCH = 0xffff_ffffL
}
}
object GroupIds {
private const val PREFIX = "group_"
private val pattern = Regex("^group_[0-9a-f]{32}$")
fun peerID(groupID: ByteArray): String {
require(groupID.size == BitchatGroup.GROUP_ID_LENGTH)
return PREFIX + groupID.hexEncodedString()
}
fun groupID(peerID: String): ByteArray? {
val normalized = peerID.lowercase()
if (!pattern.matches(normalized)) return null
return normalized.removePrefix(PREFIX).dataFromHexString()
}
fun isGroup(peerID: String?): Boolean =
peerID != null && pattern.matches(peerID.lowercase())
}
class GroupTlvValueTooLongException : IllegalArgumentException("group TLV value exceeds UInt16")
internal object GroupTLV {
data class Field(val type: Int, val value: ByteArray)
fun put(type: Int, value: ByteArray, output: ByteArrayOutputStream) {
if (value.size > 0xffff) throw GroupTlvValueTooLongException()
output.write(type and 0xff)
output.write((value.size ushr 8) and 0xff)
output.write(value.size and 0xff)
output.write(value)
}
fun encode(vararg fields: Pair<Int, ByteArray>): ByteArray {
val output = ByteArrayOutputStream()
fields.forEach { (type, value) -> put(type, value, output) }
return output.toByteArray()
}
fun parse(data: ByteArray): List<Field>? {
val fields = mutableListOf<Field>()
var offset = 0
while (offset < data.size) {
if (offset + 3 > data.size) return null
val type = data[offset].toInt() and 0xff
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
fields += Field(type, data.copyOfRange(offset, offset + length))
offset += length
}
return fields
}
fun epochData(epoch: Long): ByteArray {
require(epoch in 0..BitchatGroup.MAX_EPOCH)
return ByteBuffer.allocate(Int.SIZE_BYTES)
.order(ByteOrder.BIG_ENDIAN)
.putInt(epoch.toInt())
.array()
}
fun epoch(data: ByteArray): Long? {
if (data.size != Int.SIZE_BYTES) return null
return ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN).int.toLong() and 0xffff_ffffL
}
fun timestampData(timestampMs: Long): ByteArray =
ByteBuffer.allocate(Long.SIZE_BYTES)
.order(ByteOrder.BIG_ENDIAN)
.putLong(timestampMs)
.array()
fun timestamp(data: ByteArray): Long? {
if (data.size != Long.SIZE_BYTES) return null
return ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN).long
}
fun strictUtf8(data: ByteArray): String? = try {
Charsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(data))
.toString()
} catch (_: Exception) {
null
}
}
object GroupRosterCoding {
private const val FINGERPRINT_LENGTH = 32
private const val SIGNING_KEY_LENGTH = 32
private const val MAX_NICKNAME_BYTES = 64
fun encode(members: List<GroupMember>): ByteArray? {
if (members.size > BitchatGroup.MAX_MEMBERS) return null
val output = ByteArrayOutputStream()
output.write(members.size)
members.forEach { member ->
val fingerprint = member.fingerprint.dataFromHexString()
if (fingerprint?.size != FINGERPRINT_LENGTH ||
member.signingKey.size != SIGNING_KEY_LENGTH
) {
return null
}
output.write(fingerprint)
output.write(member.signingKey)
val nickname = truncatedNicknameBytes(member.nickname)
output.write(nickname.size)
output.write(nickname)
}
return output.toByteArray()
}
fun decode(data: ByteArray): List<GroupMember>? {
if (data.isEmpty()) return null
val count = data[0].toInt() and 0xff
if (count > BitchatGroup.MAX_MEMBERS) return null
val members = mutableListOf<GroupMember>()
var offset = 1
repeat(count) {
val fixedLength = FINGERPRINT_LENGTH + SIGNING_KEY_LENGTH + 1
if (offset + fixedLength > data.size) return null
val fingerprint =
data.copyOfRange(offset, offset + FINGERPRINT_LENGTH).hexEncodedString()
offset += FINGERPRINT_LENGTH
val signingKey = data.copyOfRange(offset, offset + SIGNING_KEY_LENGTH)
offset += SIGNING_KEY_LENGTH
val nicknameLength = data[offset].toInt() and 0xff
offset += 1
if (offset + nicknameLength > data.size) return null
val nickname = GroupTLV.strictUtf8(
data.copyOfRange(offset, offset + nicknameLength)
) ?: return null
offset += nicknameLength
members += GroupMember(fingerprint, signingKey, nickname)
}
if (offset != data.size) return null
return members
}
private fun truncatedNicknameBytes(nickname: String): ByteArray {
val output = StringBuilder()
var offset = 0
while (offset < nickname.length) {
val codePoint = nickname.codePointAt(offset)
val candidate = output.toString() + String(Character.toChars(codePoint))
if (candidate.toByteArray(Charsets.UTF_8).size > MAX_NICKNAME_BYTES) break
output.appendCodePoint(codePoint)
offset += Character.charCount(codePoint)
}
return output.toString().toByteArray(Charsets.UTF_8)
}
}
class GroupStatePayload(
val groupID: ByteArray,
val name: String,
val key: ByteArray,
val epoch: Long,
val members: List<GroupMember>,
val creatorFingerprint: String,
val signature: ByteArray
) {
fun encode(): ByteArray? {
val roster = GroupRosterCoding.encode(members) ?: return null
val creator = creatorFingerprint.dataFromHexString()
if (creator?.size != 32) return null
return try {
GroupTLV.encode(
FIELD_GROUP_ID to groupID,
FIELD_NAME to name.toByteArray(Charsets.UTF_8),
FIELD_KEY to key,
FIELD_EPOCH to GroupTLV.epochData(epoch),
FIELD_ROSTER to roster,
FIELD_CREATOR_FINGERPRINT to creator,
FIELD_SIGNATURE to signature
)
} catch (_: GroupTlvValueTooLongException) {
null
}
}
fun verifyCreatorSignature(): Boolean {
if (members.size > BitchatGroup.MAX_MEMBERS) return false
val creator = members.firstOrNull { it.fingerprint == creatorFingerprint } ?: return false
val roster = GroupRosterCoding.encode(members) ?: return false
return GroupCrypto.verify(
signature,
signingContent(groupID, epoch, key, roster, name),
creator.signingKey
)
}
fun asGroup(): BitchatGroup =
BitchatGroup(groupID, name, epoch, members, creatorFingerprint)
override fun equals(other: Any?): Boolean =
this === other ||
(other is GroupStatePayload &&
groupID.contentEquals(other.groupID) &&
name == other.name &&
key.contentEquals(other.key) &&
epoch == other.epoch &&
members == other.members &&
creatorFingerprint == other.creatorFingerprint &&
signature.contentEquals(other.signature))
override fun hashCode(): Int {
var result = Arrays.hashCode(groupID)
result = 31 * result + name.hashCode()
result = 31 * result + Arrays.hashCode(key)
result = 31 * result + epoch.hashCode()
result = 31 * result + members.hashCode()
result = 31 * result + creatorFingerprint.hashCode()
result = 31 * result + Arrays.hashCode(signature)
return result
}
companion object {
private const val FIELD_GROUP_ID = 0x01
private const val FIELD_NAME = 0x02
private const val FIELD_KEY = 0x03
private const val FIELD_EPOCH = 0x04
private const val FIELD_ROSTER = 0x05
private const val FIELD_CREATOR_FINGERPRINT = 0x06
private const val FIELD_SIGNATURE = 0x07
private val SIGNING_DOMAIN = "bitchat-group-v1".toByteArray(Charsets.UTF_8)
fun signingContent(
groupID: ByteArray,
epoch: Long,
key: ByteArray,
rosterBlob: ByteArray,
name: String
): ByteArray = concat(
SIGNING_DOMAIN,
groupID,
GroupTLV.epochData(epoch),
sha256(key),
sha256(rosterBlob),
sha256(name.toByteArray(Charsets.UTF_8))
)
fun makeSigned(
group: BitchatGroup,
key: ByteArray,
sign: (ByteArray) -> ByteArray?
): GroupStatePayload? {
val roster = GroupRosterCoding.encode(group.members) ?: return null
val signature = sign(
signingContent(group.groupID, group.epoch, key, roster, group.name)
) ?: return null
if (signature.size != 64) return null
return GroupStatePayload(
group.groupID,
group.name,
key,
group.epoch,
group.members,
group.creatorFingerprint,
signature
)
}
fun decode(data: ByteArray): GroupStatePayload? {
val fields = GroupTLV.parse(data) ?: return null
var groupID: ByteArray? = null
var name: String? = null
var key: ByteArray? = null
var epoch: Long? = null
var members: List<GroupMember>? = null
var creatorFingerprint: String? = null
var signature: ByteArray? = null
fields.forEach { field ->
when (field.type) {
FIELD_GROUP_ID ->
if (field.value.size == BitchatGroup.GROUP_ID_LENGTH) groupID = field.value
FIELD_NAME -> name = GroupTLV.strictUtf8(field.value)
FIELD_KEY ->
if (field.value.size == BitchatGroup.KEY_LENGTH) key = field.value
FIELD_EPOCH -> epoch = GroupTLV.epoch(field.value)
FIELD_ROSTER -> members = GroupRosterCoding.decode(field.value)
FIELD_CREATOR_FINGERPRINT ->
if (field.value.size == 32) creatorFingerprint = field.value.hexEncodedString()
FIELD_SIGNATURE -> if (field.value.size == 64) signature = field.value
}
}
val decodedMembers = members ?: return null
if (decodedMembers.isEmpty()) return null
return GroupStatePayload(
groupID ?: return null,
name ?: return null,
key ?: return null,
epoch ?: return null,
decodedMembers,
creatorFingerprint ?: return null,
signature ?: return null
)
}
}
}
class GroupMessageEnvelope(
val groupID: ByteArray,
val epoch: Long,
val nonce: ByteArray,
val ciphertext: ByteArray
) {
fun encode(): ByteArray = GroupTLV.encode(
FIELD_GROUP_ID to groupID,
FIELD_EPOCH to GroupTLV.epochData(epoch),
FIELD_NONCE to nonce,
FIELD_CIPHERTEXT to ciphertext
)
override fun equals(other: Any?): Boolean =
this === other ||
(other is GroupMessageEnvelope &&
groupID.contentEquals(other.groupID) &&
epoch == other.epoch &&
nonce.contentEquals(other.nonce) &&
ciphertext.contentEquals(other.ciphertext))
override fun hashCode(): Int {
var result = groupID.contentHashCode()
result = 31 * result + epoch.hashCode()
result = 31 * result + nonce.contentHashCode()
result = 31 * result + ciphertext.contentHashCode()
return result
}
companion object {
private const val FIELD_GROUP_ID = 0x01
private const val FIELD_EPOCH = 0x02
private const val FIELD_NONCE = 0x03
private const val FIELD_CIPHERTEXT = 0x04
fun decode(data: ByteArray): GroupMessageEnvelope? {
val fields = GroupTLV.parse(data) ?: return null
var groupID: ByteArray? = null
var epoch: Long? = null
var nonce: ByteArray? = null
var ciphertext: ByteArray? = null
fields.forEach { field ->
when (field.type) {
FIELD_GROUP_ID ->
if (field.value.size == BitchatGroup.GROUP_ID_LENGTH) groupID = field.value
FIELD_EPOCH -> epoch = GroupTLV.epoch(field.value)
FIELD_NONCE -> if (field.value.size == 12) nonce = field.value
FIELD_CIPHERTEXT -> if (field.value.isNotEmpty()) ciphertext = field.value
}
}
return GroupMessageEnvelope(
groupID ?: return null,
epoch ?: return null,
nonce ?: return null,
ciphertext ?: return null
)
}
}
}
data class GroupMessagePlaintext(
val messageID: String,
val senderSigningKey: ByteArray,
val senderNickname: String,
val timestampMs: Long,
val content: String
) {
override fun equals(other: Any?): Boolean =
this === other ||
(other is GroupMessagePlaintext &&
messageID == other.messageID &&
senderSigningKey.contentEquals(other.senderSigningKey) &&
senderNickname == other.senderNickname &&
timestampMs == other.timestampMs &&
content == other.content)
override fun hashCode(): Int {
var result = messageID.hashCode()
result = 31 * result + senderSigningKey.contentHashCode()
result = 31 * result + senderNickname.hashCode()
result = 31 * result + timestampMs.hashCode()
result = 31 * result + content.hashCode()
return result
}
}
sealed class GroupCryptoException(message: String) : Exception(message) {
class MalformedPayload : GroupCryptoException("malformed group payload")
class SigningFailed : GroupCryptoException("group message signing failed")
class SealFailed : GroupCryptoException("group message sealing failed")
class DecryptionFailed : GroupCryptoException("group message decryption failed")
class BadSenderSignature : GroupCryptoException("bad group sender signature")
}
object GroupCrypto {
private const val FIELD_MESSAGE_ID = 0x01
private const val FIELD_SENDER_SIGNING_KEY = 0x02
private const val FIELD_SENDER_NICKNAME = 0x03
private const val FIELD_TIMESTAMP = 0x04
private const val FIELD_CONTENT = 0x05
private const val FIELD_SIGNATURE = 0x06
private val MESSAGE_SIGNING_DOMAIN =
"bitchat-group-msg-v1".toByteArray(Charsets.UTF_8)
private val random = SecureRandom()
fun messageSigningContent(
groupID: ByteArray,
epoch: Long,
messageID: String,
timestampMs: Long,
content: String
): ByteArray = concat(
MESSAGE_SIGNING_DOMAIN,
groupID,
GroupTLV.epochData(epoch),
messageID.toByteArray(Charsets.UTF_8),
GroupTLV.timestampData(timestampMs),
content.toByteArray(Charsets.UTF_8)
)
fun verify(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean {
if (signature.size != 64 || publicKey.size != 32) return false
return try {
val verifier = Ed25519Signer()
verifier.init(false, Ed25519PublicKeyParameters(publicKey, 0))
verifier.update(data, 0, data.size)
verifier.verifySignature(signature)
} catch (_: Exception) {
false
}
}
@Throws(GroupCryptoException::class, GroupTlvValueTooLongException::class)
fun sealMessage(
content: String,
messageID: String,
senderNickname: String,
senderSigningKey: ByteArray,
timestampMs: Long,
groupID: ByteArray,
epoch: Long,
key: ByteArray,
sign: (ByteArray) -> ByteArray?
): ByteArray {
if (!isCanonicalMessageID(messageID)) {
throw GroupCryptoException.MalformedPayload()
}
val signature = sign(
messageSigningContent(groupID, epoch, messageID, timestampMs, content)
)
if (signature?.size != 64) throw GroupCryptoException.SigningFailed()
val inner = GroupTLV.encode(
FIELD_MESSAGE_ID to messageID.toByteArray(Charsets.UTF_8),
FIELD_SENDER_SIGNING_KEY to senderSigningKey,
FIELD_SENDER_NICKNAME to senderNickname.toByteArray(Charsets.UTF_8),
FIELD_TIMESTAMP to GroupTLV.timestampData(timestampMs),
FIELD_CONTENT to content.toByteArray(Charsets.UTF_8),
FIELD_SIGNATURE to signature
)
if (key.size != BitchatGroup.KEY_LENGTH ||
groupID.size != BitchatGroup.GROUP_ID_LENGTH
) {
throw GroupCryptoException.SealFailed()
}
return try {
val nonce = ByteArray(12).also(random::nextBytes)
val aad = concat(groupID, GroupTLV.epochData(epoch))
val ciphertext = crypt(encrypt = true, key, nonce, aad, inner)
GroupMessageEnvelope(groupID, epoch, nonce, ciphertext).encode()
} catch (error: GroupTlvValueTooLongException) {
throw error
} catch (_: Exception) {
throw GroupCryptoException.SealFailed()
}
}
@Throws(GroupCryptoException::class)
fun openMessage(envelope: GroupMessageEnvelope, key: ByteArray): GroupMessagePlaintext {
if (key.size != BitchatGroup.KEY_LENGTH || envelope.ciphertext.size <= 16) {
throw GroupCryptoException.DecryptionFailed()
}
val inner = try {
crypt(
encrypt = false,
key,
envelope.nonce,
concat(envelope.groupID, GroupTLV.epochData(envelope.epoch)),
envelope.ciphertext
)
} catch (_: Exception) {
throw GroupCryptoException.DecryptionFailed()
}
val fields = GroupTLV.parse(inner) ?: throw GroupCryptoException.MalformedPayload()
var messageID: String? = null
var senderSigningKey: ByteArray? = null
var senderNickname: String? = null
var timestampMs: Long? = null
var content: String? = null
var signature: ByteArray? = null
fields.forEach { field ->
when (field.type) {
FIELD_MESSAGE_ID -> messageID = GroupTLV.strictUtf8(field.value)
FIELD_SENDER_SIGNING_KEY ->
if (field.value.size == 32) senderSigningKey = field.value
FIELD_SENDER_NICKNAME -> senderNickname = GroupTLV.strictUtf8(field.value)
FIELD_TIMESTAMP -> timestampMs = GroupTLV.timestamp(field.value)
FIELD_CONTENT -> content = GroupTLV.strictUtf8(field.value)
FIELD_SIGNATURE -> if (field.value.size == 64) signature = field.value
}
}
val decodedMessageID = messageID?.takeIf(::isCanonicalMessageID)
?: throw GroupCryptoException.MalformedPayload()
val decodedSigningKey = senderSigningKey ?: throw GroupCryptoException.MalformedPayload()
val decodedNickname = senderNickname ?: throw GroupCryptoException.MalformedPayload()
val decodedTimestamp = timestampMs ?: throw GroupCryptoException.MalformedPayload()
val decodedContent = content ?: throw GroupCryptoException.MalformedPayload()
val decodedSignature = signature ?: throw GroupCryptoException.MalformedPayload()
val signingContent = messageSigningContent(
envelope.groupID,
envelope.epoch,
decodedMessageID,
decodedTimestamp,
decodedContent
)
if (!verify(decodedSignature, signingContent, decodedSigningKey)) {
throw GroupCryptoException.BadSenderSignature()
}
return GroupMessagePlaintext(
decodedMessageID,
decodedSigningKey,
decodedNickname,
decodedTimestamp,
decodedContent
)
}
private fun isCanonicalMessageID(messageID: String): Boolean {
val encoded = messageID.toByteArray(Charsets.UTF_8)
if (encoded.size != UUID_TEXT_LENGTH || !UUID_PATTERN.matches(messageID)) return false
return try {
UUID.fromString(messageID)
true
} catch (_: IllegalArgumentException) {
false
}
}
@Throws(InvalidCipherTextException::class)
private fun crypt(
encrypt: Boolean,
key: ByteArray,
nonce: ByteArray,
aad: ByteArray,
input: ByteArray
): ByteArray {
val cipher = ChaCha20Poly1305()
cipher.init(encrypt, AEADParameters(KeyParameter(key), 128, nonce, aad))
val output = ByteArray(cipher.getOutputSize(input.size))
var length = cipher.processBytes(input, 0, input.size, output, 0)
length += cipher.doFinal(output, length)
return output.copyOf(length)
}
private const val UUID_TEXT_LENGTH = 36
private val UUID_PATTERN = Regex(
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" +
"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
}
internal fun sha256(data: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(data)
internal fun concat(vararg arrays: ByteArray): ByteArray {
val output = ByteArrayOutputStream(arrays.sumOf(ByteArray::size))
arrays.forEach(output::write)
return output.toByteArray()
}

View File

@ -0,0 +1,96 @@
package com.bitchat.android.groups
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import com.bitchat.android.mesh.GroupMessagePort
import com.bitchat.android.mesh.GroupMessageReceiver
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.service.MeshServiceHolder
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.ui.DataManager
import java.lang.ref.WeakReference
import java.util.Date
import kotlinx.coroutines.*
interface GroupUiDelegate {
val nickname: String
fun markGroupUnread(groupPeerID: String)
fun openGroupConversation(groupPeerID: String)
fun closeGroupConversation()
}
/** Keeps group membership, decryption, and durable delivery alive when the Activity closes. */
class GroupRuntime private constructor(private val application: Context) : GroupMessageReceiver {
val store = GroupStore(application)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile private var ui = WeakReference<GroupUiDelegate>(null)
private val mesh get() = MeshServiceHolder.unifiedMeshService
private val notifications = com.bitchat.android.ui.NotificationManager(application, NotificationManagerCompat.from(application))
val coordinator = GroupCoordinator(object : GroupCoordinatorContext {
override val groupStore get() = store
override val nickname get() = ui.get()?.nickname ?: AppStateStore.nickname.value
override val myPeerID get() = mesh?.myPeerID.orEmpty()
override val selectedConversationID get() = AppStateStore.selectedPrivateChatPeer.value
override fun myNoiseFingerprint() = mesh?.getIdentityFingerprint().orEmpty()
override fun mySigningPublicKey() = mesh?.getSigningPublicKey()
override fun sign(data: ByteArray) = mesh?.signData(data)
override fun peerIDsForNickname(nickname: String) = mesh?.getPeerNicknames().orEmpty()
.filterValues { it.equals(nickname, ignoreCase = true) }.keys.toList()
override fun isPeerConnected(peerID: String) = mesh?.getPeerInfo(peerID)?.isConnected == true && mesh?.hasEstablishedSession(peerID) == true
override fun peerGroupCapability(peerID: String): PeerGroupCapability {
val peer = mesh?.getPeerInfo(peerID) ?: return PeerGroupCapability.UNKNOWN
return PeerGroupCapability.fromPeerState(peer.capabilities, peer.hasVerifiedAnnouncement)
}
override fun peerNickname(peerID: String) = mesh?.getPeerNicknames()?.get(peerID)
override fun peerIdentity(peerID: String): GroupPeerIdentity? {
val info = mesh?.getPeerInfo(peerID) ?: return null
val signing = info.signingPublicKey?.takeIf { it.size == 32 } ?: return null
val key = info.noisePublicKey ?: return null
val fingerprint = ContactIdentityResolver.fingerprintHex(key)
if (!fingerprint.equals(mesh?.getPeerFingerprint(peerID), ignoreCase = true)) return null
return GroupPeerIdentity(fingerprint, signing.copyOf())
}
override fun connectedPeerID(fingerprint: String) = mesh?.getPeerNicknames()?.keys?.firstOrNull {
isPeerConnected(it) && fingerprint.equals(mesh?.getPeerFingerprint(it), ignoreCase = true)
}
override fun isFingerprintBlocked(fingerprint: String) = DataManager.isFingerprintBlocked(application, fingerprint)
override fun sendGroupInvite(payload: ByteArray, peerID: String) { mesh?.sendGroupInvite(payload, peerID) }
override fun sendGroupKeyUpdate(payload: ByteArray, peerID: String) { mesh?.sendGroupKeyUpdate(payload, peerID) }
override fun broadcastGroupMessage(payload: ByteArray) { mesh?.broadcastGroupMessage(payload) }
override fun appendGroupMessage(groupPeerID: String, message: BitchatMessage): Boolean = runBlocking {
AppStateStore.addPrivateMessageDurably(groupPeerID, message, selectedConversationID == groupPeerID || message.senderPeerID == myPeerID)
}
override fun markGroupUnread(groupPeerID: String) { ui.get()?.markGroupUnread(groupPeerID) }
override fun removeGroupConversation(groupPeerID: String) { AppStateStore.deletePrivateConversation(groupPeerID) }
override fun openGroupConversation(groupPeerID: String) { scope.launch(Dispatchers.Main) { ui.get()?.openGroupConversation(groupPeerID) } }
override fun closeGroupConversation() { scope.launch(Dispatchers.Main) { ui.get()?.closeGroupConversation() } }
override fun addSystemMessage(message: String) { AppStateStore.addPublicMessage(BitchatMessage(sender = "system", content = message, timestamp = Date())) }
override fun addGroupSystemMessage(groupPeerID: String, message: String) {
appendGroupMessage(groupPeerID, BitchatMessage(sender = "system", content = message, timestamp = Date(), isPrivate = true))
}
override fun notifyGroupMessage(groupPeerID: String, sender: String, message: String) {
notifications.showPrivateMessageNotification(groupPeerID, sender, message)
}
})
init {
GroupMessagePort.receiver = this
scope.launch { if (store.initialize()) coordinator.onStoreReady() }
}
fun attach(context: GroupUiDelegate) { ui = WeakReference(context) }
fun detach(context: GroupUiDelegate) { if (ui.get() === context) ui.clear() }
override fun invite(peerID: String, authenticatedKey: ByteArray, payload: ByteArray) = coordinator.handleInvite(peerID, authenticatedKey, payload)
override fun keyUpdate(peerID: String, authenticatedKey: ByteArray, payload: ByteArray) = coordinator.handleKeyUpdate(peerID, authenticatedKey, payload)
override fun message(payload: ByteArray, timestampMs: Long) = coordinator.handleMessage(payload, timestampMs)
override fun peerAuthenticated(peerID: String) { scope.launch { coordinator.handlePeerAuthenticated(peerID) } }
companion object {
@Volatile private var instance: GroupRuntime? = null
fun getInstance(context: Context): GroupRuntime = instance ?: synchronized(this) {
instance ?: GroupRuntime(context.applicationContext).also { instance = it }
}
}
}

View File

@ -0,0 +1,552 @@
package com.bitchat.android.groups
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.bitchat.android.util.hexEncodedString
import com.google.gson.Gson
import com.google.gson.JsonParser
import com.google.gson.reflect.TypeToken
import java.io.File
import java.io.FileOutputStream
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.SecureRandom
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
internal interface GroupKeyStorage {
fun get(key: String): ByteArray?
fun put(key: String, value: ByteArray): Boolean
fun remove(key: String): Boolean
fun clear(): Boolean
}
internal interface GroupMetadataStorage {
fun read(): String?
fun write(contents: String): Boolean
fun delete(): Boolean
}
@SuppressLint("ApplySharedPref", "UseKtx")
private class EncryptedPreferencesGroupKeyStorage(context: Context) : GroupKeyStorage {
private val preferences: SharedPreferences
init {
val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
preferences = EncryptedSharedPreferences.create(
context,
"bitchat_private_groups",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
override fun get(key: String): ByteArray? = try {
preferences.getString(key, null)?.let {
Base64.decode(it, Base64.NO_WRAP)
}
} catch (_: Exception) {
null
}
override fun put(key: String, value: ByteArray): Boolean = try {
// The metadata must not advance unless the epoch key is durably stored.
preferences.edit()
.putString(key, Base64.encodeToString(value, Base64.NO_WRAP))
.commit()
} catch (_: Exception) {
false
}
override fun remove(key: String): Boolean = try {
// Removal is a security boundary, so report the synchronous result.
preferences.edit().remove(key).commit()
} catch (_: Exception) {
false
}
override fun clear(): Boolean = try {
preferences.edit().clear().commit() && preferences.all.isEmpty()
} catch (_: Exception) {
false
}
}
private class FileGroupMetadataStorage(private val file: File) : GroupMetadataStorage {
override fun read(): String? = try {
file.takeIf(File::exists)?.readText(Charsets.UTF_8)
} catch (_: Exception) {
null
}
override fun write(contents: String): Boolean {
var temporary: File? = null
return try {
val parent = file.parentFile
if (parent != null && !parent.exists() && !parent.mkdirs()) return false
temporary = File(parent, "${file.name}.tmp")
FileOutputStream(temporary).use { output ->
output.write(contents.toByteArray(Charsets.UTF_8))
output.fd.sync()
}
try {
Files.move(
temporary.toPath(),
file.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(
temporary.toPath(),
file.toPath(),
StandardCopyOption.REPLACE_EXISTING
)
}
true
} catch (_: Exception) {
temporary?.delete()
false
}
}
override fun delete(): Boolean = try {
val deleted = !file.exists() || file.delete()
if (deleted) {
file.parentFile
?.takeIf { it.listFiles().isNullOrEmpty() }
?.delete()
}
deleted
} catch (_: Exception) {
false
}
}
/**
* Persistent private-group metadata and epoch keys.
*
* Metadata is kept in app-private no-backup storage. Symmetric group keys are
* stored separately in EncryptedSharedPreferences backed by Android Keystore.
*
* The Android constructor is intentionally inert. [initialize] performs
* Keystore and disk access and must be called from a background dispatcher.
*/
class GroupStore private constructor(
private val keyStorageFactory: () -> GroupKeyStorage,
private val metadataStorageFactory: () -> GroupMetadataStorage?,
autoInitialize: Boolean
) {
private data class StoredMember(
val fingerprint: String,
val signingKey: String,
val nickname: String
)
private data class StoredGroup(
val groupID: String,
val name: String,
val epoch: Long,
val members: List<StoredMember>,
val creatorFingerprint: String
)
private data class StoredState(
val version: Int = 1,
val groups: List<StoredGroup>,
val departures: Map<String, Long>
)
private val lock = Any()
private val gson = Gson()
private val random by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { SecureRandom() }
private val _groups = MutableStateFlow<List<BitchatGroup>>(emptyList())
private val departures = mutableMapOf<String, Long>()
private var keyStorage: GroupKeyStorage? = null
private var metadataStorage: GroupMetadataStorage? = null
@Volatile
private var initialized = false
val groups: StateFlow<List<BitchatGroup>> = _groups.asStateFlow()
val isReady: Boolean
get() = initialized
constructor(context: Context) : this(
keyStorageFactory = {
EncryptedPreferencesGroupKeyStorage(context.applicationContext)
},
metadataStorageFactory = {
FileGroupMetadataStorage(
File(context.applicationContext.noBackupFilesDir, "groups/groups.json")
)
},
autoInitialize = false
)
internal constructor(
keyStorage: GroupKeyStorage,
metadataFile: File? = null,
testOnly: Boolean,
autoInitialize: Boolean = true
) : this(
keyStorageFactory = { keyStorage },
metadataStorageFactory = {
metadataFile?.let(::FileGroupMetadataStorage)
},
autoInitialize = autoInitialize
) {
require(testOnly)
}
internal constructor(
keyStorage: GroupKeyStorage,
metadataStorage: GroupMetadataStorage,
testOnly: Boolean,
autoInitialize: Boolean = true
) : this(
keyStorageFactory = { keyStorage },
metadataStorageFactory = { metadataStorage },
autoInitialize = autoInitialize
) {
require(testOnly)
}
init {
if (autoInitialize) initialize()
}
/**
* Initializes encrypted key storage and loads metadata. Callers using the
* Android constructor must invoke this away from the main thread.
*/
fun initialize(): Boolean = synchronized(lock) {
if (initialized) return@synchronized true
val keys = try {
keyStorageFactory()
} catch (error: Exception) {
Log.e(TAG, "Failed to initialize private-group key storage", error)
return@synchronized false
}
val metadata = try {
metadataStorageFactory()
} catch (error: Exception) {
Log.e(TAG, "Failed to initialize private-group metadata storage", error)
return@synchronized false
}
keyStorage = keys
metadataStorage = metadata
loadLocked(keys, metadata)
initialized = true
true
}
fun group(groupID: ByteArray): BitchatGroup? = synchronized(lock) {
_groups.value.firstOrNull { it.groupID.contentEquals(groupID) }?.deepCopy()
}
fun group(peerID: String): BitchatGroup? =
GroupIds.groupID(peerID)?.let(::group)
fun key(groupID: ByteArray): ByteArray? = synchronized(lock) {
keyStorage
?.get(keyName(groupID))
?.takeIf { it.size == BitchatGroup.KEY_LENGTH }
?.copyOf()
}
fun departureEpoch(groupID: ByteArray): Long? = synchronized(lock) {
departures[groupID.hexEncodedString()]
}
fun createGroup(name: String, creator: GroupMember): BitchatGroup? {
if (!isReady) return null
val groupID = ByteArray(BitchatGroup.GROUP_ID_LENGTH).also(random::nextBytes)
val key = ByteArray(BitchatGroup.KEY_LENGTH).also(random::nextBytes)
val group = BitchatGroup(
groupID = groupID,
name = name,
epoch = 1,
members = listOf(creator),
creatorFingerprint = creator.fingerprint
)
return group.takeIf { upsert(it, key) }
}
fun upsert(group: BitchatGroup, key: ByteArray): Boolean = synchronized(lock) {
upsertLocked(group, key, clearDeparture = false)
}
fun acceptInvite(group: BitchatGroup, key: ByteArray): Boolean = synchronized(lock) {
upsertLocked(group, key, clearDeparture = true)
}
fun rotateKey(
groupID: ByteArray,
members: List<GroupMember>
): Pair<BitchatGroup, ByteArray>? = synchronized(lock) {
if (!initialized) return@synchronized null
val existing = _groups.value.firstOrNull { it.groupID.contentEquals(groupID) }
?: return@synchronized null
val newKey = ByteArray(BitchatGroup.KEY_LENGTH).also(random::nextBytes)
val rotated = existing.copy(
epoch = (existing.epoch + 1) and BitchatGroup.MAX_EPOCH,
members = members.map { it.deepCopy() }
)
if (!upsertLocked(rotated, newKey, clearDeparture = false)) {
return@synchronized null
}
rotated.deepCopy() to newKey.copyOf()
}
fun removeGroup(groupID: ByteArray): Boolean = synchronized(lock) {
removeLocked(groupID, departureEpoch = null)
}
fun removeGroupForState(groupID: ByteArray, stateEpoch: Long): BitchatGroup? =
synchronized(lock) {
if (stateEpoch !in 0..BitchatGroup.MAX_EPOCH) return@synchronized null
val existing = _groups.value.firstOrNull { it.groupID.contentEquals(groupID) }
?: return@synchronized null
if (stateEpoch < existing.epoch) return@synchronized null
if (!removeLocked(groupID, departureEpoch = null)) return@synchronized null
existing.deepCopy()
}
fun departGroup(groupID: ByteArray, epoch: Long): Boolean = synchronized(lock) {
if (epoch !in 0..BitchatGroup.MAX_EPOCH) return@synchronized false
removeLocked(groupID, departureEpoch = epoch)
}
fun wipe(): Boolean = synchronized(lock) {
if (!initialized && !initialize()) return@synchronized false
val keys = keyStorage ?: return@synchronized false
val keysCleared = keys.clear()
val metadataDeleted = metadataStorage?.delete() ?: true
_groups.value = emptyList()
departures.clear()
keysCleared && metadataDeleted
}
private fun upsertLocked(
group: BitchatGroup,
key: ByteArray,
clearDeparture: Boolean
): Boolean {
val keys = keyStorage ?: return false
if (!initialized || !isValid(group, key)) return false
val updatedGroups = _groups.value.toMutableList()
val index = updatedGroups.indexOfFirst { it.groupID.contentEquals(group.groupID) }
if (index >= 0 && group.epoch < updatedGroups[index].epoch) return false
if (index >= 0) {
updatedGroups[index] = group.deepCopy()
} else {
updatedGroups += group.deepCopy()
}
val updatedDepartures = departures.toMutableMap()
val groupID = group.groupID.hexEncodedString()
val departureEpoch = updatedDepartures[groupID]
if (clearDeparture) {
if (departureEpoch != null && group.epoch <= departureEpoch) return false
updatedDepartures.remove(groupID)
} else if (departureEpoch != null) {
return false
}
val name = keyName(group.groupID)
val previousKey = keys.get(name)?.copyOf()
if (!keys.put(name, key.copyOf())) {
Log.e(TAG, "Failed to store private-group epoch key")
return false
}
if (!persistLocked(updatedGroups, updatedDepartures)) {
restoreKey(keys, name, previousKey)
return false
}
departures.clear()
departures.putAll(updatedDepartures)
_groups.value = updatedGroups.map { it.deepCopy() }
return true
}
private fun removeLocked(groupID: ByteArray, departureEpoch: Long?): Boolean {
val keys = keyStorage ?: return false
if (!initialized) return false
val updatedGroups = _groups.value.filterNot { it.groupID.contentEquals(groupID) }
val updatedDepartures = departures.toMutableMap()
if (departureEpoch != null) {
val id = groupID.hexEncodedString()
updatedDepartures[id] = maxOf(updatedDepartures[id] ?: -1, departureEpoch)
}
val name = keyName(groupID)
val previousKey = keys.get(name)?.copyOf()
if (previousKey != null && !keys.remove(name)) {
Log.e(TAG, "Failed to remove private-group epoch key")
return false
}
if (!persistLocked(updatedGroups, updatedDepartures)) {
restoreKey(keys, name, previousKey)
return false
}
departures.clear()
departures.putAll(updatedDepartures)
_groups.value = updatedGroups.map { it.deepCopy() }
return true
}
private fun restoreKey(
storage: GroupKeyStorage,
name: String,
previousKey: ByteArray?
) {
val restored = if (previousKey == null) {
storage.remove(name)
} else {
storage.put(name, previousKey)
}
if (!restored && previousKey != null) {
Log.e(TAG, "Failed to restore private-group epoch key after metadata failure")
}
}
private fun isValid(group: BitchatGroup, key: ByteArray): Boolean =
group.groupID.size == BitchatGroup.GROUP_ID_LENGTH &&
key.size == BitchatGroup.KEY_LENGTH &&
group.epoch in 0..BitchatGroup.MAX_EPOCH &&
group.members.isNotEmpty() &&
group.members.size <= BitchatGroup.MAX_MEMBERS &&
group.creator != null &&
group.members.all {
it.fingerprint.matches(Regex("^[0-9a-fA-F]{64}$")) &&
it.signingKey.size == 32
}
private fun persistLocked(
groups: List<BitchatGroup>,
departures: Map<String, Long>
): Boolean {
val storage = metadataStorage ?: return true
if (groups.isEmpty() && departures.isEmpty()) return storage.delete()
val state = StoredState(
groups = groups.map { group ->
StoredGroup(
groupID = Base64.encodeToString(group.groupID, Base64.NO_WRAP),
name = group.name,
epoch = group.epoch,
members = group.members.map { member ->
StoredMember(
fingerprint = member.fingerprint,
signingKey = Base64.encodeToString(
member.signingKey,
Base64.NO_WRAP
),
nickname = member.nickname
)
},
creatorFingerprint = group.creatorFingerprint
)
},
departures = departures.toSortedMap()
)
val persisted = storage.write(gson.toJson(state))
if (!persisted) Log.e(TAG, "Failed to persist private-group metadata")
return persisted
}
private fun loadLocked(
keys: GroupKeyStorage,
metadata: GroupMetadataStorage?
) {
val raw = metadata?.read() ?: return
val parsed = try {
val json = JsonParser.parseString(raw)
if (json.isJsonArray) {
val type = object : TypeToken<List<StoredGroup>>() {}.type
StoredState(groups = gson.fromJson(json, type), departures = emptyMap())
} else {
val objectValue = json.asJsonObject
val groupType = object : TypeToken<List<StoredGroup>>() {}.type
val departureType = object : TypeToken<Map<String, Long>>() {}.type
StoredState(
version = objectValue.get("version")?.asInt ?: 1,
groups = objectValue.get("groups")?.let {
gson.fromJson(it, groupType)
} ?: emptyList(),
departures = objectValue.get("departures")?.let {
gson.fromJson(it, departureType)
} ?: emptyMap()
)
}
} catch (_: Exception) {
null
} ?: return
departures.clear()
parsed.departures.forEach { (groupID, epoch) ->
if (GROUP_ID_HEX.matches(groupID) && epoch in 0..BitchatGroup.MAX_EPOCH) {
departures[groupID.lowercase()] = epoch
}
}
_groups.value = parsed.groups.mapNotNull { item ->
try {
val group = BitchatGroup(
groupID = Base64.decode(item.groupID, Base64.NO_WRAP),
name = item.name,
epoch = item.epoch,
members = item.members.map { member ->
GroupMember(
member.fingerprint,
Base64.decode(member.signingKey, Base64.NO_WRAP),
member.nickname
)
},
creatorFingerprint = item.creatorFingerprint
)
if (departures.containsKey(group.groupID.hexEncodedString())) {
keys.remove(keyName(group.groupID))
return@mapNotNull null
}
val storedKey = keys.get(keyName(group.groupID))
?.takeIf { it.size == BitchatGroup.KEY_LENGTH }
?: return@mapNotNull null
group.takeIf { isValid(it, storedKey) }?.deepCopy()
} catch (_: Exception) {
null
}
}
}
private fun keyName(groupID: ByteArray): String =
"groupKey-${groupID.hexEncodedString()}"
private fun BitchatGroup.deepCopy(): BitchatGroup = copy(
groupID = groupID.copyOf(),
members = members.map { it.deepCopy() }
)
private fun GroupMember.deepCopy(): GroupMember =
copy(signingKey = signingKey.copyOf())
companion object {
private const val TAG = "GroupStore"
private val GROUP_ID_HEX = Regex("^[0-9a-fA-F]{32}$")
}
}

View File

@ -10,8 +10,11 @@ import android.util.Base64
import android.util.Log
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.model.VouchAttestation
import com.bitchat.android.util.hexEncodedString
import androidx.core.content.edit
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* Manages persistent identity storage and peer ID rotation - 100% compatible with iOS implementation
@ -37,22 +40,38 @@ class SecureIdentityStateManager {
private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames"
private const val KEY_PRIVATE_MEDIA_CAPABILITY_PINS = "private_media_capability_pins_v1"
private const val KEY_AUTHENTICATED_PEER_STATES = "authenticated_peer_states_v1"
private const val KEY_VOUCH_RECORDS = "vouch_records_v1"
private const val KEY_VOUCH_BATCH_SENT_AT = "vouch_batch_sent_at_v1"
private const val KEY_VERIFIED_AT = "verified_at_v1"
const val MAX_VOUCHERS_PER_VOUCHEE = 8
private const val VOUCH_RECORD_FIELD_COUNT = 4
private const val RECORD_SEPARATOR = ':'
private const val RECORD_SEPARATOR_LENGTH = 1
private const val VOUCHEE_FIELD_INDEX = 0
private const val VOUCHER_FIELD_INDEX = 1
private const val VOUCHEE_SIGNING_KEY_FIELD_INDEX = 2
private const val INDEX_NOT_FOUND = -1
private const val IDENTITY_EVENT_BUFFER_CAPACITY = 1
private const val EXPIRY_TRANSITION_OFFSET_MS = 1L
// BLE, Wi-Fi Aware, and Noise services each hold their own manager
// instance over the same encrypted preferences. Serialize pin updates
// process-wide so concurrent promotions cannot lose one another or
// race a panic wipe.
private val privateMediaPinsLock = Any()
private var privateMediaPinsEpoch = 0L
private val identityPersistenceLock = Any()
private var identityPersistenceEpoch = 0L
private val identityChanges =
MutableSharedFlow<Unit>(extraBufferCapacity = IDENTITY_EVENT_BUFFER_CAPACITY)
val changes = identityChanges.asSharedFlow()
}
private val prefs: SharedPreferences
private val lock = Any()
private var privateMediaPinsEpochAtCreation: Long
private var identityPersistenceEpochAtCreation: Long
constructor(context: Context) {
privateMediaPinsEpochAtCreation = synchronized(privateMediaPinsLock) {
privateMediaPinsEpoch
identityPersistenceEpochAtCreation = synchronized(identityPersistenceLock) {
identityPersistenceEpoch
}
// Create master key for encryption
val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
@ -72,8 +91,8 @@ class SecureIdentityStateManager {
/** Test-only storage injection; production always uses encrypted prefs. */
internal constructor(prefs: SharedPreferences, testOnly: Boolean) {
require(testOnly) { "Plain SharedPreferences are test-only" }
privateMediaPinsEpochAtCreation = synchronized(privateMediaPinsLock) {
privateMediaPinsEpoch
identityPersistenceEpochAtCreation = synchronized(identityPersistenceLock) {
identityPersistenceEpoch
}
this.prefs = prefs
}
@ -223,19 +242,197 @@ class SecureIdentityStateManager {
return getVerifiedFingerprints().contains(fingerprint)
}
@SuppressLint("UseKtx")
fun setVerifiedFingerprint(fingerprint: String, verified: Boolean) {
if (!isValidFingerprint(fingerprint)) return
synchronized(lock) {
val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())?.toMutableSet() ?: mutableSetOf()
val normalizedFingerprint = fingerprint.lowercase()
synchronized(identityPersistenceLock) {
if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return
val current = prefs.getStringSet(KEY_VERIFIED_FINGERPRINTS, emptySet())
?.mapTo(mutableSetOf()) { it.lowercase() } ?: mutableSetOf()
if (verified) {
current.add(fingerprint)
current.add(normalizedFingerprint)
} else {
current.remove(fingerprint)
current.remove(normalizedFingerprint)
}
prefs.edit { putStringSet(KEY_VERIFIED_FINGERPRINTS, current) }
val verifiedAt = readTimestampMap(KEY_VERIFIED_AT).toMutableMap()
if (verified) verifiedAt[normalizedFingerprint] = System.currentTimeMillis()
else verifiedAt.remove(normalizedFingerprint)
val committed = prefs.edit()
.putStringSet(KEY_VERIFIED_FINGERPRINTS, current)
.putStringSet(KEY_VERIFIED_AT, encodeTimestampMap(verifiedAt))
.commit()
if (!committed) return
identityChanges.tryEmit(Unit)
}
}
data class VouchRecord(
val voucherFingerprint: String,
val voucheeSigningKeyHex: String,
val timestampMs: Long
)
@SuppressLint("UseKtx")
fun recordVouch(
voucheeFingerprint: String,
voucherFingerprint: String,
voucheeSigningKey: ByteArray,
timestampMs: Long,
nowMs: Long = System.currentTimeMillis()
): Boolean {
val vouchee = voucheeFingerprint.lowercase()
val voucher = voucherFingerprint.lowercase()
if (!isValidFingerprint(vouchee) || !isValidFingerprint(voucher) ||
voucheeSigningKey.size != VouchAttestation.SIGNING_KEY_SIZE
) return false
synchronized(identityPersistenceLock) {
if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return false
val verified = getVerifiedFingerprints().mapTo(mutableSetOf()) { it.lowercase() }
val age = nowMs - timestampMs
if (vouchee == voucher || voucher !in verified || vouchee in verified ||
age > VouchAttestation.MAX_AGE_MS ||
age < -VouchAttestation.MAX_CLOCK_SKEW_MS
) return false
val all = readVouchRecords().toMutableMap()
val records = all[vouchee].orEmpty().toMutableList()
val existing = records.indexOfFirst { it.voucherFingerprint == voucher }
val proposed = VouchRecord(
voucherFingerprint = voucher,
voucheeSigningKeyHex = voucheeSigningKey.hexEncodedString(),
timestampMs = timestampMs
)
val record = records.getOrNull(existing)
?.takeIf { it.timestampMs >= timestampMs }
?: proposed
if (existing > INDEX_NOT_FOUND) records[existing] = record else records += record
val capped = records.sortedByDescending { it.timestampMs }.take(MAX_VOUCHERS_PER_VOUCHEE)
if (capped.none { it.voucherFingerprint == voucher }) return false
all[vouchee] = capped
val committed = prefs.edit()
.putStringSet(KEY_VOUCH_RECORDS, encodeVouchRecords(all))
.commit()
if (!committed) return false
identityChanges.tryEmit(Unit)
return true
}
}
fun validVouchers(
fingerprint: String,
nowMs: Long = System.currentTimeMillis()
): List<VouchRecord> {
val normalized = fingerprint.lowercase()
if (!isValidFingerprint(normalized)) return emptyList()
synchronized(identityPersistenceLock) {
if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return emptyList()
val verified = getVerifiedFingerprints().mapTo(mutableSetOf()) { it.lowercase() }
val authenticatedSigningKeyHex = getAuthenticatedSigningKey(normalized)
?.hexEncodedString() ?: return emptyList()
return readVouchRecords()[normalized].orEmpty().filter {
it.voucherFingerprint != normalized &&
it.voucherFingerprint in verified &&
it.voucheeSigningKeyHex == authenticatedSigningKeyHex &&
nowMs - it.timestampMs <= VouchAttestation.MAX_AGE_MS &&
nowMs - it.timestampMs >= -VouchAttestation.MAX_CLOCK_SKEW_MS
}
}
}
fun isVouched(fingerprint: String, nowMs: Long = System.currentTimeMillis()): Boolean {
val normalized = fingerprint.lowercase()
val isExplicitlyVerified = getVerifiedFingerprints().any {
it.equals(normalized, ignoreCase = true)
}
return !isExplicitlyVerified && validVouchers(normalized, nowMs).isNotEmpty()
}
fun getVouchedFingerprints(nowMs: Long = System.currentTimeMillis()): Set<String> =
synchronized(identityPersistenceLock) {
readVouchRecords().keys.filterTo(mutableSetOf()) { isVouched(it, nowMs) }
}
fun nextVouchExpiryMs(nowMs: Long = System.currentTimeMillis()): Long? =
synchronized(identityPersistenceLock) {
readVouchRecords().keys
.flatMap { validVouchers(it, nowMs) }
.minOfOrNull {
it.timestampMs +
VouchAttestation.MAX_AGE_MS +
EXPIRY_TRANSITION_OFFSET_MS
}
}
fun mostRecentlyVerifiedFingerprints(limit: Int, excluding: String): List<String> {
return synchronized(identityPersistenceLock) {
val verifiedAt = readTimestampMap(KEY_VERIFIED_AT)
getVerifiedFingerprints()
.map { it.lowercase() }
.filterNot { it == excluding.lowercase() }
.sortedWith(compareByDescending<String> { verifiedAt[it] ?: Long.MIN_VALUE }.thenByDescending { it })
.take(limit)
}
}
fun lastVouchBatchSent(fingerprint: String): Long? =
synchronized(identityPersistenceLock) {
readTimestampMap(KEY_VOUCH_BATCH_SENT_AT)[fingerprint.lowercase()]
}
@SuppressLint("UseKtx")
fun markVouchBatchSent(fingerprint: String, timestampMs: Long) {
synchronized(identityPersistenceLock) {
if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return
val sent = readTimestampMap(KEY_VOUCH_BATCH_SENT_AT).toMutableMap()
sent[fingerprint.lowercase()] = timestampMs
if (!prefs.edit()
.putStringSet(KEY_VOUCH_BATCH_SENT_AT, encodeTimestampMap(sent))
.commit()
) {
Log.e(TAG, "Vouch batch timestamp could not be committed")
}
}
}
private fun readTimestampMap(key: String): Map<String, Long> =
prefs.getStringSet(key, emptySet()).orEmpty().mapNotNull { entry ->
val split = entry.lastIndexOf(RECORD_SEPARATOR)
if (split <= VOUCHEE_FIELD_INDEX) {
null
} else {
entry.substring(split + RECORD_SEPARATOR_LENGTH).toLongOrNull()?.let {
entry.substring(VOUCHEE_FIELD_INDEX, split) to it
}
}
}.toMap()
private fun encodeTimestampMap(values: Map<String, Long>): Set<String> =
values.mapTo(mutableSetOf()) { (fingerprint, timestamp) ->
"$fingerprint$RECORD_SEPARATOR$timestamp"
}
private fun readVouchRecords(): Map<String, List<VouchRecord>> =
prefs.getStringSet(KEY_VOUCH_RECORDS, emptySet()).orEmpty().mapNotNull { entry ->
val fields = entry.split(RECORD_SEPARATOR)
if (fields.size != VOUCH_RECORD_FIELD_COUNT) null else fields.last().toLongOrNull()?.let {
fields[VOUCHEE_FIELD_INDEX] to VouchRecord(
voucherFingerprint = fields[VOUCHER_FIELD_INDEX],
voucheeSigningKeyHex = fields[VOUCHEE_SIGNING_KEY_FIELD_INDEX],
timestampMs = it
)
}
}.groupBy({ it.first }, { it.second })
private fun encodeVouchRecords(values: Map<String, List<VouchRecord>>): Set<String> =
values.flatMapTo(mutableSetOf()) { (vouchee, records) ->
records.map {
"$vouchee$RECORD_SEPARATOR${it.voucherFingerprint}" +
"$RECORD_SEPARATOR${it.voucheeSigningKeyHex}" +
"$RECORD_SEPARATOR${it.timestampMs}"
}
}
fun getCachedPeerFingerprint(peerID: String): String? {
val pid = peerID.lowercase()
// Reading is safe without lock for SharedPreferences, but synchronizing ensures memory visibility
@ -323,8 +520,8 @@ class SecureIdentityStateManager {
fun isPrivateMediaCapable(fingerprint: String): Boolean {
if (!isValidFingerprint(fingerprint)) return false
return synchronized(privateMediaPinsLock) {
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) {
return synchronized(identityPersistenceLock) {
if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) {
return@synchronized false
}
prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet())
@ -339,11 +536,13 @@ class SecureIdentityStateManager {
state: AuthenticatedPeerState,
onCommitted: () -> Unit = {}
): Boolean {
if (!isValidFingerprint(fingerprint) || state.signingPublicKey.size != 32) return false
if (!isValidFingerprint(fingerprint) ||
state.signingPublicKey.size != VouchAttestation.SIGNING_KEY_SIZE
) return false
val normalizedFingerprint = fingerprint.lowercase()
return synchronized(privateMediaPinsLock) {
return synchronized(identityPersistenceLock) {
// A controller that survived panic must not republish pre-wipe proof state.
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized false
if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return@synchronized false
val records = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet())
?.toMutableSet() ?: mutableSetOf()
records.removeAll { it.startsWith("$normalizedFingerprint:") }
@ -362,15 +561,18 @@ class SecureIdentityStateManager {
// This result is a security boundary: do not publish the Ed key in memory unless the
// encrypted identity record and its HSTS pin were durably committed together.
editor.commit().also { committed ->
if (committed) onCommitted()
if (committed) {
identityChanges.tryEmit(Unit)
onCommitted()
}
}
}
}
fun getAuthenticatedPeerState(fingerprint: String): AuthenticatedPeerState? {
if (!isValidFingerprint(fingerprint)) return null
return synchronized(privateMediaPinsLock) {
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized null
return synchronized(identityPersistenceLock) {
if (identityPersistenceEpochAtCreation != identityPersistenceEpoch) return@synchronized null
val prefix = "${fingerprint.lowercase()}:"
val record = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet())
?.firstOrNull { it.startsWith(prefix) } ?: return@synchronized null
@ -466,21 +668,18 @@ class SecureIdentityStateManager {
* Clear all identity data (for panic mode)
*/
@SuppressLint("UseKtx")
fun clearIdentityData() {
try {
synchronized(privateMediaPinsLock) {
privateMediaPinsEpoch += 1
privateMediaPinsEpochAtCreation = privateMediaPinsEpoch
if (!prefs.edit().clear().commit()) {
Log.e(TAG, "Identity preference wipe could not be committed")
}
}
Log.w(TAG, "All identity data cleared")
} catch (e: Exception) {
Log.e(TAG, "Failed to clear identity data: ${e.message}")
fun clearIdentityData(): Boolean = try {
synchronized(identityPersistenceLock) {
identityPersistenceEpoch += 1
identityPersistenceEpochAtCreation = identityPersistenceEpoch
check(prefs.edit().clear().commit()) { "Identity preference wipe could not be committed" }
identityChanges.tryEmit(Unit)
}
true
} catch (_: Exception) {
false
}
/**
* Check if identity data exists
*/
@ -493,6 +692,10 @@ class SecureIdentityStateManager {
/**
* Store a string value in secure preferences
*/
/** Persist keys and consumption state before making their use observable. */
fun storeSecureValueSynchronously(key: String, value: String): Boolean =
prefs.edit().putString(key, value).commit()
fun storeSecureValue(key: String, value: String) {
prefs.edit().putString(key, value).apply()
}

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

@ -2,13 +2,13 @@ package com.bitchat.android.mesh
import android.content.Context
import android.util.Log
import com.bitchat.android.board.transportSenderID
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.protocol.MessagePadding
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.protocol.BitchatPacket
@ -19,6 +19,8 @@ import com.bitchat.android.sync.GossipSyncManager
import com.bitchat.android.util.toHexString
import com.bitchat.android.services.VerificationService
import com.bitchat.android.service.TransportBridgeService
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.services.bridge.BridgeProtocolPacketFactory
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import java.util.*
@ -49,10 +51,17 @@ 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)
private val peerManager = PeerManager()
private val identityState = SecureIdentityStateManager(context.applicationContext)
private val fragmentManager = FragmentManager()
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val readReceiptRetrySender = RetryingControlPacketSender(serviceScope)
@ -65,13 +74,14 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
store = authenticatedPeerStateStore,
localStateProvider = {
AuthenticatedPeerState(
PeerCapabilities.LOCAL_SUPPORTED,
PeerCapabilities.localSupported(),
requireNotNull(encryptionService.getSigningPublicKey())
)
},
applyAuthenticatedState = peerManager::applyAuthenticatedPeerState,
sendState = ::sendAuthenticatedPeerState,
onResolution = { peerID -> delegate?.didResolvePrivateMediaPolicy(peerID) }
onResolution = { peerID -> GroupMessagePort.receiver?.peerAuthenticated(peerID)
delegate?.didResolvePrivateMediaPolicy(peerID) }
)
}
private val privateMediaSecurity by lazy { PrivateMediaSecurityController(
@ -109,11 +119,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
private val securityManager = SecurityManager(encryptionService, myPeerID)
private val storeForwardManager = StoreForwardManager()
private val boardStore = com.bitchat.android.board.BoardStore.getInstance(context)
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
private val packetProcessor = PacketProcessor(myPeerID)
private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray)
private val voiceFrameQueue = Channel<VoiceFrameRequest>(capacity = 128)
private val meshPingManager = MeshPingManager(myPeerID, serviceScope) { packet ->
broadcastRoutedPacket(RoutedPacket(packet))
}
private lateinit var gossipSyncManager: GossipSyncManager
// Service-level notification manager for background (no-UI) DMs
private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
@ -130,6 +144,20 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Coroutines
// Tracks whether this instance has been terminated via stopServices()
private var terminated = false
private val vouchCoordinator by lazy {
VouchCoordinator(
scope = serviceScope,
identity = identityState,
connectedPeerIDs = peerManager::getActivePeerIDs,
fingerprintForPeer = peerManager::getFingerprintForPeer,
peerInfo = peerManager::getPeerInfo,
signingKeyForFingerprint = ::signingKeyForFingerprint,
hasEstablishedSession = encryptionService::hasEstablishedSession,
sign = encryptionService::signData,
verify = encryptionService::verifyEd25519Signature,
send = ::sendVouchPayload
)
}
init {
serviceScope.launch {
@ -156,10 +184,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)
@ -170,6 +199,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
} catch (_: Exception) { 0.01 }
}
)
gossipSyncManager.boardPacketsProvider = boardStore::syncCandidates
com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) { packet ->
signPacketBeforeBroadcast(packet)
@ -177,6 +207,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
if (isBleTransportEnabled()) {
TransportBridgeService.register("BLE", this)
}
serviceScope.launch {
com.bitchat.android.services.bridge.MeshBridgeService.isEnabled.collect { enabled ->
if (enabled) bridgeCourierService.start() else bridgeCourierService.stop()
}
}
// Inject dynamic direct connection check into PeerManager
// Matches iOS logic: checks if we have an active hardware mapping for this peer
@ -200,6 +235,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)
@ -259,6 +299,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun onPeerListUpdated(peerIDs: List<String>) {
// Update process-wide state first
try { com.bitchat.android.services.AppStateStore.setTransportPeers("BLE", peerIDs) } catch (_: Exception) { }
vouchCoordinator.peersUpdated(peerIDs)
// Then notify UI delegate if attached
delegate?.didUpdatePeerList(peerIDs)
}
@ -291,6 +332,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
authenticatedRemoteStaticKey,
authenticatedSessionToken
)
vouchCoordinator.peerAuthenticated(
peerID,
identityState.generateFingerprint(authenticatedRemoteStaticKey)
)
// Send announcement and cached messages after key exchange
serviceScope.launch {
delay(100)
@ -396,7 +441,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun getBroadcastRecipient(): ByteArray {
return SpecialRecipients.BROADCAST
}
// Cryptographic operations
override fun verifySignature(packet: BitchatPacket, peerID: String): Boolean {
return securityManager.verifySignature(packet, peerID)
@ -514,6 +559,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
override fun onDeliveryAckReceived(messageID: String, peerID: String) {
com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.acknowledge(messageID, peerID)
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 +573,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
override fun onReadReceiptReceived(messageID: String, peerID: String) {
com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.acknowledge(messageID, peerID)
try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageID, peerID) } catch (_: Exception) { }
try {
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
messageID,
@ -542,6 +591,30 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
}
override fun onGroupInviteReceived(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
GroupMessagePort.receiver?.invite(peerID, authenticatedRemoteStaticKey, payload)
}
override fun onGroupKeyUpdateReceived(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
GroupMessagePort.receiver?.keyUpdate(peerID, authenticatedRemoteStaticKey, payload)
}
override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) {
GroupMessagePort.receiver?.message(payload, timestampMs)
}
override fun onVouchPayloadReceived(peerID: String, payload: ByteArray) {
vouchCoordinator.handlePayload(peerID, payload)
}
}
// PacketProcessor delegates
@ -566,6 +639,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun getBroadcastRecipient(): ByteArray {
return SpecialRecipients.BROADCAST
}
override fun isPeerDirectlyConnected(peerID: String): Boolean =
peerManager.getPeerInfo(peerID)?.isDirectConnection == true
override fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
return runBlocking { securityManager.handleNoiseHandshake(routed) }
@ -574,6 +650,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 +679,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
}
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
handleCourierAnnounce(routed)
return true
}
@ -608,7 +689,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) { }
@ -616,6 +697,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun handleVoiceFrame(routed: RoutedPacket): Boolean =
messageHandler.handlePublicVoiceFrame(routed)
override fun handleGroupMessage(routed: RoutedPacket) {
messageHandler.handleGroupMessage(routed)
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
}
override fun handleLeave(routed: RoutedPacket) {
serviceScope.launch { messageHandler.handleLeave(routed) }
@ -656,6 +741,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
val req = RequestSyncPacket.decode(routed.packet.payload) ?: return
gossipSyncManager.handleRequestSync(fromPeer, req)
}
override fun handleBoardPost(routed: RoutedPacket): Boolean {
val wire = com.bitchat.android.board.BoardWireCodec.decode(routed.packet.payload)
?: return false
return boardStore.ingestRemoteForRelay(wire, routed.packet)
}
override fun handlePing(routed: RoutedPacket) = meshPingManager.handlePing(routed)
override fun handlePong(routed: RoutedPacket) = meshPingManager.handlePong(routed)
}
// BluetoothConnectionManager delegates
@ -746,6 +840,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
}
fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit) {
meshPingManager.ping(peerID, callback)
}
/**
* Start the mesh service
*/
@ -809,6 +907,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
@ -857,6 +956,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
*/
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null) {
if (content.isEmpty()) return
val bridgePolicyAtSend = BridgeMeshPort.outboundPolicy()
serviceScope.launch {
val packet = BitchatPacket(
@ -875,6 +975,84 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
broadcastRoutedPacket(RoutedPacket(signedPacket))
// Track our own broadcast message for sync
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
if (channel == null) {
val nickname = runCatching {
com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID)
}.getOrNull()
BridgeMeshPort.bridgeOutgoing(
content,
myPeerID,
packet.timestamp.toLong(),
nickname,
bridgePolicyAtSend
)
}
}
}
fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String?) {
sendRawProtocolPacket(MessageType.NOSTR_CARRIER, payload, recipientPeerID, sign = true)
}
fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) {
sendRawProtocolPacket(MessageType.COURIER_ENVELOPE, payload, recipientPeerID, sign = true)
}
fun sendPrekeyBundle(payload: ByteArray) {
sendRawProtocolPacket(MessageType.PREKEY_BUNDLE, payload, null, sign = true)
}
private fun sendRawProtocolPacket(
type: MessageType,
payload: ByteArray,
recipientPeerID: String?,
sign: Boolean
) {
if (payload.isEmpty()) return
serviceScope.launch {
val packet = BridgeProtocolPacketFactory.protocolPacket(
type = type,
payload = payload,
senderPeerId = myPeerID,
recipientPeerId = recipientPeerID,
ttl = MAX_TTL
) ?: return@launch
val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet
if (sign &&
type != MessageType.COURIER_ENVELOPE &&
outgoing.signature?.size != 64
) {
return@launch
}
gossipSyncManager.onPublicPacketSeen(outgoing)
broadcastRoutedPacket(RoutedPacket(outgoing))
}
}
fun sendBoardPayload(payload: ByteArray) {
val wire = com.bitchat.android.board.BoardWireCodec.decode(payload) ?: return
if (!wire.verifySignature()) return
serviceScope.launch {
// The inner board signature is authoritative. A stable outer
// sender/signature would re-link otherwise isolated location scopes.
val packet = BitchatPacket(
version = 1u,
type = MessageType.BOARD_POST.value,
senderID = wire.transportSenderID(),
recipientID = null,
timestamp = System.currentTimeMillis().coerceAtLeast(0).toULong(),
payload = payload,
signature = null,
ttl = MAX_TTL
)
boardStore.ingest(
wire,
packet,
com.bitchat.android.board.BoardIngestSource.LOCAL
)
broadcastRoutedPacket(RoutedPacket(packet))
}
}
@ -907,6 +1085,31 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
return true
}
private fun signingKeyForFingerprint(fingerprint: String): ByteArray? {
identityState.getAuthenticatedSigningKey(fingerprint)?.let { return it }
return peerManager.getActivePeerIDs().firstNotNullOfOrNull { peerID ->
val peerFingerprint = peerManager.getFingerprintForPeer(peerID)
peerManager.getPeerInfo(peerID)?.signingPublicKey
?.takeIf { peerFingerprint.equals(fingerprint, ignoreCase = true) }
}
}
private fun sendVouchPayload(peerID: String, payload: ByteArray): Boolean {
val plaintext = NoisePayload(NoisePayloadType.VOUCH, payload).encode()
val encrypted = securityManager.encryptForPeer(plaintext, peerID) ?: return false
val packet = BitchatPacket(
version = VouchCoordinator.NOISE_PACKET_VERSION,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(peerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encrypted,
ttl = MAX_TTL
)
broadcastRoutedPacket(RoutedPacket(signPacketBeforeBroadcast(packet)))
return true
}
fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) {
try {
val payload = file.encode()
@ -937,6 +1140,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
/** Safe non-interactive entry point: encrypted sends commit; legacy sends require UI consent. */
fun supportsPrivateMediaReceipts(peerID: String): Boolean {
val session = encryptionService.getAuthenticatedSession(peerID) ?: return false
val proof = authenticatedPeerState.status(peerID, session) as? AuthenticatedPeerStateStatus.Proven ?: return false
return proof.state.capabilities.contains(com.bitchat.android.model.PeerCapabilities.PRIVATE_MEDIA_RECEIPTS)
}
fun sendFilePrivate(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) {
val payload = file.encode() ?: return
when (val prepared = prepareFilePrivate(
@ -1206,6 +1415,42 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
sendNoisePayloadToPeer(payload, peerID, "verify response")
}
fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) {
sendNoisePayloadToPeer(
NoisePayload(NoisePayloadType.GROUP_INVITE, payload),
recipientPeerID,
"group invite"
)
}
fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) {
sendNoisePayloadToPeer(
NoisePayload(NoisePayloadType.GROUP_KEY_UPDATE, payload),
recipientPeerID,
"group key update"
)
}
fun broadcastGroupMessage(payload: ByteArray) {
if (payload.isEmpty()) return
serviceScope.launch {
val packet = BitchatPacket(
version = if (payload.size > 0xffff) 2u else 1u,
type = MessageType.GROUP_MESSAGE.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = SpecialRecipients.BROADCAST,
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
signature = null,
ttl = MAX_TTL
)
// The outer packet is intentionally unsigned. Authenticity is
// verified from the Ed25519 signature inside the ciphertext.
broadcastRoutedPacket(RoutedPacket(packet))
try { gossipSyncManager.onPublicPacketSeen(packet) } catch (_: Exception) { }
}
}
private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String, label: String) {
serviceScope.launch {
try {
@ -1251,7 +1496,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
// Create iOS-compatible IdentityAnnouncement with TLV encoding
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
nickname,
staticKey,
signingKey
)
var tlvPayload = announcement.encode()
if (tlvPayload == null) {
Log.e(TAG, "Failed to encode announcement as TLV")
@ -1313,7 +1562,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
// Create iOS-compatible IdentityAnnouncement with TLV encoding
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
nickname,
staticKey,
signingKey
)
var tlvPayload = announcement.encode()
if (tlvPayload == null) {
Log.e(TAG, "Failed to encode peer announcement as TLV")
@ -1442,6 +1695,218 @@ 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 { com.bitchat.android.services.bridge.PrekeyManager.getInstance(context).seal(typedPayload, messageID, recipientNoiseKey, true) } 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.ciphertext,
prekeyID = sealed.prekeyId?.toUInt(),
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 opened = try {
com.bitchat.android.services.bridge.PrekeyManager.getInstance(context)
.open(envelope.ciphertext, envelope.prekeyID?.toLong())
} catch (_: Exception) { return false }
val senderKey = opened.senderStaticKey
val typedPayload = opened.payload
if (opened.consumedPrekey) {
com.bitchat.android.services.bridge.MeshBridgeService.refreshPrekeys()
}
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
*/
@ -1465,6 +1930,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
fun getStaticNoisePublicKey(): ByteArray? {
return encryptionService.getStaticPublicKey()
}
fun getSigningPublicKey(): ByteArray? =
encryptionService.getSigningPublicKey()?.copyOf()
fun signData(data: ByteArray): ByteArray? =
encryptionService.signData(data)
/**
* Check if encryption icon should be shown for a peer
@ -1620,20 +2091,23 @@ 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,
{ boardStore.wipe() },
bridgeCourierService::stop,
gossipSyncManager::clear
)
var failure: Exception? = null
operations.forEach { operation ->
try { operation() } catch (error: Exception) { failure = error }
}
failure?.let { throw IllegalStateException("Mesh data wipe incomplete", it) }
}
/**

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,98 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.protocol.BitchatPacket
/**
* Immutable privacy decision captured when a public message is accepted.
*
* A later opt-in must not authorize a message that was composed while
* bridging was disabled or nearby-only. Publication therefore requires both
* this send-time decision and the current policy to allow bridging.
*/
class BridgeOutboundPolicy internal constructor(
internal val enabled: Boolean,
internal val nearbyOnly: Boolean
) {
val allowsBridging: Boolean
get() = enabled && !nearbyOnly
fun permitsPublication(current: BridgeOutboundPolicy): Boolean =
allowsBridging && current.allowsBridging
companion object {
val Denied = BridgeOutboundPolicy(enabled = false, nearbyOnly = false)
internal fun capture(enabled: Boolean, nearbyOnly: Boolean): BridgeOutboundPolicy =
BridgeOutboundPolicy(enabled = enabled, nearbyOnly = nearbyOnly)
}
}
/**
* Transport-facing bridge boundary.
*
* BLE/Wi-Fi packet code depends only on this protocol surface; application
* bootstrap installs the process bridge controller. Tests can install a fake
* without constructing relay or persistence infrastructure.
*/
interface BridgeMeshDelegate {
fun advertisedCell(): String?
fun outboundPolicy(): BridgeOutboundPolicy
fun bridgeOutgoing(
content: String,
senderPeerId: String,
timestampMs: Long,
nickname: String?,
policyAtSend: BridgeOutboundPolicy
)
fun handleAuthenticatedRadioMessage(messageId: String)
fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement)
fun handlePrekeyPacket(packet: BitchatPacket)
fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean)
}
object BridgeMeshPort : BridgeMeshDelegate {
@Volatile
private var delegate: BridgeMeshDelegate? = null
fun install(delegate: BridgeMeshDelegate) {
this.delegate = delegate
}
override fun advertisedCell(): String? = delegate?.advertisedCell()
override fun outboundPolicy(): BridgeOutboundPolicy =
delegate?.outboundPolicy() ?: BridgeOutboundPolicy.Denied
override fun bridgeOutgoing(
content: String,
senderPeerId: String,
timestampMs: Long,
nickname: String?,
policyAtSend: BridgeOutboundPolicy
) {
delegate?.bridgeOutgoing(content, senderPeerId, timestampMs, nickname, policyAtSend)
}
override fun handleAuthenticatedRadioMessage(messageId: String) {
delegate?.handleAuthenticatedRadioMessage(messageId)
}
override fun handleVerifiedAnnouncement(
peerId: String,
announcement: IdentityAnnouncement
) {
delegate?.handleVerifiedAnnouncement(peerId, announcement)
}
override fun handlePrekeyPacket(packet: BitchatPacket) {
delegate?.handlePrekeyPacket(packet)
}
override fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) {
delegate?.handleCarrier(payload, fromPeerId, directedToUs)
}
}

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

@ -0,0 +1,13 @@
package com.bitchat.android.mesh
/** Process-level group ingress, independent of Activity and transport lifetimes. */
interface GroupMessageReceiver {
fun invite(peerID: String, authenticatedKey: ByteArray, payload: ByteArray)
fun keyUpdate(peerID: String, authenticatedKey: ByteArray, payload: ByteArray)
fun message(payload: ByteArray, timestampMs: Long)
fun peerAuthenticated(peerID: String)
}
object GroupMessagePort {
@Volatile var receiver: GroupMessageReceiver? = null
}

View File

@ -2,6 +2,7 @@ package com.bitchat.android.mesh
import android.content.Context
import android.util.Log
import com.bitchat.android.board.transportSenderID
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatFilePacket
@ -16,7 +17,9 @@ import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.service.TransportBridgeService
import com.bitchat.android.services.bridge.BridgeProtocolPacketFactory
import com.bitchat.android.sync.GossipSyncManager
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.CoroutineScope
@ -47,6 +50,7 @@ class MeshCore(
* Return false to suppress all downstream effects for a rejected message.
*/
val onMessageReceived: ((BitchatMessage) -> Boolean)? = null,
val onDeliveryReceipt: ((String, String) -> Unit)? = null,
val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null,
val readReceiptInterceptor: ((String, String) -> Boolean)? = null,
val onReadReceiptSent: ((String) -> Unit)? = null,
@ -55,6 +59,7 @@ class MeshCore(
)
private val peerManager = PeerManager()
private val identityState = SecureIdentityStateManager(context.applicationContext)
val fragmentManager = FragmentManager()
private val readReceiptRetrySender = RetryingControlPacketSender(scope)
private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context)
@ -66,13 +71,14 @@ class MeshCore(
store = authenticatedPeerStateStore,
localStateProvider = {
AuthenticatedPeerState(
PeerCapabilities.LOCAL_SUPPORTED,
PeerCapabilities.localSupported(),
requireNotNull(encryptionService.getSigningPublicKey())
)
},
applyAuthenticatedState = peerManager::applyAuthenticatedPeerState,
sendState = ::sendAuthenticatedPeerState,
onResolution = { peerID -> delegate?.didResolvePrivateMediaPolicy(peerID) }
onResolution = { peerID -> GroupMessagePort.receiver?.peerAuthenticated(peerID)
delegate?.didResolvePrivateMediaPolicy(peerID) }
)
}
private val privateMediaSecurity by lazy { PrivateMediaSecurityController(
@ -110,11 +116,27 @@ class MeshCore(
}
private val securityManager = SecurityManager(encryptionService, myPeerID)
private val storeForwardManager = StoreForwardManager()
private val boardStore = com.bitchat.android.board.BoardStore.getInstance(context)
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
private val packetProcessor = PacketProcessor(myPeerID)
private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray)
private val voiceFrameQueue = Channel<VoiceFrameRequest>(capacity = 128)
private val directPeers = ConcurrentHashMap.newKeySet<String>()
private val vouchCoordinator by lazy {
VouchCoordinator(
scope = scope,
identity = identityState,
connectedPeerIDs = peerManager::getActivePeerIDs,
fingerprintForPeer = peerManager::getFingerprintForPeer,
peerInfo = peerManager::getPeerInfo,
signingKeyForFingerprint = ::signingKeyForFingerprint,
hasEstablishedSession = encryptionService::hasEstablishedSession,
sign = encryptionService::signData,
verify = encryptionService::verifyEd25519Signature,
send = ::sendVouchPayload
)
}
private val meshPingManager = MeshPingManager(myPeerID, scope, ::dispatchUnsignedDiagnostic)
val gossipSyncManager: GossipSyncManager =
sharedGossipManager ?: GossipSyncManager(myPeerID = myPeerID, scope = scope, configProvider = gossipConfigProvider)
@ -129,6 +151,7 @@ class MeshCore(
for (request in voiceFrameQueue) dispatchVoiceFrame(request)
}
messageHandler.packetProcessor = packetProcessor
gossipSyncManager.boardPacketsProvider = boardStore::syncCandidates
peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) }
setupDelegates()
@ -217,6 +240,7 @@ class MeshCore(
peerManager.delegate = object : PeerManagerDelegate {
override fun onPeerListUpdated(peerIDs: List<String>) {
try { com.bitchat.android.services.AppStateStore.setTransportPeers(transport.id, peerIDs) } catch (_: Exception) { }
vouchCoordinator.peersUpdated(peerIDs)
delegate?.didUpdatePeerList(peerIDs)
}
@ -242,6 +266,10 @@ class MeshCore(
authenticatedRemoteStaticKey,
authenticatedSessionToken
)
vouchCoordinator.peerAuthenticated(
peerID,
identityState.generateFingerprint(authenticatedRemoteStaticKey)
)
scope.launch {
delay(100)
sendAnnouncementToPeer(peerID)
@ -411,6 +439,7 @@ class MeshCore(
}
override fun onDeliveryAckReceived(messageID: String, peerID: String) {
hooks.onDeliveryReceipt?.invoke(messageID, peerID)
try {
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
messageID,
@ -421,6 +450,7 @@ class MeshCore(
}
override fun onReadReceiptReceived(messageID: String, peerID: String) {
hooks.onDeliveryReceipt?.invoke(messageID, peerID)
try {
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
messageID,
@ -437,6 +467,30 @@ class MeshCore(
override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
}
override fun onGroupInviteReceived(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
GroupMessagePort.receiver?.invite(peerID, authenticatedRemoteStaticKey, payload)
}
override fun onGroupKeyUpdateReceived(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
GroupMessagePort.receiver?.keyUpdate(peerID, authenticatedRemoteStaticKey, payload)
}
override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) {
GroupMessagePort.receiver?.message(payload, timestampMs)
}
override fun onVouchPayloadReceived(peerID: String, payload: ByteArray) {
vouchCoordinator.handlePayload(peerID, payload)
}
}
packetProcessor.delegate = object : PacketProcessorDelegate {
@ -460,6 +514,9 @@ class MeshCore(
return SpecialRecipients.BROADCAST
}
override fun isPeerDirectlyConnected(peerID: String): Boolean =
peerManager.getPeerInfo(peerID)?.isDirectConnection == true
override fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
return runBlocking { securityManager.handleNoiseHandshake(routed) }
}
@ -468,6 +525,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 +540,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) { }
@ -489,6 +548,10 @@ class MeshCore(
override fun handleVoiceFrame(routed: RoutedPacket): Boolean =
messageHandler.handlePublicVoiceFrame(routed)
override fun handleGroupMessage(routed: RoutedPacket) {
messageHandler.handleGroupMessage(routed)
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
}
override fun handleLeave(routed: RoutedPacket) {
scope.launch { messageHandler.handleLeave(routed) }
@ -527,11 +590,29 @@ class MeshCore(
val req = RequestSyncPacket.decode(routed.packet.payload) ?: return
gossipSyncManager.handleRequestSync(fromPeer, req)
}
override fun handleBoardPost(routed: RoutedPacket): Boolean {
val wire = com.bitchat.android.board.BoardWireCodec.decode(routed.packet.payload)
?: return false
return boardStore.ingestRemoteForRelay(wire, routed.packet)
}
override fun handlePing(routed: RoutedPacket) = meshPingManager.handlePing(routed)
override fun handlePong(routed: RoutedPacket) = meshPingManager.handlePong(routed)
}
}
private fun dispatchUnsignedDiagnostic(packet: BitchatPacket) {
dispatchGlobal(RoutedPacket(packet))
}
fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit) {
meshPingManager.ping(peerID, callback)
}
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null) {
if (content.isEmpty()) return
val bridgePolicyAtSend = BridgeMeshPort.outboundPolicy()
scope.launch {
val packet = BitchatPacket(
version = 1u,
@ -546,6 +627,97 @@ class MeshCore(
val signedPacket = signPacketBeforeBroadcast(packet)
dispatchGlobal(RoutedPacket(signedPacket))
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
if (channel == null) {
val nickname = hooks.announcementNicknameProvider?.invoke()
?: delegate?.getNickname()
BridgeMeshPort.bridgeOutgoing(
content,
myPeerID,
packet.timestamp.toLong(),
nickname,
bridgePolicyAtSend
)
}
}
}
fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String? = null) {
sendRawProtocolPacket(
type = MessageType.NOSTR_CARRIER,
payload = payload,
recipientPeerID = recipientPeerID,
sign = true
)
}
fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) {
sendRawProtocolPacket(
type = MessageType.COURIER_ENVELOPE,
payload = payload,
recipientPeerID = recipientPeerID,
sign = true
)
}
fun sendPrekeyBundle(payload: ByteArray) {
sendRawProtocolPacket(
type = MessageType.PREKEY_BUNDLE,
payload = payload,
recipientPeerID = null,
sign = true
)
}
private fun sendRawProtocolPacket(
type: MessageType,
payload: ByteArray,
recipientPeerID: String?,
sign: Boolean
) {
if (payload.isEmpty()) return
scope.launch {
val packet = BridgeProtocolPacketFactory.protocolPacket(
type = type,
payload = payload,
senderPeerId = myPeerID,
recipientPeerId = recipientPeerID,
ttl = maxTtl
) ?: return@launch
val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet
if (sign &&
type != MessageType.COURIER_ENVELOPE &&
outgoing.signature?.size != 64
) {
return@launch
}
dispatchGlobal(RoutedPacket(outgoing))
}
}
fun sendBoardPayload(payload: ByteArray) {
val wire = com.bitchat.android.board.BoardWireCodec.decode(payload) ?: return
if (!wire.verifySignature()) return
scope.launch {
// The inner board signature is authoritative. A stable outer
// sender/signature would re-link otherwise isolated location scopes.
val packet = BitchatPacket(
version = 1u,
type = MessageType.BOARD_POST.value,
senderID = wire.transportSenderID(),
recipientID = null,
timestamp = System.currentTimeMillis().coerceAtLeast(0).toULong(),
payload = payload,
signature = null,
ttl = maxTtl
)
boardStore.ingest(
wire,
packet,
com.bitchat.android.board.BoardIngestSource.LOCAL
)
dispatchGlobal(RoutedPacket(packet))
}
}
@ -575,6 +747,31 @@ class MeshCore(
return true
}
private fun signingKeyForFingerprint(fingerprint: String): ByteArray? {
identityState.getAuthenticatedSigningKey(fingerprint)?.let { return it }
return peerManager.getActivePeerIDs().firstNotNullOfOrNull { peerID ->
val peerFingerprint = peerManager.getFingerprintForPeer(peerID)
peerManager.getPeerInfo(peerID)?.signingPublicKey
?.takeIf { peerFingerprint.equals(fingerprint, ignoreCase = true) }
}
}
private fun sendVouchPayload(peerID: String, payload: ByteArray): Boolean {
val plaintext = NoisePayload(NoisePayloadType.VOUCH, payload).encode()
val encrypted = securityManager.encryptForPeer(plaintext, peerID) ?: return false
val packet = BitchatPacket(
version = VouchCoordinator.NOISE_PACKET_VERSION,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
recipientID = MeshPacketUtils.hexStringToByteArray(peerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encrypted,
ttl = maxTtl
)
dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
return true
}
fun sendFileBroadcast(file: BitchatFilePacket) {
try {
val payload = file.encode() ?: return
@ -658,6 +855,12 @@ class MeshCore(
}
}
fun supportsPrivateMediaReceipts(peerID: String): Boolean {
val session = encryptionService.getAuthenticatedSession(peerID) ?: return false
val proof = authenticatedPeerState.status(peerID, session) as? AuthenticatedPeerStateStatus.Proven ?: return false
return proof.state.capabilities.contains(com.bitchat.android.model.PeerCapabilities.PRIVATE_MEDIA_RECEIPTS)
}
fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,
@ -798,6 +1001,38 @@ class MeshCore(
sendNoisePayloadToPeer(payload, peerID)
}
fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) {
sendNoisePayloadToPeer(
NoisePayload(NoisePayloadType.GROUP_INVITE, payload),
recipientPeerID
)
}
fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) {
sendNoisePayloadToPeer(
NoisePayload(NoisePayloadType.GROUP_KEY_UPDATE, payload),
recipientPeerID
)
}
fun broadcastGroupMessage(payload: ByteArray) {
if (payload.isEmpty()) return
scope.launch {
val packet = BitchatPacket(
version = if (payload.size > 0xffff) 2u else 1u,
type = MessageType.GROUP_MESSAGE.value,
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
recipientID = SpecialRecipients.BROADCAST,
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
signature = null,
ttl = maxTtl
)
dispatchGlobal(RoutedPacket(packet))
try { gossipSyncManager.onPublicPacketSeen(packet) } catch (_: Exception) { }
}
}
private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String) {
scope.launch {
try {
@ -832,7 +1067,11 @@ class MeshCore(
Log.e("MeshCore", "No signing public key available for announcement")
return@launch
}
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
nickname,
staticKey,
signingKey
)
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch
val announcePacket = BitchatPacket(
type = MessageType.ANNOUNCE.value,
@ -853,7 +1092,11 @@ class MeshCore(
?: myPeerID
val staticKey = encryptionService.getStaticPublicKey() ?: return
val signingKey = encryptionService.getSigningPublicKey() ?: return
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
val announcement = BridgeProtocolPacketFactory.identityAnnouncement(
nickname,
staticKey,
signingKey
)
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return
val packet = BitchatPacket(
type = MessageType.ANNOUNCE.value,
@ -991,6 +1234,12 @@ class MeshCore(
fun getStaticNoisePublicKey(): ByteArray? = encryptionService.getStaticPublicKey()
fun getSigningPublicKey(): ByteArray? =
encryptionService.getSigningPublicKey()?.copyOf()
fun signData(data: ByteArray): ByteArray? =
encryptionService.signData(data)
fun shouldShowEncryptionIcon(peerID: String): Boolean = encryptionService.hasEstablishedSession(peerID)
fun getEncryptedPeers(): List<String> = emptyList()
@ -1035,6 +1284,7 @@ class MeshCore(
peerManager.clearAllPeers()
peerManager.clearAllFingerprints()
try { gossipSyncManager.clear() } catch (_: Exception) { }
boardStore.wipe()
}
fun clearAllEncryptionData() {

View File

@ -13,6 +13,17 @@ interface MeshDelegate {
fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {}
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {}
fun didReceiveGroupInvite(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {}
fun didReceiveGroupKeyUpdate(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {}
fun didReceiveGroupMessage(payload: ByteArray, timestampMs: Long) {}
/** Current Noise generation either proved peer state or exhausted its 5-second watchdog. */
fun didResolvePrivateMediaPolicy(peerID: String) {}
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?

View File

@ -0,0 +1,111 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MeshDiagnosticsConstants
import com.bitchat.android.protocol.MeshPingPayload
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
data class MeshPingResult(val rttMillis: Long, val hopCount: Int)
internal class MeshPingManager(
private val myPeerID: String,
private val scope: CoroutineScope,
private val send: (BitchatPacket) -> Unit,
) {
companion object {
/**
* BLE and Wi-Fi Aware have separate manager instances, but a reply can return over either
* transport. Pending probes therefore live at process scope and are namespaced by the
* local identity.
*/
private val pending = ConcurrentHashMap<String, Pending>()
}
private data class Pending(
val peerID: String,
val startedNanos: Long,
val callback: (MeshPingResult?) -> Unit,
val timeout: Job,
)
private val inboundByLink = ConcurrentHashMap<String, ArrayDeque<Long>>()
fun ping(peerID: String, callback: (MeshPingResult?) -> Unit) {
if (pending.size >= 64) { callback(null); return }
val payload = MeshPingPayload.create(MeshDiagnosticsConstants.TTL)
val key = pendingKey(payload)
val timeout = scope.launch {
delay(MeshDiagnosticsConstants.TIMEOUT_MILLIS)
pending.remove(key)?.callback?.invoke(null)
}
pending[key] = Pending(peerID, System.nanoTime(), callback, timeout)
send(packet(MessageType.PING, peerID, payload))
}
fun handlePing(routed: RoutedPacket) {
val packet = routed.packet
val payload = MeshPingPayload.decode(packet.payload) ?: return
val sender = packet.senderID.toHexString()
val ingress = routed.ingressLinkID ?: routed.relayAddress ?: routed.peerID ?: return
if (!consumeInboundBudget(ingress)) return
send(packet(MessageType.PONG, sender, payload))
}
fun handlePong(routed: RoutedPacket) {
val payload = MeshPingPayload.decode(routed.packet.payload) ?: return
val key = pendingKey(payload)
val candidate = pending[key] ?: return
if (routed.packet.senderID.toHexString() != candidate.peerID) return
if (!pending.remove(key, candidate)) return
candidate.timeout.cancel()
val elapsed = (System.nanoTime() - candidate.startedNanos) / 1_000_000
candidate.callback(MeshPingResult(elapsed, payload.hopCount(routed.packet.ttl)))
}
private fun pendingKey(payload: MeshPingPayload): String =
"$myPeerID:${payload.nonce.toHexString()}"
private fun consumeInboundBudget(link: String): Boolean {
val now = System.currentTimeMillis()
synchronized(inboundByLink) {
inboundByLink.entries.removeAll { (_, times) ->
synchronized(times) {
times.lastOrNull()?.let { now - it >= MeshDiagnosticsConstants.INBOUND_RATE_WINDOW_MILLIS } != false
}
}
if (inboundByLink.size >= 256 && !inboundByLink.containsKey(link)) return false
inboundByLink.putIfAbsent(link, ArrayDeque())
}
val timestamps = inboundByLink[link] ?: return false
synchronized(timestamps) {
while (timestamps.firstOrNull()?.let {
now - it >= MeshDiagnosticsConstants.INBOUND_RATE_WINDOW_MILLIS
} == true
) {
timestamps.removeFirst()
}
if (timestamps.size >= MeshDiagnosticsConstants.INBOUND_RATE_LIMIT) return false
timestamps.addLast(now)
return true
}
}
private fun packet(type: MessageType, recipientPeerID: String, payload: MeshPingPayload) =
BitchatPacket(
version = 1u,
type = type.value,
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = payload.encode(),
signature = null,
ttl = MeshDiagnosticsConstants.TTL,
)
}

View File

@ -13,15 +13,24 @@ interface MeshService {
fun stopServices()
fun sendMessage(content: String, mentions: List<String> = emptyList(), channel: String? = null)
fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String? = null)
fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String)
fun sendPrekeyBundle(payload: ByteArray)
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null)
fun supportsPrivateMediaReceipts(peerID: String): Boolean = false
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String)
fun sendDeliveryAck(messageID: String, recipientPeerID: String) {}
fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {}
fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
fun sendGroupInvite(payload: ByteArray, recipientPeerID: String)
fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String)
fun broadcastGroupMessage(payload: ByteArray)
fun sendFileBroadcast(file: BitchatFilePacket)
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket)
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray)
fun sendBoardPayload(payload: ByteArray) {}
fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,
@ -32,6 +41,7 @@ interface MeshService {
fun sendBroadcastAnnounce()
fun sendAnnouncementToPeer(peerID: String)
fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit)
fun getPeerNicknames(): Map<String, String>
fun getPeerRSSI(): Map<String, Int>
@ -41,6 +51,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,
@ -50,11 +73,14 @@ interface MeshService {
): Boolean
fun getIdentityFingerprint(): String
fun getStaticNoisePublicKey(): ByteArray?
fun getSigningPublicKey(): ByteArray?
fun signData(data: ByteArray): ByteArray?
fun shouldShowEncryptionIcon(peerID: String): Boolean
fun getEncryptedPeers(): List<String>
fun getDeviceAddressForPeer(peerID: String): String?
fun getDeviceAddressToPeerMapping(): Map<String, String>
fun getDirectBlePeerIDs(): Set<String> = emptySet()
fun printDeviceAddressesForPeers(): String
fun getDebugStatus(): String

View File

@ -9,6 +9,7 @@ import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.sync.PacketIdUtil
import com.bitchat.android.nostr.MeshMessageIdentity
import com.bitchat.android.util.toHexString
import com.bitchat.android.features.voice.LiveVoiceManager
import com.bitchat.android.features.voice.LiveVoiceScope
@ -118,7 +119,8 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
// Notify delegate
delegate?.onMessageReceived(message)
// Send delivery ACK exactly like iOS
// An ACK means durable admission, including a previously deleted duplicate.
if (!com.bitchat.android.services.AppStateStore.hasPrivateTextReceipt(message)) return false
sendDeliveryAck(privateMessage.messageID, peerID)
}
}
@ -128,7 +130,19 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
val file = com.bitchat.android.model.BitchatFilePacket.decode(noisePayload.data)
if (file != null) {
Log.d(TAG, "Encrypted file from $peerID: ${file.fileSize} bytes")
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
val stableID = com.bitchat.android.model.PrivateMediaMessageIdentity.stableID(peerID, myPeerID, file.fileName)
if (stableID != null) {
when (com.bitchat.android.services.AppStateStore.privateMediaReceiptState(stableID)) {
com.bitchat.android.services.PrivateMediaReceiptState.ACCEPTED,
com.bitchat.android.services.PrivateMediaReceiptState.TOMBSTONED -> {
sendDeliveryAck(stableID, peerID)
return true
}
com.bitchat.android.services.PrivateMediaReceiptState.UNAVAILABLE -> return false
com.bitchat.android.services.PrivateMediaReceiptState.ABSENT -> Unit
}
}
val uniqueMsgId = stableID ?: java.util.UUID.randomUUID().toString().uppercase()
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
val message = BitchatMessage(
id = uniqueMsgId,
@ -146,8 +160,18 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
delegate?.onMessageReceived(message)
}
// Send delivery ACK with generated message ID
sendDeliveryAck(uniqueMsgId, peerID)
if (stableID == null) {
sendDeliveryAck(uniqueMsgId, peerID)
} else {
val receipt = com.bitchat.android.services.AppStateStore.privateMediaReceiptState(stableID)
if (receipt == com.bitchat.android.services.PrivateMediaReceiptState.ACCEPTED ||
receipt == com.bitchat.android.services.PrivateMediaReceiptState.TOMBSTONED) {
sendDeliveryAck(stableID, peerID)
} else {
com.bitchat.android.features.file.FileUtils.deleteStoredMediaPaths(appContext, listOf(savedPath))
return false
}
}
} else {
Log.w(TAG, "Failed to decode encrypted file transfer from $peerID")
}
@ -199,6 +223,23 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
com.bitchat.android.model.NoisePayloadType.VERIFY_RESPONSE -> {
delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong())
}
com.bitchat.android.model.NoisePayloadType.GROUP_INVITE -> {
delegate?.onGroupInviteReceived(
peerID,
decryption.authenticatedSession.remoteStaticKey.copyOf(),
noisePayload.data
)
}
com.bitchat.android.model.NoisePayloadType.GROUP_KEY_UPDATE -> {
delegate?.onGroupKeyUpdateReceived(
peerID,
decryption.authenticatedSession.remoteStaticKey.copyOf(),
noisePayload.data
)
}
com.bitchat.android.model.NoisePayloadType.VOUCH -> {
delegate?.onVouchPayloadReceived(peerID, noisePayload.data)
}
}
} catch (e: Exception) {
@ -207,6 +248,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 com.bitchat.android.services.AppStateStore.hasPrivateTextReceipt(message)
}
/**
* 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
@ -230,6 +297,13 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
consecutiveDecryptFailures[peerID] = failures
}
}
fun handleGroupMessage(routed: RoutedPacket) {
delegate?.onGroupMessageReceived(
routed.packet.payload,
routed.packet.timestamp.toLong()
)
}
/**
* Send delivery ACK for a received private message - exactly like iOS
@ -348,6 +422,11 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
capabilities = announcement.capabilities
) ?: false
BridgeMeshPort.handleVerifiedAnnouncement(
peerID,
announcement
)
// Update mesh graph from gossip neighbors (only if TLV present)
try {
val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload)
@ -453,6 +532,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)
@ -486,13 +571,18 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
// Fallback: plain text
val message = BitchatMessage(
id = PacketIdUtil.computeIdHex(packet).uppercase(),
id = MeshMessageIdentity.stableId(
peerID,
packet.timestamp.toLong(),
String(packet.payload, Charsets.UTF_8)
),
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
content = String(packet.payload, Charsets.UTF_8),
senderPeerID = peerID,
timestamp = Date(packet.timestamp.toLong())
)
delegate?.onMessageReceived(message)
BridgeMeshPort.handleAuthenticatedRadioMessage(message.id)
} catch (e: Exception) {
Log.e(TAG, "Failed to process broadcast message: ${e.message}")
}
@ -738,4 +828,16 @@ interface MessageHandlerDelegate {
fun onReadReceiptReceived(messageID: String, peerID: String)
fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long)
fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long)
fun onGroupInviteReceived(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {}
fun onGroupKeyUpdateReceived(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {}
fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) {}
fun onVouchPayloadReceived(peerID: String, payload: ByteArray) {}
}

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()
@ -137,15 +153,35 @@ class PacketProcessor(private val myPeerID: String) {
MessageType.MESSAGE -> handleMessage(routed)
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
MessageType.VOICE_FRAME -> validPacket = delegate?.handleVoiceFrame(routed) ?: false
MessageType.GROUP_MESSAGE -> handleGroupMessage(routed)
MessageType.BOARD_POST -> validPacket = handleBoardPost(routed)
MessageType.LEAVE -> handleLeave(routed)
MessageType.FRAGMENT -> handleFragment(routed)
MessageType.REQUEST_SYNC -> handleRequestSync(routed)
MessageType.PREKEY_BUNDLE -> {
BridgeMeshPort.handlePrekeyPacket(packet)
}
MessageType.NOSTR_CARRIER -> {
val directedToUs = packetRelayManager.isPacketAddressedToMe(packet)
val isBroadcast = packet.recipientID == null ||
packet.recipientID.contentEquals(delegate?.getBroadcastRecipient())
if (directedToUs || isBroadcast) {
BridgeMeshPort.handleCarrier(
packet.payload,
peerID,
directedToUs
)
}
}
else -> {
// Handle private packet types (address check required)
if (packetRelayManager.isPacketAddressedToMe(packet)) {
when (messageType) {
MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed)
MessageType.NOISE_ENCRYPTED -> validPacket = handleNoiseEncrypted(routed)
MessageType.COURIER_ENVELOPE -> validPacket = delegate?.handleCourierEnvelope(routed) ?: false
MessageType.PING -> delegate?.handlePing(routed)
MessageType.PONG -> delegate?.handlePong(routed)
MessageType.FILE_TRANSFER -> handleMessage(routed)
else -> {
validPacket = false
@ -195,6 +231,12 @@ class PacketProcessor(private val myPeerID: String) {
private suspend fun handleMessage(routed: RoutedPacket) {
delegate?.handleMessage(routed)
}
private fun handleGroupMessage(routed: RoutedPacket) {
val peerID = routed.peerID ?: "unknown"
Log.d(TAG, "Processing private-group message from ${formatPeerForLog(peerID)}")
delegate?.handleGroupMessage(routed)
}
/**
* Handle leave message
@ -226,8 +268,24 @@ class PacketProcessor(private val myPeerID: String) {
* Handle REQUEST_SYNC packets (public, TTL=1)
*/
private suspend fun handleRequestSync(routed: RoutedPacket) {
val peerID = routed.peerID ?: "unknown"
if (routed.packet.ttl != com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) {
Log.w(TAG, "Dropping non-link-local REQUEST_SYNC from ${formatPeerForLog(peerID)}")
return
}
Log.d(TAG, "Processing REQUEST_SYNC from ${formatPeerForLog(peerID)}")
delegate?.handleRequestSync(routed)
}
/**
* Board packets are self-authenticating. The delegate verifies their
* embedded Ed25519 signature and returns false for anything that must not relay.
*/
private fun handleBoardPost(routed: RoutedPacket): Boolean {
val peerID = routed.peerID ?: "unknown"
Log.d(TAG, "Processing board packet from ${formatPeerForLog(peerID)}")
return delegate?.handleBoardPost(routed) ?: false
}
/**
* Handle delivery acknowledgment
@ -291,16 +349,22 @@ interface PacketProcessorDelegate {
// Network information
fun getNetworkSize(): Int
fun getBroadcastRecipient(): ByteArray
fun isPeerDirectlyConnected(peerID: String): Boolean = false
// 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
fun handleGroupMessage(routed: RoutedPacket) {}
fun handleLeave(routed: RoutedPacket)
fun handleFragment(packet: BitchatPacket): BitchatPacket?
fun handleRequestSync(routed: RoutedPacket)
fun handleBoardPost(routed: RoutedPacket): Boolean = false
fun handlePing(routed: RoutedPacket) {}
fun handlePong(routed: RoutedPacket) {}
// Communication
fun sendAnnouncementToPeer(peerID: String)

View File

@ -4,8 +4,10 @@ import com.bitchat.android.protocol.MessageType
import android.util.Log
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MeshDiagnosticsConstants
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap
import kotlin.random.Random
/**
@ -32,6 +34,7 @@ class PacketRelayManager(private val myPeerID: String) {
// Coroutines
private val relayScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val diagnosticRelayTimestamps = ConcurrentHashMap<String, ArrayDeque<Long>>()
/**
* Main entry point for relay decisions
@ -60,6 +63,13 @@ class PacketRelayManager(private val myPeerID: String) {
Log.d(TAG, "TTL expired, not relaying packet")
return
}
val isDiagnostic = MessageType.fromValue(packet.type) in
setOf(MessageType.PING, MessageType.PONG)
if (isDiagnostic && !consumeDiagnosticRelayBudget(routed, peerID)) {
Log.w(TAG, "Diagnostic relay budget exhausted for ingress link")
return
}
// Decrement TTL by 1
val networkSize = delegate?.getNetworkSize() ?: 1
@ -108,11 +118,36 @@ class PacketRelayManager(private val myPeerID: String) {
// Apply relay logic based on packet type and debug switch
val shouldRelay = isRelayEnabled() && shouldRelayPacket(relayPacket, peerID)
if (shouldRelay) {
if (isDiagnostic) {
delay(
Random.nextLong(
MeshDiagnosticsConstants.RELAY_JITTER_MIN_MILLIS,
MeshDiagnosticsConstants.RELAY_JITTER_MAX_MILLIS + 1,
)
)
}
relayPacket(RoutedPacket(relayPacket, peerID, routed.relayAddress))
} else {
Log.d(TAG, "Relay decision: NOT relaying packet type ${packet.type}")
}
}
private fun consumeDiagnosticRelayBudget(routed: RoutedPacket, fallbackPeerID: String): Boolean {
val ingressKey = routed.ingressLinkID ?: routed.relayAddress ?: fallbackPeerID
val now = System.currentTimeMillis()
val timestamps = diagnosticRelayTimestamps.computeIfAbsent(ingressKey) { ArrayDeque() }
synchronized(timestamps) {
while (timestamps.firstOrNull()?.let {
now - it >= MeshDiagnosticsConstants.INBOUND_RATE_WINDOW_MILLIS
} == true
) {
timestamps.removeFirst()
}
if (timestamps.size >= MeshDiagnosticsConstants.INBOUND_RATE_LIMIT) return false
timestamps.addLast(now)
return true
}
}
/**
* Check if a packet is specifically addressed to us
@ -140,6 +175,9 @@ class PacketRelayManager(private val myPeerID: String) {
* Determine if we should relay this packet based on type and network conditions
*/
private fun shouldRelayPacket(packet: BitchatPacket, fromPeerID: String): Boolean {
if (MessageType.fromValue(packet.type) in setOf(MessageType.PING, MessageType.PONG)) {
return true
}
// Always relay if TTL is high enough (indicates important message)
if (packet.ttl >= 4u) {
Log.d(TAG, "High TTL (${packet.ttl}), relaying")

View File

@ -89,8 +89,10 @@ class SecurityManager(private val encryptionService: EncryptionService, private
}
}
// Enforce mandatory signature verification
if (!verifyPacketSignature(packet, peerID)) {
// Mesh diagnostics are intentionally unsigned for iOS wire compatibility.
val isUnsignedDiagnostic =
messageType in setOf(MessageType.PING, MessageType.PONG) && packet.signature == null
if (!isUnsignedDiagnostic && !verifyPacketSignature(packet, peerID)) {
return false
}
@ -257,8 +259,11 @@ class SecurityManager(private val encryptionService: EncryptionService, private
MessageType.ANNOUNCE,
MessageType.MESSAGE,
MessageType.FILE_TRANSFER,
MessageType.COURIER_ENVELOPE,
MessageType.VOICE_FRAME,
MessageType.LEAVE
MessageType.LEAVE,
MessageType.REQUEST_SYNC,
MessageType.NOSTR_CARRIER
)) {
return true
}

View File

@ -5,7 +5,9 @@ import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.service.TransportBridgeService
import com.bitchat.android.wifiaware.WifiAwareController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -37,6 +39,10 @@ class UnifiedMeshService(
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val powerManager = PowerManager.getInstance(context.applicationContext)
private var announcementJob: Job? = null
private val diagnosticsScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val meshPingManager = MeshPingManager(bluetooth.myPeerID, diagnosticsScope) { packet ->
TransportBridgeService.broadcastFromLocal(RoutedPacket(packet))
}
override val myPeerID: String
get() = bluetooth.myPeerID
@ -101,6 +107,29 @@ class UnifiedMeshService(
}
}
override fun sendNostrCarrier(payload: ByteArray, recipientPeerID: String?) {
when {
isBleEnabled() -> bluetooth.sendNostrCarrier(payload, recipientPeerID)
else -> wifiService()?.sendNostrCarrier(payload, recipientPeerID)
}
}
override fun sendCourierEnvelope(payload: ByteArray, recipientPeerID: String) {
when {
isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
bluetooth.sendCourierEnvelope(payload, recipientPeerID)
else -> wifiService()?.sendCourierEnvelope(payload, recipientPeerID)
}
}
override fun sendPrekeyBundle(payload: ByteArray) {
if (isBleEnabled()) {
bluetooth.sendPrekeyBundle(payload)
} else {
wifiService()?.sendPrekeyBundle(payload)
}
}
override fun sendPrivateMessage(
content: String,
recipientPeerID: String,
@ -116,6 +145,25 @@ 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 supportsPrivateMediaReceipts(peerID: String): Boolean =
bluetooth.supportsPrivateMediaReceipts(peerID) || wifiService()?.supportsPrivateMediaReceipts(peerID) == true
override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
when {
isBleReady(recipientPeerID) -> bluetooth.sendReadReceipt(messageID, recipientPeerID, readerNickname)
@ -150,6 +198,30 @@ class UnifiedMeshService(
}
}
override fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) {
when {
isBleReady(recipientPeerID) -> bluetooth.sendGroupInvite(payload, recipientPeerID)
isWifiReady(recipientPeerID) ->
wifiService()?.sendGroupInvite(payload, recipientPeerID)
}
}
override fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) {
when {
isBleReady(recipientPeerID) ->
bluetooth.sendGroupKeyUpdate(payload, recipientPeerID)
isWifiReady(recipientPeerID) ->
wifiService()?.sendGroupKeyUpdate(payload, recipientPeerID)
}
}
override fun broadcastGroupMessage(payload: ByteArray) {
when {
isBleEnabled() -> bluetooth.broadcastGroupMessage(payload)
else -> wifiService()?.broadcastGroupMessage(payload)
}
}
override fun sendFileBroadcast(file: BitchatFilePacket) {
when {
isBleEnabled() -> bluetooth.sendFileBroadcast(file)
@ -184,6 +256,15 @@ class UnifiedMeshService(
}
}
override fun sendBoardPayload(payload: ByteArray) {
when {
isBleEnabled() -> bluetooth.sendBoardPayload(payload)
else -> wifiService()?.sendBoardPayload(payload)
}
}
override fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,
@ -239,6 +320,10 @@ class UnifiedMeshService(
}
}
override fun sendMeshPing(peerID: String, callback: (MeshPingResult?) -> Unit) {
meshPingManager.ping(peerID, callback)
}
override fun getPeerNicknames(): Map<String, String> {
val merged = linkedMapOf<String, String>()
try { merged.putAll(wifiService()?.getPeerNicknames().orEmpty()) } catch (_: Exception) { }
@ -327,6 +412,12 @@ class UnifiedMeshService(
return bluetooth.getStaticNoisePublicKey() ?: wifiService()?.getStaticNoisePublicKey()
}
override fun getSigningPublicKey(): ByteArray? =
bluetooth.getSigningPublicKey() ?: wifiService()?.getSigningPublicKey()
override fun signData(data: ByteArray): ByteArray? =
bluetooth.signData(data) ?: wifiService()?.signData(data)
override fun shouldShowEncryptionIcon(peerID: String): Boolean {
return hasEstablishedSession(peerID)
}
@ -351,6 +442,13 @@ class UnifiedMeshService(
return merged
}
override fun getDirectBlePeerIDs(): Set<String> =
try {
bluetooth.getDeviceAddressToPeerMapping().values.toSet()
} catch (_: Exception) {
emptySet()
}
override fun printDeviceAddressesForPeers(): String {
return buildString {
appendLine(bluetooth.printDeviceAddressesForPeers())
@ -376,8 +474,10 @@ class UnifiedMeshService(
}
override fun clearAllInternalData() {
try { bluetooth.clearAllInternalData() } catch (_: Exception) { }
try { wifiService()?.clearAllInternalData() } catch (_: Exception) { }
val bluetoothResult = runCatching { bluetooth.clearAllInternalData() }
val wifiResult = runCatching { wifiService()?.clearAllInternalData() }
bluetoothResult.getOrThrow()
wifiResult.getOrThrow()
}
override fun clearAllEncryptionData() {
@ -413,6 +513,26 @@ class UnifiedMeshService(
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
}
override fun didReceiveGroupInvite(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
delegate?.didReceiveGroupInvite(peerID, authenticatedRemoteStaticKey, payload)
}
override fun didReceiveGroupKeyUpdate(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
delegate?.didReceiveGroupKeyUpdate(peerID, authenticatedRemoteStaticKey, payload)
}
override fun didReceiveGroupMessage(payload: ByteArray, timestampMs: Long) {
delegate?.didReceiveGroupMessage(payload, timestampMs)
}
override fun didResolvePrivateMediaPolicy(peerID: String) {
delegate?.didResolvePrivateMediaPolicy(peerID)
}

View File

@ -0,0 +1,127 @@
package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.model.VouchAttestation
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.util.hexEncodedString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
/**
* Transport-neutral exchange and acceptance policy for transitive verification.
*
* All payloads supplied to [handlePayload] have already been authenticated and
* decrypted by the Noise session for [peerID].
*/
class VouchCoordinator(
private val scope: CoroutineScope,
private val identity: SecureIdentityStateManager,
private val connectedPeerIDs: () -> Collection<String>,
private val fingerprintForPeer: (String) -> String?,
private val peerInfo: (String) -> PeerInfo?,
private val signingKeyForFingerprint: (String) -> ByteArray?,
private val hasEstablishedSession: (String) -> Boolean,
private val sign: (ByteArray) -> ByteArray?,
private val verify: (ByteArray, ByteArray, ByteArray) -> Boolean,
private val send: (String, ByteArray) -> Boolean
) {
init {
scope.launch {
SecureIdentityStateManager.changes.collect {
vouchToConnectedVerifiedPeers()
}
}
}
fun peerAuthenticated(peerID: String, fingerprint: String) {
attemptVouch(peerID, fingerprint)
}
fun peersUpdated(peerIDs: Collection<String>) {
peerIDs.forEach { peerID ->
fingerprintForPeer(peerID)?.let { attemptVouch(peerID, it) }
}
}
fun vouchToConnectedVerifiedPeers(nowMs: Long = System.currentTimeMillis()) {
connectedPeerIDs().forEach { peerID ->
fingerprintForPeer(peerID)?.let { attemptVouch(peerID, it, nowMs) }
}
}
fun attemptVouch(
peerID: String,
peerFingerprint: String,
nowMs: Long = System.currentTimeMillis()
): Boolean {
val normalizedPeerFingerprint = peerFingerprint.lowercase()
if (!hasEstablishedSession(peerID) ||
!identity.isVerifiedFingerprint(normalizedPeerFingerprint)
) return false
val capabilities = peerInfo(peerID)?.capabilities
if (capabilities != null && capabilities != PeerCapabilities.NONE &&
!capabilities.contains(PeerCapabilities.VOUCH)
) return false
val lastSent = identity.lastVouchBatchSent(normalizedPeerFingerprint)
if (lastSent != null && nowMs - lastSent < BATCH_INTERVAL_MS) return false
val attestations = identity.mostRecentlyVerifiedFingerprints(
VouchAttestation.MAX_BATCH_COUNT,
excluding = normalizedPeerFingerprint
).mapNotNull { vouchee ->
val fingerprintBytes = vouchee.dataFromHexString() ?: return@mapNotNull null
val signingKey = signingKeyForFingerprint(vouchee) ?: return@mapNotNull null
VouchAttestation.build(fingerprintBytes, signingKey, nowMs, sign)
}
val payload = VouchAttestation.encodeList(attestations) ?: return false
if (!send(peerID, payload)) return false
identity.markVouchBatchSent(normalizedPeerFingerprint, nowMs)
Log.d(TAG, "Sent ${attestations.size} vouch(es) to ${peerID.take(LOG_FINGERPRINT_LENGTH)}")
return true
}
fun handlePayload(
peerID: String,
payload: ByteArray,
nowMs: Long = System.currentTimeMillis()
) {
val senderFingerprint = fingerprintForPeer(peerID)?.lowercase() ?: return
if (!identity.isVerifiedFingerprint(senderFingerprint)) return
val senderSigningKey = signingKeyForFingerprint(senderFingerprint) ?: return
var accepted = INITIAL_ACCEPTED_COUNT
VouchAttestation.decodeList(payload).forEach { attestation ->
if (!attestation.isExpired(nowMs) &&
verify(attestation.signature, attestation.signableBytes(), senderSigningKey) &&
identity.recordVouch(
attestation.voucheeFingerprint.hexEncodedString(),
senderFingerprint,
attestation.voucheeSigningKey,
attestation.timestampMs,
nowMs
)
) accepted++
}
if (accepted > INITIAL_ACCEPTED_COUNT) {
Log.i(TAG, "Accepted $accepted vouch(es) from ${peerID.take(LOG_FINGERPRINT_LENGTH)}")
}
}
companion object {
private const val TAG = "VouchCoordinator"
private const val INITIAL_ACCEPTED_COUNT = 0
private const val LOG_FINGERPRINT_LENGTH = 8
private const val HOURS_PER_DAY = 24L
private const val MINUTES_PER_HOUR = 60L
private const val SECONDS_PER_MINUTE = 60L
private const val MILLIS_PER_SECOND = 1000L
const val BATCH_INTERVAL_MS =
HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLIS_PER_SECOND
val NOISE_PACKET_VERSION: UByte = 1u
}
}

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}"
@ -77,7 +81,15 @@ data class BitchatMessage(
* surfaces color the sender by the same stable key while [senderPeerID] remains available for
* mesh IDs and private-chat routing aliases.
*/
val senderNostrPubkey: String? = null
val senderNostrPubkey: String? = null,
/** Rendered from a signed bridge rendezvous event rather than local radio. */
val isBridged: Boolean = false,
/**
* Untrusted radio-coordinate hint from the bridge event. It may merge a
* duplicate when the authenticated radio copy arrives, but never owns the
* bridge row's primary ID.
*/
val bridgeRadioMessageIdHint: String? = null
) : Parcelable {
/**
@ -362,4 +374,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

@ -13,7 +13,8 @@ data class IdentityAnnouncement(
val noisePublicKey: ByteArray, // Noise static public key (Curve25519.KeyAgreement)
val signingPublicKey: ByteArray, // Ed25519 public key for signing
val capabilities: PeerCapabilities? = null,
val unknownTLVs: List<UnknownAnnouncementTLV> = emptyList()
val unknownTLVs: List<UnknownAnnouncementTLV> = emptyList(),
val bridgeGeohash: String? = null
) : Parcelable {
/**
@ -23,7 +24,8 @@ data class IdentityAnnouncement(
NICKNAME(0x01u),
NOISE_PUBLIC_KEY(0x02u),
SIGNING_PUBLIC_KEY(0x03u), // NEW: Ed25519 signing public key
CAPABILITIES(0x05u);
CAPABILITIES(0x05u),
BRIDGE_GEOHASH(0x06u);
companion object {
fun fromValue(value: UByte): TLVType? {
@ -39,6 +41,9 @@ data class IdentityAnnouncement(
val nicknameData = nickname.toByteArray(Charsets.UTF_8)
// Check size limits
val bridgeGeohashData = bridgeGeohash
?.toByteArray(Charsets.UTF_8)
?.takeIf { it.size in 1..12 }
if (nicknameData.size > 255 || noisePublicKey.size > 255 || signingPublicKey.size > 255 ||
unknownTLVs.any { it.value.size > 255 }) {
return null
@ -68,6 +73,12 @@ data class IdentityAnnouncement(
result.addAll(capabilityBytes.toList())
}
bridgeGeohashData?.let { geohash ->
result.add(TLVType.BRIDGE_GEOHASH.value.toByte())
result.add(geohash.size.toByte())
result.addAll(geohash.toList())
}
// Preserve extensions this build does not understand. This includes
// gossip TLV 0x04 when an announcement is decoded through this model.
unknownTLVs.forEach { tlv ->
@ -92,6 +103,7 @@ data class IdentityAnnouncement(
var noisePublicKey: ByteArray? = null
var signingPublicKey: ByteArray? = null
var capabilities: PeerCapabilities? = null
var bridgeGeohash: String? = null
val unknownTLVs = mutableListOf<UnknownAnnouncementTLV>()
while (offset + 2 <= dataCopy.size) {
@ -125,6 +137,10 @@ data class IdentityAnnouncement(
TLVType.CAPABILITIES -> {
capabilities = PeerCapabilities.decode(value)
}
TLVType.BRIDGE_GEOHASH -> {
if (value.size !in 1..12) return null
bridgeGeohash = String(value, Charsets.UTF_8)
}
null -> {
// Retain unknown extensions so callers can forward or
// re-encode the announcement without erasing them.
@ -135,7 +151,14 @@ data class IdentityAnnouncement(
// All three fields are required
return if (nickname != null && noisePublicKey != null && signingPublicKey != null) {
IdentityAnnouncement(nickname, noisePublicKey, signingPublicKey, capabilities, unknownTLVs)
IdentityAnnouncement(
nickname,
noisePublicKey,
signingPublicKey,
capabilities,
unknownTLVs,
bridgeGeohash
)
} else {
null
}
@ -145,12 +168,14 @@ data class IdentityAnnouncement(
fun forLocalPeer(
nickname: String,
noisePublicKey: ByteArray,
signingPublicKey: ByteArray
signingPublicKey: ByteArray,
bridgeGeohash: String? = null
): IdentityAnnouncement = IdentityAnnouncement(
nickname = nickname,
noisePublicKey = noisePublicKey,
signingPublicKey = signingPublicKey,
capabilities = PeerCapabilities.LOCAL_SUPPORTED
capabilities = PeerCapabilities.localSupported(),
bridgeGeohash = bridgeGeohash
)
}
@ -166,6 +191,7 @@ data class IdentityAnnouncement(
if (!signingPublicKey.contentEquals(other.signingPublicKey)) return false
if (capabilities != other.capabilities) return false
if (unknownTLVs != other.unknownTLVs) return false
if (bridgeGeohash != other.bridgeGeohash) return false
return true
}
@ -176,10 +202,11 @@ data class IdentityAnnouncement(
result = 31 * result + signingPublicKey.contentHashCode()
result = 31 * result + (capabilities?.hashCode() ?: 0)
result = 31 * result + unknownTLVs.hashCode()
result = 31 * result + (bridgeGeohash?.hashCode() ?: 0)
return result
}
override fun toString(): String {
return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., capabilities=${capabilities?.rawValue})"
return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., capabilities=${capabilities?.rawValue}, bridgeGeohash=$bridgeGeohash)"
}
}

View File

@ -22,8 +22,11 @@ enum class NoisePayloadType(val value: UByte) {
READ_RECEIPT(0x02u), // Message was read
DELIVERED(0x03u), // Message was delivered
VOICE_FRAME(0x08u), // Ephemeral live push-to-talk frame
GROUP_INVITE(0x06u), // Creator-signed private-group state
GROUP_KEY_UPDATE(0x07u), // Creator-signed roster/key rotation
VERIFY_CHALLENGE(0x10u), // Verification challenge
VERIFY_RESPONSE(0x11u), // Verification response
VOUCH(0x12u), // Transitive verification attestations
FILE_TRANSFER(0x20u),
/** Authenticated capabilities + Ed25519 binding for the current Noise generation. */
PEER_STATE(0x21u);

View File

@ -0,0 +1,101 @@
package com.bitchat.android.model
import com.bitchat.android.nostr.NostrEvent
/**
* Wire payload for MessageType.NOSTR_CARRIER (0x28).
*
* The TLV layout and limits intentionally match iOS. Lengths are unsigned
* 16-bit big-endian values and unknown TLVs are skipped.
*/
data class NostrCarrierPacket(
val direction: Direction,
val geohash: String,
val eventJson: ByteArray
) {
enum class Direction(val value: Int) {
TO_GATEWAY(0x01),
FROM_GATEWAY(0x02),
TO_BRIDGE(0x03),
FROM_BRIDGE(0x04);
companion object {
fun fromValue(value: Int): Direction? = entries.firstOrNull { it.value == value }
}
}
init {
require(geohash.toByteArray(Charsets.UTF_8).size in 1..MAX_GEOHASH_LENGTH)
require(eventJson.size in 1..MAX_EVENT_JSON_BYTES)
}
fun event(): NostrEvent? =
NostrEvent.fromJsonString(String(eventJson, Charsets.UTF_8))
fun encode(): ByteArray {
return checkNotNull(
Tlv16Codec.encode(
Tlv16Codec.Field(TLV_DIRECTION, byteArrayOf(direction.value.toByte())),
Tlv16Codec.Field(TLV_GEOHASH, geohash.toByteArray(Charsets.UTF_8)),
Tlv16Codec.Field(TLV_EVENT_JSON, eventJson)
)
)
}
companion object {
const val MAX_EVENT_JSON_BYTES = 16 * 1024
const val MAX_GEOHASH_LENGTH = 12
private const val TLV_DIRECTION = 0x01
private const val TLV_GEOHASH = 0x02
private const val TLV_EVENT_JSON = 0x03
fun fromEvent(direction: Direction, geohash: String, event: NostrEvent): NostrCarrierPacket? =
runCatching {
NostrCarrierPacket(
direction = direction,
geohash = geohash,
eventJson = event.toJsonString().toByteArray(Charsets.UTF_8)
)
}.getOrNull()
fun decode(data: ByteArray): NostrCarrierPacket? {
var direction: Direction? = null
var geohash: String? = null
var eventJson: ByteArray? = null
Tlv16Codec.decode(data)?.forEach { field ->
when (field.type) {
TLV_DIRECTION -> {
if (field.value.size != 1) return null
direction =
Direction.fromValue(field.value[0].toInt() and 0xFF) ?: return null
}
TLV_GEOHASH -> {
geohash = field.value.toString(Charsets.UTF_8)
}
TLV_EVENT_JSON -> eventJson = field.value
}
} ?: return null
return runCatching {
NostrCarrierPacket(
direction = direction ?: return null,
geohash = geohash ?: return null,
eventJson = eventJson ?: return null
)
}.getOrNull()
}
}
override fun equals(other: Any?): Boolean =
this === other ||
(other is NostrCarrierPacket &&
direction == other.direction &&
geohash == other.geohash &&
eventJson.contentEquals(other.eventJson))
override fun hashCode(): Int =
31 * (31 * direction.hashCode() + geohash.hashCode()) + eventJson.contentHashCode()
}

View File

@ -1,6 +1,7 @@
package com.bitchat.android.model
import android.os.Parcelable
import com.bitchat.android.protocol.MeshDiagnosticsConstants
import kotlinx.parcelize.Parcelize
/**
@ -27,27 +28,57 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
}
companion object {
private const val VOUCH_BIT_INDEX = 5
private const val PRIVATE_MEDIA_BIT_INDEX = 8
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 = PeerCapabilities(1L shl PRIVATE_MEDIA_BIT_INDEX)
/** Transitive verification attestations over authenticated Noise. */
val VOUCH = PeerCapabilities(1L shl VOUCH_BIT_INDEX)
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)
/** Can bridge public mesh traffic through geohash rendezvous relays. */
val BRIDGE = PeerCapabilities(1L shl 7)
/** Publishes signed one-time prekeys for forward-secret courier mail. */
val PREKEYS = PeerCapabilities(1L shl 0)
/** Capabilities implemented by this Android build. */
val LOCAL_SUPPORTED = PRIVATE_MEDIA
@Deprecated("Use localSupported() so runtime bridge state is included")
val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or BOARD.rawValue or VOUCH.rawValue or MESH_DIAGNOSTICS.rawValue)
@Volatile
private var bridgeEnabled: Boolean = false
@Volatile private var gatewayEnabled: Boolean = false
fun setGatewayEnabled(enabled: Boolean) { gatewayEnabled = enabled }
@Volatile private var phoneFeaturesEnabled = false
fun setPhoneFeaturesEnabled(enabled: Boolean) { phoneFeaturesEnabled = enabled }
fun setBridgeEnabled(enabled: Boolean) {
bridgeEnabled = enabled
}
fun localSupported(): PeerCapabilities = PeerCapabilities(
LOCAL_SUPPORTED.rawValue or
(if (phoneFeaturesEnabled) PREKEYS.rawValue or GROUPS.rawValue or PRIVATE_MEDIA_RECEIPTS.rawValue else 0L) or
(if (bridgeEnabled) BRIDGE.rawValue else 0L) or
(if (gatewayEnabled) GATEWAY.rawValue else 0L)
)
/**
* Decode the low 64 bits and ignore any future extension bytes, which

View File

@ -0,0 +1,171 @@
package com.bitchat.android.model
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Signed batch of one-time Curve25519 keys carried by MessageType.PREKEY_BUNDLE (0x24).
*
* The canonical signing bytes and TLV representation intentionally match the
* iOS BitFoundation implementation byte-for-byte.
*/
data class PrekeyBundle(
val noiseStaticPublicKey: ByteArray,
val prekeys: List<Prekey>,
val generatedAt: Long,
val signature: ByteArray
) {
data class Prekey(val id: Long, val publicKey: ByteArray) {
init {
require(id in 0..0xFFFF_FFFFL)
require(publicKey.size == KEY_LENGTH)
}
override fun equals(other: Any?): Boolean =
this === other ||
(other is Prekey && id == other.id && publicKey.contentEquals(other.publicKey))
override fun hashCode(): Int = 31 * id.hashCode() + publicKey.contentHashCode()
}
fun signableBytes(): ByteArray {
val output = ByteArrayOutputStream(
1 + SIGNING_CONTEXT.size + KEY_LENGTH + 1 + prekeys.size * PREKEY_ENTRY_LENGTH + Long.SIZE_BYTES
)
output.write(SIGNING_CONTEXT.size)
output.write(SIGNING_CONTEXT)
output.write(fixedKey(noiseStaticPublicKey))
output.write(prekeys.size.coerceAtMost(0xFF))
prekeys.take(0xFF).forEach { prekey ->
output.write(uint32Bytes(prekey.id))
output.write(fixedKey(prekey.publicKey))
}
output.write(uint64Bytes(generatedAt))
return output.toByteArray()
}
fun encode(): ByteArray? {
if (noiseStaticPublicKey.size != KEY_LENGTH ||
signature.size != SIGNATURE_LENGTH ||
prekeys.isEmpty() ||
prekeys.size > MAX_PREKEYS ||
prekeys.map { it.id }.distinct().size != prekeys.size
) {
return null
}
val entries = ByteArrayOutputStream(prekeys.size * PREKEY_ENTRY_LENGTH)
prekeys.forEach { prekey ->
if (prekey.publicKey.size != KEY_LENGTH || prekey.id !in 0..0xFFFF_FFFFL) return null
entries.write(uint32Bytes(prekey.id))
entries.write(prekey.publicKey)
}
return Tlv16Codec.encode(
Tlv16Codec.Field(TLV_NOISE_STATIC_KEY, noiseStaticPublicKey),
Tlv16Codec.Field(TLV_PREKEYS, entries.toByteArray()),
Tlv16Codec.Field(TLV_GENERATED_AT, uint64Bytes(generatedAt)),
Tlv16Codec.Field(TLV_SIGNATURE, signature)
)
}
companion object {
const val KEY_LENGTH = 32
const val SIGNATURE_LENGTH = 64
const val MAX_PREKEYS = 8
private const val PREKEY_ENTRY_LENGTH = 4 + KEY_LENGTH
private val SIGNING_CONTEXT = "bitchat-prekey-bundle-v1".toByteArray(Charsets.UTF_8)
private const val TLV_NOISE_STATIC_KEY = 0x01
private const val TLV_PREKEYS = 0x02
private const val TLV_GENERATED_AT = 0x03
private const val TLV_SIGNATURE = 0x04
fun decode(data: ByteArray): PrekeyBundle? {
var noiseStaticKey: ByteArray? = null
var prekeys: List<Prekey>? = null
var generatedAt: Long? = null
var signature: ByteArray? = null
Tlv16Codec.decode(data)?.forEach { field ->
when (field.type) {
TLV_NOISE_STATIC_KEY -> {
if (field.value.size != KEY_LENGTH) return null
noiseStaticKey = field.value
}
TLV_PREKEYS -> {
if (field.value.isEmpty() ||
field.value.size % PREKEY_ENTRY_LENGTH != 0 ||
field.value.size / PREKEY_ENTRY_LENGTH > MAX_PREKEYS
) {
return null
}
val parsed = mutableListOf<Prekey>()
var entryOffset = 0
while (entryOffset < field.value.size) {
val id = ByteBuffer.wrap(field.value, entryOffset, Int.SIZE_BYTES)
.order(ByteOrder.BIG_ENDIAN)
.int.toLong() and 0xFFFF_FFFFL
entryOffset += Int.SIZE_BYTES
val publicKey =
field.value.copyOfRange(entryOffset, entryOffset + KEY_LENGTH)
entryOffset += KEY_LENGTH
parsed += Prekey(id, publicKey)
}
if (parsed.map { it.id }.distinct().size != parsed.size) return null
prekeys = parsed
}
TLV_GENERATED_AT -> {
if (field.value.size != Long.SIZE_BYTES) return null
generatedAt = ByteBuffer.wrap(field.value).order(ByteOrder.BIG_ENDIAN).long
}
TLV_SIGNATURE -> {
if (field.value.size != SIGNATURE_LENGTH) return null
signature = field.value
}
}
} ?: return null
return runCatching {
PrekeyBundle(
noiseStaticPublicKey = noiseStaticKey ?: return null,
prekeys = prekeys?.takeIf { it.isNotEmpty() } ?: return null,
generatedAt = generatedAt ?: return null,
signature = signature ?: return null
)
}.getOrNull()
}
private fun uint32Bytes(value: Long): ByteArray =
ByteBuffer.allocate(Int.SIZE_BYTES)
.order(ByteOrder.BIG_ENDIAN)
.putInt(value.toInt())
.array()
private fun uint64Bytes(value: Long): ByteArray =
ByteBuffer.allocate(Long.SIZE_BYTES)
.order(ByteOrder.BIG_ENDIAN)
.putLong(value)
.array()
private fun fixedKey(key: ByteArray): ByteArray =
key.copyOf(KEY_LENGTH)
}
override fun equals(other: Any?): Boolean =
this === other ||
(other is PrekeyBundle &&
noiseStaticPublicKey.contentEquals(other.noiseStaticPublicKey) &&
prekeys == other.prekeys &&
generatedAt == other.generatedAt &&
signature.contentEquals(other.signature))
override fun hashCode(): Int {
var result = noiseStaticPublicKey.contentHashCode()
result = 31 * result + prekeys.hashCode()
result = 31 * result + generatedAt.hashCode()
result = 31 * result + signature.contentHashCode()
return result
}
}

View File

@ -0,0 +1,38 @@
package com.bitchat.android.model
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.security.MessageDigest
/** Stable, direction-bound IDs matching the iOS private-media receipt contract. */
object PrivateMediaMessageIdentity {
private val stable = Regex("media-[0-9a-f]{32}")
private val uuid = Regex("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
private val peer = Regex("[0-9a-f]{16}")
fun isStableID(value: String): Boolean = stable.matches(value)
fun stableID(senderPeerID: String, recipientPeerID: String, fileName: String?): String? {
if (fileName.isNullOrEmpty() || '/' in fileName || '\\' in fileName) return null
val sender = senderPeerID.lowercase()
val recipient = recipientPeerID.lowercase()
if (!peer.matches(sender) || !peer.matches(recipient)) return null
val stem = fileName.substringBeforeLast('.', fileName)
val extension = fileName.substringAfterLast('.', "").lowercase()
val isVoice = stem.startsWith("voice_") && extension == "m4a"
val isImage = stem.startsWith("img_") && extension in setOf("jpg", "jpeg")
if (!isVoice && !isImage) return null
val burst = stem.removePrefix("voice_")
if (!uuid.matches(stem.substringAfterLast('_')) &&
!(isVoice && Regex("[0-9a-fA-F]{16}").matches(burst))) return null
val input = ByteArrayOutputStream()
input.write("bitchat-private-media-message-v1".toByteArray(Charsets.UTF_8))
listOf(sender, recipient, fileName).forEach { field ->
val bytes = field.toByteArray(Charsets.UTF_8)
input.write(ByteBuffer.allocate(4).putInt(bytes.size).array())
input.write(bytes)
}
return "media-" + MessageDigest.getInstance("SHA-256").digest(input.toByteArray())
.take(16).joinToString("") { "%02x".format(it) }
}
}

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

@ -0,0 +1,47 @@
package com.bitchat.android.model
import java.io.ByteArrayOutputStream
/**
* Minimal unsigned 16-bit big-endian TLV codec used by bridge wire models.
*
* Semantic validation intentionally remains in each model. This helper only
* owns framing so every decoder rejects truncated and trailing data the same
* way.
*/
internal object Tlv16Codec {
data class Field(val type: Int, val value: ByteArray)
fun encode(vararg fields: Field): ByteArray? {
val output = ByteArrayOutputStream(
fields.sumOf { HEADER_SIZE + it.value.size }
)
fields.forEach { field ->
if (field.type !in 0..0xFF || field.value.size > 0xFFFF) return null
output.write(field.type)
output.write((field.value.size ushr 8) and 0xFF)
output.write(field.value.size and 0xFF)
output.write(field.value)
}
return output.toByteArray()
}
fun decode(data: ByteArray): List<Field>? {
val fields = mutableListOf<Field>()
var offset = 0
while (offset < data.size) {
if (data.size - offset < HEADER_SIZE) return null
val type = data[offset].toInt() and 0xFF
val length =
((data[offset + 1].toInt() and 0xFF) shl 8) or
(data[offset + 2].toInt() and 0xFF)
offset += HEADER_SIZE
if (length > data.size - offset) return null
fields += Field(type, data.copyOfRange(offset, offset + length))
offset += length
}
return fields
}
private const val HEADER_SIZE = 3
}

View File

@ -0,0 +1,193 @@
package com.bitchat.android.model
import java.io.ByteArrayOutputStream
/**
* An iOS-compatible, Ed25519-signed statement that the sender of the enclosing
* authenticated Noise payload verified [voucheeFingerprint].
*/
data class VouchAttestation(
val voucheeFingerprint: ByteArray,
val voucheeSigningKey: ByteArray,
val timestampMs: Long,
val signature: ByteArray
) {
init {
require(voucheeFingerprint.size == FINGERPRINT_SIZE)
require(voucheeSigningKey.size == SIGNING_KEY_SIZE)
require(signature.size == SIGNATURE_SIZE)
}
fun signableBytes(): ByteArray = signableBytes(
voucheeFingerprint,
voucheeSigningKey,
timestampMs
)
fun isExpired(nowMs: Long = System.currentTimeMillis()): Boolean {
val age = nowMs - timestampMs
return age > MAX_AGE_MS || age < -MAX_CLOCK_SKEW_MS
}
fun encode(): ByteArray {
val output = ByteArrayOutputStream()
output.writeTlv(TYPE_FINGERPRINT, voucheeFingerprint)
output.writeTlv(TYPE_SIGNING_KEY, voucheeSigningKey)
output.writeTlv(TYPE_TIMESTAMP, timestampBytes(timestampMs))
output.writeTlv(TYPE_SIGNATURE, signature)
return output.toByteArray()
}
override fun equals(other: Any?): Boolean =
other is VouchAttestation &&
voucheeFingerprint.contentEquals(other.voucheeFingerprint) &&
voucheeSigningKey.contentEquals(other.voucheeSigningKey) &&
timestampMs == other.timestampMs &&
signature.contentEquals(other.signature)
override fun hashCode(): Int {
var result = voucheeFingerprint.contentHashCode()
result = HASH_MULTIPLIER * result + voucheeSigningKey.contentHashCode()
result = HASH_MULTIPLIER * result + timestampMs.hashCode()
return HASH_MULTIPLIER * result + signature.contentHashCode()
}
companion object {
const val MAX_BATCH_COUNT = 16
const val FINGERPRINT_SIZE = 32
const val SIGNING_KEY_SIZE = 32
const val SIGNATURE_SIZE = 64
private const val DAYS_VALID = 30L
private const val HOURS_PER_DAY = 24L
private const val MINUTES_PER_HOUR = 60L
private const val SECONDS_PER_MINUTE = 60L
private const val MILLIS_PER_SECOND = 1000L
const val MAX_AGE_MS =
DAYS_VALID * HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLIS_PER_SECOND
const val MAX_CLOCK_SKEW_MS =
MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLIS_PER_SECOND
private const val SIGNING_CONTEXT = "bitchat-vouch-v1"
private const val TYPE_FINGERPRINT = 0x01
private const val TYPE_SIGNING_KEY = 0x02
private const val TYPE_TIMESTAMP = 0x03
private const val TYPE_SIGNATURE = 0x04
private const val TLV_HEADER_SIZE = 2
private const val TLV_LENGTH_SIZE = 1
private const val BATCH_COUNT_SIZE = 1
private const val BATCH_ENTRY_LENGTH_SIZE = 2
private const val BITS_PER_BYTE = 8
private const val BYTE_MASK = 0xFF
private const val UINT16_MAX = 0xFFFF
private const val INITIAL_OFFSET = 0
private const val INITIAL_TIMESTAMP = 0L
private const val MINIMUM_TIMESTAMP_MS = 0L
private const val INITIAL_ENTRY_COUNT = 0
private const val HASH_MULTIPLIER = 31
private const val TIMESTAMP_LENGTH = Long.SIZE_BYTES
fun build(
voucheeFingerprint: ByteArray,
voucheeSigningKey: ByteArray,
timestampMs: Long = System.currentTimeMillis(),
sign: (ByteArray) -> ByteArray?
): VouchAttestation? {
if (voucheeFingerprint.size != FINGERPRINT_SIZE ||
voucheeSigningKey.size != SIGNING_KEY_SIZE
) return null
val signature = sign(signableBytes(voucheeFingerprint, voucheeSigningKey, timestampMs))
?: return null
if (signature.size != SIGNATURE_SIZE) return null
return VouchAttestation(voucheeFingerprint, voucheeSigningKey, timestampMs, signature)
}
fun signableBytes(
voucheeFingerprint: ByteArray,
voucheeSigningKey: ByteArray,
timestampMs: Long
): ByteArray = SIGNING_CONTEXT.toByteArray(Charsets.UTF_8) +
voucheeFingerprint + voucheeSigningKey + timestampBytes(timestampMs)
fun decode(data: ByteArray): VouchAttestation? {
var offset = INITIAL_OFFSET
var fingerprint: ByteArray? = null
var signingKey: ByteArray? = null
var timestamp: Long? = null
var signature: ByteArray? = null
while (offset < data.size) {
if (offset + TLV_HEADER_SIZE > data.size) return null
val type = data[offset++].toInt() and BYTE_MASK
val length = data[offset++].toInt() and BYTE_MASK
if (offset + length > data.size) return null
val value = data.copyOfRange(offset, offset + length)
offset += length
when (type) {
TYPE_FINGERPRINT -> if (length == FINGERPRINT_SIZE) fingerprint = value else return null
TYPE_SIGNING_KEY -> if (length == SIGNING_KEY_SIZE) signingKey = value else return null
TYPE_TIMESTAMP -> if (length == TIMESTAMP_LENGTH) {
val decodedTimestamp = value.fold(INITIAL_TIMESTAMP) { result, byte ->
(result shl BITS_PER_BYTE) or (byte.toLong() and BYTE_MASK.toLong())
}
if (decodedTimestamp < MINIMUM_TIMESTAMP_MS) return null
timestamp = decodedTimestamp
} else return null
TYPE_SIGNATURE -> if (length == SIGNATURE_SIZE) signature = value else return null
}
}
return VouchAttestation(
fingerprint ?: return null,
signingKey ?: return null,
timestamp ?: return null,
signature ?: return null
)
}
fun encodeList(attestations: List<VouchAttestation>): ByteArray? {
if (attestations.isEmpty() || attestations.size > MAX_BATCH_COUNT) return null
val output = ByteArrayOutputStream()
output.write(attestations.size)
attestations.forEach { attestation ->
val encoded = attestation.encode()
if (encoded.size > UINT16_MAX) return null
output.write(encoded.size ushr BITS_PER_BYTE)
output.write(encoded.size and BYTE_MASK)
output.write(encoded)
}
return output.toByteArray()
}
fun decodeList(data: ByteArray): List<VouchAttestation> {
if (data.size <= BATCH_COUNT_SIZE) return emptyList()
val limit = minOf(data[0].toInt() and BYTE_MASK, MAX_BATCH_COUNT)
val decoded = mutableListOf<VouchAttestation>()
var offset = BATCH_COUNT_SIZE
var entriesRead = INITIAL_ENTRY_COUNT
while (entriesRead < limit && offset < data.size) {
if (offset + BATCH_ENTRY_LENGTH_SIZE > data.size) break
val length = ((data[offset].toInt() and BYTE_MASK) shl BITS_PER_BYTE) or
(data[offset + TLV_LENGTH_SIZE].toInt() and BYTE_MASK)
offset += BATCH_ENTRY_LENGTH_SIZE
if (offset + length > data.size) break
decode(data.copyOfRange(offset, offset + length))?.let(decoded::add)
offset += length
entriesRead++
}
return decoded
}
private const val LAST_BYTE_INDEX_OFFSET = 1
private const val TIMESTAMP_HIGH_BIT_OFFSET =
(TIMESTAMP_LENGTH - LAST_BYTE_INDEX_OFFSET) * BITS_PER_BYTE
private fun timestampBytes(timestampMs: Long): ByteArray =
ByteArray(TIMESTAMP_LENGTH) { index ->
(timestampMs ushr (TIMESTAMP_HIGH_BIT_OFFSET - index * BITS_PER_BYTE)).toByte()
}
private fun ByteArrayOutputStream.writeTlv(type: Int, value: ByteArray) {
write(type)
write(value.size)
write(value)
}
}
}

View File

@ -0,0 +1,121 @@
package com.bitchat.android.noise
import com.bitchat.android.noise.southernstorm.protocol.HandshakeState
import com.bitchat.android.noise.southernstorm.protocol.Noise
/**
* One-message Noise X helper used by iOS-compatible courier envelopes.
*
* Protocol: Noise_X_25519_ChaChaPoly_SHA256
* Prologue: "bitchat-courier-v1"
*/
object CourierNoiseCrypto {
private const val PROTOCOL_NAME = "Noise_X_25519_ChaChaPoly_SHA256"
private val COURIER_PROLOGUE = "bitchat-courier-v1".toByteArray(Charsets.UTF_8)
private val PREKEY_PROLOGUE_PREFIX = "bitchat-prekey-v1".toByteArray(Charsets.UTF_8)
private const val X_OVERHEAD_BYTES = 32 + 48 + 16
data class Opened(val payload: ByteArray, val senderStaticKey: ByteArray)
fun seal(
payload: ByteArray,
senderStaticPrivateKey: ByteArray,
recipientStaticPublicKey: ByteArray
): ByteArray = sealWithPrologue(
payload,
senderStaticPrivateKey,
recipientStaticPublicKey,
COURIER_PROLOGUE
)
fun sealToPrekey(
payload: ByteArray,
senderStaticPrivateKey: ByteArray,
recipientPrekey: com.bitchat.android.model.PrekeyBundle.Prekey
): ByteArray = sealWithPrologue(
payload,
senderStaticPrivateKey,
recipientPrekey.publicKey,
prekeyPrologue(recipientPrekey.id)
)
private fun sealWithPrologue(
payload: ByteArray,
senderStaticPrivateKey: ByteArray,
recipientStaticPublicKey: ByteArray,
prologue: ByteArray
): ByteArray {
require(senderStaticPrivateKey.size == 32)
require(recipientStaticPublicKey.size == 32)
val handshake = HandshakeState(PROTOCOL_NAME, HandshakeState.INITIATOR)
return try {
handshake.setPrologue(prologue, 0, prologue.size)
handshake.getLocalKeyPair().setPrivateKey(senderStaticPrivateKey, 0)
handshake.getRemotePublicKey().setPublicKey(recipientStaticPublicKey, 0)
handshake.start()
val message = ByteArray(payload.size + X_OVERHEAD_BYTES)
val length = handshake.writeMessage(message, 0, payload, 0, payload.size)
message.copyOf(length)
} finally {
handshake.destroy()
}
}
fun open(
ciphertext: ByteArray,
recipientStaticPrivateKey: ByteArray
): Opened = openWithPrologue(ciphertext, recipientStaticPrivateKey, COURIER_PROLOGUE)
fun openWithPrekey(
ciphertext: ByteArray,
recipientPrekeyPrivateKey: ByteArray,
prekeyId: Long
): Opened = openWithPrologue(
ciphertext,
recipientPrekeyPrivateKey,
prekeyPrologue(prekeyId)
)
private fun openWithPrologue(
ciphertext: ByteArray,
recipientStaticPrivateKey: ByteArray,
prologue: ByteArray
): Opened {
require(recipientStaticPrivateKey.size == 32)
val handshake = HandshakeState(PROTOCOL_NAME, HandshakeState.RESPONDER)
return try {
handshake.setPrologue(prologue, 0, prologue.size)
handshake.getLocalKeyPair().setPrivateKey(recipientStaticPrivateKey, 0)
handshake.start()
val payload = ByteArray(ciphertext.size)
val length = handshake.readMessage(ciphertext, 0, ciphertext.size, payload, 0)
val senderKey = ByteArray(handshake.getRemotePublicKey().publicKeyLength)
handshake.getRemotePublicKey().getPublicKey(senderKey, 0)
Opened(payload.copyOf(length), senderKey)
} finally {
handshake.destroy()
}
}
/** Test/support helper that derives the X25519 public key used on the wire. */
fun publicKey(privateKey: ByteArray): ByteArray {
require(privateKey.size == 32)
val key = Noise.createDH("25519")
return try {
key.setPrivateKey(privateKey, 0)
ByteArray(key.publicKeyLength).also { key.getPublicKey(it, 0) }
} finally {
key.destroy()
}
}
private fun prekeyPrologue(prekeyId: Long): ByteArray {
require(prekeyId in 0..0xFFFF_FFFFL)
return PREKEY_PROLOGUE_PREFIX + byteArrayOf(
(prekeyId ushr 24).toByte(),
(prekeyId ushr 16).toByte(),
(prekeyId ushr 8).toByte(),
prekeyId.toByte()
)
}
}

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,253 @@
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
fun sealWithPrekey(payload: ByteArray, recipientNoiseKey: ByteArray): Pair<ByteArray, UInt?> =
seal(payload, recipientNoiseKey) to null
}
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,
publicationAllowed = com.bitchat.android.services.bridge.MeshBridgeService.publicationPermit())
}
private class EncryptionBridgeCourierCipher(
private val encryptionService: EncryptionService,
private val prekeys: com.bitchat.android.services.bridge.PrekeyManager
) : BridgeCourierCipher {
override fun staticPublicKey(): ByteArray? = encryptionService.getStaticPublicKey()
override fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray =
encryptionService.sealCourierPayload(payload, recipientNoiseKey)
override fun sealWithPrekey(payload: ByteArray, recipientNoiseKey: ByteArray): Pair<ByteArray, UInt?> {
val id = java.security.MessageDigest.getInstance("SHA-256").digest(payload)
.joinToString("") { "%02x".format(it) }
val sealed = prekeys.seal(payload, id, recipientNoiseKey, recipientAdvertisesPrekeys = true)
return sealed.ciphertext to sealed.prekeyId?.toUInt()
}
}
/** 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,
private val enabled: () -> Boolean = { true }
) {
constructor(
context: Context,
encryptionService: EncryptionService,
onEnvelope: (CourierEnvelope) -> Unit
) : this(
cipher = EncryptionBridgeCourierCipher(encryptionService, com.bitchat.android.services.bridge.PrekeyManager.getInstance(context)),
onEnvelope = onEnvelope,
relayManager = NostrBridgeCourierRelay(
NostrRelayManager.getInstance(context.applicationContext)
),
enabled = { com.bitchat.android.services.bridge.MeshBridgeService.isEnabled.value }
)
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() {
if (!enabled()) return
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)
.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 {
if (!enabled()) return false
start()
if (!relayManager.hasConnectedRelay(relayUrls)) return false
val sealed = try { cipher.sealWithPrekey(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.first,
prekeyID = sealed.second,
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)
)
)
if (!enabled()) return false
return relayManager.sendEvent(event, relayUrls, onAccepted)
}
@Synchronized
fun stop() {
relayManager.unsubscribe(subscriptionID)
subscribedDay = null
refreshJob?.cancel()
refreshJob = null
}
private fun handleEvent(event: NostrEvent) {
if (!enabled()) return
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

@ -0,0 +1,14 @@
package com.bitchat.android.nostr
import java.net.URI
object CustomRelayUrl {
fun normalize(input: String): String? = runCatching {
val uri = URI(input.trim())
require(uri.scheme.equals("wss", ignoreCase = true))
require(!uri.host.isNullOrBlank() && uri.userInfo == null && uri.fragment == null && uri.query == null)
require(uri.port == -1 || uri.port in 1..65535)
URI("wss", null, uri.host.lowercase(), uri.port,
uri.path?.takeUnless { it == "/" }, null, null).toASCIIString()
}.getOrNull()
}

View File

@ -38,7 +38,10 @@ class LocationNotesManager private constructor() {
val pubkey: String,
val content: String,
val createdAt: Int,
val nickname: String?
val nickname: String?,
val geohash: String,
val expiresAt: Int? = null,
val isUrgent: Boolean = false
) {
/**
* Display name for the note - matches iOS exactly
@ -97,6 +100,15 @@ class LocationNotesManager private constructor() {
// Coroutine scope for background operations
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var liveLocationToken: Long? = null
init {
scope.launch {
while (isActive) {
delay(60_000)
pruneExpiredNotes()
}
}
}
private var subscribeRetryJob: Job? = null
private var initialLoadJob: Job? = null
@ -139,7 +151,7 @@ class LocationNotesManager private constructor() {
}
// Validate geohash (building-level precision: 8 chars) - matches iOS
if (!isValidBuildingGeohash(normalized)) {
if (normalized.length != 8 || !isValidGeohash(normalized)) {
Log.w(TAG, "LocationNotesManager rejected an invalid building geohash")
return
}
@ -172,8 +184,8 @@ class LocationNotesManager private constructor() {
/**
* Validate building-level geohash (precision 8) - matches iOS Geohash.isValidBuildingGeohash
*/
private fun isValidBuildingGeohash(geohash: String): Boolean {
if (geohash.length != 8) return false
private fun isValidGeohash(geohash: String): Boolean {
if (geohash.length !in 1..12) return false
val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz"
return geohash.all { it in base32Chars }
}
@ -210,7 +222,7 @@ class LocationNotesManager private constructor() {
/**
* Send a new location note
*/
fun send(content: String, nickname: String?) {
fun send(content: String, nickname: String?, expiresAt: Int? = null, urgent: Boolean = false) {
val token = LiveLocationPrivacyGate.captureToken() ?: run {
stop()
return
@ -275,7 +287,9 @@ class LocationNotesManager private constructor() {
content = trimmed,
geohash = currentGeohash,
senderIdentity = preparedIdentity,
nickname = nickname
nickname = nickname,
expiresAt = expiresAt,
urgent = urgent
)
}
if (!LiveLocationPrivacyGate.accepts(token)) return@launch
@ -286,7 +300,10 @@ class LocationNotesManager private constructor() {
pubkey = preparedEvent.pubkey,
content = trimmed,
createdAt = preparedEvent.createdAt,
nickname = nickname
nickname = nickname,
geohash = currentGeohash,
expiresAt = expiresAt,
isUrgent = urgent
)
if (!noteIDs.contains(preparedEvent.id)) {
@ -301,7 +318,7 @@ class LocationNotesManager private constructor() {
}
// CRITICAL FIX: Send to geo-specific relays (matching iOS pattern)
// iOS: dependencies.sendEvent(event, relays)
// iOS: dependencies.sendEvent(event, relays, token)
val sent = withContext(Dispatchers.IO) {
LiveLocationPrivacyGate.runIfAllowed(token) {
sendEventFunc?.invoke(preparedEvent, relays, token)
@ -443,6 +460,16 @@ class LocationNotesManager private constructor() {
// Extract nickname from tags
val nicknameTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "n" }
val nickname = nicknameTag?.get(1)
val expiresAt = event.tags
.firstOrNull { it.size >= 2 && it[0].equals("expiration", ignoreCase = true) }
?.get(1)
?.toIntOrNull()
if (expiresAt != null && expiresAt <= currentEpochSeconds()) return
val urgent = event.tags.any {
it.size >= 2 &&
it[0].equals("t", ignoreCase = true) &&
it[1].equals("urgent", ignoreCase = true)
}
// Create note
val note = Note(
@ -450,7 +477,10 @@ class LocationNotesManager private constructor() {
pubkey = event.pubkey,
content = event.content,
createdAt = event.createdAt,
nickname = nickname
nickname = nickname,
geohash = eventGeohash.lowercase(),
expiresAt = expiresAt,
isUrgent = urgent
)
// Add to collection
@ -493,6 +523,89 @@ class LocationNotesManager private constructor() {
fun clearError() {
_errorMessage.value = null
}
fun isOwnNote(note: Note): Boolean {
val current = _geohash.value ?: return false
val deriveIdentity = deriveIdentityFunc ?: return false
return runCatching { deriveIdentity(current).publicKeyHex == note.pubkey }.getOrDefault(false)
}
fun delete(note: Note): Boolean {
if (!isOwnNote(note)) return false
return deleteEvent(note.id, note.geohash) {
_notes.value = _notes.value.filterNot { it.id == note.id }
}
}
/**
* Publishes the relay copy of a geohash board post without changing the
* active notes subscription. The callback lets BoardManager retract the
* copy with NIP-09 while the app remains alive.
*/
fun publishBoardBridge(
content: String,
geohash: String,
nickname: String,
expiresAtSeconds: Int,
urgent: Boolean,
onPublished: (String) -> Unit
) {
val token = LiveLocationPrivacyGate.captureToken() ?: return
val deriveIdentity = deriveIdentityFunc ?: return
val sendEvent = sendEventFunc ?: return
val relays = runCatching {
RelayDirectory.closestRelaysForGeohash(geohash, 5)
}.getOrDefault(emptyList())
if (relays.isEmpty()) return
scope.launch {
runCatching {
val identity = withContext(Dispatchers.IO) { deriveIdentity(geohash) }
val event = NostrProtocol.createGeohashTextNote(
content = content,
geohash = geohash,
senderIdentity = identity,
nickname = nickname,
expiresAt = expiresAtSeconds,
urgent = urgent
)
withContext(Dispatchers.IO) { sendEvent(event, relays, token) }
onPublished(event.id)
}.onFailure {
Log.e(TAG, "Failed to bridge board post to Nostr: ${it.message}")
}
}
}
fun deleteEvent(eventID: String, geohash: String, onDeleted: () -> Unit = {}): Boolean {
val token = LiveLocationPrivacyGate.captureToken() ?: return false
val deriveIdentity = deriveIdentityFunc ?: return false
val sendEvent = sendEventFunc ?: return false
val relays = runCatching {
RelayDirectory.closestRelaysForGeohash(geohash, 5)
}.getOrDefault(emptyList())
if (relays.isEmpty()) return false
scope.launch {
runCatching {
val identity = withContext(Dispatchers.IO) { deriveIdentity(geohash) }
val deletion = NostrProtocol.createDeleteEvent(eventID, identity)
withContext(Dispatchers.IO) { sendEvent(deletion, relays, token) }
onDeleted()
}.onFailure {
Log.e(TAG, "Failed to delete Nostr notice: ${it.message}")
}
}
return true
}
fun pruneExpiredNotes() {
val now = currentEpochSeconds()
_notes.value = _notes.value.filter { note ->
note.expiresAt?.let { it > now } ?: true
}
}
private fun currentEpochSeconds(): Int =
(System.currentTimeMillis() / 1_000L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
/**
* Cancel subscription and clear state

View File

@ -0,0 +1,14 @@
package com.bitchat.android.nostr
import java.security.MessageDigest
/** Cross-platform stable identity for a public mesh radio/bridge copy. */
object MeshMessageIdentity {
fun stableId(senderIdHex: String, timestampMs: Long, content: String): String {
val input = "${senderIdHex.lowercase()}|$timestampMs|${content.trim()}"
return MessageDigest.getInstance("SHA-256")
.digest(input.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
.take(32)
}
}

View File

@ -92,6 +92,13 @@ object NostrBackgroundRuntime {
)
}
fun receiveGatewayEvent(event: NostrEvent, geohash: String) {
if (!initialized || activeGeohash != geohash) return
val selected = (locationChannels.selectedChannel.value as? ChannelID.Location)?.channel ?: return
if (selected.geohash != geohash || !locationChannels.canUseSelectedLocationChannel(selected)) return
eventProcessor.onGeohashMessage(event, geohash)
}
private fun subscribeAccountDm() {
val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) ?: return
subscriptions.subscribeGiftWraps(
@ -147,7 +154,10 @@ object NostrBackgroundRuntime {
sinceMs = System.currentTimeMillis() - 3_600_000L,
limit = 200,
id = "geohash-$geohash",
handler = { event -> eventProcessor.onGeohashMessage(event, geohash) },
handler = { event ->
eventProcessor.onGeohashMessage(event, geohash)
com.bitchat.android.services.bridge.MeshGatewayService.rebroadcastRelayEvent(event, geohash, liveLocationToken)
},
liveLocationToken = liveLocationToken
)
subscribeGeohashDm(geohash, "geo-dm-$geohash", liveLocationToken)

View File

@ -241,10 +241,8 @@ class NostrClient private constructor(private val context: Context) {
giftWrap: NostrEvent,
handler: (content: String, senderNpub: String, timestamp: Int) -> Unit
) {
// Age filtering (24h + 15min buffer for randomized timestamps)
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
if (messageAge > 173700) { // 48 hours + 15 minutes
Log.v(TAG, "Ignoring old private message")
if (!NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(giftWrap.createdAt)) {
Log.v(TAG, "Ignoring private message with implausible gift-wrap created_at")
return
}
@ -254,6 +252,10 @@ class NostrClient private constructor(private val context: Context) {
val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity)
if (decryptResult != null) {
val (content, senderPubkey, timestamp) = decryptResult
if (!NostrTimestampPolicy.isPlausibleRumorTimestamp(timestamp)) {
Log.w(TAG, "Dropping private message with implausible rumor timestamp")
return
}
// Convert sender pubkey to npub
val senderNpub = try {

View File

@ -60,8 +60,10 @@ class NostrDirectMessageHandler(
try {
if (dedupe(giftWrap.id)) return@launch
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
if (messageAge > 173700) return@launch // 48 hours + 15 mins
if (!NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(giftWrap.createdAt)) {
Log.v(TAG, "Ignoring gift wrap with implausible created_at")
return@launch
}
val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity)
if (decryptResult == null) {
@ -70,6 +72,10 @@ class NostrDirectMessageHandler(
}
val (content, rawSenderPubkey, rumorTimestamp) = decryptResult
if (!NostrTimestampPolicy.isPlausibleRumorTimestamp(rumorTimestamp)) {
Log.w(TAG, "Dropping Nostr DM with implausible rumor timestamp")
return@launch
}
val senderPubkey = rawSenderPubkey.lowercase()
// If sender is blocked for geohash contexts, drop any events from this pubkey
@ -188,6 +194,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 +204,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,
@ -243,6 +251,9 @@ class NostrDirectMessageHandler(
NoisePayloadType.VERIFY_CHALLENGE,
NoisePayloadType.VERIFY_RESPONSE,
NoisePayloadType.VOICE_FRAME,
NoisePayloadType.GROUP_INVITE,
NoisePayloadType.GROUP_KEY_UPDATE,
NoisePayloadType.VOUCH,
NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation.
}
}

View File

@ -210,10 +210,12 @@ data class NostrEvent(
object NostrKind {
const val METADATA = 0
const val TEXT_NOTE = 1
const val DELETION = 5
const val DIRECT_MESSAGE = 14 // NIP-17 direct message (unsigned)
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

@ -69,6 +69,28 @@ data class NostrFilter(
limit = limit
)
}
fun bridgeRendezvous(
cells: Collection<String>,
since: Long? = null,
limit: Int = 200
): NostrFilter = NostrFilter(
kinds = listOf(NostrKind.EPHEMERAL_EVENT, NostrKind.GEOHASH_PRESENCE),
since = since?.let { (it / 1000).toInt() },
tagFilters = mapOf("r" to cells.toList()),
limit = limit
)
fun courierDrops(
recipientTagsHex: Collection<String>,
since: Long? = null,
limit: Int = 100
): NostrFilter = NostrFilter(
kinds = listOf(NostrKind.COURIER_DROP),
since = since?.let { (it / 1000).toInt() },
tagFilters = mapOf("x" to recipientTagsHex.toList()),
limit = limit
)
/**
* Create filter for text notes from specific authors

View File

@ -176,6 +176,39 @@ object NostrIdentityBridge {
Log.d(TAG, "Used fallback geohash identity derivation")
return fallbackIdentity
}
/**
* Derive the iOS-compatible bridge rendezvous identity. The domain label
* prevents linking it to geohash-chat identity and the iteration is
* encoded big-endian, matching CryptoKit's UInt32.bigEndian bytes.
*/
fun deriveBridgeIdentity(cell: String, context: Context): NostrIdentity {
val label = "bridge|$cell"
geohashIdentityCache[label]?.let { return it }
val stateManager = SecureIdentityStateManager(context)
val seed = getOrCreateDeviceSeed(stateManager)
val message = label.toByteArray(Charsets.UTF_8)
for (iteration in 0 until 10) {
val input = message + byteArrayOf(
(iteration ushr 24).toByte(),
(iteration ushr 16).toByte(),
(iteration ushr 8).toByte(),
iteration.toByte()
)
val candidate = hmacSha256(seed, input).toHexStringLocal()
if (NostrCrypto.isValidPrivateKey(candidate)) {
return NostrIdentity.fromPrivateKey(candidate).also {
geohashIdentityCache[label] = it
}
}
}
val fallback = MessageDigest.getInstance("SHA-256").digest(seed + message)
return NostrIdentity.fromPrivateKey(fallback.toHexStringLocal()).also {
geohashIdentityCache[label] = it
}
}
/**
* Generate candidate key for a specific iteration (matches iOS implementation)

View File

@ -16,14 +16,16 @@ internal class NostrPendingEventQueue(
data class Delivery(
val queueId: Long,
val event: NostrEvent,
val liveLocationToken: Long?
val liveLocationToken: Long?,
val publicationAllowed: () -> Boolean
)
private data class Entry(
val queueId: Long,
val event: NostrEvent,
val pendingRelayUrls: MutableSet<String>,
val liveLocationToken: Long?
val liveLocationToken: Long?,
val publicationAllowed: () -> Boolean
)
private val lock = Any()
@ -33,7 +35,8 @@ internal class NostrPendingEventQueue(
fun enqueue(
event: NostrEvent,
relayUrls: Collection<String>,
liveLocationToken: Long?
liveLocationToken: Long?,
publicationAllowed: () -> Boolean = { true }
): Long? {
val pendingRelays = relayUrls.filterTo(linkedSetOf()) { it.isNotBlank() }
if (pendingRelays.isEmpty()) return null
@ -46,7 +49,8 @@ internal class NostrPendingEventQueue(
queueId = queueId,
event = event,
pendingRelayUrls = pendingRelays,
liveLocationToken = liveLocationToken
liveLocationToken = liveLocationToken,
publicationAllowed = publicationAllowed
)
)
queueId
@ -54,10 +58,11 @@ internal class NostrPendingEventQueue(
}
fun pendingForRelay(relayUrl: String): List<Delivery> = synchronized(lock) {
entries.removeAll { !it.publicationAllowed() }
entries
.asSequence()
.filter { relayUrl in it.pendingRelayUrls }
.map { Delivery(it.queueId, it.event, it.liveLocationToken) }
.map { Delivery(it.queueId, it.event, it.liveLocationToken, it.publicationAllowed) }
.toList()
}
@ -74,6 +79,11 @@ internal class NostrPendingEventQueue(
}
}
fun removeRelay(relayUrl: String) = synchronized(lock) {
entries.forEach { it.pendingRelayUrls.remove(relayUrl) }
entries.removeAll { it.pendingRelayUrls.isEmpty() }
}
fun removeLiveLocationEvents() {
synchronized(lock) {
entries.removeAll { it.liveLocationToken != null }

View File

@ -65,6 +65,12 @@ object NostrProtocol {
Log.v(TAG, "Starting decryption of gift wrap: ${giftWrap.id.take(16)}...")
return try {
val recipientTags = listOf(listOf("p", recipientIdentity.publicKeyHex))
if (giftWrap.content.toByteArray(Charsets.UTF_8).size > 64 * 1024 ||
giftWrap.kind != NostrKind.GIFT_WRAP || giftWrap.tags != recipientTags ||
!NostrTimestampPolicy.isAcceptableGiftWrapTimestamp(giftWrap.createdAt) ||
!giftWrap.isValidSignature()
) return null
// 1. Unwrap the gift wrap
val seal = unwrapGiftWrap(giftWrap, recipientIdentity.privateKeyHex)
?: run {
@ -74,7 +80,7 @@ object NostrProtocol {
Log.v(TAG, "Successfully unwrapped gift wrap from: ${seal.pubkey.take(16)}...")
if (seal.kind != NostrKind.SEAL || !seal.isValidSignature()) {
if (seal.kind != NostrKind.SEAL || seal.tags.isNotEmpty() || !seal.isValidSignature()) {
Log.w(TAG, "❌ Invalid NIP-17 seal signature")
return null
}
@ -86,7 +92,10 @@ object NostrProtocol {
return null
}
if (seal.pubkey != rumor.pubkey) {
if (seal.pubkey != rumor.pubkey || rumor.kind != NostrKind.DIRECT_MESSAGE ||
(rumor.tags.isNotEmpty() && rumor.tags != recipientTags) || rumor.sig != null ||
!NostrTimestampPolicy.isPlausibleRumorTimestamp(rumor.createdAt)
) {
Log.w(TAG, "❌ NIP-17 seal pubkey does not match rumor pubkey")
return null
}
@ -108,7 +117,9 @@ object NostrProtocol {
content: String,
geohash: String,
senderIdentity: NostrIdentity,
nickname: String? = null
nickname: String? = null,
expiresAt: Int? = null,
urgent: Boolean = false
): NostrEvent = withContext(Dispatchers.Default) {
val tags = mutableListOf<List<String>>()
tags.add(listOf("g", geohash))
@ -116,6 +127,12 @@ object NostrProtocol {
if (!nickname.isNullOrEmpty()) {
tags.add(listOf("n", nickname))
}
expiresAt?.let {
tags.add(listOf("expiration", it.toString()))
}
if (urgent) {
tags.add(listOf("t", "urgent"))
}
val event = NostrEvent(
pubkey = senderIdentity.publicKeyHex,
@ -128,6 +145,21 @@ object NostrProtocol {
return@withContext senderIdentity.signEvent(event)
}
/** Create a NIP-09 deletion request signed by the original event identity. */
suspend fun createDeleteEvent(
eventID: String,
senderIdentity: NostrIdentity
): NostrEvent = withContext(Dispatchers.Default) {
val event = NostrEvent(
pubkey = senderIdentity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.DELETION,
tags = listOf(listOf("e", eventID)),
content = ""
)
senderIdentity.signEvent(event)
}
/**
* Create a geohash-scoped presence event (kind 20001)
* Has no content and no nickname, used for participant counting
@ -212,6 +244,73 @@ object NostrProtocol {
return@withContext senderIdentity.signEvent(event)
}
/** iOS-compatible public mesh event on a bridge rendezvous cell. */
fun createBridgeMeshEvent(
content: String,
cell: String,
senderIdentity: NostrIdentity,
nickname: String? = null,
meshSenderId: String? = null,
meshTimestampMs: Long? = null
): NostrEvent {
val tags = mutableListOf<List<String>>(listOf("r", cell))
nickname?.trim()?.takeIf { it.isNotEmpty() }?.let { tags += listOf("n", it) }
val sender = meshSenderId?.trim()?.takeIf { it.isNotEmpty() }
if (sender != null && meshTimestampMs != null) {
tags += listOf(
"m",
MeshMessageIdentity.stableId(sender, meshTimestampMs, content),
sender,
meshTimestampMs.toString()
)
}
return senderIdentity.signEvent(
NostrEvent(
pubkey = senderIdentity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.EPHEMERAL_EVENT,
tags = tags,
content = content
)
)
}
/** Empty bridge-presence heartbeat, deliberately separate from `#g` chat. */
fun createBridgePresenceEvent(
cell: String,
senderIdentity: NostrIdentity
): NostrEvent = senderIdentity.signEvent(
NostrEvent(
pubkey = senderIdentity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.GEOHASH_PRESENCE,
tags = listOf(listOf("r", cell)),
content = ""
)
)
/**
* Opaque relay drop. Callers use a throwaway identity so deposits cannot
* be linked by their Nostr publisher key.
*/
fun createCourierDropEvent(
envelope: ByteArray,
recipientTagHex: String,
expiresAtMs: Long,
senderIdentity: NostrIdentity
): NostrEvent = senderIdentity.signEvent(
NostrEvent(
pubkey = senderIdentity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.COURIER_DROP,
tags = listOf(
listOf("x", recipientTagHex),
listOf("expiration", (expiresAtMs / 1000).toString())
),
content = android.util.Base64.encodeToString(envelope, android.util.Base64.NO_WRAP)
)
)
// MARK: - Private Methods

View File

@ -35,6 +35,7 @@ class NostrRelayManager private constructor() {
*/
fun getInstance(context: android.content.Context): NostrRelayManager {
shared.appContext = context.applicationContext
shared.loadCustomRelays(context.applicationContext)
return shared
}
@ -55,7 +56,7 @@ class NostrRelayManager private constructor() {
pendingGiftWrapIDs.add(id)
}
fun defaultRelays(): List<String> = DEFAULT_RELAYS
fun defaultRelays(): List<String> = (DEFAULT_RELAYS + shared.customRelays.value).distinct()
}
/**
@ -73,12 +74,82 @@ class NostrRelayManager private constructor() {
var nextReconnectTime: Long? = null
)
private val _customRelays = MutableStateFlow<List<String>>(emptyList())
val customRelays: StateFlow<List<String>> = _customRelays.asStateFlow()
private var customRelaysLoaded = false
private val removedCustomRelayUrls = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
@Synchronized
private fun loadCustomRelays(context: android.content.Context) {
if (customRelaysLoaded) return
customRelaysLoaded = true
val urls = context.getSharedPreferences("nostr_custom_relays", android.content.Context.MODE_PRIVATE)
.getStringSet("urls", emptySet()).orEmpty().mapNotNull(CustomRelayUrl::normalize).distinct().take(20)
_customRelays.value = urls
nonLiveRelayUrls.addAll(urls)
ensureConnectionsFor(urls.toSet())
}
@Synchronized
fun addCustomRelay(input: String): Boolean {
val url = CustomRelayUrl.normalize(input) ?: return false
if (url in defaultRelays()) return true
if (_customRelays.value.size >= 20) return false
val previousDefaults = defaultRelays().toSet()
removedCustomRelayUrls.remove(url)
_customRelays.value = _customRelays.value + url
persistCustomRelays()
nonLiveRelayUrls.add(url)
activeSubscriptions.replaceAll { _, subscription ->
if (subscription.targetRelayUrls == previousDefaults) {
subscription.copy(targetRelayUrls = previousDefaults + url)
} else subscription
}
ensureConnectionsFor(setOf(url))
return true
}
@Synchronized
fun removeCustomRelay(url: String) {
if (url !in _customRelays.value) return
removedCustomRelayUrls.add(url)
messageQueue.removeRelay(url)
_customRelays.value = _customRelays.value - url
persistCustomRelays()
activeSubscriptions.replaceAll { _, subscription ->
subscription.copy(targetRelayUrls = subscription.targetRelayUrls?.minus(url))
}
reconnectJobs.remove(url)?.cancel()
connections.remove(url)?.close(1000, "Relay removed")
subscriptions.remove(url)
nonLiveRelayUrls.remove(url)
synchronized(relaysList) { relaysList.removeAll { it.url == url } }
updateRelaysList()
updateConnectionStatus()
}
fun clearCustomRelays() {
_customRelays.value.toList().forEach(::removeCustomRelay)
check(appContext?.getSharedPreferences("nostr_custom_relays", android.content.Context.MODE_PRIVATE)
?.edit()?.clear()?.commit() != false)
}
private fun persistCustomRelays() {
appContext?.getSharedPreferences("nostr_custom_relays", android.content.Context.MODE_PRIVATE)
?.edit()?.putStringSet("urls", _customRelays.value.toSet())?.apply()
}
// Published state
private val _relays = MutableStateFlow<List<Relay>>(emptyList())
val relays: StateFlow<List<Relay>> = _relays.asStateFlow()
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>()
@ -226,23 +297,24 @@ class NostrRelayManager private constructor() {
geohash: String,
includeDefaults: Boolean = false,
nRelays: Int = 5,
liveLocationToken: Long? = null
liveLocationToken: Long? = null,
publicationAllowed: () -> Boolean = { true }
) {
if (!isNetworkActionAllowed(liveLocationToken)) return
if (!publicationAllowed() || !isNetworkActionAllowed(liveLocationToken)) return
ensureGeohashRelaysConnected(
geohash,
nRelays,
includeDefaults,
liveLocationToken
)
if (!isNetworkActionAllowed(liveLocationToken)) return
if (!publicationAllowed() || !isNetworkActionAllowed(liveLocationToken)) return
val relayUrls = getRelaysForGeohash(geohash)
if (relayUrls.isEmpty()) {
Log.w(TAG, "No target relays for geohash event; falling back to defaults")
sendEvent(event, Companion.defaultRelays(), liveLocationToken)
sendEvent(event, Companion.defaultRelays(), liveLocationToken, publicationAllowed = publicationAllowed)
return
}
sendEvent(event, relayUrls, liveLocationToken)
sendEvent(event, relayUrls, liveLocationToken, publicationAllowed = publicationAllowed)
}
// --- Internal helpers ---
@ -446,32 +518,49 @@ class NostrRelayManager private constructor() {
fun sendEvent(
event: NostrEvent,
relayUrls: List<String>? = null,
liveLocationToken: Long? = null
) {
liveLocationToken: Long? = null,
onAccepted: (() -> Unit)? = null,
publicationAllowed: () -> Boolean = { true }
): Boolean {
if (!publicationAllowed()) return false
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(
event = event,
relayUrls = targetRelays,
liveLocationToken = liveLocationToken
liveLocationToken = liveLocationToken,
publicationAllowed = publicationAllowed
) ?: 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 ->
val webSocket = connections[relayUrl]
if (webSocket != null) {
if (sendToRelay(event, webSocket, relayUrl, liveLocationToken)) {
if (sendToRelay(event, webSocket, relayUrl, liveLocationToken, publicationAllowed)) {
messageQueue.markDelivered(queueId, relayUrl)
}
}
}
}
}
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 +756,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) {
@ -838,7 +928,7 @@ class NostrRelayManager private constructor() {
urlString: String,
liveLocationToken: Long? = null
) {
if (!desiredConnected.get()) return
if (!desiredConnected.get() || urlString in removedCustomRelayUrls) return
val connectionToken = liveLocationToken
?.takeIf { urlString !in nonLiveRelayUrls }
if (!isNetworkActionAllowed(connectionToken)) return
@ -853,6 +943,7 @@ class NostrRelayManager private constructor() {
.build()
val started = runNetworkAction(connectionToken) {
if (urlString in removedCustomRelayUrls) return@runNetworkAction
val webSocket = httpClient.newWebSocket(
request,
RelayWebSocketListener(urlString, connectionToken)
@ -860,7 +951,7 @@ class NostrRelayManager private constructor() {
val existing = connections.putIfAbsent(urlString, webSocket)
when {
existing != null -> webSocket.close(1000, "Duplicate connection")
!desiredConnected.get() -> {
!desiredConnected.get() || urlString in removedCustomRelayUrls -> {
connections.remove(urlString, webSocket)
webSocket.close(1000, "Connection no longer desired")
}
@ -878,16 +969,17 @@ class NostrRelayManager private constructor() {
event: NostrEvent,
webSocket: WebSocket,
relayUrl: String,
liveLocationToken: Long? = null
liveLocationToken: Long? = null,
publicationAllowed: () -> Boolean = { true }
): Boolean {
if (!isNetworkActionAllowed(liveLocationToken)) return false
if (!publicationAllowed() || relayUrl in removedCustomRelayUrls || !isNetworkActionAllowed(liveLocationToken)) return false
return try {
val request = NostrRequest.Event(event)
val message = gson.toJson(request, NostrRequest::class.java)
var success = false
runNetworkAction(liveLocationToken) {
success = webSocket.send(message)
if (publicationAllowed() && relayUrl !in removedCustomRelayUrls) success = webSocket.send(message)
}
if (success) {
// Update relay stats
@ -960,8 +1052,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()
}
}
@ -1118,7 +1216,7 @@ class NostrRelayManager private constructor() {
) : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
if (!desiredConnected.get() ||
if (!desiredConnected.get() || relayUrl in removedCustomRelayUrls ||
connections[relayUrl] !== webSocket ||
!isNetworkActionAllowed(liveLocationToken)
) {
@ -1140,7 +1238,8 @@ class NostrRelayManager private constructor() {
delivery.event,
webSocket,
relayUrl,
delivery.liveLocationToken
delivery.liveLocationToken,
delivery.publicationAllowed
)
) {
messageQueue.markDelivered(delivery.queueId, relayUrl)

View File

@ -0,0 +1,42 @@
package com.bitchat.android.nostr
import com.bitchat.android.util.AppConstants
/**
* Client-side timestamp windows for inbound Nostr DMs.
*
* Mirrors iOS `NostrInboundPipeline.isPlausibleRumorTimestamp`: a relay that
* ignores the subscription `since` filter — or replays archived events — must
* not inject stale or future-dated DMs. The inner rumor timestamp is the
* sender's true send time; only the outer gift wrap is NIP-17-randomized.
*/
object NostrTimestampPolicy {
/**
* Accept an inner rumor `created_at` inside
* `[now − lookback − skew, now + skew]`.
*/
fun isPlausibleRumorTimestamp(
tsSeconds: Int,
nowSeconds: Long = System.currentTimeMillis() / 1000L
): Boolean {
val age = nowSeconds - tsSeconds.toLong()
val skew = AppConstants.Nostr.DM_MAX_CLOCK_SKEW_SECONDS
val lookback = AppConstants.Nostr.DM_SUBSCRIBE_LOOKBACK_SECONDS
return age >= -skew && age <= lookback + skew
}
/**
* Accept an outer gift-wrap `created_at` that is not in the far future and
* not older than the NIP-17 randomization ceiling plus skew.
*/
fun isAcceptableGiftWrapTimestamp(
createdAtSeconds: Int,
nowSeconds: Long = System.currentTimeMillis() / 1000L
): Boolean {
val age = nowSeconds - createdAtSeconds.toLong()
val skew = AppConstants.Nostr.DM_MAX_CLOCK_SKEW_SECONDS
val maxAge = AppConstants.Nostr.DM_GIFT_WRAP_MAX_AGE_SECONDS
return age >= -skew && age <= maxAge
}
}

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

@ -14,12 +14,19 @@ 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
REQUEST_SYNC(0x21u), // GCS-based sync request
FILE_TRANSFER(0x22u), // New: File transfer packet (BLE voice notes, etc.)
VOICE_FRAME(0x29u); // Ephemeral live push-to-talk frame; never added to gossip sync
VOICE_FRAME(0x29u), // Ephemeral live push-to-talk frame; never added to gossip sync
BOARD_POST(0x23u),
GROUP_MESSAGE(0x25u), // Opaque private-group ciphertext broadcast
PREKEY_BUNDLE(0x24u), // Signed batch of one-time courier prekeys
NOSTR_CARRIER(0x28u), // Signed bridge/gateway event carrier
PING(MeshDiagnosticsConstants.PING_TYPE),
PONG(MeshDiagnosticsConstants.PONG_TYPE);
companion object {
fun fromValue(value: UByte): MessageType? {

View File

@ -0,0 +1,15 @@
package com.bitchat.android.protocol
object MeshDiagnosticsConstants {
val PING_TYPE: UByte = 0x26u
val PONG_TYPE: UByte = 0x27u
val TTL: UByte = 7u
const val NONCE_SIZE = 8
const val PAYLOAD_SIZE = NONCE_SIZE + 1
const val TIMEOUT_MILLIS = 10_000L
const val INBOUND_RATE_LIMIT = 5
const val INBOUND_RATE_WINDOW_MILLIS = 10_000L
const val RELAY_JITTER_MIN_MILLIS = 20L
const val RELAY_JITTER_MAX_MILLIS = 60L
const val CAPABILITY_BIT = 6
}

View File

@ -0,0 +1,46 @@
package com.bitchat.android.protocol
import java.security.SecureRandom
/**
* iOS-compatible mesh diagnostics payload.
*
* Wire format: 8-byte nonce followed by the TTL used to launch the ping.
* Decoding deliberately accepts trailing bytes for forward compatibility.
*/
data class MeshPingPayload(
val nonce: ByteArray,
val originTtl: UByte,
) {
init {
require(nonce.size == MeshDiagnosticsConstants.NONCE_SIZE) {
"Mesh ping nonce must be ${MeshDiagnosticsConstants.NONCE_SIZE} bytes"
}
}
fun encode(): ByteArray = nonce + byteArrayOf(originTtl.toByte())
fun hopCount(receivedTtl: UByte): Int =
(originTtl.toInt() - receivedTtl.toInt() + 1).coerceAtLeast(1)
override fun equals(other: Any?): Boolean =
other is MeshPingPayload && nonce.contentEquals(other.nonce) && originTtl == other.originTtl
override fun hashCode(): Int = 31 * nonce.contentHashCode() + originTtl.hashCode()
companion object {
fun create(originTtl: UByte): MeshPingPayload =
MeshPingPayload(
ByteArray(MeshDiagnosticsConstants.NONCE_SIZE).also(SecureRandom()::nextBytes),
originTtl,
)
fun decode(data: ByteArray): MeshPingPayload? {
if (data.size < MeshDiagnosticsConstants.PAYLOAD_SIZE) return null
return MeshPingPayload(
data.copyOfRange(0, MeshDiagnosticsConstants.NONCE_SIZE),
data[MeshDiagnosticsConstants.NONCE_SIZE].toUByte(),
)
}
}
}

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

@ -188,6 +188,11 @@ object AppStateStore {
fun addPublicMessage(msg: BitchatMessage) {
synchronized(this) {
if (!msg.isBridged) {
_publicMessages.value = _publicMessages.value.filterNot {
it.isBridged && it.bridgeRadioMessageIdHint == msg.id
}
}
val publicKey = publicMessageKey(msg)
if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return
seenMessageIds.add(msg.id)
@ -196,6 +201,10 @@ object AppStateStore {
}
}
fun hasRadioPublicMessage(messageId: String): Boolean = synchronized(this) {
_publicMessages.value.any { !it.isBridged && it.id == messageId }
}
/** Replace a live media row by ID, or append it if the row was not admitted yet. */
fun upsertPublicMessage(msg: BitchatMessage) {
synchronized(this) {
@ -263,6 +272,22 @@ object AppStateStore {
* Persists an incoming private message before it is admitted to UI, unread, haptic, or
* notification state. Transport callbacks invoke this from their background worker.
*/
suspend fun hasPrivateTextReceipt(message: BitchatMessage): Boolean {
val repository = synchronized(this) {
if (privateConversationWritesSuspended) return false
conversationRepository
} ?: return false
return repository.hasPrivateTextReceipt(message)
}
suspend fun privateMediaReceiptState(messageID: String): PrivateMediaReceiptState {
val repository = synchronized(this) {
if (privateConversationWritesSuspended) return PrivateMediaReceiptState.UNAVAILABLE
conversationRepository
} ?: return PrivateMediaReceiptState.UNAVAILABLE
return repository.privateMediaReceiptState(messageID)
}
suspend fun addPrivateMessageDurably(
peerID: String,
msg: BitchatMessage,
@ -408,13 +433,27 @@ object AppStateStore {
messageID in seenMessageIds
}
fun removePrivateConversation(peerID: String) {
synchronized(this) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val map = _privateMessages.value.toMutableMap()
val removedPeer = map.remove(peerID) != null
val removedConversation = map.remove(conversationID) != null
val changed = removedPeer || removedConversation
if (changed) {
_privateMessages.value = map
}
}
}
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

@ -0,0 +1,15 @@
package com.bitchat.android.services
import java.net.URI
/** Parses explicit channel invitations; never requests or infers device location. */
object ChannelInvitation {
private val geohash = Regex("[0123456789bcdefghjkmnpqrstuvwxyz]{1,12}")
fun decode(link: String): String? = runCatching {
val uri = URI(link)
require(uri.scheme == "bitchat" && uri.host == "geohash" && uri.query == null && uri.fragment == null && uri.userInfo == null && uri.port == -1)
val cell = uri.path.removePrefix("/").lowercase()
cell.takeIf(geohash::matches)
}.getOrNull()
fun link(cell: String): String? = cell.lowercase().takeIf(geohash::matches)?.let { "bitchat://geohash/$it" }
}

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

@ -27,6 +27,8 @@ import java.util.Date
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
enum class PrivateMediaReceiptState { ABSENT, ACCEPTED, TOMBSTONED, UNAVAILABLE }
/**
* Process-wide, serialized persistence for private conversations.
*
@ -141,6 +143,16 @@ class ConversationRepository internal constructor(
}
}
suspend fun hasPrivateTextReceipt(message: BitchatMessage): Boolean = withContext(dispatcher) {
try { database.hasPrivateTextReceipt(message) } catch (_: Exception) { false }
}
suspend fun privateMediaReceiptState(messageID: String): PrivateMediaReceiptState = withContext(dispatcher) {
try { database.privateMediaReceiptState(messageID) } catch (_: Exception) {
PrivateMediaReceiptState.UNAVAILABLE
}
}
suspend fun upsertMessageAndWait(
conversationID: String,
aliases: Set<String>,
@ -743,6 +755,33 @@ internal class ConversationDatabase(
}
}
fun hasPrivateTextReceipt(incoming: BitchatMessage): Boolean {
if (!incoming.isPrivate || incoming.type != BitchatMessageType.Message) return false
if (isDeletedMessageLocked(readableDatabase, incoming.id)) return true
readableDatabase.query("private_messages", MESSAGE_COLUMNS, "message_id = ?",
arrayOf(incoming.id), null, null, null).use { cursor ->
if (!cursor.moveToFirst()) return false
val stored = cursor.toMessage()
return stored.type == BitchatMessageType.Message && stored.content == incoming.content &&
stored.senderPeerID == incoming.senderPeerID
}
}
fun privateMediaReceiptState(messageID: String): PrivateMediaReceiptState {
if (!com.bitchat.android.model.PrivateMediaMessageIdentity.isStableID(messageID)) {
return PrivateMediaReceiptState.UNAVAILABLE
}
if (isDeletedMessageLocked(readableDatabase, messageID)) return PrivateMediaReceiptState.TOMBSTONED
readableDatabase.query("private_messages", MESSAGE_COLUMNS, "message_id = ?",
arrayOf(messageID), null, null, null).use { cursor ->
if (!cursor.moveToFirst()) return PrivateMediaReceiptState.ABSENT
val message = cursor.toMessage()
return if (message.type != BitchatMessageType.Message && File(message.content).isFile) {
PrivateMediaReceiptState.ACCEPTED
} else PrivateMediaReceiptState.UNAVAILABLE
}
}
private fun loadDeletedMessageIDs(): Set<String> {
readableDatabase.query(
"deleted_private_messages",
@ -1684,6 +1723,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 +1750,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 +1822,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

@ -0,0 +1,190 @@
package com.bitchat.android.services
import android.content.Context
import android.util.AtomicFile
import android.util.Base64
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.PrivateMediaPreparation
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.model.PrivateMediaMessageIdentity
import com.google.gson.Gson
import java.io.File
import java.security.MessageDigest
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/** One encrypted atomic record per pending media message; no plaintext retransmission spool. */
internal class PrivateMediaOutboxStore(
context: Context,
private val cipher: ConversationStorageCipher = AndroidConversationStorageCipher("bitchat_private_media_outbox_v1")
) {
data class Entry(
val id: String,
val conversationID: String,
val recipientPeerID: String,
val encodedPacket: String,
val createdAt: Long,
val attempts: Int = 1,
val lastAttemptAt: Long = createdAt
)
private val directory = File(context.filesDir, "private-media-outbox")
private val gson = Gson()
fun load(): List<Entry> {
if (!directory.exists()) return emptyList()
return checkNotNull(directory.listFiles()).filter { PrivateMediaMessageIdentity.isStableID(it.name) }
.take(MAX_ENTRIES).mapNotNull { file ->
// A corrupt record stays quarantined in place; it is never retransmitted.
runCatching {
require(file.length() <= MAX_RECORD_BYTES)
val plaintext = cipher.decrypt(AtomicFile(file).readFully(), file.name.toByteArray())
gson.fromJson(plaintext.toString(Charsets.UTF_8), Entry::class.java)
.also { require(it.id == file.name && it.attempts in 1..MAX_ATTEMPTS) }
}.getOrNull()
}
}
fun save(entry: Entry) {
require(PrivateMediaMessageIdentity.isStableID(entry.id))
check(directory.isDirectory || directory.mkdirs())
val files = checkNotNull(directory.listFiles())
val file = File(directory, entry.id)
check(file.exists() || files.size < MAX_ENTRIES)
val encrypted = cipher.encrypt(gson.toJson(entry).toByteArray(), entry.id.toByteArray())
require(encrypted.size <= MAX_RECORD_BYTES)
check(files.sumOf { it.length() } - file.length() + encrypted.size <= MAX_TOTAL_BYTES)
val atomic = AtomicFile(file)
val stream = atomic.startWrite()
try {
stream.write(encrypted)
atomic.finishWrite(stream)
} catch (error: Exception) {
atomic.failWrite(stream)
throw error
}
}
fun remove(id: String) {
require(PrivateMediaMessageIdentity.isStableID(id))
AtomicFile(File(directory, id)).delete()
check(!File(directory, id).exists())
}
fun wipe() {
cipher.destroyKey()
check(!directory.exists() || directory.deleteRecursively())
}
companion object {
const val MAX_ATTEMPTS = 8
private const val MAX_ENTRIES = 100
private const val MAX_RECORD_BYTES = 8 * 1024 * 1024L
private const val MAX_TOTAL_BYTES = 64 * 1024 * 1024L
}
}
/** Retries the original file and message identity until a matching recipient acknowledges it. */
class PrivateMediaOutbox private constructor(context: Context) {
private val store = PrivateMediaOutboxStore(context)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mutex = Mutex()
private var entries: MutableMap<String, PrivateMediaOutboxStore.Entry>? = null
@Volatile private var paused = false
init {
scope.launch {
while (isActive) {
delay(15_000)
runCatching { retryPending() }
}
}
}
private fun loaded(): MutableMap<String, PrivateMediaOutboxStore.Entry> =
entries ?: store.load().associateByTo(linkedMapOf()) { it.id }.also { entries = it }
suspend fun enqueue(id: String, conversationID: String, recipientPeerID: String, packet: BitchatFilePacket): Boolean =
withContext(Dispatchers.IO) {
mutex.withLock {
if (paused) return@withLock false
runCatching {
val encoded = checkNotNull(packet.encode())
val entry = PrivateMediaOutboxStore.Entry(id, conversationID, recipientPeerID,
Base64.encodeToString(encoded, Base64.NO_WRAP), System.currentTimeMillis())
store.save(entry)
loaded()[id] = entry
true
}.getOrDefault(false)
}
}
fun acknowledge(id: String, peerID: String) {
if (!PrivateMediaMessageIdentity.isStableID(id)) return
scope.launch {
mutex.withLock {
val entry = loaded()[id] ?: return@withLock
if (entry.recipientPeerID != peerID) return@withLock
runCatching { store.remove(id) }.onSuccess { loaded().remove(id) }
}
}
}
suspend fun wipe() {
paused = true
withContext(Dispatchers.IO) {
mutex.withLock {
store.wipe()
entries = linkedMapOf()
}
}
}
fun resume() { paused = false }
private suspend fun retryPending() = mutex.withLock {
if (paused) return@withLock
val mesh = com.bitchat.android.service.MeshServiceHolder.unifiedMeshService ?: return@withLock
val now = System.currentTimeMillis()
loaded().values.toList().forEach { entry ->
if (AppStateStore.privateMediaReceiptState(entry.id) == PrivateMediaReceiptState.TOMBSTONED) {
store.remove(entry.id)
loaded().remove(entry.id)
return@forEach
}
if (now - entry.createdAt > 24 * 60 * 60 * 1000L ||
(entry.attempts >= PrivateMediaOutboxStore.MAX_ATTEMPTS && now - entry.lastAttemptAt >= 300_000)) {
AppStateStore.updatePrivateMessageStatus(entry.id, DeliveryStatus.Failed("No delivery receipt received"))
store.remove(entry.id)
loaded().remove(entry.id)
return@forEach
}
if (entry.attempts >= PrivateMediaOutboxStore.MAX_ATTEMPTS) return@forEach
val interval = (30_000L shl (entry.attempts - 1).coerceAtMost(4)).coerceAtMost(300_000L)
if (now - entry.lastAttemptAt < interval) return@forEach
val recipient = ContactDirectory.resolve(entry.conversationID).meshPeerID ?: return@forEach
if (recipient != entry.recipientPeerID || !mesh.supportsPrivateMediaReceipts(recipient)) return@forEach
val encoded = Base64.decode(entry.encodedPacket, Base64.NO_WRAP)
val packet = BitchatFilePacket.decode(encoded) ?: return@forEach
if (PrivateMediaMessageIdentity.stableID(mesh.myPeerID, recipient, packet.fileName) != entry.id) return@forEach
val transferID = MessageDigest.getInstance("SHA-256").digest(encoded).joinToString("") { "%02x".format(it) }
val prepared = mesh.prepareFilePrivate(recipient, packet, transferID, allowLegacyFallback = false)
if (prepared is PrivateMediaPreparation.Ready) {
val attempted = entry.copy(attempts = entry.attempts + 1, lastAttemptAt = now)
store.save(attempted)
loaded()[entry.id] = attempted
if (prepared.transfer.commit()) AppStateStore.updatePrivateMessageStatus(entry.id, DeliveryStatus.Sent)
}
}
}
companion object {
@Volatile private var instance: PrivateMediaOutbox? = null
fun initialize(context: Context): PrivateMediaOutbox = instance ?: synchronized(this) {
instance ?: PrivateMediaOutbox(context.applicationContext).also { instance = it }
}
fun tryGetInstance(): PrivateMediaOutbox? = instance
}
}

View File

@ -146,7 +146,10 @@ object VerificationService {
val service = encryptionServiceRef?.get() ?: return null
val qr = VerificationQR.fromUrlString(urlString) ?: return null
val now = System.currentTimeMillis() / 1000L
if (now - qr.ts > maxAgeSeconds) return null
// Freshness in both directions: a future-dated timestamp must not
// buy a QR a longer validity window than a fresh one gets. iOS uses
// the same abs() check in VerificationService.verifyScannedQR.
if (verificationTimestampSkewSeconds(now, qr.ts) > maxAgeSeconds) return null
val sig = qr.sigHex.dataFromHexString() ?: return null
val signKey = qr.signKeyHex.dataFromHexString() ?: return null
@ -292,3 +295,10 @@ object VerificationService {
var last: CacheEntry? = null
}
}
/** Absolute age of a verification QR timestamp, in seconds. */
internal fun verificationTimestampSkewSeconds(nowSeconds: Long, qrTimestampSeconds: Long): Long {
if (nowSeconds < 0 || qrTimestampSeconds < 0) return Long.MAX_VALUE
return if (nowSeconds >= qrTimestampSeconds) nowSeconds - qrTimestampSeconds
else qrTimestampSeconds - nowSeconds
}

View File

@ -0,0 +1,47 @@
package com.bitchat.android.services.bridge
internal class BoundedIdSet(private val capacity: Int) {
private val values = LinkedHashSet<String>()
fun add(id: String): Boolean {
if (!values.add(id)) return false
while (values.size > capacity) values.remove(values.first())
return true
}
fun contains(id: String): Boolean = id in values
fun clear() = values.clear()
}
/**
* Owns one logical relay subscription while assigning a distinct wire ID to
* every replacement. Relay CLOSE/REQ writes may execute out of order, but a
* delayed close can only affect the retired generation.
*
* Callers keep this object confined to their coordinator dispatcher.
*/
internal class RelaySubscriptionSlot(private val idPrefix: String) {
private var generation = 0L
private var activeId: String? = null
fun replace(close: (String) -> Unit, open: (String) -> Unit): String {
activeId?.let(close)
val replacementId = "$idPrefix-${++generation}"
activeId = replacementId
try {
open(replacementId)
} catch (error: Throwable) {
activeId = null
throw error
}
return replacementId
}
fun close(close: (String) -> Unit) {
val id = activeId ?: return
activeId = null
close(id)
}
}

View File

@ -0,0 +1,29 @@
package com.bitchat.android.services.bridge
import com.bitchat.android.model.PeerCapabilities
data class BridgedParticipant(
val pubkey: String,
val nickname: String?,
val lastSeenMs: Long
) {
val displayName: String
get() = "${nickname?.trim()?.takeIf { it.isNotEmpty() } ?: "anon"}#${pubkey.takeLast(4)}"
}
data class BridgeUiState(
val enabled: Boolean = false,
val nearbyOnly: Boolean = false,
val participants: List<BridgedParticipant> = emptyList()
)
internal data class VerifiedBridgePeer(
val peerId: String,
val nickname: String,
val noiseKey: ByteArray,
val signingKey: ByteArray,
val isVerifiedNickname: Boolean,
val capabilities: PeerCapabilities?,
val bridgeCell: String?,
val lastSeenMs: Long
)

View File

@ -0,0 +1,40 @@
package com.bitchat.android.services.bridge
import com.bitchat.android.protocol.BitchatPacket
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
/**
* Verifies bridge protocol packets against the signing key bound by the
* sender's authenticated announcement.
*/
internal object BridgePacketSignatureVerifier {
fun verify(packet: BitchatPacket, publicKey: ByteArray): Boolean {
val signature = packet.signature?.takeIf { it.size == 64 } ?: return false
val signingData = packet.toBinaryDataForSigning() ?: return false
if (publicKey.size != 32) return false
return runCatching {
Ed25519Signer().apply {
init(false, Ed25519PublicKeyParameters(publicKey, 0))
update(signingData, 0, signingData.size)
}.verifySignature(signature)
}.getOrDefault(false)
}
}
internal object CourierDepositAuthenticator {
fun authenticate(
packet: BitchatPacket,
fromPeerId: String,
directIngress: Boolean,
peers: List<VerifiedBridgePeer>,
isTrusted: (VerifiedBridgePeer) -> Boolean
): VerifiedBridgePeer? {
if (!directIngress || packet.senderID.toHex() != fromPeerId) return null
val peer = peers.firstOrNull { it.peerId == fromPeerId } ?: return null
if (!BridgePacketSignatureVerifier.verify(packet, peer.signingKey)) return null
return peer.takeIf(isTrusted)
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
}

View File

@ -0,0 +1,46 @@
package com.bitchat.android.services.bridge
import com.bitchat.android.mesh.MeshPacketUtils
import com.bitchat.android.mesh.BridgeMeshPort
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
/**
* Shared construction policy for bridge protocol packets emitted by BLE and
* Wi-Fi Aware mesh implementations.
*/
internal object BridgeProtocolPacketFactory {
fun protocolPacket(
type: MessageType,
payload: ByteArray,
senderPeerId: String,
recipientPeerId: String?,
ttl: UByte,
nowMs: Long = System.currentTimeMillis()
): BitchatPacket? {
if (payload.isEmpty()) return null
return BitchatPacket(
version = if (payload.size > 0xFFFF) 2u else 1u,
type = type.value,
senderID = MeshPacketUtils.hexStringToByteArray(senderPeerId),
recipientID = recipientPeerId?.let(MeshPacketUtils::hexStringToByteArray),
timestamp = nowMs.toULong(),
payload = payload,
signature = null,
ttl = ttl
)
}
fun identityAnnouncement(
nickname: String,
noiseStaticKey: ByteArray,
signingKey: ByteArray
): IdentityAnnouncement =
IdentityAnnouncement.forLocalPeer(
nickname,
noiseStaticKey,
signingKey,
BridgeMeshPort.advertisedCell()
)
}

View File

@ -0,0 +1,774 @@
package com.bitchat.android.services.bridge
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import androidx.core.content.edit
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.geohash.Geohash
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.BridgeMeshDelegate
import com.bitchat.android.mesh.BridgeOutboundPolicy
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.NostrCarrierPacket
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.nostr.MeshMessageIdentity
import com.bitchat.android.nostr.NostrEvent
import com.bitchat.android.nostr.NostrFilter
import com.bitchat.android.nostr.NostrIdentityBridge
import com.bitchat.android.nostr.NostrKind
import com.bitchat.android.nostr.NostrProtocol
import com.bitchat.android.nostr.NostrRelayManager
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.ui.DataManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.util.Date
import java.util.concurrent.atomic.AtomicReference
import kotlin.math.abs
/**
* Opt-in bridge policy shared by foreground transport and Compose UI.
*
* Outbound public traffic crosses the bridge only when the author opted in
* and did not mark the message nearby-only. Passive `fromBridge` reception is
* accepted regardless of the switch because it exposes no local traffic.
*/
object MeshBridgeService : BridgeMeshDelegate {
private data class PendingUplink(
val depositor: String,
val cell: String,
val event: NostrEvent
)
private data class PendingDownlink(
val cell: String,
val event: NostrEvent
)
private const val TAG = "MeshBridgeService"
private const val PREFS = "bitchat_bridge"
private const val KEY_ENABLED = "bridge_enabled_v1"
private const val CELL_PRECISION = 6
private const val MAX_EVENT_AGE_MS = 15L * 60 * 1000
private const val MAX_CONTENT_BYTES = 16_000
private const val MAX_TRACKED_IDS = 512
private const val MAX_PARTICIPANTS = 128
private const val PARTICIPANT_FRESH_MS = 10L * 60 * 1000
private const val PRESENCE_INTERVAL_MS = 4L * 60 * 1000
private const val MAX_QUEUED_UPLINKS = 20
private const val MAX_UPLINKS_PER_DEPOSITOR = 5
private const val UPLINKS_PER_MINUTE_PER_DEPOSITOR = 10
private const val MAX_PENDING_DOWNLINKS = 30
private const val DOWNLINKS_PER_MINUTE = 20
private const val INBOUND_PER_MINUTE = 600
private const val INBOUND_PER_SIGNER_PER_MINUTE = 120
private const val SIGNATURE_ATTEMPTS_PER_MINUTE = 720
private val dispatcher = Dispatchers.Default.limitedParallelism(1)
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
private val _isEnabled = MutableStateFlow(false)
val isEnabled: StateFlow<Boolean> = _isEnabled.asStateFlow()
private val _nearbyOnly = MutableStateFlow(false)
val nearbyOnly: StateFlow<Boolean> = _nearbyOnly.asStateFlow()
private val _activeCell = MutableStateFlow<String?>(null)
val activeCell: StateFlow<String?> = _activeCell.asStateFlow()
private val _bridgedParticipants = MutableStateFlow<List<BridgedParticipant>>(emptyList())
val bridgedParticipants: StateFlow<List<BridgedParticipant>> = _bridgedParticipants.asStateFlow()
private val outboundPolicy = AtomicReference(BridgeOutboundPolicy.Denied)
private val rendezvousSubscription = RelaySubscriptionSlot("mesh-bridge-rendezvous")
@Volatile
private var appContext: Context? = null
private var relayManager: NostrRelayManager? = null
private var prekeyCoordinator: PrekeyCoordinator? = null
private var prefs: android.content.SharedPreferences? = null
@Volatile
private var meshProvider: () -> MeshService? = { null }
private var clock: () -> Long = System::currentTimeMillis
private var jitter: (Long, Long) -> Long = kotlin.random.Random::nextLong
private var localLocationCell: String? = null
private var subscribedCells: Set<String> = emptySet()
private val verifiedPeers = linkedMapOf<String, VerifiedBridgePeer>()
private val verifiedPeerSnapshot = AtomicReference<List<VerifiedBridgePeer>>(emptyList())
private val publishedEventIds = BoundedIdSet(MAX_TRACKED_IDS)
private val receivedEventIds = BoundedIdSet(MAX_TRACKED_IDS)
private val meshBroadcastEventIds = BoundedIdSet(MAX_TRACKED_IDS)
private val rebroadcastEventIds = BoundedIdSet(MAX_TRACKED_IDS)
private val injectedEventIds = BoundedIdSet(MAX_TRACKED_IDS)
private val radioMessageIds = BoundedIdSet(MAX_TRACKED_IDS)
private val queuedUplinks = mutableListOf<PendingUplink>()
private val pendingDownlinks = mutableListOf<PendingDownlink>()
private val participants = linkedMapOf<String, BridgedParticipant>()
private val uplinkTimes = mutableMapOf<String, MutableList<Long>>()
private val inboundTimes = mutableListOf<Long>()
private val inboundTimesBySigner = mutableMapOf<String, MutableList<Long>>()
private val downlinkTimes = mutableListOf<Long>()
private val signatureAttemptTimes = mutableListOf<Long>()
private var downlinkJob: Job? = null
private var presenceJob: Job? = null
@Volatile
private var suppressPrekeyBroadcasts = false
@Volatile
private var panicQuiesced = false
fun initialize(
context: Context,
meshProvider: () -> MeshService? = {
com.bitchat.android.service.MeshServiceHolder.unifiedMeshService
},
clock: () -> Long = System::currentTimeMillis,
jitter: (Long, Long) -> Long = kotlin.random.Random::nextLong
) {
if (appContext != null) return
synchronized(this) {
if (appContext != null) return
val application = context.applicationContext
appContext = application
this.meshProvider = meshProvider
this.clock = clock
this.jitter = jitter
relayManager = NostrRelayManager.getInstance(application)
val prekeyManager = PrekeyManager.getInstance(application)
prefs = application.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val storedEnabled = loadEnabledWithMigration(prefs!!)
_isEnabled.value = storedEnabled && !panicQuiesced
if (panicQuiesced && storedEnabled) {
prefs?.edit { putBoolean(KEY_ENABLED, false) }
}
outboundPolicy.set(
BridgeOutboundPolicy.capture(
enabled = _isEnabled.value,
nearbyOnly = _nearbyOnly.value
)
)
PeerCapabilities.setBridgeEnabled(_isEnabled.value)
prekeyCoordinator = PrekeyCoordinator(
manager = prekeyManager,
meshProvider = ::currentMesh,
peersProvider = verifiedPeerSnapshot::get,
clock = clock
)
}
val location = LocationChannelManager.getInstance(context)
scope.launch {
location.availableChannels.collect { channels ->
if (!_isEnabled.value) {
localLocationCell = null
return@collect
}
localLocationCell = channels
.firstOrNull { it.level == GeohashChannelLevel.NEIGHBORHOOD }
?.geohash
?.take(CELL_PRECISION)
refreshRendezvous()
}
}
scope.launch {
relayManager?.isConnected?.collect { connected ->
if (connected) {
refreshRendezvous(forceSubscriptions = true)
flushQueuedUplinks()
publishPresence()
}
}
}
scope.launch {
if (_isEnabled.value) {
relayManager?.connect()
location.refreshChannels(
forceFresh = true,
updatePlaceNames = false
)
refreshRendezvous(forceSubscriptions = true)
}
if (_isEnabled.value) startPresenceLoop()
delay(2_000)
broadcastPrekeys(force = true)
}
}
private val publicationGeneration = java.util.concurrent.atomic.AtomicLong()
fun publicationPermit(): () -> Boolean {
val generation = publicationGeneration.get()
val enabledAtCapture = _isEnabled.value
return { enabledAtCapture && _isEnabled.value && generation == publicationGeneration.get() }
}
fun setEnabled(enabled: Boolean) {
if (outboundPolicy.get().enabled == enabled) return
if (enabled) {
panicQuiesced = false
suppressPrekeyBroadcasts = false
}
publicationGeneration.incrementAndGet()
outboundPolicy.set(BridgeOutboundPolicy.capture(enabled, nearbyOnly = false))
_isEnabled.value = enabled
prefs?.edit { putBoolean(KEY_ENABLED, enabled) }
PeerCapabilities.setBridgeEnabled(enabled)
_nearbyOnly.value = false
scope.launch {
if (!enabled) {
stopPresenceLoop()
closeSubscriptions()
queuedUplinks.clear()
pendingDownlinks.clear()
participants.clear()
publishParticipants()
localLocationCell = null
_activeCell.value = null
} else {
startPresenceLoop()
relayManager?.connect()
LocationChannelManager.getInstance(requireContext())
.refreshChannels(
forceFresh = true,
updatePlaceNames = false
)
refreshRendezvous(forceSubscriptions = true)
broadcastPrekeys(force = true)
}
currentMesh()?.sendBroadcastAnnounce()
}
}
fun setNearbyOnly(enabled: Boolean) {
outboundPolicy.updateAndGet { current ->
BridgeOutboundPolicy.capture(current.enabled, nearbyOnly = enabled)
}
_nearbyOnly.value = enabled
}
/** Cell included in announce TLV 0x06 while the bridge switch is on. */
override fun advertisedCell(): String? = _activeCell.value.takeIf { _isEnabled.value }
override fun outboundPolicy(): BridgeOutboundPolicy = outboundPolicy.get()
override fun bridgeOutgoing(
content: String,
senderPeerId: String,
timestampMs: Long,
nickname: String?,
policyAtSend: BridgeOutboundPolicy
) {
scope.launch {
if (!policyAtSend.permitsPublication(outboundPolicy.get())) return@launch
val cell = _activeCell.value ?: currentCell() ?: return@launch
if (content.toByteArray(Charsets.UTF_8).size > MAX_CONTENT_BYTES) return@launch
val identity = NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext())
val event = NostrProtocol.createBridgeMeshEvent(
content = content,
cell = cell,
senderIdentity = identity,
nickname = nickname,
meshSenderId = senderPeerId,
meshTimestampMs = timestampMs
)
if (!policyAtSend.permitsPublication(outboundPolicy.get())) return@launch
publishedEventIds.add(event.id)
injectedEventIds.add(event.id)
if (relayManager?.isConnected?.value == true) {
relayManager?.sendEventToGeohash(event, cell, publicationAllowed = publicationPermit())
} else {
val peer = availableBridgePeer() ?: return@launch
NostrCarrierPacket.fromEvent(
NostrCarrierPacket.Direction.TO_BRIDGE,
cell,
event
)?.encode()?.let { currentMesh()?.sendNostrCarrier(it, peer.peerId) }
}
}
}
/** Called only after a public radio packet's Ed25519 signature was accepted. */
override fun handleAuthenticatedRadioMessage(messageId: String) {
if (panicQuiesced || messageId.isBlank()) return
scope.launch {
radioMessageIds.add(messageId)
pendingDownlinks.removeAll { pending ->
classifyMessage(pending.event, pending.cell)?.bridgeRadioMessageIdHint == messageId
}
}
}
override fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement) {
if (panicQuiesced) return
scope.launch {
val peer = VerifiedBridgePeer(
peerId = peerId,
nickname = announcement.nickname,
noiseKey = announcement.noisePublicKey.copyOf(),
signingKey = announcement.signingPublicKey.copyOf(),
isVerifiedNickname =
currentMesh()?.getPeerInfo(peerId)?.isVerifiedNickname == true,
capabilities = announcement.capabilities,
bridgeCell = announcement.bridgeGeohash?.takeIf(::isValidGeohash),
lastSeenMs = clock()
)
verifiedPeers[peerId] = peer
while (verifiedPeers.size > 200) verifiedPeers.remove(verifiedPeers.keys.first())
publishVerifiedPeerSnapshot()
prekeyCoordinator?.handlePeerVerified(peerId)
if (_isEnabled.value) {
refreshRendezvous()
}
broadcastPrekeys()
}
}
override fun handlePrekeyPacket(packet: BitchatPacket) {
if (panicQuiesced) return
scope.launch { prekeyCoordinator?.handlePacket(packet) }
}
override fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) {
if (panicQuiesced) return
scope.launch {
val carrier = NostrCarrierPacket.decode(payload) ?: return@launch
when (carrier.direction) {
NostrCarrierPacket.Direction.TO_BRIDGE -> {
if (directedToUs) handleUplink(carrier, fromPeerId)
}
NostrCarrierPacket.Direction.FROM_BRIDGE -> {
if (!directedToUs) handleDownlink(carrier)
}
NostrCarrierPacket.Direction.TO_GATEWAY,
NostrCarrierPacket.Direction.FROM_GATEWAY -> MeshGatewayService.handleCarrier(carrier, fromPeerId, directedToUs)
}
}
}
@SuppressLint("ApplySharedPref", "UseKtx")
suspend fun wipe() {
// Revoke policy synchronously so no already-queued bridge work can be
// authorized by the pre-panic setting.
publicationGeneration.incrementAndGet()
outboundPolicy.set(BridgeOutboundPolicy.Denied)
panicQuiesced = true
suppressPrekeyBroadcasts = true
_isEnabled.value = false
_nearbyOnly.value = false
PeerCapabilities.setBridgeEnabled(false)
kotlinx.coroutines.withContext(dispatcher) {
prefs?.edit()?.putBoolean(KEY_ENABLED, false)?.commit()
stopPresenceLoop()
downlinkJob?.cancel()
downlinkJob = null
closeSubscriptions()
queuedUplinks.clear()
pendingDownlinks.clear()
localLocationCell = null
_activeCell.value = null
verifiedPeers.clear()
publishVerifiedPeerSnapshot()
participants.clear()
publishedEventIds.clear()
receivedEventIds.clear()
meshBroadcastEventIds.clear()
rebroadcastEventIds.clear()
injectedEventIds.clear()
radioMessageIds.clear()
uplinkTimes.clear()
inboundTimes.clear()
inboundTimesBySigner.clear()
downlinkTimes.clear()
signatureAttemptTimes.clear()
prekeyCoordinator?.wipe()
publishParticipants()
}
}
private fun publishVerifiedPeerSnapshot() {
verifiedPeerSnapshot.set(
verifiedPeers.values.map { peer ->
peer.copy(
noiseKey = peer.noiseKey.copyOf(),
signingKey = peer.signingKey.copyOf()
)
}
)
}
private suspend fun refreshRendezvous(forceSubscriptions: Boolean = false) {
if (!_isEnabled.value) return
val cell = currentCell()
val changed = cell != _activeCell.value
if (changed) {
_activeCell.value = cell
currentMesh()?.sendBroadcastAnnounce()
}
if (cell == null) return
val cells = linkedSetOf(cell).apply { addAll(Geohash.neighborsSamePrecision(cell)) }
if (changed || forceSubscriptions || cells != subscribedCells) {
val targets = linkedSetOf<String>()
cells.forEach { subscribedCell ->
relayManager?.ensureGeohashRelaysConnected(subscribedCell)
targets += relayManager?.getRelaysForGeohash(subscribedCell).orEmpty()
}
relayManager?.let { manager ->
rendezvousSubscription.replace(
close = manager::unsubscribe,
open = { subscriptionId ->
manager.subscribe(
filter = NostrFilter.bridgeRendezvous(
cells,
since = clock() - MAX_EVENT_AGE_MS
),
id = subscriptionId,
handler = { event -> scope.launch { handleRendezvousEvent(event) } },
targetRelayUrls = targets.toList()
)
}
)
subscribedCells = cells
}
publishPresence()
}
}
private fun currentCell(): String? {
localLocationCell?.takeIf(::isValidGeohash)?.let { return it.take(CELL_PRECISION) }
return availableBridgePeer()?.bridgeCell?.take(CELL_PRECISION)
}
private fun availableBridgePeer(): VerifiedBridgePeer? =
verifiedPeers.values.firstOrNull { peer ->
peer.capabilities?.contains(PeerCapabilities.BRIDGE) == true &&
peer.bridgeCell != null &&
currentMesh()?.getPeerInfo(peer.peerId)?.isConnected == true
}
private fun handleRendezvousEvent(event: NostrEvent) {
if (!_isEnabled.value) return
val cell = event.tagValue("r") ?: return
if (cell !in subscribedCells || publishedEventIds.contains(event.id)) return
if (isOwnEvent(event, cell)) {
publishedEventIds.add(event.id)
return
}
if (!allowSignatureAttempt() || !event.isValidSignature()) return
if (!receivedEventIds.add(event.id) || !allowInbound(event.pubkey)) return
if (!isFresh(event) || event.tagValue("r") != cell || !isValidGeohash(cell)) return
when (event.kind) {
NostrKind.GEOHASH_PRESENCE -> recordParticipant(event.pubkey, null)
NostrKind.EPHEMERAL_EVENT -> {
val message = classifyMessage(event, cell) ?: return
val localRadio = message.bridgeRadioMessageIdHint?.let(::radioCopyPresent) == true
if (!localRadio && injectBridgeMessage(message)) {
recordParticipant(event.pubkey, event.tagValue("n"))
}
if (!localRadio &&
!meshBroadcastEventIds.contains(event.id) &&
!rebroadcastEventIds.contains(event.id) &&
pendingDownlinks.none { it.event.id == event.id }
) {
pendingDownlinks += PendingDownlink(cell, event)
while (pendingDownlinks.size > MAX_PENDING_DOWNLINKS) pendingDownlinks.removeAt(0)
scheduleDownlink(jitter = true)
}
}
}
}
private fun handleUplink(carrier: NostrCarrierPacket, depositor: String) {
if (!_isEnabled.value || !allowUplink(depositor)) return
val event = structurallyValidEvent(carrier) ?: return
if (meshBroadcastEventIds.contains(event.id) ||
publishedEventIds.contains(event.id) ||
queuedUplinks.any { it.event.id == event.id }
) {
return
}
if (!allowSignatureAttempt() || !event.isValidSignature()) return
if (relayManager?.isConnected?.value == true) {
publishCarriedEvent(event, carrier.geohash)
} else {
if (queuedUplinks.count { it.depositor == depositor } >= MAX_UPLINKS_PER_DEPOSITOR) return
queuedUplinks += PendingUplink(depositor, carrier.geohash, event)
while (queuedUplinks.size > MAX_QUEUED_UPLINKS) queuedUplinks.removeAt(0)
}
}
private fun handleDownlink(carrier: NostrCarrierPacket) {
val event = structurallyValidEvent(carrier) ?: return
if (publishedEventIds.contains(event.id) || isOwnEvent(event, carrier.geohash)) return
if (!allowSignatureAttempt() || !event.isValidSignature()) return
val firstMesh = meshBroadcastEventIds.add(event.id)
if (!firstMesh || !receivedEventIds.add(event.id) || !allowInbound(event.pubkey)) return
val message = classifyMessage(event, carrier.geohash) ?: return
if (injectBridgeMessage(message)) recordParticipant(event.pubkey, event.tagValue("n"))
}
private fun structurallyValidEvent(carrier: NostrCarrierPacket): NostrEvent? {
if (!isValidGeohash(carrier.geohash) ||
carrier.eventJson.size > NostrCarrierPacket.MAX_EVENT_JSON_BYTES
) {
return null
}
val event = carrier.event() ?: return null
if (!isFresh(event) || event.tagValue("r") != carrier.geohash) return null
return when (event.kind) {
NostrKind.GEOHASH_PRESENCE -> event
NostrKind.EPHEMERAL_EVENT ->
event.takeIf { classifyMessage(it, carrier.geohash) != null }
else -> null
}
}
private fun classifyMessage(event: NostrEvent, cell: String): BitchatMessage? {
if (event.kind != NostrKind.EPHEMERAL_EVENT ||
!isFresh(event) ||
event.tagValue("r") != cell ||
!isValidGeohash(cell)
) {
return null
}
val content = event.content
if (content.isBlank() || content.toByteArray(Charsets.UTF_8).size > MAX_CONTENT_BYTES) return null
val nickname = event.tagValue("n")?.trim()?.takeIf { it.isNotEmpty() }
val m = event.tags.firstOrNull { it.size >= 4 && it[0] == "m" }
val radioHint = if (m != null &&
m[2].matches(Regex("^[0-9a-fA-F]{16}$"))
) {
m[3].toLongOrNull()?.let { MeshMessageIdentity.stableId(m[2], it, content) }
} else {
null
}
return BitchatMessage(
id = event.id,
sender = "${nickname ?: "anon"}#${event.pubkey.takeLast(4)}",
content = content,
timestamp = Date(event.createdAt * 1000L),
senderPeerID = "bridge:${event.pubkey.take(16)}",
isBridged = true,
bridgeRadioMessageIdHint = radioHint
)
}
private fun injectBridgeMessage(message: BitchatMessage): Boolean {
if (!injectedEventIds.add(message.id)) return false
if (message.bridgeRadioMessageIdHint?.let(::radioCopyPresent) == true) return false
AppStateStore.addPublicMessage(message)
return true
}
private fun radioCopyPresent(messageId: String): Boolean =
radioMessageIds.contains(messageId) || AppStateStore.hasRadioPublicMessage(messageId)
private fun scheduleDownlink(jitter: Boolean) {
if (downlinkJob?.isActive == true || pendingDownlinks.isEmpty()) return
val now = clock()
downlinkTimes.removeAll { now - it >= 60_000 }
val waitMs = if (jitter) {
jitter(200, 1_501)
} else {
(downlinkTimes.minOrNull()?.plus(60_000)?.minus(now) ?: 50).coerceAtLeast(50)
}
downlinkJob = scope.launch {
delay(waitMs)
drainDownlinks()
}
}
private fun drainDownlinks() {
val now = clock()
downlinkTimes.removeAll { now - it >= 60_000 }
while (pendingDownlinks.isNotEmpty() && downlinkTimes.size < DOWNLINKS_PER_MINUTE) {
val item = pendingDownlinks.removeAt(0)
if (!isFresh(item.event) ||
meshBroadcastEventIds.contains(item.event.id) ||
rebroadcastEventIds.contains(item.event.id)
) {
continue
}
val message = classifyMessage(item.event, item.cell)
if (message?.bridgeRadioMessageIdHint?.let(::radioCopyPresent) == true) continue
val payload = NostrCarrierPacket.fromEvent(
NostrCarrierPacket.Direction.FROM_BRIDGE,
item.cell,
item.event
)?.encode() ?: continue
currentMesh()?.sendNostrCarrier(payload)
rebroadcastEventIds.add(item.event.id)
downlinkTimes += clock()
}
if (pendingDownlinks.isNotEmpty()) scheduleDownlink(jitter = false)
}
private fun flushQueuedUplinks() {
if (!_isEnabled.value || relayManager?.isConnected?.value != true) return
val queued = queuedUplinks.toList()
queuedUplinks.clear()
queued.filterNot { publishedEventIds.contains(it.event.id) }
.forEach { publishCarriedEvent(it.event, it.cell) }
}
private fun publishCarriedEvent(event: NostrEvent, cell: String) {
publishedEventIds.add(event.id)
relayManager?.sendEventToGeohash(event, cell, publicationAllowed = publicationPermit())
}
private fun publishPresence() {
if (!_isEnabled.value || relayManager?.isConnected?.value != true) return
val cell = _activeCell.value ?: return
val identity = NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext())
val event = NostrProtocol.createBridgePresenceEvent(cell, identity)
publishedEventIds.add(event.id)
relayManager?.sendEventToGeohash(event, cell, publicationAllowed = publicationPermit())
}
private fun startPresenceLoop() {
if (presenceJob?.isActive == true) return
presenceJob = scope.launch {
while (true) {
delay(PRESENCE_INTERVAL_MS)
pruneParticipants()
if (_isEnabled.value) {
LocationChannelManager.getInstance(requireContext())
.refreshChannels(
forceFresh = true,
updatePlaceNames = false
)
refreshRendezvous()
publishPresence()
broadcastPrekeys()
}
}
}
}
private fun stopPresenceLoop() {
presenceJob?.cancel()
presenceJob = null
}
fun resumeAfterPanic() {
panicQuiesced = false
suppressPrekeyBroadcasts = false
refreshPrekeys()
}
fun refreshPrekeys() {
scope.launch { broadcastPrekeys(force = true) }
}
private fun broadcastPrekeys(force: Boolean = false) {
if (suppressPrekeyBroadcasts) return
prekeyCoordinator?.broadcast(force)
}
private fun recordParticipant(pubkey: String, nickname: String?) {
val now = clock()
participants.entries.removeAll { now - it.value.lastSeenMs > PARTICIPANT_FRESH_MS }
if (pubkey !in participants && participants.size >= MAX_PARTICIPANTS) {
participants.minByOrNull { it.value.lastSeenMs }?.key?.let(participants::remove)
}
val previous = participants[pubkey]
participants[pubkey] = BridgedParticipant(
pubkey,
nickname?.trim()?.takeIf { it.isNotEmpty() } ?: previous?.nickname,
now
)
publishParticipants()
}
private fun pruneParticipants() {
val now = clock()
participants.entries.removeAll { now - it.value.lastSeenMs > PARTICIPANT_FRESH_MS }
publishParticipants()
}
private fun publishParticipants() {
_bridgedParticipants.value = participants.values.sortedByDescending { it.lastSeenMs }
}
private fun allowUplink(depositor: String): Boolean {
val now = clock()
val times = uplinkTimes.getOrPut(depositor) { mutableListOf() }
times.removeAll { now - it >= 60_000 }
if (times.size >= UPLINKS_PER_MINUTE_PER_DEPOSITOR) return false
times += now
return true
}
private fun allowInbound(signer: String): Boolean {
val now = clock()
inboundTimes.removeAll { now - it >= 60_000 }
if (inboundTimes.size >= INBOUND_PER_MINUTE) return false
val signerTimes = inboundTimesBySigner.getOrPut(signer) { mutableListOf() }
signerTimes.removeAll { now - it >= 60_000 }
if (signerTimes.size >= INBOUND_PER_SIGNER_PER_MINUTE) return false
inboundTimes += now
signerTimes += now
return true
}
private fun allowSignatureAttempt(): Boolean {
val now = clock()
signatureAttemptTimes.removeAll { now - it >= 60_000 }
if (signatureAttemptTimes.size >= SIGNATURE_ATTEMPTS_PER_MINUTE) return false
signatureAttemptTimes += now
return true
}
private fun closeSubscriptions() {
relayManager?.let { manager ->
rendezvousSubscription.close(manager::unsubscribe)
}
subscribedCells = emptySet()
}
private fun isOwnEvent(event: NostrEvent, cell: String): Boolean =
runCatching {
NostrIdentityBridge.deriveBridgeIdentity(cell, requireContext())
.publicKeyHex.equals(event.pubkey, ignoreCase = true)
}.getOrDefault(false)
private fun isFresh(event: NostrEvent): Boolean =
abs(clock() - event.createdAt * 1000L) <= MAX_EVENT_AGE_MS
private fun isValidGeohash(value: String): Boolean =
value.length in 1..12 &&
value.matches(Regex("^[0123456789bcdefghjkmnpqrstuvwxyz]+$", RegexOption.IGNORE_CASE))
private fun NostrEvent.tagValue(name: String): String? =
tags.firstOrNull { it.size >= 2 && it[0] == name }?.get(1)
private fun currentMesh(): MeshService? = meshProvider()
private fun requireContext(): Context =
checkNotNull(appContext) { "MeshBridgeService.initialize must be called first" }
private fun loadEnabledWithMigration(
preferences: android.content.SharedPreferences
): Boolean {
if (preferences.contains(KEY_ENABLED)) return preferences.getBoolean(KEY_ENABLED, false)
val legacyKeys = listOf("gateway_user_enabled", "gateway_enabled", "gateway.userEnabled")
val migrated = legacyKeys.any { preferences.getBoolean(it, false) }
preferences.edit {
putBoolean(KEY_ENABLED, migrated)
legacyKeys.forEach(::remove)
}
return migrated
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
}

View File

@ -0,0 +1,167 @@
package com.bitchat.android.services.bridge
import android.content.Context
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.geohash.LiveLocationPrivacyGate
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.model.NostrCarrierPacket
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.nostr.*
import com.bitchat.android.service.MeshServiceHolder
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
internal object GatewayEventPolicy {
fun inspect(carrier: NostrCarrierPacket, nowSeconds: Long): NostrEvent? {
if (!Regex("[0123456789bcdefghjkmnpqrstuvwxyz]{1,12}").matches(carrier.geohash)) return null
val event = carrier.event() ?: return null
if (event.kind != NostrKind.EPHEMERAL_EVENT ||
event.tags.none { it.size >= 2 && it[0] == "g" && it[1] == carrier.geohash } ||
nowSeconds - event.createdAt.toLong() !in -900L..900L) return null
return event
}
}
/** Opt-in internet sharing for signed public geohash events (carrier directions 1 and 2). */
object MeshGatewayService {
private val dispatcher = Dispatchers.IO.limitedParallelism(1)
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
private val generation = AtomicLong()
private val _enabled = MutableStateFlow(false)
val enabled: StateFlow<Boolean> = _enabled.asStateFlow()
private var context: Context? = null
private val radioEvents = BoundedIdSet(512)
private val published = BoundedIdSet(512)
private val delivered = BoundedIdSet(512)
private val inboundTimes = ArrayDeque<Long>()
private val perPeer = linkedMapOf<String, ArrayDeque<Long>>()
private val downlinkTimes = ArrayDeque<Long>()
private val pending = linkedMapOf<String, Pair<NostrCarrierPacket, Long?>>()
private var drain: Job? = null
private val mesh get() = MeshServiceHolder.unifiedMeshService
private val relays get() = context?.let(NostrRelayManager::getInstance)
@Synchronized
fun initialize(application: Context) {
if (context != null) return
context = application.applicationContext
_enabled.value = application.getSharedPreferences("mesh_gateway", Context.MODE_PRIVATE).getBoolean("enabled", false)
PeerCapabilities.setGatewayEnabled(_enabled.value)
}
fun setEnabled(enabled: Boolean) {
generation.incrementAndGet()
_enabled.value = enabled
context?.getSharedPreferences("mesh_gateway", Context.MODE_PRIVATE)?.edit()?.putBoolean("enabled", enabled)?.apply()
PeerCapabilities.setGatewayEnabled(enabled)
if (!enabled) scope.launch { pending.clear(); drain?.cancel(); drain = null }
mesh?.sendBroadcastAnnounce()
}
suspend fun wipe() {
setEnabled(false)
withContext(dispatcher) {
pending.clear()
drain?.cancel()
drain = null
radioEvents.clear()
published.clear()
delivered.clear()
inboundTimes.clear()
perPeer.clear()
downlinkTimes.clear()
check(context?.getSharedPreferences("mesh_gateway", Context.MODE_PRIVATE)?.edit()?.clear()?.commit() != false)
}
}
private fun permit(): () -> Boolean {
val captured = generation.get()
val enabledAtSend = _enabled.value
return { enabledAtSend && _enabled.value && generation.get() == captured }
}
fun uplinkIfOffline(event: NostrEvent, cell: String, liveToken: Long?) {
val current = mesh ?: return
if (liveToken != null && !LiveLocationPrivacyGate.accepts(liveToken)) return
if (relays?.hasConnectedRelay(relays?.getRelaysForGeohash(cell).orEmpty()) == true) return
val gateway = current.getPeerNicknames().keys.firstOrNull { peer ->
current.getPeerInfo(peer)?.let { it.isConnected && it.hasVerifiedAnnouncement &&
it.capabilities?.contains(PeerCapabilities.GATEWAY) == true } == true
} ?: return
val carrier = NostrCarrierPacket.fromEvent(NostrCarrierPacket.Direction.TO_GATEWAY, cell, event) ?: return
scope.launch {
if (liveToken != null && !LiveLocationPrivacyGate.accepts(liveToken)) return@launch
radioEvents.add(event.id)
current.sendNostrCarrier(carrier.encode(), gateway)
}
}
fun handleCarrier(carrier: NostrCarrierPacket, peerID: String, directedToUs: Boolean) {
val application = context ?: return
val allowed = permit()
scope.launch {
val event = GatewayEventPolicy.inspect(carrier, System.currentTimeMillis() / 1000) ?: return@launch
if (carrier.direction == NostrCarrierPacket.Direction.TO_GATEWAY) {
if (!directedToUs || !allowed() || published.contains(event.id)) return@launch
if (mesh?.getPeerInfo(peerID)?.hasVerifiedAnnouncement != true || !allowInbound(peerID)) return@launch
if (!event.isValidSignature() || !allowed()) return@launch
radioEvents.add(event.id)
published.add(event.id)
relays?.sendEventToGeohash(event, carrier.geohash, publicationAllowed = allowed)
NostrBackgroundRuntime.receiveGatewayEvent(event, carrier.geohash)
} else if (carrier.direction == NostrCarrierPacket.Direction.FROM_GATEWAY) {
if (directedToUs || delivered.contains(event.id) || !allowInbound(peerID)) return@launch
val channelManager = LocationChannelManager.getInstance(application)
val selected = (channelManager.selectedChannel.value as? ChannelID.Location)?.channel ?: return@launch
if (selected.geohash != carrier.geohash || !channelManager.canUseSelectedLocationChannel(selected)) return@launch
if (!event.isValidSignature()) return@launch
radioEvents.add(event.id)
delivered.add(event.id)
NostrBackgroundRuntime.receiveGatewayEvent(event, carrier.geohash)
}
}
}
fun rebroadcastRelayEvent(event: NostrEvent, cell: String, liveToken: Long?) {
val allowed = permit()
if (!allowed()) return
val carrier = NostrCarrierPacket.fromEvent(NostrCarrierPacket.Direction.FROM_GATEWAY, cell, event) ?: return
scope.launch {
if (!allowed() || (liveToken != null && !LiveLocationPrivacyGate.accepts(liveToken)) ||
GatewayEventPolicy.inspect(carrier, System.currentTimeMillis() / 1000) == null ||
radioEvents.contains(event.id) || delivered.contains(event.id) || pending.containsKey(event.id)) return@launch
if (!event.isValidSignature() || pending.size >= 100) return@launch
pending[event.id] = carrier to liveToken
if (drain?.isActive != true) drain = scope.launch {
while (pending.isNotEmpty() && allowed()) {
delay(kotlin.random.Random.nextLong(200, 1501))
val now = System.currentTimeMillis()
while (downlinkTimes.firstOrNull()?.let { now - it >= 60_000 } == true) downlinkTimes.removeFirst()
if (downlinkTimes.size >= 30) { delay(1000); continue }
val next = pending.entries.first()
pending.remove(next.key)
val (outgoing, token) = next.value
if (!allowed() || (token != null && !LiveLocationPrivacyGate.accepts(token)) || radioEvents.contains(next.key) ||
GatewayEventPolicy.inspect(outgoing, now / 1000) == null) continue
mesh?.sendNostrCarrier(outgoing.encode())
delivered.add(next.key)
downlinkTimes.addLast(now)
}
}
}
}
private fun allowInbound(peerID: String): Boolean {
val now = System.currentTimeMillis()
while (inboundTimes.firstOrNull()?.let { now - it >= 60_000 } == true) inboundTimes.removeFirst()
perPeer.entries.removeAll { it.value.lastOrNull()?.let { now - it >= 60_000 } != false }
if (perPeer.size >= 200 && peerID !in perPeer) return false
val times = perPeer.getOrPut(peerID) { ArrayDeque() }
while (times.firstOrNull()?.let { now - it >= 60_000 } == true) times.removeFirst()
if (inboundTimes.size >= 20 || times.size >= 5) return false
inboundTimes.addLast(now)
times.addLast(now)
return true
}
}

View File

@ -0,0 +1,71 @@
package com.bitchat.android.services.bridge
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.PrekeyBundle
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.services.ContactIdentityResolver
/**
* Coordinates authenticated prekey packets without owning cryptographic
* persistence. [PrekeyManager] remains the repository/crypto boundary.
*/
internal class PrekeyCoordinator(
private val manager: PrekeyManager,
private val meshProvider: () -> MeshService?,
private val peersProvider: () -> List<VerifiedBridgePeer>,
private val clock: () -> Long = System::currentTimeMillis
) {
private val pendingPackets = linkedMapOf<String, BitchatPacket>()
private var lastBroadcastMs = 0L
fun handlePacket(packet: BitchatPacket) {
val bundle = PrekeyBundle.decode(packet.payload) ?: return
val owner = ContactIdentityResolver.peerIdForNoiseKey(bundle.noiseStaticPublicKey)
if (owner != packet.senderID.toHex()) return
val peer = peersProvider().firstOrNull { it.peerId == owner }
if (peer == null || !peer.noiseKey.contentEquals(bundle.noiseStaticPublicKey)) {
if (pendingPackets.size < MAX_PENDING_PACKETS || owner in pendingPackets) {
pendingPackets[owner] = packet
}
return
}
ingestVerified(packet, bundle, peer)
}
fun handlePeerVerified(peerId: String) {
pendingPackets.remove(peerId)?.let(::handlePacket)
}
fun broadcast(force: Boolean = false) {
val now = clock()
if (!force && now - lastBroadcastMs < REBROADCAST_INTERVAL_MS) return
val bundle = manager.currentSignedBundle(now) ?: return
val encoded = bundle.encode() ?: return
lastBroadcastMs = now
meshProvider()?.sendPrekeyBundle(encoded)
}
fun wipe() {
pendingPackets.clear()
lastBroadcastMs = 0L
manager.wipe()
}
private fun ingestVerified(
packet: BitchatPacket,
bundle: PrekeyBundle,
peer: VerifiedBridgePeer
) {
if (!BridgePacketSignatureVerifier.verify(packet, peer.signingKey)) return
if (manager.verifyAndIngest(bundle, peer.noiseKey, peer.signingKey, clock())) {
com.bitchat.android.service.MeshServiceHolder.sharedGossipSyncManager?.onPublicPacketSeen(packet)
}
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
private companion object {
const val MAX_PENDING_PACKETS = 64
const val REBROADCAST_INTERVAL_MS = 60L * 60 * 1000
}
}

View File

@ -0,0 +1,331 @@
package com.bitchat.android.services.bridge
import android.content.Context
import android.util.Base64
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.model.PrekeyBundle
import com.bitchat.android.noise.CourierNoiseCrypto
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import java.security.SecureRandom
/**
* Owns local one-time prekeys and verified peer bundles for courier v2.
*
* Local private keys use EncryptedSharedPreferences through
* [SecureIdentityStateManager]. Peer bundles contain public material only,
* but their consumption assignments are persisted so retries of one message
* never spend additional prekeys.
*/
class PrekeyManager internal constructor(
private val identity: PrekeyIdentity,
private val localStore: LocalPrekeyStore,
private val peerStore: PeerPrekeyStore,
private val randomBytes: () -> ByteArray
) {
data class Sealed(
val ciphertext: ByteArray,
val prekeyId: Long?
)
data class Opened(
val payload: ByteArray,
val senderStaticKey: ByteArray,
val consumedPrekey: Boolean
)
private val lock = Any()
private var local: LocalPrekeyState? = null
private var peerBundles: MutableMap<String, StoredPeerPrekeyBundle>? = null
fun currentSignedBundle(nowMs: Long = System.currentTimeMillis()): PrekeyBundle? = synchronized(lock) {
val staticKey = identity.staticKey()?.second ?: return@synchronized null
val signingPrivateKey = identity.signingKey()?.first ?: return@synchronized null
val state = loadLocalLocked()
replenishLocked(state, nowMs)
val prekeys = state.records
.asSequence()
.filter { it.consumedAt == null }
.sortedBy { it.id }
.mapNotNull { record ->
decode(record.privateKey)?.let { privateKey ->
PrekeyBundle.Prekey(record.id, CourierNoiseCrypto.publicKey(privateKey))
}
}
.toList()
if (prekeys.isEmpty()) return@synchronized null
val unsigned = PrekeyBundle(
noiseStaticPublicKey = staticKey,
prekeys = prekeys,
generatedAt = state.generatedAt,
signature = ByteArray(PrekeyBundle.SIGNATURE_LENGTH)
)
val signature = signEd25519(unsigned.signableBytes(), signingPrivateKey) ?: return@synchronized null
unsigned.copy(signature = signature)
}
fun verifyAndIngest(
bundle: PrekeyBundle,
expectedNoiseKey: ByteArray,
announceBoundSigningKey: ByteArray,
nowMs: Long = System.currentTimeMillis()
): Boolean {
if (!bundle.noiseStaticPublicKey.contentEquals(expectedNoiseKey) ||
announceBoundSigningKey.size != PrekeyBundle.KEY_LENGTH ||
!verifyEd25519(bundle.signature, bundle.signableBytes(), announceBoundSigningKey)
) {
return false
}
synchronized(lock) {
val bundles = loadPeerBundlesLocked()
val key = encode(bundle.noiseStaticPublicKey)
val existing = bundles[key]
if (existing != null && existing.generatedAt >= bundle.generatedAt) return false
val freshIds = bundle.prekeys.map { it.id }.toSet()
bundles[key] = StoredPeerPrekeyBundle(
noiseKey = key,
generatedAt = bundle.generatedAt,
prekeyIds = bundle.prekeys.map { it.id },
prekeyPublicKeys = bundle.prekeys.map { encode(it.publicKey) },
usedIds = existing?.usedIds?.filterTo(mutableSetOf()) { it in freshIds } ?: mutableSetOf(),
assignments = existing?.assignments
?.filterValues { it in freshIds }
?.toMutableMap() ?: mutableMapOf(),
updatedAt = nowMs
)
while (bundles.size > MAX_PEERS) {
bundles.minByOrNull { it.value.updatedAt }?.key?.let(bundles::remove)
}
persistPeerBundlesLocked(bundles)
return true
}
}
fun seal(
payload: ByteArray,
messageId: String,
recipientNoiseKey: ByteArray,
recipientAdvertisesPrekeys: Boolean,
nowMs: Long = System.currentTimeMillis()
): Sealed {
val senderPrivateKey = identity.staticKey()?.first
?: throw IllegalStateException("Noise static identity is unavailable")
val assigned = if (recipientAdvertisesPrekeys) {
assignPrekey(messageId, recipientNoiseKey, nowMs)
} else {
null
}
return if (assigned != null) {
Sealed(
CourierNoiseCrypto.sealToPrekey(payload, senderPrivateKey, assigned),
assigned.id
)
} else {
Sealed(
CourierNoiseCrypto.seal(payload, senderPrivateKey, recipientNoiseKey),
null
)
}
}
fun open(
ciphertext: ByteArray,
prekeyId: Long?,
nowMs: Long = System.currentTimeMillis()
): Opened {
if (prekeyId == null) {
val staticPrivateKey = identity.staticKey()?.first
?: throw IllegalStateException("Noise static identity is unavailable")
val opened = CourierNoiseCrypto.open(ciphertext, staticPrivateKey)
return Opened(opened.payload, opened.senderStaticKey, false)
}
synchronized(lock) {
val state = loadLocalLocked()
pruneLocked(state, nowMs)
val record = state.records.firstOrNull { it.id == prekeyId }
?: throw IllegalArgumentException("Unknown or expired courier prekey")
val consumedAt = record.consumedAt
if (consumedAt != null && nowMs - consumedAt > CONSUMED_GRACE_MS) {
throw IllegalArgumentException("Courier prekey grace window expired")
}
val privateKey = decode(record.privateKey)
?: throw IllegalArgumentException("Invalid courier prekey")
val opened = CourierNoiseCrypto.openWithPrekey(ciphertext, privateKey, prekeyId)
val newlyConsumed = record.consumedAt == null
if (newlyConsumed) {
record.consumedAt = nowMs
advanceGeneratedAtLocked(state, nowMs)
replenishLocked(state, nowMs)
persistLocalLocked(state)
}
return Opened(opened.payload, opened.senderStaticKey, newlyConsumed)
}
}
fun hasUsableBundle(
recipientNoiseKey: ByteArray,
nowMs: Long = System.currentTimeMillis()
): Boolean = synchronized(lock) {
val bundle = loadPeerBundlesLocked()[encode(recipientNoiseKey)] ?: return@synchronized false
isFresh(bundle, nowMs) && bundle.prekeyIds.any { it !in bundle.usedIds }
}
fun wipe() = synchronized(lock) {
local = LocalPrekeyState()
peerBundles = mutableMapOf()
localStore.clear()
peerStore.clear()
}
private fun assignPrekey(
messageId: String,
recipientNoiseKey: ByteArray,
nowMs: Long
): PrekeyBundle.Prekey? = synchronized(lock) {
val bundles = loadPeerBundlesLocked()
val key = encode(recipientNoiseKey)
val bundle = bundles[key] ?: return@synchronized null
if (!isFresh(bundle, nowMs)) return@synchronized null
bundle.assignments[messageId]?.let { assigned ->
val index = bundle.prekeyIds.indexOf(assigned)
if (index >= 0) {
return@synchronized decode(bundle.prekeyPublicKeys[index])
?.let { PrekeyBundle.Prekey(assigned, it) }
}
}
val index = bundle.prekeyIds.indices
.filter { bundle.prekeyIds[it] !in bundle.usedIds }
.minByOrNull { bundle.prekeyIds[it] }
?: return@synchronized null
val id = bundle.prekeyIds[index]
val publicKey = decode(bundle.prekeyPublicKeys[index]) ?: return@synchronized null
bundle.usedIds += id
bundle.assignments[messageId] = id
bundle.updatedAt = nowMs
persistPeerBundlesLocked(bundles)
PrekeyBundle.Prekey(id, publicKey)
}
private fun replenishLocked(state: LocalPrekeyState, nowMs: Long): Boolean {
val beforeRecords = state.records.size
val beforeUnconsumed = state.records.count { it.consumedAt == null }
pruneLocked(state, nowMs)
val unconsumed = state.records.count { it.consumedAt == null }
var changed = unconsumed != beforeUnconsumed
if (unconsumed < REPLENISH_THRESHOLD) {
repeat(PrekeyBundle.MAX_PREKEYS - unconsumed) {
val privateKey = randomBytes()
require(privateKey.size == PrekeyBundle.KEY_LENGTH)
state.records += LocalPrekeyRecord(
id = state.nextId and 0xFFFF_FFFFL,
privateKey = encode(privateKey),
createdAt = nowMs
)
state.nextId = (state.nextId + 1) and 0xFFFF_FFFFL
}
advanceGeneratedAtLocked(state, nowMs)
changed = true
}
if (changed || state.records.size != beforeRecords) persistLocalLocked(state)
return changed
}
private fun pruneLocked(state: LocalPrekeyState, nowMs: Long) {
state.records.removeAll { record ->
record.consumedAt?.let { nowMs - it > CONSUMED_GRACE_MS }
?: (nowMs - record.createdAt > UNCONSUMED_RETENTION_MS)
}
}
private fun advanceGeneratedAtLocked(state: LocalPrekeyState, nowMs: Long) {
state.generatedAt = maxOf(nowMs.coerceAtLeast(0), state.generatedAt + 1)
}
private fun loadLocalLocked(): LocalPrekeyState {
local?.let { return it }
val loaded = localStore.load()
local = loaded
return loaded
}
private fun persistLocalLocked(state: LocalPrekeyState) {
try { localStore.save(state) } catch (error: Exception) {
local = null
throw error
}
}
private fun loadPeerBundlesLocked(): MutableMap<String, StoredPeerPrekeyBundle> {
peerBundles?.let { return it }
return peerStore.load().also { peerBundles = it }
}
private fun persistPeerBundlesLocked(bundles: Map<String, StoredPeerPrekeyBundle>) {
try { peerStore.save(bundles) } catch (error: Exception) {
peerBundles = null
throw error
}
}
private fun isFresh(bundle: StoredPeerPrekeyBundle, nowMs: Long): Boolean =
nowMs - bundle.generatedAt <= MAX_BUNDLE_AGE_MS
private fun signEd25519(data: ByteArray, privateKey: ByteArray): ByteArray? = runCatching {
Ed25519Signer().apply {
init(true, Ed25519PrivateKeyParameters(privateKey, 0))
update(data, 0, data.size)
}.generateSignature()
}.getOrNull()
private fun verifyEd25519(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean =
runCatching {
Ed25519Signer().apply {
init(false, Ed25519PublicKeyParameters(publicKey, 0))
update(data, 0, data.size)
}.verifySignature(signature)
}.getOrDefault(false)
private fun encode(data: ByteArray): String =
Base64.encodeToString(data, Base64.NO_WRAP)
private fun decode(value: String): ByteArray? =
runCatching { Base64.decode(value, Base64.NO_WRAP) }.getOrNull()
companion object {
private const val PEER_PREFS = "bitchat_prekey_bundles"
private const val REPLENISH_THRESHOLD = 3
private const val CONSUMED_GRACE_MS = 48L * 60 * 60 * 1000
private const val UNCONSUMED_RETENTION_MS = 30L * 24 * 60 * 60 * 1000
private const val MAX_BUNDLE_AGE_MS = 7L * 24 * 60 * 60 * 1000
private const val MAX_PEERS = 200
@Volatile
private var instance: PrekeyManager? = null
fun getInstance(context: Context): PrekeyManager =
instance ?: synchronized(this) {
instance ?: run {
val application = context.applicationContext
val identityState = SecureIdentityStateManager(application)
val random = SecureRandom()
PrekeyManager(
identity = AndroidPrekeyIdentity(identityState),
localStore = SecureLocalPrekeyStore(identityState),
peerStore = SharedPreferencesPeerPrekeyStore(
application.getSharedPreferences(PEER_PREFS, Context.MODE_PRIVATE)
),
randomBytes = {
ByteArray(PrekeyBundle.KEY_LENGTH).also(random::nextBytes)
}
).also { instance = it }
}
}
}
}

View File

@ -0,0 +1,109 @@
package com.bitchat.android.services.bridge
import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
import com.bitchat.android.identity.SecureIdentityStateManager
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
internal data class LocalPrekeyRecord(
val id: Long,
val privateKey: String,
val createdAt: Long,
var consumedAt: Long? = null
)
internal data class LocalPrekeyState(
var records: MutableList<LocalPrekeyRecord> = mutableListOf(),
var nextId: Long = 0,
var generatedAt: Long = 0
)
internal data class StoredPeerPrekeyBundle(
val noiseKey: String,
var generatedAt: Long,
var prekeyIds: List<Long>,
var prekeyPublicKeys: List<String>,
var usedIds: MutableSet<Long>,
var assignments: MutableMap<String, Long>,
var updatedAt: Long
)
internal interface PrekeyIdentity {
fun staticKey(): Pair<ByteArray, ByteArray>?
fun signingKey(): Pair<ByteArray, ByteArray>?
}
internal interface LocalPrekeyStore {
fun load(): LocalPrekeyState
fun save(state: LocalPrekeyState)
fun clear()
}
internal interface PeerPrekeyStore {
fun load(): MutableMap<String, StoredPeerPrekeyBundle>
fun save(bundles: Map<String, StoredPeerPrekeyBundle>)
fun clear()
}
internal class AndroidPrekeyIdentity(
private val state: SecureIdentityStateManager
) : PrekeyIdentity {
override fun staticKey(): Pair<ByteArray, ByteArray>? = state.loadStaticKey()
override fun signingKey(): Pair<ByteArray, ByteArray>? = state.loadSigningKey()
}
internal class SecureLocalPrekeyStore(
private val state: SecureIdentityStateManager,
private val gson: Gson = Gson()
) : LocalPrekeyStore {
override fun load(): LocalPrekeyState =
runCatching {
state.getSecureValue(LOCAL_STORE_KEY)
?.let { gson.fromJson(it, LocalPrekeyState::class.java) }
}.getOrNull() ?: LocalPrekeyState()
override fun save(state: LocalPrekeyState) {
check(this.state.storeSecureValueSynchronously(LOCAL_STORE_KEY, gson.toJson(state))) {
"Unable to persist courier prekeys"
}
}
override fun clear() {
check(state.clearSecureValuesSynchronously(LOCAL_STORE_KEY))
}
private companion object {
const val TAG = "LocalPrekeyStore"
const val LOCAL_STORE_KEY = "courier_prekeys_v1"
}
}
internal class SharedPreferencesPeerPrekeyStore(
private val preferences: SharedPreferences,
private val gson: Gson = Gson()
) : PeerPrekeyStore {
override fun load(): MutableMap<String, StoredPeerPrekeyBundle> {
val type = object : TypeToken<List<StoredPeerPrekeyBundle>>() {}.type
val values: List<StoredPeerPrekeyBundle> = runCatching {
preferences.getString(PEER_BUNDLES_KEY, null)
?.let { json -> gson.fromJson<List<StoredPeerPrekeyBundle>>(json, type) }
}.getOrNull() ?: emptyList()
return values
.filter { it.prekeyIds.size == it.prekeyPublicKeys.size }
.associateByTo(mutableMapOf()) { it.noiseKey }
}
override fun save(bundles: Map<String, StoredPeerPrekeyBundle>) {
check(preferences.edit().putString(PEER_BUNDLES_KEY, gson.toJson(bundles.values.toList())).commit())
}
override fun clear() {
check(preferences.edit().clear().commit())
}
private companion object {
const val PEER_BUNDLES_KEY = "bundles_v1"
}
}

View File

@ -76,6 +76,31 @@ class MeshGraphService private constructor() {
}
}
/**
* Returns a shortest path using only bidirectionally confirmed topology claims.
*/
fun computeRoute(fromPeerID: String, toPeerID: String): List<String>? {
if (fromPeerID == toPeerID) return listOf(fromPeerID)
val adjacency = mutableMapOf<String, MutableSet<String>>()
graphState.value.edges.filter(GraphEdge::isConfirmed).forEach { edge ->
adjacency.getOrPut(edge.a) { mutableSetOf() }.add(edge.b)
adjacency.getOrPut(edge.b) { mutableSetOf() }.add(edge.a)
}
val queue = ArrayDeque<List<String>>()
val visited = mutableSetOf(fromPeerID)
queue.add(listOf(fromPeerID))
while (queue.isNotEmpty()) {
val path = queue.removeFirst()
adjacency[path.last()].orEmpty().sorted().forEach { neighbor ->
if (!visited.add(neighbor)) return@forEach
val next = path + neighbor
if (neighbor == toPeerID) return next
queue.add(next)
}
}
return null
}
private fun publishSnapshot() {
// Collect all known nodes from nicknames and announcements
val allNodes = mutableSetOf<String>()

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,8 +37,21 @@ 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 boardPacketsProvider: (() -> List<BitchatPacket>)? = null
private val responseTimesByPeer = ConcurrentHashMap<String, ArrayDeque<Long>>()
private val observedBoardSyncPeers = ConcurrentHashMap.newKeySet<String>()
private fun peerSupportsBoard(peerID: String): Boolean =
peerID.lowercase() in observedBoardSyncPeers ||
latestAnnouncementByPeer[peerID.lowercase()]?.second?.payload?.let {
com.bitchat.android.model.IdentityAnnouncement.decode(it)?.capabilities
?.contains(com.bitchat.android.model.PeerCapabilities.BOARD)
} == true
var delegate: Delegate? = null
// Defaults (configurable constants)
@ -44,11 +61,16 @@ 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 groupMessages = 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 +106,12 @@ class GossipSyncManager(
synchronized(messages) {
messages.clear()
}
synchronized(fragments) { fragments.clear() }
synchronized(groupMessages) { groupMessages.clear() }
latestAnnouncementByPeer.clear()
responseTimesByPeer.clear()
observedBoardSyncPeers.clear()
archiveFile?.delete()
Log.d(TAG, "Cleared all gossip sync messages and announcements")
}
@ -106,13 +133,33 @@ 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 isGroupMessage = mt in setOf(MessageType.GROUP_MESSAGE, MessageType.PREKEY_BUNDLE) &&
(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 && !isGroupMessage) return
val idBytes = PacketIdUtil.computeIdBytes(packet)
val id = idBytes.joinToString("") { b -> "%02x".format(b) }
if (isBroadcastMessage) {
if (isGroupMessage) {
val age = System.currentTimeMillis() - packet.timestamp.toLong()
if (age !in -PUBLIC_PACKET_FUTURE_SKEW_MS..PUBLIC_MESSAGE_MAX_AGE_MS) return
synchronized(groupMessages) {
val cacheKey = if (mt == MessageType.PREKEY_BUNDLE) "prekey-${packet.senderID.toHexString()}" else id
groupMessages[cacheKey] = packet
while (groupMessages.size > 200) {
val iterator = groupMessages.entries.iterator()
iterator.next(); iterator.remove()
}
}
} else 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 +169,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 +201,35 @@ 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,
SyncTypeFlags.GROUP_MESSAGE.union(SyncTypeFlags.fromMessageTypes(MessageType.PREKEY_BUNDLE))
).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)
}
if (boardPacketsProvider != null) {
latestAnnouncementByPeer.keys.filter(::peerSupportsBoard).forEach {
sendRequestSyncToPeer(it, SyncTypeFlags.BOARD)
}
}
}
private fun sendRequestSyncToPeer(peerID: String) {
val payload = buildGcsPayload()
private fun sendRequestSyncToPeer(peerID: String, requestedTypes: SyncTypeFlags? = null) {
val types = requestedTypes ?: SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES)
.union(SyncTypeFlags.GROUP_MESSAGE).union(SyncTypeFlags.fromMessageTypes(MessageType.PREKEY_BUNDLE))
.let { if (boardPacketsProvider != null && peerSupportsBoard(peerID)) it.union(SyncTypeFlags.BOARD) else it }
val payload = buildGcsPayload(types)
val packet = BitchatPacket(
type = MessageType.REQUEST_SYNC.value,
@ -175,6 +246,12 @@ class GossipSyncManager(
}
fun handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
val requestedTypes = request.types ?: SyncTypeFlags.PUBLIC_MESSAGES
if (requestedTypes.contains(MessageType.BOARD_POST) && latestAnnouncementByPeer.containsKey(fromPeerID.lowercase())) {
observedBoardSyncPeers.add(fromPeerID.lowercase())
}
if (!shouldRespondTo(fromPeerID)) return
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 +260,85 @@ 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()}")
}
}
}
if (requestedTypes.contains(MessageType.GROUP_MESSAGE) || requestedTypes.contains(MessageType.PREKEY_BUNDLE)) {
typedSyncCandidates(requestedTypes).forEach { packet ->
if ((sinceTimestamp == null || packet.timestamp >= sinceTimestamp) &&
!mightContain(PacketIdUtil.computeIdBytes(packet))) {
delegate?.sendPacketToPeer(fromPeerID, packet.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS))
}
}
}
if (requestedTypes.contains(MessageType.BOARD_POST)) {
boardPacketsProvider?.invoke().orEmpty().take(200).forEach { packet ->
if ((sinceTimestamp == null || packet.timestamp >= sinceTimestamp) &&
!mightContain(PacketIdUtil.computeIdBytes(packet))) {
delegate?.sendPacketToPeer(fromPeerID, packet.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS))
}
}
}
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))
}
}
}
private fun typedSyncCandidates(types: SyncTypeFlags): List<BitchatPacket> = synchronized(groupMessages) {
val now = System.currentTimeMillis()
groupMessages.entries.removeAll { now - it.value.timestamp.toLong() > PUBLIC_MESSAGE_MAX_AGE_MS }
groupMessages.values.filter { packet ->
MessageType.fromValue(packet.type)?.let(types::contains) == true
}
}
private fun shouldRespondTo(peerID: String): Boolean {
val now = System.currentTimeMillis()
responseTimesByPeer.entries.removeIf { (_, times) ->
synchronized(times) { times.isEmpty() || now - times.last() >= 30_000L }
}
if (responseTimesByPeer.size >= 256 && !responseTimesByPeer.containsKey(peerID)) return false
val times = responseTimesByPeer.computeIfAbsent(peerID) { ArrayDeque() }
return synchronized(times) {
while (times.isNotEmpty() && now - times.first() >= 30_000L) {
times.removeFirst()
}
if (times.size >= 8) {
false
} else {
times.addLast(now)
true
}
}
}
@ -232,16 +368,25 @@ 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.BOARD_POST)) list.addAll(boardPacketsProvider?.invoke().orEmpty().take(200))
list.addAll(typedSyncCandidates(types))
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 +399,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 +430,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 +450,66 @@ 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
java.io.DataInputStream(file.inputStream().buffered()).use { input ->
val count = input.readInt()
require(count in 0..configProvider.seenCapacity())
repeat(count) {
val length = input.readInt()
require(length in 1..(com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + 256))
val bytes = ByteArray(length)
input.readFully(bytes)
val packet = com.bitchat.android.protocol.BinaryProtocol.decode(bytes) ?: 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")
} 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,80 @@
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 = 0x7ffu
val BOARD = fromMessageTypes(MessageType.BOARD_POST)
val GROUP_MESSAGE = fromMessageTypes(MessageType.GROUP_MESSAGE)
fun of(vararg types: MessageType) = fromMessageTypes(*types)
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.BOARD_POST -> 8
MessageType.PREKEY_BUNDLE -> 9
MessageType.GROUP_MESSAGE -> 10
MessageType.PING,
MessageType.PONG,
MessageType.NOSTR_CARRIER,
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 encoded(): ByteArray? = encode()
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

@ -303,11 +303,14 @@ private fun SettingsToggleRow(
fun AboutSheet(
isPresented: Boolean,
onDismiss: () -> Unit,
bridgeEnabled: Boolean,
onBridgeEnabledChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
onShowDebug: (() -> Unit)? = null,
modifier: Modifier = Modifier
onShowMeshTopology: (() -> Unit)? = null
) {
val context = LocalContext.current
// Get version name from package info
val versionName = remember {
try {
@ -445,6 +448,8 @@ fun AboutSheet(
}
}
item(key = "client_privacy") { ClientSettingsSection() }
item(key = "language") {
val selectedLanguageName = supportedLanguages
.firstOrNull { it.languageTag == selectedLanguageTag }
@ -544,6 +549,19 @@ fun AboutSheet(
}
)
HorizontalDivider(
modifier = Modifier.padding(start = 56.dp),
color = colorScheme.outline.copy(alpha = 0.12f)
)
SettingsToggleRow(
icon = Icons.Filled.Public,
title = stringResource(R.string.mesh_bridge_title),
subtitle = stringResource(R.string.mesh_bridge_description),
checked = bridgeEnabled,
onCheckedChange = onBridgeEnabledChange
)
HorizontalDivider(
modifier = Modifier.padding(start = 54.dp),
thickness = 1.dp,
@ -1256,6 +1274,16 @@ fun AboutSheet(
)
}
}
if (onShowMeshTopology != null) {
TextButton(onClick = onShowMeshTopology) {
Text(
text = "network → mesh topology",
fontSize = 13.sp,
fontFamily = BitchatFontFamily,
color = colorScheme.primary,
)
}
}
Text(
text = stringResource(R.string.about_footer),
fontSize = 11.sp,
@ -1303,7 +1331,7 @@ fun PasswordPromptDialog(
) {
if (show && channelName != null) {
val colorScheme = MaterialTheme.colorScheme
AlertDialog(
onDismissRequest = onDismiss,
title = {
@ -1321,7 +1349,7 @@ fun PasswordPromptDialog(
color = colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = passwordInput,
onValueChange = onPasswordChange,

View File

@ -355,9 +355,9 @@ fun NoiseSessionIcon(
)
}
// Closed once the handshake resolves (success or failure); open while idle or in flight.
// A closed lock only represents an established encrypted session.
val lockIconRes = when {
sessionState == "established" || sessionState?.startsWith("failed") == true ->
sessionState == "established" ->
R.drawable.ic_spec_lock
else -> R.drawable.ic_spec_lock_open
}
@ -564,6 +564,7 @@ fun NicknameEditor(
@Composable
fun PeerCounter(
connectedPeers: List<String>,
bridgedPeopleCount: Int = 0,
joinedChannels: Set<String>,
hasUnreadChannels: Map<String, Int>,
isConnected: Boolean,
@ -586,8 +587,8 @@ fun PeerCounter(
is com.bitchat.android.geohash.ChannelID.Mesh,
null -> {
// Mesh channel: show Bluetooth-connected peers (excluding self)
val count = connectedPeers.size
Pair(count, if (isConnected && count > 0) colorScheme.secondary else palette.textTertiary)
val count = connectedPeers.size + bridgedPeopleCount
Pair(count, if ((isConnected || bridgedPeopleCount > 0) && count > 0) colorScheme.secondary else palette.textTertiary)
}
}
@ -727,6 +728,7 @@ private fun MainHeader(
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle()
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val crowdingMode = headerCrowdingMode(maxWidth)
@ -809,6 +811,7 @@ private fun MainHeader(
PeerCounter(
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
bridgedPeopleCount = bridgeUiState.participants.size,
joinedChannels = joinedChannels,
hasUnreadChannels = hasUnreadChannels,
isConnected = isConnected,

View File

@ -83,6 +83,10 @@ fun ChatScreen(viewModel: ChatViewModel) {
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
val legacyPrivateMediaConsent by viewModel.legacyPrivateMediaConsent.collectAsStateWithLifecycle()
val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle()
val panicWipeState by viewModel.panicWipeState.collectAsStateWithLifecycle()
PanicWipeDialog(panicWipeState, viewModel::panicClearAllData, viewModel::dismissPanicClear)
var messageText by remember { mutableStateOf(TextFieldValue("")) }
var showPasswordPrompt by remember { mutableStateOf(false) }
@ -107,6 +111,15 @@ fun ChatScreen(viewModel: ChatViewModel) {
)
}
val sharedDraft by viewModel.sharedDraft.collectAsStateWithLifecycle()
LaunchedEffect(sharedDraft) {
sharedDraft?.let { shared ->
val combined = listOf(messageText.text, shared).filter { it.isNotBlank() }.joinToString("\n")
messageText = TextFieldValue(combined, androidx.compose.ui.text.TextRange(combined.length))
viewModel.consumeSharedText()
}
}
// Show password dialog when needed
LaunchedEffect(showPasswordPrompt) {
showPasswordDialog = showPasswordPrompt
@ -307,7 +320,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
conversationKey = conversationKey,
contentPadding = PaddingValues(
top = statusBarHeight + headerHeight +
(if (showNotesStrip) notesStripHeight else 0.dp),
notesStripHeight,
bottom = composerHeight
),
forceScrollToBottom = forceScrollToBottom,
@ -361,16 +374,13 @@ fun ChatScreen(viewModel: ChatViewModel) {
}
)
if (showNotesStrip) {
NearbyNotesStrip(
Column(modifier = Modifier.align(Alignment.TopCenter)
.padding(top = statusBarHeight + headerHeight)
.onSizeChanged { notesStripHeight = with(density) { it.height.toDp() } }) {
ConnectivityBanner()
if (showNotesStrip) NearbyNotesStrip(
noteCount = nearbyNotes.size,
onClick = { showLocationNotesSheet = true },
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = statusBarHeight + headerHeight)
.onSizeChanged { size ->
notesStripHeight = with(density) { size.height.toDp() }
},
onClick = { showLocationNotesSheet = true }
)
}
@ -443,7 +453,12 @@ fun ChatScreen(viewModel: ChatViewModel) {
currentChannel = currentChannel,
nickname = nickname,
colorScheme = colorScheme,
showMediaButtons = showMediaButtons
showMediaButtons = showMediaButtons,
showBridgeControls = bridgeUiState.enabled &&
currentChannel == null &&
selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location,
nearbyOnly = bridgeUiState.nearbyOnly,
onNearbyOnlyChange = viewModel::setBridgeNearbyOnly
)
}
}
@ -457,7 +472,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
colorScheme = colorScheme,
onSidebarToggle = { viewModel.showMeshPeerList() },
onShowAppInfo = { viewModel.showAppInfo() },
onPanicClear = { viewModel.panicClearAllData() },
onPanicClear = { viewModel.requestPanicClear() },
onLocationChannelsClick = { showLocationChannelsSheet = true },
onLocationNotesClick = {
nearbyNotesController.reveal()
@ -645,7 +660,10 @@ fun ChatInputSection(
colorScheme: ColorScheme,
showMediaButtons: Boolean,
recorderFactory: ((String?, String?) -> com.bitchat.android.features.voice.VoiceRecorder)? = null,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
showBridgeControls: Boolean = false,
nearbyOnly: Boolean = false,
onNearbyOnlyChange: (Boolean) -> Unit = {}
) {
val context = androidx.compose.ui.platform.LocalContext.current
val activePublicTalker by remember(context) {
@ -726,6 +744,9 @@ fun ChatInputSection(
currentChannel = currentChannel,
nickname = nickname,
showMediaButtons = showMediaButtons,
showBridgeControls = showBridgeControls,
nearbyOnly = nearbyOnly,
onNearbyOnlyChange = onNearbyOnlyChange,
mentionPeerIdentities = mentionPeerIdentities,
recorderFactory = recorderFactory,
activePublicTalker = activePublicTalker,
@ -840,6 +861,7 @@ private fun ChatDialogs(
onMeshPeerListDismiss: () -> Unit,
) {
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle()
// Password dialog
PasswordPromptDialog(
@ -853,10 +875,14 @@ private fun ChatDialogs(
// About sheet
var showDebugSheet by remember { mutableStateOf(false) }
var showMeshTopology by remember { mutableStateOf(false) }
AboutSheet(
isPresented = showAppInfo,
onDismiss = onAppInfoDismiss,
onShowDebug = { showDebugSheet = true }
onShowDebug = { showDebugSheet = true },
bridgeEnabled = bridgeUiState.enabled,
onBridgeEnabledChange = viewModel::setBridgeEnabled,
onShowMeshTopology = { showMeshTopology = true },
)
if (showDebugSheet) {
com.bitchat.android.ui.debug.DebugSettingsSheet(
@ -865,6 +891,11 @@ private fun ChatDialogs(
meshService = viewModel.meshService
)
}
MeshTopologySheet(
isPresented = showMeshTopology,
onDismiss = { showMeshTopology = false },
meshService = viewModel.meshServiceFacade,
)
// Location channels sheet
if (showLocationChannelsSheet) {
@ -876,9 +907,9 @@ private fun ChatDialogs(
)
}
// Location notes sheet (extracted to separate presenter)
// Unified mesh and geohash notices sheet.
if (showLocationNotesSheet) {
LocationNotesSheetPresenter(
NoticesSheetPresenter(
viewModel = viewModel,
onDismiss = onLocationNotesSheetDismiss
)

View File

@ -2,6 +2,7 @@ package com.bitchat.android.ui
import android.app.Application
import android.util.Log
import com.bitchat.android.R
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
@ -39,10 +40,25 @@ import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.util.hexEncodedString
import com.bitchat.android.services.bridge.BridgeUiState
import com.bitchat.android.services.bridge.MeshBridgeService
import com.bitchat.android.features.voice.LiveVoicePreferences
import com.bitchat.android.features.voice.LiveVoiceTarget
import com.bitchat.android.features.voice.VoiceRecorder
import com.bitchat.android.groups.BitchatGroup
import com.bitchat.android.groups.GroupCommandResult
import com.bitchat.android.groups.GroupCoordinator
import com.bitchat.android.groups.GroupCoordinatorContext
import com.bitchat.android.groups.GroupIds
import com.bitchat.android.groups.GroupPeerIdentity
import com.bitchat.android.groups.GroupStore
import com.bitchat.android.groups.PeerGroupCapability
import com.bitchat.android.board.BoardManager
import com.bitchat.android.board.BoardSigningIdentity
import com.bitchat.android.board.BoardStore
private data class ConversationLiveIdentityState(
val connectedPeerIDs: List<String>,
val peerNicknames: Map<String, String>,
@ -70,6 +86,9 @@ class ChatViewModel(
companion object {
private const val TAG = "ChatViewModel"
private const val CONVERSATION_DISCONNECT_GRACE_MS = 3_000L
private const val GROUP_COMMAND_USAGE =
"usage: /group create <name> · invite @name[#identity] · " +
"remove @name[#identity] · leave · list"
}
fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
@ -151,6 +170,10 @@ class ChatViewModel(
.select(com.bitchat.android.geohash.ChannelID.Mesh)
}
)
private val groupRuntime = com.bitchat.android.groups.GroupRuntime.getInstance(application)
private val groupStore = groupRuntime.store
private val groupCoordinator = groupRuntime.coordinator
val groups: StateFlow<List<BitchatGroup>> = groupStore.groups
// Create Noise session delegate for clean dependency injection
private val noiseSessionDelegate = object : NoiseSessionDelegate {
@ -178,11 +201,87 @@ class ChatViewModel(
privateChatManager,
viewModelScope
)
val boardManager = BoardManager(
store = BoardStore.getInstance(application.applicationContext),
scope = viewModelScope,
meshProvider = { mesh },
geoIdentityProvider = { geohash ->
runCatching {
NostrIdentityBridge.deriveIdentity(
forGeohash = geohash,
context = application.applicationContext
)
}.getOrNull()?.let { identity ->
BoardSigningIdentity.fromNostrPrivateKeyHex(identity.privateKeyHex)
}
},
onUrgentPosts = { geohash, posts ->
val text = if (posts.size == 1) {
val post = posts.single()
application.getString(
R.string.notices_alert_urgent_single,
post.authorNickname.trim().ifEmpty { "anon" },
post.content.truncateNoticeAlert()
)
} else {
application.getString(
R.string.notices_alert_urgent_collapsed,
posts.size
)
}
if (geohash.isEmpty()) {
messageManager.addSystemMessage(text)
} else {
messageManager.addChannelMessage(
"geo:$geohash",
BitchatMessage(
sender = "system",
content = text,
timestamp = Date(),
isRelay = false
)
)
}
}
)
private val notificationManager = NotificationManager(
application.applicationContext,
NotificationManagerCompat.from(application.applicationContext)
)
private val groupContext = object : com.bitchat.android.groups.GroupUiDelegate {
override val nickname get() = state.getNicknameValue()
override fun markGroupUnread(groupPeerID: String) {
state.setUnreadPrivateMessages(state.getUnreadPrivateMessagesValue() + groupPeerID)
}
override fun openGroupConversation(groupPeerID: String) {
privateChatManager.startPrivateChat(groupPeerID, mesh)
showPrivateChatSheet(groupPeerID)
}
override fun closeGroupConversation() { endPrivateChat() }
}
fun executeGroupCommand(arguments: List<String>, onResult: (GroupCommandResult) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
val result = handleGroupCommand(arguments)
kotlinx.coroutines.withContext(Dispatchers.Main) { onResult(result) }
}
}
private fun handleGroupCommand(arguments: List<String>): GroupCommandResult {
val subcommand = arguments.firstOrNull()?.lowercase()
?: return GroupCommandResult(false, GROUP_COMMAND_USAGE)
val value = arguments.drop(1).joinToString(" ")
return when (subcommand) {
"create" -> groupCoordinator.createGroup(value)
"invite" -> groupCoordinator.inviteMember(value)
"remove" -> groupCoordinator.removeMember(value)
"leave" -> groupCoordinator.leaveGroup()
"list" -> groupCoordinator.listGroups()
else -> GroupCommandResult(false, GROUP_COMMAND_USAGE)
}
}
private val verificationHandler = VerificationHandler(
context = application.applicationContext,
scope = viewModelScope,
@ -193,6 +292,7 @@ class ChatViewModel(
messageManager = messageManager
)
val verifiedFingerprints = verificationHandler.verifiedFingerprints
val vouchedFingerprints = verificationHandler.vouchedFingerprints
// Media file sending manager
private val mediaSendingManager = MediaSendingManager(
@ -410,6 +510,21 @@ class ChatViewModel(
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
val teleportedGeo: StateFlow<Set<String>> = state.teleportedGeo
val geohashParticipantCounts: StateFlow<Map<String, Int>> = state.geohashParticipantCounts
val bridgeUiState: StateFlow<BridgeUiState> = combine(
MeshBridgeService.isEnabled,
MeshBridgeService.nearbyOnly,
MeshBridgeService.bridgedParticipants
) { enabled, nearbyOnly, participants ->
BridgeUiState(enabled, nearbyOnly, participants)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = BridgeUiState(
enabled = MeshBridgeService.isEnabled.value,
nearbyOnly = MeshBridgeService.nearbyOnly.value,
participants = MeshBridgeService.bridgedParticipants.value
)
)
val meshServiceFacade: MeshService
get() = mesh
val myPeerID: String
@ -425,6 +540,7 @@ class ChatViewModel(
init {
observeConversationPresenceWithDisconnectGrace()
groupRuntime.attach(groupContext)
// Note: Mesh service delegate is now set by MainActivity
loadAndInitialize()
ContactDirectory.initialize(getApplication()) { mesh }
@ -646,6 +762,7 @@ class ChatViewModel(
}
override fun onCleared() {
groupRuntime.detach(groupContext)
if (favoriteRelationshipListenerRegistered) {
runCatching {
FavoritesPersistenceService.shared.removeListener(
@ -976,6 +1093,14 @@ class ChatViewModel(
val currentChannelValue = state.getCurrentChannelValue()
if (selectedPeer != null) {
if (GroupIds.isGroup(selectedPeer)) {
val groupPeer = selectedPeer
viewModelScope.launch(Dispatchers.IO) {
val accepted = groupCoordinator.sendMessage(content, groupPeer)
kotlinx.coroutines.withContext(Dispatchers.Main) { onAccepted(accepted) }
}
return
}
// If the selected peer is a temporary Nostr alias or a noise-hex identity, resolve to a canonical target
selectedPeer = ContactDirectory.canonicalConversationId(
com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
@ -1020,6 +1145,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)
@ -1269,6 +1399,20 @@ class ChatViewModel(
return verifiedFingerprints.contains(fingerprint)
}
fun isFingerprintVouched(fingerprint: String): Boolean =
verificationHandler.isFingerprintVouched(fingerprint)
fun isNoisePublicKeyVouched(noisePublicKey: ByteArray): Boolean =
verificationHandler.isFingerprintVouched(
verificationHandler.fingerprintFromNoiseBytes(noisePublicKey)
)
fun vouchersForFingerprint(fingerprint: String) =
verificationHandler.vouchersForFingerprint(fingerprint)
fun voucherNamesForFingerprint(fingerprint: String): List<String> =
verificationHandler.voucherNamesForFingerprint(fingerprint)
fun unverifyFingerprint(peerID: String) {
verificationHandler.unverifyFingerprint(peerID)
}
@ -1332,6 +1476,14 @@ class ChatViewModel(
state.setShowMeshPeerList(true)
}
fun setBridgeEnabled(enabled: Boolean) {
MeshBridgeService.setEnabled(enabled)
}
fun setBridgeNearbyOnly(enabled: Boolean) {
MeshBridgeService.setNearbyOnly(enabled)
}
fun hideMeshPeerList() {
state.setShowMeshPeerList(false)
}
@ -1421,6 +1573,27 @@ class ChatViewModel(
override fun didResolvePrivateMediaPolicy(peerID: String) {
mediaSendingManager.retryPendingPrivateMedia(peerID)
groupCoordinator.handlePeerAuthenticated(peerID)
}
override fun didReceiveGroupInvite(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
groupCoordinator.handleInvite(peerID, authenticatedRemoteStaticKey, payload)
}
override fun didReceiveGroupKeyUpdate(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
payload: ByteArray
) {
groupCoordinator.handleKeyUpdate(peerID, authenticatedRemoteStaticKey, payload)
}
override fun didReceiveGroupMessage(payload: ByteArray, timestampMs: Long) {
groupCoordinator.handleMessage(payload, timestampMs)
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
@ -1435,102 +1608,78 @@ class ChatViewModel(
return meshDelegateHandler.isFavorite(peerID)
}
private val _sharedDraft = MutableStateFlow<String?>(null)
val sharedDraft: StateFlow<String?> = _sharedDraft.asStateFlow()
fun receiveSharedText(text: String) { _sharedDraft.value = text.take(16_000) }
fun consumeSharedText() { _sharedDraft.value = null }
// MARK: - Emergency Clear
private var panicClearInProgress = false
private val _panicWipeState = MutableStateFlow(PanicWipeState.IDLE)
val panicWipeState: StateFlow<PanicWipeState> = _panicWipeState.asStateFlow()
fun requestPanicClear() {
if (_panicWipeState.value != PanicWipeState.ERASING) _panicWipeState.value = PanicWipeState.CONFIRM
}
fun dismissPanicClear() {
if (_panicWipeState.value != PanicWipeState.ERASING) _panicWipeState.value = PanicWipeState.IDLE
}
fun panicClearAllData() {
if (panicClearInProgress) return
panicClearInProgress = true
if (_panicWipeState.value == PanicWipeState.ERASING) return
_panicWipeState.value = PanicWipeState.ERASING
viewModelScope.launch {
try {
performPanicClearAllData()
} finally {
panicClearInProgress = false
}
val completed = try { performPanicClearAllData() } catch (_: Exception) { false }
_panicWipeState.value = if (completed) PanicWipeState.COMPLETE else PanicWipeState.FAILED
}
}
private suspend fun performPanicClearAllData() {
Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data")
try {
com.bitchat.android.geohash.LocationChannelManager
.getInstance(getApplication())
.disableLocationServices()
} catch (_: Exception) { }
// A pending one-shot downgrade confirmation must not survive panic or
// become actionable against the fresh post-wipe identity.
mediaSendingManager.clearPendingPrivateMediaConsent()
// Stop all message admission before wiping storage. The AppStateStore gate also rejects
// any transport callback already in flight until the fresh identity is ready.
clearAllMeshServiceData()
val conversationsCleared =
com.bitchat.android.services.AppStateStore
.panicClearPrivateConversations()
// Clear all UI managers
com.bitchat.android.services.AppStateStore.clear()
messageManager.clearAllMessages()
channelManager.clearAllChannels()
privateChatManager.clearAllPrivateChats()
dataManager.clearAllData()
conversationListPreferences.clearAll()
// Clear seen message store and MessageRouter outbox
try {
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear()
} catch (_: Exception) { }
try {
com.bitchat.android.services.MessageRouter.tryGetInstance()?.clearAll()
} catch (_: Exception) { }
// Clear all cryptographic data
clearAllCryptographicData()
// Clear all notifications
notificationManager.clearAllNotifications(removeConversationShortcuts = true)
// Clear all media files
com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication())
// Clear Nostr/geohash state, keys, connections, bookmarks, and reinitialize from scratch
try {
// Clear geohash bookmarks too (panic should remove everything)
try {
val store = com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(getApplication())
store.clearAll()
} catch (_: Exception) { }
try {
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
locationManager.clearPersistedChannel()
} catch (_: Exception) { }
geohashViewModel.panicReset()
} catch (e: Exception) {
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
private suspend fun performPanicClearAllData(): Boolean {
var successful = true
suspend fun step(action: suspend () -> Unit) {
try { action() } catch (_: Exception) { successful = false }
}
groupCoordinator.suspendForPanic()
step { com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()).disableLocationServices() }
step { com.bitchat.android.services.bridge.MeshGatewayService.wipe() }
step { MeshBridgeService.wipe() }
step { mesh.stopServices(); mesh.clearAllInternalData() }
step { check(com.bitchat.android.services.AppStateStore.panicClearPrivateConversations()) }
step { com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.wipe() }
mediaSendingManager.clearPendingPrivateMediaConsent()
step {
com.bitchat.android.services.AppStateStore.clear()
messageManager.clearAllMessages()
channelManager.clearAllChannels()
privateChatManager.clearAllPrivateChats()
check(groupStore.wipe())
dataManager.clearAllData()
conversationListPreferences.clearAll()
boardManager.clearTransientState()
}
step { com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() }
step { com.bitchat.android.services.MessageRouter.panicClear(getApplication()) }
step {
mesh.clearAllEncryptionData()
check(SecureIdentityStateManager(getApplication()).clearIdentityData())
FavoritesPersistenceService.shared.clearAllFavorites()
}
step { notificationManager.clearAllNotifications(removeConversationShortcuts = true) }
step { check(com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication())) }
step { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(getApplication()).clearAll() }
step { com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()).panicReset() }
step { com.bitchat.android.nostr.NostrRelayManager.getInstance(getApplication()).clearCustomRelays() }
step { geohashViewModel.panicReset() }
if (!successful) return false
// Reset nickname
val newNickname = "anon${Random.nextInt(1000, 9999)}"
state.setNickname(newNickname)
dataManager.saveNickname(newNickname)
if (!conversationsCleared) {
// Privacy wins over availability: keep private-message admission and transports
// stopped if SQLite could not prove that the conversation history was erased.
Log.e(TAG, "🚨 PANIC MODE INCOMPLETE - conversation database wipe failed")
return
}
// Recreate mesh service with fresh identity
com.bitchat.android.services.AppStateStore
.resumePrivateConversationsAfterPanic()
recreateMeshServiceAfterPanic()
Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}")
com.bitchat.android.services.AppStateStore.resumePrivateConversationsAfterPanic()
groupCoordinator.resumeAfterPanic()
MeshBridgeService.resumeAfterPanic()
com.bitchat.android.services.PrivateMediaOutbox.tryGetInstance()?.resume()
return true
}
/**
@ -1562,53 +1711,6 @@ class ChatViewModel(
)
}
/**
* Clear all mesh service related data
*/
private fun clearAllMeshServiceData() {
try {
// Request mesh service to clear all its internal data
mesh.clearAllInternalData()
Log.d(TAG, "✅ Cleared all mesh service data")
} catch (e: Exception) {
Log.e(TAG, "❌ Error clearing mesh service data: ${e.message}")
}
}
/**
* Clear all cryptographic data including persistent identity
*/
private fun clearAllCryptographicData() {
try {
// Clear encryption service persistent identity (Ed25519 signing keys)
mesh.clearAllEncryptionData()
// Clear secure identity state (if used)
try {
val identityManager = SecureIdentityStateManager(getApplication())
identityManager.clearIdentityData()
// Also clear secure values used by FavoritesPersistenceService (favorites + peerID index)
try {
identityManager.clearSecureValues("favorite_relationships", "favorite_peerid_index")
} catch (_: Exception) { }
Log.d(TAG, "✅ Cleared secure identity state and secure favorites store")
} catch (e: Exception) {
Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}")
}
// Clear FavoritesPersistenceService persistent relationships
try {
FavoritesPersistenceService.shared.clearAllFavorites()
Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships")
} catch (_: Exception) { }
Log.d(TAG, "✅ Cleared all cryptographic data")
} catch (e: Exception) {
Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}")
}
}
/**
* Get participant count for a specific geohash (5-minute activity window)
*/
@ -1730,4 +1832,6 @@ class ChatViewModel(
fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity =
geohashViewModel.peerIdentityForNostrPubkey(pubkeyHex)
private fun String.truncateNoticeAlert(): String =
if (length <= 120) this else take(120) + "…"
}

View File

@ -0,0 +1,14 @@
package com.bitchat.android.ui
import android.content.Context
object ClientPrivacyPreferences {
fun showNotificationPreviews(context: Context): Boolean =
context.getSharedPreferences("client_privacy", Context.MODE_PRIVATE).getBoolean("notification_previews", false)
fun setNotificationPreviews(context: Context, enabled: Boolean) {
context.getSharedPreferences("client_privacy", Context.MODE_PRIVATE).edit()
.putBoolean("notification_previews", enabled).apply()
NotificationManager.clearAllForPrivacyChange(context)
}
}

View File

@ -0,0 +1,65 @@
package com.bitchat.android.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.nostr.NostrRelayManager
@Composable
fun ClientSettingsSection() {
val context = LocalContext.current
val relayManager = remember { NostrRelayManager.getInstance(context) }
val relays by relayManager.customRelays.collectAsStateWithLifecycle()
val gateway by com.bitchat.android.services.bridge.MeshGatewayService.enabled.collectAsStateWithLifecycle()
var previews by remember { mutableStateOf(ClientPrivacyPreferences.showNotificationPreviews(context)) }
var relayInput by remember { mutableStateOf("") }
var relayError by remember { mutableStateOf(false) }
Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.notification_previews), style = MaterialTheme.typography.titleSmall)
Text(stringResource(R.string.notification_previews_description), style = MaterialTheme.typography.bodySmall)
}
Switch(checked = previews, onCheckedChange = {
previews = it
ClientPrivacyPreferences.setNotificationPreviews(context, it)
})
}
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.gateway_title), style = MaterialTheme.typography.titleSmall)
Text(stringResource(R.string.gateway_description), style = MaterialTheme.typography.bodySmall)
}
Switch(gateway, onCheckedChange = com.bitchat.android.services.bridge.MeshGatewayService::setEnabled)
}
Text(stringResource(R.string.custom_relays), style = MaterialTheme.typography.titleSmall)
Text(stringResource(R.string.custom_relays_description), style = MaterialTheme.typography.bodySmall)
relays.forEach { url ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(url, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodySmall)
TextButton(onClick = { relayManager.removeCustomRelay(url) }) { Text(stringResource(R.string.relay_remove)) }
}
}
OutlinedTextField(
value = relayInput,
onValueChange = { relayInput = it; relayError = false },
label = { Text(stringResource(R.string.relay_url)) },
placeholder = { Text("wss://relay.example") },
singleLine = true,
isError = relayError,
modifier = Modifier.fillMaxWidth()
)
if (relayError) Text(stringResource(R.string.relay_invalid), color = MaterialTheme.colorScheme.error)
TextButton(onClick = {
relayError = !relayManager.addCustomRelay(relayInput)
if (!relayError) relayInput = ""
}, enabled = relayInput.isNotBlank()) { Text(stringResource(R.string.relay_add)) }
}
}

View File

@ -1,7 +1,9 @@
package com.bitchat.android.ui
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.services.meshgraph.MeshGraphService
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.CoroutineScope
@ -23,11 +25,14 @@ class CommandProcessor(
CommandSuggestion("/block", emptyList(), "[nickname]", "block or list blocked peers"),
CommandSuggestion("/channels", emptyList(), null, "show all discovered channels"),
CommandSuggestion("/clear", emptyList(), null, "clear chat messages"),
CommandSuggestion("/group", emptyList(), "<create|invite|remove|leave|list>", "manage private groups"),
CommandSuggestion("/hug", emptyList(), "<nickname>", "send someone a warm hug"),
CommandSuggestion("/j", listOf("/join"), "<channel>", "join or create a channel"),
CommandSuggestion("/m", listOf("/msg"), "<nickname> [message]", "send private message"),
CommandSuggestion("/pay", emptyList(), "<token> [public]", "send a Cashu ecash token"),
CommandSuggestion("/ping", emptyList(), "<nickname>", "measure mesh round-trip time"),
CommandSuggestion("/slap", emptyList(), "<nickname>", "slap someone with a trout"),
CommandSuggestion("/trace", emptyList(), "<nickname>", "estimate the mesh path"),
CommandSuggestion("/unblock", emptyList(), "<nickname>", "unblock a peer"),
CommandSuggestion("/w", emptyList(), null, "see who's online")
)
@ -51,11 +56,50 @@ class CommandProcessor(
"/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage, viewModel)
"/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage, viewModel)
"/channels" -> handleChannelsCommand()
"/group" -> handleGroupCommand(parts, viewModel)
"/ping" -> handlePingCommand(parts, meshService)
"/trace" -> handleTraceCommand(parts, meshService)
else -> handleUnknownCommand(cmd)
}
return true
}
private fun handleGroupCommand(parts: List<String>, viewModel: ChatViewModel?) {
if (viewModel == null) {
addCommandOutput("private groups are unavailable")
return
}
val selectedPeer = state.getSelectedPrivateChatPeerValue()
if (viewModel.selectedLocationChannel.value is ChannelID.Location ||
selectedPeer?.startsWith("nostr_") == true ||
selectedPeer?.startsWith("nostr:") == true
) {
addCommandOutput("groups are only for mesh peers in #mesh")
return
}
viewModel.executeGroupCommand(parts.drop(1).filter(String::isNotBlank)) { result ->
addCommandOutput(result.message)
}
}
private fun addCommandOutput(message: String) {
val destination = state.getSelectedPrivateChatPeerValue()
if (destination != null) {
messageManager.addPrivateMessage(
destination,
BitchatMessage(
sender = "system",
content = message,
timestamp = Date(),
isPrivate = true
)
)
} else {
messageManager.addSystemMessage(message)
}
}
private fun handleJoinCommand(parts: List<String>, myPeerID: String) {
if (parts.size > 1) {
@ -454,6 +498,104 @@ class CommandProcessor(
else -> messageManager.addMessage(message)
}
}
private fun handlePingCommand(parts: List<String>, meshService: MeshService) {
if (!isMeshContext()) {
addCommandOutput("mesh diagnostics are only available in #mesh")
return
}
val targetName = parts.getOrNull(1)?.removePrefix("@")
val peerID = targetName?.let { getPeerIDForNickname(it, meshService) }
if (targetName == null) {
addCommandOutput("usage: /ping <nickname>")
return
}
if (peerID == null) {
addCommandOutput("user '$targetName' not found")
return
}
val destination = currentCommandDestination()
addCommandOutput("pinging $targetName…", destination)
meshService.sendMeshPing(peerID) { result ->
val output = if (result == null) {
"no reply from $targetName"
} else {
val hops = if (result.hopCount == 1) {
"direct (1 hop)"
} else {
"${result.hopCount} hops"
}
"pong from $targetName: ${result.rttMillis} ms · $hops"
}
addCommandOutput(output, destination)
}
}
private fun handleTraceCommand(parts: List<String>, meshService: MeshService) {
if (!isMeshContext()) {
addCommandOutput("mesh diagnostics are only available in #mesh")
return
}
val targetName = parts.getOrNull(1)?.removePrefix("@")
val peerID = targetName?.let { getPeerIDForNickname(it, meshService) }
if (targetName == null) {
addCommandOutput("usage: /trace <nickname>")
return
}
if (peerID == null) {
addCommandOutput("user '$targetName' not found")
return
}
val graph = MeshGraphService.getInstance()
val route = graph.computeRoute(meshService.myPeerID, peerID)
?: meshService.getPeerInfo(peerID)
?.takeIf { it.isConnected && it.isDirectConnection }
?.let { listOf(meshService.myPeerID, peerID) }
if (route == null) {
addCommandOutput("no known path to $targetName")
return
}
val labels = route.mapIndexed { index, id ->
when {
index == 0 -> "you"
id == peerID -> targetName
else -> meshService.getPeerNicknames()[id] ?: id.take(8)
}
}
val hopCount = route.size - 1
val hops = if (hopCount == 1) "1 hop" else "$hopCount hops"
addCommandOutput("estimated path: ${labels.joinToString(" → ")} ($hops)")
}
private sealed interface CommandDestination {
data object Main : CommandDestination
data class Channel(val name: String) : CommandDestination
data class Private(val peerID: String) : CommandDestination
}
private fun currentCommandDestination(): CommandDestination =
state.getSelectedPrivateChatPeerValue()?.let(CommandDestination::Private)
?: state.getCurrentChannelValue()?.let(CommandDestination::Channel)
?: CommandDestination.Main
private fun addCommandOutput(
text: String,
destination: CommandDestination = currentCommandDestination(),
) {
val message = BitchatMessage(
sender = "system",
content = text,
timestamp = Date(),
isRelay = false,
)
when (destination) {
CommandDestination.Main -> messageManager.addMessage(message)
is CommandDestination.Channel -> messageManager.addChannelMessage(destination.name, message)
is CommandDestination.Private -> messageManager.addPrivateMessage(destination.peerID, message)
}
}
private fun isMeshContext(): Boolean =
state.selectedLocationChannel.value.let { it == null || it is ChannelID.Mesh }
private fun handleUnknownCommand(cmd: String) {
val systemMessage = BitchatMessage(
@ -517,7 +659,10 @@ class CommandProcessor(
state.getSelectedPrivateChatPeerValue() == null &&
state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location
return (baseCommands + channelCommands).filterNot {
isPublicGeohash && it.command == "/pay"
(isPublicGeohash && it.command == "/pay") ||
(!isMeshContext() && it.command in setOf("/ping", "/trace")) ||
(it.command == "/group" && (state.selectedLocationChannel.value is ChannelID.Location ||
state.getSelectedPrivateChatPeerValue()?.startsWith("nostr") == true))
}
}

View File

@ -0,0 +1,62 @@
package com.bitchat.android.ui
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.net.TorMode
@Composable
fun ConnectivityBanner() {
val context = LocalContext.current
val lifecycle = LocalLifecycleOwner.current.lifecycle
fun bluetoothReady(): Boolean = runCatching {
if (Build.VERSION.SDK_INT >= 31 && ContextCompat.checkSelfPermission(context,
Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) false
else context.getSystemService(BluetoothManager::class.java)?.adapter?.isEnabled == true
}.getOrDefault(false)
var bluetoothEnabled by remember { mutableStateOf(bluetoothReady()) }
val tor by remember { ArtiTorManager.getInstance().statusFlow }.collectAsStateWithLifecycle()
DisposableEffect(context, lifecycle) {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { bluetoothEnabled = bluetoothReady() }
}
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) bluetoothEnabled = bluetoothReady()
}
ContextCompat.registerReceiver(context, receiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED), ContextCompat.RECEIVER_EXPORTED)
lifecycle.addObserver(observer)
onDispose { context.unregisterReceiver(receiver); lifecycle.removeObserver(observer) }
}
val warning = when {
!bluetoothEnabled -> stringResource(R.string.bluetooth_unavailable_banner)
tor.mode == TorMode.ON && tor.state == ArtiTorManager.TorState.ERROR -> stringResource(R.string.tor_failed_banner)
tor.mode == TorMode.ON && tor.state != ArtiTorManager.TorState.RUNNING -> stringResource(R.string.tor_connecting_banner, tor.bootstrapPercent)
else -> null
}
warning?.let {
Surface(color = MaterialTheme.colorScheme.errorContainer, modifier = Modifier.fillMaxWidth()) {
Text(it, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer)
}
}
}

View File

@ -14,9 +14,21 @@ class DataManager(private val context: Context) {
companion object {
private const val TAG = "DataManager"
private const val PREFS_NAME = "bitchat_prefs"
private const val BLOCKED_USERS_KEY = "blocked_users"
/**
* Reads the current persisted block list for ingress paths that have a
* full Noise key but no live mesh peer/session.
*/
fun isFingerprintBlocked(context: Context, fingerprint: String): Boolean =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getStringSet(BLOCKED_USERS_KEY, emptySet())
?.contains(fingerprint) == true
}
private val prefs: SharedPreferences = context.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE)
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val gson = Gson()
// Channel-related maps that need to persist state
@ -200,13 +212,13 @@ class DataManager(private val context: Context) {
@Synchronized
fun loadBlockedUsers() {
val savedBlockedUsers = prefs.getStringSet("blocked_users", emptySet()) ?: emptySet()
val savedBlockedUsers = prefs.getStringSet(BLOCKED_USERS_KEY, emptySet()) ?: emptySet()
_blockedUsers.clear()
_blockedUsers.addAll(savedBlockedUsers)
}
fun saveBlockedUsers() {
prefs.edit().putStringSet("blocked_users", _blockedUsers).apply()
prefs.edit().putStringSet(BLOCKED_USERS_KEY, _blockedUsers).apply()
}
fun addBlockedUser(fingerprint: String) {

View File

@ -164,6 +164,7 @@ class GeohashViewModel(
teleported
)
val relayManager = NostrRelayManager.getInstance(getApplication())
com.bitchat.android.services.bridge.MeshGatewayService.uplinkIfOffline(event, channel.geohash, liveLocationToken)
relayManager.sendEventToGeohash(
event,
channel.geohash,

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