mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-10-25 07:40:36 +00:00
Add expanded store-and-forward delivery
This commit is contained in:
parent
5156f7de89
commit
a09e0cbcd7
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
193
app/src/main/java/com/bitchat/android/mesh/CourierStore.kt
Normal file
193
app/src/main/java/com/bitchat/android/mesh/CourierStore.kt
Normal 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) }
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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) { }
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) { }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
105
app/src/test/kotlin/com/bitchat/android/mesh/CourierStoreTest.kt
Normal file
105
app/src/test/kotlin/com/bitchat/android/mesh/CourierStoreTest.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
@ -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" }))
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user