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 package com.bitchat.android.testhook
import android.content.Context import android.content.Context
import android.content.ContextWrapper
import android.content.Intent import android.content.Intent
import android.util.Log import android.util.Log
import com.bitchat.android.favorites.FavoritesPersistenceService 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.MeshService
import com.bitchat.android.mesh.PrivateMediaPreparation import com.bitchat.android.mesh.PrivateMediaPreparation
import com.bitchat.android.mesh.TransferProgressManager 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.BitchatFilePacket
import com.bitchat.android.model.CourierEnvelope
import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.NoiseSession import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.protocol.BitchatPacket 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.MeshServiceHolder
import com.bitchat.android.service.TransportBridgeService import com.bitchat.android.service.TransportBridgeService
import com.bitchat.android.services.AppStateStore 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.DataManager
import com.bitchat.android.ui.PrivateMediaRecipientResolver import com.bitchat.android.ui.PrivateMediaRecipientResolver
import com.bitchat.android.ui.debug.DebugSettingsManager
import com.bitchat.android.util.AppConstants import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -69,6 +76,13 @@ object TestHookDriver {
"announce" -> announce(context) "announce" -> announce(context)
"broadcast_msg" -> broadcastMsg(context, intent.requiredString("content"), intent.getStringExtra("channel")) "broadcast_msg" -> broadcastMsg(context, intent.requiredString("content"), intent.getStringExtra("channel"))
"dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id")) "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) "dm_recv" -> dmRecv(context, intent)
"msg_recv" -> msgRecv(context, intent) "msg_recv" -> msgRecv(context, intent)
"favorite_set" -> favoriteSet( "favorite_set" -> favoriteSet(
@ -89,7 +103,11 @@ object TestHookDriver {
"ptt_send" -> pttSend(context, intent) "ptt_send" -> pttSend(context, intent)
"ptt_recv" -> pttRecv(context, intent) "ptt_recv" -> pttRecv(context, intent)
"raw_send" -> rawSend(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)) "ble" -> setBle(intent.getBooleanExtra("enabled", true))
"wifi_aware" -> setWifiAware(intent.getBooleanExtra("enabled", true))
"inject_peers" -> injectPeers(intent.getStringExtra("peers")) "inject_peers" -> injectPeers(intent.getStringExtra("peers"))
"state" -> state(context) "state" -> state(context)
"clear_results" -> clearResults(context) "clear_results" -> clearResults(context)
@ -243,6 +261,27 @@ object TestHookDriver {
return ok("dm_send").put("peer", peerID).put("msg_id", id) 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 { private suspend fun dmRecv(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS) val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
val fromPeer = intent.getStringExtra("peer") val fromPeer = intent.getStringExtra("peer")
@ -276,10 +315,11 @@ object TestHookDriver {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS) val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
val contains = intent.getStringExtra("contains") val contains = intent.getStringExtra("contains")
val channel = intent.getStringExtra("channel") val channel = intent.getStringExtra("channel")
val includeExisting = intent.getBooleanExtra("include_existing", false)
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
val mesh = mesh(context) val mesh = mesh(context)
val matches: (com.bitchat.android.model.BitchatMessage) -> Boolean = { msg -> val matches: (com.bitchat.android.model.BitchatMessage) -> Boolean = { msg ->
msg.timestamp.time >= startTime && (includeExisting || msg.timestamp.time >= startTime) &&
msg.senderPeerID != mesh.myPeerID && msg.senderPeerID != mesh.myPeerID &&
(contains == null || msg.content.contains(contains)) && (contains == null || msg.content.contains(contains)) &&
(channel == null || msg.channel == channel) (channel == null || msg.channel == channel)
@ -653,6 +693,128 @@ object TestHookDriver {
.put("peer", peerID) .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 // MARK: - Transport / state
private fun setBle(enabled: Boolean): JSONObject { private fun setBle(enabled: Boolean): JSONObject {
@ -661,6 +823,14 @@ object TestHookDriver {
return ok("ble").put("enabled", enabled) 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 { private fun state(context: Context): JSONObject {
val mesh = mesh(context) val mesh = mesh(context)
val peersJson = peerInfosJson(mesh, AppStateStore.peers.value) val peersJson = peerInfosJson(mesh, AppStateStore.peers.value)

View File

@ -112,6 +112,12 @@ open class EncryptionService(private val context: Context) {
fun getStaticPublicKey(): ByteArray? { fun getStaticPublicKey(): ByteArray? {
return noiseService.getStaticPublicKeyData() 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) * 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 { object BLEPacketPaddingPolicy {
fun shouldPadForBLE(type: UByte): Boolean { fun shouldPadForBLE(type: UByte): Boolean {
return when (MessageType.fromValue(type)) { return when (MessageType.fromValue(type)) {
MessageType.NOISE_ENCRYPTED, MessageType.NOISE_HANDSHAKE -> true MessageType.NOISE_ENCRYPTED, MessageType.NOISE_HANDSHAKE, MessageType.COURIER_ENVELOPE -> true
else -> false 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 { fun cancelTransfer(transferId: String): Boolean {
return packetBroadcaster.cancelTransfer(transferId) return packetBroadcaster.cancelTransfer(transferId)
} }

View File

@ -49,6 +49,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// Core components - each handling specific responsibilities // Core components - each handling specific responsibilities
private val encryptionService = EncryptionService(context) 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) // My peer identification - derived from persisted Noise identity fingerprint (first 16 hex chars)
val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) val myPeerID: String = encryptionService.getIdentityFingerprint().take(16)
@ -156,10 +162,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
gossipSyncManager = GossipSyncManager( gossipSyncManager = GossipSyncManager(
myPeerID = myPeerID, myPeerID = myPeerID,
scope = serviceScope, scope = serviceScope,
context = context,
configProvider = object : GossipSyncManager.ConfigProvider { configProvider = object : GossipSyncManager.ConfigProvider {
override fun seenCapacity(): Int = try { override fun seenCapacity(): Int = try {
com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(500) com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(1000)
} catch (_: Exception) { 500 } } catch (_: Exception) { 1000 }
override fun gcsMaxBytes(): Int = try { override fun gcsMaxBytes(): Int = try {
com.bitchat.android.ui.debug.DebugPreferenceManager.getGcsMaxFilterBytes(400) com.bitchat.android.ui.debug.DebugPreferenceManager.getGcsMaxFilterBytes(400)
@ -177,6 +184,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
if (isBleTransportEnabled()) { if (isBleTransportEnabled()) {
TransportBridgeService.register("BLE", this) TransportBridgeService.register("BLE", this)
} }
bridgeCourierService.start()
// Inject dynamic direct connection check into PeerManager // Inject dynamic direct connection check into PeerManager
// Matches iOS logic: checks if we have an active hardware mapping for this peer // 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) 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 { private fun broadcastRoutedPacket(routed: RoutedPacket): Boolean {
if (!isBleTransportEnabled()) return false if (!isBleTransportEnabled()) return false
val queued = connectionManager.broadcastPacket(routed) val queued = connectionManager.broadcastPacket(routed)
@ -514,6 +527,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
} }
override fun onDeliveryAckReceived(messageID: String, peerID: String) { 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. // Status events can arrive while MainActivity has detached the UI delegate.
// Persist first so the next UI collector observes the advancement. // Persist first so the next UI collector observes the advancement.
try { try {
@ -526,6 +540,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
} }
override fun onReadReceiptReceived(messageID: String, peerID: String) { override fun onReadReceiptReceived(messageID: String, peerID: String) {
try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageID, peerID) } catch (_: Exception) { }
try { try {
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
messageID, messageID,
@ -574,6 +589,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean {
return runBlocking { messageHandler.handleNoiseEncrypted(routed) } return runBlocking { messageHandler.handleNoiseEncrypted(routed) }
} }
override fun handleCourierEnvelope(routed: RoutedPacket): Boolean {
return handleCourierEnvelopePacket(routed)
}
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
val result = messageHandler.handleAnnounceWithResult(routed) val result = messageHandler.handleAnnounceWithResult(routed)
@ -599,6 +618,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
} }
} }
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
handleCourierAnnounce(routed)
return true return true
} }
@ -608,7 +628,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
try { try {
val pkt = routed.packet val pkt = routed.packet
val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST)) 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) gossipSyncManager.onPublicPacketSeen(pkt)
} }
} catch (_: Exception) { } } catch (_: Exception) { }
@ -809,6 +829,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
* Stop all mesh services * Stop all mesh services
*/ */
fun stopServices() { fun stopServices() {
if (bridgeCourierService.isStarted) bridgeCourierService.stop()
if (!isActive) { if (!isActive) {
Log.w(TAG, "Mesh service not active, ignoring stop request") Log.w(TAG, "Mesh service not active, ignoring stop request")
return return
@ -1442,6 +1463,209 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
return peerManager.getPeerInfo(peerID) 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 * Update peer information with verification data
*/ */
@ -1620,19 +1844,21 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
*/ */
fun clearAllInternalData() { fun clearAllInternalData() {
Log.w(TAG, "Clearing all mesh service internal data") Log.w(TAG, "Clearing all mesh service internal data")
try { val operations = listOf<() -> Unit>(
// Stop services to cease broadcasting old ID immediately ::stopServices,
stopServices() fragmentManager::clearAllFragments,
storeForwardManager::clearAllCache,
// Clear all managers securityManager::clearAllData,
fragmentManager.clearAllFragments() peerManager::clearAllPeers,
storeForwardManager.clearAllCache() peerManager::clearAllFingerprints,
securityManager.clearAllData() courierStore::wipe,
peerManager.clearAllPeers() bridgeCourierService::stop,
peerManager.clearAllFingerprints() gossipSyncManager::clear
try { gossipSyncManager.clear() } catch (_: Exception) { } )
} catch (e: Exception) { operations.forEach { operation ->
Log.e(TAG, "Error clearing mesh service internal data: ${e.message}") 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.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.channels.actor import kotlinx.coroutines.channels.actor
import java.util.ArrayDeque import java.util.ArrayDeque
@ -55,6 +56,7 @@ class BluetoothPacketBroadcaster(
private const val MAX_PENDING_BYTES_PER_LINK = 1_048_576 private const val MAX_PENDING_BYTES_PER_LINK = 1_048_576
private const val SEND_RETRY_DELAY_MS = 15L private const val SEND_RETRY_DELAY_MS = 15L
private const val MAX_CALLBACK_RETRIES = 3 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?) // Optional nickname resolver injected by higher layer (peerID -> nickname?)
@ -140,6 +142,7 @@ class BluetoothPacketBroadcaster(
val gatt: BluetoothGatt? = null, val gatt: BluetoothGatt? = null,
val gattServer: BluetoothGattServer? = null, val gattServer: BluetoothGattServer? = null,
val characteristic: BluetoothGattCharacteristic, val characteristic: BluetoothGattCharacteristic,
val completion: CompletableDeferred<Boolean>? = null,
var callbackFailures: Int = 0 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( fun sendPacketToLink(
routed: RoutedPacket, routed: RoutedPacket,
deviceAddress: String, deviceAddress: String,
@ -267,6 +294,35 @@ class BluetoothPacketBroadcaster(
return false 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 * Public entry point for broadcasting - submits request to actor for serialization
@ -474,7 +530,8 @@ class BluetoothPacketBroadcaster(
device: BluetoothDevice, device: BluetoothDevice,
data: ByteArray, data: ByteArray,
gattServer: BluetoothGattServer?, gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic? characteristic: BluetoothGattCharacteristic?,
completion: CompletableDeferred<Boolean>? = null
): Boolean { ): Boolean {
val server = gattServer ?: return false val server = gattServer ?: return false
val char = characteristic ?: return false val char = characteristic ?: return false
@ -484,7 +541,13 @@ class BluetoothPacketBroadcaster(
?: return false ?: return false
return enqueueSend( return enqueueSend(
SendKey(device.address, linkID, SendDirection.SERVER_NOTIFICATION), 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( private fun writeToDeviceConn(
deviceConn: BluetoothConnectionTracker.DeviceConnection, deviceConn: BluetoothConnectionTracker.DeviceConnection,
data: ByteArray data: ByteArray,
completion: CompletableDeferred<Boolean>? = null
): Boolean { ): Boolean {
val gatt = deviceConn.gatt ?: return false val gatt = deviceConn.gatt ?: return false
val char = deviceConn.characteristic ?: return false val char = deviceConn.characteristic ?: return false
return enqueueSend( return enqueueSend(
SendKey(deviceConn.device.address, deviceConn.linkID, SendDirection.CLIENT_WRITE), 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.pending.removeFirst()
state.pendingBytes -= head.data.size state.pendingBytes -= head.data.size
head.completion?.complete(status == BluetoothGatt.GATT_SUCCESS)
if (state.pending.isEmpty()) { if (state.pending.isEmpty()) {
sendStates.remove(key) sendStates.remove(key)
false false
@ -645,8 +716,13 @@ class BluetoothPacketBroadcaster(
fun onLinkDisconnected(deviceAddress: String, linkID: String?) { fun onLinkDisconnected(deviceAddress: String, linkID: String?) {
synchronized(sendLock) { synchronized(sendLock) {
sendStates.keys.removeAll { key -> val iterator = sendStates.entries.iterator()
key.deviceAddress == deviceAddress && (linkID == null || key.linkID == linkID) 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 * Shutdown the broadcaster actor gracefully
*/ */
fun shutdown() { 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 // Close the actor gracefully
broadcasterActor.close() 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 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 { fun cancelTransfer(transferId: String): Boolean {
val job = transferJobs.remove(transferId) ?: return false val job = transferJobs.remove(transferId) ?: return false
job.cancel() job.cancel()

View File

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

View File

@ -41,6 +41,19 @@ interface MeshService {
fun initiateNoiseHandshake(peerID: String) fun initiateNoiseHandshake(peerID: String)
fun getPeerFingerprint(peerID: String): String? fun getPeerFingerprint(peerID: String): String?
fun getPeerInfo(peerID: String): PeerInfo? 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( fun updatePeerInfo(
peerID: String, peerID: String,
nickname: String, nickname: String,

View File

@ -207,6 +207,32 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
return true 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 * 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 * 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) { private suspend fun handleBroadcastMessage(routed: RoutedPacket) {
val packet = routed.packet val packet = routed.packet
val peerID = routed.peerID ?: "unknown" 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 // Enforce: only accept public messages from verified peers we know
val peerInfo = delegate?.getPeerInfo(peerID) val peerInfo = delegate?.getPeerInfo(peerID)

View File

@ -122,6 +122,22 @@ class PacketProcessor(private val myPeerID: String) {
var validPacket = true var validPacket = true
val messageType = MessageType.fromValue(packet.type) 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) // Verbose logging to debug manager (and chat via ChatViewModel observer)
try { try {
val mt = messageType?.name ?: packet.type.toString() val mt = messageType?.name ?: packet.type.toString()
@ -146,6 +162,7 @@ class PacketProcessor(private val myPeerID: String) {
when (messageType) { when (messageType) {
MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed) MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed)
MessageType.NOISE_ENCRYPTED -> validPacket = handleNoiseEncrypted(routed) MessageType.NOISE_ENCRYPTED -> validPacket = handleNoiseEncrypted(routed)
MessageType.COURIER_ENVELOPE -> validPacket = delegate?.handleCourierEnvelope(routed) ?: false
MessageType.FILE_TRANSFER -> handleMessage(routed) MessageType.FILE_TRANSFER -> handleMessage(routed)
else -> { else -> {
validPacket = false validPacket = false
@ -295,6 +312,7 @@ interface PacketProcessorDelegate {
// Message type handlers // Message type handlers
fun handleNoiseHandshake(routed: RoutedPacket): Boolean fun handleNoiseHandshake(routed: RoutedPacket): Boolean
fun handleNoiseEncrypted(routed: RoutedPacket): Boolean fun handleNoiseEncrypted(routed: RoutedPacket): Boolean
fun handleCourierEnvelope(routed: RoutedPacket): Boolean = false
suspend fun handleAnnounce(routed: RoutedPacket): Boolean suspend fun handleAnnounce(routed: RoutedPacket): Boolean
fun handleMessage(routed: RoutedPacket) fun handleMessage(routed: RoutedPacket)
fun handleVoiceFrame(routed: RoutedPacket): Boolean = false fun handleVoiceFrame(routed: RoutedPacket): Boolean = false

View File

@ -268,6 +268,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
MessageType.ANNOUNCE, MessageType.ANNOUNCE,
MessageType.MESSAGE, MessageType.MESSAGE,
MessageType.FILE_TRANSFER, MessageType.FILE_TRANSFER,
MessageType.COURIER_ENVELOPE,
MessageType.VOICE_FRAME, MessageType.VOICE_FRAME,
MessageType.LEAVE 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) { override fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
when { when {
isBleReady(recipientPeerID) -> bluetooth.sendReadReceipt(messageID, recipientPeerID, readerNickname) isBleReady(recipientPeerID) -> bluetooth.sendReadReceipt(messageID, recipientPeerID, readerNickname)

View File

@ -22,6 +22,9 @@ sealed class DeliveryStatus : Parcelable {
@Parcelize @Parcelize
object Sending : DeliveryStatus() object Sending : DeliveryStatus()
@Parcelize
object Queued : DeliveryStatus()
@Parcelize @Parcelize
object Sent : DeliveryStatus() object Sent : DeliveryStatus()
@ -40,6 +43,7 @@ sealed class DeliveryStatus : Parcelable {
fun getDisplayText(): String { fun getDisplayText(): String {
return when (this) { return when (this) {
is Sending -> "Sending..." is Sending -> "Sending..."
is Queued -> "Queued"
is Sent -> "Sent" is Sent -> "Sent"
is Delivered -> "Delivered to ${this.to}" is Delivered -> "Delivered to ${this.to}"
is Read -> "Read by ${this.by}" is Read -> "Read by ${this.by}"
@ -362,4 +366,3 @@ data class BitchatMessage(
return result 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 package com.bitchat.android.model
import com.bitchat.android.sync.SyncDefaults 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. * 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 * - 0x01: P (uint8) — Golomb-Rice parameter
* - 0x02: M (uint32, big-endian) — hash range (N * 2^P) * - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
* - 0x03: data (opaque) — GR bitstream bytes * - 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( data class RequestSyncPacket(
val p: Int, val p: Int,
val m: Long, val m: Long,
val data: ByteArray val data: ByteArray,
val types: SyncTypeFlags? = null,
val sinceTimestamp: ULong? = null
) { ) {
fun encode(): ByteArray { fun encode(): ByteArray {
val out = ArrayList<Byte>() val out = ArrayList<Byte>()
@ -38,6 +44,15 @@ data class RequestSyncPacket(
) )
// data // data
putTLV(0x03, 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() return out.toByteArray()
} }
@ -50,6 +65,8 @@ data class RequestSyncPacket(
var p: Int? = null var p: Int? = null
var m: Long? = null var m: Long? = null
var payload: ByteArray? = null var payload: ByteArray? = null
var types: SyncTypeFlags? = null
var sinceTimestamp: ULong? = null
while (off + 3 <= data.size) { while (off + 3 <= data.size) {
val t = (data[off].toInt() and 0xFF); off += 1 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 if (v.size > MAX_ACCEPT_FILTER_BYTES) return null
payload = v 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 pp = p ?: return null
val mm = m ?: return null val mm = m ?: return null
val dd = payload ?: return null val dd = payload ?: return null
if (pp < 1 || mm <= 0L) return null if (pp !in 1..GCSFilter.MAX_P || mm <= 0L) return null
return RequestSyncPacket(pp, mm, dd) 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.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.PeerFingerprintManager import com.bitchat.android.mesh.PeerFingerprintManager
import com.bitchat.android.noise.southernstorm.protocol.Noise import com.bitchat.android.noise.southernstorm.protocol.Noise
import com.bitchat.android.noise.southernstorm.protocol.HandshakeState
import java.security.MessageDigest import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@ -18,7 +19,10 @@ import java.util.concurrent.ConcurrentHashMap
* - Channel encryption using password-derived keys * - Channel encryption using password-derived keys
* - Peer fingerprint mapping and identity persistence * - 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 { companion object {
private const val TAG = "NoiseEncryptionService" private const val TAG = "NoiseEncryptionService"
@ -42,9 +46,6 @@ class NoiseEncryptionService(private val context: Context) {
// Channel encryption for password-protected channels // Channel encryption for password-protected channels
private val channelEncryption = NoiseChannelEncryption() private val channelEncryption = NoiseChannelEncryption()
// Identity management for peer ID rotation support
private val identityStateManager: SecureIdentityStateManager
// Centralized fingerprint management - NO LOCAL STORAGE // Centralized fingerprint management - NO LOCAL STORAGE
private val fingerprintManager = PeerFingerprintManager.getInstance() private val fingerprintManager = PeerFingerprintManager.getInstance()
@ -53,9 +54,6 @@ class NoiseEncryptionService(private val context: Context) {
var onHandshakeRequired: ((String) -> Unit)? = null // peerID needs handshake var onHandshakeRequired: ((String) -> Unit)? = null // peerID needs handshake
init { init {
// Initialize identity state manager for persistent storage
identityStateManager = SecureIdentityStateManager(context)
// Load or create keys - temporary placeholders // Load or create keys - temporary placeholders
staticIdentityPrivateKey = ByteArray(32) staticIdentityPrivateKey = ByteArray(32)
staticIdentityPublicKey = ByteArray(32) staticIdentityPublicKey = ByteArray(32)
@ -147,6 +145,40 @@ class NoiseEncryptionService(private val context: Context) {
return sessionManager.getRemoteStaticKey(peerID) 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? = fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? =
sessionManager.getAuthenticatedSession(peerID) 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 -> { NoisePayloadType.DELIVERED -> {
val messageId = String(payload.data, Charsets.UTF_8) val messageId = String(payload.data, Charsets.UTF_8)
com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageId, conversationID)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
updateDeliveryStatus( updateDeliveryStatus(
messageId, messageId,
@ -197,6 +198,7 @@ class NostrDirectMessageHandler(
} }
NoisePayloadType.READ_RECEIPT -> { NoisePayloadType.READ_RECEIPT -> {
val messageId = String(payload.data, Charsets.UTF_8) val messageId = String(payload.data, Charsets.UTF_8)
com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageId, conversationID)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
updateDeliveryStatus( updateDeliveryStatus(
messageId, messageId,

View File

@ -214,6 +214,7 @@ object NostrKind {
const val FILE_MESSAGE = 15 // NIP-17 file message (unsigned) const val FILE_MESSAGE = 15 // NIP-17 file message (unsigned)
const val SEAL = 13 // NIP-17 sealed event const val SEAL = 13 // NIP-17 sealed event
const val GIFT_WRAP = 1059 // NIP-17 gift wrap 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 EPHEMERAL_EVENT = 20000 // For geohash channels
const val GEOHASH_PRESENCE = 20001 // For geohash presence heartbeat 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) private val _isConnected = MutableStateFlow<Boolean>(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow() 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 // Internal state
private val relaysList = mutableListOf<Relay>() private val relaysList = mutableListOf<Relay>()
@ -452,12 +457,14 @@ class NostrRelayManager private constructor() {
fun sendEvent( fun sendEvent(
event: NostrEvent, event: NostrEvent,
relayUrls: List<String>? = null, relayUrls: List<String>? = null,
liveLocationToken: Long? = null liveLocationToken: Long? = null,
) { onAccepted: (() -> Unit)? = null
): Boolean {
val targetRelays = (relayUrls ?: relaysList.map { it.url }) val targetRelays = (relayUrls ?: relaysList.map { it.url })
.filter { it.isNotBlank() } .filter { it.isNotBlank() }
.distinct() .distinct()
if (targetRelays.isEmpty()) return if (targetRelays.isEmpty()) return false
var enqueued = false
val queued = runNetworkAction(liveLocationToken) { val queued = runNetworkAction(liveLocationToken) {
val queueId = messageQueue.enqueue( val queueId = messageQueue.enqueue(
@ -465,6 +472,13 @@ class NostrRelayManager private constructor() {
relayUrls = targetRelays, relayUrls = targetRelays,
liveLocationToken = liveLocationToken liveLocationToken = liveLocationToken
) ?: return@runNetworkAction ) ?: return@runNetworkAction
enqueued = true
if (onAccepted != null) {
eventAcceptanceHandlers[event.id] = PendingAcceptance(
onAccepted,
targetRelays.map { it.trim().trimEnd('/') }.toMutableSet()
)
}
scope.launch { scope.launch {
if (!isNetworkActionAllowed(liveLocationToken)) return@launch if (!isNetworkActionAllowed(liveLocationToken)) return@launch
targetRelays.forEach { relayUrl -> 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 // Clear any queued messages waiting to be sent
messageQueue.clear() messageQueue.clear()
eventAcceptanceHandlers.clear()
Log.i(TAG, "Cleared all Nostr subscriptions and routing caches") Log.i(TAG, "Cleared all Nostr subscriptions and routing caches")
} catch (e: Exception) { } catch (e: Exception) {
@ -966,8 +986,14 @@ class NostrRelayManager private constructor() {
is NostrResponse.Ok -> { is NostrResponse.Ok -> {
val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId) val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId)
if (!response.accepted) { 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 val level = if (wasGiftWrap) Log.WARN else Log.ERROR
Log.println(level, TAG, "Event rejected by relay: ${response.message ?: "no reason"}") 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 // MARK: - Transport Interface Methods
val myPeerID: String get() = senderPeerID val myPeerID: String get() = senderPeerID
fun canDeliverPromptly(): Boolean = try {
NostrRelayManager.getInstance(context)
.hasConnectedRelay(NostrRelayManager.defaultRelays())
} catch (_: Exception) { false }
fun sendPrivateMessage( fun sendPrivateMessage(
content: String, content: String,

View File

@ -14,6 +14,7 @@ enum class MessageType(val value: UByte) {
ANNOUNCE(0x01u), ANNOUNCE(0x01u),
MESSAGE(0x02u), // All user messages (private and broadcast) MESSAGE(0x02u), // All user messages (private and broadcast)
LEAVE(0x03u), LEAVE(0x03u),
COURIER_ENVELOPE(0x04u), // Opaque Noise X store-and-forward envelope
NOISE_HANDSHAKE(0x10u), // Noise handshake NOISE_HANDSHAKE(0x10u), // Noise handshake
NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message
FRAGMENT(0x20u), // Fragmentation for large packets 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). * Send a packet to a specific peer via this transport (optional).
*/ */
fun sendToPeer(peerID: String, packet: BitchatPacket) { } 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>() 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? { private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): PreparedForward? {
if (packet.ttl == 0u.toUByte()) { if (packet.ttl == 0u.toUByte()) {
Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired") 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) { private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
null -> 0 null -> 0
is DeliveryStatus.Sending -> 1 is DeliveryStatus.Sending -> 1
is DeliveryStatus.Sent -> 2 is DeliveryStatus.Queued -> 2
is DeliveryStatus.PartiallyDelivered -> 3 is DeliveryStatus.Sent -> 3
is DeliveryStatus.Delivered -> 4 is DeliveryStatus.PartiallyDelivered -> 4
is DeliveryStatus.Read -> 5 is DeliveryStatus.Delivered -> 5
is DeliveryStatus.Read -> 6
is DeliveryStatus.Failed -> 0 is DeliveryStatus.Failed -> 0
} }

View File

@ -72,6 +72,9 @@ object ContactDirectory {
favorite != null -> favorite.peerNoisePublicKey favorite != null -> favorite.peerNoisePublicKey
ContactIdentityResolver.isNoiseKeyHex(peerOrConversationID) -> ContactIdentityResolver.isNoiseKeyHex(peerOrConversationID) ->
ContactIdentityResolver.bytesFromHex(peerOrConversationID) ContactIdentityResolver.bytesFromHex(peerOrConversationID)
contactFingerprint != null -> cachedNoiseKeyForFingerprint(contactFingerprint)
ContactIdentityResolver.isMeshPeerId(peerOrConversationID) ->
noiseKeyForAlias(peerOrConversationID)
else -> null else -> null
} }
val liveMeshPeerID = contactFingerprint?.let { findLiveMeshPeerForFingerprint(it) } 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? { private fun cachedFingerprintNickname(fingerprint: String): String? {
val context = appContext ?: return null val context = appContext ?: return null
return try { return try {

View File

@ -1684,6 +1684,7 @@ internal class ConversationDatabase(
null -> put("delivery_type", 0) null -> put("delivery_type", 0)
DeliveryStatus.Sending -> put("delivery_type", 1) DeliveryStatus.Sending -> put("delivery_type", 1)
DeliveryStatus.Sent -> put("delivery_type", 2) DeliveryStatus.Sent -> put("delivery_type", 2)
DeliveryStatus.Queued -> put("delivery_type", 7)
is DeliveryStatus.Delivered -> { is DeliveryStatus.Delivered -> {
put("delivery_type", 3) put("delivery_type", 3)
if (includeSensitiveText) put("delivery_text", status.to) else putNull("delivery_text") if (includeSensitiveText) put("delivery_text", status.to) else putNull("delivery_text")
@ -1710,10 +1711,11 @@ internal class ConversationDatabase(
null -> 0 null -> 0
is DeliveryStatus.Failed -> 0 is DeliveryStatus.Failed -> 0
DeliveryStatus.Sending -> 1 DeliveryStatus.Sending -> 1
DeliveryStatus.Sent -> 2 DeliveryStatus.Queued -> 2
is DeliveryStatus.PartiallyDelivered -> 3 DeliveryStatus.Sent -> 3
is DeliveryStatus.Delivered -> 4 is DeliveryStatus.PartiallyDelivered -> 4
is DeliveryStatus.Read -> 5 is DeliveryStatus.Delivered -> 5
is DeliveryStatus.Read -> 6
} }
private fun Cursor.toMessage(): BitchatMessage { private fun Cursor.toMessage(): BitchatMessage {
@ -1781,6 +1783,7 @@ internal class ConversationDatabase(
reached = nullableInt("delivery_reached") ?: 0, reached = nullableInt("delivery_reached") ?: 0,
total = nullableInt("delivery_total") ?: 0 total = nullableInt("delivery_total") ?: 0
) )
7 -> DeliveryStatus.Queued
else -> null 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 DROPPED
} }
private data class QueuedMessage(
val content: String,
val nickname: String,
val messageID: String,
val enqueuedAtMs: Long
)
private data class ConversationRetry( private data class ConversationRetry(
val handshakeAttempts: Int, val handshakeAttempts: Int,
val nextHandshakeAttemptAtMs: Long 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_TICK_MS = AppConstants.Router.OUTBOX_TICK_MS
private const val OUTBOX_MESSAGE_TTL_MS = AppConstants.Router.OUTBOX_MESSAGE_TTL_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 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 private val HANDSHAKE_RETRY_BACKOFF_MS = AppConstants.Router.HANDSHAKE_RETRY_BACKOFF_MS
@Volatile private var INSTANCE: MessageRouter? = null @Volatile private var INSTANCE: MessageRouter? = null
internal var disableSchedulerForTesting = false internal var disableSchedulerForTesting = false
internal var outboxStoreFactory: (Context) -> MessageOutboxStore = ::MessageOutboxStore
fun tryGetInstance(): MessageRouter? = INSTANCE fun tryGetInstance(): MessageRouter? = INSTANCE
fun getInstance(context: Context, mesh: MeshService): MessageRouter { fun getInstance(context: Context, mesh: MeshService): MessageRouter {
val instance = INSTANCE ?: synchronized(this) { val instance = INSTANCE ?: synchronized(this) {
@ -78,10 +76,19 @@ class MessageRouter private constructor(
INSTANCE?.schedulerScope?.cancel() INSTANCE?.schedulerScope?.cancel()
INSTANCE = null 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 // 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 // Per-conversation handshake retry state for queued messages
private val retryState = ConcurrentHashMap<String, ConversationRetry>() private val retryState = ConcurrentHashMap<String, ConversationRetry>()
@ -99,9 +106,13 @@ class MessageRouter private constructor(
startOutboxScheduler() startOutboxScheduler()
} }
@Synchronized
fun clearAll() { fun clearAll() {
schedulerJob?.cancel()
schedulerJob = null
outbox.clear() outbox.clear()
retryState.clear() retryState.clear()
outboxStore.wipe()
Log.d(TAG, "Cleared all MessageRouter outbox messages and retry state") 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 hasMesh = meshTarget?.let { isConnected(mesh, it) } == true
val entry = MessageOutboxStore.Entry(content, recipientNickname, messageID, clock())
enqueue(conversationID, entry)
if (meshTarget != null && isReady(mesh, meshTarget)) { if (meshTarget != null && isReady(mesh, meshTarget)) {
Log.d(TAG, "Routing PM via mesh to ${meshTarget} msg_id=${messageID.take(8)}…") Log.d(TAG, "Routing PM via mesh to ${meshTarget} msg_id=${messageID.take(8)}…")
mesh.sendPrivateMessage(content, meshTarget, recipientNickname, messageID) mesh.sendPrivateMessage(content, meshTarget, recipientNickname, messageID)
markAttempt(conversationID, messageID)
return RouteResult.MESH return RouteResult.MESH
} else if (canSendViaNostr(nostrTarget)) { } else if (canSendViaNostr(nostrTarget)) {
Log.d(TAG, "Routing PM via Nostr to ${conversationID.take(32)}… msg_id=${messageID.take(8)}…") Log.d(TAG, "Routing PM via Nostr to ${conversationID.take(32)}… msg_id=${messageID.take(8)}…")
nostr.sendPrivateMessage(content, nostrTarget, recipientNickname, messageID) 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 { } else {
Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}…") 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)}…") Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}…")
if (hasMesh) meshTarget?.let { kickHandshake(conversationID, it, immediate = true) } if (hasMesh) meshTarget?.let { kickHandshake(conversationID, it, immediate = true) }
return RouteResult.QUEUED return RouteResult.QUEUED
@ -209,12 +226,17 @@ class MessageRouter private constructor(
val resolution = ContactDirectory.resolve(conversationID) val resolution = ContactDirectory.resolve(conversationID)
val meshTarget = resolution.meshPeerID val meshTarget = resolution.meshPeerID
val nostrTarget = resolution.noiseKeyHex ?: conversationID 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)) { if (meshTarget != null && isReady(mesh, meshTarget)) {
mesh.sendPrivateMessage(entry.content, meshTarget, entry.nickname, entry.messageID) mesh.sendPrivateMessage(entry.content, meshTarget, entry.nickname, entry.messageID)
iterator.remove() entry.sendAttempts++
entry.lastAttemptAtMs = clock()
} else if (canSendViaNostr(nostrTarget)) { } else if (canSendViaNostr(nostrTarget)) {
nostr.sendPrivateMessage(entry.content, nostrTarget, entry.nickname, entry.messageID) nostr.sendPrivateMessage(entry.content, nostrTarget, entry.nickname, entry.messageID)
iterator.remove() entry.sendAttempts++
entry.lastAttemptAtMs = clock()
if (!canDeliverViaNostrPromptly()) attemptCourierDeposit(conversationID, entry)
} }
} }
if (queued.isEmpty()) { if (queued.isEmpty()) {
@ -223,6 +245,7 @@ class MessageRouter private constructor(
retryState.remove(conversationID) retryState.remove(conversationID)
retryState.remove(peerID) retryState.remove(peerID)
} }
persistOutbox()
} }
// Flush everything (rarely used) // Flush everything (rarely used)
@ -231,14 +254,120 @@ class MessageRouter private constructor(
} }
@Synchronized @Synchronized
private fun enqueue(conversationID: String, entry: QueuedMessage) { private fun enqueue(conversationID: String, entry: MessageOutboxStore.Entry) {
val queue = outbox.getOrPut(conversationID) { mutableListOf() } val queue = outbox.getOrPut(conversationID) { mutableListOf() }
if (queue.any { it.messageID == entry.messageID }) return
queue.add(entry) queue.add(entry)
while (queue.size > OUTBOX_MAX_PER_PEER) { while (queue.size > OUTBOX_MAX_PER_PEER) {
val evicted = queue.removeAt(0) val evicted = queue.removeAt(0)
Log.w(TAG, "Outbox full for ${conversationID.take(16)}…; evicting oldest msg_id=${evicted.messageID.take(8)}…") Log.w(TAG, "Outbox full for ${conversationID.take(16)}…; evicting oldest msg_id=${evicted.messageID.take(8)}…")
notifyExpired(evicted.messageID) 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) { private fun notifyExpired(messageID: String) {
@ -286,6 +415,7 @@ class MessageRouter private constructor(
* follow the MeshForegroundService lifecycle; getInstance restarts the scheduler * follow the MeshForegroundService lifecycle; getInstance restarts the scheduler
* and rebinds the mesh reference when the service comes back. * and rebinds the mesh reference when the service comes back.
*/ */
@Synchronized
fun stopOutboxScheduler() { fun stopOutboxScheduler() {
schedulerJob?.cancel() schedulerJob?.cancel()
schedulerJob = null schedulerJob = null
@ -304,6 +434,7 @@ class MessageRouter private constructor(
expireOldEntries(conversationID, nowMs) expireOldEntries(conversationID, nowMs)
val queued = outbox[conversationID] ?: return@forEach val queued = outbox[conversationID] ?: return@forEach
if (queued.isEmpty()) return@forEach if (queued.isEmpty()) return@forEach
queued.forEach { attemptCourierDeposit(conversationID, it) }
val resolution = ContactDirectory.resolve(conversationID) val resolution = ContactDirectory.resolve(conversationID)
val meshTarget = resolution.meshPeerID val meshTarget = resolution.meshPeerID
@ -338,6 +469,7 @@ class MessageRouter private constructor(
outbox.remove(conversationID, queued) outbox.remove(conversationID, queued)
retryState.remove(conversationID) retryState.remove(conversationID)
} }
persistOutbox()
} }
private fun canSendViaNostr(peerID: String): Boolean { private fun canSendViaNostr(peerID: String): Boolean {
@ -385,8 +517,18 @@ class MessageRouter private constructor(
} catch (_: Exception) { null } } catch (_: Exception) { null }
noiseHex?.let { noiseHex?.let {
kickHandshakeIfPending(it) 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 } } catch (_: Exception) { null }
noiseHex?.let { noiseHex?.let {
resetRetry(it) resetRetry(it)
flushOutboxFor(it) if (ContactDirectory.canonicalConversationId(it) != ContactDirectory.canonicalConversationId(peerID)) {
flushOutboxFor(it)
}
} }
} }

View File

@ -21,9 +21,12 @@ object GCSFilter {
data class Params( data class Params(
val p: Int, // Golomb-Rice parameter (>= 1) val p: Int, // Golomb-Rice parameter (>= 1)
val m: Long, // Range M = N * 2^P 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 // Derive P from target FPR; FPR ~= 1 / 2^P
fun deriveP(targetFpr: Double): Int { fun deriveP(targetFpr: Double): Int {
val f = targetFpr.coerceIn(0.000001, 0.25) val f = targetFpr.coerceIn(0.000001, 0.25)
@ -66,7 +69,12 @@ object GCSFilter {
encoded = encode(mapped, p) 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 { 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 com.bitchat.android.protocol.SpecialRecipients
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap 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. * Gossip-based synchronization manager using on-demand GCS filters.
@ -17,7 +20,8 @@ import java.util.concurrent.ConcurrentHashMap
class GossipSyncManager( class GossipSyncManager(
private val myPeerID: String, private val myPeerID: String,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val configProvider: ConfigProvider private val configProvider: ConfigProvider,
context: Context? = null
) { ) {
interface Delegate { interface Delegate {
fun sendPacket(packet: BitchatPacket) fun sendPacket(packet: BitchatPacket)
@ -33,6 +37,10 @@ class GossipSyncManager(
companion object { companion object {
private const val TAG = "GossipSyncManager" 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 var delegate: Delegate? = null
@ -44,11 +52,15 @@ class GossipSyncManager(
// Stored packets for sync: // Stored packets for sync:
// - broadcast messages: keep up to seenCapacity() most recent, keyed by packetId // - broadcast messages: keep up to seenCapacity() most recent, keyed by packetId
private val messages = LinkedHashMap<String, BitchatPacket>() 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 // - announcements: only keep latest per sender peerID
private val latestAnnouncementByPeer = ConcurrentHashMap<String, Pair<String, BitchatPacket>>() private val latestAnnouncementByPeer = ConcurrentHashMap<String, Pair<String, BitchatPacket>>()
private var periodicJob: Job? = null private var periodicJob: Job? = null
private var cleanupJob: Job? = null private var cleanupJob: Job? = null
init { restoreArchive() }
fun start() { fun start() {
periodicJob?.cancel() periodicJob?.cancel()
periodicJob = scope.launch(Dispatchers.IO) { periodicJob = scope.launch(Dispatchers.IO) {
@ -84,7 +96,9 @@ class GossipSyncManager(
synchronized(messages) { synchronized(messages) {
messages.clear() messages.clear()
} }
synchronized(fragments) { fragments.clear() }
latestAnnouncementByPeer.clear() latestAnnouncementByPeer.clear()
archiveFile?.delete()
Log.d(TAG, "Cleared all gossip sync messages and announcements") Log.d(TAG, "Cleared all gossip sync messages and announcements")
} }
@ -106,13 +120,20 @@ class GossipSyncManager(
// Only ANNOUNCE or broadcast MESSAGE // Only ANNOUNCE or broadcast MESSAGE
val mt = MessageType.fromValue(packet.type) val mt = MessageType.fromValue(packet.type)
val isBroadcastMessage = (mt == MessageType.MESSAGE && (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))) 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) 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 idBytes = PacketIdUtil.computeIdBytes(packet)
val id = idBytes.joinToString("") { b -> "%02x".format(b) } val id = idBytes.joinToString("") { b -> "%02x".format(b) }
if (isBroadcastMessage) { 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) { synchronized(messages) {
messages[id] = packet messages[id] = packet
// Enforce capacity (remove oldest when exceeded) // Enforce capacity (remove oldest when exceeded)
@ -122,6 +143,17 @@ class GossipSyncManager(
if (it.hasNext()) { it.next(); it.remove() } else break 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) { } else if (isAnnouncement) {
// Ignore stale announcements older than STALE_PEER_TIMEOUT // Ignore stale announcements older than STALE_PEER_TIMEOUT
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
@ -143,22 +175,27 @@ class GossipSyncManager(
} }
private fun sendRequestSync() { private fun sendRequestSync() {
val payload = buildGcsPayload() listOf(
SyncTypeFlags.PUBLIC_MESSAGES,
val packet = BitchatPacket( SyncTypeFlags.FRAGMENT,
type = MessageType.REQUEST_SYNC.value, SyncTypeFlags.FILE_TRANSFER
senderID = hexStringToByteArray(myPeerID), ).forEach { types ->
timestamp = System.currentTimeMillis().toULong(), val payload = buildGcsPayload(types)
payload = payload, val packet = BitchatPacket(
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbors only type = MessageType.REQUEST_SYNC.value,
) senderID = hexStringToByteArray(myPeerID),
// Sign and broadcast timestamp = System.currentTimeMillis().toULong(),
val signed = delegate?.signPacketForBroadcast(packet) ?: packet payload = payload,
delegate?.sendPacket(signed) 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) { 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( val packet = BitchatPacket(
type = MessageType.REQUEST_SYNC.value, type = MessageType.REQUEST_SYNC.value,
@ -175,6 +212,8 @@ class GossipSyncManager(
} }
fun handleRequestSync(fromPeerID: String, request: RequestSyncPacket) { 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 // Decode GCS into sorted set for membership checks
val sorted = GCSFilter.decodeToSortedSet(request.p, request.m, request.data) val sorted = GCSFilter.decodeToSortedSet(request.p, request.m, request.data)
fun mightContain(id: ByteArray): Boolean { fun mightContain(id: ByteArray): Boolean {
@ -183,26 +222,41 @@ class GossipSyncManager(
return GCSFilter.contains(sorted, nonZeroV) return GCSFilter.contains(sorted, nonZeroV)
} }
// 1) Announcements: send latest per peerID if remote doesn't have them // Announcements are exempt from the cursor: only the latest per peer is retained,
for ((_, pair) in latestAnnouncementByPeer.entries) { // and peers need their signing keys before they can verify other sync responses.
val (id, pkt) = pair if (requestedTypes.contains(MessageType.ANNOUNCE)) {
val idBytes = hexToBytes(id) for ((_, pair) in latestAnnouncementByPeer.entries) {
if (!mightContain(idBytes)) { val (id, pkt) = pair
// Send original packet unchanged to requester only (keep local TTL) val idBytes = hexToBytes(id)
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) if (!mightContain(idBytes)) {
delegate?.sendPacketToPeer(fromPeerID, toSend) val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}") 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 if (requestedTypes.contains(MessageType.MESSAGE)) {
val toSendMsgs = synchronized(messages) { messages.values.toList() } val toSendMsgs = synchronized(messages) { messages.values.toList() }
for (pkt in toSendMsgs) { 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) val idBytes = PacketIdUtil.computeIdBytes(pkt)
if (!mightContain(idBytes)) { if (!mightContain(idBytes)) {
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) delegate?.sendPacketToPeer(fromPeerID, 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()}")
} }
} }
} }
@ -232,16 +286,23 @@ class GossipSyncManager(
return out return out
} }
private fun buildGcsPayload(): ByteArray { internal fun buildGcsPayload(types: SyncTypeFlags): ByteArray {
// Collect candidates: latest announcement per peer + recent broadcast messages // Collect only the packet types represented by this filter.
val list = ArrayList<BitchatPacket>() val list = ArrayList<BitchatPacket>()
// announcements if (types.contains(MessageType.ANNOUNCE)) {
for ((_, pair) in latestAnnouncementByPeer) { for ((_, pair) in latestAnnouncementByPeer) {
list.add(pair.second) list.add(pair.second)
}
} }
// messages if (types.contains(MessageType.MESSAGE)) {
synchronized(messages) { synchronized(messages) {
list.addAll(messages.values) 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) // sort by timestamp desc, then take up to min(seenCapacity, fit capacity)
list.sortByDescending { it.timestamp.toLong() } list.sortByDescending { it.timestamp.toLong() }
@ -254,15 +315,24 @@ class GossipSyncManager(
val takeN = minOf(nMax, cap, list.size) val takeN = minOf(nMax, cap, list.size)
if (takeN <= 0) { if (takeN <= 0) {
val p0 = GCSFilter.deriveP(fpr) 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 params = GCSFilter.buildFilter(ids, maxBytes, fpr)
val mVal = if (params.m <= 0L) 1 else params.m 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() { private fun pruneStaleAnnouncements() {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val stalePeers = mutableListOf<String>() val stalePeers = mutableListOf<String>()
@ -276,26 +346,17 @@ class GossipSyncManager(
} }
} }
if (stalePeers.isEmpty()) return var changed = false
// Remove announcements and their messages
var totalPrunedMsgs = 0
for (peerID in stalePeers) { for (peerID in stalePeers) {
// Count messages to be pruned for logging changed = latestAnnouncementByPeer.remove(peerID) != null || changed
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)
} }
synchronized(messages) {
Log.d(TAG, "Pruned ${stalePeers.size} stale announcements and $totalPrunedMsgs 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) // 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") 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) { private fun restoreArchive() {
for ((id, message) in messages) { val file = archiveFile ?: return
val sender = message.senderID.joinToString("") { b -> "%02x".format(b) } if (!file.exists()) return
if (sender == key) { try {
idsToRemove.add(id) 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)
} }
} input.close()
} catch (_: Exception) {
// Now remove the collected IDs synchronized(messages) { messages.clear() }
synchronized(messages) { } finally {
for (id in idsToRemove) { restoringArchive = false
messages.remove(id)
}
}
if (idsToRemove.isNotEmpty()) {
Log.d(TAG, "Pruned ${idsToRemove.size} messages with senders without announcements")
} }
} }
@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, messageId,
com.bitchat.android.model.DeliveryStatus.Sent 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) onAccepted(accepted)
@ -1483,7 +1488,7 @@ class ChatViewModel(
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear()
} catch (_: Exception) { } } catch (_: Exception) { }
try { try {
com.bitchat.android.services.MessageRouter.tryGetInstance()?.clearAll() com.bitchat.android.services.MessageRouter.panicClear(getApplication())
} catch (_: Exception) { } } catch (_: Exception) { }
// Clear all cryptographic data // Clear all cryptographic data

View File

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

View File

@ -136,6 +136,7 @@ object AppConstants {
const val OUTBOX_TICK_MS: Long = 2_000L const val OUTBOX_TICK_MS: Long = 2_000L
const val OUTBOX_MESSAGE_TTL_MS: Long = 86_400_000L // 24 hours const val OUTBOX_MESSAGE_TTL_MS: Long = 86_400_000L // 24 hours
const val OUTBOX_MAX_PER_PEER: Int = 100 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) 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) sendPacketToPeer(peerID, packet)
} }
override fun sendToPeerAndReport(peerID: String, packet: BitchatPacket): Boolean =
sendPacketToPeer(peerID, packet)
/** /**
* Broadcasts routed packet to currently connected peers. * 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) 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 @Test
fun `fragment count at cap boundary is not rejected`() { fun `fragment count at cap boundary is not rejected`() {
val manager = FragmentManager() 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.PeerCapabilities
import com.bitchat.android.model.PrivateMessagePacket import com.bitchat.android.model.PrivateMessagePacket
import com.bitchat.android.model.RequestSyncPacket import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.sync.SyncTypeFlags
import com.bitchat.android.model.UnknownAnnouncementTLV import com.bitchat.android.model.UnknownAnnouncementTLV
import com.bitchat.android.protocol.BinaryProtocol import com.bitchat.android.protocol.BinaryProtocol
import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.BitchatPacket
@ -203,9 +204,14 @@ class ClientRewriteWireContractTest {
val request = RequestSyncPacket( val request = RequestSyncPacket(
p = 19, p = 19,
m = 0x01020304L, 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()) assertArrayEquals(wire, request.encode())
assertSyncRequestEquals(request, RequestSyncPacket.decode(wire)) assertSyncRequestEquals(request, RequestSyncPacket.decode(wire))
@ -297,5 +303,7 @@ class ClientRewriteWireContractTest {
assertEquals(expected.p, actual!!.p) assertEquals(expected.p, actual!!.p)
assertEquals(expected.m, actual.m) assertEquals(expected.m, actual.m)
assertArrayEquals(expected.data, actual.data) 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.NoisePayload
import com.bitchat.android.model.NoisePayloadType import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.model.PrivateMessagePacket
import com.bitchat.android.model.RoutedPacket import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.NoisePeerIdentity import com.bitchat.android.noise.NoisePeerIdentity
import com.bitchat.android.noise.AuthenticatedNoiseSession import com.bitchat.android.noise.AuthenticatedNoiseSession
@ -25,9 +26,11 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mockito.kotlin.any import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.argThat
import org.mockito.kotlin.eq import org.mockito.kotlin.eq
import org.mockito.kotlin.mock import org.mockito.kotlin.mock
import org.mockito.kotlin.never import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
@ -435,6 +438,48 @@ class MessageHandlerTest {
assertTrue(handler.handleNoiseEncrypted(RoutedPacket(encryptedPacket(), peerID, "direct-link"))) 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( private fun encryptedPacket(): BitchatPacket = BitchatPacket(
version = 1u, version = 1u,
type = MessageType.NOISE_ENCRYPTED.value, type = MessageType.NOISE_ENCRYPTED.value,

View File

@ -64,6 +64,26 @@ class PacketProcessorAnnounceSideEffectTest {
assertEquals(PEER_ID, withTimeout(1_000) { delegate.lastSeen.await() }) 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 = private fun processor(delegate: RecordingDelegate): PacketProcessor =
PacketProcessor(MY_PEER_ID).also { PacketProcessor(MY_PEER_ID).also {
it.delegate = delegate it.delegate = delegate
@ -96,12 +116,26 @@ class PacketProcessorAnnounceSideEffectTest {
return RoutedPacket(packet, PEER_ID, "direct-link") 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 class RecordingDelegate(
private val acceptAnnounce: Boolean, private val acceptAnnounce: Boolean,
private val acceptHandshake: Boolean = false private val acceptHandshake: Boolean = false
) : PacketProcessorDelegate { ) : PacketProcessorDelegate {
val handled = CompletableDeferred<Unit>() val handled = CompletableDeferred<Unit>()
val handshakeHandled = CompletableDeferred<Unit>() val handshakeHandled = CompletableDeferred<Unit>()
val messageHandled = CompletableDeferred<Unit>()
val lastSeen = CompletableDeferred<String>() val lastSeen = CompletableDeferred<String>()
@Volatile var relayCount = 0 @Volatile var relayCount = 0
@ -121,7 +155,9 @@ class PacketProcessorAnnounceSideEffectTest {
handled.complete(Unit) handled.complete(Unit)
return acceptAnnounce return acceptAnnounce
} }
override fun handleMessage(routed: RoutedPacket) = Unit override fun handleMessage(routed: RoutedPacket) {
messageHandled.complete(Unit)
}
override fun handleLeave(routed: RoutedPacket) = Unit override fun handleLeave(routed: RoutedPacket) = Unit
override fun handleFragment(packet: BitchatPacket): BitchatPacket? = null override fun handleFragment(packet: BitchatPacket): BitchatPacket? = null
override fun handleRequestSync(routed: RoutedPacket) = Unit 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.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -56,4 +57,16 @@ class ContactDirectoryTest {
assertNull(resolution.displayName) 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.junit.runner.RunWith
import org.mockito.kotlin.any import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.argThat
import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.clearInvocations
import org.mockito.kotlin.eq import org.mockito.kotlin.eq
import org.mockito.kotlin.mock import org.mockito.kotlin.mock
@ -36,6 +37,7 @@ class MessageRouterTest {
private lateinit var mesh: MeshService private lateinit var mesh: MeshService
private lateinit var router: MessageRouter private lateinit var router: MessageRouter
private lateinit var identityManager: SecureIdentityStateManager
private var fakeTime = 1_000_000L private var fakeTime = 1_000_000L
private val expired = mutableListOf<String>() private val expired = mutableListOf<String>()
@ -46,7 +48,7 @@ class MessageRouterTest {
"message-router-test-${UUID.randomUUID()}", "message-router-test-${UUID.randomUUID()}",
Context.MODE_PRIVATE Context.MODE_PRIVATE
) )
val identityManager = SecureIdentityStateManager(prefs, testOnly = true) identityManager = SecureIdentityStateManager(prefs, testOnly = true)
ContactDirectory.identityManagerProvider = { identityManager } ContactDirectory.identityManagerProvider = { identityManager }
mesh = mock() mesh = mock()
@ -56,11 +58,19 @@ class MessageRouterTest {
ContactDirectory.initialize(context) { mesh } ContactDirectory.initialize(context) { mesh }
MessageRouter.disableSchedulerForTesting = true 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() MessageRouter.resetForTesting()
fakeTime = 1_000_000L fakeTime = 1_000_000L
expired.clear() expired.clear()
router = MessageRouter.getInstance(context, mesh) router = MessageRouter.getInstance(context, mesh)
router.clearAll()
router.clock = { fakeTime } router.clock = { fakeTime }
router.onMessageExpired = { expired.add(it) } router.onMessageExpired = { expired.add(it) }
} }
@ -69,6 +79,7 @@ class MessageRouterTest {
fun tearDown() { fun tearDown() {
MessageRouter.resetForTesting() MessageRouter.resetForTesting()
MessageRouter.disableSchedulerForTesting = false MessageRouter.disableSchedulerForTesting = false
MessageRouter.outboxStoreFactory = ::MessageOutboxStore
ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) } ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) }
} }
@ -174,6 +185,159 @@ class MessageRouterTest {
verify(mesh, never()).initiateNoiseHandshake(any()) 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 @Test
fun `scheduler stops with the mesh service and restarts on rebind`() { fun `scheduler stops with the mesh service and restarts on rebind`() {
MessageRouter.disableSchedulerForTesting = false MessageRouter.disableSchedulerForTesting = false

View File

@ -18,6 +18,7 @@ class GCSFilterTest {
// Build filter with plenty of bytes (no trimming) // Build filter with plenty of bytes (no trimming)
val params = GCSFilter.buildFilter(ids, maxBytes = 400, targetFpr = 0.01) 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) val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data)
for (id in ids) { for (id in ids) {
@ -47,7 +48,7 @@ class GCSFilterTest {
val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data) val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data)
// Let's verify that the first trimmedN elements in ids are all matched // 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) assertTrue("At least some elements should have been encoded", trimmedN > 0)
val retainedIds = ids.take(trimmedN) 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` | | 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` | | 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` | | 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` | | 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` | | 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` | | 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 2. Runtime permission denial/retry for Bluetooth, location, notifications, and
microphone. microphone.
3. Foreground-service survival with the screen off and after process recreation. 3. Foreground-service survival with the screen off and after process recreation.
4. Cross-client Android/iOS exchange for announce, public/private text, delivery 4. Cross-client Android/iOS exchange for announce, public/private text, courier
and read receipts, image/audio/file transfer, sync replay, and Nostr fallback. deposit/handover, delivery/read receipts, media, six-hour sync replay, and Nostr fallback.
5. Corrupt, duplicated, reordered, delayed, and partially delivered fragments. 5. Corrupt, duplicated, reordered, delayed, and partially delivered fragments.
6. Identity rotation, verification continuity, downgrade rejection, and recovery 6. Identity rotation, verification continuity, downgrade rejection, and recovery
after stale Noise sessions. 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 | | `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 | | `favorite_verification` | favorite signal, orange-outline/filled mutual state, and peer fingerprint verification |
| `broadcast` | public mesh message A→B | | `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_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 | | `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 | | `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 | | `raw` | raw packet injection is accepted by the mesh |
| `session_recovery` | force-stop B mid-session: identity persists, re-handshake, DMs flow again | | `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 | | `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, Each run writes `<scenario>-evidence.json` to `--out` (digests, timings,
session states, logcat excerpts on failure) and exits non-zero on failure. 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`, See `TestHookDriver.kt` for the full command set (`ping`, `start`, `stop`,
`whoami`, `set_nickname`, `scan`, `peers`, `connect`, `handshake`, `session`, `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`, `favorite_status`, `verification_set`, `verification_status`, `file_send`,
`file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `ble`, `state`, `file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `courier_contract`,
`clear_results`). `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 ### Troubleshooting

View File

@ -1,17 +1,19 @@
# GCS Filter Sync (REQUEST_SYNC) # 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 ## Overview
- Each node maintains a rolling set of public BitChat packets it has seen recently: - Each node maintains a rolling set of public BitChat packets it has seen recently:
- Broadcast messages (MessageType.MESSAGE where recipient is broadcast) - Broadcast messages (MessageType.MESSAGE where recipient is broadcast)
- Identity announcements (MessageType.ANNOUNCE) - 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. - 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). - 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 REQUEST_SYNC only to that peer (unicast; local only). - 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 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. - 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. 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)) - 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. - 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: - What goes into the set:
- Combine the following and sort by packet timestamp (descending): - 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.
- Broadcast messages (MessageType 1)
- The most recent ANNOUNCE per peer
- Take at most `min(N_max, maxPacketsPerSync)` items from this ordered list. - 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. - 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. - 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 - 0x01: P (uint8) — Golomb‑Rice parameter
- 0x02: M (uint32) — hash range N * 2^P - 0x02: M (uint32) — hash range N * 2^P
- 0x03: data (opaque) — GCS bitstream (MSB‑first bit packing) - 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: Notes:
- The GCS bitstream uses MSB‑first packing (bit 7 is the first bit in each byte). - The GCS bitstream uses MSB‑first packing (bit 7 is the first bit in each byte).
@ -71,7 +75,9 @@ Sender behavior:
Receiver behavior: Receiver behavior:
- Decode the REQUEST_SYNC payload and reconstruct the sorted set of mapped values using the provided P, M, and bitstream. - 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. - 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 announcements, send only the latest announcement per (sender peerID).
- For broadcast messages, send all missing ones. - For broadcast messages, send all missing ones.
@ -90,6 +96,8 @@ Important: original packets are sent unmodified to preserve original signatures
Included in sync: Included in sync:
- Public broadcast messages: `MessageType.MESSAGE` with BROADCAST recipient (or null recipient). - Public broadcast messages: `MessageType.MESSAGE` with BROADCAST recipient (or null recipient).
- Identity announcements: `MessageType.ANNOUNCE`. - 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. - 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. - 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). - 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. - 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). - 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 ANNOUNCE and broadcast MESSAGE are synchronized; ANNOUNCE de‑dupe is “latest per sender peerID”. - 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): 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): Validation and limits (recommended):
- Reject malformed REQUEST_SYNC payloads (e.g., P < 1, M <= 0, or data length too large for local limits). - 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: Versioning:

View File

@ -166,11 +166,14 @@ class Device:
def reset_bluetooth(self) -> None: def reset_bluetooth(self) -> None:
"""Cycle the BT adapter; clears zombie GATT connections from peer restarts.""" """Cycle the BT adapter; clears zombie GATT connections from peer restarts."""
_shell(self.serial, "svc bluetooth disable") self.disable_bluetooth()
time.sleep(2) time.sleep(2)
_shell(self.serial, "svc bluetooth enable") _shell(self.serial, "svc bluetooth enable")
time.sleep(3) time.sleep(3)
def disable_bluetooth(self) -> None:
_shell(self.serial, "svc bluetooth disable")
def enable_bluetooth(self) -> None: def enable_bluetooth(self) -> None:
subprocess.run( subprocess.run(
[find_adb(), "-s", self.serial, "shell", "svc", "bluetooth", "enable"], [find_adb(), "-s", self.serial, "shell", "svc", "bluetooth", "enable"],
@ -365,6 +368,31 @@ def setup_pair(
wait_for_mutual_discovery(a, b) 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: def whoami(device: Device) -> dict:
return device.cmd_ok("whoami") return device.cmd_ok("whoami")
@ -391,6 +419,47 @@ def wait_for_mutual_discovery(a: Device, b: Device) -> None:
fb.result() 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 # MARK: - scenarios
def scenario_dm(a: Device, b: Device) -> dict: 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} 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( def _ptt_one_way(
sender: Device, sender: Device,
receiver: 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(a, id_b, timeout_s=120)
wait_for_peer(b, id_a, 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)): for device, peer, announcer in ((a, id_b, b), (b, id_a, a)):
connected = False
last: dict = {} last: dict = {}
for _attempt in range(4): for _attempt in range(4):
last = device.cmd("connect", timeout_ms=45_000, peer=peer) last = device.cmd("connect", timeout_ms=45_000, peer=peer)
if last.get("status") == "ok" and last.get("direct"): if last.get("status") == "ok" and last.get("direct"):
connected = True # A GATT link carries traffic both ways. Requiring a second client connection from
break # the other endpoint turns a healthy single direct link into a false test failure,
# Already acceptable if the mesh formed a direct link on its own. # especially after one phone has just restarted and has not rebuilt its address map.
peers = device.cmd_ok("peers").get("peers", []) return
match = next((p for p in peers if p.get("id") == peer), None) # Already acceptable if either endpoint formed a direct link on its own.
if match and match.get("direct"): for observer, observed_peer in ((device, peer), (announcer, whoami(device)["peer_id"])):
connected = True peers = observer.cmd_ok("peers").get("peers", [])
break match = next((p for p in peers if p.get("id") == observed_peer), None)
if match and match.get("direct"):
return
try: try:
announcer.cmd_ok("announce") announcer.cmd_ok("announce")
except MeshLabError: except MeshLabError:
pass pass
time.sleep(4) time.sleep(4)
if not connected: raise MeshLabError(f"no direct link formed between {a.alias} and {b.alias}: connect={last}")
raise MeshLabError(f"[{device.alias}] no direct link to {peer}: connect={last}")
def force_handshake(device: Device, peer_id: str, attempts: int = 5, per_attempt_s: int = 20) -> dict: 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, "dm": scenario_dm,
"favorite_verification": scenario_favorite_verification, "favorite_verification": scenario_favorite_verification,
"broadcast": scenario_broadcast, "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_dm": scenario_ptt_dm,
"ptt_broadcast": scenario_ptt_broadcast, "ptt_broadcast": scenario_ptt_broadcast,
# Broadcast transfers are receiver-capped at 256 fragments (~120 KB); only # Broadcast transfers are receiver-capped at 256 fragments (~120 KB); only
@ -893,6 +1267,11 @@ SCENARIOS = {
"identity_reset": scenario_identity_reset, "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, # Scenarios supported when device B is a watch (file scenarios are receive-only: phone sends,
# the watch must receive with matching digests). # the watch must receive with matching digests).
WATCH_SCENARIOS = [ 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() 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: try:
supported = WATCH_SCENARIOS if isinstance(b, WatchDevice) else list(SCENARIOS) supported = WATCH_SCENARIOS if isinstance(b, WatchDevice) else list(SCENARIOS)
if name == "all": 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")}) results[n] = sub.get("results", {"error": sub.get("error", "unknown")})
if sub["status"] != "pass": if sub["status"] != "pass":
failures.append(n) 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 evidence["results"] = results
if failures: if failures:
raise MeshLabError(f"sub-scenarios failed: {', '.join(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: elif name not in supported:
raise MeshLabError(f"scenario '{name}' is not supported on device '{b.alias}'") raise MeshLabError(f"scenario '{name}' is not supported on device '{b.alias}'")
else: else:
@ -933,7 +1330,7 @@ def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict:
except (MeshLabError, AssertionError) as error: except (MeshLabError, AssertionError) as error:
evidence["status"] = "fail" evidence["status"] = "fail"
evidence["error"] = str(error) 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) evidence["duration_s"] = round(time.time() - started, 1)
if out is not None: if out is not None:
out.mkdir(parents=True, exist_ok=True) 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 = commands.add_parser("setup", help="install, grant, launch, nickname, discover")
setup.add_argument("--serial-a", required=True) setup.add_argument("--serial-a", required=True)
setup.add_argument("--serial-b") 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("--serial-watch", help="watch serial; used as device B (overrides --serial-b)")
setup.add_argument("--apk", type=Path, default=None) setup.add_argument("--apk", type=Path, default=None)
setup.add_argument("--watch-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-a", default="alice")
setup.add_argument("--nickname-b", default="bob") 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 = commands.add_parser("scenario", help="run a test scenario on two phones, or three for courier delivery")
scenario.add_argument("name", choices=[*SCENARIOS.keys(), "all"]) scenario.add_argument("name", choices=[*SCENARIOS.keys(), *TRIAD_SCENARIOS.keys(), "all"])
scenario.add_argument("--serial-a", required=True) scenario.add_argument("--serial-a", required=True)
scenario.add_argument("--serial-b") 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("--serial-watch", help="watch serial; used as device B (overrides --serial-b)")
scenario.add_argument("--out", type=Path, default=None, help="evidence output directory") scenario.add_argument("--out", type=Path, default=None, help="evidence output directory")
@ -972,30 +1372,36 @@ def build_parser() -> argparse.ArgumentParser:
return parser 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.""" """Device A is always the phone; device B is a watch when --serial-watch is given."""
a = Device(args.serial_a, "alpha") a = Device(args.serial_a, "alpha")
if getattr(args, "serial_watch", None): 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): if not getattr(args, "serial_b", None):
raise MeshLabError("either --serial-b or --serial-watch is required") 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: def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv) args = build_parser().parse_args(argv)
try: try:
if args.command == "setup": 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 nickname_b = "watch" if isinstance(b, WatchDevice) and args.nickname_b == "bob" else args.nickname_b
setup_pair( if c is not None:
a, b, args.apk, args.nickname_a, nickname_b, setup_triad(a, b, c, args.apk, args.nickname_a, nickname_b, args.nickname_c)
apk_b=args.watch_apk if isinstance(b, WatchDevice) else None, 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"})) print(json.dumps({"status": "ok", "step": "setup"}))
elif args.command == "scenario": elif args.command == "scenario":
a, b = _resolve_devices(args) a, b, c = _resolve_devices(args)
evidence = run_scenario(args.name, a, b, args.out) evidence = run_scenario(args.name, a, b, args.out, c)
print(json.dumps(evidence, indent=2, default=str)) print(json.dumps(evidence, indent=2, default=str))
return 0 if evidence["status"] == "pass" else 1 return 0 if evidence["status"] == "pass" else 1
elif args.command == "cmd": elif args.command == "cmd":