Add expanded store-and-forward delivery

This commit is contained in:
wollow 2026-08-13 16:01:47 +03:00
parent 5156f7de89
commit a09e0cbcd7
42 changed files with 1701 additions and 100 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -204,6 +204,18 @@ 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)
}
}
fun sendPacketToLink(
routed: RoutedPacket,
deviceAddress: String,

View File

@ -0,0 +1,193 @@
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
)
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()
@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 removed = stored.removeAll {
it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true
}
if (removed) 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
if (record.copies <= 1 || record.depositorKey == key || key in record.sprayedTo ||
courierTags.any { it.contentEquals(envelope.recipientTag) }
) return@forEach
val given = record.copies / 2
result += envelope.copy(copies = given.toUByte())
}
return result
}
@Synchronized
fun commitSpray(envelope: CourierEnvelope, courierNoiseKey: ByteArray): Boolean {
val key = courierNoiseKey.toHex()
val record = stored.firstOrNull {
it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true
} ?: return false
if (record.copies <= 1 || key in record.sprayedTo) return false
val given = record.copies / 2
if (given != envelope.copies.toInt()) return false
record.copies -= given
record.sprayedTo += key
persist()
return true
}
@Synchronized
fun wipe() {
stored.clear()
file.delete()
cipher.destroyKey()
}
private fun pruneExpired() {
val nowMs = now().toULong()
if (stored.removeAll { (it.envelope()?.expiry ?: 0u) <= nowMs }) 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) }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,99 @@
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
) {
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
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
}
}
}
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)
}
}
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())
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
override fun hashCode(): Int = 31 * (31 * recipientTag.contentHashCode() + expiry.hashCode()) +
31 * ciphertext.contentHashCode() + copies.hashCode()
}

View File

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

View File

