diff --git a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt index b15a6254..8f078a3d 100644 --- a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt @@ -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 = + noiseService.openCourierPayload(ciphertext) /** * Get our signing public key for Ed25519 signatures (for identity announcements) diff --git a/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt index 279fe527..d9276151 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BLEPacketPaddingPolicy.kt @@ -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 } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt index 0e4f29af..a8649bd5 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt @@ -367,6 +367,16 @@ class BluetoothConnectionManager( ) } + suspend fun sendToPeerAndAwaitAcceptance(peerID: String, routed: RoutedPacket): Boolean { + if (!isActive || !isBleTransportEnabled()) return false + return packetBroadcaster.sendPacketToPeerAndAwaitAcceptance( + routed, + peerID, + serverManager.getGattServer(), + serverManager.getCharacteristic() + ) + } + fun cancelTransfer(transferId: String): Boolean { return packetBroadcaster.cancelTransfer(transferId) } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 22f17867..99a2c886 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -49,6 +49,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic // Core components - each handling specific responsibilities private val encryptionService = EncryptionService(context) + private val courierStore = CourierStore(context) + private val bridgeCourierService by lazy { + com.bitchat.android.nostr.BridgeCourierService(context, encryptionService) { envelope -> + handleLocalCourierEnvelope(envelope) + } + } // My peer identification - derived from persisted Noise identity fingerprint (first 16 hex chars) val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) @@ -156,10 +162,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic gossipSyncManager = GossipSyncManager( myPeerID = myPeerID, scope = serviceScope, + context = context, configProvider = object : GossipSyncManager.ConfigProvider { override fun seenCapacity(): Int = try { - com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(500) - } catch (_: Exception) { 500 } + com.bitchat.android.ui.debug.DebugPreferenceManager.getSeenPacketCapacity(1000) + } catch (_: Exception) { 1000 } override fun gcsMaxBytes(): Int = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getGcsMaxFilterBytes(400) @@ -177,6 +184,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic if (isBleTransportEnabled()) { TransportBridgeService.register("BLE", this) } + bridgeCourierService.start() // Inject dynamic direct connection check into PeerManager // Matches iOS logic: checks if we have an active hardware mapping for this peer @@ -200,6 +208,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic connectionManager.sendPacketToPeer(peerID, packet) } + override fun sendToPeerAndReport(peerID: String, packet: BitchatPacket): Boolean { + if (!isBleTransportEnabled()) return false + return connectionManager.sendPacketToPeer(peerID, packet) + } + private fun broadcastRoutedPacket(routed: RoutedPacket): Boolean { if (!isBleTransportEnabled()) return false val queued = connectionManager.broadcastPacket(routed) @@ -514,6 +527,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } override fun onDeliveryAckReceived(messageID: String, peerID: String) { + try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageID, peerID) } catch (_: Exception) { } // Status events can arrive while MainActivity has detached the UI delegate. // Persist first so the next UI collector observes the advancement. try { @@ -526,6 +540,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } override fun onReadReceiptReceived(messageID: String, peerID: String) { + try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.onMessageAcknowledged(messageID, peerID) } catch (_: Exception) { } try { com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus( messageID, @@ -574,6 +589,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean { return runBlocking { messageHandler.handleNoiseEncrypted(routed) } } + + override fun handleCourierEnvelope(routed: RoutedPacket): Boolean { + return handleCourierEnvelopePacket(routed) + } override suspend fun handleAnnounce(routed: RoutedPacket): Boolean { val result = messageHandler.handleAnnounceWithResult(routed) @@ -599,6 +618,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } } try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { } + handleCourierAnnounce(routed) return true } @@ -608,7 +628,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic try { val pkt = routed.packet val isBroadcast = (pkt.recipientID == null || pkt.recipientID.contentEquals(SpecialRecipients.BROADCAST)) - if (isBroadcast && pkt.type == MessageType.MESSAGE.value) { + if (isBroadcast && pkt.type in setOf(MessageType.MESSAGE.value, MessageType.FILE_TRANSFER.value)) { gossipSyncManager.onPublicPacketSeen(pkt) } } catch (_: Exception) { } @@ -809,6 +829,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic * Stop all mesh services */ fun stopServices() { + if (bridgeCourierService.isStarted) bridgeCourierService.stop() if (!isActive) { Log.w(TAG, "Mesh service not active, ignoring stop request") return @@ -1442,6 +1463,196 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic return peerManager.getPeerInfo(peerID) } + fun getPeerInfos(): List = peerManager.getAllPeerNicknames().keys.mapNotNull(peerManager::getPeerInfo) + + fun sendCourierMessage( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + courierPeerIDs: List + ): List { + val privateMessage = com.bitchat.android.model.PrivateMessagePacket(messageID, content).encode() ?: return emptyList() + val typedPayload = com.bitchat.android.model.NoisePayload( + com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE, + privateMessage + ).encode() + val sealed = try { encryptionService.sealCourierPayload(typedPayload, recipientNoiseKey) } catch (_: Exception) { return emptyList() } + val now = System.currentTimeMillis() + val couriers = courierPeerIDs.distinct().take(4) + if (couriers.isEmpty()) return emptyList() + return couriers.filter { courierID -> + val envelope = com.bitchat.android.model.CourierEnvelope( + recipientTag = com.bitchat.android.model.CourierEnvelope.recipientTag( + recipientNoiseKey, + com.bitchat.android.model.CourierEnvelope.epochDay(now) + ), + expiry = (now + com.bitchat.android.model.CourierEnvelope.MAX_LIFETIME_MS).toULong(), + ciphertext = sealed, + copies = 4u + ) + val payload = envelope.encode() ?: return@filter false + val packet = BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(courierID), + timestamp = now.toULong(), + payload = payload, + ttl = MAX_TTL + ) + TransportBridgeService.sendToPeerFromLocalAndReport(courierID, signPacketBeforeBroadcast(packet)) + } + } + + fun sendBridgeCourierMessage( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + onAccepted: () -> Unit = {} + ): Boolean = bridgeCourierService.deposit(content, messageID, recipientNoiseKey, onAccepted) + + private fun handleLocalCourierEnvelope(envelope: com.bitchat.android.model.CourierEnvelope) { + val packet = BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = ByteArray(8), + recipientID = hexStringToByteArray(myPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = envelope.encode() ?: return, + ttl = MAX_TTL + ) + handleCourierEnvelopePacket(RoutedPacket(packet, peerID = "bridge")) + } + + private fun handleCourierEnvelopePacket(routed: RoutedPacket): Boolean { + val envelope = com.bitchat.android.model.CourierEnvelope.decode(routed.packet.payload) ?: return false + val now = System.currentTimeMillis() + if (envelope.expiry.toLong() <= now) return false + val localKey = encryptionService.getStaticPublicKey() ?: return false + if (envelope.matchesRecipient(localKey, now)) { + val (senderKey, typedPayload) = try { encryptionService.openCourierPayload(envelope.ciphertext) } catch (_: Exception) { return false } + val noisePayload = com.bitchat.android.model.NoisePayload.decode(typedPayload) ?: return false + if (noisePayload.type !in setOf( + com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE, + com.bitchat.android.model.NoisePayloadType.DELIVERED + ) + ) return false + val senderPeerID = com.bitchat.android.services.ContactIdentityResolver.peerIdForNoiseKey(senderKey) + val synthetic = routed.copy( + packet = routed.packet.copy( + type = MessageType.NOISE_ENCRYPTED.value, + senderID = hexStringToByteArray(senderPeerID), + payload = typedPayload, + timestamp = System.currentTimeMillis().toULong() + ), + peerID = senderPeerID + ) + val delivered = runBlocking { messageHandler.handleOpenedCourierPayload(synthetic) } + if (delivered && noisePayload.type == com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) { + val messageID = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data)?.messageID + if (messageID != null) sendCourierDeliveryAck(messageID, senderKey, routed) + } + return delivered + } + val peerID = routed.peerID ?: return false + if (!DirectCourierDepositPolicy.accepts( + routed, + MAX_TTL, + connectionManager::getCurrentLinkID, + connectionManager.addressPeerMap::get + ) + ) return false + val depositor = peerManager.getPeerInfo(peerID) ?: return false + val key = depositor.noisePublicKey ?: return false + val favorite = try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(key)?.isMutual == true + } catch (_: Exception) { false } + val tier = if (favorite) CourierDepositTier.FAVORITE + else if (depositor.hasVerifiedAnnouncement) CourierDepositTier.VERIFIED + else return false + return courierStore.deposit(envelope, key, tier) + } + + private fun sendCourierDeliveryAck(messageID: String, senderNoiseKey: ByteArray, ingress: RoutedPacket) { + val typedPayload = com.bitchat.android.model.NoisePayload( + com.bitchat.android.model.NoisePayloadType.DELIVERED, + messageID.toByteArray(Charsets.UTF_8) + ).encode() + if (ingress.peerID == "bridge") { + bridgeCourierService.depositPayload(typedPayload, senderNoiseKey) + return + } + val courierPeerID = ingress.peerID ?: return + val now = System.currentTimeMillis() + val sealed = try { encryptionService.sealCourierPayload(typedPayload, senderNoiseKey) } catch (_: Exception) { return } + val envelope = com.bitchat.android.model.CourierEnvelope( + recipientTag = com.bitchat.android.model.CourierEnvelope.recipientTag( + senderNoiseKey, + com.bitchat.android.model.CourierEnvelope.epochDay(now) + ), + expiry = (now + com.bitchat.android.model.CourierEnvelope.MAX_LIFETIME_MS).toULong(), + ciphertext = sealed, + copies = 4u + ) + val packet = BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(courierPeerID), + timestamp = now.toULong(), + payload = envelope.encode() ?: return, + ttl = MAX_TTL + ) + TransportBridgeService.sendToPeerFromLocalAndReport(courierPeerID, signPacketBeforeBroadcast(packet)) + } + + private fun handleCourierAnnounce(routed: RoutedPacket) { + val peerID = routed.peerID ?: return + val info = peerManager.getPeerInfo(peerID) ?: return + val noiseKey = info.noisePublicKey ?: return + val direct = routed.packet.ttl >= MAX_TTL + val envelopes = if (direct) courierStore.copiesForRecipient(noiseKey) else courierStore.copiesForRemoteHandover(noiseKey) + envelopes.forEach { envelope -> + val packet = BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = envelope.encode() ?: return@forEach, + ttl = MAX_TTL + ) + if (direct) { + serviceScope.launch { + if (connectionManager.sendToPeerAndAwaitAcceptance( + peerID, + RoutedPacket(signPacketBeforeBroadcast(packet)) + ) + ) { + courierStore.remove(envelope) + } + } + } else TransportBridgeService.broadcastFromLocal(RoutedPacket(signPacketBeforeBroadcast(packet))) + } + if (direct) { + courierStore.sprayCopiesFor(noiseKey).forEach { envelope -> + val packet = BitchatPacket( + type = MessageType.COURIER_ENVELOPE.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(peerID), + timestamp = System.currentTimeMillis().toULong(), + payload = envelope.encode() ?: return@forEach, + ttl = MAX_TTL + ) + serviceScope.launch { + if (connectionManager.sendToPeerAndAwaitAcceptance( + peerID, + RoutedPacket(signPacketBeforeBroadcast(packet)) + ) + ) { + courierStore.commitSpray(envelope, noiseKey) + } + } + } + } + } + /** * Update peer information with verification data */ @@ -1620,19 +1831,21 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic */ fun clearAllInternalData() { Log.w(TAG, "Clearing all mesh service internal data") - try { - // Stop services to cease broadcasting old ID immediately - stopServices() - - // Clear all managers - fragmentManager.clearAllFragments() - storeForwardManager.clearAllCache() - securityManager.clearAllData() - peerManager.clearAllPeers() - peerManager.clearAllFingerprints() - try { gossipSyncManager.clear() } catch (_: Exception) { } - } catch (e: Exception) { - Log.e(TAG, "Error clearing mesh service internal data: ${e.message}") + val operations = listOf<() -> Unit>( + ::stopServices, + fragmentManager::clearAllFragments, + storeForwardManager::clearAllCache, + securityManager::clearAllData, + peerManager::clearAllPeers, + peerManager::clearAllFingerprints, + courierStore::wipe, + bridgeCourierService::stop, + gossipSyncManager::clear + ) + operations.forEach { operation -> + try { operation() } catch (e: Exception) { + Log.e(TAG, "Error clearing mesh service internal data: ${e.message}") + } } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt index 9ff9856d..5a9babca 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt @@ -204,6 +204,18 @@ class BluetoothPacketBroadcaster( } } + suspend fun sendPacketToPeerAndAwaitAcceptance( + routed: RoutedPacket, + targetPeerID: String, + gattServer: BluetoothGattServer?, + characteristic: BluetoothGattCharacteristic? + ): Boolean { + if (!hasPeerConnection(targetPeerID)) return false + return fragmentingSender.sendAndAwaitAcceptance(routed, "BLE peer ${targetPeerID.take(8)}") { packet -> + sendSinglePacketToPeer(packet, targetPeerID, gattServer, characteristic) + } + } + fun sendPacketToLink( routed: RoutedPacket, deviceAddress: String, diff --git a/app/src/main/java/com/bitchat/android/mesh/CourierStore.kt b/app/src/main/java/com/bitchat/android/mesh/CourierStore.kt new file mode 100644 index 00000000..b63afdb3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/CourierStore.kt @@ -0,0 +1,193 @@ +package com.bitchat.android.mesh + +import android.content.Context +import com.bitchat.android.model.CourierEnvelope +import com.bitchat.android.services.AndroidConversationStorageCipher +import com.bitchat.android.services.ConversationStorageCipher +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.io.File +import java.util.Base64 +import java.nio.file.StandardCopyOption + +enum class CourierDepositTier { FAVORITE, VERIFIED } + +/** Bounded persistent mailbag for opaque envelopes deposited by other peers. */ +internal class CourierStore( + context: Context, + private val cipher: ConversationStorageCipher = AndroidConversationStorageCipher(KEY_ALIAS), + private val now: () -> Long = System::currentTimeMillis +) { + private data class Stored( + val encoded: String, + val depositorKey: String, + val tier: CourierDepositTier, + var copies: Int, + val sprayedTo: MutableSet = mutableSetOf(), + var lastRemoteHandoverAtMs: Long = 0 + ) + + companion object { + private const val KEY_ALIAS = "bitchat_courier_store_v1" + private val AAD = "bitchat-courier-store-v1".toByteArray(Charsets.UTF_8) + private const val MAX_ENVELOPES = 40 + private const val MAX_VERIFIED_ENVELOPES = 20 + private const val MAX_PER_FAVORITE = 5 + private const val MAX_PER_VERIFIED = 2 + private const val EXPIRY_SLACK_MS = 60 * 60 * 1000L + private const val REMOTE_HANDOVER_COOLDOWN_MS = 10 * 60 * 1000L + } + + private val gson = Gson() + private val file = File(context.applicationContext.filesDir, "courier-store.sealed") + private val stored = load() + + @Synchronized + fun deposit(envelope: CourierEnvelope, depositorNoiseKey: ByteArray, tier: CourierDepositTier): Boolean { + pruneExpired() + val nowMs = now() + if (envelope.expiry.toLong() <= nowMs || + envelope.expiry.toLong() > nowMs + CourierEnvelope.MAX_LIFETIME_MS + EXPIRY_SLACK_MS + ) return false + val encodedBytes = envelope.encode() ?: return false + val encoded = Base64.getEncoder().encodeToString(encodedBytes) + if (stored.any { it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true }) return true + val depositor = depositorNoiseKey.toHex() + val perDepositor = if (tier == CourierDepositTier.FAVORITE) MAX_PER_FAVORITE else MAX_PER_VERIFIED + if (stored.count { it.depositorKey == depositor && it.tier == tier } >= perDepositor) return false + if (tier == CourierDepositTier.VERIFIED && stored.count { it.tier == tier } >= MAX_VERIFIED_ENVELOPES) { + stored.removeAt(stored.indexOfFirst { it.tier == CourierDepositTier.VERIFIED }) + } + if (stored.size >= MAX_ENVELOPES) { + val verifiedIndex = stored.indexOfFirst { it.tier == CourierDepositTier.VERIFIED } + if (tier == CourierDepositTier.VERIFIED && verifiedIndex < 0) return false + val index = verifiedIndex.takeIf { it >= 0 } ?: 0 + stored.removeAt(index) + } + stored += Stored(encoded, depositor, tier, envelope.copies.toInt()) + persist() + return true + } + + @Synchronized + fun copiesForRecipient(recipientNoiseKey: ByteArray): List { + pruneExpired() + val nowMs = now() + return stored.mapNotNull { record -> + record.envelope() + ?.takeIf { it.matchesRecipient(recipientNoiseKey, nowMs) } + ?.copy(copies = 1u) + } + } + + @Synchronized + fun remove(envelope: CourierEnvelope): Boolean { + val removed = stored.removeAll { + it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true + } + if (removed) persist() + return removed + } + + @Synchronized + fun copiesForRemoteHandover(recipientNoiseKey: ByteArray): List { + pruneExpired() + val nowMs = now() + val result = mutableListOf() + 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 { + 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() + stored.forEach { record -> + val envelope = record.envelope() ?: return@forEach + if (record.copies <= 1 || record.depositorKey == key || key in record.sprayedTo || + courierTags.any { it.contentEquals(envelope.recipientTag) } + ) return@forEach + val given = record.copies / 2 + result += envelope.copy(copies = given.toUByte()) + } + return result + } + + @Synchronized + fun commitSpray(envelope: CourierEnvelope, courierNoiseKey: ByteArray): Boolean { + val key = courierNoiseKey.toHex() + val record = stored.firstOrNull { + it.envelope()?.ciphertext?.contentEquals(envelope.ciphertext) == true + } ?: return false + if (record.copies <= 1 || key in record.sprayedTo) return false + val given = record.copies / 2 + if (given != envelope.copies.toInt()) return false + record.copies -= given + record.sprayedTo += key + persist() + return true + } + + @Synchronized + fun wipe() { + stored.clear() + file.delete() + cipher.destroyKey() + } + + private fun pruneExpired() { + val nowMs = now().toULong() + if (stored.removeAll { (it.envelope()?.expiry ?: 0u) <= nowMs }) persist() + } + + private fun Stored.envelope(): CourierEnvelope? = try { + CourierEnvelope.decode(Base64.getDecoder().decode(encoded)) + } catch (_: Exception) { null } + + private fun load(): MutableList = try { + if (!file.exists()) return mutableListOf() + val plaintext = cipher.decrypt(file.readBytes(), AAD) + val type = object : TypeToken>() {}.type + gson.fromJson>(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) } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/DirectCourierDepositPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/DirectCourierDepositPolicy.kt new file mode 100644 index 00000000..188872b9 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/DirectCourierDepositPolicy.kt @@ -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 + } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt index 9c6bd15e..8fc0bf5c 100644 --- a/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt +++ b/app/src/main/java/com/bitchat/android/mesh/FragmentingPacketSender.kt @@ -112,6 +112,23 @@ class FragmentingPacketSender( return true } + suspend fun sendAndAwaitAcceptance( + routed: RoutedPacket, + description: String, + sendSingle: (RoutedPacket) -> Boolean + ): Boolean { + val packets = packetsForTransport(routed) ?: return false + Log.d(logTag, "Sending ${packets.size} packet(s) for $description") + for ((index, packet) in packets.withIndex()) { + val accepted = sendSingle( + routed.copy(packet = packet, transferId = null, preparedPackets = null) + ) + if (!accepted) return false + if (index < packets.lastIndex) delay(interFragmentDelayMs) + } + return true + } + fun cancelTransfer(transferId: String): Boolean { val job = transferJobs.remove(transferId) ?: return false job.cancel() diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index 28cd3d02..aac2a880 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -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) { } diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt index 7b357149..65d88ba0 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -41,6 +41,19 @@ interface MeshService { fun initiateNoiseHandshake(peerID: String) fun getPeerFingerprint(peerID: String): String? fun getPeerInfo(peerID: String): PeerInfo? + fun getPeerInfos(): List = getPeerNicknames().keys.mapNotNull(::getPeerInfo) + fun sendCourierMessage( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + courierPeerIDs: List + ): List = emptyList() + fun sendBridgeCourierMessage( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + onAccepted: () -> Unit = {} + ): Boolean = false fun updatePeerInfo( peerID: String, nickname: String, diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index 9434fc6e..97d07b88 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -207,6 +207,32 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro return true } + /** Admit an already authenticated Noise X payload through the normal private-message path. */ + suspend fun handleOpenedCourierPayload(routed: RoutedPacket): Boolean { + val packet = routed.packet + val peerID = routed.peerID ?: return false + val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return false + if (noisePayload.type == com.bitchat.android.model.NoisePayloadType.DELIVERED) { + val messageID = noisePayload.data.toString(Charsets.UTF_8) + if (messageID.isBlank()) return false + delegate?.onDeliveryAckReceived(messageID, peerID) + return true + } + if (noisePayload.type != com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) return false + val privateMessage = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) ?: return false + val message = BitchatMessage( + id = privateMessage.messageID, + sender = delegate?.getPeerNickname(peerID) ?: "Unknown", + content = privateMessage.content, + timestamp = Date(packet.timestamp.toLong()), + isPrivate = true, + recipientNickname = delegate?.getMyNickname(), + senderPeerID = peerID + ) + delegate?.onMessageReceived(message) + return true + } + /** * Count consecutive decrypt failures from a signature-verified peer that we still hold an * established session for. After repeated failures the session is stale (the peer completed @@ -453,6 +479,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro private suspend fun handleBroadcastMessage(routed: RoutedPacket) { val packet = routed.packet val peerID = routed.peerID ?: "unknown" + if (packet.timestamp > Long.MAX_VALUE.toULong()) return + val ageMs = System.currentTimeMillis() - packet.timestamp.toLong() + if (ageMs !in 0..com.bitchat.android.sync.GossipSyncManager.PUBLIC_MESSAGE_MAX_AGE_MS) return // Enforce: only accept public messages from verified peers we know val peerInfo = delegate?.getPeerInfo(peerID) diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index 37a295e8..b111433c 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -122,6 +122,20 @@ class PacketProcessor(private val myPeerID: String) { var validPacket = true val messageType = MessageType.fromValue(packet.type) + val isBroadcast = packet.recipientID == null || + packet.recipientID.contentEquals(com.bitchat.android.protocol.SpecialRecipients.BROADCAST) + if (isBroadcast && packet.timestamp <= Long.MAX_VALUE.toULong()) { + val ageMs = System.currentTimeMillis() - packet.timestamp.toLong() + val maxAgeMs = when (messageType) { + MessageType.MESSAGE -> com.bitchat.android.sync.GossipSyncManager.PUBLIC_MESSAGE_MAX_AGE_MS + MessageType.FRAGMENT, MessageType.FILE_TRANSFER -> + com.bitchat.android.sync.GossipSyncManager.FRAGMENT_MAX_AGE_MS + else -> null + } + if (maxAgeMs != null && ageMs !in 0..maxAgeMs) return + } else if (isBroadcast && packet.timestamp > Long.MAX_VALUE.toULong()) { + return + } // Verbose logging to debug manager (and chat via ChatViewModel observer) try { val mt = messageType?.name ?: packet.type.toString() @@ -146,6 +160,7 @@ class PacketProcessor(private val myPeerID: String) { when (messageType) { MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed) MessageType.NOISE_ENCRYPTED -> validPacket = handleNoiseEncrypted(routed) + MessageType.COURIER_ENVELOPE -> validPacket = delegate?.handleCourierEnvelope(routed) ?: false MessageType.FILE_TRANSFER -> handleMessage(routed) else -> { validPacket = false @@ -295,6 +310,7 @@ interface PacketProcessorDelegate { // Message type handlers fun handleNoiseHandshake(routed: RoutedPacket): Boolean fun handleNoiseEncrypted(routed: RoutedPacket): Boolean + fun handleCourierEnvelope(routed: RoutedPacket): Boolean = false suspend fun handleAnnounce(routed: RoutedPacket): Boolean fun handleMessage(routed: RoutedPacket) fun handleVoiceFrame(routed: RoutedPacket): Boolean = false diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index 3f684679..40cacbda 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -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 )) { diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index 3efe758d..054ca25e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -116,6 +116,22 @@ class UnifiedMeshService( } } + override fun getPeerInfos(): List = bluetooth.getPeerInfos() + + override fun sendCourierMessage( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + courierPeerIDs: List + ): List = 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) diff --git a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt index 528a269e..a70fc069 100644 --- a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt +++ b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt @@ -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 } } - diff --git a/app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt b/app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt new file mode 100644 index 00000000..8f8cea10 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt @@ -0,0 +1,99 @@ +package com.bitchat.android.model + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** Opaque store-and-forward envelope, wire-compatible with iOS CourierEnvelope. */ +data class CourierEnvelope( + val recipientTag: ByteArray, + val expiry: ULong, + val ciphertext: ByteArray, + val copies: UByte = 1u +) { + companion object { + const val TAG_LENGTH = 16 + const val MAX_CIPHERTEXT_BYTES = 16 * 1024 + const val MAX_LIFETIME_MS = 24 * 60 * 60 * 1000L + const val MAX_COPIES = 8 + private val TAG_DOMAIN = "bitchat-courier-tag-v1".toByteArray(Charsets.UTF_8) + + fun epochDay(nowMs: Long): UInt = Math.floorDiv(nowMs, 86_400_000L).toUInt() + + fun recipientTag(noiseStaticKey: ByteArray, epochDay: UInt): ByteArray { + require(noiseStaticKey.size == 32) + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(noiseStaticKey, "HmacSHA256")) + mac.update(TAG_DOMAIN) + mac.update(ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(epochDay.toInt()).array()) + return mac.doFinal().copyOf(TAG_LENGTH) + } + + fun decode(data: ByteArray): CourierEnvelope? { + var offset = 0 + var tag: ByteArray? = null + var expiry: ULong? = null + var ciphertext: ByteArray? = null + var copies: UByte = 1u + while (offset + 3 <= data.size) { + val type = data[offset].toUByte() + val length = ((data[offset + 1].toInt() and 0xff) shl 8) or + (data[offset + 2].toInt() and 0xff) + offset += 3 + if (offset + length > data.size) return null + val value = data.copyOfRange(offset, offset + length) + offset += length + when (type.toInt()) { + 1 -> if (length == TAG_LENGTH && tag == null) tag = value else return null + 2 -> if (length == 8 && expiry == null) expiry = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).long.toULong() else return null + 3 -> if (ciphertext == null) ciphertext = value else return null + 4 -> { + if (length != 1 || copies != 1u.toUByte()) return null + copies = value[0].toUByte() + if (copies !in 2u.toUByte()..MAX_COPIES.toUByte()) return null + } + } + } + if (offset != data.size) return null + val requiredTag = tag ?: return null + val requiredExpiry = expiry ?: return null + val requiredCiphertext = ciphertext ?: return null + if (requiredCiphertext.isEmpty() || requiredCiphertext.size > MAX_CIPHERTEXT_BYTES) return null + return CourierEnvelope(requiredTag, requiredExpiry, requiredCiphertext, copies) + } + } + + fun encode(): ByteArray? { + if (recipientTag.size != TAG_LENGTH || ciphertext.isEmpty() || ciphertext.size > MAX_CIPHERTEXT_BYTES || + copies !in 1u.toUByte()..MAX_COPIES.toUByte() + ) return null + val fields = mutableListOf>() + fields += 1 to recipientTag + fields += 2 to ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN).putLong(expiry.toLong()).array() + fields += 3 to ciphertext + if (copies > 1u) fields += 4 to byteArrayOf(copies.toByte()) + val size = fields.sumOf { 3 + it.second.size } + val buffer = ByteBuffer.allocate(size).order(ByteOrder.BIG_ENDIAN) + fields.forEach { (type, value) -> + buffer.put(type.toByte()) + buffer.putShort(value.size.toShort()) + buffer.put(value) + } + return buffer.array() + } + + fun matchesRecipient(noiseStaticKey: ByteArray, nowMs: Long): Boolean { + val day = epochDay(nowMs) + return listOf(day - 1u, day, day + 1u).any { + recipientTag.contentEquals(recipientTag(noiseStaticKey, it)) + } + } + + override fun equals(other: Any?): Boolean = other is CourierEnvelope && + recipientTag.contentEquals(other.recipientTag) && expiry == other.expiry && + ciphertext.contentEquals(other.ciphertext) && copies == other.copies + + override fun hashCode(): Int = 31 * (31 * recipientTag.contentHashCode() + expiry.hashCode()) + + 31 * ciphertext.contentHashCode() + copies.hashCode() +} diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt index 909ca82b..254a7b62 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt @@ -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 { + 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) diff --git a/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt b/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt new file mode 100644 index 00000000..ad3a95bc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/BridgeCourierService.kt @@ -0,0 +1,148 @@ +package com.bitchat.android.nostr + +import android.content.Context +import android.util.Base64 +import com.bitchat.android.crypto.EncryptionService +import com.bitchat.android.model.CourierEnvelope +import java.util.Collections +import java.util.LinkedHashMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** Parks opaque courier envelopes on default Nostr relays using the iOS kind-1401 contract. */ +class BridgeCourierService( + context: Context, + private val encryptionService: EncryptionService, + private val onEnvelope: (CourierEnvelope) -> Unit +) { + companion object { + private const val KIND = 1401 + private const val MAX_ENCODED_BYTES = 20 * 1024 + private const val TAG_REFRESH_INTERVAL_MS = 60 * 60 * 1000L + } + + private val relayManager = NostrRelayManager.getInstance(context.applicationContext) + private val seenEvents = Collections.synchronizedMap( + object : LinkedHashMap(512, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = size > 512 + } + ) + private val subscriptionID = "bridge-courier-drops-${System.identityHashCode(this)}" + @Volatile private var subscribedDay: UInt? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var refreshJob: Job? = null + val isStarted: Boolean get() = subscribedDay != null + + @Synchronized + fun start() { + val localKey = encryptionService.getStaticPublicKey() ?: ByteArray(0) + if (localKey.size == 32) { + val day = CourierEnvelope.epochDay(System.currentTimeMillis()) + if (subscribedDay == day) return + if (subscribedDay != null) relayManager.unsubscribe(subscriptionID) + val tags = listOf(day - 1u, day, day + 1u).map { + CourierEnvelope.recipientTag(localKey, it).toHex() + } + val filter = NostrFilter.Builder() + .kinds(KIND) + .since(System.currentTimeMillis() - CourierEnvelope.MAX_LIFETIME_MS) + .limit(100) + .tag("x", *tags.toTypedArray()) + .build() + relayManager.subscribe( + filter = filter, + id = subscriptionID, + targetRelayUrls = NostrRelayManager.defaultRelays(), + handler = ::handleEvent + ) + subscribedDay = day + if (refreshJob?.isActive != true) { + refreshJob = scope.launch { + while (isActive) { + delay(TAG_REFRESH_INTERVAL_MS) + start() + } + } + } + } + } + + fun deposit( + content: String, + messageID: String, + recipientNoiseKey: ByteArray, + onAccepted: () -> Unit = {} + ): Boolean { + val privateMessage = com.bitchat.android.model.PrivateMessagePacket(messageID, content).encode() ?: return false + val typed = com.bitchat.android.model.NoisePayload( + com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE, + privateMessage + ).encode() + return depositPayload(typed, recipientNoiseKey, onAccepted) + } + + fun depositPayload( + typedPayload: ByteArray, + recipientNoiseKey: ByteArray, + onAccepted: () -> Unit = {} + ): Boolean { + start() + if (!relayManager.hasConnectedRelay(NostrRelayManager.defaultRelays())) return false + val sealed = try { encryptionService.sealCourierPayload(typedPayload, recipientNoiseKey) } catch (_: Exception) { return false } + val now = System.currentTimeMillis() + val envelope = CourierEnvelope( + recipientTag = CourierEnvelope.recipientTag(recipientNoiseKey, CourierEnvelope.epochDay(now)), + expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(), + ciphertext = sealed, + copies = 1u + ) + val encoded = envelope.encode() ?: return false + if (encoded.size > MAX_ENCODED_BYTES) return false + val identity = try { NostrIdentity.generate() } catch (_: Exception) { return false } + val event = identity.signEvent( + NostrEvent( + pubkey = identity.publicKeyHex, + createdAt = (now / 1000).toInt(), + kind = KIND, + tags = listOf( + listOf("x", envelope.recipientTag.toHex()), + listOf("expiration", (envelope.expiry / 1000u).toString()) + ), + content = Base64.encodeToString(encoded, Base64.NO_WRAP) + ) + ) + return relayManager.sendEvent(event, NostrRelayManager.defaultRelays(), onAccepted = onAccepted) + } + + @Synchronized + fun stop() { + relayManager.unsubscribe(subscriptionID) + subscribedDay = null + refreshJob?.cancel() + refreshJob = null + } + + private fun handleEvent(event: NostrEvent) { + if (event.kind != KIND || !event.isValidSignature()) return + synchronized(seenEvents) { if (seenEvents.put(event.id, Unit) != null) return } + if (event.content.length > ((MAX_ENCODED_BYTES + 2) / 3) * 4) return + val encoded = try { Base64.decode(event.content, Base64.DEFAULT) } catch (_: Exception) { return } + if (encoded.size > MAX_ENCODED_BYTES) return + val envelope = CourierEnvelope.decode(encoded) ?: return + val now = System.currentTimeMillis() + if (envelope.expiry.toLong() <= now || envelope.expiry.toLong() > now + CourierEnvelope.MAX_LIFETIME_MS + 60 * 60 * 1000L) return + val eventTag = event.tags.firstOrNull { it.size > 1 && it[0] == "x" }?.get(1) ?: return + val expiration = event.tags.firstOrNull { it.size > 1 && it[0] == "expiration" }?.get(1)?.toULongOrNull() ?: return + if (eventTag != envelope.recipientTag.toHex() || expiration != envelope.expiry / 1000u) return + val localKey = encryptionService.getStaticPublicKey() ?: return + if (!envelope.matchesRecipient(localKey, now)) return + onEnvelope(envelope) + } + + private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 4125d5ca..ac31f76b 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -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, diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt index 92752b17..2cdf75d6 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt @@ -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 } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index 772e2a60..d8e431a8 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -85,6 +85,11 @@ class NostrRelayManager private constructor() { private val _isConnected = MutableStateFlow(false) val isConnected: StateFlow = _isConnected.asStateFlow() + private data class PendingAcceptance( + val callback: () -> Unit, + val pendingRelays: MutableSet + ) + private val eventAcceptanceHandlers = java.util.concurrent.ConcurrentHashMap() // Internal state private val relaysList = mutableListOf() @@ -452,12 +457,14 @@ class NostrRelayManager private constructor() { fun sendEvent( event: NostrEvent, relayUrls: List? = 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): 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() } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt index f39324fd..5dd1d49f 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt @@ -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, diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 9ef9e969..b38ab057 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -13,6 +13,7 @@ enum class MessageType(val value: UByte) { ANNOUNCE(0x01u), MESSAGE(0x02u), // All user messages (private and broadcast) LEAVE(0x03u), + COURIER_ENVELOPE(0x04u), // Opaque Noise X store-and-forward envelope NOISE_HANDSHAKE(0x10u), // Noise handshake NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message FRAGMENT(0x20u), // Fragmentation for large packets diff --git a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt index 67a7a1d8..ab0baeb6 100644 --- a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt +++ b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt @@ -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() @@ -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") diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index c4f52675..d229da3e 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -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 } diff --git a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt index 84420ec0..c2f53c84 100644 --- a/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt +++ b/app/src/main/java/com/bitchat/android/services/ConversationRepository.kt @@ -1667,6 +1667,7 @@ internal class ConversationDatabase( null -> put("delivery_type", 0) DeliveryStatus.Sending -> put("delivery_type", 1) DeliveryStatus.Sent -> put("delivery_type", 2) + DeliveryStatus.Queued -> put("delivery_type", 7) is DeliveryStatus.Delivered -> { put("delivery_type", 3) if (includeSensitiveText) put("delivery_text", status.to) else putNull("delivery_text") @@ -1693,10 +1694,11 @@ internal class ConversationDatabase( null -> 0 is DeliveryStatus.Failed -> 0 DeliveryStatus.Sending -> 1 - DeliveryStatus.Sent -> 2 - is DeliveryStatus.PartiallyDelivered -> 3 - is DeliveryStatus.Delivered -> 4 - is DeliveryStatus.Read -> 5 + DeliveryStatus.Queued -> 2 + DeliveryStatus.Sent -> 3 + is DeliveryStatus.PartiallyDelivered -> 4 + is DeliveryStatus.Delivered -> 5 + is DeliveryStatus.Read -> 6 } private fun Cursor.toMessage(): BitchatMessage { @@ -1764,6 +1766,7 @@ internal class ConversationDatabase( reached = nullableInt("delivery_reached") ?: 0, total = nullableInt("delivery_total") ?: 0 ) + 7 -> DeliveryStatus.Queued else -> null } diff --git a/app/src/main/java/com/bitchat/android/services/MessageOutboxStore.kt b/app/src/main/java/com/bitchat/android/services/MessageOutboxStore.kt new file mode 100644 index 00000000..687de6bc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/MessageOutboxStore.kt @@ -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 = 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> = try { + if (!file.exists()) return mutableMapOf() + val plaintext = cipher.decrypt(file.readBytes(), AAD) + val type = object : TypeToken>>() {}.type + gson.fromJson>>(plaintext.toString(Charsets.UTF_8), type) + ?: mutableMapOf() + } catch (_: Exception) { + mutableMapOf() + } + + @Synchronized + fun save(outbox: Map>) { + 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() + } +} diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt index dc6ad4d2..ee83cf07 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -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>() + private val outboxStore = outboxStoreFactory(context) + private val outbox = ConcurrentHashMap>().apply { + putAll(outboxStore.load()) + } // Per-conversation handshake retry state for queued messages private val retryState = ConcurrentHashMap() @@ -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) + } } } diff --git a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt index 42a10ceb..92fb25c9 100644 --- a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt +++ b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt @@ -8,6 +8,9 @@ import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.SpecialRecipients import kotlinx.coroutines.* import java.util.concurrent.ConcurrentHashMap +import android.content.Context +import java.io.File +import java.nio.file.StandardCopyOption /** * Gossip-based synchronization manager using on-demand GCS filters. @@ -17,7 +20,8 @@ import java.util.concurrent.ConcurrentHashMap class GossipSyncManager( private val myPeerID: String, private val scope: CoroutineScope, - private val configProvider: ConfigProvider + private val configProvider: ConfigProvider, + context: Context? = null ) { interface Delegate { fun sendPacket(packet: BitchatPacket) @@ -33,6 +37,9 @@ class GossipSyncManager( companion object { private const val TAG = "GossipSyncManager" + const val PUBLIC_MESSAGE_MAX_AGE_MS = 6 * 60 * 60 * 1000L + const val FRAGMENT_MAX_AGE_MS = 15 * 60 * 1000L + private const val ARCHIVE_FILE = "gossip-public-history.bin" } var delegate: Delegate? = null @@ -44,11 +51,15 @@ class GossipSyncManager( // Stored packets for sync: // - broadcast messages: keep up to seenCapacity() most recent, keyed by packetId private val messages = LinkedHashMap() + private val fragments = LinkedHashMap() + 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>() private var periodicJob: Job? = null private var cleanupJob: Job? = null + init { restoreArchive() } fun start() { periodicJob?.cancel() periodicJob = scope.launch(Dispatchers.IO) { @@ -84,7 +95,9 @@ class GossipSyncManager( synchronized(messages) { messages.clear() } + synchronized(fragments) { fragments.clear() } latestAnnouncementByPeer.clear() + archiveFile?.delete() Log.d(TAG, "Cleared all gossip sync messages and announcements") } @@ -106,13 +119,20 @@ class GossipSyncManager( // Only ANNOUNCE or broadcast MESSAGE val mt = MessageType.fromValue(packet.type) val isBroadcastMessage = (mt == MessageType.MESSAGE && (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST))) + val isBroadcastFile = mt == MessageType.FILE_TRANSFER && + (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) val isAnnouncement = (mt == MessageType.ANNOUNCE) - if (!isBroadcastMessage && !isAnnouncement) return + val isFragment = (mt == MessageType.FRAGMENT || isBroadcastFile) && + (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) + if (!isBroadcastMessage && !isAnnouncement && !isFragment) return val idBytes = PacketIdUtil.computeIdBytes(packet) val id = idBytes.joinToString("") { b -> "%02x".format(b) } if (isBroadcastMessage) { + val now = System.currentTimeMillis() + val age = now - packet.timestamp.toLong() + if (age !in 0..PUBLIC_MESSAGE_MAX_AGE_MS) return synchronized(messages) { messages[id] = packet // Enforce capacity (remove oldest when exceeded) @@ -122,6 +142,17 @@ class GossipSyncManager( if (it.hasNext()) { it.next(); it.remove() } else break } } + if (!restoringArchive) persistArchive() + } else if (isFragment) { + val age = System.currentTimeMillis() - packet.timestamp.toLong() + if (age !in 0..FRAGMENT_MAX_AGE_MS) return + synchronized(fragments) { + fragments[id] = packet + while (fragments.size > configProvider.seenCapacity().coerceAtLeast(1)) { + val iterator = fragments.entries.iterator() + if (iterator.hasNext()) { iterator.next(); iterator.remove() } else break + } + } } else if (isAnnouncement) { // Ignore stale announcements older than STALE_PEER_TIMEOUT val now = System.currentTimeMillis() @@ -205,6 +236,13 @@ class GossipSyncManager( Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}") } } + val toSendFragments = synchronized(fragments) { fragments.values.toList() } + for (pkt in toSendFragments) { + val idBytes = PacketIdUtil.computeIdBytes(pkt) + if (!mightContain(idBytes)) { + delegate?.sendPacketToPeer(fromPeerID, pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)) + } + } } private fun hexStringToByteArray(hexString: String): ByteArray { @@ -243,6 +281,7 @@ class GossipSyncManager( synchronized(messages) { list.addAll(messages.values) } + synchronized(fragments) { list.addAll(fragments.values) } // sort by timestamp desc, then take up to min(seenCapacity, fit capacity) list.sortByDescending { it.timestamp.toLong() } @@ -262,7 +301,7 @@ class GossipSyncManager( return RequestSyncPacket(p = params.p, m = mVal, data = params.data).encode() } - // Periodically remove stale announcements and all their messages + // Announcements age out quickly; public history remains independently sync-able for six hours. private fun pruneStaleAnnouncements() { val now = System.currentTimeMillis() val stalePeers = mutableListOf() @@ -276,26 +315,17 @@ class GossipSyncManager( } } - if (stalePeers.isEmpty()) return - - // Remove announcements and their messages - var totalPrunedMsgs = 0 + var changed = false for (peerID in stalePeers) { - // Count messages to be pruned for logging - val toRemove = mutableListOf() - synchronized(messages) { - for ((id, message) in messages) { - val sender = message.senderID.joinToString("") { b -> "%02x".format(b) } - if (sender == peerID) toRemove.add(id) - } - } - totalPrunedMsgs += toRemove.size - - // Reuse existing removal which also clears announcement entry - removeAnnouncementForPeer(peerID) + changed = latestAnnouncementByPeer.remove(peerID) != null || changed } - - Log.d(TAG, "Pruned ${stalePeers.size} stale announcements and $totalPrunedMsgs messages") + synchronized(messages) { + changed = messages.entries.removeAll { now - it.value.timestamp.toLong() > PUBLIC_MESSAGE_MAX_AGE_MS } || changed + } + synchronized(fragments) { + fragments.entries.removeAll { now - it.value.timestamp.toLong() > FRAGMENT_MAX_AGE_MS } + } + if (changed) persistArchive() } // Explicitly remove stored announcement for a given peer (hex ID) @@ -305,26 +335,53 @@ class GossipSyncManager( Log.d(TAG, "Removed stored announcement for peer $peerID") } - // Collect IDs to remove first to avoid modifying collection while iterating - val idsToRemove = mutableListOf() - synchronized(messages) { - for ((id, message) in messages) { - val sender = message.senderID.joinToString("") { b -> "%02x".format(b) } - if (sender == key) { - idsToRemove.add(id) - } + } + + private fun restoreArchive() { + val file = archiveFile ?: return + if (!file.exists()) return + try { + restoringArchive = true + val input = java.io.DataInputStream(file.inputStream().buffered()) + val count = input.readInt().coerceIn(0, configProvider.seenCapacity()) + repeat(count) { + val length = input.readInt() + if (length <= 0 || length > com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + 256) return@repeat + val packet = com.bitchat.android.protocol.BinaryProtocol.decode(input.readNBytes(length)) ?: return@repeat + onPublicPacketSeen(packet) } - } - - // Now remove the collected IDs - synchronized(messages) { - for (id in idsToRemove) { - messages.remove(id) - } - } - - if (idsToRemove.isNotEmpty()) { - Log.d(TAG, "Pruned ${idsToRemove.size} messages with senders without announcements") + input.close() + } catch (_: Exception) { + synchronized(messages) { messages.clear() } + } finally { + restoringArchive = false } } + + @Synchronized + private fun persistArchive() { + val file = archiveFile ?: return + try { + val packets = synchronized(messages) { messages.values.toList() } + val temporary = File(file.parentFile, "${file.name}.tmp") + java.io.DataOutputStream(temporary.outputStream().buffered()).use { output -> + output.writeInt(packets.size) + packets.forEach { packet -> + val encoded = com.bitchat.android.protocol.BinaryProtocol.encode(packet, padding = false) ?: return@forEach + output.writeInt(encoded.size) + output.write(encoded) + } + } + try { + java.nio.file.Files.move( + temporary.toPath(), + file.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + } catch (_: Exception) { + temporary.delete() + } + } catch (_: Exception) { } + } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index bf9e2f7c..3dea9f63 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1010,6 +1010,11 @@ class ChatViewModel( messageId, com.bitchat.android.model.DeliveryStatus.Sent ) + } else if (route == com.bitchat.android.services.MessageRouter.RouteResult.QUEUED) { + messageManager.updateMessageDeliveryStatus( + messageId, + com.bitchat.android.model.DeliveryStatus.Queued + ) } } onAccepted(accepted) @@ -1473,7 +1478,7 @@ class ChatViewModel( com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() } catch (_: Exception) { } try { - com.bitchat.android.services.MessageRouter.tryGetInstance()?.clearAll() + com.bitchat.android.services.MessageRouter.panicClear(getApplication()) } catch (_: Exception) { } // Clear all cryptographic data diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index 31e7d1af..70ef0b53 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -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 } diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index b1822806..f690b215 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -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) } diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index 02bfb2d3..4b6061d0 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -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. */ diff --git a/app/src/test/java/com/bitchat/android/crypto/NoiseCourierTest.kt b/app/src/test/java/com/bitchat/android/crypto/NoiseCourierTest.kt new file mode 100644 index 00000000..a0eda7d9 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/crypto/NoiseCourierTest.kt @@ -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() + 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) } + } +} diff --git a/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt b/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt index c4225288..49d31c5a 100644 --- a/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt +++ b/app/src/test/java/com/bitchat/android/mesh/FragmentingPacketSenderTest.kt @@ -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() diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/CourierStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/CourierStoreTest.kt new file mode 100644 index 00000000..7677c3f9 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/CourierStoreTest.kt @@ -0,0 +1,105 @@ +package com.bitchat.android.mesh + +import android.os.Build +import com.bitchat.android.model.CourierEnvelope +import com.bitchat.android.services.ConversationStorageCipher +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class CourierStoreTest { + private val now = 2_000_000L + private val recipient = ByteArray(32) { 7 } + private val favorite = ByteArray(32) { 8 } + private val verified = ByteArray(32) { 9 } + private val cipher = TestCipher(0x5a) + + @Test + fun `direct pickup remains retryable until explicitly removed`() { + val store = newStore() + val envelope = envelope(1) + assertTrue(store.deposit(envelope, favorite, CourierDepositTier.FAVORITE)) + + assertEquals(1, store.copiesForRecipient(recipient).size) + assertEquals(1, store.copiesForRecipient(recipient).size) + assertTrue(store.remove(envelope)) + assertTrue(store.copiesForRecipient(recipient).isEmpty()) + } + + @Test + fun `verified pool evicts oldest verified and never favorites`() { + val store = newStore() + repeat(20) { index -> + assertTrue(store.deposit(envelope(index), ByteArray(32) { index.toByte() }, CourierDepositTier.VERIFIED)) + } + val favoriteEnvelope = envelope(100) + assertTrue(store.deposit(favoriteEnvelope, favorite, CourierDepositTier.FAVORITE)) + assertTrue(store.deposit(envelope(200), ByteArray(32) { 100 }, CourierDepositTier.VERIFIED)) + + val copies = reloaded().copiesForRecipient(recipient) + assertEquals(21, copies.size) + assertTrue(copies.any { it.ciphertext.contentEquals(favoriteEnvelope.ciphertext) }) + } + + @Test + fun `spray history and copy budget survive restart`() { + val store = newStore() + assertTrue(store.deposit(envelope(1, copies = 4u), verified, CourierDepositTier.VERIFIED)) + val courier = ByteArray(32) { 11 } + + val spray = store.sprayCopiesFor(courier).single() + assertEquals(2u.toUByte(), spray.copies) + assertTrue(store.commitSpray(spray, courier)) + assertTrue(reloaded().sprayCopiesFor(courier).isEmpty()) + } + + @Test + fun `failed spray preview does not consume custody`() { + val store = newStore() + assertTrue(store.deposit(envelope(1, copies = 4u), verified, CourierDepositTier.VERIFIED)) + val courier = ByteArray(32) { 12 } + + assertEquals(2u.toUByte(), store.sprayCopiesFor(courier).single().copies) + assertEquals(2u.toUByte(), reloaded().sprayCopiesFor(courier).single().copies) + } + + @Test + fun `wipe deletes sealed custody and destroys key`() { + val store = newStore() + assertTrue(store.deposit(envelope(1), favorite, CourierDepositTier.FAVORITE)) + store.wipe() + + assertFalse(File(RuntimeEnvironment.getApplication().filesDir, "courier-store.sealed").exists()) + assertTrue(cipher.destroyed) + } + + private fun envelope(id: Int, copies: UByte = 1u) = CourierEnvelope( + recipientTag = CourierEnvelope.recipientTag(recipient, CourierEnvelope.epochDay(now)), + expiry = (now + 60_000).toULong(), + ciphertext = byteArrayOf((id ushr 8).toByte(), id.toByte()), + copies = copies + ) + + private fun newStore(): CourierStore { + File(RuntimeEnvironment.getApplication().filesDir, "courier-store.sealed").delete() + return reloaded() + } + + private fun reloaded() = CourierStore(RuntimeEnvironment.getApplication(), cipher) { now } + + private class TestCipher(private val mask: Int) : ConversationStorageCipher { + var destroyed = false + override fun encrypt(plaintext: ByteArray, associatedData: ByteArray) = + plaintext.map { (it.toInt() xor mask).toByte() }.toByteArray() + override fun decrypt(envelope: ByteArray, associatedData: ByteArray) = encrypt(envelope, associatedData) + override fun destroyKey() { destroyed = true } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/DirectCourierDepositPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/DirectCourierDepositPolicyTest.kt new file mode 100644 index 00000000..559b07b4 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/DirectCourierDepositPolicyTest.kt @@ -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" })) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/model/CourierEnvelopeTest.kt b/app/src/test/kotlin/com/bitchat/android/model/CourierEnvelopeTest.kt new file mode 100644 index 00000000..a9e2d548 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/CourierEnvelopeTest.kt @@ -0,0 +1,82 @@ +package com.bitchat.android.model + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CourierEnvelopeTest { + @Test + fun `encoding matches fixed iOS wire vector`() { + val envelope = CourierEnvelope( + recipientTag = ByteArray(16) { it.toByte() }, + expiry = 0x0102030405060708u, + ciphertext = byteArrayOf(0xaa.toByte(), 0xbb.toByte(), 0xcc.toByte()), + copies = 4u + ) + assertEquals( + "010010000102030405060708090a0b0c0d0e0f" + + "0200080102030405060708" + + "030003aabbcc" + + "04000104", + envelope.encode()!!.toHex() + ) + } + + @Test + fun `copies TLV round trips and legacy omission defaults to one`() { + val envelope = CourierEnvelope( + recipientTag = ByteArray(16) { it.toByte() }, + expiry = 123456789u, + ciphertext = ByteArray(96) { (it + 1).toByte() }, + copies = 4u + ) + val decoded = CourierEnvelope.decode(envelope.encode()!!)!! + assertEquals(4u.toUByte(), decoded.copies) + assertArrayEquals(envelope.recipientTag, decoded.recipientTag) + assertArrayEquals(envelope.ciphertext, decoded.ciphertext) + + val legacy = envelope.copy(copies = 1u) + assertEquals(1u.toUByte(), CourierEnvelope.decode(legacy.encode()!!)!!.copies) + } + + @Test + fun `tag rotates daily and matches adjacent day for clock skew`() { + val key = ByteArray(32) { 0x2a } + val now = 10L * 86_400_000L + val tag = CourierEnvelope.recipientTag(key, CourierEnvelope.epochDay(now) - 1u) + val envelope = CourierEnvelope(tag, (now + 1_000).toULong(), ByteArray(96) { 1 }) + assertEquals(true, envelope.matchesRecipient(key, now)) + assertEquals(false, envelope.matchesRecipient(ByteArray(32) { 0x2b }, now)) + } + + @Test + fun `oversized ciphertext is rejected`() { + val envelope = CourierEnvelope( + ByteArray(16), + 1u, + ByteArray(CourierEnvelope.MAX_CIPHERTEXT_BYTES + 1) + ) + assertNull(envelope.encode()) + } + + @Test + fun `invalid copy budgets are rejected instead of normalized`() { + val envelope = CourierEnvelope(ByteArray(16), 1u, ByteArray(32) { 1 }) + assertNull(envelope.copy(copies = 0u).encode()) + assertNull(envelope.copy(copies = 9u).encode()) + + val encoded = envelope.copy(copies = 2u).encode()!! + encoded[encoded.lastIndex] = 0 + assertNull(CourierEnvelope.decode(encoded)) + } + + @Test + fun `duplicate required fields are rejected`() { + val envelope = CourierEnvelope(ByteArray(16), 1u, ByteArray(32) { 1 }) + val encoded = envelope.encode()!! + assertNull(CourierEnvelope.decode(encoded + encoded.copyOfRange(0, 19))) + } + + private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/MessageOutboxStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/MessageOutboxStoreTest.kt new file mode 100644 index 00000000..ded30b31 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/MessageOutboxStoreTest.kt @@ -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 } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt b/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt index 48cbe2da..9a577a44 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt @@ -56,11 +56,19 @@ class MessageRouterTest { ContactDirectory.initialize(context) { mesh } MessageRouter.disableSchedulerForTesting = true + MessageRouter.outboxStoreFactory = { testContext -> + MessageOutboxStore(testContext, object : ConversationStorageCipher { + override fun encrypt(plaintext: ByteArray, associatedData: ByteArray) = plaintext + override fun decrypt(envelope: ByteArray, associatedData: ByteArray) = envelope + override fun destroyKey() = Unit + }) + } MessageRouter.resetForTesting() fakeTime = 1_000_000L expired.clear() router = MessageRouter.getInstance(context, mesh) + router.clearAll() router.clock = { fakeTime } router.onMessageExpired = { expired.add(it) } } @@ -69,6 +77,7 @@ class MessageRouterTest { fun tearDown() { MessageRouter.resetForTesting() MessageRouter.disableSchedulerForTesting = false + MessageRouter.outboxStoreFactory = ::MessageOutboxStore ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) } } @@ -174,6 +183,48 @@ class MessageRouterTest { verify(mesh, never()).initiateNoiseHandshake(any()) } + @Test + fun `direct send remains retained until delivery acknowledgement`() { + peerReady() + router.sendPrivate("direct", peerID, "peer", "msg-direct") + clearInvocations(mesh) + + fakeTime += 31_000 + router.tickOutbox() + verify(mesh, times(1)).sendPrivateMessage("direct", peerID, "peer", "msg-direct") + + router.onMessageAcknowledged("msg-direct", peerID) + clearInvocations(mesh) + router.tickOutbox() + verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull()) + } + + @Test + fun `queued message survives router recreation`() { + peerOffline() + router.sendPrivate("durable", peerID, "peer", "msg-durable") + + MessageRouter.resetForTesting() + router = MessageRouter.getInstance(RuntimeEnvironment.getApplication(), mesh) + router.clock = { fakeTime } + peerReady() + router.onSessionEstablished(peerID) + + verify(mesh).sendPrivateMessage("durable", peerID, "peer", "msg-durable") + } + + @Test + fun `outbox stops transport retries after eight attempts`() { + peerReady() + router.sendPrivate("bounded", peerID, "peer", "msg-bounded") + repeat(10) { + fakeTime += 10 * 60_000L + 1 + router.tickOutbox() + } + + verify(mesh, times(8)).sendPrivateMessage("bounded", peerID, "peer", "msg-bounded") + } + @Test fun `scheduler stops with the mesh service and restarts on rebind`() { MessageRouter.disableSchedulerForTesting = false diff --git a/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncManagerTest.kt new file mode 100644 index 00000000..1b0f2cad --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/sync/GossipSyncManagerTest.kt @@ -0,0 +1,51 @@ +package com.bitchat.android.sync + +import android.os.Build +import com.bitchat.android.model.RequestSyncPacket +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import kotlinx.coroutines.test.TestScope +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class GossipSyncManagerTest { + private val config = object : GossipSyncManager.ConfigProvider { + override fun seenCapacity() = 100 + override fun gcsMaxBytes() = 400 + override fun gcsTargetFpr() = 0.01 + } + + @Test + fun `whole messages retain six hours while fragments retain fifteen minutes`() { + val sent = mutableListOf() + val manager = GossipSyncManager("1111222233334444", TestScope(), config) + manager.delegate = object : GossipSyncManager.Delegate { + override fun sendPacket(packet: BitchatPacket) = Unit + override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) { sent += packet } + override fun signPacketForBroadcast(packet: BitchatPacket) = packet + } + val now = System.currentTimeMillis() + manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now - 5 * 60 * 60 * 1000L, 1)) + manager.onPublicPacketSeen(packet(MessageType.MESSAGE, now - 7 * 60 * 60 * 1000L, 2)) + manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now - 10 * 60 * 1000L, 3)) + manager.onPublicPacketSeen(packet(MessageType.FRAGMENT, now - 20 * 60 * 1000L, 4)) + manager.onPublicPacketSeen(packet(MessageType.FILE_TRANSFER, now - 10 * 60 * 1000L, 5)) + + manager.handleRequestSync("peer", RequestSyncPacket(p = 1, m = 1, data = byteArrayOf())) + + assertEquals(listOf(1, 3, 5), sent.map { it.payload.single().toInt() }) + } + + private fun packet(type: MessageType, timestamp: Long, marker: Int) = BitchatPacket( + type = type.value, + senderID = ByteArray(8) { 1 }, + timestamp = timestamp.toULong(), + payload = byteArrayOf(marker.toByte()), + ttl = 3u + ) +} diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md index 1abed243..b83eaaf0 100644 --- a/docs/client-rewrite-contracts.md +++ b/docs/client-rewrite-contracts.md @@ -15,6 +15,7 @@ The remaining implementation work and milestone progress are tracked in | Outer mesh packet | v1/v2 header widths, big-endian fields, flags, section order, route placement, signature placement, padding, compression, signing bytes | `BinaryProtocolTest`, `ClientRewriteWireContractTest` | | Chat payload | Flag bits, millisecond timestamp, UTF-8 byte lengths, encrypted-content substitution, optional-field order | `ClientRewriteWireContractTest` | | Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, live-voice bursts, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `VoiceBurstPacketTest`, `FragmentManagerTest` | +| Store and forward | Courier type `0x04`, rotating HMAC recipient tags, Noise X seals, copy-budget TLV, 24-hour expiry, bounded tiered custody | `CourierEnvelopeTest`, `NoiseCourierTest`, `MessageRouterTest` | | Identity/security | Announcement extensions, capability bitfield endianness, Noise static-key binding, handshake identity binding, signatures | `IdentityAnnouncementTest`, `NoiseSessionManagerIdentityBindingTest`, `ClientRewritePrimitiveContractTest` | | Sync/routing | Stable packet IDs, GCS bitstream, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` | | Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals | `ClientRewriteNostrContractTest`, `NostrProtocolTest` | @@ -53,8 +54,8 @@ shipping a rewrite, run the following on at least two physical devices: 2. Runtime permission denial/retry for Bluetooth, location, notifications, and microphone. 3. Foreground-service survival with the screen off and after process recreation. -4. Cross-client Android/iOS exchange for announce, public/private text, delivery - and read receipts, image/audio/file transfer, sync replay, and Nostr fallback. +4. Cross-client Android/iOS exchange for announce, public/private text, courier + deposit/handover, delivery/read receipts, media, six-hour sync replay, and Nostr fallback. 5. Corrupt, duplicated, reordered, delayed, and partially delivered fragments. 6. Identity rotation, verification continuity, downgrade rejection, and recovery after stale Noise sessions.