mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Add durable private delivery and courier transport
This commit is contained in:
parent
936a4cdf6d
commit
5334c08fac
@ -1,6 +1,7 @@
|
||||
package com.bitchat.android.testhook
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
@ -15,7 +16,10 @@ import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.mesh.PrivateMediaPreparation
|
||||
import com.bitchat.android.mesh.TransferProgressManager
|
||||
import com.bitchat.android.mesh.CourierDepositTier
|
||||
import com.bitchat.android.mesh.CourierStore
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.CourierEnvelope
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
@ -23,8 +27,11 @@ import com.bitchat.android.service.MeshForegroundService
|
||||
import com.bitchat.android.service.MeshServiceHolder
|
||||
import com.bitchat.android.service.TransportBridgeService
|
||||
import com.bitchat.android.services.AppStateStore
|
||||
import com.bitchat.android.services.ConversationStorageCipher
|
||||
import com.bitchat.android.services.MessageRouter
|
||||
import com.bitchat.android.ui.DataManager
|
||||
import com.bitchat.android.ui.PrivateMediaRecipientResolver
|
||||
import com.bitchat.android.ui.debug.DebugSettingsManager
|
||||
import com.bitchat.android.util.AppConstants
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@ -69,6 +76,13 @@ object TestHookDriver {
|
||||
"announce" -> announce(context)
|
||||
"broadcast_msg" -> broadcastMsg(context, intent.requiredString("content"), intent.getStringExtra("channel"))
|
||||
"dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id"))
|
||||
"router_private_send" -> routerPrivateSend(
|
||||
context,
|
||||
intent.requiredString("peer"),
|
||||
intent.requiredString("content"),
|
||||
intent.getStringExtra("msg_id")
|
||||
)
|
||||
"router_resume" -> routerResume(context)
|
||||
"dm_recv" -> dmRecv(context, intent)
|
||||
"msg_recv" -> msgRecv(context, intent)
|
||||
"favorite_set" -> favoriteSet(
|
||||
@ -89,7 +103,11 @@ object TestHookDriver {
|
||||
"ptt_send" -> pttSend(context, intent)
|
||||
"ptt_recv" -> pttRecv(context, intent)
|
||||
"raw_send" -> rawSend(context, intent)
|
||||
"courier_contract" -> courierContract(context)
|
||||
"cache_peer_identity" -> cachePeerIdentity(context, intent.requiredString("peer"))
|
||||
"sync_request" -> syncRequest(intent.requiredString("peer"))
|
||||
"ble" -> setBle(intent.getBooleanExtra("enabled", true))
|
||||
"wifi_aware" -> setWifiAware(intent.getBooleanExtra("enabled", true))
|
||||
"inject_peers" -> injectPeers(intent.getStringExtra("peers"))
|
||||
"state" -> state(context)
|
||||
"clear_results" -> clearResults(context)
|
||||
@ -243,6 +261,27 @@ object TestHookDriver {
|
||||
return ok("dm_send").put("peer", peerID).put("msg_id", id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the same durable outbox/router path used by private-chat sends instead of the
|
||||
* lower-level direct mesh API that backs `dm_send`.
|
||||
*/
|
||||
private fun routerPrivateSend(context: Context, peerID: String, content: String, msgID: String?): JSONObject {
|
||||
val mesh = mesh(context)
|
||||
val nickname = mesh.getPeerNicknames()[peerID] ?: peerID
|
||||
val id = msgID ?: "testhook-router-${System.currentTimeMillis()}"
|
||||
val route = MessageRouter.getInstance(context, mesh).sendPrivate(content, peerID, nickname, id)
|
||||
return ok("router_private_send")
|
||||
.put("peer", peerID)
|
||||
.put("msg_id", id)
|
||||
.put("route", route.name)
|
||||
}
|
||||
|
||||
/** Recreates the durable router after a process restart without adding another message. */
|
||||
private fun routerResume(context: Context): JSONObject {
|
||||
MessageRouter.getInstance(context, mesh(context))
|
||||
return ok("router_resume")
|
||||
}
|
||||
|
||||
private suspend fun dmRecv(context: Context, intent: Intent): JSONObject {
|
||||
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
|
||||
val fromPeer = intent.getStringExtra("peer")
|
||||
@ -276,10 +315,11 @@ object TestHookDriver {
|
||||
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
|
||||
val contains = intent.getStringExtra("contains")
|
||||
val channel = intent.getStringExtra("channel")
|
||||
val includeExisting = intent.getBooleanExtra("include_existing", false)
|
||||
val startTime = System.currentTimeMillis()
|
||||
val mesh = mesh(context)
|
||||
val matches: (com.bitchat.android.model.BitchatMessage) -> Boolean = { msg ->
|
||||
msg.timestamp.time >= startTime &&
|
||||
(includeExisting || msg.timestamp.time >= startTime) &&
|
||||
msg.senderPeerID != mesh.myPeerID &&
|
||||
(contains == null || msg.content.contains(contains)) &&
|
||||
(channel == null || msg.channel == channel)
|
||||
@ -653,6 +693,128 @@ object TestHookDriver {
|
||||
.put("peer", peerID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exercises the real courier wire/store implementation on a physical debug build.
|
||||
* This is intentionally local: the two-phone harness has no third identity to act as
|
||||
* both recipient and an independent courier, so transport scenarios cannot observe the
|
||||
* spray budget without weakening the assertion.
|
||||
*/
|
||||
private fun courierContract(context: Context): JSONObject {
|
||||
val filesDir = File(context.cacheDir, "testhook/courier-contract-files")
|
||||
filesDir.deleteRecursively()
|
||||
filesDir.mkdirs()
|
||||
val labContext = LabStorageContext(context, filesDir)
|
||||
val cipher = LabCipher(0x5a)
|
||||
val now = System.currentTimeMillis()
|
||||
val recipientKey = ByteArray(32) { 7 }
|
||||
val depositorKey = ByteArray(32) { 8 }
|
||||
val firstCourier = ByteArray(32) { 11 }
|
||||
val secondCourier = ByteArray(32) { 12 }
|
||||
val thirdCourier = ByteArray(32) { 13 }
|
||||
val fourthCourier = ByteArray(32) { 14 }
|
||||
val tag = CourierEnvelope.recipientTag(recipientKey, CourierEnvelope.epochDay(now))
|
||||
val store = CourierStore(labContext, cipher) { now }
|
||||
try {
|
||||
val firstEnvelope = CourierEnvelope(
|
||||
recipientTag = tag,
|
||||
expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(),
|
||||
ciphertext = ByteArray(32) { (it + 1).toByte() },
|
||||
copies = 4u,
|
||||
prekeyID = 0x11223344u
|
||||
)
|
||||
require(store.deposit(firstEnvelope, depositorKey, CourierDepositTier.VERIFIED))
|
||||
val wire = requireNotNull(firstEnvelope.encode())
|
||||
val decoded = requireNotNull(CourierEnvelope.decode(wire))
|
||||
val first = store.sprayCopiesFor(firstCourier).single()
|
||||
val second = store.sprayCopiesFor(secondCourier).single()
|
||||
val sameCourierEmpty = store.sprayCopiesFor(firstCourier).isEmpty()
|
||||
val secondCommitted = store.commitSpray(second, secondCourier)
|
||||
val firstCommitted = store.commitSpray(first, firstCourier)
|
||||
val thirdCourierEmpty = store.sprayCopiesFor(thirdCourier).isEmpty()
|
||||
|
||||
val secondEnvelope = firstEnvelope.copy(ciphertext = ByteArray(32) { (it + 65).toByte() })
|
||||
require(store.deposit(secondEnvelope, depositorKey, CourierDepositTier.VERIFIED))
|
||||
val cancelledPreview = store.sprayCopiesFor(fourthCourier).single()
|
||||
val cancelled = store.cancelSpray(cancelledPreview, fourthCourier)
|
||||
val retry = store.sprayCopiesFor(fourthCourier).single()
|
||||
val retryCommitted = store.commitSpray(retry, fourthCourier)
|
||||
|
||||
val reloaded = CourierStore(labContext, LabCipher(0x5a)) { now }
|
||||
val persistedSprayHistory = reloaded.sprayCopiesFor(fourthCourier)
|
||||
.none { it.ciphertext.contentEquals(secondEnvelope.ciphertext) }
|
||||
val remainingCopies = reloaded.sprayCopiesFor(thirdCourier)
|
||||
.firstOrNull { it.ciphertext.contentEquals(secondEnvelope.ciphertext) }
|
||||
?.copies
|
||||
?.toInt()
|
||||
reloaded.wipe()
|
||||
|
||||
require(decoded.prekeyID == firstEnvelope.prekeyID)
|
||||
require(decoded.encode()?.contentEquals(wire) == true)
|
||||
require(first.prekeyID == firstEnvelope.prekeyID)
|
||||
require(first.copies == 2u.toUByte())
|
||||
require(second.copies == 1u.toUByte())
|
||||
require(sameCourierEmpty)
|
||||
require(secondCommitted && firstCommitted && thirdCourierEmpty)
|
||||
require(cancelled && retry.copies == 2u.toUByte() && retryCommitted)
|
||||
require(persistedSprayHistory && remainingCopies == 1)
|
||||
|
||||
return ok("courier_contract")
|
||||
.put("wire_prekey_id_preserved", true)
|
||||
.put("stored_prekey_id_preserved", true)
|
||||
.put("first_reserved_copies", first.copies.toInt())
|
||||
.put("second_reserved_copies", second.copies.toInt())
|
||||
.put("same_courier_second_reservation_empty", sameCourierEmpty)
|
||||
.put("reverse_order_commits", true)
|
||||
.put("cancel_restored_eligibility", cancelled)
|
||||
.put("persisted_spray_history", persistedSprayHistory)
|
||||
.put("remaining_copies_after_restart", remainingCopies)
|
||||
} finally {
|
||||
store.wipe()
|
||||
filesDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the currently authenticated peer key exactly as the normal UI session observer does. */
|
||||
private fun cachePeerIdentity(context: Context, peerID: String): JSONObject {
|
||||
val info = mesh(context).getPeerInfo(peerID)
|
||||
?: return err("cache_peer_identity", "peer is not known")
|
||||
val noiseKey = info.noisePublicKey
|
||||
?: return err("cache_peer_identity", "peer Noise key is unavailable")
|
||||
val noiseKeyHex = noiseKey.toHex()
|
||||
val identityManager = SecureIdentityStateManager(context)
|
||||
identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
|
||||
identityManager.cacheNoiseFingerprint(noiseKeyHex, com.bitchat.android.services.ContactIdentityResolver.fingerprintHex(noiseKey))
|
||||
info.nickname.takeIf { it.isNotBlank() }?.let { nickname ->
|
||||
identityManager.cacheFingerprintNickname(
|
||||
com.bitchat.android.services.ContactIdentityResolver.fingerprintHex(noiseKey),
|
||||
nickname
|
||||
)
|
||||
}
|
||||
return ok("cache_peer_identity").put("peer", peerID)
|
||||
}
|
||||
|
||||
private fun syncRequest(peerID: String): JSONObject {
|
||||
val manager = MeshServiceHolder.sharedGossipSyncManager
|
||||
?: return err("sync_request", "gossip sync manager is unavailable")
|
||||
manager.scheduleInitialSyncToPeer(peerID, 0)
|
||||
return ok("sync_request").put("peer", peerID)
|
||||
}
|
||||
|
||||
private class LabStorageContext(base: Context, private val labFilesDir: File) : ContextWrapper(base) {
|
||||
override fun getApplicationContext(): Context = this
|
||||
override fun getFilesDir(): File = labFilesDir
|
||||
}
|
||||
|
||||
private class LabCipher(private val mask: Int) : ConversationStorageCipher {
|
||||
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray =
|
||||
plaintext.map { (it.toInt() xor mask).toByte() }.toByteArray()
|
||||
|
||||
override fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray =
|
||||
encrypt(envelope, associatedData)
|
||||
|
||||
override fun destroyKey() = Unit
|
||||
}
|
||||
|
||||
// MARK: - Transport / state
|
||||
|
||||
private fun setBle(enabled: Boolean): JSONObject {
|
||||
@ -661,6 +823,14 @@ object TestHookDriver {
|
||||
return ok("ble").put("enabled", enabled)
|
||||
}
|
||||
|
||||
private fun setWifiAware(enabled: Boolean): JSONObject {
|
||||
val previous = com.bitchat.android.wifiaware.WifiAwareController.enabled.value
|
||||
DebugSettingsManager.getInstance().setWifiAwareEnabled(enabled)
|
||||
return ok("wifi_aware")
|
||||
.put("enabled", enabled)
|
||||
.put("previous_enabled", previous)
|
||||
}
|
||||
|
||||
private fun state(context: Context): JSONObject {
|
||||
val mesh = mesh(context)
|
||||
val peersJson = peerInfosJson(mesh, AppStateStore.peers.value)
|
||||
|
||||
@ -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,26 @@ class BluetoothConnectionManager(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun sendToPeerAndAwaitAcceptance(peerID: String, routed: RoutedPacket): Boolean {
|
||||
if (!isActive || !isBleTransportEnabled()) return false
|
||||
return packetBroadcaster.sendPacketToPeerAndAwaitAcceptance(
|
||||
routed,
|
||||
peerID,
|
||||
serverManager.getGattServer(),
|
||||
serverManager.getCharacteristic()
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun sendToPeerAndAwaitCompletion(peerID: String, routed: RoutedPacket): Boolean {
|
||||
if (!isActive || !isBleTransportEnabled()) return false
|
||||
return packetBroadcaster.sendPacketToPeerAndAwaitCompletion(
|
||||
routed,
|
||||
peerID,
|
||||
serverManager.getGattServer(),
|
||||
serverManager.getCharacteristic()
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelTransfer(transferId: String): Boolean {
|
||||
return packetBroadcaster.cancelTransfer(transferId)
|
||||
}
|
||||
|
||||
@ -49,6 +49,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// Core components - each handling specific responsibilities
|
||||
private val encryptionService = EncryptionService(context)
|
||||
private val courierStore = CourierStore(context)
|
||||
private val bridgeCourierService by lazy {
|
||||
com.bitchat.android.nostr.BridgeCourierService(context, encryptionService) { envelope ->
|
||||
handleLocalCourierEnvelope(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
// My peer identification - derived from persisted Noise identity fingerprint (first 16 hex chars)
|
||||
val myPeerID: String = encryptionService.getIdentityFingerprint().take(16)
|
||||
@ -156,10 +162,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
gossipSyncManager = GossipSyncManager(
|
||||
myPeerID = myPeerID,
|
||||
scope = serviceScope,
|
||||
context = context,
|
||||
configProvider = object : GossipSyncManager.ConfigProvider {
|
||||
override fun seenCapacity(): Int = try {
|
||||
com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(500)
|
||||
} catch (_: Exception) { 500 }
|
||||
com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(1000)
|
||||
} catch (_: Exception) { 1000 }
|
||||
|
||||
override fun gcsMaxBytes(): Int = try {
|
||||
com.bitchat.android.ui.debug.DebugPreferenceManager.getGcsMaxFilterBytes(400)
|
||||
@ -177,6 +184,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
if (isBleTransportEnabled()) {
|
||||
TransportBridgeService.register("BLE", this)
|
||||
}
|
||||
bridgeCourierService.start()
|
||||
|
||||
// Inject dynamic direct connection check into PeerManager
|
||||
// Matches iOS logic: checks if we have an active hardware mapping for this peer
|
||||
@ -200,6 +208,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
connectionManager.sendPacketToPeer(peerID, packet)
|
||||
}
|
||||
|
||||
override fun sendToPeerAndReport(peerID: String, packet: BitchatPacket): Boolean {
|
||||
if (!isBleTransportEnabled()) return false
|
||||
return connectionManager.sendPacketToPeer(peerID, packet)
|
||||
}
|
||||
|
||||
private fun broadcastRoutedPacket(routed: RoutedPacket): Boolean {
|
||||
if (!isBleTransportEnabled()) return false
|
||||
val queued = connectionManager.broadcastPacket(routed)
|
||||
@ -514,6 +527,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
override fun onDeliveryAckReceived(messageID: String, peerID: String) {
|
||||
try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageID, peerID) } catch (_: Exception) { }
|
||||
// Status events can arrive while MainActivity has detached the UI delegate.
|
||||
// Persist first so the next UI collector observes the advancement.
|
||||
try {
|
||||
@ -526,6 +540,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
override fun onReadReceiptReceived(messageID: String, peerID: String) {
|
||||
try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageID, peerID) } catch (_: Exception) { }
|
||||
try {
|
||||
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
|
||||
messageID,
|
||||
@ -574,6 +589,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean {
|
||||
return runBlocking { messageHandler.handleNoiseEncrypted(routed) }
|
||||
}
|
||||
|
||||
override fun handleCourierEnvelope(routed: RoutedPacket): Boolean {
|
||||
return handleCourierEnvelopePacket(routed)
|
||||
}
|
||||
|
||||
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
|
||||
val result = messageHandler.handleAnnounceWithResult(routed)
|
||||
@ -599,6 +618,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
}
|
||||
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
|
||||
handleCourierAnnounce(routed)
|
||||
return true
|
||||
}
|
||||
|
||||
@ -608,7 +628,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
try {
|
||||
val pkt = routed.packet
|
||||
val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST))
|
||||
if (isBroadcast && pkt.type == MessageType.MESSAGE.value) {
|
||||
if (isBroadcast && pkt.type in setOf(MessageType.MESSAGE.value, MessageType.FILE_TRANSFER.value)) {
|
||||
gossipSyncManager.onPublicPacketSeen(pkt)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
@ -809,6 +829,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
* Stop all mesh services
|
||||
*/
|
||||
fun stopServices() {
|
||||
if (bridgeCourierService.isStarted) bridgeCourierService.stop()
|
||||
if (!isActive) {
|
||||
Log.w(TAG, "Mesh service not active, ignoring stop request")
|
||||
return
|
||||
@ -1442,6 +1463,209 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
return peerManager.getPeerInfo(peerID)
|
||||
}
|
||||
|
||||
fun getPeerInfos(): List<PeerInfo> = peerManager.getAllPeerNicknames().keys.mapNotNull(peerManager::getPeerInfo)
|
||||
|
||||
fun sendCourierMessage(
|
||||
content: String,
|
||||
messageID: String,
|
||||
recipientNoiseKey: ByteArray,
|
||||
courierPeerIDs: List<String>
|
||||
): List<String> {
|
||||
val privateMessage = com.bitchat.android.model.PrivateMessagePacket(messageID, content).encode() ?: return emptyList()
|
||||
val typedPayload = com.bitchat.android.model.NoisePayload(
|
||||
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE,
|
||||
privateMessage
|
||||
).encode()
|
||||
val sealed = try { encryptionService.sealCourierPayload(typedPayload, recipientNoiseKey) } catch (_: Exception) { return emptyList() }
|
||||
val now = System.currentTimeMillis()
|
||||
val couriers = courierPeerIDs.distinct().take(4)
|
||||
if (couriers.isEmpty()) return emptyList()
|
||||
return couriers.filter { courierID ->
|
||||
val envelope = com.bitchat.android.model.CourierEnvelope(
|
||||
recipientTag = com.bitchat.android.model.CourierEnvelope.recipientTag(
|
||||
recipientNoiseKey,
|
||||
com.bitchat.android.model.CourierEnvelope.epochDay(now)
|
||||
),
|
||||
expiry = (now + com.bitchat.android.model.CourierEnvelope.MAX_LIFETIME_MS).toULong(),
|
||||
ciphertext = sealed,
|
||||
copies = 4u
|
||||
)
|
||||
val payload = envelope.encode() ?: return@filter false
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.COURIER_ENVELOPE.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(courierID),
|
||||
timestamp = now.toULong(),
|
||||
payload = payload,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
TransportBridgeService.sendToPeerFromLocalAndReport(courierID, signPacketBeforeBroadcast(packet))
|
||||
}
|
||||
}
|
||||
|
||||
fun sendBridgeCourierMessage(
|
||||
content: String,
|
||||
messageID: String,
|
||||
recipientNoiseKey: ByteArray,
|
||||
onAccepted: () -> Unit = {}
|
||||
): Boolean = bridgeCourierService.deposit(content, messageID, recipientNoiseKey, onAccepted)
|
||||
|
||||
private fun handleLocalCourierEnvelope(envelope: com.bitchat.android.model.CourierEnvelope) {
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.COURIER_ENVELOPE.value,
|
||||
senderID = ByteArray(8),
|
||||
recipientID = hexStringToByteArray(myPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = envelope.encode() ?: return,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
handleCourierEnvelopePacket(RoutedPacket(packet, peerID = "bridge"))
|
||||
}
|
||||
|
||||
private fun handleCourierEnvelopePacket(routed: RoutedPacket): Boolean {
|
||||
val envelope = com.bitchat.android.model.CourierEnvelope.decode(routed.packet.payload) ?: return false
|
||||
val now = System.currentTimeMillis()
|
||||
if (envelope.expiry.toLong() <= now) return false
|
||||
val localKey = encryptionService.getStaticPublicKey() ?: return false
|
||||
if (envelope.matchesRecipient(localKey, now)) {
|
||||
val (senderKey, typedPayload) = try { encryptionService.openCourierPayload(envelope.ciphertext) } catch (_: Exception) { return false }
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload.decode(typedPayload) ?: return false
|
||||
if (noisePayload.type !in setOf(
|
||||
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE,
|
||||
com.bitchat.android.model.NoisePayloadType.DELIVERED
|
||||
)
|
||||
) return false
|
||||
val senderPeerID = com.bitchat.android.services.ContactIdentityResolver.peerIdForNoiseKey(senderKey)
|
||||
val synthetic = routed.copy(
|
||||
packet = routed.packet.copy(
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(senderPeerID),
|
||||
payload = typedPayload,
|
||||
timestamp = System.currentTimeMillis().toULong()
|
||||
),
|
||||
peerID = senderPeerID
|
||||
)
|
||||
val delivered = runBlocking { messageHandler.handleOpenedCourierPayload(synthetic) }
|
||||
if (delivered && noisePayload.type == com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) {
|
||||
val messageID = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data)?.messageID
|
||||
if (messageID != null) sendCourierDeliveryAck(messageID, senderKey, routed)
|
||||
}
|
||||
return delivered
|
||||
}
|
||||
val peerID = routed.peerID ?: return false
|
||||
if (!DirectCourierDepositPolicy.accepts(
|
||||
routed,
|
||||
MAX_TTL,
|
||||
connectionManager::getCurrentLinkID,
|
||||
connectionManager.addressPeerMap::get
|
||||
)
|
||||
) return false
|
||||
val depositor = peerManager.getPeerInfo(peerID) ?: return false
|
||||
val key = depositor.noisePublicKey ?: return false
|
||||
val favorite = try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(key)?.isMutual == true
|
||||
} catch (_: Exception) { false }
|
||||
val tier = if (favorite) CourierDepositTier.FAVORITE
|
||||
else if (depositor.hasVerifiedAnnouncement) CourierDepositTier.VERIFIED
|
||||
else return false
|
||||
return courierStore.deposit(envelope, key, tier)
|
||||
}
|
||||
|
||||
private fun sendCourierDeliveryAck(messageID: String, senderNoiseKey: ByteArray, ingress: RoutedPacket) {
|
||||
val typedPayload = com.bitchat.android.model.NoisePayload(
|
||||
com.bitchat.android.model.NoisePayloadType.DELIVERED,
|
||||
messageID.toByteArray(Charsets.UTF_8)
|
||||
).encode()
|
||||
if (ingress.peerID == "bridge") {
|
||||
bridgeCourierService.depositPayload(typedPayload, senderNoiseKey)
|
||||
return
|
||||
}
|
||||
val courierPeerID = ingress.peerID ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
val sealed = try { encryptionService.sealCourierPayload(typedPayload, senderNoiseKey) } catch (_: Exception) { return }
|
||||
val envelope = com.bitchat.android.model.CourierEnvelope(
|
||||
recipientTag = com.bitchat.android.model.CourierEnvelope.recipientTag(
|
||||
senderNoiseKey,
|
||||
com.bitchat.android.model.CourierEnvelope.epochDay(now)
|
||||
),
|
||||
expiry = (now + com.bitchat.android.model.CourierEnvelope.MAX_LIFETIME_MS).toULong(),
|
||||
ciphertext = sealed,
|
||||
copies = 4u
|
||||
)
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.COURIER_ENVELOPE.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(courierPeerID),
|
||||
timestamp = now.toULong(),
|
||||
payload = envelope.encode() ?: return,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
TransportBridgeService.sendToPeerFromLocalAndReport(courierPeerID, signPacketBeforeBroadcast(packet))
|
||||
}
|
||||
|
||||
private fun handleCourierAnnounce(routed: RoutedPacket) {
|
||||
val peerID = routed.peerID ?: return
|
||||
val info = peerManager.getPeerInfo(peerID) ?: return
|
||||
val noiseKey = info.noisePublicKey ?: return
|
||||
val direct = routed.packet.ttl >= MAX_TTL
|
||||
val envelopes = if (direct) courierStore.copiesForRecipient(noiseKey) else courierStore.copiesForRemoteHandover(noiseKey)
|
||||
envelopes.forEach { envelope ->
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.COURIER_ENVELOPE.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(peerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = envelope.encode() ?: return@forEach,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
if (direct) {
|
||||
serviceScope.launch {
|
||||
if (connectionManager.sendToPeerAndAwaitCompletion(
|
||||
peerID,
|
||||
RoutedPacket(signPacketBeforeBroadcast(packet))
|
||||
)
|
||||
) {
|
||||
courierStore.remove(envelope)
|
||||
}
|
||||
}
|
||||
} else TransportBridgeService.broadcastFromLocal(RoutedPacket(signPacketBeforeBroadcast(packet)))
|
||||
}
|
||||
if (direct) {
|
||||
courierStore.sprayCopiesFor(noiseKey).forEach { envelope ->
|
||||
val payload = envelope.encode()
|
||||
if (payload == null) {
|
||||
courierStore.cancelSpray(envelope, noiseKey)
|
||||
return@forEach
|
||||
}
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.COURIER_ENVELOPE.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(peerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
serviceScope.launch {
|
||||
var committed = false
|
||||
try {
|
||||
if (connectionManager.sendToPeerAndAwaitCompletion(
|
||||
peerID,
|
||||
RoutedPacket(signPacketBeforeBroadcast(packet))
|
||||
)
|
||||
) {
|
||||
committed = courierStore.commitSpray(envelope, noiseKey)
|
||||
}
|
||||
} finally {
|
||||
if (!committed) courierStore.cancelSpray(envelope, noiseKey)
|
||||
}
|
||||
}.invokeOnCompletion {
|
||||
// A coroutine cancelled before its body starts never reaches finally.
|
||||
courierStore.cancelSpray(envelope, noiseKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update peer information with verification data
|
||||
*/
|
||||
@ -1620,19 +1844,21 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
*/
|
||||
fun clearAllInternalData() {
|
||||
Log.w(TAG, "Clearing all mesh service internal data")
|
||||
try {
|
||||
// Stop services to cease broadcasting old ID immediately
|
||||
stopServices()
|
||||
|
||||
// Clear all managers
|
||||
fragmentManager.clearAllFragments()
|
||||
storeForwardManager.clearAllCache()
|
||||
securityManager.clearAllData()
|
||||
peerManager.clearAllPeers()
|
||||
peerManager.clearAllFingerprints()
|
||||
try { gossipSyncManager.clear() } catch (_: Exception) { }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error clearing mesh service internal data: ${e.message}")
|
||||
val operations = listOf<() -> Unit>(
|
||||
::stopServices,
|
||||
fragmentManager::clearAllFragments,
|
||||
storeForwardManager::clearAllCache,
|
||||
securityManager::clearAllData,
|
||||
peerManager::clearAllPeers,
|
||||
peerManager::clearAllFingerprints,
|
||||
courierStore::wipe,
|
||||
bridgeCourierService::stop,
|
||||
gossipSyncManager::clear
|
||||
)
|
||||
operations.forEach { operation ->
|
||||
try { operation() } catch (e: Exception) {
|
||||
Log.e(TAG, "Error clearing mesh service internal data: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.coroutines.channels.actor
|
||||
import java.util.ArrayDeque
|
||||
|
||||
@ -55,6 +56,7 @@ class BluetoothPacketBroadcaster(
|
||||
private const val MAX_PENDING_BYTES_PER_LINK = 1_048_576
|
||||
private const val SEND_RETRY_DELAY_MS = 15L
|
||||
private const val MAX_CALLBACK_RETRIES = 3
|
||||
private const val SEND_COMPLETION_TIMEOUT_MS = 30_000L
|
||||
}
|
||||
|
||||
// Optional nickname resolver injected by higher layer (peerID -> nickname?)
|
||||
@ -140,6 +142,7 @@ class BluetoothPacketBroadcaster(
|
||||
val gatt: BluetoothGatt? = null,
|
||||
val gattServer: BluetoothGattServer? = null,
|
||||
val characteristic: BluetoothGattCharacteristic,
|
||||
val completion: CompletableDeferred<Boolean>? = null,
|
||||
var callbackFailures: Int = 0
|
||||
)
|
||||
|
||||
@ -204,6 +207,30 @@ class BluetoothPacketBroadcaster(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendPacketToPeerAndAwaitAcceptance(
|
||||
routed: RoutedPacket,
|
||||
targetPeerID: String,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
if (!hasPeerConnection(targetPeerID)) return false
|
||||
return fragmentingSender.sendAndAwaitAcceptance(routed, "BLE peer ${targetPeerID.take(8)}") { packet ->
|
||||
sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendPacketToPeerAndAwaitCompletion(
|
||||
routed: RoutedPacket,
|
||||
targetPeerID: String,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
if (!hasPeerConnection(targetPeerID)) return false
|
||||
return fragmentingSender.sendAndAwaitAcceptance(routed, "BLE peer ${targetPeerID.take(8)}") { packet ->
|
||||
sendSinglePacketToPeerAndAwaitCompletion(packet, targetPeerID, gattServer, characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPacketToLink(
|
||||
routed: RoutedPacket,
|
||||
deviceAddress: String,
|
||||
@ -267,6 +294,35 @@ class BluetoothPacketBroadcaster(
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun sendSinglePacketToPeerAndAwaitCompletion(
|
||||
routed: RoutedPacket,
|
||||
targetPeerID: String,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
val packet = routed.packet
|
||||
val data = packet.toBinaryData(
|
||||
padding = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type)
|
||||
) ?: return false
|
||||
val completion = CompletableDeferred<Boolean>()
|
||||
|
||||
val serverTarget = connectionTracker.getSubscribedDevices()
|
||||
.firstOrNull { connectionTracker.addressPeerMap[it.address] == targetPeerID }
|
||||
val queuedOnServer = serverTarget != null &&
|
||||
notifyDevice(serverTarget, data, gattServer, characteristic, completion)
|
||||
val queued = if (queuedOnServer) {
|
||||
true
|
||||
} else {
|
||||
val clientTarget = connectionTracker.getConnectedDevices().values
|
||||
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == targetPeerID }
|
||||
?: return false
|
||||
writeToDeviceConn(clientTarget, data, completion)
|
||||
}
|
||||
if (!queued) return false
|
||||
|
||||
return withTimeoutOrNull(SEND_COMPLETION_TIMEOUT_MS) { completion.await() } ?: false
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Public entry point for broadcasting - submits request to actor for serialization
|
||||
@ -474,7 +530,8 @@ class BluetoothPacketBroadcaster(
|
||||
device: BluetoothDevice,
|
||||
data: ByteArray,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
characteristic: BluetoothGattCharacteristic?,
|
||||
completion: CompletableDeferred<Boolean>? = null
|
||||
): Boolean {
|
||||
val server = gattServer ?: return false
|
||||
val char = characteristic ?: return false
|
||||
@ -484,7 +541,13 @@ class BluetoothPacketBroadcaster(
|
||||
?: return false
|
||||
return enqueueSend(
|
||||
SendKey(device.address, linkID, SendDirection.SERVER_NOTIFICATION),
|
||||
PendingSend(data.copyOf(), device, gattServer = server, characteristic = char)
|
||||
PendingSend(
|
||||
data.copyOf(),
|
||||
device,
|
||||
gattServer = server,
|
||||
characteristic = char,
|
||||
completion = completion
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -493,13 +556,20 @@ class BluetoothPacketBroadcaster(
|
||||
*/
|
||||
private fun writeToDeviceConn(
|
||||
deviceConn: BluetoothConnectionTracker.DeviceConnection,
|
||||
data: ByteArray
|
||||
data: ByteArray,
|
||||
completion: CompletableDeferred<Boolean>? = null
|
||||
): Boolean {
|
||||
val gatt = deviceConn.gatt ?: return false
|
||||
val char = deviceConn.characteristic ?: return false
|
||||
return enqueueSend(
|
||||
SendKey(deviceConn.device.address, deviceConn.linkID, SendDirection.CLIENT_WRITE),
|
||||
PendingSend(data.copyOf(), deviceConn.device, gatt = gatt, characteristic = char)
|
||||
PendingSend(
|
||||
data.copyOf(),
|
||||
deviceConn.device,
|
||||
gatt = gatt,
|
||||
characteristic = char,
|
||||
completion = completion
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -631,6 +701,7 @@ class BluetoothPacketBroadcaster(
|
||||
}
|
||||
state.pending.removeFirst()
|
||||
state.pendingBytes -= head.data.size
|
||||
head.completion?.complete(status == BluetoothGatt.GATT_SUCCESS)
|
||||
if (state.pending.isEmpty()) {
|
||||
sendStates.remove(key)
|
||||
false
|
||||
@ -645,8 +716,13 @@ class BluetoothPacketBroadcaster(
|
||||
|
||||
fun onLinkDisconnected(deviceAddress: String, linkID: String?) {
|
||||
synchronized(sendLock) {
|
||||
sendStates.keys.removeAll { key ->
|
||||
key.deviceAddress == deviceAddress && (linkID == null || key.linkID == linkID)
|
||||
val iterator = sendStates.entries.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val (key, state) = iterator.next()
|
||||
if (key.deviceAddress == deviceAddress && (linkID == null || key.linkID == linkID)) {
|
||||
state.pending.forEach { it.completion?.complete(false) }
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -667,7 +743,12 @@ class BluetoothPacketBroadcaster(
|
||||
* Shutdown the broadcaster actor gracefully
|
||||
*/
|
||||
fun shutdown() {
|
||||
synchronized(sendLock) { sendStates.clear() }
|
||||
synchronized(sendLock) {
|
||||
sendStates.values.forEach { state ->
|
||||
state.pending.forEach { it.completion?.complete(false) }
|
||||
}
|
||||
sendStates.clear()
|
||||
}
|
||||
// Close the actor gracefully
|
||||
broadcasterActor.close()
|
||||
|
||||
|
||||
241
app/src/main/java/com/bitchat/android/mesh/CourierStore.kt
Normal file
241
app/src/main/java/com/bitchat/android/mesh/CourierStore.kt
Normal file
@ -0,0 +1,241 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.content.Context
|
||||
import com.bitchat.android.model.CourierEnvelope
|
||||
import com.bitchat.android.services.AndroidConversationStorageCipher
|
||||
import com.bitchat.android.services.ConversationStorageCipher
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.io.File
|
||||
import java.util.Base64
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
enum class CourierDepositTier { FAVORITE, VERIFIED }
|
||||
|
||||
/** Bounded persistent mailbag for opaque envelopes deposited by other peers. */
|
||||
internal class CourierStore(
|
||||
context: Context,
|
||||
private val cipher: ConversationStorageCipher = AndroidConversationStorageCipher(KEY_ALIAS),
|
||||
private val now: () -> Long = System::currentTimeMillis
|
||||
) {
|
||||
private data class Stored(
|
||||
val encoded: String,
|
||||
val depositorKey: String,
|
||||
val tier: CourierDepositTier,
|
||||
var copies: Int,
|
||||
val sprayedTo: MutableSet<String> = mutableSetOf(),
|
||||
var lastRemoteHandoverAtMs: Long = 0
|
||||
)
|
||||
|
||||
private data class SprayReservation(
|
||||
val envelopeKey: String,
|
||||
val courierKey: String,
|
||||
val copies: Int
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val KEY_ALIAS = "bitchat_courier_store_v1"
|
||||
private val AAD = "bitchat-courier-store-v1".toByteArray(Charsets.UTF_8)
|
||||
private const val MAX_ENVELOPES = 40
|
||||
private const val MAX_VERIFIED_ENVELOPES = 20
|
||||
private const val MAX_PER_FAVORITE = 5
|
||||
private const val MAX_PER_VERIFIED = 2
|
||||
private const val EXPIRY_SLACK_MS = 60 * 60 * 1000L
|
||||
private const val REMOTE_HANDOVER_COOLDOWN_MS = 10 * 60 * 1000L
|
||||
}
|
||||
|
||||
private val gson = Gson()
|
||||
private val file = File(context.applicationContext.filesDir, "courier-store.sealed")
|
||||
private val stored = load()
|
||||
private val sprayReservations = mutableListOf<SprayReservation>()
|
||||
|
||||
@Synchronized
|
||||
fun deposit(envelope: CourierEnvelope, depositorNoiseKey: ByteArray, tier: CourierDepositTier): Boolean {
|
||||
pruneExpired()
|
||||
val nowMs = now()
|
||||
if (envelope.expiry.toLong() <= nowMs ||
|
||||
envelope.expiry.toLong() > nowMs + CourierEnvelope.MAX_LIFETIME_MS + EXPIRY_SLACK_MS
|
||||
) return false
|
||||
val encodedBytes = envelope.encode() ?: return false
|
||||
val encoded = Base64.getEncoder().encodeToString(encodedBytes)
|
||||
if (stored.any { it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true }) return true
|
||||
val depositor = depositorNoiseKey.toHex()
|
||||
val perDepositor = if (tier == CourierDepositTier.FAVORITE) MAX_PER_FAVORITE else MAX_PER_VERIFIED
|
||||
if (stored.count { it.depositorKey == depositor && it.tier == tier } >= perDepositor) return false
|
||||
if (tier == CourierDepositTier.VERIFIED && stored.count { it.tier == tier } >= MAX_VERIFIED_ENVELOPES) {
|
||||
stored.removeAt(stored.indexOfFirst { it.tier == CourierDepositTier.VERIFIED })
|
||||
}
|
||||
if (stored.size >= MAX_ENVELOPES) {
|
||||
val verifiedIndex = stored.indexOfFirst { it.tier == CourierDepositTier.VERIFIED }
|
||||
if (tier == CourierDepositTier.VERIFIED && verifiedIndex < 0) return false
|
||||
val index = verifiedIndex.takeIf { it >= 0 } ?: 0
|
||||
stored.removeAt(index)
|
||||
}
|
||||
stored += Stored(encoded, depositor, tier, envelope.copies.toInt())
|
||||
persist()
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun copiesForRecipient(recipientNoiseKey: ByteArray): List<CourierEnvelope> {
|
||||
pruneExpired()
|
||||
val nowMs = now()
|
||||
return stored.mapNotNull { record ->
|
||||
record.envelope()
|
||||
?.takeIf { it.matchesRecipient(recipientNoiseKey, nowMs) }
|
||||
?.copy(copies = 1u)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun remove(envelope: CourierEnvelope): Boolean {
|
||||
val envelopeKey = envelope.ciphertext.storageKey()
|
||||
val removed = stored.removeAll {
|
||||
it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true
|
||||
}
|
||||
if (removed) {
|
||||
sprayReservations.removeAll { it.envelopeKey == envelopeKey }
|
||||
persist()
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun copiesForRemoteHandover(recipientNoiseKey: ByteArray): List<CourierEnvelope> {
|
||||
pruneExpired()
|
||||
val nowMs = now()
|
||||
val result = mutableListOf<CourierEnvelope>()
|
||||
stored.forEach { record ->
|
||||
val envelope = record.envelope() ?: return@forEach
|
||||
if (envelope.matchesRecipient(recipientNoiseKey, nowMs) &&
|
||||
nowMs - record.lastRemoteHandoverAtMs >= REMOTE_HANDOVER_COOLDOWN_MS
|
||||
) {
|
||||
record.lastRemoteHandoverAtMs = nowMs
|
||||
result += envelope.copy(copies = 1u)
|
||||
}
|
||||
}
|
||||
if (result.isNotEmpty()) persist()
|
||||
return result
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sprayCopiesFor(courierNoiseKey: ByteArray): List<CourierEnvelope> {
|
||||
pruneExpired()
|
||||
val key = courierNoiseKey.toHex()
|
||||
val nowMs = now()
|
||||
val courierTags = listOf(-1, 0, 1).map {
|
||||
CourierEnvelope.recipientTag(courierNoiseKey, CourierEnvelope.epochDay(nowMs) + it.toUInt())
|
||||
}
|
||||
val result = mutableListOf<CourierEnvelope>()
|
||||
stored.forEach { record ->
|
||||
val envelope = record.envelope() ?: return@forEach
|
||||
val envelopeKey = envelope.ciphertext.storageKey()
|
||||
if (record.copies <= 1 || record.depositorKey == key || key in record.sprayedTo ||
|
||||
sprayReservations.any { it.envelopeKey == envelopeKey && it.courierKey == key } ||
|
||||
courierTags.any { it.contentEquals(envelope.recipientTag) }
|
||||
) return@forEach
|
||||
val reserved = sprayReservations
|
||||
.filter { it.envelopeKey == envelopeKey }
|
||||
.sumOf { it.copies }
|
||||
val available = record.copies - reserved
|
||||
if (available <= 1) return@forEach
|
||||
val given = available / 2
|
||||
sprayReservations += SprayReservation(envelopeKey, key, given)
|
||||
result += envelope.copy(copies = given.toUByte())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun commitSpray(envelope: CourierEnvelope, courierNoiseKey: ByteArray): Boolean {
|
||||
val key = courierNoiseKey.toHex()
|
||||
val envelopeKey = envelope.ciphertext.storageKey()
|
||||
val reservationIndex = sprayReservations.indexOfFirst {
|
||||
it.envelopeKey == envelopeKey && it.courierKey == key && it.copies == envelope.copies.toInt()
|
||||
}
|
||||
if (reservationIndex < 0) return false
|
||||
val reservation = sprayReservations[reservationIndex]
|
||||
val record = stored.firstOrNull {
|
||||
it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true
|
||||
}
|
||||
val otherReservedCopies = sprayReservations.withIndex()
|
||||
.filter { (index, candidate) -> index != reservationIndex && candidate.envelopeKey == envelopeKey }
|
||||
.sumOf { it.value.copies }
|
||||
if (record == null || key in record.sprayedTo ||
|
||||
record.copies - otherReservedCopies <= reservation.copies
|
||||
) {
|
||||
sprayReservations.removeAt(reservationIndex)
|
||||
return false
|
||||
}
|
||||
record.copies -= reservation.copies
|
||||
record.sprayedTo += key
|
||||
sprayReservations.removeAt(reservationIndex)
|
||||
persist()
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun cancelSpray(envelope: CourierEnvelope, courierNoiseKey: ByteArray): Boolean {
|
||||
val envelopeKey = envelope.ciphertext.storageKey()
|
||||
val courierKey = courierNoiseKey.toHex()
|
||||
return sprayReservations.removeAll {
|
||||
it.envelopeKey == envelopeKey && it.courierKey == courierKey && it.copies == envelope.copies.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun wipe() {
|
||||
stored.clear()
|
||||
sprayReservations.clear()
|
||||
file.delete()
|
||||
cipher.destroyKey()
|
||||
}
|
||||
|
||||
private fun pruneExpired() {
|
||||
val nowMs = now().toULong()
|
||||
if (stored.removeAll { (it.envelope()?.expiry ?: 0u) <= nowMs }) {
|
||||
val retainedEnvelopeKeys = stored.mapNotNull { it.envelope()?.ciphertext?.storageKey() }.toSet()
|
||||
sprayReservations.removeAll { it.envelopeKey !in retainedEnvelopeKeys }
|
||||
persist()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Stored.envelope(): CourierEnvelope? = try {
|
||||
CourierEnvelope.decode(Base64.getDecoder().decode(encoded))
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
private fun load(): MutableList<Stored> = try {
|
||||
if (!file.exists()) return mutableListOf()
|
||||
val plaintext = cipher.decrypt(file.readBytes(), AAD)
|
||||
val type = object : TypeToken<MutableList<Stored>>() {}.type
|
||||
gson.fromJson<MutableList<Stored>>(plaintext.toString(Charsets.UTF_8), type) ?: mutableListOf()
|
||||
} catch (_: Exception) { mutableListOf() }
|
||||
|
||||
private fun persist() {
|
||||
if (stored.isEmpty()) {
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
val encrypted = cipher.encrypt(gson.toJson(stored).toByteArray(Charsets.UTF_8), AAD)
|
||||
val temporary = File(file.parentFile, "${file.name}.tmp")
|
||||
temporary.writeBytes(encrypted)
|
||||
try {
|
||||
java.nio.file.Files.move(
|
||||
temporary.toPath(),
|
||||
file.toPath(),
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
java.nio.file.Files.move(
|
||||
temporary.toPath(),
|
||||
file.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun ByteArray.storageKey(): String = Base64.getEncoder().encodeToString(this)
|
||||
}
|
||||
@ -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: suspend (RoutedPacket) -> Boolean
|
||||
): Boolean {
|
||||
val packets = packetsForTransport(routed) ?: return false
|
||||
Log.d(logTag, "Sending ${packets.size} packet(s) for $description")
|
||||
for ((index, packet) in packets.withIndex()) {
|
||||
val accepted = sendSingle(
|
||||
routed.copy(packet = packet, transferId = null, preparedPackets = null)
|
||||
)
|
||||
if (!accepted) return false
|
||||
if (index < packets.lastIndex) delay(interFragmentDelayMs)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun cancelTransfer(transferId: String): Boolean {
|
||||
val job = transferJobs.remove(transferId) ?: return false
|
||||
job.cancel()
|
||||
|
||||
@ -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,12 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
private suspend fun handleBroadcastMessage(routed: RoutedPacket) {
|
||||
val packet = routed.packet
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
if (packet.timestamp > Long.MAX_VALUE.toULong()) return
|
||||
val ageMs = System.currentTimeMillis() - packet.timestamp.toLong()
|
||||
if (ageMs !in
|
||||
-com.bitchat.android.sync.GossipSyncManager.PUBLIC_PACKET_FUTURE_SKEW_MS..
|
||||
com.bitchat.android.sync.GossipSyncManager.PUBLIC_MESSAGE_MAX_AGE_MS
|
||||
) return
|
||||
|
||||
// Enforce: only accept public messages from verified peers we know
|
||||
val peerInfo = delegate?.getPeerInfo(peerID)
|
||||
|
||||
@ -122,6 +122,22 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
|
||||
var validPacket = true
|
||||
val messageType = MessageType.fromValue(packet.type)
|
||||
val isBroadcast = packet.recipientID == null ||
|
||||
packet.recipientID.contentEquals(com.bitchat.android.protocol.SpecialRecipients.BROADCAST)
|
||||
if (isBroadcast && packet.timestamp <= Long.MAX_VALUE.toULong()) {
|
||||
val ageMs = System.currentTimeMillis() - packet.timestamp.toLong()
|
||||
val maxAgeMs = when (messageType) {
|
||||
MessageType.MESSAGE -> com.bitchat.android.sync.GossipSyncManager.PUBLIC_MESSAGE_MAX_AGE_MS
|
||||
MessageType.FRAGMENT, MessageType.FILE_TRANSFER ->
|
||||
com.bitchat.android.sync.GossipSyncManager.FRAGMENT_MAX_AGE_MS
|
||||
else -> null
|
||||
}
|
||||
if (maxAgeMs != null && ageMs !in
|
||||
-com.bitchat.android.sync.GossipSyncManager.PUBLIC_PACKET_FUTURE_SKEW_MS..maxAgeMs
|
||||
) return
|
||||
} else if (isBroadcast && packet.timestamp > Long.MAX_VALUE.toULong()) {
|
||||
return
|
||||
}
|
||||
// Verbose logging to debug manager (and chat via ChatViewModel observer)
|
||||
try {
|
||||
val mt = messageType?.name ?: packet.type.toString()
|
||||
@ -146,6 +162,7 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
when (messageType) {
|
||||
MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed)
|
||||
MessageType.NOISE_ENCRYPTED -> validPacket = handleNoiseEncrypted(routed)
|
||||
MessageType.COURIER_ENVELOPE -> validPacket = delegate?.handleCourierEnvelope(routed) ?: false
|
||||
MessageType.FILE_TRANSFER -> handleMessage(routed)
|
||||
else -> {
|
||||
validPacket = false
|
||||
@ -295,6 +312,7 @@ interface PacketProcessorDelegate {
|
||||
// Message type handlers
|
||||
fun handleNoiseHandshake(routed: RoutedPacket): Boolean
|
||||
fun handleNoiseEncrypted(routed: RoutedPacket): Boolean
|
||||
fun handleCourierEnvelope(routed: RoutedPacket): Boolean = false
|
||||
suspend fun handleAnnounce(routed: RoutedPacket): Boolean
|
||||
fun handleMessage(routed: RoutedPacket)
|
||||
fun handleVoiceFrame(routed: RoutedPacket): Boolean = false
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
115
app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt
Normal file
115
app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt
Normal file
@ -0,0 +1,115 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/** Opaque store-and-forward envelope, wire-compatible with iOS CourierEnvelope. */
|
||||
data class CourierEnvelope(
|
||||
val recipientTag: ByteArray,
|
||||
val expiry: ULong,
|
||||
val ciphertext: ByteArray,
|
||||
val copies: UByte = 1u,
|
||||
val prekeyID: UInt? = null
|
||||
) {
|
||||
companion object {
|
||||
const val TAG_LENGTH = 16
|
||||
const val MAX_CIPHERTEXT_BYTES = 16 * 1024
|
||||
const val MAX_LIFETIME_MS = 24 * 60 * 60 * 1000L
|
||||
const val MAX_COPIES = 8
|
||||
private val TAG_DOMAIN = "bitchat-courier-tag-v1".toByteArray(Charsets.UTF_8)
|
||||
|
||||
fun epochDay(nowMs: Long): UInt = Math.floorDiv(nowMs, 86_400_000L).toUInt()
|
||||
|
||||
fun recipientTag(noiseStaticKey: ByteArray, epochDay: UInt): ByteArray {
|
||||
require(noiseStaticKey.size == 32)
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
mac.init(SecretKeySpec(noiseStaticKey, "HmacSHA256"))
|
||||
mac.update(TAG_DOMAIN)
|
||||
mac.update(ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(epochDay.toInt()).array())
|
||||
return mac.doFinal().copyOf(TAG_LENGTH)
|
||||
}
|
||||
|
||||
fun decode(data: ByteArray): CourierEnvelope? {
|
||||
var offset = 0
|
||||
var tag: ByteArray? = null
|
||||
var expiry: ULong? = null
|
||||
var ciphertext: ByteArray? = null
|
||||
var copies: UByte = 1u
|
||||
var prekeyID: UInt? = null
|
||||
while (offset + 3 <= data.size) {
|
||||
val type = data[offset].toUByte()
|
||||
val length = ((data[offset + 1].toInt() and 0xff) shl 8) or
|
||||
(data[offset + 2].toInt() and 0xff)
|
||||
offset += 3
|
||||
if (offset + length > data.size) return null
|
||||
val value = data.copyOfRange(offset, offset + length)
|
||||
offset += length
|
||||
when (type.toInt()) {
|
||||
1 -> if (length == TAG_LENGTH && tag == null) tag = value else return null
|
||||
2 -> if (length == 8 && expiry == null) expiry = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).long.toULong() else return null
|
||||
3 -> if (ciphertext == null) ciphertext = value else return null
|
||||
4 -> {
|
||||
if (length != 1 || copies != 1u.toUByte()) return null
|
||||
copies = value[0].toUByte()
|
||||
if (copies !in 2u.toUByte()..MAX_COPIES.toUByte()) return null
|
||||
}
|
||||
5 -> if (length == 4 && prekeyID == null) {
|
||||
prekeyID = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).int.toUInt()
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
if (offset != data.size) return null
|
||||
val requiredTag = tag ?: return null
|
||||
val requiredExpiry = expiry ?: return null
|
||||
val requiredCiphertext = ciphertext ?: return null
|
||||
if (requiredCiphertext.isEmpty() || requiredCiphertext.size > MAX_CIPHERTEXT_BYTES) return null
|
||||
return CourierEnvelope(requiredTag, requiredExpiry, requiredCiphertext, copies, prekeyID)
|
||||
}
|
||||
}
|
||||
|
||||
fun encode(): ByteArray? {
|
||||
if (recipientTag.size != TAG_LENGTH || ciphertext.isEmpty() || ciphertext.size > MAX_CIPHERTEXT_BYTES ||
|
||||
copies !in 1u.toUByte()..MAX_COPIES.toUByte()
|
||||
) return null
|
||||
val fields = mutableListOf<Pair<Int, ByteArray>>()
|
||||
fields += 1 to recipientTag
|
||||
fields += 2 to ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN).putLong(expiry.toLong()).array()
|
||||
fields += 3 to ciphertext
|
||||
if (copies > 1u) fields += 4 to byteArrayOf(copies.toByte())
|
||||
prekeyID?.let {
|
||||
fields += 5 to ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(it.toInt()).array()
|
||||
}
|
||||
val size = fields.sumOf { 3 + it.second.size }
|
||||
val buffer = ByteBuffer.allocate(size).order(ByteOrder.BIG_ENDIAN)
|
||||
fields.forEach { (type, value) ->
|
||||
buffer.put(type.toByte())
|
||||
buffer.putShort(value.size.toShort())
|
||||
buffer.put(value)
|
||||
}
|
||||
return buffer.array()
|
||||
}
|
||||
|
||||
fun matchesRecipient(noiseStaticKey: ByteArray, nowMs: Long): Boolean {
|
||||
val day = epochDay(nowMs)
|
||||
return listOf(day - 1u, day, day + 1u).any {
|
||||
recipientTag.contentEquals(recipientTag(noiseStaticKey, it))
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean = other is CourierEnvelope &&
|
||||
recipientTag.contentEquals(other.recipientTag) && expiry == other.expiry &&
|
||||
ciphertext.contentEquals(other.ciphertext) && copies == other.copies && prekeyID == other.prekeyID
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = recipientTag.contentHashCode()
|
||||
result = 31 * result + expiry.hashCode()
|
||||
result = 31 * result + ciphertext.contentHashCode()
|
||||
result = 31 * result + copies.hashCode()
|
||||
result = 31 * result + (prekeyID?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import com.bitchat.android.sync.SyncDefaults
|
||||
import com.bitchat.android.sync.GCSFilter
|
||||
import com.bitchat.android.sync.SyncTypeFlags
|
||||
|
||||
/**
|
||||
* REQUEST_SYNC payload using GCS (Golomb-Coded Set) parameters.
|
||||
@ -8,11 +10,15 @@ import com.bitchat.android.sync.SyncDefaults
|
||||
* - 0x01: P (uint8) — Golomb-Rice parameter
|
||||
* - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
||||
* - 0x03: data (opaque) — GR bitstream bytes
|
||||
* - 0x04: types (compact little-endian SyncTypeFlags) — packet types covered by the filter
|
||||
* - 0x05: sinceTimestamp (uint64, big-endian) — oldest timestamp covered by the filter
|
||||
*/
|
||||
data class RequestSyncPacket(
|
||||
val p: Int,
|
||||
val m: Long,
|
||||
val data: ByteArray
|
||||
val data: ByteArray,
|
||||
val types: SyncTypeFlags? = null,
|
||||
val sinceTimestamp: ULong? = null
|
||||
) {
|
||||
fun encode(): ByteArray {
|
||||
val out = ArrayList<Byte>()
|
||||
@ -38,6 +44,15 @@ data class RequestSyncPacket(
|
||||
)
|
||||
// data
|
||||
putTLV(0x03, data)
|
||||
types?.encode()?.let { putTLV(0x04, it) }
|
||||
sinceTimestamp?.let { timestamp ->
|
||||
putTLV(
|
||||
0x05,
|
||||
ByteArray(8) { index ->
|
||||
(timestamp shr ((7 - index) * 8) and 0xffu).toByte()
|
||||
}
|
||||
)
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
@ -50,6 +65,8 @@ data class RequestSyncPacket(
|
||||
var p: Int? = null
|
||||
var m: Long? = null
|
||||
var payload: ByteArray? = null
|
||||
var types: SyncTypeFlags? = null
|
||||
var sinceTimestamp: ULong? = null
|
||||
|
||||
while (off + 3 <= data.size) {
|
||||
val t = (data[off].toInt() and 0xFF); off += 1
|
||||
@ -69,14 +86,20 @@ data class RequestSyncPacket(
|
||||
if (v.size > MAX_ACCEPT_FILTER_BYTES) return null
|
||||
payload = v
|
||||
}
|
||||
0x04 -> SyncTypeFlags.decode(v)?.let { types = it }
|
||||
0x05 -> if (v.size == 8) {
|
||||
var timestamp = 0uL
|
||||
v.forEach { byte -> timestamp = (timestamp shl 8) or byte.toUByte().toULong() }
|
||||
sinceTimestamp = timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val pp = p ?: return null
|
||||
val mm = m ?: return null
|
||||
val dd = payload ?: return null
|
||||
if (pp < 1 || mm <= 0L) return null
|
||||
return RequestSyncPacket(pp, mm, dd)
|
||||
if (pp !in 1..GCSFilter.MAX_P || mm <= 0L) return null
|
||||
return RequestSyncPacket(pp, mm, dd, types, sinceTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,234 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import com.bitchat.android.crypto.EncryptionService
|
||||
import com.bitchat.android.model.CourierEnvelope
|
||||
import java.util.Collections
|
||||
import java.util.LinkedHashMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Minimal relay surface used by the bridge courier.
|
||||
*
|
||||
* Keeping this adapter boundary small lets the kind-1401 contract be exercised with a
|
||||
* deterministic in-memory relay in unit tests, without opening a network connection.
|
||||
*/
|
||||
internal interface BridgeCourierRelay {
|
||||
fun subscribe(
|
||||
filter: NostrFilter,
|
||||
id: String,
|
||||
targetRelayUrls: List<String>,
|
||||
handler: (NostrEvent) -> Unit
|
||||
)
|
||||
|
||||
fun unsubscribe(id: String)
|
||||
|
||||
fun hasConnectedRelay(relayUrls: Collection<String>): Boolean
|
||||
|
||||
fun sendEvent(
|
||||
event: NostrEvent,
|
||||
relayUrls: List<String>,
|
||||
onAccepted: () -> Unit
|
||||
): Boolean
|
||||
}
|
||||
|
||||
internal interface BridgeCourierCipher {
|
||||
fun staticPublicKey(): ByteArray?
|
||||
|
||||
fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray
|
||||
}
|
||||
|
||||
private class NostrBridgeCourierRelay(
|
||||
private val relayManager: NostrRelayManager
|
||||
) : BridgeCourierRelay {
|
||||
override fun subscribe(
|
||||
filter: NostrFilter,
|
||||
id: String,
|
||||
targetRelayUrls: List<String>,
|
||||
handler: (NostrEvent) -> Unit
|
||||
) {
|
||||
relayManager.subscribe(
|
||||
filter = filter,
|
||||
id = id,
|
||||
targetRelayUrls = targetRelayUrls,
|
||||
handler = handler
|
||||
)
|
||||
}
|
||||
|
||||
override fun unsubscribe(id: String) {
|
||||
relayManager.unsubscribe(id)
|
||||
}
|
||||
|
||||
override fun hasConnectedRelay(relayUrls: Collection<String>): Boolean =
|
||||
relayManager.hasConnectedRelay(relayUrls)
|
||||
|
||||
override fun sendEvent(
|
||||
event: NostrEvent,
|
||||
relayUrls: List<String>,
|
||||
onAccepted: () -> Unit
|
||||
): Boolean = relayManager.sendEvent(event, relayUrls, onAccepted = onAccepted)
|
||||
}
|
||||
|
||||
private class EncryptionBridgeCourierCipher(
|
||||
private val encryptionService: EncryptionService
|
||||
) : BridgeCourierCipher {
|
||||
override fun staticPublicKey(): ByteArray? = encryptionService.getStaticPublicKey()
|
||||
|
||||
override fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray =
|
||||
encryptionService.sealCourierPayload(payload, recipientNoiseKey)
|
||||
}
|
||||
|
||||
/** Parks opaque courier envelopes on default Nostr relays using the iOS kind-1401 contract. */
|
||||
class BridgeCourierService internal constructor(
|
||||
private val cipher: BridgeCourierCipher,
|
||||
private val onEnvelope: (CourierEnvelope) -> Unit,
|
||||
private val relayManager: BridgeCourierRelay,
|
||||
private val relayUrls: List<String> = NostrRelayManager.defaultRelays(),
|
||||
private val clock: () -> Long = System::currentTimeMillis,
|
||||
private val identityFactory: () -> NostrIdentity = NostrIdentity::generate
|
||||
) {
|
||||
constructor(
|
||||
context: Context,
|
||||
encryptionService: EncryptionService,
|
||||
onEnvelope: (CourierEnvelope) -> Unit
|
||||
) : this(
|
||||
cipher = EncryptionBridgeCourierCipher(encryptionService),
|
||||
onEnvelope = onEnvelope,
|
||||
relayManager = NostrBridgeCourierRelay(
|
||||
NostrRelayManager.getInstance(context.applicationContext)
|
||||
)
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val KIND = 1401
|
||||
private const val MAX_ENCODED_BYTES = 20 * 1024
|
||||
private const val TAG_REFRESH_INTERVAL_MS = 60 * 60 * 1000L
|
||||
}
|
||||
|
||||
private val seenEvents = Collections.synchronizedMap(
|
||||
object : LinkedHashMap<String, Unit>(512, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Unit>?) = size > 512
|
||||
}
|
||||
)
|
||||
private val subscriptionID = "bridge-courier-drops-${System.identityHashCode(this)}"
|
||||
@Volatile private var subscribedDay: UInt? = null
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var refreshJob: Job? = null
|
||||
val isStarted: Boolean get() = subscribedDay != null
|
||||
|
||||
@Synchronized
|
||||
fun start() {
|
||||
val localKey = cipher.staticPublicKey() ?: ByteArray(0)
|
||||
if (localKey.size == 32) {
|
||||
val now = clock()
|
||||
val day = CourierEnvelope.epochDay(now)
|
||||
if (subscribedDay == day) return
|
||||
if (subscribedDay != null) relayManager.unsubscribe(subscriptionID)
|
||||
val tags = listOf(day - 1u, day, day + 1u).map {
|
||||
CourierEnvelope.recipientTag(localKey, it).toHex()
|
||||
}
|
||||
val filter = NostrFilter.Builder()
|
||||
.kinds(KIND)
|
||||
.since(now - CourierEnvelope.MAX_LIFETIME_MS)
|
||||
.limit(100)
|
||||
.tag("x", *tags.toTypedArray())
|
||||
.build()
|
||||
relayManager.subscribe(
|
||||
filter = filter,
|
||||
id = subscriptionID,
|
||||
targetRelayUrls = relayUrls,
|
||||
handler = ::handleEvent
|
||||
)
|
||||
subscribedDay = day
|
||||
if (refreshJob?.isActive != true) {
|
||||
refreshJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(TAG_REFRESH_INTERVAL_MS)
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deposit(
|
||||
content: String,
|
||||
messageID: String,
|
||||
recipientNoiseKey: ByteArray,
|
||||
onAccepted: () -> Unit = {}
|
||||
): Boolean {
|
||||
val privateMessage = com.bitchat.android.model.PrivateMessagePacket(messageID, content).encode() ?: return false
|
||||
val typed = com.bitchat.android.model.NoisePayload(
|
||||
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE,
|
||||
privateMessage
|
||||
).encode()
|
||||
return depositPayload(typed, recipientNoiseKey, onAccepted)
|
||||
}
|
||||
|
||||
fun depositPayload(
|
||||
typedPayload: ByteArray,
|
||||
recipientNoiseKey: ByteArray,
|
||||
onAccepted: () -> Unit = {}
|
||||
): Boolean {
|
||||
start()
|
||||
if (!relayManager.hasConnectedRelay(relayUrls)) return false
|
||||
val sealed = try { cipher.seal(typedPayload, recipientNoiseKey) } catch (_: Exception) { return false }
|
||||
val now = clock()
|
||||
val envelope = CourierEnvelope(
|
||||
recipientTag = CourierEnvelope.recipientTag(recipientNoiseKey, CourierEnvelope.epochDay(now)),
|
||||
expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(),
|
||||
ciphertext = sealed,
|
||||
copies = 1u
|
||||
)
|
||||
val encoded = envelope.encode() ?: return false
|
||||
if (encoded.size > MAX_ENCODED_BYTES) return false
|
||||
val identity = try { identityFactory() } catch (_: Exception) { return false }
|
||||
val event = identity.signEvent(
|
||||
NostrEvent(
|
||||
pubkey = identity.publicKeyHex,
|
||||
createdAt = (now / 1000).toInt(),
|
||||
kind = KIND,
|
||||
tags = listOf(
|
||||
listOf("x", envelope.recipientTag.toHex()),
|
||||
listOf("expiration", (envelope.expiry / 1000u).toString())
|
||||
),
|
||||
content = Base64.encodeToString(encoded, Base64.NO_WRAP)
|
||||
)
|
||||
)
|
||||
return relayManager.sendEvent(event, relayUrls, onAccepted)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
relayManager.unsubscribe(subscriptionID)
|
||||
subscribedDay = null
|
||||
refreshJob?.cancel()
|
||||
refreshJob = null
|
||||
}
|
||||
|
||||
private fun handleEvent(event: NostrEvent) {
|
||||
if (event.kind != KIND || !event.isValidSignature()) return
|
||||
synchronized(seenEvents) { if (seenEvents.put(event.id, Unit) != null) return }
|
||||
if (event.content.length > ((MAX_ENCODED_BYTES + 2) / 3) * 4) return
|
||||
val encoded = try { Base64.decode(event.content, Base64.DEFAULT) } catch (_: Exception) { return }
|
||||
if (encoded.size > MAX_ENCODED_BYTES) return
|
||||
val envelope = CourierEnvelope.decode(encoded) ?: return
|
||||
val now = clock()
|
||||
if (envelope.expiry.toLong() <= now || envelope.expiry.toLong() > now + CourierEnvelope.MAX_LIFETIME_MS + 60 * 60 * 1000L) return
|
||||
val eventTag = event.tags.firstOrNull { it.size > 1 && it[0] == "x" }?.get(1) ?: return
|
||||
val expiration = event.tags.firstOrNull { it.size > 1 && it[0] == "expiration" }?.get(1)?.toULongOrNull() ?: return
|
||||
if (eventTag != envelope.recipientTag.toHex() || expiration != envelope.expiry / 1000u) return
|
||||
val localKey = cipher.staticPublicKey() ?: return
|
||||
if (!envelope.matchesRecipient(localKey, now)) return
|
||||
onEnvelope(envelope)
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -14,6 +14,7 @@ enum class MessageType(val value: UByte) {
|
||||
ANNOUNCE(0x01u),
|
||||
MESSAGE(0x02u), // All user messages (private and broadcast)
|
||||
LEAVE(0x03u),
|
||||
COURIER_ENVELOPE(0x04u), // Opaque Noise X store-and-forward envelope
|
||||
NOISE_HANDSHAKE(0x10u), // Noise handshake
|
||||
NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message
|
||||
FRAGMENT(0x20u), // Fragmentation for large packets
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -72,6 +72,9 @@ object ContactDirectory {
|
||||
favorite != null -> favorite.peerNoisePublicKey
|
||||
ContactIdentityResolver.isNoiseKeyHex(peerOrConversationID) ->
|
||||
ContactIdentityResolver.bytesFromHex(peerOrConversationID)
|
||||
contactFingerprint != null -> cachedNoiseKeyForFingerprint(contactFingerprint)
|
||||
ContactIdentityResolver.isMeshPeerId(peerOrConversationID) ->
|
||||
noiseKeyForAlias(peerOrConversationID)
|
||||
else -> null
|
||||
}
|
||||
val liveMeshPeerID = contactFingerprint?.let { findLiveMeshPeerForFingerprint(it) }
|
||||
@ -156,6 +159,18 @@ object ContactDirectory {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A contact conversation ID contains the SHA-256 fingerprint of the Noise key, whose
|
||||
* first 16 hex characters are the stable mesh peer ID. Recovering that cached key keeps
|
||||
* offline routing viable after the outbox has canonicalized a peer ID to `contact_…`.
|
||||
* Recompute the fingerprint before returning it so stale or mismatched cache entries cannot
|
||||
* redirect a private message.
|
||||
*/
|
||||
private fun cachedNoiseKeyForFingerprint(fingerprint: String): ByteArray? =
|
||||
cachedNoiseKey(fingerprint.take(16))?.takeIf {
|
||||
ContactIdentityResolver.fingerprintHex(it).equals(fingerprint, ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun cachedFingerprintNickname(fingerprint: String): String? {
|
||||
val context = appContext ?: return null
|
||||
return try {
|
||||
|
||||
@ -1684,6 +1684,7 @@ internal class ConversationDatabase(
|
||||
null -> put("delivery_type", 0)
|
||||
DeliveryStatus.Sending -> put("delivery_type", 1)
|
||||
DeliveryStatus.Sent -> put("delivery_type", 2)
|
||||
DeliveryStatus.Queued -> put("delivery_type", 7)
|
||||
is DeliveryStatus.Delivered -> {
|
||||
put("delivery_type", 3)
|
||||
if (includeSensitiveText) put("delivery_text", status.to) else putNull("delivery_text")
|
||||
@ -1710,10 +1711,11 @@ internal class ConversationDatabase(
|
||||
null -> 0
|
||||
is DeliveryStatus.Failed -> 0
|
||||
DeliveryStatus.Sending -> 1
|
||||
DeliveryStatus.Sent -> 2
|
||||
is DeliveryStatus.PartiallyDelivered -> 3
|
||||
is DeliveryStatus.Delivered -> 4
|
||||
is DeliveryStatus.Read -> 5
|
||||
DeliveryStatus.Queued -> 2
|
||||
DeliveryStatus.Sent -> 3
|
||||
is DeliveryStatus.PartiallyDelivered -> 4
|
||||
is DeliveryStatus.Delivered -> 5
|
||||
is DeliveryStatus.Read -> 6
|
||||
}
|
||||
|
||||
private fun Cursor.toMessage(): BitchatMessage {
|
||||
@ -1781,6 +1783,7 @@ internal class ConversationDatabase(
|
||||
reached = nullableInt("delivery_reached") ?: 0,
|
||||
total = nullableInt("delivery_total") ?: 0
|
||||
)
|
||||
7 -> DeliveryStatus.Queued
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -21,9 +21,12 @@ object GCSFilter {
|
||||
data class Params(
|
||||
val p: Int, // Golomb-Rice parameter (>= 1)
|
||||
val m: Long, // Range M = N * 2^P
|
||||
val data: ByteArray // Encoded GR bitstream
|
||||
val data: ByteArray, // Encoded GR bitstream
|
||||
val includedCount: Int // Number of newest-first input IDs actually encoded
|
||||
)
|
||||
|
||||
const val MAX_P = 32
|
||||
|
||||
// Derive P from target FPR; FPR ~= 1 / 2^P
|
||||
fun deriveP(targetFpr: Double): Int {
|
||||
val f = targetFpr.coerceIn(0.000001, 0.25)
|
||||
@ -66,7 +69,12 @@ object GCSFilter {
|
||||
encoded = encode(mapped, p)
|
||||
}
|
||||
|
||||
return Params(p = p, m = finalM, data = encoded)
|
||||
return Params(
|
||||
p = p,
|
||||
m = finalM,
|
||||
data = encoded,
|
||||
includedCount = if (encoded.isEmpty()) 0 else trimmedN
|
||||
)
|
||||
}
|
||||
|
||||
fun decodeToSortedSet(p: Int, m: Long, data: ByteArray): LongArray {
|
||||
@ -196,4 +204,3 @@ object GCSFilter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,9 @@ import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
/**
|
||||
* Gossip-based synchronization manager using on-demand GCS filters.
|
||||
@ -17,7 +20,8 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
class GossipSyncManager(
|
||||
private val myPeerID: String,
|
||||
private val scope: CoroutineScope,
|
||||
private val configProvider: ConfigProvider
|
||||
private val configProvider: ConfigProvider,
|
||||
context: Context? = null
|
||||
) {
|
||||
interface Delegate {
|
||||
fun sendPacket(packet: BitchatPacket)
|
||||
@ -33,6 +37,10 @@ class GossipSyncManager(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "GossipSyncManager"
|
||||
const val PUBLIC_MESSAGE_MAX_AGE_MS = 6 * 60 * 60 * 1000L
|
||||
const val FRAGMENT_MAX_AGE_MS = 15 * 60 * 1000L
|
||||
const val PUBLIC_PACKET_FUTURE_SKEW_MS = 10 * 60 * 1000L
|
||||
private const val ARCHIVE_FILE = "gossip-public-history.bin"
|
||||
}
|
||||
|
||||
var delegate: Delegate? = null
|
||||
@ -44,11 +52,15 @@ class GossipSyncManager(
|
||||
// Stored packets for sync:
|
||||
// - broadcast messages: keep up to seenCapacity() most recent, keyed by packetId
|
||||
private val messages = LinkedHashMap<String, BitchatPacket>()
|
||||
private val fragments = LinkedHashMap<String, BitchatPacket>()
|
||||
private val archiveFile = context?.applicationContext?.filesDir?.let { File(it, ARCHIVE_FILE) }
|
||||
private var restoringArchive = false
|
||||
// - announcements: only keep latest per sender peerID
|
||||
private val latestAnnouncementByPeer = ConcurrentHashMap<String, Pair<String, BitchatPacket>>()
|
||||
|
||||
private var periodicJob: Job? = null
|
||||
private var cleanupJob: Job? = null
|
||||
init { restoreArchive() }
|
||||
fun start() {
|
||||
periodicJob?.cancel()
|
||||
periodicJob = scope.launch(Dispatchers.IO) {
|
||||
@ -84,7 +96,9 @@ class GossipSyncManager(
|
||||
synchronized(messages) {
|
||||
messages.clear()
|
||||
}
|
||||
synchronized(fragments) { fragments.clear() }
|
||||
latestAnnouncementByPeer.clear()
|
||||
archiveFile?.delete()
|
||||
Log.d(TAG, "Cleared all gossip sync messages and announcements")
|
||||
}
|
||||
|
||||
@ -106,13 +120,20 @@ class GossipSyncManager(
|
||||
// Only ANNOUNCE or broadcast MESSAGE
|
||||
val mt = MessageType.fromValue(packet.type)
|
||||
val isBroadcastMessage = (mt == MessageType.MESSAGE && (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)))
|
||||
val isBroadcastFile = mt == MessageType.FILE_TRANSFER &&
|
||||
(packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
|
||||
val isAnnouncement = (mt == MessageType.ANNOUNCE)
|
||||
if (!isBroadcastMessage && !isAnnouncement) return
|
||||
val isFragment = (mt == MessageType.FRAGMENT || isBroadcastFile) &&
|
||||
(packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))
|
||||
if (!isBroadcastMessage && !isAnnouncement && !isFragment) return
|
||||
|
||||
val idBytes = PacketIdUtil.computeIdBytes(packet)
|
||||
val id = idBytes.joinToString("") { b -> "%02x".format(b) }
|
||||
|
||||
if (isBroadcastMessage) {
|
||||
val now = System.currentTimeMillis()
|
||||
val age = now - packet.timestamp.toLong()
|
||||
if (age !in -PUBLIC_PACKET_FUTURE_SKEW_MS..PUBLIC_MESSAGE_MAX_AGE_MS) return
|
||||
synchronized(messages) {
|
||||
messages[id] = packet
|
||||
// Enforce capacity (remove oldest when exceeded)
|
||||
@ -122,6 +143,17 @@ class GossipSyncManager(
|
||||
if (it.hasNext()) { it.next(); it.remove() } else break
|
||||
}
|
||||
}
|
||||
if (!restoringArchive) persistArchive()
|
||||
} else if (isFragment) {
|
||||
val age = System.currentTimeMillis() - packet.timestamp.toLong()
|
||||
if (age !in -PUBLIC_PACKET_FUTURE_SKEW_MS..FRAGMENT_MAX_AGE_MS) return
|
||||
synchronized(fragments) {
|
||||
fragments[id] = packet
|
||||
while (fragments.size > configProvider.seenCapacity().coerceAtLeast(1)) {
|
||||
val iterator = fragments.entries.iterator()
|
||||
if (iterator.hasNext()) { iterator.next(); iterator.remove() } else break
|
||||
}
|
||||
}
|
||||
} else if (isAnnouncement) {
|
||||
// Ignore stale announcements older than STALE_PEER_TIMEOUT
|
||||
val now = System.currentTimeMillis()
|
||||
@ -143,22 +175,27 @@ class GossipSyncManager(
|
||||
}
|
||||
|
||||
private fun sendRequestSync() {
|
||||
val payload = buildGcsPayload()
|
||||
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.REQUEST_SYNC.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbors only
|
||||
)
|
||||
// Sign and broadcast
|
||||
val signed = delegate?.signPacketForBroadcast(packet) ?: packet
|
||||
delegate?.sendPacket(signed)
|
||||
listOf(
|
||||
SyncTypeFlags.PUBLIC_MESSAGES,
|
||||
SyncTypeFlags.FRAGMENT,
|
||||
SyncTypeFlags.FILE_TRANSFER
|
||||
).forEach { types ->
|
||||
val payload = buildGcsPayload(types)
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.REQUEST_SYNC.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // neighbors only
|
||||
)
|
||||
val signed = delegate?.signPacketForBroadcast(packet) ?: packet
|
||||
delegate?.sendPacket(signed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendRequestSyncToPeer(peerID: String) {
|
||||
val payload = buildGcsPayload()
|
||||
val types = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES)
|
||||
val payload = buildGcsPayload(types)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.REQUEST_SYNC.value,
|
||||
@ -175,6 +212,8 @@ class GossipSyncManager(
|
||||
}
|
||||
|
||||
fun handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
||||
val requestedTypes = request.types ?: SyncTypeFlags.PUBLIC_MESSAGES
|
||||
val sinceTimestamp = request.sinceTimestamp
|
||||
// Decode GCS into sorted set for membership checks
|
||||
val sorted = GCSFilter.decodeToSortedSet(request.p, request.m, request.data)
|
||||
fun mightContain(id: ByteArray): Boolean {
|
||||
@ -183,26 +222,41 @@ class GossipSyncManager(
|
||||
return GCSFilter.contains(sorted, nonZeroV)
|
||||
}
|
||||
|
||||
// 1) Announcements: send latest per peerID if remote doesn't have them
|
||||
for ((_, pair) in latestAnnouncementByPeer.entries) {
|
||||
val (id, pkt) = pair
|
||||
val idBytes = hexToBytes(id)
|
||||
if (!mightContain(idBytes)) {
|
||||
// Send original packet unchanged to requester only (keep local TTL)
|
||||
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
// Announcements are exempt from the cursor: only the latest per peer is retained,
|
||||
// and peers need their signing keys before they can verify other sync responses.
|
||||
if (requestedTypes.contains(MessageType.ANNOUNCE)) {
|
||||
for ((_, pair) in latestAnnouncementByPeer.entries) {
|
||||
val (id, pkt) = pair
|
||||
val idBytes = hexToBytes(id)
|
||||
if (!mightContain(idBytes)) {
|
||||
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Broadcast messages: send all they lack
|
||||
val toSendMsgs = synchronized(messages) { messages.values.toList() }
|
||||
for (pkt in toSendMsgs) {
|
||||
if (requestedTypes.contains(MessageType.MESSAGE)) {
|
||||
val toSendMsgs = synchronized(messages) { messages.values.toList() }
|
||||
for (pkt in toSendMsgs) {
|
||||
if (sinceTimestamp != null && pkt.timestamp < sinceTimestamp) continue
|
||||
val idBytes = PacketIdUtil.computeIdBytes(pkt)
|
||||
if (!mightContain(idBytes)) {
|
||||
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val toSendFragments = synchronized(fragments) { fragments.values.toList() }
|
||||
for (pkt in toSendFragments) {
|
||||
val type = MessageType.fromValue(pkt.type) ?: continue
|
||||
if (!requestedTypes.contains(type)) continue
|
||||
if (sinceTimestamp != null && pkt.timestamp < sinceTimestamp) continue
|
||||
val idBytes = PacketIdUtil.computeIdBytes(pkt)
|
||||
if (!mightContain(idBytes)) {
|
||||
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
delegate?.sendPacketToPeer(fromPeerID, pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -232,16 +286,23 @@ class GossipSyncManager(
|
||||
return out
|
||||
}
|
||||
|
||||
private fun buildGcsPayload(): ByteArray {
|
||||
// Collect candidates: latest announcement per peer + recent broadcast messages
|
||||
internal fun buildGcsPayload(types: SyncTypeFlags): ByteArray {
|
||||
// Collect only the packet types represented by this filter.
|
||||
val list = ArrayList<BitchatPacket>()
|
||||
// announcements
|
||||
for ((_, pair) in latestAnnouncementByPeer) {
|
||||
list.add(pair.second)
|
||||
if (types.contains(MessageType.ANNOUNCE)) {
|
||||
for ((_, pair) in latestAnnouncementByPeer) {
|
||||
list.add(pair.second)
|
||||
}
|
||||
}
|
||||
// messages
|
||||
synchronized(messages) {
|
||||
list.addAll(messages.values)
|
||||
if (types.contains(MessageType.MESSAGE)) {
|
||||
synchronized(messages) {
|
||||
list.addAll(messages.values)
|
||||
}
|
||||
}
|
||||
synchronized(fragments) {
|
||||
list.addAll(fragments.values.filter { packet ->
|
||||
MessageType.fromValue(packet.type)?.let(types::contains) == true
|
||||
})
|
||||
}
|
||||
// sort by timestamp desc, then take up to min(seenCapacity, fit capacity)
|
||||
list.sortByDescending { it.timestamp.toLong() }
|
||||
@ -254,15 +315,24 @@ class GossipSyncManager(
|
||||
val takeN = minOf(nMax, cap, list.size)
|
||||
if (takeN <= 0) {
|
||||
val p0 = GCSFilter.deriveP(fpr)
|
||||
return RequestSyncPacket(p = p0, m = 1, data = ByteArray(0)).encode()
|
||||
return RequestSyncPacket(p = p0, m = 1, data = ByteArray(0), types = types).encode()
|
||||
}
|
||||
val ids = list.take(takeN).map { pkt -> PacketIdUtil.computeIdBytes(pkt) }
|
||||
val included = list.take(takeN)
|
||||
val ids = included.map { pkt -> PacketIdUtil.computeIdBytes(pkt) }
|
||||
val params = GCSFilter.buildFilter(ids, maxBytes, fpr)
|
||||
val mVal = if (params.m <= 0L) 1 else params.m
|
||||
return RequestSyncPacket(p = params.p, m = mVal, data = params.data).encode()
|
||||
val covered = params.includedCount
|
||||
val sinceTimestamp = if (covered in 1 until list.size) included[covered - 1].timestamp else null
|
||||
return RequestSyncPacket(
|
||||
p = params.p,
|
||||
m = mVal,
|
||||
data = params.data,
|
||||
types = types,
|
||||
sinceTimestamp = sinceTimestamp
|
||||
).encode()
|
||||
}
|
||||
|
||||
// Periodically remove stale announcements and all their messages
|
||||
// Announcements age out quickly; public history remains independently sync-able for six hours.
|
||||
private fun pruneStaleAnnouncements() {
|
||||
val now = System.currentTimeMillis()
|
||||
val stalePeers = mutableListOf<String>()
|
||||
@ -276,26 +346,17 @@ class GossipSyncManager(
|
||||
}
|
||||
}
|
||||
|
||||
if (stalePeers.isEmpty()) return
|
||||
|
||||
// Remove announcements and their messages
|
||||
var totalPrunedMsgs = 0
|
||||
var changed = false
|
||||
for (peerID in stalePeers) {
|
||||
// Count messages to be pruned for logging
|
||||
val toRemove = mutableListOf<String>()
|
||||
synchronized(messages) {
|
||||
for ((id, message) in messages) {
|
||||
val sender = message.senderID.joinToString("") { b -> "%02x".format(b) }
|
||||
if (sender == peerID) toRemove.add(id)
|
||||
}
|
||||
}
|
||||
totalPrunedMsgs += toRemove.size
|
||||
|
||||
// Reuse existing removal which also clears announcement entry
|
||||
removeAnnouncementForPeer(peerID)
|
||||
changed = latestAnnouncementByPeer.remove(peerID) != null || changed
|
||||
}
|
||||
|
||||
Log.d(TAG, "Pruned ${stalePeers.size} stale announcements and $totalPrunedMsgs messages")
|
||||
synchronized(messages) {
|
||||
changed = messages.entries.removeAll { now - it.value.timestamp.toLong() > PUBLIC_MESSAGE_MAX_AGE_MS } || changed
|
||||
}
|
||||
synchronized(fragments) {
|
||||
fragments.entries.removeAll { now - it.value.timestamp.toLong() > FRAGMENT_MAX_AGE_MS }
|
||||
}
|
||||
if (changed) persistArchive()
|
||||
}
|
||||
|
||||
// Explicitly remove stored announcement for a given peer (hex ID)
|
||||
@ -305,26 +366,63 @@ class GossipSyncManager(
|
||||
Log.d(TAG, "Removed stored announcement for peer $peerID")
|
||||
}
|
||||
|
||||
// Collect IDs to remove first to avoid modifying collection while iterating
|
||||
val idsToRemove = mutableListOf<String>()
|
||||
synchronized(messages) {
|
||||
for ((id, message) in messages) {
|
||||
val sender = message.senderID.joinToString("") { b -> "%02x".format(b) }
|
||||
if (sender == key) {
|
||||
idsToRemove.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreArchive() {
|
||||
val file = archiveFile ?: return
|
||||
if (!file.exists()) return
|
||||
try {
|
||||
restoringArchive = true
|
||||
val input = java.io.DataInputStream(file.inputStream().buffered())
|
||||
val count = input.readInt().coerceIn(0, configProvider.seenCapacity())
|
||||
repeat(count) {
|
||||
val length = input.readInt()
|
||||
if (length <= 0 || length > com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + 256) return@repeat
|
||||
val packet = com.bitchat.android.protocol.BinaryProtocol.decode(input.readNBytes(length)) ?: return@repeat
|
||||
onPublicPacketSeen(packet)
|
||||
}
|
||||
}
|
||||
|
||||
// Now remove the collected IDs
|
||||
synchronized(messages) {
|
||||
for (id in idsToRemove) {
|
||||
messages.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (idsToRemove.isNotEmpty()) {
|
||||
Log.d(TAG, "Pruned ${idsToRemove.size} messages with senders without announcements")
|
||||
input.close()
|
||||
} catch (_: Exception) {
|
||||
synchronized(messages) { messages.clear() }
|
||||
} finally {
|
||||
restoringArchive = false
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun persistArchive() {
|
||||
val file = archiveFile ?: return
|
||||
try {
|
||||
val packets = synchronized(messages) { messages.values.toList() }
|
||||
val temporary = File(file.parentFile, "${file.name}.tmp")
|
||||
java.io.DataOutputStream(temporary.outputStream().buffered()).use { output ->
|
||||
output.writeInt(packets.size)
|
||||
packets.forEach { packet ->
|
||||
val encoded = com.bitchat.android.protocol.BinaryProtocol.encode(packet, padding = false) ?: return@forEach
|
||||
output.writeInt(encoded.size)
|
||||
output.write(encoded)
|
||||
}
|
||||
}
|
||||
try {
|
||||
java.nio.file.Files.move(
|
||||
temporary.toPath(),
|
||||
file.toPath(),
|
||||
StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
// Some Android filesystems do not support ATOMIC_MOVE. Preserve the durable
|
||||
// public-history guarantee with a regular replacement move before giving up.
|
||||
try {
|
||||
java.nio.file.Files.move(
|
||||
temporary.toPath(),
|
||||
file.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
temporary.delete()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
68
app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt
Normal file
68
app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt
Normal file
@ -0,0 +1,68 @@
|
||||
package com.bitchat.android.sync
|
||||
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
|
||||
/** Compact little-endian bitfield matching iOS SyncTypeFlags. */
|
||||
@JvmInline
|
||||
value class SyncTypeFlags private constructor(val rawValue: ULong) {
|
||||
companion object {
|
||||
private const val KNOWN_TYPE_MASK: ULong = 0xffu
|
||||
|
||||
val ANNOUNCE = fromMessageTypes(MessageType.ANNOUNCE)
|
||||
val MESSAGE = fromMessageTypes(MessageType.MESSAGE)
|
||||
val FRAGMENT = fromMessageTypes(MessageType.FRAGMENT)
|
||||
val FILE_TRANSFER = fromMessageTypes(MessageType.FILE_TRANSFER)
|
||||
val PUBLIC_MESSAGES = fromMessageTypes(MessageType.ANNOUNCE, MessageType.MESSAGE)
|
||||
val FRAGMENTS_AND_FILES = fromMessageTypes(MessageType.FRAGMENT, MessageType.FILE_TRANSFER)
|
||||
|
||||
fun fromRawValue(rawValue: ULong): SyncTypeFlags = SyncTypeFlags(rawValue and KNOWN_TYPE_MASK)
|
||||
|
||||
fun fromMessageTypes(vararg types: MessageType): SyncTypeFlags {
|
||||
var rawValue = 0uL
|
||||
types.forEach { type ->
|
||||
bitIndex(type)?.let { bit -> rawValue = rawValue or (1uL shl bit) }
|
||||
}
|
||||
return fromRawValue(rawValue)
|
||||
}
|
||||
|
||||
fun decode(data: ByteArray): SyncTypeFlags? {
|
||||
if (data.size !in 1..8) return null
|
||||
var rawValue = 0uL
|
||||
data.forEachIndexed { index, byte ->
|
||||
rawValue = rawValue or (byte.toUByte().toULong() shl (index * 8))
|
||||
}
|
||||
return fromRawValue(rawValue)
|
||||
}
|
||||
|
||||
private fun bitIndex(type: MessageType): Int? = when (type) {
|
||||
MessageType.ANNOUNCE -> 0
|
||||
MessageType.MESSAGE -> 1
|
||||
MessageType.LEAVE -> 2
|
||||
MessageType.NOISE_HANDSHAKE -> 3
|
||||
MessageType.NOISE_ENCRYPTED -> 4
|
||||
MessageType.FRAGMENT -> 5
|
||||
MessageType.REQUEST_SYNC -> 6
|
||||
MessageType.FILE_TRANSFER -> 7
|
||||
MessageType.COURIER_ENVELOPE,
|
||||
MessageType.VOICE_FRAME -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun contains(type: MessageType): Boolean {
|
||||
val bit = bitIndex(type) ?: return false
|
||||
return (rawValue and (1uL shl bit)) != 0uL
|
||||
}
|
||||
|
||||
fun union(other: SyncTypeFlags): SyncTypeFlags = fromRawValue(rawValue or other.rawValue)
|
||||
|
||||
fun encode(): ByteArray? {
|
||||
if (rawValue == 0uL) return null
|
||||
var remaining = rawValue
|
||||
val bytes = ArrayList<Byte>(8)
|
||||
while (remaining != 0uL && bytes.size < 8) {
|
||||
bytes += (remaining and 0xffu).toByte()
|
||||
remaining = remaining shr 8
|
||||
}
|
||||
return bytes.toByteArray()
|
||||
}
|
||||
}
|
||||
@ -1020,6 +1020,11 @@ class ChatViewModel(
|
||||
messageId,
|
||||
com.bitchat.android.model.DeliveryStatus.Sent
|
||||
)
|
||||
} else if (route == com.bitchat.android.services.MessageRouter.RouteResult.QUEUED) {
|
||||
messageManager.updateMessageDeliveryStatus(
|
||||
messageId,
|
||||
com.bitchat.android.model.DeliveryStatus.Queued
|
||||
)
|
||||
}
|
||||
}
|
||||
onAccepted(accepted)
|
||||
@ -1483,7 +1488,7 @@ class ChatViewModel(
|
||||
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear()
|
||||
} catch (_: Exception) { }
|
||||
try {
|
||||
com.bitchat.android.services.MessageRouter.tryGetInstance()?.clearAll()
|
||||
com.bitchat.android.services.MessageRouter.panicClear(getApplication())
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Clear all cryptographic data
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -9,6 +9,7 @@ import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import com.bitchat.android.model.PrivateMessagePacket
|
||||
import com.bitchat.android.model.RequestSyncPacket
|
||||
import com.bitchat.android.sync.SyncTypeFlags
|
||||
import com.bitchat.android.model.UnknownAnnouncementTLV
|
||||
import com.bitchat.android.protocol.BinaryProtocol
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
@ -203,9 +204,14 @@ class ClientRewriteWireContractTest {
|
||||
val request = RequestSyncPacket(
|
||||
p = 19,
|
||||
m = 0x01020304L,
|
||||
data = hex("aabb")
|
||||
data = hex("aabb"),
|
||||
types = SyncTypeFlags.FRAGMENTS_AND_FILES,
|
||||
sinceTimestamp = 0x0102030405060708u
|
||||
)
|
||||
val wire = hex(
|
||||
"0100011302000401020304030002aabb" +
|
||||
"040001a00500080102030405060708"
|
||||
)
|
||||
val wire = hex("0100011302000401020304030002aabb")
|
||||
|
||||
assertArrayEquals(wire, request.encode())
|
||||
assertSyncRequestEquals(request, RequestSyncPacket.decode(wire))
|
||||
@ -297,5 +303,7 @@ class ClientRewriteWireContractTest {
|
||||
assertEquals(expected.p, actual!!.p)
|
||||
assertEquals(expected.m, actual.m)
|
||||
assertArrayEquals(expected.data, actual.data)
|
||||
assertEquals(expected.types, actual.types)
|
||||
assertEquals(expected.sinceTimestamp, actual.sinceTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.os.Build
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.timeout
|
||||
import org.mockito.kotlin.verify
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
|
||||
class BluetoothPacketBroadcasterCompletionTest {
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
private val tracker = BluetoothConnectionTracker(scope, mock())
|
||||
private val broadcaster = BluetoothPacketBroadcaster(scope, tracker, null, MY_PEER_ID)
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
broadcaster.shutdown()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `queue admission waits for successful GATT completion`() = runBlocking {
|
||||
val connection = connectedPeer()
|
||||
|
||||
val result = async(Dispatchers.Default) {
|
||||
broadcaster.sendPacketToPeerAndAwaitCompletion(packet(), PEER_ID, null, null)
|
||||
}
|
||||
|
||||
verify(connection.gatt!!, timeout(1_000)).writeCharacteristic(connection.characteristic!!)
|
||||
assertFalse(result.isCompleted)
|
||||
|
||||
broadcaster.onGattClientWriteComplete(DEVICE_ADDRESS, LINK_ID, BluetoothGatt.GATT_SUCCESS)
|
||||
assertTrue(result.await())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disconnect fails an admitted send before custody can be released`() = runBlocking {
|
||||
val connection = connectedPeer()
|
||||
|
||||
val result = async(Dispatchers.Default) {
|
||||
broadcaster.sendPacketToPeerAndAwaitCompletion(packet(), PEER_ID, null, null)
|
||||
}
|
||||
|
||||
verify(connection.gatt!!, timeout(1_000)).writeCharacteristic(connection.characteristic!!)
|
||||
assertFalse(result.isCompleted)
|
||||
|
||||
broadcaster.onLinkDisconnected(DEVICE_ADDRESS, LINK_ID)
|
||||
assertFalse(result.await())
|
||||
}
|
||||
|
||||
private fun connectedPeer(): BluetoothConnectionTracker.DeviceConnection {
|
||||
val device = mock<BluetoothDevice>()
|
||||
val gatt = mock<BluetoothGatt>()
|
||||
val characteristic = mock<BluetoothGattCharacteristic>()
|
||||
whenever(device.address).thenReturn(DEVICE_ADDRESS)
|
||||
whenever(gatt.writeCharacteristic(any())).thenReturn(true)
|
||||
val connection = BluetoothConnectionTracker.DeviceConnection(
|
||||
device = device,
|
||||
gatt = gatt,
|
||||
characteristic = characteristic,
|
||||
isClient = true,
|
||||
linkID = LINK_ID
|
||||
)
|
||||
tracker.addDeviceConnection(DEVICE_ADDRESS, connection)
|
||||
tracker.observePeerIfCurrent(DEVICE_ADDRESS, LINK_ID, PEER_ID)
|
||||
return connection
|
||||
}
|
||||
|
||||
private fun packet() = RoutedPacket(
|
||||
BitchatPacket(
|
||||
type = MessageType.COURIER_ENVELOPE.value,
|
||||
senderID = MY_PEER_ID.hexToBytes(),
|
||||
recipientID = PEER_ID.hexToBytes(),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = byteArrayOf(1, 2, 3),
|
||||
ttl = 7u
|
||||
)
|
||||
)
|
||||
|
||||
private fun String.hexToBytes(): ByteArray =
|
||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
|
||||
private companion object {
|
||||
const val MY_PEER_ID = "1111222233334444"
|
||||
const val PEER_ID = "aaaabbbbccccdddd"
|
||||
const val DEVICE_ADDRESS = "00:11:22:33:44:55"
|
||||
const val LINK_ID = "synthetic-link"
|
||||
}
|
||||
}
|
||||
148
app/src/test/kotlin/com/bitchat/android/mesh/CourierStoreTest.kt
Normal file
148
app/src/test/kotlin/com/bitchat/android/mesh/CourierStoreTest.kt
Normal file
@ -0,0 +1,148 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.os.Build
|
||||
import com.bitchat.android.model.CourierEnvelope
|
||||
import com.bitchat.android.services.ConversationStorageCipher
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
|
||||
class CourierStoreTest {
|
||||
private val now = 2_000_000L
|
||||
private val recipient = ByteArray(32) { 7 }
|
||||
private val favorite = ByteArray(32) { 8 }
|
||||
private val verified = ByteArray(32) { 9 }
|
||||
private val cipher = TestCipher(0x5a)
|
||||
|
||||
@Test
|
||||
fun `direct pickup remains retryable until explicitly removed`() {
|
||||
val store = newStore()
|
||||
val envelope = envelope(1)
|
||||
assertTrue(store.deposit(envelope, favorite, CourierDepositTier.FAVORITE))
|
||||
|
||||
assertEquals(1, store.copiesForRecipient(recipient).size)
|
||||
assertEquals(1, store.copiesForRecipient(recipient).size)
|
||||
assertTrue(store.remove(envelope))
|
||||
assertTrue(store.copiesForRecipient(recipient).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verified pool evicts oldest verified and never favorites`() {
|
||||
val store = newStore()
|
||||
repeat(20) { index ->
|
||||
assertTrue(store.deposit(envelope(index), ByteArray(32) { index.toByte() }, CourierDepositTier.VERIFIED))
|
||||
}
|
||||
val favoriteEnvelope = envelope(100)
|
||||
assertTrue(store.deposit(favoriteEnvelope, favorite, CourierDepositTier.FAVORITE))
|
||||
assertTrue(store.deposit(envelope(200), ByteArray(32) { 100 }, CourierDepositTier.VERIFIED))
|
||||
|
||||
val copies = reloaded().copiesForRecipient(recipient)
|
||||
assertEquals(21, copies.size)
|
||||
assertTrue(copies.any { it.ciphertext.contentEquals(favoriteEnvelope.ciphertext) })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spray history and copy budget survive restart`() {
|
||||
val store = newStore()
|
||||
assertTrue(store.deposit(envelope(1, copies = 4u), verified, CourierDepositTier.VERIFIED))
|
||||
val courier = ByteArray(32) { 11 }
|
||||
|
||||
val spray = store.sprayCopiesFor(courier).single()
|
||||
assertEquals(2u.toUByte(), spray.copies)
|
||||
assertTrue(store.commitSpray(spray, courier))
|
||||
assertTrue(reloaded().sprayCopiesFor(courier).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed spray preview does not consume custody`() {
|
||||
val store = newStore()
|
||||
assertTrue(store.deposit(envelope(1, copies = 4u), verified, CourierDepositTier.VERIFIED))
|
||||
val courier = ByteArray(32) { 12 }
|
||||
|
||||
assertEquals(2u.toUByte(), store.sprayCopiesFor(courier).single().copies)
|
||||
assertEquals(2u.toUByte(), reloaded().sprayCopiesFor(courier).single().copies)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent spray reservations cannot over allocate custody`() {
|
||||
val store = newStore()
|
||||
assertTrue(store.deposit(envelope(1, copies = 4u), verified, CourierDepositTier.VERIFIED))
|
||||
val firstCourier = ByteArray(32) { 12 }
|
||||
val secondCourier = ByteArray(32) { 13 }
|
||||
val thirdCourier = ByteArray(32) { 14 }
|
||||
|
||||
val first = store.sprayCopiesFor(firstCourier).single()
|
||||
val second = store.sprayCopiesFor(secondCourier).single()
|
||||
|
||||
assertEquals(2u.toUByte(), first.copies)
|
||||
assertEquals(1u.toUByte(), second.copies)
|
||||
assertTrue(store.sprayCopiesFor(firstCourier).isEmpty())
|
||||
assertTrue(store.sprayCopiesFor(thirdCourier).isEmpty())
|
||||
assertTrue(store.commitSpray(second, secondCourier))
|
||||
assertTrue(store.commitSpray(first, firstCourier))
|
||||
assertTrue(reloaded().sprayCopiesFor(thirdCourier).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelled spray reservation makes its copies eligible again`() {
|
||||
val store = newStore()
|
||||
assertTrue(store.deposit(envelope(1, copies = 4u), verified, CourierDepositTier.VERIFIED))
|
||||
val courier = ByteArray(32) { 15 }
|
||||
|
||||
val first = store.sprayCopiesFor(courier).single()
|
||||
|
||||
assertTrue(store.cancelSpray(first, courier))
|
||||
assertEquals(2u.toUByte(), store.sprayCopiesFor(courier).single().copies)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stored prekey envelope retains its prekey id through spray`() {
|
||||
val store = newStore()
|
||||
val envelope = envelope(1, copies = 4u).copy(prekeyID = 0x11223344u)
|
||||
assertTrue(store.deposit(envelope, verified, CourierDepositTier.VERIFIED))
|
||||
|
||||
val spray = store.sprayCopiesFor(ByteArray(32) { 16 }).single()
|
||||
|
||||
assertEquals(0x11223344u, spray.prekeyID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wipe deletes sealed custody and destroys key`() {
|
||||
val store = newStore()
|
||||
assertTrue(store.deposit(envelope(1), favorite, CourierDepositTier.FAVORITE))
|
||||
store.wipe()
|
||||
|
||||
assertFalse(File(RuntimeEnvironment.getApplication().filesDir, "courier-store.sealed").exists())
|
||||
assertTrue(cipher.destroyed)
|
||||
}
|
||||
|
||||
private fun envelope(id: Int, copies: UByte = 1u) = CourierEnvelope(
|
||||
recipientTag = CourierEnvelope.recipientTag(recipient, CourierEnvelope.epochDay(now)),
|
||||
expiry = (now + 60_000).toULong(),
|
||||
ciphertext = byteArrayOf((id ushr 8).toByte(), id.toByte()),
|
||||
copies = copies
|
||||
)
|
||||
|
||||
private fun newStore(): CourierStore {
|
||||
File(RuntimeEnvironment.getApplication().filesDir, "courier-store.sealed").delete()
|
||||
return reloaded()
|
||||
}
|
||||
|
||||
private fun reloaded() = CourierStore(RuntimeEnvironment.getApplication(), cipher) { now }
|
||||
|
||||
private class TestCipher(private val mask: Int) : ConversationStorageCipher {
|
||||
var destroyed = false
|
||||
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray) =
|
||||
plaintext.map { (it.toInt() xor mask).toByte() }.toByteArray()
|
||||
override fun decrypt(envelope: ByteArray, associatedData: ByteArray) = encrypt(envelope, associatedData)
|
||||
override fun destroyKey() { destroyed = true }
|
||||
}
|
||||
}
|
||||
@ -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" }))
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@ import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.NoisePayload
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import com.bitchat.android.model.PrivateMessagePacket
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.noise.NoisePeerIdentity
|
||||
import com.bitchat.android.noise.AuthenticatedNoiseSession
|
||||
@ -25,9 +26,11 @@ import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.anyOrNull
|
||||
import org.mockito.kotlin.argThat
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.never
|
||||
import org.mockito.kotlin.times
|
||||
import org.mockito.kotlin.verify
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
@ -435,6 +438,48 @@ class MessageHandlerTest {
|
||||
assertTrue(handler.handleNoiseEncrypted(RoutedPacket(encryptedPacket(), peerID, "direct-link")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `opened courier private message is admitted as its Noise sender`() = runBlocking {
|
||||
whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname)
|
||||
whenever(delegate.getMyNickname()).thenReturn("me")
|
||||
val payload = NoisePayload(
|
||||
NoisePayloadType.PRIVATE_MESSAGE,
|
||||
requireNotNull(PrivateMessagePacket("courier-message", "opaque courier content").encode())
|
||||
).encode()
|
||||
|
||||
assertTrue(
|
||||
handler.handleOpenedCourierPayload(
|
||||
RoutedPacket(encryptedPacket().copy(payload = payload), peerID, "courier-ingress")
|
||||
)
|
||||
)
|
||||
verify(delegate).onMessageReceived(argThat {
|
||||
id == "courier-message" &&
|
||||
content == "opaque courier content" &&
|
||||
senderPeerID == peerID &&
|
||||
sender == nickname &&
|
||||
recipientNickname == "me"
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `opened courier delivery receipt acknowledges the original sender and rejects blank IDs`() = runBlocking {
|
||||
val receipt = NoisePayload(NoisePayloadType.DELIVERED, "courier-message".toByteArray()).encode()
|
||||
assertTrue(
|
||||
handler.handleOpenedCourierPayload(
|
||||
RoutedPacket(encryptedPacket().copy(payload = receipt), peerID, "courier-ingress")
|
||||
)
|
||||
)
|
||||
verify(delegate).onDeliveryAckReceived("courier-message", peerID)
|
||||
|
||||
val blankReceipt = NoisePayload(NoisePayloadType.DELIVERED, ByteArray(0)).encode()
|
||||
assertFalse(
|
||||
handler.handleOpenedCourierPayload(
|
||||
RoutedPacket(encryptedPacket().copy(payload = blankReceipt), peerID, "courier-ingress")
|
||||
)
|
||||
)
|
||||
verify(delegate, times(1)).onDeliveryAckReceived("courier-message", peerID)
|
||||
}
|
||||
|
||||
private fun encryptedPacket(): BitchatPacket = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
|
||||
@ -64,6 +64,26 @@ class PacketProcessorAnnounceSideEffectTest {
|
||||
assertEquals(PEER_ID, withTimeout(1_000) { delegate.lastSeen.await() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `broadcast message within future skew is handled`() = runBlocking {
|
||||
val delegate = RecordingDelegate(acceptAnnounce = true)
|
||||
val processor = processor(delegate)
|
||||
|
||||
processor.processPacket(message(timestampOffsetMs = 60_000))
|
||||
|
||||
withTimeout(1_000) { delegate.messageHandled.await() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `broadcast message beyond future skew is rejected`() = runBlocking {
|
||||
val delegate = RecordingDelegate(acceptAnnounce = true)
|
||||
val processor = processor(delegate)
|
||||
|
||||
processor.processPacket(message(timestampOffsetMs = 11 * 60_000L))
|
||||
|
||||
assertNull(withTimeoutOrNull(250) { delegate.messageHandled.await() })
|
||||
}
|
||||
|
||||
private fun processor(delegate: RecordingDelegate): PacketProcessor =
|
||||
PacketProcessor(MY_PEER_ID).also {
|
||||
it.delegate = delegate
|
||||
@ -96,12 +116,26 @@ class PacketProcessorAnnounceSideEffectTest {
|
||||
return RoutedPacket(packet, PEER_ID, "direct-link")
|
||||
}
|
||||
|
||||
private fun message(timestampOffsetMs: Long): RoutedPacket {
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.MESSAGE.value,
|
||||
senderID = PEER_ID.hexToBytes(),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = (System.currentTimeMillis() + timestampOffsetMs).toULong(),
|
||||
payload = byteArrayOf(0x01),
|
||||
ttl = 7u
|
||||
)
|
||||
return RoutedPacket(packet, PEER_ID, "direct-link")
|
||||
}
|
||||
|
||||
private class RecordingDelegate(
|
||||
private val acceptAnnounce: Boolean,
|
||||
private val acceptHandshake: Boolean = false
|
||||
) : PacketProcessorDelegate {
|
||||
val handled = CompletableDeferred<Unit>()
|
||||
val handshakeHandled = CompletableDeferred<Unit>()
|
||||
val messageHandled = CompletableDeferred<Unit>()
|
||||
val lastSeen = CompletableDeferred<String>()
|
||||
@Volatile var relayCount = 0
|
||||
|
||||
@ -121,7 +155,9 @@ class PacketProcessorAnnounceSideEffectTest {
|
||||
handled.complete(Unit)
|
||||
return acceptAnnounce
|
||||
}
|
||||
override fun handleMessage(routed: RoutedPacket) = Unit
|
||||
override fun handleMessage(routed: RoutedPacket) {
|
||||
messageHandled.complete(Unit)
|
||||
}
|
||||
override fun handleLeave(routed: RoutedPacket) = Unit
|
||||
override fun handleFragment(packet: BitchatPacket): BitchatPacket? = null
|
||||
override fun handleRequestSync(routed: RoutedPacket) = Unit
|
||||
|
||||
@ -0,0 +1,111 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class CourierEnvelopeTest {
|
||||
@Test
|
||||
fun `encoding matches fixed iOS wire vector`() {
|
||||
val envelope = CourierEnvelope(
|
||||
recipientTag = ByteArray(16) { it.toByte() },
|
||||
expiry = 0x0102030405060708u,
|
||||
ciphertext = byteArrayOf(0xaa.toByte(), 0xbb.toByte(), 0xcc.toByte()),
|
||||
copies = 4u,
|
||||
prekeyID = 0x11223344u
|
||||
)
|
||||
assertEquals(
|
||||
"010010000102030405060708090a0b0c0d0e0f" +
|
||||
"0200080102030405060708" +
|
||||
"030003aabbcc" +
|
||||
"04000104" +
|
||||
"05000411223344",
|
||||
envelope.encode()!!.toHex()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copies TLV round trips and legacy omission defaults to one`() {
|
||||
val envelope = CourierEnvelope(
|
||||
recipientTag = ByteArray(16) { it.toByte() },
|
||||
expiry = 123456789u,
|
||||
ciphertext = ByteArray(96) { (it + 1).toByte() },
|
||||
copies = 4u
|
||||
)
|
||||
val decoded = CourierEnvelope.decode(envelope.encode()!!)!!
|
||||
assertEquals(4u.toUByte(), decoded.copies)
|
||||
assertArrayEquals(envelope.recipientTag, decoded.recipientTag)
|
||||
assertArrayEquals(envelope.ciphertext, decoded.ciphertext)
|
||||
|
||||
val legacy = envelope.copy(copies = 1u)
|
||||
assertEquals(1u.toUByte(), CourierEnvelope.decode(legacy.encode()!!)!!.copies)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prekey id survives decode re-encode and copy changes`() {
|
||||
val envelope = CourierEnvelope(
|
||||
recipientTag = ByteArray(16) { it.toByte() },
|
||||
expiry = 123456789u,
|
||||
ciphertext = ByteArray(96) { (it + 1).toByte() },
|
||||
copies = 4u,
|
||||
prekeyID = 0xfedcba98u
|
||||
)
|
||||
|
||||
val decoded = CourierEnvelope.decode(envelope.encode()!!)!!
|
||||
|
||||
assertEquals(0xfedcba98u, decoded.prekeyID)
|
||||
assertArrayEquals(envelope.encode(), decoded.copy(copies = 4u).encode())
|
||||
assertEquals(0xfedcba98u, decoded.copy(copies = 2u).prekeyID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid or duplicate prekey fields are rejected`() {
|
||||
val envelope = CourierEnvelope(ByteArray(16), 1u, ByteArray(32) { 1 }, prekeyID = 7u)
|
||||
val encoded = envelope.encode()!!
|
||||
val prekeyField = encoded.copyOfRange(encoded.size - 7, encoded.size)
|
||||
|
||||
assertNull(CourierEnvelope.decode(encoded + prekeyField))
|
||||
assertNull(CourierEnvelope.decode(encoded.copyOf(encoded.size - 1)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tag rotates daily and matches adjacent day for clock skew`() {
|
||||
val key = ByteArray(32) { 0x2a }
|
||||
val now = 10L * 86_400_000L
|
||||
val tag = CourierEnvelope.recipientTag(key, CourierEnvelope.epochDay(now) - 1u)
|
||||
val envelope = CourierEnvelope(tag, (now + 1_000).toULong(), ByteArray(96) { 1 })
|
||||
assertEquals(true, envelope.matchesRecipient(key, now))
|
||||
assertEquals(false, envelope.matchesRecipient(ByteArray(32) { 0x2b }, now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oversized ciphertext is rejected`() {
|
||||
val envelope = CourierEnvelope(
|
||||
ByteArray(16),
|
||||
1u,
|
||||
ByteArray(CourierEnvelope.MAX_CIPHERTEXT_BYTES + 1)
|
||||
)
|
||||
assertNull(envelope.encode())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid copy budgets are rejected instead of normalized`() {
|
||||
val envelope = CourierEnvelope(ByteArray(16), 1u, ByteArray(32) { 1 })
|
||||
assertNull(envelope.copy(copies = 0u).encode())
|
||||
assertNull(envelope.copy(copies = 9u).encode())
|
||||
|
||||
val encoded = envelope.copy(copies = 2u).encode()!!
|
||||
encoded[encoded.lastIndex] = 0
|
||||
assertNull(CourierEnvelope.decode(encoded))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate required fields are rejected`() {
|
||||
val envelope = CourierEnvelope(ByteArray(16), 1u, ByteArray(32) { 1 })
|
||||
val encoded = envelope.encode()!!
|
||||
assertNull(CourierEnvelope.decode(encoded + encoded.copyOfRange(0, 19)))
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@ -0,0 +1,240 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import com.bitchat.android.model.CourierEnvelope
|
||||
import com.bitchat.android.model.NoisePayload
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.model.PrivateMessagePacket
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.Base64
|
||||
|
||||
class BridgeCourierServiceTest {
|
||||
|
||||
private val now = 1_750_000_000_000L
|
||||
private val relays = listOf("wss://bridge-test.invalid")
|
||||
private val recipientKey = ByteArray(32) { 0x32 }
|
||||
|
||||
@Test
|
||||
fun `deposit creates a signed iOS-compatible event and receiver admits it once`() {
|
||||
val senderRelay = FakeRelay()
|
||||
val sender = BridgeCourierService(
|
||||
cipher = FakeCipher(ByteArray(32) { 0x11 }),
|
||||
onEnvelope = {},
|
||||
relayManager = senderRelay,
|
||||
relayUrls = relays,
|
||||
clock = { now },
|
||||
identityFactory = { NostrIdentity.fromSeed("bridge-courier-sender") }
|
||||
)
|
||||
var accepted = false
|
||||
|
||||
val event = try {
|
||||
assertTrue(sender.deposit("synthetic bridge payload", "bridge-message", recipientKey) {
|
||||
accepted = true
|
||||
})
|
||||
assertTrue(accepted)
|
||||
assertEquals(1, senderRelay.sentEvents.size)
|
||||
|
||||
senderRelay.sentEvents.single().also {
|
||||
assertEquals(1401, it.kind)
|
||||
assertTrue(it.isValidSignature())
|
||||
assertEquals(relays, senderRelay.lastSendRelays)
|
||||
}
|
||||
} finally {
|
||||
sender.stop()
|
||||
}
|
||||
|
||||
val encoded = Base64.getDecoder().decode(event.content)
|
||||
val envelope = requireNotNull(CourierEnvelope.decode(encoded))
|
||||
assertTrue(envelope.matchesRecipient(recipientKey, now))
|
||||
assertEquals(1u.toUByte(), envelope.copies)
|
||||
assertEquals(
|
||||
listOf("x", envelope.recipientTag.toHex()),
|
||||
event.tags.first { it.firstOrNull() == "x" }
|
||||
)
|
||||
assertEquals(
|
||||
listOf("expiration", (envelope.expiry / 1000u).toString()),
|
||||
event.tags.first { it.firstOrNull() == "expiration" }
|
||||
)
|
||||
val typed = requireNotNull(NoisePayload.decode(envelope.ciphertext))
|
||||
assertEquals(NoisePayloadType.PRIVATE_MESSAGE, typed.type)
|
||||
val privateMessage = requireNotNull(PrivateMessagePacket.decode(typed.data))
|
||||
assertEquals("bridge-message", privateMessage.messageID)
|
||||
assertEquals("synthetic bridge payload", privateMessage.content)
|
||||
|
||||
val receiverRelay = FakeRelay()
|
||||
val received = mutableListOf<CourierEnvelope>()
|
||||
val receiver = BridgeCourierService(
|
||||
cipher = FakeCipher(recipientKey),
|
||||
onEnvelope = received::add,
|
||||
relayManager = receiverRelay,
|
||||
relayUrls = relays,
|
||||
clock = { now },
|
||||
identityFactory = { NostrIdentity.fromSeed("bridge-courier-receiver") }
|
||||
)
|
||||
try {
|
||||
receiver.start()
|
||||
assertNotNull(receiverRelay.subscription)
|
||||
assertTrue(receiverRelay.subscription!!.filter.matches(event))
|
||||
assertEquals(relays, receiverRelay.subscription!!.relayUrls)
|
||||
|
||||
receiverRelay.emit(event)
|
||||
receiverRelay.emit(event)
|
||||
|
||||
assertEquals(listOf(envelope), received)
|
||||
} finally {
|
||||
receiver.stop()
|
||||
}
|
||||
assertEquals(1, receiverRelay.unsubscribedIDs.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `receiver rejects a validly signed event with mismatched metadata and deposit fails closed`() {
|
||||
val relay = FakeRelay()
|
||||
val received = mutableListOf<CourierEnvelope>()
|
||||
val receiver = BridgeCourierService(
|
||||
cipher = FakeCipher(recipientKey),
|
||||
onEnvelope = received::add,
|
||||
relayManager = relay,
|
||||
relayUrls = relays,
|
||||
clock = { now },
|
||||
identityFactory = { NostrIdentity.fromSeed("bridge-courier-receiver") }
|
||||
)
|
||||
val envelope = CourierEnvelope(
|
||||
recipientTag = CourierEnvelope.recipientTag(recipientKey, CourierEnvelope.epochDay(now)),
|
||||
expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(),
|
||||
ciphertext = byteArrayOf(1, 2, 3),
|
||||
copies = 1u
|
||||
)
|
||||
val identity = NostrIdentity.fromSeed("bridge-courier-invalid-event")
|
||||
val mismatchedTag = identity.signEvent(
|
||||
NostrEvent(
|
||||
pubkey = identity.publicKeyHex,
|
||||
createdAt = (now / 1000).toInt(),
|
||||
kind = 1401,
|
||||
tags = listOf(
|
||||
listOf("x", "00".repeat(CourierEnvelope.TAG_LENGTH)),
|
||||
listOf("expiration", (envelope.expiry / 1000u).toString())
|
||||
),
|
||||
content = Base64.getEncoder().encodeToString(requireNotNull(envelope.encode()))
|
||||
)
|
||||
)
|
||||
try {
|
||||
receiver.start()
|
||||
relay.emit(mismatchedTag)
|
||||
assertTrue(received.isEmpty())
|
||||
|
||||
val oversizedSender = BridgeCourierService(
|
||||
cipher = FakeCipher(ByteArray(32) { 0x21 }, ByteArray(CourierEnvelope.MAX_CIPHERTEXT_BYTES + 1)),
|
||||
onEnvelope = {},
|
||||
relayManager = relay,
|
||||
relayUrls = relays,
|
||||
clock = { now },
|
||||
identityFactory = { NostrIdentity.fromSeed("bridge-courier-oversized") }
|
||||
)
|
||||
try {
|
||||
assertFalse(oversizedSender.depositPayload(byteArrayOf(7), recipientKey))
|
||||
|
||||
relay.connected = false
|
||||
assertFalse(oversizedSender.depositPayload(byteArrayOf(7), recipientKey))
|
||||
} finally {
|
||||
oversizedSender.stop()
|
||||
}
|
||||
} finally {
|
||||
receiver.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deposit completion waits for relay acceptance`() {
|
||||
val relay = FakeRelay().apply { acceptImmediately = false }
|
||||
val sender = BridgeCourierService(
|
||||
cipher = FakeCipher(ByteArray(32) { 0x11 }),
|
||||
onEnvelope = {},
|
||||
relayManager = relay,
|
||||
relayUrls = relays,
|
||||
clock = { now },
|
||||
identityFactory = { NostrIdentity.fromSeed("bridge-courier-pending") }
|
||||
)
|
||||
var accepted = 0
|
||||
|
||||
try {
|
||||
assertTrue(sender.depositPayload(byteArrayOf(1, 2, 3), recipientKey) { accepted++ })
|
||||
assertEquals(0, accepted)
|
||||
|
||||
relay.acceptPendingEvent()
|
||||
assertEquals(1, accepted)
|
||||
} finally {
|
||||
sender.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeCipher(
|
||||
private val staticKey: ByteArray,
|
||||
private val sealedOverride: ByteArray? = null
|
||||
) : BridgeCourierCipher {
|
||||
override fun staticPublicKey(): ByteArray = staticKey
|
||||
|
||||
override fun seal(payload: ByteArray, recipientNoiseKey: ByteArray): ByteArray =
|
||||
sealedOverride ?: payload.copyOf()
|
||||
}
|
||||
|
||||
private class FakeRelay : BridgeCourierRelay {
|
||||
data class Subscription(
|
||||
val filter: NostrFilter,
|
||||
val id: String,
|
||||
val relayUrls: List<String>,
|
||||
val handler: (NostrEvent) -> Unit
|
||||
)
|
||||
|
||||
var connected = true
|
||||
var acceptImmediately = true
|
||||
var subscription: Subscription? = null
|
||||
val sentEvents = mutableListOf<NostrEvent>()
|
||||
var lastSendRelays: List<String> = emptyList()
|
||||
val unsubscribedIDs = mutableListOf<String>()
|
||||
private var pendingAccepted: (() -> Unit)? = null
|
||||
|
||||
override fun subscribe(
|
||||
filter: NostrFilter,
|
||||
id: String,
|
||||
targetRelayUrls: List<String>,
|
||||
handler: (NostrEvent) -> Unit
|
||||
) {
|
||||
subscription = Subscription(filter, id, targetRelayUrls, handler)
|
||||
}
|
||||
|
||||
override fun unsubscribe(id: String) {
|
||||
unsubscribedIDs += id
|
||||
if (subscription?.id == id) subscription = null
|
||||
}
|
||||
|
||||
override fun hasConnectedRelay(relayUrls: Collection<String>): Boolean = connected
|
||||
|
||||
override fun sendEvent(
|
||||
event: NostrEvent,
|
||||
relayUrls: List<String>,
|
||||
onAccepted: () -> Unit
|
||||
): Boolean {
|
||||
if (!connected) return false
|
||||
sentEvents += event
|
||||
lastSendRelays = relayUrls
|
||||
if (acceptImmediately) onAccepted() else pendingAccepted = onAccepted
|
||||
return true
|
||||
}
|
||||
|
||||
fun acceptPendingEvent() {
|
||||
val callback = requireNotNull(pendingAccepted)
|
||||
pendingAccepted = null
|
||||
callback()
|
||||
}
|
||||
|
||||
fun emit(event: NostrEvent) {
|
||||
subscription?.handler?.invoke(event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@ -6,6 +6,7 @@ import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@ -56,4 +57,16 @@ class ContactDirectoryTest {
|
||||
|
||||
assertNull(resolution.displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline contact resolves a fingerprint-validated cached Noise key`() {
|
||||
val noiseKey = ByteArray(32) { it.toByte() }
|
||||
val fingerprint = ContactIdentityResolver.fingerprintHex(noiseKey)
|
||||
val peerID = ContactIdentityResolver.peerIdForNoiseKey(noiseKey)
|
||||
identityManager.cachePeerNoiseKey(peerID, ContactIdentityResolver.noiseKeyHex(noiseKey))
|
||||
|
||||
val resolution = ContactDirectory.resolve("contact_$fingerprint")
|
||||
|
||||
assertTrue(resolution.noisePublicKey!!.contentEquals(noiseKey))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@ import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.anyOrNull
|
||||
import org.mockito.kotlin.argThat
|
||||
import org.mockito.kotlin.clearInvocations
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.mock
|
||||
@ -36,6 +37,7 @@ class MessageRouterTest {
|
||||
|
||||
private lateinit var mesh: MeshService
|
||||
private lateinit var router: MessageRouter
|
||||
private lateinit var identityManager: SecureIdentityStateManager
|
||||
private var fakeTime = 1_000_000L
|
||||
private val expired = mutableListOf<String>()
|
||||
|
||||
@ -46,7 +48,7 @@ class MessageRouterTest {
|
||||
"message-router-test-${UUID.randomUUID()}",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
val identityManager = SecureIdentityStateManager(prefs, testOnly = true)
|
||||
identityManager = SecureIdentityStateManager(prefs, testOnly = true)
|
||||
ContactDirectory.identityManagerProvider = { identityManager }
|
||||
|
||||
mesh = mock()
|
||||
@ -56,11 +58,19 @@ class MessageRouterTest {
|
||||
ContactDirectory.initialize(context) { mesh }
|
||||
|
||||
MessageRouter.disableSchedulerForTesting = true
|
||||
MessageRouter.outboxStoreFactory = { testContext ->
|
||||
MessageOutboxStore(testContext, object : ConversationStorageCipher {
|
||||
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray) = plaintext
|
||||
override fun decrypt(envelope: ByteArray, associatedData: ByteArray) = envelope
|
||||
override fun destroyKey() = Unit
|
||||
})
|
||||
}
|
||||
MessageRouter.resetForTesting()
|
||||
fakeTime = 1_000_000L
|
||||
expired.clear()
|
||||
|
||||
router = MessageRouter.getInstance(context, mesh)
|
||||
router.clearAll()
|
||||
router.clock = { fakeTime }
|
||||
router.onMessageExpired = { expired.add(it) }
|
||||
}
|
||||
@ -69,6 +79,7 @@ class MessageRouterTest {
|
||||
fun tearDown() {
|
||||
MessageRouter.resetForTesting()
|
||||
MessageRouter.disableSchedulerForTesting = false
|
||||
MessageRouter.outboxStoreFactory = ::MessageOutboxStore
|
||||
ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) }
|
||||
}
|
||||
|
||||
@ -174,6 +185,159 @@ class MessageRouterTest {
|
||||
verify(mesh, never()).initiateNoiseHandshake(any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `direct send remains retained until delivery acknowledgement`() {
|
||||
peerReady()
|
||||
router.sendPrivate("direct", peerID, "peer", "msg-direct")
|
||||
clearInvocations(mesh)
|
||||
|
||||
fakeTime += 31_000
|
||||
router.tickOutbox()
|
||||
verify(mesh, times(1)).sendPrivateMessage("direct", peerID, "peer", "msg-direct")
|
||||
|
||||
router.onMessageAcknowledged("msg-direct", peerID)
|
||||
clearInvocations(mesh)
|
||||
router.tickOutbox()
|
||||
verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `queued message survives router recreation`() {
|
||||
peerOffline()
|
||||
router.sendPrivate("durable", peerID, "peer", "msg-durable")
|
||||
|
||||
MessageRouter.resetForTesting()
|
||||
router = MessageRouter.getInstance(RuntimeEnvironment.getApplication(), mesh)
|
||||
router.clock = { fakeTime }
|
||||
peerReady()
|
||||
router.onSessionEstablished(peerID)
|
||||
|
||||
verify(mesh).sendPrivateMessage("durable", peerID, "peer", "msg-durable")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline cached contact deposits once with a verified peer courier`() {
|
||||
val recipientNoiseKey = ByteArray(32) { 0x2A }
|
||||
val recipientPeerID = ContactIdentityResolver.peerIdForNoiseKey(recipientNoiseKey)
|
||||
val courierPeerID = "9999aaaabbbbcccc"
|
||||
val courierNoiseKey = ByteArray(32) { 0x3B }
|
||||
identityManager.cachePeerNoiseKey(
|
||||
recipientPeerID,
|
||||
ContactIdentityResolver.noiseKeyHex(recipientNoiseKey)
|
||||
)
|
||||
whenever(mesh.getPeerInfo(recipientPeerID)).thenReturn(
|
||||
PeerInfo(
|
||||
id = recipientPeerID,
|
||||
nickname = "offline contact",
|
||||
isConnected = false,
|
||||
isDirectConnection = false,
|
||||
noisePublicKey = recipientNoiseKey,
|
||||
signingPublicKey = ByteArray(32) { 0x0A },
|
||||
isVerifiedNickname = false,
|
||||
lastSeen = fakeTime
|
||||
)
|
||||
)
|
||||
whenever(mesh.getPeerInfos()).thenReturn(
|
||||
listOf(
|
||||
PeerInfo(
|
||||
id = courierPeerID,
|
||||
nickname = "verified courier",
|
||||
isConnected = true,
|
||||
isDirectConnection = true,
|
||||
noisePublicKey = courierNoiseKey,
|
||||
signingPublicKey = ByteArray(32) { 0x0C },
|
||||
isVerifiedNickname = false,
|
||||
lastSeen = fakeTime,
|
||||
hasVerifiedAnnouncement = true
|
||||
)
|
||||
)
|
||||
)
|
||||
whenever(mesh.sendCourierMessage(any(), any(), any(), any())).thenReturn(listOf(courierPeerID))
|
||||
|
||||
val result = router.sendPrivate("courier payload", recipientPeerID, "offline contact", "msg-courier")
|
||||
|
||||
assertEquals(MessageRouter.RouteResult.QUEUED, result)
|
||||
verify(mesh).sendCourierMessage(
|
||||
eq("courier payload"),
|
||||
eq("msg-courier"),
|
||||
argThat { contentEquals(recipientNoiseKey) },
|
||||
eq(listOf(courierPeerID))
|
||||
)
|
||||
|
||||
router.tickOutbox()
|
||||
|
||||
verify(mesh, times(1)).sendCourierMessage(
|
||||
eq("courier payload"),
|
||||
eq("msg-courier"),
|
||||
argThat { contentEquals(recipientNoiseKey) },
|
||||
eq(listOf(courierPeerID))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `courier acknowledgement clears an offline contact outbox entry before direct reconnect`() {
|
||||
val recipientNoiseKey = ByteArray(32) { 0x4A }
|
||||
val recipientPeerID = ContactIdentityResolver.peerIdForNoiseKey(recipientNoiseKey)
|
||||
identityManager.cachePeerNoiseKey(
|
||||
recipientPeerID,
|
||||
ContactIdentityResolver.noiseKeyHex(recipientNoiseKey)
|
||||
)
|
||||
whenever(mesh.getPeerNicknames()).thenReturn(mapOf(recipientPeerID to "offline contact"))
|
||||
whenever(mesh.getPeerInfo(recipientPeerID)).thenReturn(
|
||||
PeerInfo(
|
||||
id = recipientPeerID,
|
||||
nickname = "offline contact",
|
||||
isConnected = false,
|
||||
isDirectConnection = false,
|
||||
noisePublicKey = recipientNoiseKey,
|
||||
signingPublicKey = ByteArray(32) { 0x0A },
|
||||
isVerifiedNickname = false,
|
||||
lastSeen = fakeTime
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
MessageRouter.RouteResult.QUEUED,
|
||||
router.sendPrivate("courier payload", recipientPeerID, "offline contact", "msg-courier-ack")
|
||||
)
|
||||
router.onMessageAcknowledged("msg-courier-ack", recipientPeerID)
|
||||
|
||||
whenever(mesh.getPeerInfo(recipientPeerID)).thenReturn(
|
||||
PeerInfo(
|
||||
id = recipientPeerID,
|
||||
nickname = "offline contact",
|
||||
isConnected = true,
|
||||
isDirectConnection = true,
|
||||
noisePublicKey = recipientNoiseKey,
|
||||
signingPublicKey = ByteArray(32) { 0x0A },
|
||||
isVerifiedNickname = false,
|
||||
lastSeen = fakeTime
|
||||
)
|
||||
)
|
||||
whenever(mesh.hasEstablishedSession(recipientPeerID)).thenReturn(true)
|
||||
|
||||
router.tickOutbox()
|
||||
|
||||
verify(mesh, never()).sendPrivateMessage(
|
||||
eq("courier payload"),
|
||||
eq(recipientPeerID),
|
||||
any(),
|
||||
eq("msg-courier-ack")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `outbox stops transport retries after eight attempts`() {
|
||||
peerReady()
|
||||
router.sendPrivate("bounded", peerID, "peer", "msg-bounded")
|
||||
repeat(10) {
|
||||
fakeTime += 10 * 60_000L + 1
|
||||
router.tickOutbox()
|
||||
}
|
||||
|
||||
verify(mesh, times(8)).sendPrivateMessage("bounded", peerID, "peer", "msg-bounded")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scheduler stops with the mesh service and restarts on rebind`() {
|
||||
MessageRouter.disableSchedulerForTesting = false
|
||||
|
||||
@ -18,6 +18,7 @@ class GCSFilterTest {
|
||||
|
||||
// Build filter with plenty of bytes (no trimming)
|
||||
val params = GCSFilter.buildFilter(ids, maxBytes = 400, targetFpr = 0.01)
|
||||
assertEquals(ids.size, params.includedCount)
|
||||
val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data)
|
||||
|
||||
for (id in ids) {
|
||||
@ -47,7 +48,7 @@ class GCSFilterTest {
|
||||
val sorted = GCSFilter.decodeToSortedSet(params.p, params.m, params.data)
|
||||
|
||||
// Let's verify that the first trimmedN elements in ids are all matched
|
||||
val trimmedN = (params.m ushr params.p).toInt()
|
||||
val trimmedN = params.includedCount
|
||||
assertTrue("At least some elements should have been encoded", trimmedN > 0)
|
||||
|
||||
val retainedIds = ids.take(trimmedN)
|
||||
|
||||
@ -0,0 +1,223 @@
|
||||
package com.bitchat.android.sync
|
||||
|
||||
import android.content.ContextWrapper
|
||||
import android.os.Build
|
||||
import com.bitchat.android.model.RequestSyncPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
|
||||
class GossipSyncManagerTest {
|
||||
private val config = object : GossipSyncManager.ConfigProvider {
|
||||
override fun seenCapacity() = 100
|
||||
override fun gcsMaxBytes() = 400
|
||||
override fun gcsTargetFpr() = 0.01
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `whole messages retain six hours while fragments retain fifteen minutes`() {
|
||||
val sent = mutableListOf<BitchatPacket>()
|
||||
val manager = GossipSyncManager("1111222233334444", TestScope(), config)
|
||||
manager.delegate = object : GossipSyncManager.Delegate {
|
||||
override fun sendPacket(packet: BitchatPacket) = Unit
|
||||
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { sent += packet }
|
||||
override fun signPacketForBroadcast(packet: BitchatPacket) = packet
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now - 5 * 60 * 60 * 1000L, 1))
|
||||
manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now - 7 * 60 * 60 * 1000L, 2))
|
||||
manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now - 10 * 60 * 1000L, 3))
|
||||
manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now - 20 * 60 * 1000L, 4))
|
||||
manager.onPublicPacketSeen(packet(MessageType.FILE_TRANSFER, now - 10 * 60 * 1000L, 5))
|
||||
|
||||
manager.handleRequestSync(
|
||||
"peer",
|
||||
RequestSyncPacket(
|
||||
p = 1,
|
||||
m = 1,
|
||||
data = byteArrayOf(),
|
||||
types = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf(1, 3, 5), sent.map { it.payload.single().toInt() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `public history tolerates bounded future clock skew`() {
|
||||
val sent = mutableListOf<BitchatPacket>()
|
||||
val manager = GossipSyncManager("1111222233334444", TestScope(), config)
|
||||
manager.delegate = object : GossipSyncManager.Delegate {
|
||||
override fun sendPacket(packet: BitchatPacket) = Unit
|
||||
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { sent += packet }
|
||||
override fun signPacketForBroadcast(packet: BitchatPacket) = packet
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now + 60_000L, 1))
|
||||
manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now + 60_000L, 2))
|
||||
manager.onPublicPacketSeen(packet(MessageType.FILE_TRANSFER, now + 60_000L, 3))
|
||||
manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now + 11 * 60_000L, 4))
|
||||
manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now + 11 * 60_000L, 5))
|
||||
|
||||
manager.handleRequestSync(
|
||||
"peer",
|
||||
RequestSyncPacket(
|
||||
p = 1,
|
||||
m = 1,
|
||||
data = byteArrayOf(),
|
||||
types = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf(1, 2, 3), sent.map { it.payload.single().toInt() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `type scoped fragment request does not replay other packet classes`() {
|
||||
val sent = mutableListOf<BitchatPacket>()
|
||||
val manager = GossipSyncManager("1111222233334444", TestScope(), config)
|
||||
manager.delegate = recordingDelegate(sent)
|
||||
val now = System.currentTimeMillis()
|
||||
manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now, 1))
|
||||
manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now, 2))
|
||||
manager.onPublicPacketSeen(packet(MessageType.FILE_TRANSFER, now, 3))
|
||||
|
||||
manager.handleRequestSync(
|
||||
"peer",
|
||||
RequestSyncPacket(
|
||||
p = 1,
|
||||
m = 1,
|
||||
data = byteArrayOf(),
|
||||
types = SyncTypeFlags.FRAGMENT
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf(2), sent.map { it.payload.single().toInt() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `coverage cursor prevents replay of an identical history tail`() {
|
||||
val tinyFilterConfig = object : GossipSyncManager.ConfigProvider {
|
||||
override fun seenCapacity() = 100
|
||||
override fun gcsMaxBytes() = 1
|
||||
override fun gcsTargetFpr() = 0.01
|
||||
}
|
||||
val requester = GossipSyncManager("1111222233334444", TestScope(), tinyFilterConfig)
|
||||
val responder = GossipSyncManager("5555666677778888", TestScope(), tinyFilterConfig)
|
||||
val now = System.currentTimeMillis()
|
||||
val history = listOf(
|
||||
packet(MessageType.MESSAGE, now - 100, 1),
|
||||
packet(MessageType.MESSAGE, now - 200, 2),
|
||||
packet(MessageType.MESSAGE, now - 300, 3)
|
||||
)
|
||||
history.forEach {
|
||||
requester.onPublicPacketSeen(it)
|
||||
responder.onPublicPacketSeen(it)
|
||||
}
|
||||
val request = RequestSyncPacket.decode(
|
||||
requester.buildGcsPayload(SyncTypeFlags.PUBLIC_MESSAGES)
|
||||
)!!
|
||||
val sent = mutableListOf<BitchatPacket>()
|
||||
responder.delegate = recordingDelegate(sent)
|
||||
|
||||
responder.handleRequestSync("peer", request)
|
||||
|
||||
assertEquals(SyncTypeFlags.PUBLIC_MESSAGES, request.types)
|
||||
assertEquals(history.first().timestamp, request.sinceTimestamp)
|
||||
assertEquals(emptyList<Int>(), sent.map { it.payload.single().toInt() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy request without type metadata remains public message only`() {
|
||||
val sent = mutableListOf<BitchatPacket>()
|
||||
val manager = GossipSyncManager("1111222233334444", TestScope(), config)
|
||||
manager.delegate = recordingDelegate(sent)
|
||||
val now = System.currentTimeMillis()
|
||||
manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now, 1))
|
||||
manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now, 2))
|
||||
|
||||
manager.handleRequestSync("peer", RequestSyncPacket(p = 1, m = 1, data = byteArrayOf()))
|
||||
|
||||
assertEquals(listOf(1), sent.map { it.payload.single().toInt() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `public history survives manager recreation for post-reconnect sync`() {
|
||||
val filesDir = Files.createTempDirectory("gossip-sync-test-").toFile()
|
||||
val context = object : ContextWrapper(RuntimeEnvironment.getApplication()) {
|
||||
override fun getApplicationContext() = this
|
||||
override fun getFilesDir(): File = filesDir
|
||||
}
|
||||
try {
|
||||
val now = System.currentTimeMillis()
|
||||
GossipSyncManager("1111222233334444", TestScope(), config, context)
|
||||
.onPublicPacketSeen(packet(MessageType.MESSAGE, now, 42))
|
||||
|
||||
val sent = mutableListOf<BitchatPacket>()
|
||||
val restored = GossipSyncManager("1111222233334444", TestScope(), config, context)
|
||||
restored.delegate = recordingDelegate(sent)
|
||||
restored.handleRequestSync(
|
||||
"peer",
|
||||
RequestSyncPacket(
|
||||
p = 1,
|
||||
m = 1,
|
||||
data = byteArrayOf(),
|
||||
types = SyncTypeFlags.PUBLIC_MESSAGES
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf(42), sent.map { it.payload.single().toInt() })
|
||||
} finally {
|
||||
filesDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `announcement-only request returns the latest announcement independently of message history`() {
|
||||
val sent = mutableListOf<BitchatPacket>()
|
||||
val manager = GossipSyncManager("1111222233334444", TestScope(), config)
|
||||
manager.delegate = recordingDelegate(sent)
|
||||
val now = System.currentTimeMillis()
|
||||
manager.onPublicPacketSeen(packet(MessageType.ANNOUNCE, now - 1_000, 6))
|
||||
manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now, 7))
|
||||
|
||||
manager.handleRequestSync(
|
||||
"peer",
|
||||
RequestSyncPacket(
|
||||
p = 1,
|
||||
m = 1,
|
||||
data = byteArrayOf(),
|
||||
types = SyncTypeFlags.ANNOUNCE
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf(6), sent.map { it.payload.single().toInt() })
|
||||
}
|
||||
|
||||
private fun recordingDelegate(sent: MutableList<BitchatPacket>) =
|
||||
object : GossipSyncManager.Delegate {
|
||||
override fun sendPacket(packet: BitchatPacket) = Unit
|
||||
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
|
||||
sent += packet
|
||||
}
|
||||
override fun signPacketForBroadcast(packet: BitchatPacket) = packet
|
||||
}
|
||||
|
||||
private fun packet(type: MessageType, timestamp: Long, marker: Int) = BitchatPacket(
|
||||
type = type.value,
|
||||
senderID = ByteArray(8) { 1 },
|
||||
timestamp = timestamp.toULong(),
|
||||
payload = byteArrayOf(marker.toByte()),
|
||||
ttl = 3u
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package com.bitchat.android.sync
|
||||
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SyncTypeFlagsTest {
|
||||
@Test
|
||||
fun `wire encoding uses compact little endian iOS bit positions`() {
|
||||
val flags = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES)
|
||||
|
||||
assertArrayEquals(byteArrayOf(0xa3.toByte()), flags.encode())
|
||||
assertEquals(flags, SyncTypeFlags.decode(byteArrayOf(0xa3.toByte())))
|
||||
assertTrue(flags.contains(MessageType.ANNOUNCE))
|
||||
assertTrue(flags.contains(MessageType.FILE_TRANSFER))
|
||||
assertFalse(flags.contains(MessageType.NOISE_HANDSHAKE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown extended bits are normalized away`() {
|
||||
val decoded = SyncTypeFlags.decode(byteArrayOf(0x03, 0xfc.toByte()))!!
|
||||
|
||||
assertEquals(SyncTypeFlags.PUBLIC_MESSAGES, decoded)
|
||||
assertArrayEquals(byteArrayOf(0x03), decoded.encode())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty and oversized fields are rejected`() {
|
||||
assertNull(SyncTypeFlags.decode(byteArrayOf()))
|
||||
assertNull(SyncTypeFlags.decode(ByteArray(9)))
|
||||
}
|
||||
}
|
||||
@ -15,8 +15,9 @@ The remaining implementation work and milestone progress are tracked in
|
||||
| Outer mesh packet | v1/v2 header widths, big-endian fields, flags, section order, route placement, signature placement, padding, compression, signing bytes | `BinaryProtocolTest`, `ClientRewriteWireContractTest` |
|
||||
| Chat payload | Flag bits, millisecond timestamp, UTF-8 byte lengths, encrypted-content substitution, optional-field order | `ClientRewriteWireContractTest` |
|
||||
| Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, live-voice bursts, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `VoiceBurstPacketTest`, `FragmentManagerTest` |
|
||||
| Store and forward | Courier type `0x04`, rotating HMAC recipient tags, Noise X seals, copy-budget and prekey-ID TLVs, 24-hour expiry, bounded tiered custody | `CourierEnvelopeTest`, `NoiseCourierTest`, `MessageRouterTest` |
|
||||
| Identity/security | Announcement extensions, capability bitfield endianness, Noise static-key binding, handshake identity binding, signatures | `IdentityAnnouncementTest`, `NoiseSessionManagerIdentityBindingTest`, `ClientRewritePrimitiveContractTest` |
|
||||
| Sync/routing | Stable packet IDs, GCS bitstream, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` |
|
||||
| Sync/routing | Stable packet IDs, GCS bitstream, type-scoped filters, bounded-history cursors, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewriteWireContractTest`, `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `GossipSyncManagerTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` |
|
||||
| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals | `ClientRewriteNostrContractTest`, `NostrProtocolTest` |
|
||||
| Application state | Peer unions, canonical private conversations, chronological history, delivery/read behavior, media migration policy | `AppStateStoreTest`, `PrivateChatManagerTest`, `MediaSendingManagerMigrationTest` |
|
||||
|
||||
@ -53,8 +54,8 @@ shipping a rewrite, run the following on at least two physical devices:
|
||||
2. Runtime permission denial/retry for Bluetooth, location, notifications, and
|
||||
microphone.
|
||||
3. Foreground-service survival with the screen off and after process recreation.
|
||||
4. Cross-client Android/iOS exchange for announce, public/private text, delivery
|
||||
and read receipts, image/audio/file transfer, sync replay, and Nostr fallback.
|
||||
4. Cross-client Android/iOS exchange for announce, public/private text, courier
|
||||
deposit/handover, delivery/read receipts, media, six-hour sync replay, and Nostr fallback.
|
||||
5. Corrupt, duplicated, reordered, delayed, and partially delivered fragments.
|
||||
6. Identity rotation, verification continuity, downgrade rejection, and recovery
|
||||
after stale Noise sessions.
|
||||
|
||||
@ -298,6 +298,12 @@ python3 tools/release_gate/mesh_lab.py scenario all \
|
||||
| `dm` | Noise handshake both ways, encrypted DM round trips with content match |
|
||||
| `favorite_verification` | favorite signal, orange-outline/filled mutual state, and peer fingerprint verification |
|
||||
| `broadcast` | public mesh message A→B |
|
||||
| `sync_recovery` | message missed while B's BLE and Wi-Fi Aware transports are disabled is recovered by the gossip sync request after reconnection |
|
||||
| `sync_auto_recovery` | message missed while B is offline is recovered after restart by the production gossip scheduler, without a test-hook sync request |
|
||||
| `sync_file_recovery` | broadcast file missed while B is offline is recovered through sync; validates the synthetic file digest and fragment replay |
|
||||
| `durable_outbox` | a router-queued DM survives sender process death while the recipient is offline, then reaches the recipient after reconnection |
|
||||
| `courier_delivery` | three-phone sender→courier→recipient handoff: recipient is offline for deposit and sender remains offline for recipient pickup |
|
||||
| `courier_contract` | on-device courier wire/store contract: prekey TLV retention, split reservations, cancellation, reverse commits, and persisted spray history |
|
||||
| `ptt_dm` | Noise-encrypted 440 Hz PTT in both directions; asserts real-time capture, zero sequence gaps, decoded PCM duration/energy/continuity, and finalized-note absorption |
|
||||
| `ptt_broadcast` | signed public 440 Hz PTT with the same bidirectional packet and decoded-audio quality assertions |
|
||||
| `file` | 1 KB broadcast file, receiver SHA-256 matches fixture |
|
||||
@ -307,7 +313,7 @@ python3 tools/release_gate/mesh_lab.py scenario all \
|
||||
| `raw` | raw packet injection is accepted by the mesh |
|
||||
| `session_recovery` | force-stop B mid-session: identity persists, re-handshake, DMs flow again |
|
||||
| `identity_reset` | pm clear B mid-session: new identity, rediscovery, handshake, DMs |
|
||||
| `all` | every scenario above in sequence |
|
||||
| `all` | every supported two-phone scenario in sequence, plus `courier_delivery` when a third phone is supplied |
|
||||
|
||||
Each run writes `<scenario>-evidence.json` to `--out` (digests, timings,
|
||||
session states, logcat excerpts on failure) and exits non-zero on failure.
|
||||
@ -326,10 +332,15 @@ python3 tools/release_gate/mesh_lab.py cmd --serial <serial> state # full mesh
|
||||
|
||||
See `TestHookDriver.kt` for the full command set (`ping`, `start`, `stop`,
|
||||
`whoami`, `set_nickname`, `scan`, `peers`, `connect`, `handshake`, `session`,
|
||||
`announce`, `broadcast_msg`, `dm_send`, `dm_recv`, `msg_recv`, `favorite_set`,
|
||||
`announce`, `broadcast_msg`, `dm_send`, `router_private_send`, `router_resume`,
|
||||
`dm_recv`, `msg_recv`, `favorite_set`,
|
||||
`favorite_status`, `verification_set`, `verification_status`, `file_send`,
|
||||
`file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `ble`, `state`,
|
||||
`clear_results`).
|
||||
`file_recv`, `file_cancel`, `ptt_send`, `ptt_recv`, `raw_send`, `courier_contract`,
|
||||
`cache_peer_identity`, `sync_request`, `ble`, `wifi_aware`, `state`, `clear_results`).
|
||||
|
||||
`msg_recv` accepts `include_existing=true` for deterministic backfill checks when
|
||||
the packet's original timestamp predates the receive hook; use a unique synthetic
|
||||
content token with this mode.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
32
docs/sync.md
32
docs/sync.md
@ -1,17 +1,19 @@
|
||||
# GCS Filter Sync (REQUEST_SYNC)
|
||||
|
||||
This document specifies the gossip-based synchronization feature for BitChat, inspired by Plumtree. It ensures eventual consistency of public packets (ANNOUNCE and broadcast MESSAGE) across nodes via periodic sync requests containing a compact Golomb‑Coded Set (GCS) of recently seen packets.
|
||||
This document specifies the gossip-based synchronization feature for BitChat, inspired by Plumtree. It ensures eventual consistency of public packets across nodes via periodic, type-scoped sync requests containing a compact Golomb‑Coded Set (GCS) of recently seen packets.
|
||||
|
||||
## Overview
|
||||
|
||||
- Each node maintains a rolling set of public BitChat packets it has seen recently:
|
||||
- Broadcast messages (MessageType.MESSAGE where recipient is broadcast)
|
||||
- Identity announcements (MessageType.ANNOUNCE)
|
||||
- Default retention is 100 recent packets (configurable in the debug sheet). This value is the maximum number of packets that are synchronized per request (across both types combined).
|
||||
- Broadcast fragments (MessageType.FRAGMENT)
|
||||
- Broadcast file transfers (MessageType.FILE_TRANSFER)
|
||||
- Default retention is 100 recent packets per local store (configurable in the debug sheet).
|
||||
- Nodes do not maintain a rolling Bloom filter. Instead, they compute a GCS filter on demand when sending a REQUEST_SYNC.
|
||||
- Every 30 seconds, a node sends a REQUEST_SYNC packet to all immediate neighbors (local only; not relayed).
|
||||
- Additionally, 5 seconds after the first announcement from a newly directly connected peer is detected, a node sends a REQUEST_SYNC only to that peer (unicast; local only).
|
||||
- The receiver checks which packets are not in the sender’s filter and sends those packets back. For announcements, only the latest announcement per peerID is sent; for broadcast messages, all missing ones are sent.
|
||||
- Every 30 seconds, Android sends separate public-message, fragment, and file-transfer REQUEST_SYNC rounds to immediate neighbors (local only; not relayed). Keeping the filters type-scoped prevents one high-volume class from crowding another out.
|
||||
- Additionally, 5 seconds after the first announcement from a newly directly connected peer is detected, a node sends a combined type-scoped REQUEST_SYNC only to that peer (unicast; local only).
|
||||
- The receiver checks only the packet types named by the request and sends packets absent from the filter. For announcements, only the latest announcement per peerID is sent.
|
||||
|
||||
This synchronization is strictly local (not relayed), ensuring only immediate neighbors participate and preventing wide-area flooding while converging content across the mesh.
|
||||
|
||||
@ -36,9 +38,7 @@ Implementation: `com.bitchat.android.sync.GCSFilter`.
|
||||
- Maximum number of elements that fit into the filter is estimated as: N_max ≈ floor((8 * sizeBytes) / (P + 2))
|
||||
- This estimate is used to cap the set; the actual encoder will trim further if needed to stay within the configured size.
|
||||
- What goes into the set:
|
||||
- Combine the following and sort by packet timestamp (descending):
|
||||
- Broadcast messages (MessageType 1)
|
||||
- The most recent ANNOUNCE per peer
|
||||
- Select only the classes named by the request's `types` field, then sort matching packets by timestamp (descending). Android stores broadcast messages, the most recent ANNOUNCE per peer, broadcast fragments, and broadcast file transfers.
|
||||
- Take at most `min(N_max, maxPacketsPerSync)` items from this ordered list.
|
||||
- Compute the 16-byte Packet ID (see below), then for hashing use the first 8 bytes of SHA‑256 over the 16‑byte ID.
|
||||
- Map each hash to [0, M) with M = N * 2^P; sort ascending and encode deltas with Golomb‑Rice parameter P.
|
||||
@ -56,6 +56,10 @@ MessageType: `REQUEST_SYNC (0x21)`
|
||||
- 0x01: P (uint8) — Golomb‑Rice parameter
|
||||
- 0x02: M (uint32) — hash range N * 2^P
|
||||
- 0x03: data (opaque) — GCS bitstream (MSB‑first bit packing)
|
||||
- 0x04: types (1–8 byte compact little-endian bitfield) — packet classes represented by this filter
|
||||
- 0x05: sinceTimestamp (uint64, big-endian) — timestamp of the oldest packet represented when the candidate tail did not fit
|
||||
|
||||
`types` uses the cross-client bit mapping: ANNOUNCE=bit 0, MESSAGE=bit 1, LEAVE=bit 2, NOISE_HANDSHAKE=bit 3, NOISE_ENCRYPTED=bit 4, FRAGMENT=bit 5, REQUEST_SYNC=bit 6, and FILE_TRANSFER=bit 7. Implementations ignore bits they do not recognize. When `types` is absent, receivers use the legacy scope of ANNOUNCE plus broadcast MESSAGE.
|
||||
|
||||
Notes:
|
||||
- The GCS bitstream uses MSB‑first packing (bit 7 is the first bit in each byte).
|
||||
@ -71,7 +75,9 @@ Sender behavior:
|
||||
|
||||
Receiver behavior:
|
||||
- Decode the REQUEST_SYNC payload and reconstruct the sorted set of mapped values using the provided P, M, and bitstream.
|
||||
- For each locally stored public packet ID:
|
||||
- Restrict the diff to the packet classes in `types` (or the legacy public-message scope when absent).
|
||||
- For each locally stored packet ID in that scope:
|
||||
- If `sinceTimestamp` is present, skip messages, fragments, and files older than it because they were outside the requester's bounded filter rather than missing. ANNOUNCE is exempt so a peer can still obtain current verification keys.
|
||||
- Compute h64(ID) % M and check if it is in the reconstructed set; if NOT present, send the original packet back with `ttl=0` to the requester only.
|
||||
- For announcements, send only the latest announcement per (sender peerID).
|
||||
- For broadcast messages, send all missing ones.
|
||||
@ -90,6 +96,8 @@ Important: original packets are sent unmodified to preserve original signatures
|
||||
Included in sync:
|
||||
- Public broadcast messages: `MessageType.MESSAGE` with BROADCAST recipient (or null recipient).
|
||||
- Identity announcements: `MessageType.ANNOUNCE`.
|
||||
- Broadcast fragments: `MessageType.FRAGMENT` with BROADCAST recipient (or null recipient).
|
||||
- Broadcast files: `MessageType.FILE_TRANSFER` with BROADCAST recipient (or null recipient).
|
||||
- Both packets produced by other peers and packets produced by the requester itself MUST be represented in the requester’s GCS; the responder MUST track and consider its own produced public packets as candidates to return when they are missing on the requester.
|
||||
- Announcements included in the GCS MUST be at most 60 seconds old at the time of filter construction; older announcements are excluded by pruning.
|
||||
|
||||
@ -130,8 +138,8 @@ The following items require consensus across all implementations to ensure inter
|
||||
|
||||
- Packet ID recipe: first 16 bytes of SHA‑256(type | senderID | timestamp | payload).
|
||||
- GCS hashing function and mapping to [0, M) as specified above (v1), and MSB‑first bit packing for the bitstream.
|
||||
- Payload encoding: TLV with 16‑bit big‑endian lengths; TLV types 0x01 = P (uint8), 0x02 = M (uint32), 0x03 = data (opaque).
|
||||
- Packet type and scope: REQUEST_SYNC = 0x21; local-only (not relayed); only ANNOUNCE and broadcast MESSAGE are synchronized; ANNOUNCE de‑dupe is “latest per sender peerID”.
|
||||
- Payload encoding: TLV with 16‑bit big‑endian lengths; TLV types 0x01 = P (uint8), 0x02 = M (uint32), 0x03 = data (opaque), 0x04 = compact little-endian type flags, and 0x05 = big-endian coverage cursor.
|
||||
- Packet type and scope: REQUEST_SYNC = 0x21; local-only (not relayed); only requested, supported public packet classes are synchronized; ANNOUNCE de‑dupe is “latest per sender peerID”.
|
||||
|
||||
The following are requester‑defined and communicated or local policy (no global agreement required):
|
||||
|
||||
@ -142,7 +150,7 @@ The following are requester‑defined and communicated or local policy (no globa
|
||||
Validation and limits (recommended):
|
||||
|
||||
- Reject malformed REQUEST_SYNC payloads (e.g., P < 1, M <= 0, or data length too large for local limits).
|
||||
- Practical bounds: data length in [0, 1024]; P in [1, 24]; M up to 2^32‑1.
|
||||
- Practical bounds: data length in [0, 1024]; P in [1, 32]; M up to 2^32‑1.
|
||||
|
||||
Versioning:
|
||||
|
||||
|
||||
@ -166,11 +166,14 @@ class Device:
|
||||
|
||||
def reset_bluetooth(self) -> None:
|
||||
"""Cycle the BT adapter; clears zombie GATT connections from peer restarts."""
|
||||
_shell(self.serial, "svc bluetooth disable")
|
||||
self.disable_bluetooth()
|
||||
time.sleep(2)
|
||||
_shell(self.serial, "svc bluetooth enable")
|
||||
time.sleep(3)
|
||||
|
||||
def disable_bluetooth(self) -> None:
|
||||
_shell(self.serial, "svc bluetooth disable")
|
||||
|
||||
def enable_bluetooth(self) -> None:
|
||||
subprocess.run(
|
||||
[find_adb(), "-s", self.serial, "shell", "svc", "bluetooth", "enable"],
|
||||
@ -365,6 +368,31 @@ def setup_pair(
|
||||
wait_for_mutual_discovery(a, b)
|
||||
|
||||
|
||||
def setup_triad(
|
||||
a: Device,
|
||||
b: Device,
|
||||
c: Device,
|
||||
apk: Path | None,
|
||||
nickname_a: str,
|
||||
nickname_b: str,
|
||||
nickname_c: str,
|
||||
) -> None:
|
||||
"""Install and isolate three phones, then ensure every pair can discover each other."""
|
||||
for device, nickname in ((a, nickname_a), (b, nickname_b), (c, nickname_c)):
|
||||
device.reset_bluetooth()
|
||||
device.enable_bluetooth()
|
||||
if apk is not None:
|
||||
device.install(apk)
|
||||
device.clear_app_data()
|
||||
device.grant_permissions()
|
||||
device.wake()
|
||||
device.launch()
|
||||
device.cmd_ok("start")
|
||||
device.cmd_ok("set_nickname", name=nickname)
|
||||
for left, right in ((a, b), (a, c), (b, c)):
|
||||
wait_for_mutual_discovery(left, right)
|
||||
|
||||
|
||||
def whoami(device: Device) -> dict:
|
||||
return device.cmd_ok("whoami")
|
||||
|
||||
@ -391,6 +419,47 @@ def wait_for_mutual_discovery(a: Device, b: Device) -> None:
|
||||
fb.result()
|
||||
|
||||
|
||||
def wait_for_peer_absent(device: Device, peer_id: str, timeout_s: int = 90) -> None:
|
||||
"""Wait until the peer is absent or no longer connected in the visible mesh state."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
peers = device.cmd_ok("peers").get("peers", [])
|
||||
peer = next((item for item in peers if item.get("id") == peer_id), None)
|
||||
if peer is None or not peer.get("connected", False):
|
||||
return
|
||||
time.sleep(2)
|
||||
raise MeshLabError(f"[{device.alias}] peer {peer_id} remained connected after transport shutdown")
|
||||
|
||||
|
||||
def disable_transports(device: Device) -> bool:
|
||||
"""Make a phone unreachable through either local mesh transport and return its Wi-Fi setting."""
|
||||
device.cmd_ok("ble", enabled=False)
|
||||
return bool(device.cmd_ok("wifi_aware", enabled=False)["previous_enabled"])
|
||||
|
||||
|
||||
def resume_transports(device: Device, wifi_enabled: bool) -> None:
|
||||
"""Bring a force-stopped phone back as the same identity with its prior Wi-Fi setting."""
|
||||
device.wake()
|
||||
device.launch()
|
||||
device.cmd_ok("start")
|
||||
device.cmd_ok("ble", enabled=True)
|
||||
device.cmd_ok("wifi_aware", enabled=wifi_enabled)
|
||||
|
||||
|
||||
def take_device_offline(device: Device) -> bool:
|
||||
"""Stop the app and radio so the counterpart must observe a real link loss."""
|
||||
wifi_enabled = disable_transports(device)
|
||||
device.force_stop()
|
||||
device.disable_bluetooth()
|
||||
return wifi_enabled
|
||||
|
||||
|
||||
def bring_device_online(device: Device, wifi_enabled: bool) -> None:
|
||||
device.enable_bluetooth()
|
||||
time.sleep(3)
|
||||
resume_transports(device, wifi_enabled)
|
||||
|
||||
|
||||
# MARK: - scenarios
|
||||
|
||||
def scenario_dm(a: Device, b: Device) -> dict:
|
||||
@ -532,6 +601,306 @@ def scenario_broadcast(a: Device, b: Device) -> dict:
|
||||
return {"send": send_result, "recv": recv_result}
|
||||
|
||||
|
||||
def scenario_sync_recovery(a: Device, b: Device) -> dict:
|
||||
"""Miss a public message while B's transports are offline, then receive it via gossip sync."""
|
||||
id_a = whoami(a)["peer_id"]
|
||||
id_b = whoami(b)["peer_id"]
|
||||
token = f"sync-{uuid.uuid4().hex[:8]}"
|
||||
b.cmd_ok("ble", enabled=False)
|
||||
wifi_before = b.cmd_ok("wifi_aware", enabled=False)["previous_enabled"]
|
||||
try:
|
||||
send_result = a.cmd_ok("broadcast_msg", 30_000, content=f"sync recovery {token}")
|
||||
# The hook returns after dispatching the send coroutine. Give the sender's
|
||||
# gossip observer time to retain the packet before taking B offline.
|
||||
time.sleep(2)
|
||||
offline_result = b.cmd("msg_recv", 5_000, contains=token, include_existing=True)
|
||||
if offline_result.get("status") == "ok":
|
||||
raise MeshLabError(f"message was delivered while receiver transports were disabled: {offline_result}")
|
||||
|
||||
# Restart B so the initial sync request is scheduled from a fresh peer
|
||||
# observation instead of relying on an already-cached relationship.
|
||||
b.force_stop()
|
||||
b.wake()
|
||||
b.launch()
|
||||
b.cmd_ok("start")
|
||||
b.cmd_ok("ble", enabled=True)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||
recv = pool.submit(
|
||||
b.cmd_ok,
|
||||
"msg_recv",
|
||||
180_000,
|
||||
contains=token,
|
||||
include_existing=True,
|
||||
)
|
||||
time.sleep(2)
|
||||
ensure_direct_link(a, b, id_a, id_b)
|
||||
b.cmd_ok("sync_request", peer=id_a)
|
||||
recv_result = recv.result()
|
||||
assert recv_result["from"] == id_a, recv_result
|
||||
return {
|
||||
"send": send_result,
|
||||
"offline_delivery": {"status": offline_result.get("status", "missing")},
|
||||
"synced_recv": recv_result,
|
||||
}
|
||||
finally:
|
||||
# Leave the pair usable if an assertion or timeout interrupts the recovery.
|
||||
try:
|
||||
b.cmd_ok("ble", enabled=True)
|
||||
except MeshLabError:
|
||||
pass
|
||||
try:
|
||||
b.cmd_ok("wifi_aware", enabled=wifi_before)
|
||||
except MeshLabError:
|
||||
pass
|
||||
|
||||
|
||||
def scenario_sync_auto_recovery(a: Device, b: Device) -> dict:
|
||||
"""A restarted receiver recovers a missed public message without a test-hook sync request."""
|
||||
id_a = whoami(a)["peer_id"]
|
||||
id_b = whoami(b)["peer_id"]
|
||||
token = f"sync-auto-{uuid.uuid4().hex[:8]}"
|
||||
wifi_before = disable_transports(b)
|
||||
try:
|
||||
send_result = a.cmd_ok("broadcast_msg", 30_000, content=f"automatic sync recovery {token}")
|
||||
time.sleep(2)
|
||||
offline_result = b.cmd("msg_recv", 5_000, contains=token, include_existing=True)
|
||||
if offline_result.get("status") == "ok":
|
||||
raise MeshLabError(f"message was delivered while receiver transports were disabled: {offline_result}")
|
||||
|
||||
b.force_stop()
|
||||
resume_transports(b, wifi_before)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||
recv = pool.submit(
|
||||
b.cmd_ok,
|
||||
"msg_recv",
|
||||
180_000,
|
||||
contains=token,
|
||||
include_existing=True,
|
||||
)
|
||||
time.sleep(2)
|
||||
ensure_direct_link(a, b, id_a, id_b)
|
||||
# No sync_request command here: this must arrive from the production initial/periodic
|
||||
# gossip scheduler created by the restarted receiver.
|
||||
recv_result = recv.result()
|
||||
assert recv_result["from"] == id_a, recv_result
|
||||
return {
|
||||
"send": send_result,
|
||||
"offline_delivery": {"status": offline_result.get("status", "missing")},
|
||||
"synced_recv": recv_result,
|
||||
}
|
||||
finally:
|
||||
try:
|
||||
bring_device_online(b, wifi_before)
|
||||
except MeshLabError:
|
||||
pass
|
||||
|
||||
|
||||
def scenario_sync_file_recovery(a: Device, b: Device) -> dict:
|
||||
"""Sync an offline-missed broadcast transfer, exercising FILE_TRANSFER and FRAGMENT replay."""
|
||||
id_a = whoami(a)["peer_id"]
|
||||
id_b = whoami(b)["peer_id"]
|
||||
fixture = make_fixtures(
|
||||
Path(tempfile.mkdtemp(prefix="meshlab-sync-file-")),
|
||||
names=["small_1k.bin"],
|
||||
)["small_1k.bin"]
|
||||
name = f"sync-file-{uuid.uuid4().hex[:8]}.bin"
|
||||
remote = a.push_fixture(fixture["path"], name=name)
|
||||
b.clear_incoming()
|
||||
wifi_before = disable_transports(b)
|
||||
try:
|
||||
send_result = a.cmd_ok("file_send", 180_000, path=remote)
|
||||
time.sleep(2)
|
||||
offline_result = b.cmd("file_recv", 10_000, name_contains=name)
|
||||
if offline_result.get("status") == "ok":
|
||||
raise MeshLabError(f"file was delivered while receiver transports were disabled: {offline_result}")
|
||||
|
||||
b.force_stop()
|
||||
resume_transports(b, wifi_before)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||
recv = pool.submit(b.cmd_ok, "file_recv", 240_000, name_contains=name)
|
||||
time.sleep(2)
|
||||
ensure_direct_link(a, b, id_a, id_b)
|
||||
b.cmd_ok("sync_request", peer=id_a)
|
||||
recv_result = recv.result()
|
||||
if recv_result["sha256"] != fixture["sha256"]:
|
||||
raise MeshLabError("synced file digest did not match the synthetic fixture")
|
||||
return {
|
||||
"send": send_result,
|
||||
"offline_delivery": {"status": offline_result.get("status", "missing")},
|
||||
"synced_recv": {
|
||||
"name": recv_result["name"],
|
||||
"bytes": recv_result["bytes"],
|
||||
"digest_match": True,
|
||||
},
|
||||
}
|
||||
finally:
|
||||
try:
|
||||
b.cmd_ok("ble", enabled=True)
|
||||
except MeshLabError:
|
||||
pass
|
||||
try:
|
||||
b.cmd_ok("wifi_aware", enabled=wifi_before)
|
||||
except MeshLabError:
|
||||
pass
|
||||
|
||||
|
||||
def scenario_durable_outbox(a: Device, b: Device) -> dict:
|
||||
"""Queue a private message offline, kill the sender, and deliver it after both phones return."""
|
||||
id_a = whoami(a)["peer_id"]
|
||||
id_b = whoami(b)["peer_id"]
|
||||
ensure_direct_link(a, b, id_a, id_b)
|
||||
force_handshake(a, id_b)
|
||||
force_handshake(b, id_a)
|
||||
a.cmd_ok("cache_peer_identity", peer=id_b)
|
||||
|
||||
token = f"durable-outbox-{uuid.uuid4().hex[:8]}"
|
||||
message_id = f"outbox-{uuid.uuid4().hex}"
|
||||
wifi_before = take_device_offline(b)
|
||||
try:
|
||||
wait_for_peer_absent(a, id_b)
|
||||
queued: dict | None = None
|
||||
for _attempt in range(5):
|
||||
candidate = a.cmd_ok(
|
||||
"router_private_send",
|
||||
peer=id_b,
|
||||
content=f"durable outbox {token}",
|
||||
msg_id=message_id,
|
||||
)
|
||||
if candidate.get("route") == "QUEUED":
|
||||
queued = candidate
|
||||
break
|
||||
time.sleep(3)
|
||||
if queued is None:
|
||||
raise MeshLabError("private message did not enter the durable outbox while recipient was offline")
|
||||
|
||||
# The queued entry must survive a complete sender process death, not just an in-memory
|
||||
# reconnect. B is restored before A so its receiver is ready for the resumed delivery.
|
||||
a.force_stop()
|
||||
bring_device_online(b, wifi_before)
|
||||
a.wake()
|
||||
a.launch()
|
||||
a.cmd_ok("start")
|
||||
a.cmd_ok("router_resume")
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||
recv = pool.submit(b.cmd_ok, "dm_recv", 180_000, peer=id_a, contains=token)
|
||||
time.sleep(2)
|
||||
ensure_direct_link(a, b, id_a, id_b)
|
||||
force_handshake(a, id_b)
|
||||
force_handshake(b, id_a)
|
||||
recv_result = recv.result()
|
||||
assert recv_result["from"] == id_a, recv_result
|
||||
return {
|
||||
"queued_route": queued["route"],
|
||||
"sender_process_restarted": True,
|
||||
"received_after_restart": {"msg_id": recv_result["msg_id"], "from_sender": True},
|
||||
}
|
||||
finally:
|
||||
try:
|
||||
bring_device_online(b, wifi_before)
|
||||
except MeshLabError:
|
||||
pass
|
||||
|
||||
|
||||
def _make_mutual_favorites(a: Device, b: Device, id_a: str, id_b: str) -> None:
|
||||
"""Establish a trusted direct A↔B relationship for a courier deposit."""
|
||||
ensure_direct_link(a, b, id_a, id_b)
|
||||
force_handshake(a, id_b)
|
||||
force_handshake(b, id_a)
|
||||
a.cmd_ok("favorite_set", peer=id_b, enabled=True)
|
||||
b.cmd_ok("favorite_set", peer=id_a, enabled=True)
|
||||
deadline = time.monotonic() + 45
|
||||
while time.monotonic() < deadline:
|
||||
a_status = a.cmd_ok("favorite_status", peer=id_b)
|
||||
b_status = b.cmd_ok("favorite_status", peer=id_a)
|
||||
if a_status.get("is_mutual") and b_status.get("is_mutual"):
|
||||
return
|
||||
time.sleep(1)
|
||||
raise MeshLabError("courier depositor and courier did not establish a mutual favorite relationship")
|
||||
|
||||
|
||||
def scenario_courier_delivery(a: Device, b: Device, c: Device) -> dict:
|
||||
"""A deposits to B while C is offline; C returns only after A is unavailable."""
|
||||
id_a = whoami(a)["peer_id"]
|
||||
id_b = whoami(b)["peer_id"]
|
||||
id_c = whoami(c)["peer_id"]
|
||||
_make_mutual_favorites(a, b, id_a, id_b)
|
||||
ensure_direct_link(a, c, id_a, id_c)
|
||||
a.cmd_ok("cache_peer_identity", peer=id_c)
|
||||
|
||||
token = f"courier-delivery-{uuid.uuid4().hex[:8]}"
|
||||
message_id = f"courier-{uuid.uuid4().hex}"
|
||||
wifi_c = take_device_offline(c)
|
||||
wifi_a: bool | None = None
|
||||
try:
|
||||
wait_for_peer_absent(a, id_c)
|
||||
ensure_direct_link(a, b, id_a, id_b)
|
||||
|
||||
queued: dict | None = None
|
||||
for _attempt in range(5):
|
||||
candidate = a.cmd_ok(
|
||||
"router_private_send",
|
||||
peer=id_c,
|
||||
content=f"courier delivery {token}",
|
||||
msg_id=message_id,
|
||||
)
|
||||
if candidate.get("route") == "QUEUED":
|
||||
queued = candidate
|
||||
break
|
||||
time.sleep(3)
|
||||
if queued is None:
|
||||
raise MeshLabError("offline recipient message did not enter the courier-capable outbox")
|
||||
|
||||
# A must be unavailable when C returns. This prevents the sender's direct outbox retry
|
||||
# from masking whether B actually retained and handed over the courier envelope.
|
||||
wifi_a = take_device_offline(a)
|
||||
wait_for_peer_absent(b, id_a)
|
||||
|
||||
bring_device_online(c, wifi_c)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||
recv = pool.submit(c.cmd_ok, "dm_recv", 240_000, peer=id_a, contains=token)
|
||||
time.sleep(2)
|
||||
ensure_direct_link(b, c, id_b, id_c)
|
||||
c.cmd_ok("announce")
|
||||
recv_result = recv.result()
|
||||
assert recv_result["from"] == id_a, recv_result
|
||||
return {
|
||||
"queued_route": queued["route"],
|
||||
"sender_offline_before_recipient_return": True,
|
||||
"received_from_original_sender": True,
|
||||
"received_message_id": recv_result["msg_id"],
|
||||
}
|
||||
finally:
|
||||
try:
|
||||
bring_device_online(c, wifi_c)
|
||||
except MeshLabError:
|
||||
pass
|
||||
if wifi_a is not None:
|
||||
try:
|
||||
bring_device_online(a, wifi_a)
|
||||
except MeshLabError:
|
||||
pass
|
||||
|
||||
|
||||
def scenario_courier_contract(a: Device, b: Device) -> dict:
|
||||
"""Run the real courier wire/store contract on device A's debug build."""
|
||||
result = a.cmd_ok("courier_contract", 60_000)
|
||||
expected = {
|
||||
"wire_prekey_id_preserved": True,
|
||||
"stored_prekey_id_preserved": True,
|
||||
"first_reserved_copies": 2,
|
||||
"second_reserved_copies": 1,
|
||||
"same_courier_second_reservation_empty": True,
|
||||
"reverse_order_commits": True,
|
||||
"cancel_restored_eligibility": True,
|
||||
"persisted_spray_history": True,
|
||||
"remaining_copies_after_restart": 1,
|
||||
}
|
||||
for field, value in expected.items():
|
||||
if result.get(field) != value:
|
||||
raise MeshLabError(f"courier contract field {field}={result.get(field)!r}, expected {value!r}")
|
||||
return result
|
||||
|
||||
|
||||
def _ptt_one_way(
|
||||
sender: Device,
|
||||
receiver: Device,
|
||||
@ -728,26 +1097,26 @@ def ensure_direct_link(a: Device, b: Device, id_a: str, id_b: str) -> None:
|
||||
wait_for_peer(a, id_b, timeout_s=120)
|
||||
wait_for_peer(b, id_a, timeout_s=120)
|
||||
for device, peer, announcer in ((a, id_b, b), (b, id_a, a)):
|
||||
connected = False
|
||||
last: dict = {}
|
||||
for _attempt in range(4):
|
||||
last = device.cmd("connect", timeout_ms=45_000, peer=peer)
|
||||
if last.get("status") == "ok" and last.get("direct"):
|
||||
connected = True
|
||||
break
|
||||
# Already acceptable if the mesh formed a direct link on its own.
|
||||
peers = device.cmd_ok("peers").get("peers", [])
|
||||
match = next((p for p in peers if p.get("id") == peer), None)
|
||||
if match and match.get("direct"):
|
||||
connected = True
|
||||
break
|
||||
# A GATT link carries traffic both ways. Requiring a second client connection from
|
||||
# the other endpoint turns a healthy single direct link into a false test failure,
|
||||
# especially after one phone has just restarted and has not rebuilt its address map.
|
||||
return
|
||||
# Already acceptable if either endpoint formed a direct link on its own.
|
||||
for observer, observed_peer in ((device, peer), (announcer, whoami(device)["peer_id"])):
|
||||
peers = observer.cmd_ok("peers").get("peers", [])
|
||||
match = next((p for p in peers if p.get("id") == observed_peer), None)
|
||||
if match and match.get("direct"):
|
||||
return
|
||||
try:
|
||||
announcer.cmd_ok("announce")
|
||||
except MeshLabError:
|
||||
pass
|
||||
time.sleep(4)
|
||||
if not connected:
|
||||
raise MeshLabError(f"[{device.alias}] no direct link to {peer}: connect={last}")
|
||||
raise MeshLabError(f"no direct link formed between {a.alias} and {b.alias}: connect={last}")
|
||||
|
||||
|
||||
def force_handshake(device: Device, peer_id: str, attempts: int = 5, per_attempt_s: int = 20) -> dict:
|
||||
@ -869,6 +1238,11 @@ SCENARIOS = {
|
||||
"dm": scenario_dm,
|
||||
"favorite_verification": scenario_favorite_verification,
|
||||
"broadcast": scenario_broadcast,
|
||||
"sync_recovery": scenario_sync_recovery,
|
||||
"sync_auto_recovery": scenario_sync_auto_recovery,
|
||||
"sync_file_recovery": scenario_sync_file_recovery,
|
||||
"durable_outbox": scenario_durable_outbox,
|
||||
"courier_contract": scenario_courier_contract,
|
||||
"ptt_dm": scenario_ptt_dm,
|
||||
"ptt_broadcast": scenario_ptt_broadcast,
|
||||
# Broadcast transfers are receiver-capped at 256 fragments (~120 KB); only
|
||||
@ -893,6 +1267,11 @@ SCENARIOS = {
|
||||
"identity_reset": scenario_identity_reset,
|
||||
}
|
||||
|
||||
# End-to-end peer courier delivery needs distinct sender, courier, and recipient identities.
|
||||
TRIAD_SCENARIOS = {
|
||||
"courier_delivery": scenario_courier_delivery,
|
||||
}
|
||||
|
||||
# Scenarios supported when device B is a watch (file scenarios are receive-only: phone sends,
|
||||
# the watch must receive with matching digests).
|
||||
WATCH_SCENARIOS = [
|
||||
@ -909,9 +1288,16 @@ WATCH_SCENARIOS = [
|
||||
]
|
||||
|
||||
|
||||
def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict:
|
||||
def run_scenario(
|
||||
name: str,
|
||||
a: Device,
|
||||
b: Device,
|
||||
out: Path | None,
|
||||
c: Device | None = None,
|
||||
) -> dict:
|
||||
started = time.time()
|
||||
evidence: dict[str, object] = {"scenario": name, "devices": [a.alias, b.alias]}
|
||||
devices = [a, b] + ([c] if c is not None else [])
|
||||
evidence: dict[str, object] = {"scenario": name, "devices": [device.alias for device in devices]}
|
||||
try:
|
||||
supported = WATCH_SCENARIOS if isinstance(b, WatchDevice) else list(SCENARIOS)
|
||||
if name == "all":
|
||||
@ -922,9 +1308,20 @@ def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict:
|
||||
results[n] = sub.get("results", {"error": sub.get("error", "unknown")})
|
||||
if sub["status"] != "pass":
|
||||
failures.append(n)
|
||||
if c is not None:
|
||||
sub = run_scenario("courier_delivery", a, b, out, c)
|
||||
results["courier_delivery"] = sub.get("results", {"error": sub.get("error", "unknown")})
|
||||
if sub["status"] != "pass":
|
||||
failures.append("courier_delivery")
|
||||
evidence["results"] = results
|
||||
if failures:
|
||||
raise MeshLabError(f"sub-scenarios failed: {', '.join(failures)}")
|
||||
elif name in TRIAD_SCENARIOS:
|
||||
if c is None:
|
||||
raise MeshLabError(f"scenario '{name}' requires --serial-c")
|
||||
if isinstance(b, WatchDevice):
|
||||
raise MeshLabError(f"scenario '{name}' requires three phones")
|
||||
evidence["results"] = TRIAD_SCENARIOS[name](a, b, c)
|
||||
elif name not in supported:
|
||||
raise MeshLabError(f"scenario '{name}' is not supported on device '{b.alias}'")
|
||||
else:
|
||||
@ -933,7 +1330,7 @@ def run_scenario(name: str, a: Device, b: Device, out: Path | None) -> dict:
|
||||
except (MeshLabError, AssertionError) as error:
|
||||
evidence["status"] = "fail"
|
||||
evidence["error"] = str(error)
|
||||
evidence["logcat"] = {d.alias: d.logcat_dump() for d in (a, b)}
|
||||
evidence["logcat"] = {d.alias: d.logcat_dump() for d in devices}
|
||||
evidence["duration_s"] = round(time.time() - started, 1)
|
||||
if out is not None:
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
@ -950,16 +1347,19 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
setup = commands.add_parser("setup", help="install, grant, launch, nickname, discover")
|
||||
setup.add_argument("--serial-a", required=True)
|
||||
setup.add_argument("--serial-b")
|
||||
setup.add_argument("--serial-c", help="third phone for a three-party scenario")
|
||||
setup.add_argument("--serial-watch", help="watch serial; used as device B (overrides --serial-b)")
|
||||
setup.add_argument("--apk", type=Path, default=None)
|
||||
setup.add_argument("--watch-apk", type=Path, default=None)
|
||||
setup.add_argument("--nickname-a", default="alice")
|
||||
setup.add_argument("--nickname-b", default="bob")
|
||||
setup.add_argument("--nickname-c", default="charlie")
|
||||
|
||||
scenario = commands.add_parser("scenario", help="run a test scenario on two devices")
|
||||
scenario.add_argument("name", choices=[*SCENARIOS.keys(), "all"])
|
||||
scenario = commands.add_parser("scenario", help="run a test scenario on two phones, or three for courier delivery")
|
||||
scenario.add_argument("name", choices=[*SCENARIOS.keys(), *TRIAD_SCENARIOS.keys(), "all"])
|
||||
scenario.add_argument("--serial-a", required=True)
|
||||
scenario.add_argument("--serial-b")
|
||||
scenario.add_argument("--serial-c", help="third phone; required for courier_delivery")
|
||||
scenario.add_argument("--serial-watch", help="watch serial; used as device B (overrides --serial-b)")
|
||||
scenario.add_argument("--out", type=Path, default=None, help="evidence output directory")
|
||||
|
||||
@ -972,30 +1372,36 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
return parser
|
||||
|
||||
|
||||
def _resolve_devices(args: argparse.Namespace) -> tuple[Device, Device]:
|
||||
def _resolve_devices(args: argparse.Namespace) -> tuple[Device, Device, Device | None]:
|
||||
"""Device A is always the phone; device B is a watch when --serial-watch is given."""
|
||||
a = Device(args.serial_a, "alpha")
|
||||
if getattr(args, "serial_watch", None):
|
||||
return a, WatchDevice(args.serial_watch)
|
||||
if getattr(args, "serial_c", None):
|
||||
raise MeshLabError("--serial-c cannot be combined with --serial-watch")
|
||||
return a, WatchDevice(args.serial_watch), None
|
||||
if not getattr(args, "serial_b", None):
|
||||
raise MeshLabError("either --serial-b or --serial-watch is required")
|
||||
return a, Device(args.serial_b, "beta")
|
||||
c = Device(args.serial_c, "charlie") if getattr(args, "serial_c", None) else None
|
||||
return a, Device(args.serial_b, "beta"), c
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
if args.command == "setup":
|
||||
a, b = _resolve_devices(args)
|
||||
a, b, c = _resolve_devices(args)
|
||||
nickname_b = "watch" if isinstance(b, WatchDevice) and args.nickname_b == "bob" else args.nickname_b
|
||||
setup_pair(
|
||||
a, b, args.apk, args.nickname_a, nickname_b,
|
||||
apk_b=args.watch_apk if isinstance(b, WatchDevice) else None,
|
||||
)
|
||||
if c is not None:
|
||||
setup_triad(a, b, c, args.apk, args.nickname_a, nickname_b, args.nickname_c)
|
||||
else:
|
||||
setup_pair(
|
||||
a, b, args.apk, args.nickname_a, nickname_b,
|
||||
apk_b=args.watch_apk if isinstance(b, WatchDevice) else None,
|
||||
)
|
||||
print(json.dumps({"status": "ok", "step": "setup"}))
|
||||
elif args.command == "scenario":
|
||||
a, b = _resolve_devices(args)
|
||||
evidence = run_scenario(args.name, a, b, args.out)
|
||||
a, b, c = _resolve_devices(args)
|
||||
evidence = run_scenario(args.name, a, b, args.out, c)
|
||||
print(json.dumps(evidence, indent=2, default=str))
|
||||
return 0 if evidence["status"] == "pass" else 1
|
||||
elif args.command == "cmd":
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user