@ -0,0 +1,148 @@
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
/** Parks opaque courier envelopes on default Nostr relays using the iOS kind-1401 contract. */
class BridgeCourierService(
context: Context,
private val encryptionService: EncryptionService,
private val onEnvelope: (CourierEnvelope) -> Unit
) {
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 relayManager = NostrRelayManager.getInstance(context.applicationContext)
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 = encryptionService.getStaticPublicKey() ?: ByteArray(0)
if (localKey.size == 32) {
val day = CourierEnvelope.epochDay(System.currentTimeMillis())
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(System.currentTimeMillis() - CourierEnvelope.MAX_LIFETIME_MS)
.limit(100)
.tag("x", *tags.toTypedArray())
.build()
relayManager.subscribe(
filter = filter,
id = subscriptionID,
targetRelayUrls = NostrRelayManager.defaultRelays(),
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(NostrRelayManager.defaultRelays())) return false
val sealed = try { encryptionService.sealCourierPayload(typedPayload, recipientNoiseKey) } catch (_: Exception) { return false }
val now = System.currentTimeMillis()
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 { NostrIdentity.generate() } 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, NostrRelayManager.defaultRelays(), onAccepted = 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 = System.currentTimeMillis()
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 = encryptionService.getStaticPublicKey() ?: return
if (!envelope.matchesRecipient(localKey, now)) return
onEnvelope(envelope)
}
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1667,6 +1667,7 @@ internal class ConversationDatabase(
null -> put("delivery_type", 0)
DeliveryStatus.Sending -> put("delivery_type", 1)
DeliveryStatus.Sent -> put("delivery_type", 2)
DeliveryStatus.Queued -> put("delivery_type", 7)
is DeliveryStatus.Delivered -> {
put("delivery_type", 3)
if (includeSensitiveText) put("delivery_text", status.to) else putNull("delivery_text")
@ -1693,10 +1694,11 @@ internal class ConversationDatabase(
null -> 0
is DeliveryStatus.Failed -> 0
DeliveryStatus.Sending -> 1
DeliveryStatus.Sent -> 2
is DeliveryStatus.PartiallyDelivered -> 3
is DeliveryStatus.Delivered -> 4
is DeliveryStatus.Read -> 5
DeliveryStatus.Queued -> 2
DeliveryStatus.Sent -> 3
is DeliveryStatus.PartiallyDelivered -> 4
is DeliveryStatus.Delivered -> 5
is DeliveryStatus.Read -> 6
}
private fun Cursor.toMessage(): BitchatMessage {
@ -1764,6 +1766,7 @@ internal class ConversationDatabase(
reached = nullableInt("delivery_reached") ?: 0,
total = nullableInt("delivery_total") ?: 0
)
7 -> DeliveryStatus.Queued
else -> null
}

View File

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

View File

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

View File

@ -8,6 +8,9 @@ import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap
import android.content.Context
import java.io.File
import java.nio.file.StandardCopyOption
/**
* Gossip-based synchronization manager using on-demand GCS filters.
@ -17,7 +20,8 @@ import java.util.concurrent.ConcurrentHashMap
class GossipSyncManager(
private val myPeerID: String,
private val scope: CoroutineScope,
private val configProvider: ConfigProvider
private val configProvider: ConfigProvider,
context: Context? = null
) {
interface Delegate {
fun sendPacket(packet: BitchatPacket)
@ -33,6 +37,9 @@ class GossipSyncManager(
companion object {
private const val TAG = "GossipSyncManager"
const val PUBLIC_MESSAGE_MAX_AGE_MS = 6 * 60 * 60 * 1000L
const val FRAGMENT_MAX_AGE_MS = 15 * 60 * 1000L
private const val ARCHIVE_FILE = "gossip-public-history.bin"
}
var delegate: Delegate? = null
@ -44,11 +51,15 @@ class GossipSyncManager(
// Stored packets for sync:
// - broadcast messages: keep up to seenCapacity() most recent, keyed by packetId
private val messages = LinkedHashMap<String, BitchatPacket>()
private val fragments = LinkedHashMap<String, BitchatPacket>()
private val archiveFile = context?.applicationContext?.filesDir?.let { File(it, ARCHIVE_FILE) }
private var restoringArchive = false
// - announcements: only keep latest per sender peerID
private val latestAnnouncementByPeer = ConcurrentHashMap<String, Pair<String, BitchatPacket>>()
private var periodicJob: Job? = null
private var cleanupJob: Job? = null
init { restoreArchive() }
fun start() {
periodicJob?.cancel()
periodicJob = scope.launch(Dispatchers.IO) {
@ -84,7 +95,9 @@ class GossipSyncManager(
synchronized(messages) {
messages.clear()
}
synchronized(fragments) { fragments.clear() }
latestAnnouncementByPeer.clear()
archiveFile?.delete()
Log.d(TAG, "Cleared all gossip sync messages and announcements")
}
@ -106,13 +119,20 @@ class GossipSyncManager(
// Only ANNOUNCE or broadcast MESSAGE
val mt = MessageType.fromValue(packet.type)
val isBroadcastMessage = (mt == MessageType.MESSAGE && (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)))
val isBroadcastFile = mt == MessageType.FILE_TRANSFER &&
(packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
val isAnnouncement = (mt == MessageType.ANNOUNCE)
if (!isBroadcastMessage && !isAnnouncement) return
val isFragment = (mt == MessageType.FRAGMENT || isBroadcastFile) &&
(packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
if (!isBroadcastMessage && !isAnnouncement && !isFragment) return
val idBytes = PacketIdUtil.computeIdBytes(packet)
val id = idBytes.joinToString("") { b -> "%02x".format(b) }
if (isBroadcastMessage) {
val now = System.currentTimeMillis()
val age = now - packet.timestamp.toLong()
if (age !in 0..PUBLIC_MESSAGE_MAX_AGE_MS) return
synchronized(messages) {
messages[id] = packet
// Enforce capacity (remove oldest when exceeded)
@ -122,6 +142,17 @@ class GossipSyncManager(
if (it.hasNext()) { it.next(); it.remove() } else break
}
}
if (!restoringArchive) persistArchive()
} else if (isFragment) {
val age = System.currentTimeMillis() - packet.timestamp.toLong()
if (age !in 0..FRAGMENT_MAX_AGE_MS) return
synchronized(fragments) {
fragments[id] = packet
while (fragments.size > configProvider.seenCapacity().coerceAtLeast(1)) {
val iterator = fragments.entries.iterator()
if (iterator.hasNext()) { iterator.next(); iterator.remove() } else break
}
}
} else if (isAnnouncement) {
// Ignore stale announcements older than STALE_PEER_TIMEOUT
val now = System.currentTimeMillis()
@ -205,6 +236,13 @@ class GossipSyncManager(
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 idBytes = PacketIdUtil.computeIdBytes(pkt)
if (!mightContain(idBytes)) {
delegate?.sendPacketToPeer(fromPeerID, pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS))
}
}
}
private fun hexStringToByteArray(hexString: String): ByteArray {
@ -243,6 +281,7 @@ class GossipSyncManager(
synchronized(messages) {
list.addAll(messages.values)
}
synchronized(fragments) { list.addAll(fragments.values) }
// sort by timestamp desc, then take up to min(seenCapacity, fit capacity)
list.sortByDescending { it.timestamp.toLong() }
@ -262,7 +301,7 @@ class GossipSyncManager(
return RequestSyncPacket(p = params.p, m = mVal, data = params.data).encode()
}
// Periodically remove stale announcements and all their messages
// Announcements age out quickly; public history remains independently sync-able for six hours.
private fun pruneStaleAnnouncements() {
val now = System.currentTimeMillis()
val stalePeers = mutableListOf<String>()
@ -276,26 +315,17 @@ class GossipSyncManager(
}
}
if (stalePeers.isEmpty()) return
// Remove announcements and their messages
var totalPrunedMsgs = 0
var changed = false
for (peerID in stalePeers) {
// Count messages to be pruned for logging
val toRemove = mutableListOf<String>()
synchronized(messages) {
for ((id, message) in messages) {
val sender = message.senderID.joinToString("") { b -> "%02x".format(b) }
if (sender == peerID) toRemove.add(id)
}
}
totalPrunedMsgs += toRemove.size
// Reuse existing removal which also clears announcement entry
removeAnnouncementForPeer(peerID)
changed = latestAnnouncementByPeer.remove(peerID) != null || changed
}
Log.d(TAG, "Pruned ${stalePeers.size} stale announcements and $totalPrunedMsgs messages")
synchronized(messages) {
changed = messages.entries.removeAll { now - it.value.timestamp.toLong() > PUBLIC_MESSAGE_MAX_AGE_MS } || changed
}
synchronized(fragments) {
fragments.entries.removeAll { now - it.value.timestamp.toLong() > FRAGMENT_MAX_AGE_MS }
}
if (changed) persistArchive()
}
// Explicitly remove stored announcement for a given peer (hex ID)
@ -305,26 +335,53 @@ class GossipSyncManager(
Log.d(TAG, "Removed stored announcement for peer $peerID")
}
// Collect IDs to remove first to avoid modifying collection while iterating
val idsToRemove = mutableListOf<String>()
synchronized(messages) {
for ((id, message) in messages) {
val sender = message.senderID.joinToString("") { b -> "%02x".format(b) }
if (sender == key) {
idsToRemove.add(id)
}
}
private fun restoreArchive() {
val file = archiveFile ?: return
if (!file.exists()) return
try {
restoringArchive = true
val input = java.io.DataInputStream(file.inputStream().buffered())
val count = input.readInt().coerceIn(0, configProvider.seenCapacity())
repeat(count) {
val length = input.readInt()
if (length <= 0 || length > com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + 256) return@repeat
val packet = com.bitchat.android.protocol.BinaryProtocol.decode(input.readNBytes(length)) ?: return@repeat
onPublicPacketSeen(packet)
}
}
// Now remove the collected IDs
synchronized(messages) {
for (id in idsToRemove) {
messages.remove(id)
}
}
if (idsToRemove.isNotEmpty()) {
Log.d(TAG, "Pruned ${idsToRemove.size} messages with senders without announcements")
input.close()
} catch (_: Exception) {
synchronized(messages) { messages.clear() }
} finally {
restoringArchive = false
}
}
@Synchronized
private fun persistArchive() {
val file = archiveFile ?: return
try {
val packets = synchronized(messages) { messages.values.toList() }
val temporary = File(file.parentFile, "${file.name}.tmp")
java.io.DataOutputStream(temporary.outputStream().buffered()).use { output ->
output.writeInt(packets.size)
packets.forEach { packet ->
val encoded = com.bitchat.android.protocol.BinaryProtocol.encode(packet, padding = false) ?: return@forEach
output.writeInt(encoded.size)
output.write(encoded)
}
}
try {
java.nio.file.Files.move(
temporary.toPath(),
file.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING
)
} catch (_: Exception) {
temporary.delete()
}
} catch (_: Exception) { }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,105 @@
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 `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

@ -0,0 +1,82 @@
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
)
assertEquals(
"010010000102030405060708090a0b0c0d0e0f" +
"0200080102030405060708" +
"030003aabbcc" +
"04000104",
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 `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,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

@ -56,11 +56,19 @@ class MessageRouterTest {
ContactDirectory.initialize(context) { mesh }
MessageRouter.disableSchedulerForTesting = true
MessageRouter.outboxStoreFactory = { testContext ->
MessageOutboxStore(testContext, object : ConversationStorageCipher {
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray) = plaintext
override fun decrypt(envelope: ByteArray, associatedData: ByteArray) = envelope
override fun destroyKey() = Unit
})
}
MessageRouter.resetForTesting()
fakeTime = 1_000_000L
expired.clear()
router = MessageRouter.getInstance(context, mesh)
router.clearAll()
router.clock = { fakeTime }
router.onMessageExpired = { expired.add(it) }
}
@ -69,6 +77,7 @@ class MessageRouterTest {
fun tearDown() {
MessageRouter.resetForTesting()
MessageRouter.disableSchedulerForTesting = false
MessageRouter.outboxStoreFactory = ::MessageOutboxStore
ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) }
}
@ -174,6 +183,48 @@ class MessageRouterTest {
verify(mesh, never()).initiateNoiseHandshake(any())
}
@Test
fun `direct send remains retained until delivery acknowledgement`() {
peerReady()
router.sendPrivate("direct", peerID, "peer", "msg-direct")
clearInvocations(mesh)
fakeTime += 31_000
router.tickOutbox()
verify(mesh, times(1)).sendPrivateMessage("direct", peerID, "peer", "msg-direct")
router.onMessageAcknowledged("msg-direct", peerID)
clearInvocations(mesh)
router.tickOutbox()
verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull())
}
@Test
fun `queued message survives router recreation`() {
peerOffline()
router.sendPrivate("durable", peerID, "peer", "msg-durable")
MessageRouter.resetForTesting()
router = MessageRouter.getInstance(RuntimeEnvironment.getApplication(), mesh)
router.clock = { fakeTime }
peerReady()
router.onSessionEstablished(peerID)
verify(mesh).sendPrivateMessage("durable", peerID, "peer", "msg-durable")
}
@Test
fun `outbox stops transport retries after eight attempts`() {
peerReady()
router.sendPrivate("bounded", peerID, "peer", "msg-bounded")
repeat(10) {
fakeTime += 10 * 60_000L + 1
router.tickOutbox()
}
verify(mesh, times(8)).sendPrivateMessage("bounded", peerID, "peer", "msg-bounded")
}
@Test
fun `scheduler stops with the mesh service and restarts on rebind`() {
MessageRouter.disableSchedulerForTesting = false

View File

@ -0,0 +1,51 @@
package com.bitchat.android.sync
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.annotation.Config
@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()))
assertEquals(listOf(1, 3, 5), sent.map { it.payload.single().toInt() })
}
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

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