Add durable private delivery and courier transport

This commit is contained in:
callebtc 2026-09-07 23:30:42 +03:00
parent 936a4cdf6d
commit 5334c08fac
58 changed files with 3645 additions and 202 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -268,6 +268,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
MessageType.ANNOUNCE,
MessageType.MESSAGE,
MessageType.FILE_TRANSFER,
MessageType.COURIER_ENVELOPE,
MessageType.VOICE_FRAME,
MessageType.LEAVE
)) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -85,6 +85,11 @@ class NostrRelayManager private constructor() {
private val _isConnected = MutableStateFlow<Boolean>(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private data class PendingAcceptance(
val callback: () -> Unit,
val pendingRelays: MutableSet<String>
)
private val eventAcceptanceHandlers = java.util.concurrent.ConcurrentHashMap<String, PendingAcceptance>()
// Internal state
private val relaysList = mutableListOf<Relay>()
@ -452,12 +457,14 @@ class NostrRelayManager private constructor() {
fun sendEvent(
event: NostrEvent,
relayUrls: List<String>? = null,
liveLocationToken: Long? = null
) {
liveLocationToken: Long? = null,
onAccepted: (() -> Unit)? = null
): Boolean {
val targetRelays = (relayUrls ?: relaysList.map { it.url })
.filter { it.isNotBlank() }
.distinct()
if (targetRelays.isEmpty()) return
if (targetRelays.isEmpty()) return false
var enqueued = false
val queued = runNetworkAction(liveLocationToken) {
val queueId = messageQueue.enqueue(
@ -465,6 +472,13 @@ class NostrRelayManager private constructor() {
relayUrls = targetRelays,
liveLocationToken = liveLocationToken
) ?: return@runNetworkAction
enqueued = true
if (onAccepted != null) {
eventAcceptanceHandlers[event.id] = PendingAcceptance(
onAccepted,
targetRelays.map { it.trim().trimEnd('/') }.toMutableSet()
)
}
scope.launch {
if (!isNetworkActionAllowed(liveLocationToken)) return@launch
targetRelays.forEach { relayUrl ->
@ -477,7 +491,12 @@ class NostrRelayManager private constructor() {
}
}
}
if (!queued) return
return queued && enqueued
}
fun hasConnectedRelay(relayUrls: Collection<String>): Boolean {
val targets = relayUrls.map { it.trim().trimEnd('/') }.toSet()
return relaysList.any { it.isConnected && it.url.trim().trimEnd('/') in targets }
}
/**
@ -673,6 +692,7 @@ class NostrRelayManager private constructor() {
// Clear any queued messages waiting to be sent
messageQueue.clear()
eventAcceptanceHandlers.clear()
Log.i(TAG, "Cleared all Nostr subscriptions and routing caches")
} catch (e: Exception) {
@ -966,8 +986,14 @@ class NostrRelayManager private constructor() {
is NostrResponse.Ok -> {
val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId)
if (!response.accepted) {
eventAcceptanceHandlers[response.eventId]?.let { pending ->
pending.pendingRelays.remove(relayUrl.trim().trimEnd('/'))
if (pending.pendingRelays.isEmpty()) eventAcceptanceHandlers.remove(response.eventId, pending)
}
val level = if (wasGiftWrap) Log.WARN else Log.ERROR
Log.println(level, TAG, "Event rejected by relay: ${response.message ?: "no reason"}")
} else {
eventAcceptanceHandlers.remove(response.eventId)?.callback?.invoke()
}
}

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,6 +14,7 @@ enum class MessageType(val value: UByte) {
ANNOUNCE(0x01u),
MESSAGE(0x02u), // All user messages (private and broadcast)
LEAVE(0x03u),
COURIER_ENVELOPE(0x04u), // Opaque Noise X store-and-forward envelope
NOISE_HANDSHAKE(0x10u), // Noise handshake
NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message
FRAGMENT(0x20u), // Fragmentation for large packets

View File

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

View File

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

View File

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

View File

@ -1684,6 +1684,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 +1711,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 +1783,7 @@ internal class ConversationDatabase(
reached = nullableInt("delivery_reached") ?: 0,
total = nullableInt("delivery_total") ?: 0
)
7 -> DeliveryStatus.Queued
else -> null
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1020,6 +1020,11 @@ class ChatViewModel(
messageId,
com.bitchat.android.model.DeliveryStatus.Sent
)
} else if (route == com.bitchat.android.services.MessageRouter.RouteResult.QUEUED) {
messageManager.updateMessageDeliveryStatus(
messageId,
com.bitchat.android.model.DeliveryStatus.Queued
)
}
}
onAccepted(accepted)
@ -1483,7 +1488,7 @@ class ChatViewModel(
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear()
} catch (_: Exception) { }
try {
com.bitchat.android.services.MessageRouter.tryGetInstance()?.clearAll()
com.bitchat.android.services.MessageRouter.panicClear(getApplication())
} catch (_: Exception) { }
// Clear all cryptographic data

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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