From 7800d4bca697007ac1d266274866e6fa924e1653 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:17:31 +0200 Subject: [PATCH] refactor: harden bridge delivery boundaries --- .../com/bitchat/android/BitchatApplication.kt | 3 + .../android/mesh/BluetoothMeshService.kt | 27 +- .../bitchat/android/mesh/BridgeMeshPort.kt | 71 +++ .../java/com/bitchat/android/mesh/MeshCore.kt | 26 +- .../bitchat/android/mesh/MessageHandler.kt | 5 +- .../bitchat/android/mesh/PacketProcessor.kt | 7 +- .../bitchat/android/model/CourierEnvelope.kt | 70 +-- .../android/model/NostrCarrierPacket.kt | 45 +- .../com/bitchat/android/model/PrekeyBundle.kt | 61 +- .../com/bitchat/android/model/Tlv16Codec.kt | 47 ++ .../android/nostr/NostrPublishTracker.kt | 68 +++ .../android/nostr/NostrRelayManager.kt | 98 +++- .../bitchat/android/services/AppStateStore.kt | 33 +- .../bitchat/android/services/MessageRouter.kt | 88 ++- .../services/PublicMessageReconciler.kt | 53 ++ .../services/bridge/BridgeCollections.kt | 69 +++ .../android/services/bridge/BridgeModels.kt | 43 ++ .../bridge/BridgeProtocolPacketFactory.kt | 46 ++ .../services/bridge/CourierCoordinator.kt | 424 ++++++++++++++ .../services/bridge/MeshBridgeService.kt | 527 ++++-------------- .../services/bridge/PrekeyCoordinator.kt | 81 +++ .../android/services/bridge/PrekeyManager.kt | 119 ++-- .../android/services/bridge/PrekeyStores.kt | 111 ++++ .../java/com/bitchat/android/ui/AboutSheet.kt | 11 +- .../java/com/bitchat/android/ui/ChatHeader.kt | 7 +- .../java/com/bitchat/android/ui/ChatScreen.kt | 14 +- .../com/bitchat/android/ui/ChatViewModel.kt | 35 +- .../bitchat/android/ui/MeshPeerListSheet.kt | 11 +- .../bitchat/android/model/Tlv16CodecTest.kt | 30 + .../android/nostr/NostrPublishTrackerTest.kt | 35 ++ .../services/bridge/PrekeyManagerTest.kt | 129 +++++ 31 files changed, 1683 insertions(+), 711 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt create mode 100644 app/src/main/java/com/bitchat/android/model/Tlv16Codec.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrPublishTracker.kt create mode 100644 app/src/main/java/com/bitchat/android/services/PublicMessageReconciler.kt create mode 100644 app/src/main/java/com/bitchat/android/services/bridge/BridgeCollections.kt create mode 100644 app/src/main/java/com/bitchat/android/services/bridge/BridgeModels.kt create mode 100644 app/src/main/java/com/bitchat/android/services/bridge/BridgeProtocolPacketFactory.kt create mode 100644 app/src/main/java/com/bitchat/android/services/bridge/CourierCoordinator.kt create mode 100644 app/src/main/java/com/bitchat/android/services/bridge/PrekeyCoordinator.kt create mode 100644 app/src/main/java/com/bitchat/android/services/bridge/PrekeyStores.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/model/Tlv16CodecTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NostrPublishTrackerTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/services/bridge/PrekeyManagerTest.kt diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 5cdce8a7..8b6b197a 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -60,6 +60,9 @@ class BitchatApplication : Application() { // continue while the activity is backgrounded. try { com.bitchat.android.services.bridge.MeshBridgeService.initialize(this) + com.bitchat.android.mesh.BridgeMeshPort.install( + com.bitchat.android.services.bridge.MeshBridgeService + ) } catch (_: Exception) { } // Proactively start the foreground service to keep mesh alive 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 a7805534..42d40a77 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -8,7 +8,6 @@ import com.bitchat.android.model.AuthenticatedPeerState import com.bitchat.android.model.PeerCapabilities import com.bitchat.android.protocol.MessagePadding import com.bitchat.android.model.RoutedPacket -import com.bitchat.android.model.IdentityAnnouncement import com.bitchat.android.model.NoisePayload import com.bitchat.android.model.NoisePayloadType import com.bitchat.android.protocol.BitchatPacket @@ -19,6 +18,7 @@ import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.util.toHexString import com.bitchat.android.services.VerificationService import com.bitchat.android.service.TransportBridgeService +import com.bitchat.android.services.bridge.BridgeProtocolPacketFactory import kotlinx.coroutines.* import java.util.* import java.util.concurrent.ConcurrentHashMap @@ -908,7 +908,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic val nickname = runCatching { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) }.getOrNull() - com.bitchat.android.services.bridge.MeshBridgeService.bridgeOutgoing( + BridgeMeshPort.bridgeOutgoing( content, myPeerID, packet.timestamp.toLong(), @@ -938,16 +938,13 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic ) { if (payload.isEmpty()) return serviceScope.launch { - val packet = BitchatPacket( - version = if (payload.size > 0xFFFF) 2u else 1u, - type = type.value, - senderID = hexStringToByteArray(myPeerID), - recipientID = recipientPeerID?.let(::hexStringToByteArray), - timestamp = System.currentTimeMillis().toULong(), + val packet = BridgeProtocolPacketFactory.protocolPacket( + type = type, payload = payload, - signature = null, + senderPeerId = myPeerID, + recipientPeerId = recipientPeerID, ttl = MAX_TTL - ) + ) ?: return@launch val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet if (sign && outgoing.signature?.size != 64) return@launch broadcastRoutedPacket(RoutedPacket(outgoing)) @@ -1286,11 +1283,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } // Create iOS-compatible IdentityAnnouncement with TLV encoding - val announcement = IdentityAnnouncement.forLocalPeer( + val announcement = BridgeProtocolPacketFactory.identityAnnouncement( nickname, staticKey, - signingKey, - com.bitchat.android.services.bridge.MeshBridgeService.advertisedCell() + signingKey ) var tlvPayload = announcement.encode() if (tlvPayload == null) { @@ -1354,11 +1350,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic } // Create iOS-compatible IdentityAnnouncement with TLV encoding - val announcement = IdentityAnnouncement.forLocalPeer( + val announcement = BridgeProtocolPacketFactory.identityAnnouncement( nickname, staticKey, - signingKey, - com.bitchat.android.services.bridge.MeshBridgeService.advertisedCell() + signingKey ) var tlvPayload = announcement.encode() if (tlvPayload == null) { diff --git a/app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt b/app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt new file mode 100644 index 00000000..6793b54a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/mesh/BridgeMeshPort.kt @@ -0,0 +1,71 @@ +package com.bitchat.android.mesh + +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.protocol.BitchatPacket + +/** + * Transport-facing bridge boundary. + * + * BLE/Wi-Fi packet code depends only on this protocol surface; application + * bootstrap installs the process bridge controller. Tests can install a fake + * without constructing relay or persistence infrastructure. + */ +interface BridgeMeshDelegate { + fun advertisedCell(): String? + + fun bridgeOutgoing( + content: String, + senderPeerId: String, + timestampMs: Long, + nickname: String? + ) + + fun handleAuthenticatedRadioMessage(messageId: String) + fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement) + fun handlePrekeyPacket(packet: BitchatPacket) + fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) + fun handleCourierEnvelope(payload: ByteArray) +} + +object BridgeMeshPort : BridgeMeshDelegate { + @Volatile + private var delegate: BridgeMeshDelegate? = null + + fun install(delegate: BridgeMeshDelegate) { + this.delegate = delegate + } + + override fun advertisedCell(): String? = delegate?.advertisedCell() + + override fun bridgeOutgoing( + content: String, + senderPeerId: String, + timestampMs: Long, + nickname: String? + ) { + delegate?.bridgeOutgoing(content, senderPeerId, timestampMs, nickname) + } + + override fun handleAuthenticatedRadioMessage(messageId: String) { + delegate?.handleAuthenticatedRadioMessage(messageId) + } + + override fun handleVerifiedAnnouncement( + peerId: String, + announcement: IdentityAnnouncement + ) { + delegate?.handleVerifiedAnnouncement(peerId, announcement) + } + + override fun handlePrekeyPacket(packet: BitchatPacket) { + delegate?.handlePrekeyPacket(packet) + } + + override fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) { + delegate?.handleCarrier(payload, fromPeerId, directedToUs) + } + + override fun handleCourierEnvelope(payload: ByteArray) { + delegate?.handleCourierEnvelope(payload) + } +} 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 fc2458ce..c6380e7d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -17,6 +17,7 @@ import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.protocol.SpecialRecipients import com.bitchat.android.service.TransportBridgeService +import com.bitchat.android.services.bridge.BridgeProtocolPacketFactory import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.util.toHexString import kotlinx.coroutines.CoroutineScope @@ -527,7 +528,7 @@ class MeshCore( if (channel == null) { val nickname = hooks.announcementNicknameProvider?.invoke() ?: delegate?.getNickname() - com.bitchat.android.services.bridge.MeshBridgeService.bridgeOutgoing( + BridgeMeshPort.bridgeOutgoing( content, myPeerID, packet.timestamp.toLong(), @@ -572,16 +573,13 @@ class MeshCore( ) { if (payload.isEmpty()) return scope.launch { - val packet = BitchatPacket( - version = if (payload.size > 0xFFFF) 2u else 1u, - type = type.value, - senderID = MeshPacketUtils.hexStringToByteArray(myPeerID), - recipientID = recipientPeerID?.let(MeshPacketUtils::hexStringToByteArray), - timestamp = System.currentTimeMillis().toULong(), + val packet = BridgeProtocolPacketFactory.protocolPacket( + type = type, payload = payload, - signature = null, + senderPeerId = myPeerID, + recipientPeerId = recipientPeerID, ttl = maxTtl - ) + ) ?: return@launch val outgoing = if (sign) signPacketBeforeBroadcast(packet) else packet if (sign && outgoing.signature?.size != 64) return@launch dispatchGlobal(RoutedPacket(outgoing)) @@ -817,11 +815,10 @@ class MeshCore( Log.e("MeshCore", "No signing public key available for announcement") return@launch } - val announcement = IdentityAnnouncement.forLocalPeer( + val announcement = BridgeProtocolPacketFactory.identityAnnouncement( nickname, staticKey, - signingKey, - com.bitchat.android.services.bridge.MeshBridgeService.advertisedCell() + signingKey ) val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch val announcePacket = BitchatPacket( @@ -843,11 +840,10 @@ class MeshCore( ?: myPeerID val staticKey = encryptionService.getStaticPublicKey() ?: return val signingKey = encryptionService.getSigningPublicKey() ?: return - val announcement = IdentityAnnouncement.forLocalPeer( + val announcement = BridgeProtocolPacketFactory.identityAnnouncement( nickname, staticKey, - signingKey, - com.bitchat.android.services.bridge.MeshBridgeService.advertisedCell() + signingKey ) val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return val packet = BitchatPacket( 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 9ccca43c..f9f0008e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -319,7 +319,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro capabilities = announcement.capabilities ) ?: false - com.bitchat.android.services.bridge.MeshBridgeService.handleVerifiedAnnouncement( + BridgeMeshPort.handleVerifiedAnnouncement( peerID, announcement ) @@ -465,8 +465,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro timestamp = Date(packet.timestamp.toLong()) ) delegate?.onMessageReceived(message) - com.bitchat.android.services.bridge.MeshBridgeService - .handleAuthenticatedRadioMessage(message.id) + BridgeMeshPort.handleAuthenticatedRadioMessage(message.id) } catch (e: Exception) { Log.e(TAG, "Failed to process broadcast message: ${e.message}") } 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 3037fc12..92cffe81 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -150,14 +150,14 @@ class PacketProcessor(private val myPeerID: String) { MessageType.FRAGMENT -> handleFragment(routed) MessageType.REQUEST_SYNC -> handleRequestSync(routed) MessageType.PREKEY_BUNDLE -> { - com.bitchat.android.services.bridge.MeshBridgeService.handlePrekeyPacket(packet) + BridgeMeshPort.handlePrekeyPacket(packet) } MessageType.NOSTR_CARRIER -> { val directedToUs = packetRelayManager.isPacketAddressedToMe(packet) val isBroadcast = packet.recipientID == null || packet.recipientID.contentEquals(delegate?.getBroadcastRecipient()) if (directedToUs || isBroadcast) { - com.bitchat.android.services.bridge.MeshBridgeService.handleCarrier( + BridgeMeshPort.handleCarrier( packet.payload, peerID, directedToUs @@ -171,8 +171,7 @@ class PacketProcessor(private val myPeerID: String) { MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed) MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed) MessageType.COURIER_ENVELOPE -> { - com.bitchat.android.services.bridge.MeshBridgeService - .handleCourierEnvelope(packet.payload) + BridgeMeshPort.handleCourierEnvelope(packet.payload) } MessageType.FILE_TRANSFER -> handleMessage(routed) else -> { diff --git a/app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt b/app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt index b86f6725..688c8d73 100644 --- a/app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt +++ b/app/src/main/java/com/bitchat/android/model/CourierEnvelope.kt @@ -1,6 +1,5 @@ package com.bitchat.android.model -import java.io.ByteArrayOutputStream import java.nio.ByteBuffer import java.nio.ByteOrder import javax.crypto.Mac @@ -27,26 +26,28 @@ data class CourierEnvelope( if (recipientTag.size != TAG_LENGTH) return null if (ciphertext.isEmpty() || ciphertext.size > MAX_CIPHERTEXT_BYTES) return null - val output = ByteArrayOutputStream(ciphertext.size + 40) - appendTlv(output, TLV_RECIPIENT_TAG, recipientTag) - appendTlv( - output, - TLV_EXPIRY, - ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(expiry).array() + val fields = mutableListOf( + Tlv16Codec.Field(TLV_RECIPIENT_TAG, recipientTag), + Tlv16Codec.Field( + TLV_EXPIRY, + ByteBuffer.allocate(Long.SIZE_BYTES) + .order(ByteOrder.BIG_ENDIAN) + .putLong(expiry) + .array() + ), + Tlv16Codec.Field(TLV_CIPHERTEXT, ciphertext) ) - appendTlv(output, TLV_CIPHERTEXT, ciphertext) if (normalizedCopies > 1) { - appendTlv(output, TLV_COPIES, byteArrayOf(normalizedCopies.toByte())) + fields += Tlv16Codec.Field(TLV_COPIES, byteArrayOf(normalizedCopies.toByte())) } prekeyId?.let { if (it !in 0..0xFFFF_FFFFL) return null - appendTlv( - output, + fields += Tlv16Codec.Field( TLV_PREKEY_ID, ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(it.toInt()).array() ) } - return output.toByteArray() + return Tlv16Codec.encode(*fields.toTypedArray()) } companion object { @@ -63,48 +64,39 @@ data class CourierEnvelope( private val TAG_CONTEXT = "bitchat-courier-tag-v1".toByteArray(Charsets.UTF_8) fun decode(data: ByteArray): CourierEnvelope? { - var offset = 0 var recipientTag: ByteArray? = null var expiry: Long? = null var ciphertext: ByteArray? = null var copies = 1 var prekeyId: Long? = null - while (offset < data.size) { - if (offset + 3 > data.size) return null - val type = data[offset].toInt() and 0xFF - 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) { + Tlv16Codec.decode(data)?.forEach { field -> + when (field.type) { TLV_RECIPIENT_TAG -> { - if (length != TAG_LENGTH) return null - recipientTag = value + if (field.value.size != TAG_LENGTH) return null + recipientTag = field.value } TLV_EXPIRY -> { - if (length != Long.SIZE_BYTES) return null - expiry = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).long + if (field.value.size != Long.SIZE_BYTES) return null + expiry = ByteBuffer.wrap(field.value).order(ByteOrder.BIG_ENDIAN).long } TLV_CIPHERTEXT -> { - if (length !in 1..MAX_CIPHERTEXT_BYTES) return null - ciphertext = value + if (field.value.size !in 1..MAX_CIPHERTEXT_BYTES) return null + ciphertext = field.value } TLV_COPIES -> { - if (length != 1) return null - copies = value[0].toInt() and 0xFF + if (field.value.size != 1) return null + copies = field.value[0].toInt() and 0xFF } TLV_PREKEY_ID -> { - if (length != Int.SIZE_BYTES) return null + if (field.value.size != Int.SIZE_BYTES) return null prekeyId = - ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).int.toLong() and 0xFFFF_FFFFL + ByteBuffer.wrap(field.value) + .order(ByteOrder.BIG_ENDIAN) + .int.toLong() and 0xFFFF_FFFFL } } - } + } ?: return null return CourierEnvelope( recipientTag = recipientTag ?: return null, @@ -135,12 +127,6 @@ data class CourierEnvelope( .map { recipientTag(noiseStaticKey, it) } } - private fun appendTlv(output: ByteArrayOutputStream, type: Int, value: ByteArray) { - output.write(type) - output.write((value.size ushr 8) and 0xFF) - output.write(value.size and 0xFF) - output.write(value) - } } override fun equals(other: Any?): Boolean = diff --git a/app/src/main/java/com/bitchat/android/model/NostrCarrierPacket.kt b/app/src/main/java/com/bitchat/android/model/NostrCarrierPacket.kt index d6df4fa5..3f6be5a7 100644 --- a/app/src/main/java/com/bitchat/android/model/NostrCarrierPacket.kt +++ b/app/src/main/java/com/bitchat/android/model/NostrCarrierPacket.kt @@ -1,7 +1,6 @@ package com.bitchat.android.model import com.bitchat.android.nostr.NostrEvent -import java.io.ByteArrayOutputStream /** * Wire payload for MessageType.NOSTR_CARRIER (0x28). @@ -34,11 +33,13 @@ data class NostrCarrierPacket( NostrEvent.fromJsonString(String(eventJson, Charsets.UTF_8)) fun encode(): ByteArray { - val output = ByteArrayOutputStream(eventJson.size + geohash.length + 12) - appendTlv(output, TLV_DIRECTION, byteArrayOf(direction.value.toByte())) - appendTlv(output, TLV_GEOHASH, geohash.toByteArray(Charsets.UTF_8)) - appendTlv(output, TLV_EVENT_JSON, eventJson) - return output.toByteArray() + return checkNotNull( + Tlv16Codec.encode( + Tlv16Codec.Field(TLV_DIRECTION, byteArrayOf(direction.value.toByte())), + Tlv16Codec.Field(TLV_GEOHASH, geohash.toByteArray(Charsets.UTF_8)), + Tlv16Codec.Field(TLV_EVENT_JSON, eventJson) + ) + ) } companion object { @@ -59,34 +60,24 @@ data class NostrCarrierPacket( }.getOrNull() fun decode(data: ByteArray): NostrCarrierPacket? { - var offset = 0 var direction: Direction? = null var geohash: String? = null var eventJson: ByteArray? = null - while (offset + 3 <= data.size) { - val type = data[offset].toInt() and 0xFF - 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) { + Tlv16Codec.decode(data)?.forEach { field -> + when (field.type) { TLV_DIRECTION -> { - if (value.size != 1) return null - direction = Direction.fromValue(value[0].toInt() and 0xFF) ?: return null + if (field.value.size != 1) return null + direction = + Direction.fromValue(field.value[0].toInt() and 0xFF) ?: return null } TLV_GEOHASH -> { - geohash = value.toString(Charsets.UTF_8) + geohash = field.value.toString(Charsets.UTF_8) } - TLV_EVENT_JSON -> eventJson = value + TLV_EVENT_JSON -> eventJson = field.value } - } + } ?: return null - if (offset != data.size) return null return runCatching { NostrCarrierPacket( direction = direction ?: return null, @@ -96,12 +87,6 @@ data class NostrCarrierPacket( }.getOrNull() } - private fun appendTlv(output: ByteArrayOutputStream, type: Int, value: ByteArray) { - output.write(type) - output.write((value.size ushr 8) and 0xFF) - output.write(value.size and 0xFF) - output.write(value) - } } override fun equals(other: Any?): Boolean = diff --git a/app/src/main/java/com/bitchat/android/model/PrekeyBundle.kt b/app/src/main/java/com/bitchat/android/model/PrekeyBundle.kt index 92fc9f6d..be5d7a81 100644 --- a/app/src/main/java/com/bitchat/android/model/PrekeyBundle.kt +++ b/app/src/main/java/com/bitchat/android/model/PrekeyBundle.kt @@ -62,12 +62,12 @@ data class PrekeyBundle( entries.write(prekey.publicKey) } - return ByteArrayOutputStream(128 + entries.size()).apply { - appendTlv(this, TLV_NOISE_STATIC_KEY, noiseStaticPublicKey) - appendTlv(this, TLV_PREKEYS, entries.toByteArray()) - appendTlv(this, TLV_GENERATED_AT, uint64Bytes(generatedAt)) - appendTlv(this, TLV_SIGNATURE, signature) - }.toByteArray() + return Tlv16Codec.encode( + Tlv16Codec.Field(TLV_NOISE_STATIC_KEY, noiseStaticPublicKey), + Tlv16Codec.Field(TLV_PREKEYS, entries.toByteArray()), + Tlv16Codec.Field(TLV_GENERATED_AT, uint64Bytes(generatedAt)), + Tlv16Codec.Field(TLV_SIGNATURE, signature) + ) } companion object { @@ -83,43 +83,33 @@ data class PrekeyBundle( private const val TLV_SIGNATURE = 0x04 fun decode(data: ByteArray): PrekeyBundle? { - var offset = 0 var noiseStaticKey: ByteArray? = null var prekeys: List? = null var generatedAt: Long? = null var signature: ByteArray? = null - while (offset < data.size) { - if (offset + 3 > data.size) return null - val type = data[offset].toInt() and 0xFF - 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) { + Tlv16Codec.decode(data)?.forEach { field -> + when (field.type) { TLV_NOISE_STATIC_KEY -> { - if (length != KEY_LENGTH) return null - noiseStaticKey = value + if (field.value.size != KEY_LENGTH) return null + noiseStaticKey = field.value } TLV_PREKEYS -> { - if (length == 0 || - length % PREKEY_ENTRY_LENGTH != 0 || - length / PREKEY_ENTRY_LENGTH > MAX_PREKEYS + if (field.value.isEmpty() || + field.value.size % PREKEY_ENTRY_LENGTH != 0 || + field.value.size / PREKEY_ENTRY_LENGTH > MAX_PREKEYS ) { return null } val parsed = mutableListOf() var entryOffset = 0 - while (entryOffset < value.size) { - val id = ByteBuffer.wrap(value, entryOffset, Int.SIZE_BYTES) + while (entryOffset < field.value.size) { + val id = ByteBuffer.wrap(field.value, entryOffset, Int.SIZE_BYTES) .order(ByteOrder.BIG_ENDIAN) .int.toLong() and 0xFFFF_FFFFL entryOffset += Int.SIZE_BYTES - val publicKey = value.copyOfRange(entryOffset, entryOffset + KEY_LENGTH) + val publicKey = + field.value.copyOfRange(entryOffset, entryOffset + KEY_LENGTH) entryOffset += KEY_LENGTH parsed += Prekey(id, publicKey) } @@ -127,15 +117,15 @@ data class PrekeyBundle( prekeys = parsed } TLV_GENERATED_AT -> { - if (length != Long.SIZE_BYTES) return null - generatedAt = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).long + if (field.value.size != Long.SIZE_BYTES) return null + generatedAt = ByteBuffer.wrap(field.value).order(ByteOrder.BIG_ENDIAN).long } TLV_SIGNATURE -> { - if (length != SIGNATURE_LENGTH) return null - signature = value + if (field.value.size != SIGNATURE_LENGTH) return null + signature = field.value } } - } + } ?: return null return runCatching { PrekeyBundle( @@ -147,13 +137,6 @@ data class PrekeyBundle( }.getOrNull() } - private fun appendTlv(output: ByteArrayOutputStream, type: Int, value: ByteArray) { - output.write(type) - output.write((value.size ushr 8) and 0xFF) - output.write(value.size and 0xFF) - output.write(value) - } - private fun uint32Bytes(value: Long): ByteArray = ByteBuffer.allocate(Int.SIZE_BYTES) .order(ByteOrder.BIG_ENDIAN) diff --git a/app/src/main/java/com/bitchat/android/model/Tlv16Codec.kt b/app/src/main/java/com/bitchat/android/model/Tlv16Codec.kt new file mode 100644 index 00000000..4cc2783e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/Tlv16Codec.kt @@ -0,0 +1,47 @@ +package com.bitchat.android.model + +import java.io.ByteArrayOutputStream + +/** + * Minimal unsigned 16-bit big-endian TLV codec used by bridge wire models. + * + * Semantic validation intentionally remains in each model. This helper only + * owns framing so every decoder rejects truncated and trailing data the same + * way. + */ +internal object Tlv16Codec { + data class Field(val type: Int, val value: ByteArray) + + fun encode(vararg fields: Field): ByteArray? { + val output = ByteArrayOutputStream( + fields.sumOf { HEADER_SIZE + it.value.size } + ) + fields.forEach { field -> + if (field.type !in 0..0xFF || field.value.size > 0xFFFF) return null + output.write(field.type) + output.write((field.value.size ushr 8) and 0xFF) + output.write(field.value.size and 0xFF) + output.write(field.value) + } + return output.toByteArray() + } + + fun decode(data: ByteArray): List? { + val fields = mutableListOf() + var offset = 0 + while (offset < data.size) { + if (data.size - offset < HEADER_SIZE) return null + val type = data[offset].toInt() and 0xFF + val length = + ((data[offset + 1].toInt() and 0xFF) shl 8) or + (data[offset + 2].toInt() and 0xFF) + offset += HEADER_SIZE + if (length > data.size - offset) return null + fields += Field(type, data.copyOfRange(offset, offset + length)) + offset += length + } + return fields + } + + private const val HEADER_SIZE = 3 +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrPublishTracker.kt b/app/src/main/java/com/bitchat/android/nostr/NostrPublishTracker.kt new file mode 100644 index 00000000..cec2afaa --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrPublishTracker.kt @@ -0,0 +1,68 @@ +package com.bitchat.android.nostr + +import kotlinx.coroutines.CompletableDeferred +import java.util.concurrent.ConcurrentHashMap + +sealed interface NostrPublishResult { + data class Accepted(val relayUrl: String) : NostrPublishResult + data class Rejected(val reasons: Map) : NostrPublishResult + data object TimedOut : NostrPublishResult +} + +/** + * Correlates NIP-20 OK responses with callers that require relay acceptance. + * + * Most Nostr publishers remain fire-and-forget. Security-sensitive callers, + * such as courier delivery, opt into this tracker so local dedup state is not + * advanced before a relay has actually stored the event. + */ +internal class NostrPublishTracker { + private data class Attempt( + val remainingRelays: MutableSet, + val rejections: MutableMap, + val result: CompletableDeferred + ) + + private val attempts = ConcurrentHashMap() + + fun begin(eventId: String, relayUrls: Set): CompletableDeferred { + val result = CompletableDeferred() + if (relayUrls.isEmpty()) { + result.complete(NostrPublishResult.Rejected(emptyMap())) + return result + } + attempts.put(eventId, Attempt(relayUrls.toMutableSet(), mutableMapOf(), result)) + ?.result + ?.cancel() + return result + } + + fun record( + eventId: String, + relayUrl: String, + accepted: Boolean, + message: String? + ) { + val attempt = attempts[eventId] ?: return + synchronized(attempt) { + if (attempt.result.isCompleted || relayUrl !in attempt.remainingRelays) return + if (accepted) { + attempt.result.complete(NostrPublishResult.Accepted(relayUrl)) + attempts.remove(eventId, attempt) + return + } + attempt.remainingRelays.remove(relayUrl) + attempt.rejections[relayUrl] = message + if (attempt.remainingRelays.isEmpty()) { + attempt.result.complete(NostrPublishResult.Rejected(attempt.rejections.toMap())) + attempts.remove(eventId, attempt) + } + } + } + + fun cancel(eventId: String, result: CompletableDeferred) { + attempts.computeIfPresent(eventId) { _, attempt -> + if (attempt.result === result) null else attempt + } + } +} 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 d44e6e0b..4c8439b5 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -19,12 +19,20 @@ import kotlin.math.pow * Compatible with iOS implementation with Android-specific optimizations */ class NostrRelayManager private constructor() { + private data class QueuedEvent( + val event: NostrEvent, + val pendingRelays: MutableSet, + val queuedAtMs: Long + ) companion object { @JvmStatic val shared = NostrRelayManager() private const val TAG = "NostrRelayManager" + private const val PUBLISH_ACK_TIMEOUT_MS = 10_000L + private const val MESSAGE_QUEUE_RETENTION_MS = 24L * 60 * 60 * 1000 + private const val MAX_MESSAGE_QUEUE_SIZE = 500 /** * Get instance for Android compatibility (context-aware calls) @@ -104,8 +112,9 @@ class NostrRelayManager private constructor() { private val eventDeduplicator = NostrEventDeduplicator.getInstance() // Message queue for reliability - private val messageQueue = mutableListOf>>() + private val messageQueue = mutableListOf() private val messageQueueLock = Any() + private val publishTracker = NostrPublishTracker() // Coroutine scope for background operations private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -227,7 +236,7 @@ class NostrRelayManager private constructor() { "wss://nostr21.com" ) relaysList.addAll(defaultRelayUrls.map { Relay(it) }) - _relays.value = relaysList.toList() + _relays.value = relaysList.map { it.copy() } updateConnectionStatus() Log.d(TAG, "✅ NostrRelayManager initialized with ${relaysList.size} default relays") } catch (e: Exception) { @@ -280,11 +289,19 @@ class NostrRelayManager private constructor() { * Send an event to specified relays (or all if none specified) */ fun sendEvent(event: NostrEvent, relayUrls: List? = null) { - val targetRelays = relayUrls ?: relaysList.map { it.url } + val targetRelays = (relayUrls ?: relaysList.map { it.url }).distinct() + if (targetRelays.isEmpty()) return // Add to queue for reliability synchronized(messageQueueLock) { - messageQueue.add(Pair(event, targetRelays)) + val now = System.currentTimeMillis() + messageQueue.removeAll { + now - it.queuedAtMs > MESSAGE_QUEUE_RETENTION_MS || it.event.id == event.id + } + messageQueue += QueuedEvent(event, targetRelays.toMutableSet(), now) + while (messageQueue.size > MAX_MESSAGE_QUEUE_SIZE) { + messageQueue.removeAt(0) + } } // Attempt immediate send @@ -297,6 +314,41 @@ class NostrRelayManager private constructor() { } } } + + /** + * Publish and wait until at least one target relay accepts the event. + * + * A timeout is not success: callers that persist delivery dedup state must + * retain their own retryable payload until [NostrPublishResult.Accepted]. + */ + suspend fun sendEventAndAwaitAcceptance( + event: NostrEvent, + relayUrls: List? = null, + timeoutMs: Long = PUBLISH_ACK_TIMEOUT_MS + ): NostrPublishResult { + val targets = (relayUrls ?: relaysList.map { it.url }) + .distinct() + .mapNotNull { relayUrl -> + connections[relayUrl]?.let { relayUrl to it } + } + if (targets.isEmpty()) return NostrPublishResult.Rejected(emptyMap()) + val result = publishTracker.begin(event.id, targets.mapTo(mutableSetOf()) { it.first }) + targets.forEach { (relayUrl, webSocket) -> + if (!sendToRelay(event, webSocket, relayUrl)) { + publishTracker.record( + eventId = event.id, + relayUrl = relayUrl, + accepted = false, + message = "WebSocket send failed" + ) + } + } + return try { + withTimeoutOrNull(timeoutMs) { result.await() } ?: NostrPublishResult.TimedOut + } finally { + publishTracker.cancel(event.id, result) + } + } /** * Subscribe to events matching a filter @@ -629,8 +681,8 @@ class NostrRelayManager private constructor() { } } - private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String) { - try { + private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String): Boolean { + return try { val request = NostrRequest.Event(event) val message = gson.toJson(request, NostrRequest::class.java) @@ -645,8 +697,10 @@ class NostrRelayManager private constructor() { } else { Log.e(TAG, "❌ Failed to send event to $relayUrl: WebSocket send failed") } + success } catch (e: Exception) { Log.e(TAG, "❌ Failed to send event to $relayUrl: ${e.message}") + false } } @@ -711,6 +765,13 @@ class NostrRelayManager private constructor() { is NostrResponse.Ok -> { val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId) + publishTracker.record( + eventId = response.eventId, + relayUrl = relayUrl, + accepted = response.accepted, + message = response.message + ) + acknowledgeQueuedEvent(response.eventId, relayUrl, response.accepted) if (response.accepted) { Log.d(TAG, "✅ Event accepted id=${response.eventId.take(16)}... by relay: $relayUrl") } else { @@ -778,6 +839,22 @@ class NostrRelayManager private constructor() { connectToRelay(relayUrl) } } + + private fun acknowledgeQueuedEvent(eventId: String, relayUrl: String, accepted: Boolean) { + synchronized(messageQueueLock) { + val iterator = messageQueue.iterator() + while (iterator.hasNext()) { + val queued = iterator.next() + if (queued.event.id != eventId) continue + if (accepted) { + iterator.remove() + } else { + queued.pendingRelays.remove(relayUrl) + if (queued.pendingRelays.isEmpty()) iterator.remove() + } + } + } + } private fun updateRelayStatus(url: String, isConnected: Boolean, error: Throwable? = null) { val relay = relaysList.find { it.url == url } ?: return @@ -798,7 +875,7 @@ class NostrRelayManager private constructor() { } private fun updateRelaysList() { - _relays.value = relaysList.toList() + _relays.value = relaysList.map { it.copy() } } private fun updateConnectionStatus() { @@ -863,9 +940,9 @@ class NostrRelayManager private constructor() { synchronized(messageQueueLock) { val iterator = messageQueue.iterator() while (iterator.hasNext()) { - val (event, targetRelays) = iterator.next() - if (relayUrl in targetRelays) { - sendToRelay(event, webSocket, relayUrl) + val queued = iterator.next() + if (relayUrl in queued.pendingRelays) { + sendToRelay(queued.event, webSocket, relayUrl) } } } @@ -890,4 +967,5 @@ class NostrRelayManager private constructor() { handleDisconnection(relayUrl, t) } } + } 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 a1217c1d..b3859e0e 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow object AppStateStore { // Global de-dup set by message id to avoid duplicate keys in Compose lists private val seenMessageIds = mutableSetOf() - private val seenPublicMessageKeys = mutableSetOf() + private val publicMessageReconciler = PublicMessageReconciler() private val peerIdsByTransport = mutableMapOf>() // Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set. private val directPeerIdsByTransport = mutableMapOf>() @@ -87,19 +87,14 @@ object AppStateStore { fun addPublicMessage(msg: BitchatMessage) { synchronized(this) { - if (!msg.isBridged) { - val filtered = _publicMessages.value.filterNot { - it.isBridged && it.bridgeRadioMessageIdHint == msg.id - } - if (filtered.size != _publicMessages.value.size) { - _publicMessages.value = filtered - } - } - val publicKey = publicMessageKey(msg) - if (seenMessageIds.contains(msg.id) || seenPublicMessageKeys.contains(publicKey)) return + val result = publicMessageReconciler.reconcile( + existing = _publicMessages.value, + incoming = msg, + messageIdAlreadySeen = msg.id in seenMessageIds + ) + _publicMessages.value = result.messages + if (!result.accepted) return seenMessageIds.add(msg.id) - seenPublicMessageKeys.add(publicKey) - _publicMessages.value = _publicMessages.value + msg } } @@ -218,7 +213,7 @@ object AppStateStore { fun clear() { synchronized(this) { seenMessageIds.clear() - seenPublicMessageKeys.clear() + publicMessageReconciler.clear() peerIdsByTransport.clear() directPeerIdsByTransport.clear() _peers.value = emptyList() @@ -228,14 +223,4 @@ object AppStateStore { } } - private fun publicMessageKey(msg: BitchatMessage): String { - val sender = msg.senderPeerID ?: msg.sender - return listOf( - sender, - msg.timestamp.time.toString(), - msg.type.name, - msg.channel ?: "", - msg.content - ).joinToString("\u001F") - } } 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 150a7082..ebfb381f 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -6,15 +6,27 @@ import com.bitchat.android.favorites.FavoriteControlMessage import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.ReadReceipt import com.bitchat.android.nostr.NostrTransport +import com.bitchat.android.services.bridge.CourierDepositResult +import com.bitchat.android.services.bridge.MeshBridgeService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch /** * Routes messages between local mesh transports and Nostr, matching iOS behavior. */ class MessageRouter private constructor( - private val context: Context, private var mesh: MeshService, - private val nostr: NostrTransport + private val nostr: NostrTransport, + private val currentNostrIdentity: () -> com.bitchat.android.nostr.NostrIdentity? ) { + private data class OutboxMessage( + val content: String, + val recipientNickname: String, + val messageId: String + ) + enum class RouteResult { MESH, NOSTR, @@ -29,8 +41,16 @@ class MessageRouter private constructor( fun getInstance(context: Context, mesh: MeshService): MessageRouter { val instance = INSTANCE ?: synchronized(this) { INSTANCE ?: run { - val nostr = NostrTransport.getInstance(context) - MessageRouter(context.applicationContext, mesh, nostr).also { instance -> + val application = context.applicationContext + val nostr = NostrTransport.getInstance(application) + MessageRouter( + mesh = mesh, + nostr = nostr, + currentNostrIdentity = { + com.bitchat.android.nostr.NostrIdentityBridge + .getCurrentNostrIdentity(application) + } + ).also { instance -> // Register for favorites changes to flush outbox try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(instance.favoriteListener) @@ -46,8 +66,8 @@ class MessageRouter private constructor( } } - // Outbox: peerID -> queued (content, nickname, messageID) - private val outbox = mutableMapOf>>() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val outbox = mutableMapOf>() // Listener for favorites changes to flush outbox when npub mapping appears/changes private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener { @@ -89,16 +109,22 @@ class MessageRouter private constructor( } else { Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}…") val q = outbox.getOrPut(conversationID) { mutableListOf() } - q.add(Triple(content, recipientNickname, messageID)) + q.add(OutboxMessage(content, recipientNickname, messageID)) resolution.noisePublicKey?.let { recipientNoiseKey -> - try { - com.bitchat.android.services.bridge.MeshBridgeService.depositCourierDrop( - content = content, - messageId = messageID, - recipientNoiseKey = recipientNoiseKey - ) - } catch (e: Exception) { - Log.w(TAG, "Courier deposit failed: ${e.message}") + scope.launch { + val result = runCatching { + MeshBridgeService.depositCourierDrop( + content = content, + messageId = messageID, + recipientNoiseKey = recipientNoiseKey + ) + }.getOrElse { error -> + Log.w(TAG, "Courier deposit failed: ${error.message}") + return@launch + } + if (result is CourierDepositResult.Rejected) { + Log.d(TAG, "Courier deposit rejected: ${result.reason}") + } } } Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}…") @@ -126,7 +152,15 @@ class MessageRouter private constructor( if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) { val recipientHex = com.bitchat.android.nostr.GeohashAliasRegistry.get(toPeerID) if (recipientHex != null) { - nostr.sendDeliveryAckGeohash(messageID, recipientHex, try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)!! } catch (_: Exception) { return }) + nostr.sendDeliveryAckGeohash( + messageID, + recipientHex, + try { + currentNostrIdentity() ?: return + } catch (_: Exception) { + return + } + ) return } } @@ -141,7 +175,11 @@ class MessageRouter private constructor( val resolution = ContactDirectory.resolve(toPeerID) val meshTarget = resolution.meshPeerID ?: toPeerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) } if (meshTarget != null && mesh.getPeerInfo(meshTarget)?.isConnected == true && mesh.hasEstablishedSession(meshTarget)) { - val myNpub = try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)?.npub } catch (_: Exception) { null } + val myNpub = try { + currentNostrIdentity()?.npub + } catch (_: Exception) { + null + } val content = FavoriteControlMessage.encode(isFavorite, myNpub) val nickname = mesh.getPeerNicknames()[meshTarget] ?: meshTarget mesh.sendPrivateMessage(content, meshTarget, nickname, null) @@ -158,15 +196,25 @@ class MessageRouter private constructor( Log.d(TAG, "Flushing outbox for ${conversationID.take(16)}… count=${queued.size}") val iterator = queued.iterator() while (iterator.hasNext()) { - val (content, nickname, messageID) = iterator.next() + val queuedMessage = iterator.next() val resolution = ContactDirectory.resolve(conversationID) val meshTarget = resolution.meshPeerID val nostrTarget = resolution.noiseKeyHex ?: conversationID if (meshTarget != null && isReady(mesh, meshTarget)) { - mesh.sendPrivateMessage(content, meshTarget, nickname, messageID) + mesh.sendPrivateMessage( + queuedMessage.content, + meshTarget, + queuedMessage.recipientNickname, + queuedMessage.messageId + ) iterator.remove() } else if (canSendViaNostr(nostrTarget)) { - nostr.sendPrivateMessage(content, nostrTarget, nickname, messageID) + nostr.sendPrivateMessage( + queuedMessage.content, + nostrTarget, + queuedMessage.recipientNickname, + queuedMessage.messageId + ) iterator.remove() } } diff --git a/app/src/main/java/com/bitchat/android/services/PublicMessageReconciler.kt b/app/src/main/java/com/bitchat/android/services/PublicMessageReconciler.kt new file mode 100644 index 00000000..12365c50 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/PublicMessageReconciler.kt @@ -0,0 +1,53 @@ +package com.bitchat.android.services + +import com.bitchat.android.model.BitchatMessage + +/** + * Owns public-timeline replay deduplication and bridge/radio reconciliation. + * + * The store remains responsible for synchronization and cross-timeline IDs; + * this class keeps bridge-specific alias policy independently testable. + */ +internal class PublicMessageReconciler { + data class Result( + val messages: List, + val accepted: Boolean + ) + + private val seenKeys = mutableSetOf() + + fun reconcile( + existing: List, + incoming: BitchatMessage, + messageIdAlreadySeen: Boolean + ): Result { + val withoutBridgeAliases = if (incoming.isBridged) { + existing + } else { + existing.filterNot { + it.isBridged && it.bridgeRadioMessageIdHint == incoming.id + } + } + val key = publicMessageKey(incoming) + if (messageIdAlreadySeen || key in seenKeys) { + return Result(withoutBridgeAliases, accepted = false) + } + seenKeys += key + return Result(withoutBridgeAliases + incoming, accepted = true) + } + + fun clear() { + seenKeys.clear() + } + + private fun publicMessageKey(message: BitchatMessage): String { + val sender = message.senderPeerID ?: message.sender + return listOf( + sender, + message.timestamp.time.toString(), + message.type.name, + message.channel ?: "", + message.content + ).joinToString("\u001F") + } +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/BridgeCollections.kt b/app/src/main/java/com/bitchat/android/services/bridge/BridgeCollections.kt new file mode 100644 index 00000000..50a482c7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/BridgeCollections.kt @@ -0,0 +1,69 @@ +package com.bitchat.android.services.bridge + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +internal class BoundedIdSet(private val capacity: Int) { + private val values = LinkedHashSet() + + fun add(id: String): Boolean { + if (!values.add(id)) return false + while (values.size > capacity) values.remove(values.first()) + return true + } + + fun contains(id: String): Boolean = id in values + + fun clear() = values.clear() +} + +/** + * A small insertion-ordered expiring set. Callers own synchronization; bridge + * coordinators keep each instance confined to their serial dispatcher. + */ +internal class PersistentExpiringIdSet( + private val preferences: SharedPreferences, + private val key: String, + private val capacity: Int +) { + private val gson = Gson() + private val values: LinkedHashMap = load() + + fun contains(id: String, nowMs: Long = System.currentTimeMillis()): Boolean { + prune(nowMs) + return (values[id] ?: return false) > nowMs + } + + fun add(id: String, lifetimeMs: Long, nowMs: Long = System.currentTimeMillis()) { + prune(nowMs) + values.remove(id) + values[id] = nowMs + lifetimeMs + while (values.size > capacity) values.remove(values.keys.first()) + persist() + } + + fun clear() { + values.clear() + preferences.edit { remove(key) } + } + + private fun prune(nowMs: Long) { + val changed = values.entries.removeAll { it.value <= nowMs } + if (changed) persist() + } + + private fun load(): LinkedHashMap { + val type = object : TypeToken>() {}.type + val decoded: Map = runCatching { + preferences.getString(key, null) + ?.let { json -> gson.fromJson>(json, type) } + }.getOrNull() ?: emptyMap() + return LinkedHashMap(decoded) + } + + private fun persist() { + preferences.edit { putString(key, gson.toJson(values)) } + } +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/BridgeModels.kt b/app/src/main/java/com/bitchat/android/services/bridge/BridgeModels.kt new file mode 100644 index 00000000..27cde257 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/BridgeModels.kt @@ -0,0 +1,43 @@ +package com.bitchat.android.services.bridge + +import com.bitchat.android.model.PeerCapabilities + +data class BridgedParticipant( + val pubkey: String, + val nickname: String?, + val lastSeenMs: Long +) { + val displayName: String + get() = "${nickname?.trim()?.takeIf { it.isNotEmpty() } ?: "anon"}#${pubkey.takeLast(4)}" +} + +data class BridgeUiState( + val enabled: Boolean = false, + val nearbyOnly: Boolean = false, + val participants: List = emptyList() +) + +sealed interface CourierDepositResult { + data object Published : CourierDepositResult + data object ForwardedToGateway : CourierDepositResult + data object QueuedLocally : CourierDepositResult + data object AlreadyPublished : CourierDepositResult + data class Rejected(val reason: Reason) : CourierDepositResult + + enum class Reason { + BRIDGE_DISABLED, + CONTENT_TOO_LARGE, + INVALID_MESSAGE, + ENCRYPTION_FAILED + } +} + +internal data class VerifiedBridgePeer( + val peerId: String, + val nickname: String, + val noiseKey: ByteArray, + val signingKey: ByteArray, + val capabilities: PeerCapabilities?, + val bridgeCell: String?, + val lastSeenMs: Long +) diff --git a/app/src/main/java/com/bitchat/android/services/bridge/BridgeProtocolPacketFactory.kt b/app/src/main/java/com/bitchat/android/services/bridge/BridgeProtocolPacketFactory.kt new file mode 100644 index 00000000..247d6a34 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/BridgeProtocolPacketFactory.kt @@ -0,0 +1,46 @@ +package com.bitchat.android.services.bridge + +import com.bitchat.android.mesh.MeshPacketUtils +import com.bitchat.android.mesh.BridgeMeshPort +import com.bitchat.android.model.IdentityAnnouncement +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType + +/** + * Shared construction policy for bridge protocol packets emitted by BLE and + * Wi-Fi Aware mesh implementations. + */ +internal object BridgeProtocolPacketFactory { + fun protocolPacket( + type: MessageType, + payload: ByteArray, + senderPeerId: String, + recipientPeerId: String?, + ttl: UByte, + nowMs: Long = System.currentTimeMillis() + ): BitchatPacket? { + if (payload.isEmpty()) return null + return BitchatPacket( + version = if (payload.size > 0xFFFF) 2u else 1u, + type = type.value, + senderID = MeshPacketUtils.hexStringToByteArray(senderPeerId), + recipientID = recipientPeerId?.let(MeshPacketUtils::hexStringToByteArray), + timestamp = nowMs.toULong(), + payload = payload, + signature = null, + ttl = ttl + ) + } + + fun identityAnnouncement( + nickname: String, + noiseStaticKey: ByteArray, + signingKey: ByteArray + ): IdentityAnnouncement = + IdentityAnnouncement.forLocalPeer( + nickname, + noiseStaticKey, + signingKey, + BridgeMeshPort.advertisedCell() + ) +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/CourierCoordinator.kt b/app/src/main/java/com/bitchat/android/services/bridge/CourierCoordinator.kt new file mode 100644 index 00000000..f1dbd5e6 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/CourierCoordinator.kt @@ -0,0 +1,424 @@ +package com.bitchat.android.services.bridge + +import android.content.Context +import android.content.SharedPreferences +import android.util.Base64 +import com.bitchat.android.identity.SecureIdentityStateManager +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.CourierEnvelope +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.model.PrivateMessagePacket +import com.bitchat.android.nostr.NostrEvent +import com.bitchat.android.nostr.NostrIdentity +import com.bitchat.android.nostr.NostrKind +import com.bitchat.android.nostr.NostrProtocol +import com.bitchat.android.nostr.NostrPublishResult +import com.bitchat.android.nostr.NostrRelayManager +import com.bitchat.android.services.AppStateStore +import com.bitchat.android.services.ContactDirectory +import com.bitchat.android.services.ContactIdentityResolver +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.security.MessageDigest +import java.util.Date + +/** + * Store-and-forward courier state machine. + * + * All mutable state and persistent dedup access are confined to [dispatcher]. + * The bridge facade only supplies immutable peer snapshots and transport + * dependencies. + */ +internal class CourierCoordinator( + context: Context, + preferences: SharedPreferences, + private val relayManager: NostrRelayManager, + private val prekeys: PrekeyManager, + private val meshProvider: () -> MeshService?, + private val peersProvider: () -> List, + private val onPrekeyConsumed: () -> Unit, + private val clock: () -> Long = System::currentTimeMillis, + private val dispatcher: CoroutineDispatcher = Dispatchers.Default.limitedParallelism(1) +) { + private data class PendingDrop( + val envelope: CourierEnvelope, + val dedupKey: String?, + val queueKey: String + ) + + private val appContext = context.applicationContext + private val scope = CoroutineScope(SupervisorJob() + dispatcher) + private val pendingDrops = mutableListOf() + private val signatureAttemptTimes = mutableListOf() + private var subscribedTags: Set = emptySet() + @Volatile + private var enabled = false + private val publishedDropKeys = + PersistentExpiringIdSet(preferences, "published_drop_keys", MAX_TRACKED_IDS) + private val seenDropEventIds = + PersistentExpiringIdSet(preferences, "seen_drop_events", MAX_TRACKED_IDS) + private val openedMessageIds = + PersistentExpiringIdSet(preferences, "opened_courier_messages", MAX_TRACKED_IDS) + + fun setEnabled(value: Boolean) { + // Privacy policy changes take effect before queued coordinator work. + enabled = value + scope.launch { + if (value) { + refreshSubscription() + } else { + closeSubscription() + pendingDrops.clear() + } + } + } + + fun peerStateChanged() { + scope.launch { + if (enabled) refreshSubscription() + } + } + + fun relayConnected() { + scope.launch { + if (!enabled) return@launch + refreshSubscription() + flushPendingDrops() + } + } + + fun handleEnvelope(payload: ByteArray) { + scope.launch { + val envelope = CourierEnvelope.decode(payload) ?: return@launch + if (!validLifetime(envelope)) return@launch + if (isMyTag(envelope.recipientTag)) { + openEnvelope(envelope) + } else if (enabled) { + publishOrQueue(envelope, dedupKey = null) + } + } + } + + suspend fun deposit( + content: String, + messageId: String, + recipientNoiseKey: ByteArray + ): CourierDepositResult = withContext(dispatcher) { + if (!enabled) { + return@withContext CourierDepositResult.Rejected( + CourierDepositResult.Reason.BRIDGE_DISABLED + ) + } + if (content.toByteArray(Charsets.UTF_8).size > MAX_PRIVATE_MESSAGE_BYTES) { + return@withContext CourierDepositResult.Rejected( + CourierDepositResult.Reason.CONTENT_TOO_LARGE + ) + } + val now = clock() + val dedupKey = senderDropKey(messageId, recipientNoiseKey) + if (publishedDropKeys.contains(dedupKey, now)) { + return@withContext CourierDepositResult.AlreadyPublished + } + if (pendingDrops.any { it.queueKey == dedupKey }) { + return@withContext CourierDepositResult.QueuedLocally + } + val privatePacket = PrivateMessagePacket(messageId, content).encode() + ?: return@withContext CourierDepositResult.Rejected( + CourierDepositResult.Reason.INVALID_MESSAGE + ) + val typedPayload = NoisePayload(NoisePayloadType.PRIVATE_MESSAGE, privatePacket).encode() + val livePeer = peersProvider().firstOrNull { + it.noiseKey.contentEquals(recipientNoiseKey) && + meshProvider()?.getPeerInfo(it.peerId)?.isConnected == true + } + val allowsPrekeys = livePeer?.capabilities?.contains( + com.bitchat.android.model.PeerCapabilities.PREKEYS + ) != false + val sealed = runCatching { + prekeys.seal( + typedPayload, + messageId, + recipientNoiseKey, + recipientAdvertisesPrekeys = allowsPrekeys, + nowMs = clock() + ) + }.getOrNull() ?: return@withContext CourierDepositResult.Rejected( + CourierDepositResult.Reason.ENCRYPTION_FAILED + ) + val envelope = CourierEnvelope( + recipientTag = CourierEnvelope.recipientTag( + recipientNoiseKey, + CourierEnvelope.epochDay(now) + ), + expiry = now + CourierEnvelope.MAX_LIFETIME_MS, + ciphertext = sealed.ciphertext, + copies = 1, + prekeyId = sealed.prekeyId + ) + val encoded = envelope.encode() + ?: return@withContext CourierDepositResult.Rejected( + CourierDepositResult.Reason.INVALID_MESSAGE + ) + if (encoded.size > MAX_DROP_BYTES) { + return@withContext CourierDepositResult.Rejected( + CourierDepositResult.Reason.CONTENT_TOO_LARGE + ) + } + if (relayManager.isConnected.value) { + return@withContext publishOrQueue(envelope, dedupKey) + } + val gateway = availableGateway() + if (gateway != null) { + meshProvider()?.sendCourierEnvelope(encoded, gateway.peerId) + return@withContext CourierDepositResult.ForwardedToGateway + } + enqueue(PendingDrop(envelope, dedupKey, dedupKey)) + CourierDepositResult.QueuedLocally + } + + suspend fun wipe() = withContext(dispatcher) { + closeSubscription() + pendingDrops.clear() + signatureAttemptTimes.clear() + publishedDropKeys.clear() + seenDropEventIds.clear() + openedMessageIds.clear() + } + + private fun refreshSubscription() { + if (!enabled) return + val now = clock() + val identityKey = + SecureIdentityStateManager(appContext).loadStaticKey()?.second ?: return + val myTags = CourierEnvelope.candidateTags(identityKey, now).map { it.toHex() }.toSet() + val peerTags = peersProvider() + .asSequence() + .filter { meshProvider()?.getPeerInfo(it.peerId)?.isConnected == true } + .take(MAX_WATCHED_PEERS) + .flatMap { peer -> + CourierEnvelope.candidateTags(peer.noiseKey, now) + .asSequence() + .map { bytes -> bytes.toHex() } + } + .toSet() + val allTags = myTags + peerTags + if (allTags == subscribedTags) return + relayManager.unsubscribe(COURIER_SUBSCRIPTION) + subscribedTags = allTags + if (allTags.isEmpty()) return + relayManager.subscribe( + filter = com.bitchat.android.nostr.NostrFilter.courierDrops( + allTags, + since = now - CourierEnvelope.MAX_LIFETIME_MS + ), + id = COURIER_SUBSCRIPTION, + handler = { event -> scope.launch { handleDropEvent(event) } }, + targetRelayUrls = NostrRelayManager.defaultRelays() + ) + } + + private fun handleDropEvent(event: NostrEvent) { + if (!enabled || + event.kind != NostrKind.COURIER_DROP || + seenDropEventIds.contains(event.id, clock()) || + !allowSignatureAttempt() || + !event.isValidSignature() + ) { + return + } + val data = runCatching { Base64.decode(event.content, Base64.DEFAULT) }.getOrNull() ?: return + if (data.size > MAX_DROP_BYTES) return + val envelope = CourierEnvelope.decode(data) ?: return + if (!validLifetime(envelope)) return + val tagHex = envelope.recipientTag.toHex() + if (event.tags.none { it.size >= 2 && it[0] == "x" && it[1] == tagHex }) return + + if (isMyTag(envelope.recipientTag)) { + if (openEnvelope(envelope)) { + seenDropEventIds.add(event.id, DROP_DEDUP_MS, clock()) + } + return + } + val peer = peersProvider() + .asSequence() + .filter { meshProvider()?.getPeerInfo(it.peerId)?.isConnected == true } + .take(MAX_WATCHED_PEERS) + .firstOrNull { + CourierEnvelope.candidateTags(it.noiseKey, clock()) + .any { candidate -> candidate.contentEquals(envelope.recipientTag) } + } + if (peer != null) { + meshProvider()?.sendCourierEnvelope(data, peer.peerId) + seenDropEventIds.add(event.id, DROP_DEDUP_MS, clock()) + } + } + + private fun openEnvelope(envelope: CourierEnvelope): Boolean { + val opened = runCatching { + prekeys.open(envelope.ciphertext, envelope.prekeyId, clock()) + }.getOrNull() ?: return false + val payload = NoisePayload.decode(opened.payload) ?: return true + if (payload.type != NoisePayloadType.PRIVATE_MESSAGE) return true + val privateMessage = PrivateMessagePacket.decode(payload.data) ?: return true + if (openedMessageIds.contains(privateMessage.messageID, clock())) return true + val senderPeerId = ContactIdentityResolver.peerIdForNoiseKey(opened.senderStaticKey) + val senderResolution = ContactDirectory.resolve(opened.senderStaticKey.toHex()) + val message = BitchatMessage( + id = privateMessage.messageID, + sender = senderResolution.displayName + ?: peersProvider().firstOrNull { it.peerId == senderPeerId }?.nickname + ?: "Unknown", + content = privateMessage.content, + timestamp = Date(clock()), + isPrivate = true, + recipientNickname = meshProvider()?.myPeerID, + senderPeerID = senderPeerId + ) + AppStateStore.addPrivateMessage( + ContactIdentityResolver.contactConversationIdForNoiseKey(opened.senderStaticKey), + message + ) + openedMessageIds.add(privateMessage.messageID, DROP_DEDUP_MS, clock()) + if (opened.consumedPrekey) onPrekeyConsumed() + return true + } + + private suspend fun publishOrQueue( + envelope: CourierEnvelope, + dedupKey: String? + ): CourierDepositResult { + if (!validLifetime(envelope)) { + return CourierDepositResult.Rejected(CourierDepositResult.Reason.INVALID_MESSAGE) + } + if (!relayManager.isConnected.value) { + enqueue( + PendingDrop( + envelope, + dedupKey, + dedupKey ?: envelopeQueueKey(envelope) + ) + ) + return CourierDepositResult.QueuedLocally + } + val encoded = envelope.encode() + ?: return CourierDepositResult.Rejected(CourierDepositResult.Reason.INVALID_MESSAGE) + if (encoded.size > MAX_DROP_BYTES) { + return CourierDepositResult.Rejected(CourierDepositResult.Reason.CONTENT_TOO_LARGE) + } + val event = NostrProtocol.createCourierDropEvent( + envelope = encoded, + recipientTagHex = envelope.recipientTag.toHex(), + expiresAtMs = envelope.expiry, + senderIdentity = NostrIdentity.generate() + ) + return when ( + relayManager.sendEventAndAwaitAcceptance( + event, + NostrRelayManager.defaultRelays() + ) + ) { + is NostrPublishResult.Accepted -> { + if (!enabled) { + return CourierDepositResult.Rejected( + CourierDepositResult.Reason.BRIDGE_DISABLED + ) + } + dedupKey?.let { publishedDropKeys.add(it, DROP_DEDUP_MS, clock()) } + CourierDepositResult.Published + } + is NostrPublishResult.Rejected, + NostrPublishResult.TimedOut -> { + if (!enabled) { + return CourierDepositResult.Rejected( + CourierDepositResult.Reason.BRIDGE_DISABLED + ) + } + enqueue( + PendingDrop( + envelope, + dedupKey, + dedupKey ?: envelopeQueueKey(envelope) + ) + ) + CourierDepositResult.QueuedLocally + } + } + } + + private fun enqueue(drop: PendingDrop) { + if (pendingDrops.any { it.queueKey == drop.queueKey }) return + pendingDrops += drop + while (pendingDrops.size > MAX_PENDING_DROPS) pendingDrops.removeAt(0) + } + + private suspend fun flushPendingDrops() { + if (!enabled || !relayManager.isConnected.value) return + val queued = pendingDrops.toList() + pendingDrops.clear() + queued.forEach { publishOrQueue(it.envelope, it.dedupKey) } + } + + private fun closeSubscription() { + relayManager.unsubscribe(COURIER_SUBSCRIPTION) + subscribedTags = emptySet() + } + + private fun availableGateway(): VerifiedBridgePeer? = + peersProvider().firstOrNull { peer -> + peer.capabilities?.contains(com.bitchat.android.model.PeerCapabilities.BRIDGE) == true && + peer.bridgeCell != null && + meshProvider()?.getPeerInfo(peer.peerId)?.isConnected == true + } + + private fun isMyTag(tag: ByteArray): Boolean { + val ownKey = SecureIdentityStateManager(appContext).loadStaticKey()?.second ?: return false + return CourierEnvelope.candidateTags(ownKey, clock()).any { it.contentEquals(tag) } + } + + private fun validLifetime(envelope: CourierEnvelope): Boolean { + val now = clock() + return !envelope.isExpired(now) && + envelope.expiry > 0 && + envelope.expiry - now <= CourierEnvelope.MAX_LIFETIME_MS + } + + private fun senderDropKey(messageId: String, recipientNoiseKey: ByteArray): String { + val material = recipientNoiseKey.toHex() + "|" + messageId + return MessageDigest.getInstance("SHA-256") + .digest(material.toByteArray(Charsets.UTF_8)) + .toHex() + } + + private fun envelopeQueueKey(envelope: CourierEnvelope): String = + MessageDigest.getInstance("SHA-256") + .digest(envelope.recipientTag + envelope.ciphertext) + .toHex() + + private fun allowSignatureAttempt(): Boolean { + val now = clock() + signatureAttemptTimes.removeAll { now - it >= RATE_WINDOW_MS } + if (signatureAttemptTimes.size >= SIGNATURE_ATTEMPTS_PER_MINUTE) return false + signatureAttemptTimes += now + return true + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + private companion object { + const val COURIER_SUBSCRIPTION = "mesh-bridge-courier" + const val MAX_TRACKED_IDS = 512 + const val MAX_WATCHED_PEERS = 16 + const val MAX_PENDING_DROPS = 20 + const val MAX_DROP_BYTES = 20 * 1024 + const val MAX_PRIVATE_MESSAGE_BYTES = 255 + const val DROP_DEDUP_MS = 24L * 60 * 60 * 1000 + const val RATE_WINDOW_MS = 60_000L + const val SIGNATURE_ATTEMPTS_PER_MINUTE = 720 + } +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt b/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt index cb2996d5..6354e7cf 100644 --- a/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt +++ b/app/src/main/java/com/bitchat/android/services/bridge/MeshBridgeService.kt @@ -1,38 +1,26 @@ package com.bitchat.android.services.bridge import android.content.Context -import android.util.Base64 import android.util.Log import androidx.core.content.edit import com.bitchat.android.geohash.Geohash import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager -import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.mesh.MeshService +import com.bitchat.android.mesh.BridgeMeshDelegate import com.bitchat.android.model.BitchatMessage -import com.bitchat.android.model.CourierEnvelope import com.bitchat.android.model.IdentityAnnouncement -import com.bitchat.android.model.NoisePayload -import com.bitchat.android.model.NoisePayloadType import com.bitchat.android.model.NostrCarrierPacket import com.bitchat.android.model.PeerCapabilities -import com.bitchat.android.model.PrekeyBundle -import com.bitchat.android.model.PrivateMessagePacket import com.bitchat.android.nostr.MeshMessageIdentity import com.bitchat.android.nostr.NostrEvent import com.bitchat.android.nostr.NostrFilter -import com.bitchat.android.nostr.NostrIdentity import com.bitchat.android.nostr.NostrIdentityBridge import com.bitchat.android.nostr.NostrKind import com.bitchat.android.nostr.NostrProtocol import com.bitchat.android.nostr.NostrRelayManager import com.bitchat.android.protocol.BitchatPacket -import com.bitchat.android.service.MeshServiceHolder import com.bitchat.android.services.AppStateStore -import com.bitchat.android.services.ContactDirectory -import com.bitchat.android.services.ContactIdentityResolver -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -41,13 +29,12 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters -import org.bouncycastle.crypto.signers.Ed25519Signer -import java.security.MessageDigest import java.util.Date +import java.util.concurrent.atomic.AtomicReference import kotlin.math.abs -import kotlin.random.Random /** * Opt-in bridge policy shared by foreground transport and Compose UI. @@ -56,26 +43,7 @@ import kotlin.random.Random * and did not mark the message nearby-only. Passive `fromBridge` reception is * accepted regardless of the switch because it exposes no local traffic. */ -object MeshBridgeService { - data class BridgedParticipant( - val pubkey: String, - val nickname: String?, - val lastSeenMs: Long - ) { - val displayName: String - get() = "${nickname?.trim()?.takeIf { it.isNotEmpty() } ?: "anon"}#${pubkey.takeLast(4)}" - } - - private data class VerifiedPeer( - val peerId: String, - val nickname: String, - val noiseKey: ByteArray, - val signingKey: ByteArray, - val capabilities: PeerCapabilities?, - val bridgeCell: String?, - val lastSeenMs: Long - ) - +object MeshBridgeService : BridgeMeshDelegate { private data class PendingUplink( val depositor: String, val cell: String, @@ -87,16 +55,10 @@ object MeshBridgeService { val event: NostrEvent ) - private data class PendingDrop( - val envelope: CourierEnvelope, - val dedupKey: String? - ) - private const val TAG = "MeshBridgeService" private const val PREFS = "bitchat_bridge" private const val KEY_ENABLED = "bridge_enabled_v1" private const val BRIDGE_SUBSCRIPTION = "mesh-bridge-rendezvous" - private const val COURIER_SUBSCRIPTION = "mesh-bridge-courier" private const val CELL_PRECISION = 6 private const val MAX_EVENT_AGE_MS = 15L * 60 * 1000 private const val MAX_CONTENT_BYTES = 16_000 @@ -112,13 +74,9 @@ object MeshBridgeService { private const val INBOUND_PER_MINUTE = 600 private const val INBOUND_PER_SIGNER_PER_MINUTE = 120 private const val SIGNATURE_ATTEMPTS_PER_MINUTE = 720 - private const val MAX_WATCHED_COURIER_PEERS = 16 - private const val MAX_PENDING_DROPS = 20 - private const val MAX_DROP_BYTES = 20 * 1024 - private const val PREKEY_REBROADCAST_MS = 60L * 60 * 1000 - private const val DROP_DEDUP_MS = 24L * 60 * 60 * 1000 - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default.limitedParallelism(1)) + private val dispatcher = Dispatchers.Default.limitedParallelism(1) + private val scope = CoroutineScope(SupervisorJob() + dispatcher) private val _isEnabled = MutableStateFlow(false) val isEnabled: StateFlow = _isEnabled.asStateFlow() private val _nearbyOnly = MutableStateFlow(false) @@ -131,13 +89,17 @@ object MeshBridgeService { @Volatile private var appContext: Context? = null private var relayManager: NostrRelayManager? = null - private var prekeys: PrekeyManager? = null + private var prekeyCoordinator: PrekeyCoordinator? = null + private var courierCoordinator: CourierCoordinator? = null private var prefs: android.content.SharedPreferences? = null + @Volatile + private var meshProvider: () -> MeshService? = { null } + private var clock: () -> Long = System::currentTimeMillis + private var jitter: (Long, Long) -> Long = kotlin.random.Random::nextLong private var localLocationCell: String? = null private var subscribedCells: Set = emptySet() - private var subscribedCourierTags: Set = emptySet() - private val verifiedPeers = linkedMapOf() - private val pendingPrekeyPackets = linkedMapOf() + private val verifiedPeers = linkedMapOf() + private val verifiedPeerSnapshot = AtomicReference>(emptyList()) private val publishedEventIds = BoundedIdSet(MAX_TRACKED_IDS) private val receivedEventIds = BoundedIdSet(MAX_TRACKED_IDS) private val meshBroadcastEventIds = BoundedIdSet(MAX_TRACKED_IDS) @@ -146,7 +108,6 @@ object MeshBridgeService { private val radioMessageIds = BoundedIdSet(MAX_TRACKED_IDS) private val queuedUplinks = mutableListOf() private val pendingDownlinks = mutableListOf() - private val pendingDrops = mutableListOf() private val participants = linkedMapOf() private val uplinkTimes = mutableMapOf>() private val inboundTimes = mutableListOf() @@ -155,26 +116,47 @@ object MeshBridgeService { private val signatureAttemptTimes = mutableListOf() private var downlinkJob: Job? = null private var presenceJob: Job? = null - private var lastPrekeyBroadcastMs = 0L - private var publishedDropKeys: PersistentExpiringIdSet? = null - private var seenDropEventIds: PersistentExpiringIdSet? = null - private var openedCourierMessageIds: PersistentExpiringIdSet? = null - fun initialize(context: Context) { + fun initialize( + context: Context, + meshProvider: () -> MeshService? = { + com.bitchat.android.service.MeshServiceHolder.unifiedMeshService + }, + clock: () -> Long = System::currentTimeMillis, + jitter: (Long, Long) -> Long = kotlin.random.Random::nextLong + ) { if (appContext != null) return synchronized(this) { if (appContext != null) return val application = context.applicationContext appContext = application + this.meshProvider = meshProvider + this.clock = clock + this.jitter = jitter relayManager = NostrRelayManager.getInstance(application) - prekeys = PrekeyManager.getInstance(application) + val prekeyManager = PrekeyManager.getInstance(application) prefs = application.getSharedPreferences(PREFS, Context.MODE_PRIVATE) _isEnabled.value = loadEnabledWithMigration(prefs!!) PeerCapabilities.setBridgeEnabled(_isEnabled.value) - publishedDropKeys = PersistentExpiringIdSet(prefs!!, "published_drop_keys", MAX_TRACKED_IDS) - seenDropEventIds = PersistentExpiringIdSet(prefs!!, "seen_drop_events", MAX_TRACKED_IDS) - openedCourierMessageIds = - PersistentExpiringIdSet(prefs!!, "opened_courier_messages", MAX_TRACKED_IDS) + prekeyCoordinator = PrekeyCoordinator( + manager = prekeyManager, + meshProvider = ::currentMesh, + peersProvider = verifiedPeerSnapshot::get, + clock = clock + ) + courierCoordinator = CourierCoordinator( + context = application, + preferences = checkNotNull(prefs), + relayManager = checkNotNull(relayManager), + prekeys = prekeyManager, + meshProvider = ::currentMesh, + peersProvider = verifiedPeerSnapshot::get, + onPrekeyConsumed = { + scope.launch { prekeyCoordinator?.broadcast(force = true) } + }, + clock = clock + ) + courierCoordinator?.setEnabled(_isEnabled.value) } val location = LocationChannelManager.getInstance(context) @@ -192,21 +174,34 @@ object MeshBridgeService { if (connected) { refreshRendezvous(forceSubscriptions = true) flushQueuedUplinks() - flushPendingDrops() publishPresence() } } } + scope.launch { + val defaultRelays = NostrRelayManager.defaultRelays().toSet() + relayManager?.relays + ?.map { relays -> + relays.asSequence() + .filter { it.isConnected && it.url in defaultRelays } + .map { it.url } + .toSet() + } + ?.distinctUntilChanged() + ?.collect { connectedDefaults -> + if (connectedDefaults.isNotEmpty()) courierCoordinator?.relayConnected() + } + } scope.launch { if (_isEnabled.value) { relayManager?.connect() location.refreshChannels() refreshRendezvous(forceSubscriptions = true) - refreshCourierSubscription() + courierCoordinator?.peerStateChanged() } startPresenceLoop() delay(2_000) - broadcastPrekeyBundle(force = true) + prekeyCoordinator?.broadcast(force = true) } } @@ -216,12 +211,12 @@ object MeshBridgeService { prefs?.edit { putBoolean(KEY_ENABLED, enabled) } PeerCapabilities.setBridgeEnabled(enabled) _nearbyOnly.value = false + courierCoordinator?.setEnabled(enabled) scope.launch { if (!enabled) { closeSubscriptions() queuedUplinks.clear() pendingDownlinks.clear() - pendingDrops.clear() participants.clear() publishParticipants() _activeCell.value = null @@ -229,8 +224,8 @@ object MeshBridgeService { relayManager?.connect() LocationChannelManager.getInstance(requireContext()).refreshChannels() refreshRendezvous(forceSubscriptions = true) - refreshCourierSubscription() - broadcastPrekeyBundle(force = true) + courierCoordinator?.peerStateChanged() + prekeyCoordinator?.broadcast(force = true) } currentMesh()?.sendBroadcastAnnounce() } @@ -241,9 +236,9 @@ object MeshBridgeService { } /** Cell included in announce TLV 0x06 while the bridge switch is on. */ - fun advertisedCell(): String? = _activeCell.value.takeIf { _isEnabled.value } + override fun advertisedCell(): String? = _activeCell.value.takeIf { _isEnabled.value } - fun bridgeOutgoing( + override fun bridgeOutgoing( content: String, senderPeerId: String, timestampMs: Long, @@ -278,7 +273,7 @@ object MeshBridgeService { } /** Called only after a public radio packet's Ed25519 signature was accepted. */ - fun handleAuthenticatedRadioMessage(messageId: String) { + override fun handleAuthenticatedRadioMessage(messageId: String) { if (messageId.isBlank()) return scope.launch { radioMessageIds.add(messageId) @@ -288,33 +283,34 @@ object MeshBridgeService { } } - fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement) { + override fun handleVerifiedAnnouncement(peerId: String, announcement: IdentityAnnouncement) { scope.launch { - val peer = VerifiedPeer( + val peer = VerifiedBridgePeer( peerId = peerId, nickname = announcement.nickname, noiseKey = announcement.noisePublicKey.copyOf(), signingKey = announcement.signingPublicKey.copyOf(), capabilities = announcement.capabilities, bridgeCell = announcement.bridgeGeohash?.takeIf(::isValidGeohash), - lastSeenMs = System.currentTimeMillis() + lastSeenMs = clock() ) verifiedPeers[peerId] = peer while (verifiedPeers.size > 200) verifiedPeers.remove(verifiedPeers.keys.first()) - pendingPrekeyPackets.remove(peerId)?.let { ingestPrekeyPacket(it) } + publishVerifiedPeerSnapshot() + prekeyCoordinator?.handlePeerVerified(peerId) if (_isEnabled.value) { refreshRendezvous() - refreshCourierSubscription() + courierCoordinator?.peerStateChanged() } - broadcastPrekeyBundle() + prekeyCoordinator?.broadcast() } } - fun handlePrekeyPacket(packet: BitchatPacket) { - scope.launch { ingestPrekeyPacket(packet) } + override fun handlePrekeyPacket(packet: BitchatPacket) { + scope.launch { prekeyCoordinator?.handlePacket(packet) } } - fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) { + override fun handleCarrier(payload: ByteArray, fromPeerId: String, directedToUs: Boolean) { scope.launch { val carrier = NostrCarrierPacket.decode(payload) ?: return@launch when (carrier.direction) { @@ -330,87 +326,26 @@ object MeshBridgeService { } } - fun handleCourierEnvelope(payload: ByteArray) { - scope.launch { - val envelope = CourierEnvelope.decode(payload) ?: return@launch - if (!validEnvelopeLifetime(envelope)) return@launch - if (isMyCourierTag(envelope.recipientTag)) { - openCourierEnvelope(envelope) - } else if (_isEnabled.value) { - publishOrQueueDrop(envelope, dedupKey = null) - } - } + override fun handleCourierEnvelope(payload: ByteArray) { + courierCoordinator?.handleEnvelope(payload) } - /** - * Deposit an offline DM either directly to relays or through a reachable - * bridge peer. Returns true when a compatible envelope was produced and - * accepted by one of those paths. - */ - fun depositCourierDrop( + suspend fun depositCourierDrop( content: String, messageId: String, recipientNoiseKey: ByteArray - ): Boolean { - if (!_isEnabled.value || content.toByteArray(Charsets.UTF_8).size > 255) return false - val privatePacket = PrivateMessagePacket(messageId, content).encode() ?: return false - val typedPayload = NoisePayload(NoisePayloadType.PRIVATE_MESSAGE, privatePacket).encode() - val livePeer = verifiedPeers.values.firstOrNull { - it.noiseKey.contentEquals(recipientNoiseKey) && - currentMesh()?.getPeerInfo(it.peerId)?.isConnected == true - } - val allowsPrekeys = livePeer?.capabilities?.contains(PeerCapabilities.PREKEYS) != false - val sealed = runCatching { - prekeys?.seal( - typedPayload, - messageId, - recipientNoiseKey, - recipientAdvertisesPrekeys = allowsPrekeys - ) - }.getOrNull() ?: return false - val now = System.currentTimeMillis() - val envelope = CourierEnvelope( - recipientTag = CourierEnvelope.recipientTag( - recipientNoiseKey, - CourierEnvelope.epochDay(now) - ), - expiry = now + CourierEnvelope.MAX_LIFETIME_MS, - ciphertext = sealed.ciphertext, - copies = 1, - prekeyId = sealed.prekeyId - ) - val encoded = envelope.encode() ?: return false - if (encoded.size > MAX_DROP_BYTES) return false - val dedupKey = senderDropKey(messageId, recipientNoiseKey) - if (publishedDropKeys?.contains(dedupKey) == true) return true + ): CourierDepositResult = + courierCoordinator?.deposit(content, messageId, recipientNoiseKey) + ?: CourierDepositResult.Rejected(CourierDepositResult.Reason.BRIDGE_DISABLED) - val relayConnected = relayManager?.isConnected?.value == true - if (relayConnected) { - scope.launch { publishOrQueueDrop(envelope, dedupKey) } - return true - } - val gateway = availableBridgePeer() - if (gateway != null) { - currentMesh()?.sendCourierEnvelope(encoded, gateway.peerId) - return true - } - scope.launch { enqueueDrop(PendingDrop(envelope, dedupKey)) } - return true - } - - fun wipe() { - // Clear persistent cryptographic and dedup material immediately. The - // rest of the process-local bridge state remains serialized on scope. - prekeys?.wipe() - publishedDropKeys?.clear() - seenDropEventIds?.clear() - openedCourierMessageIds?.clear() - scope.launch { + suspend fun wipe() { + courierCoordinator?.wipe() + kotlinx.coroutines.withContext(dispatcher) { closeSubscriptions() queuedUplinks.clear() pendingDownlinks.clear() - pendingDrops.clear() verifiedPeers.clear() + publishVerifiedPeerSnapshot() participants.clear() publishedEventIds.clear() receivedEventIds.clear() @@ -418,11 +353,23 @@ object MeshBridgeService { rebroadcastEventIds.clear() injectedEventIds.clear() radioMessageIds.clear() + prekeyCoordinator?.wipe() _nearbyOnly.value = false publishParticipants() } } + private fun publishVerifiedPeerSnapshot() { + verifiedPeerSnapshot.set( + verifiedPeers.values.map { peer -> + peer.copy( + noiseKey = peer.noiseKey.copyOf(), + signingKey = peer.signingKey.copyOf() + ) + } + ) + } + private suspend fun refreshRendezvous(forceSubscriptions: Boolean = false) { if (!_isEnabled.value) return val cell = currentCell() @@ -444,7 +391,7 @@ object MeshBridgeService { relayManager?.subscribe( filter = NostrFilter.bridgeRendezvous( cells, - since = System.currentTimeMillis() - MAX_EVENT_AGE_MS + since = clock() - MAX_EVENT_AGE_MS ), id = BRIDGE_SUBSCRIPTION, handler = { event -> scope.launch { handleRendezvousEvent(event) } }, @@ -459,7 +406,7 @@ object MeshBridgeService { return availableBridgePeer()?.bridgeCell?.take(CELL_PRECISION) } - private fun availableBridgePeer(): VerifiedPeer? = + private fun availableBridgePeer(): VerifiedBridgePeer? = verifiedPeers.values.firstOrNull { peer -> peer.capabilities?.contains(PeerCapabilities.BRIDGE) == true && peer.bridgeCell != null && @@ -586,10 +533,10 @@ object MeshBridgeService { private fun scheduleDownlink(jitter: Boolean) { if (downlinkJob?.isActive == true || pendingDownlinks.isEmpty()) return - val now = System.currentTimeMillis() + val now = clock() downlinkTimes.removeAll { now - it >= 60_000 } val waitMs = if (jitter) { - Random.nextLong(200, 1_501) + jitter(200, 1_501) } else { (downlinkTimes.minOrNull()?.plus(60_000)?.minus(now) ?: 50).coerceAtLeast(50) } @@ -600,7 +547,7 @@ object MeshBridgeService { } private fun drainDownlinks() { - val now = System.currentTimeMillis() + val now = clock() downlinkTimes.removeAll { now - it >= 60_000 } while (pendingDownlinks.isNotEmpty() && downlinkTimes.size < DOWNLINKS_PER_MINUTE) { val item = pendingDownlinks.removeAt(0) @@ -619,7 +566,7 @@ object MeshBridgeService { )?.encode() ?: continue currentMesh()?.sendNostrCarrier(payload) rebroadcastEventIds.add(item.event.id) - downlinkTimes += System.currentTimeMillis() + downlinkTimes += clock() } if (pendingDownlinks.isNotEmpty()) scheduleDownlink(jitter = false) } @@ -654,16 +601,16 @@ object MeshBridgeService { pruneParticipants() if (_isEnabled.value) { refreshRendezvous() - refreshCourierSubscription() + courierCoordinator?.peerStateChanged() publishPresence() - broadcastPrekeyBundle() + prekeyCoordinator?.broadcast() } } } } private fun recordParticipant(pubkey: String, nickname: String?) { - val now = System.currentTimeMillis() + val now = clock() participants.entries.removeAll { now - it.value.lastSeenMs > PARTICIPANT_FRESH_MS } if (pubkey !in participants && participants.size >= MAX_PARTICIPANTS) { participants.minByOrNull { it.value.lastSeenMs }?.key?.let(participants::remove) @@ -678,7 +625,7 @@ object MeshBridgeService { } private fun pruneParticipants() { - val now = System.currentTimeMillis() + val now = clock() participants.entries.removeAll { now - it.value.lastSeenMs > PARTICIPANT_FRESH_MS } publishParticipants() } @@ -688,7 +635,7 @@ object MeshBridgeService { } private fun allowUplink(depositor: String): Boolean { - val now = System.currentTimeMillis() + val now = clock() val times = uplinkTimes.getOrPut(depositor) { mutableListOf() } times.removeAll { now - it >= 60_000 } if (times.size >= UPLINKS_PER_MINUTE_PER_DEPOSITOR) return false @@ -697,7 +644,7 @@ object MeshBridgeService { } private fun allowInbound(signer: String): Boolean { - val now = System.currentTimeMillis() + val now = clock() inboundTimes.removeAll { now - it >= 60_000 } if (inboundTimes.size >= INBOUND_PER_MINUTE) return false val signerTimes = inboundTimesBySigner.getOrPut(signer) { mutableListOf() } @@ -709,184 +656,16 @@ object MeshBridgeService { } private fun allowSignatureAttempt(): Boolean { - val now = System.currentTimeMillis() + val now = clock() signatureAttemptTimes.removeAll { now - it >= 60_000 } if (signatureAttemptTimes.size >= SIGNATURE_ATTEMPTS_PER_MINUTE) return false signatureAttemptTimes += now return true } - private fun ingestPrekeyPacket(packet: BitchatPacket) { - val bundle = PrekeyBundle.decode(packet.payload) ?: return - val owner = ContactIdentityResolver.peerIdForNoiseKey(bundle.noiseStaticPublicKey) - val packetOwner = packet.senderID.toHex() - if (owner != packetOwner) return - val peer = verifiedPeers[owner] - if (peer == null || - !peer.noiseKey.contentEquals(bundle.noiseStaticPublicKey) - ) { - if (pendingPrekeyPackets.size < 64 || owner in pendingPrekeyPackets) { - pendingPrekeyPackets[owner] = packet - } - return - } - val signature = packet.signature ?: return - val signingData = packet.toBinaryDataForSigning() ?: return - if (!verifyEd25519(signature, signingData, peer.signingKey)) return - prekeys?.verifyAndIngest(bundle, peer.noiseKey, peer.signingKey) - } - - private fun broadcastPrekeyBundle(force: Boolean = false) { - val now = System.currentTimeMillis() - if (!force && now - lastPrekeyBroadcastMs < PREKEY_REBROADCAST_MS) return - val bundle = prekeys?.currentSignedBundle(now) ?: return - val encoded = bundle.encode() ?: return - lastPrekeyBroadcastMs = now - currentMesh()?.sendPrekeyBundle(encoded) - } - - private fun refreshCourierSubscription() { - if (!_isEnabled.value) return - val identityKey = SecureIdentityStateManager(requireContext()).loadStaticKey()?.second ?: return - val myTags = CourierEnvelope.candidateTags(identityKey).map { it.toHex() }.toSet() - val peerTags = verifiedPeers.values - .asSequence() - .filter { currentMesh()?.getPeerInfo(it.peerId)?.isConnected == true } - .take(MAX_WATCHED_COURIER_PEERS) - .flatMap { - CourierEnvelope.candidateTags(it.noiseKey) - .asSequence() - .map { bytes -> bytes.toHex() } - } - .toSet() - val allTags = myTags + peerTags - if (allTags == subscribedCourierTags) return - relayManager?.unsubscribe(COURIER_SUBSCRIPTION) - subscribedCourierTags = allTags - if (allTags.isEmpty()) return - relayManager?.subscribe( - filter = NostrFilter.courierDrops( - allTags, - since = System.currentTimeMillis() - CourierEnvelope.MAX_LIFETIME_MS - ), - id = COURIER_SUBSCRIPTION, - handler = { event -> scope.launch { handleDropEvent(event) } }, - targetRelayUrls = NostrRelayManager.defaultRelays() - ) - } - - private fun handleDropEvent(event: NostrEvent) { - if (!_isEnabled.value || - event.kind != NostrKind.COURIER_DROP || - seenDropEventIds?.contains(event.id) == true || - !allowSignatureAttempt() || - !event.isValidSignature() - ) { - return - } - val data = runCatching { Base64.decode(event.content, Base64.DEFAULT) }.getOrNull() ?: return - if (data.size > MAX_DROP_BYTES) return - val envelope = CourierEnvelope.decode(data) ?: return - if (!validEnvelopeLifetime(envelope)) return - val tagHex = envelope.recipientTag.toHex() - if (event.tags.none { it.size >= 2 && it[0] == "x" && it[1] == tagHex }) return - - if (isMyCourierTag(envelope.recipientTag)) { - if (openCourierEnvelope(envelope)) { - seenDropEventIds?.add(event.id, DROP_DEDUP_MS) - } - return - } - val peer = verifiedPeers.values - .asSequence() - .filter { currentMesh()?.getPeerInfo(it.peerId)?.isConnected == true } - .take(MAX_WATCHED_COURIER_PEERS) - .firstOrNull { - CourierEnvelope.candidateTags(it.noiseKey) - .any { candidate -> candidate.contentEquals(envelope.recipientTag) } - } - if (peer != null) { - currentMesh()?.sendCourierEnvelope(data, peer.peerId) - seenDropEventIds?.add(event.id, DROP_DEDUP_MS) - } - } - - private fun openCourierEnvelope(envelope: CourierEnvelope): Boolean { - val opened = runCatching { - prekeys?.open(envelope.ciphertext, envelope.prekeyId) - }.getOrNull() ?: return false - val payload = NoisePayload.decode(opened.payload) ?: return true - if (payload.type != NoisePayloadType.PRIVATE_MESSAGE) return true - val privateMessage = PrivateMessagePacket.decode(payload.data) ?: return true - if (openedCourierMessageIds?.contains(privateMessage.messageID) == true) return true - val senderPeerId = ContactIdentityResolver.peerIdForNoiseKey(opened.senderStaticKey) - val senderResolution = ContactDirectory.resolve(opened.senderStaticKey.toHex()) - val message = BitchatMessage( - id = privateMessage.messageID, - sender = senderResolution.displayName ?: verifiedPeers[senderPeerId]?.nickname ?: "Unknown", - content = privateMessage.content, - timestamp = Date(), - isPrivate = true, - recipientNickname = currentMesh()?.myPeerID, - senderPeerID = senderPeerId - ) - AppStateStore.addPrivateMessage( - ContactIdentityResolver.contactConversationIdForNoiseKey(opened.senderStaticKey), - message - ) - openedCourierMessageIds?.add(privateMessage.messageID, DROP_DEDUP_MS) - if (opened.consumedPrekey) broadcastPrekeyBundle(force = true) - return true - } - - private fun publishOrQueueDrop(envelope: CourierEnvelope, dedupKey: String?) { - if (!validEnvelopeLifetime(envelope)) return - if (relayManager?.isConnected?.value != true) { - enqueueDrop(PendingDrop(envelope, dedupKey)) - return - } - val encoded = envelope.encode() ?: return - if (encoded.size > MAX_DROP_BYTES) return - val event = NostrProtocol.createCourierDropEvent( - envelope = encoded, - recipientTagHex = envelope.recipientTag.toHex(), - expiresAtMs = envelope.expiry, - senderIdentity = NostrIdentity.generate() - ) - relayManager?.sendEvent(event, NostrRelayManager.defaultRelays()) - dedupKey?.let { publishedDropKeys?.add(it, DROP_DEDUP_MS) } - } - - private fun enqueueDrop(drop: PendingDrop) { - if (drop.dedupKey != null && pendingDrops.any { it.dedupKey == drop.dedupKey }) return - pendingDrops += drop - while (pendingDrops.size > MAX_PENDING_DROPS) pendingDrops.removeAt(0) - } - - private fun flushPendingDrops() { - if (!_isEnabled.value || relayManager?.isConnected?.value != true) return - val queued = pendingDrops.toList() - pendingDrops.clear() - queued.forEach { publishOrQueueDrop(it.envelope, it.dedupKey) } - } - - private fun isMyCourierTag(tag: ByteArray): Boolean { - val ownKey = SecureIdentityStateManager(requireContext()).loadStaticKey()?.second ?: return false - return CourierEnvelope.candidateTags(ownKey).any { it.contentEquals(tag) } - } - - private fun validEnvelopeLifetime(envelope: CourierEnvelope): Boolean { - val now = System.currentTimeMillis() - return !envelope.isExpired(now) && - envelope.expiry > 0 && - envelope.expiry - now <= CourierEnvelope.MAX_LIFETIME_MS - } - private fun closeSubscriptions() { relayManager?.unsubscribe(BRIDGE_SUBSCRIPTION) - relayManager?.unsubscribe(COURIER_SUBSCRIPTION) subscribedCells = emptySet() - subscribedCourierTags = emptySet() } private fun isOwnEvent(event: NostrEvent, cell: String): Boolean = @@ -896,7 +675,7 @@ object MeshBridgeService { }.getOrDefault(false) private fun isFresh(event: NostrEvent): Boolean = - abs(System.currentTimeMillis() - event.createdAt * 1000L) <= MAX_EVENT_AGE_MS + abs(clock() - event.createdAt * 1000L) <= MAX_EVENT_AGE_MS private fun isValidGeohash(value: String): Boolean = value.length in 1..12 && @@ -905,26 +684,7 @@ object MeshBridgeService { private fun NostrEvent.tagValue(name: String): String? = tags.firstOrNull { it.size >= 2 && it[0] == name }?.get(1) - private fun senderDropKey(messageId: String, recipientNoiseKey: ByteArray): String { - val material = recipientNoiseKey.toHex() + "|" + messageId - return MessageDigest.getInstance("SHA-256") - .digest(material.toByteArray(Charsets.UTF_8)) - .toHex() - } - - private fun verifyEd25519(signature: ByteArray, data: ByteArray, key: ByteArray): Boolean = - runCatching { - Ed25519Signer().apply { - init(false, Ed25519PublicKeyParameters(key, 0)) - update(data, 0, data.size) - }.verifySignature(signature) - }.getOrDefault(false) - - private fun currentMesh(): MeshService? = - MeshServiceHolder.unifiedMeshService - ?: appContext?.let { context -> - runCatching { MeshServiceHolder.getUnifiedOrCreate(context) }.getOrNull() - } + private fun currentMesh(): MeshService? = meshProvider() private fun requireContext(): Context = checkNotNull(appContext) { "MeshBridgeService.initialize must be called first" } @@ -944,61 +704,4 @@ object MeshBridgeService { private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } - private class BoundedIdSet(private val capacity: Int) { - private val values = LinkedHashSet() - - fun add(id: String): Boolean { - if (!values.add(id)) return false - while (values.size > capacity) values.remove(values.first()) - return true - } - - fun contains(id: String): Boolean = id in values - fun clear() = values.clear() - } - - private class PersistentExpiringIdSet( - private val preferences: android.content.SharedPreferences, - private val key: String, - private val capacity: Int - ) { - private val gson = Gson() - private val values: LinkedHashMap = load() - - fun contains(id: String, nowMs: Long = System.currentTimeMillis()): Boolean { - prune(nowMs) - return (values[id] ?: return false) > nowMs - } - - fun add(id: String, lifetimeMs: Long, nowMs: Long = System.currentTimeMillis()) { - prune(nowMs) - values.remove(id) - values[id] = nowMs + lifetimeMs - while (values.size > capacity) values.remove(values.keys.first()) - persist() - } - - fun clear() { - values.clear() - preferences.edit { remove(key) } - } - - private fun prune(nowMs: Long) { - val changed = values.entries.removeAll { it.value <= nowMs } - if (changed) persist() - } - - private fun load(): LinkedHashMap { - val type = object : TypeToken>() {}.type - val decoded: Map = runCatching { - preferences.getString(key, null) - ?.let { json -> gson.fromJson>(json, type) } - }.getOrNull() ?: emptyMap() - return LinkedHashMap(decoded) - } - - private fun persist() { - preferences.edit { putString(key, gson.toJson(values)) } - } - } } diff --git a/app/src/main/java/com/bitchat/android/services/bridge/PrekeyCoordinator.kt b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyCoordinator.kt new file mode 100644 index 00000000..0fd48dda --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyCoordinator.kt @@ -0,0 +1,81 @@ +package com.bitchat.android.services.bridge + +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.model.PrekeyBundle +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.services.ContactIdentityResolver +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer + +/** + * Coordinates authenticated prekey packets without owning cryptographic + * persistence. [PrekeyManager] remains the repository/crypto boundary. + */ +internal class PrekeyCoordinator( + private val manager: PrekeyManager, + private val meshProvider: () -> MeshService?, + private val peersProvider: () -> List, + private val clock: () -> Long = System::currentTimeMillis +) { + private val pendingPackets = linkedMapOf() + private var lastBroadcastMs = 0L + + fun handlePacket(packet: BitchatPacket) { + val bundle = PrekeyBundle.decode(packet.payload) ?: return + val owner = ContactIdentityResolver.peerIdForNoiseKey(bundle.noiseStaticPublicKey) + if (owner != packet.senderID.toHex()) return + val peer = peersProvider().firstOrNull { it.peerId == owner } + if (peer == null || !peer.noiseKey.contentEquals(bundle.noiseStaticPublicKey)) { + if (pendingPackets.size < MAX_PENDING_PACKETS || owner in pendingPackets) { + pendingPackets[owner] = packet + } + return + } + ingestVerified(packet, bundle, peer) + } + + fun handlePeerVerified(peerId: String) { + pendingPackets.remove(peerId)?.let(::handlePacket) + } + + fun broadcast(force: Boolean = false) { + val now = clock() + if (!force && now - lastBroadcastMs < REBROADCAST_INTERVAL_MS) return + val bundle = manager.currentSignedBundle(now) ?: return + val encoded = bundle.encode() ?: return + lastBroadcastMs = now + meshProvider()?.sendPrekeyBundle(encoded) + } + + fun wipe() { + pendingPackets.clear() + lastBroadcastMs = 0L + manager.wipe() + } + + private fun ingestVerified( + packet: BitchatPacket, + bundle: PrekeyBundle, + peer: VerifiedBridgePeer + ) { + val signature = packet.signature ?: return + val signingData = packet.toBinaryDataForSigning() ?: return + if (!verifyEd25519(signature, signingData, peer.signingKey)) return + manager.verifyAndIngest(bundle, peer.noiseKey, peer.signingKey, clock()) + } + + private fun verifyEd25519(signature: ByteArray, data: ByteArray, key: ByteArray): Boolean = + runCatching { + Ed25519Signer().apply { + init(false, Ed25519PublicKeyParameters(key, 0)) + update(data, 0, data.size) + }.verifySignature(signature) + }.getOrDefault(false) + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + private companion object { + const val MAX_PENDING_PACKETS = 64 + const val REBROADCAST_INTERVAL_MS = 60L * 60 * 1000 + } +} diff --git a/app/src/main/java/com/bitchat/android/services/bridge/PrekeyManager.kt b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyManager.kt index 107358d1..9dcb0368 100644 --- a/app/src/main/java/com/bitchat/android/services/bridge/PrekeyManager.kt +++ b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyManager.kt @@ -2,13 +2,9 @@ package com.bitchat.android.services.bridge import android.content.Context import android.util.Base64 -import android.util.Log -import androidx.core.content.edit import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.model.PrekeyBundle import com.bitchat.android.noise.CourierNoiseCrypto -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters import org.bouncycastle.crypto.signers.Ed25519Signer @@ -22,7 +18,12 @@ import java.security.SecureRandom * but their consumption assignments are persisted so retries of one message * never spend additional prekeys. */ -class PrekeyManager private constructor(context: Context) { +class PrekeyManager internal constructor( + private val identity: PrekeyIdentity, + private val localStore: LocalPrekeyStore, + private val peerStore: PeerPrekeyStore, + private val randomBytes: () -> ByteArray +) { data class Sealed( val ciphertext: ByteArray, val prekeyId: Long? @@ -34,40 +35,13 @@ class PrekeyManager private constructor(context: Context) { val consumedPrekey: Boolean ) - private data class LocalRecord( - val id: Long, - val privateKey: String, - val createdAt: Long, - var consumedAt: Long? = null - ) - - private data class PersistedLocal( - var records: MutableList = mutableListOf(), - var nextId: Long = 0, - var generatedAt: Long = 0 - ) - - private data class StoredBundle( - val noiseKey: String, - var generatedAt: Long, - var prekeyIds: List, - var prekeyPublicKeys: List, - var usedIds: MutableSet, - var assignments: MutableMap, - var updatedAt: Long - ) - - private val appContext = context.applicationContext - private val identityState = SecureIdentityStateManager(appContext) - private val peerPrefs = appContext.getSharedPreferences(PEER_PREFS, Context.MODE_PRIVATE) - private val gson = Gson() private val lock = Any() - private var local: PersistedLocal? = null - private var peerBundles: MutableMap? = null + private var local: LocalPrekeyState? = null + private var peerBundles: MutableMap? = null fun currentSignedBundle(nowMs: Long = System.currentTimeMillis()): PrekeyBundle? = synchronized(lock) { - val staticKey = identityState.loadStaticKey()?.second ?: return@synchronized null - val signingPrivateKey = identityState.loadSigningKey()?.first ?: return@synchronized null + val staticKey = identity.staticKey()?.second ?: return@synchronized null + val signingPrivateKey = identity.signingKey()?.first ?: return@synchronized null val state = loadLocalLocked() replenishLocked(state, nowMs) val prekeys = state.records @@ -112,7 +86,7 @@ class PrekeyManager private constructor(context: Context) { if (existing != null && existing.generatedAt >= bundle.generatedAt) return false val freshIds = bundle.prekeys.map { it.id }.toSet() - bundles[key] = StoredBundle( + bundles[key] = StoredPeerPrekeyBundle( noiseKey = key, generatedAt = bundle.generatedAt, prekeyIds = bundle.prekeys.map { it.id }, @@ -138,7 +112,7 @@ class PrekeyManager private constructor(context: Context) { recipientAdvertisesPrekeys: Boolean, nowMs: Long = System.currentTimeMillis() ): Sealed { - val senderPrivateKey = identityState.loadStaticKey()?.first + val senderPrivateKey = identity.staticKey()?.first ?: throw IllegalStateException("Noise static identity is unavailable") val assigned = if (recipientAdvertisesPrekeys) { assignPrekey(messageId, recipientNoiseKey, nowMs) @@ -164,7 +138,7 @@ class PrekeyManager private constructor(context: Context) { nowMs: Long = System.currentTimeMillis() ): Opened { if (prekeyId == null) { - val staticPrivateKey = identityState.loadStaticKey()?.first + val staticPrivateKey = identity.staticKey()?.first ?: throw IllegalStateException("Noise static identity is unavailable") val opened = CourierNoiseCrypto.open(ciphertext, staticPrivateKey) return Opened(opened.payload, opened.senderStaticKey, false) @@ -202,10 +176,10 @@ class PrekeyManager private constructor(context: Context) { } fun wipe() = synchronized(lock) { - local = PersistedLocal() + local = LocalPrekeyState() peerBundles = mutableMapOf() - identityState.clearSecureValues(LOCAL_STORE_KEY) - peerPrefs.edit { clear() } + localStore.clear() + peerStore.clear() } private fun assignPrekey( @@ -239,17 +213,17 @@ class PrekeyManager private constructor(context: Context) { PrekeyBundle.Prekey(id, publicKey) } - private fun replenishLocked(state: PersistedLocal, nowMs: Long): Boolean { + private fun replenishLocked(state: LocalPrekeyState, nowMs: Long): Boolean { val beforeRecords = state.records.size val beforeUnconsumed = state.records.count { it.consumedAt == null } pruneLocked(state, nowMs) val unconsumed = state.records.count { it.consumedAt == null } var changed = unconsumed != beforeUnconsumed if (unconsumed < REPLENISH_THRESHOLD) { - val random = SecureRandom() repeat(PrekeyBundle.MAX_PREKEYS - unconsumed) { - val privateKey = ByteArray(PrekeyBundle.KEY_LENGTH).also(random::nextBytes) - state.records += LocalRecord( + val privateKey = randomBytes() + require(privateKey.size == PrekeyBundle.KEY_LENGTH) + state.records += LocalPrekeyRecord( id = state.nextId and 0xFFFF_FFFFL, privateKey = encode(privateKey), createdAt = nowMs @@ -263,50 +237,38 @@ class PrekeyManager private constructor(context: Context) { return changed } - private fun pruneLocked(state: PersistedLocal, nowMs: Long) { + private fun pruneLocked(state: LocalPrekeyState, nowMs: Long) { state.records.removeAll { record -> record.consumedAt?.let { nowMs - it > CONSUMED_GRACE_MS } ?: (nowMs - record.createdAt > UNCONSUMED_RETENTION_MS) } } - private fun advanceGeneratedAtLocked(state: PersistedLocal, nowMs: Long) { + private fun advanceGeneratedAtLocked(state: LocalPrekeyState, nowMs: Long) { state.generatedAt = maxOf(nowMs.coerceAtLeast(0), state.generatedAt + 1) } - private fun loadLocalLocked(): PersistedLocal { + private fun loadLocalLocked(): LocalPrekeyState { local?.let { return it } - val loaded = runCatching { - identityState.getSecureValue(LOCAL_STORE_KEY) - ?.let { gson.fromJson(it, PersistedLocal::class.java) } - }.getOrNull() ?: PersistedLocal() + val loaded = localStore.load() local = loaded return loaded } - private fun persistLocalLocked(state: PersistedLocal) { - runCatching { identityState.storeSecureValue(LOCAL_STORE_KEY, gson.toJson(state)) } - .onFailure { Log.e(TAG, "Failed to persist local prekeys", it) } + private fun persistLocalLocked(state: LocalPrekeyState) { + localStore.save(state) } - private fun loadPeerBundlesLocked(): MutableMap { + private fun loadPeerBundlesLocked(): MutableMap { peerBundles?.let { return it } - val type = object : TypeToken>() {}.type - val values: List = runCatching { - peerPrefs.getString(PEER_BUNDLES_KEY, null) - ?.let { json -> gson.fromJson>(json, type) } - }.getOrNull() ?: emptyList() - return values - .filter { it.prekeyIds.size == it.prekeyPublicKeys.size } - .associateByTo(mutableMapOf()) { it.noiseKey } - .also { peerBundles = it } + return peerStore.load().also { peerBundles = it } } - private fun persistPeerBundlesLocked(bundles: MutableMap) { - peerPrefs.edit { putString(PEER_BUNDLES_KEY, gson.toJson(bundles.values.toList())) } + private fun persistPeerBundlesLocked(bundles: Map) { + peerStore.save(bundles) } - private fun isFresh(bundle: StoredBundle, nowMs: Long): Boolean = + private fun isFresh(bundle: StoredPeerPrekeyBundle, nowMs: Long): Boolean = nowMs - bundle.generatedAt <= MAX_BUNDLE_AGE_MS private fun signEd25519(data: ByteArray, privateKey: ByteArray): ByteArray? = runCatching { @@ -331,10 +293,7 @@ class PrekeyManager private constructor(context: Context) { runCatching { Base64.decode(value, Base64.NO_WRAP) }.getOrNull() companion object { - private const val TAG = "PrekeyManager" - private const val LOCAL_STORE_KEY = "courier_prekeys_v1" private const val PEER_PREFS = "bitchat_prekey_bundles" - private const val PEER_BUNDLES_KEY = "bundles_v1" private const val REPLENISH_THRESHOLD = 3 private const val CONSUMED_GRACE_MS = 48L * 60 * 60 * 1000 private const val UNCONSUMED_RETENTION_MS = 30L * 24 * 60 * 60 * 1000 @@ -346,7 +305,21 @@ class PrekeyManager private constructor(context: Context) { fun getInstance(context: Context): PrekeyManager = instance ?: synchronized(this) { - instance ?: PrekeyManager(context).also { instance = it } + instance ?: run { + val application = context.applicationContext + val identityState = SecureIdentityStateManager(application) + val random = SecureRandom() + PrekeyManager( + identity = AndroidPrekeyIdentity(identityState), + localStore = SecureLocalPrekeyStore(identityState), + peerStore = SharedPreferencesPeerPrekeyStore( + application.getSharedPreferences(PEER_PREFS, Context.MODE_PRIVATE) + ), + randomBytes = { + ByteArray(PrekeyBundle.KEY_LENGTH).also(random::nextBytes) + } + ).also { instance = it } + } } } } diff --git a/app/src/main/java/com/bitchat/android/services/bridge/PrekeyStores.kt b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyStores.kt new file mode 100644 index 00000000..8d21ad80 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/services/bridge/PrekeyStores.kt @@ -0,0 +1,111 @@ +package com.bitchat.android.services.bridge + +import android.content.SharedPreferences +import android.util.Log +import androidx.core.content.edit +import com.bitchat.android.identity.SecureIdentityStateManager +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +internal data class LocalPrekeyRecord( + val id: Long, + val privateKey: String, + val createdAt: Long, + var consumedAt: Long? = null +) + +internal data class LocalPrekeyState( + var records: MutableList = mutableListOf(), + var nextId: Long = 0, + var generatedAt: Long = 0 +) + +internal data class StoredPeerPrekeyBundle( + val noiseKey: String, + var generatedAt: Long, + var prekeyIds: List, + var prekeyPublicKeys: List, + var usedIds: MutableSet, + var assignments: MutableMap, + var updatedAt: Long +) + +internal interface PrekeyIdentity { + fun staticKey(): Pair? + fun signingKey(): Pair? +} + +internal interface LocalPrekeyStore { + fun load(): LocalPrekeyState + fun save(state: LocalPrekeyState) + fun clear() +} + +internal interface PeerPrekeyStore { + fun load(): MutableMap + fun save(bundles: Map) + fun clear() +} + +internal class AndroidPrekeyIdentity( + private val state: SecureIdentityStateManager +) : PrekeyIdentity { + override fun staticKey(): Pair? = state.loadStaticKey() + override fun signingKey(): Pair? = state.loadSigningKey() +} + +internal class SecureLocalPrekeyStore( + private val state: SecureIdentityStateManager, + private val gson: Gson = Gson() +) : LocalPrekeyStore { + override fun load(): LocalPrekeyState = + runCatching { + state.getSecureValue(LOCAL_STORE_KEY) + ?.let { gson.fromJson(it, LocalPrekeyState::class.java) } + }.getOrNull() ?: LocalPrekeyState() + + override fun save(state: LocalPrekeyState) { + runCatching { + this.state.storeSecureValue(LOCAL_STORE_KEY, gson.toJson(state)) + }.onFailure { Log.e(TAG, "Failed to persist local prekeys", it) } + } + + override fun clear() { + state.clearSecureValues(LOCAL_STORE_KEY) + } + + private companion object { + const val TAG = "LocalPrekeyStore" + const val LOCAL_STORE_KEY = "courier_prekeys_v1" + } +} + +internal class SharedPreferencesPeerPrekeyStore( + private val preferences: SharedPreferences, + private val gson: Gson = Gson() +) : PeerPrekeyStore { + override fun load(): MutableMap { + val type = object : TypeToken>() {}.type + val values: List = runCatching { + preferences.getString(PEER_BUNDLES_KEY, null) + ?.let { json -> gson.fromJson>(json, type) } + }.getOrNull() ?: emptyList() + return values + .filter { it.prekeyIds.size == it.prekeyPublicKeys.size } + .associateByTo(mutableMapOf()) { it.noiseKey } + } + + override fun save(bundles: Map) { + preferences.edit { + putString(PEER_BUNDLES_KEY, gson.toJson(bundles.values.toList())) + } + } + + override fun clear() { + preferences.edit { clear() } + } + + private companion object { + const val PEER_BUNDLES_KEY = "bundles_v1" + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index f8d28a9c..236414df 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -35,7 +35,6 @@ import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager import com.bitchat.android.net.ArtiTorManager -import com.bitchat.android.services.bridge.MeshBridgeService /** * Feature row for displaying app capabilities @@ -200,8 +199,10 @@ private fun SettingsToggleRow( fun AboutSheet( isPresented: Boolean, onDismiss: () -> Unit, - onShowDebug: (() -> Unit)? = null, - modifier: Modifier = Modifier + bridgeEnabled: Boolean, + onBridgeEnabledChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + onShowDebug: (() -> Unit)? = null ) { val context = LocalContext.current @@ -227,8 +228,6 @@ fun AboutSheet( val colorScheme = MaterialTheme.colorScheme val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val bridgeEnabled by MeshBridgeService.isEnabled.collectAsState() - if (isPresented) { BitchatBottomSheet( modifier = modifier, @@ -417,7 +416,7 @@ fun AboutSheet( title = stringResource(R.string.mesh_bridge_title), subtitle = stringResource(R.string.mesh_bridge_description), checked = bridgeEnabled, - onCheckedChange = MeshBridgeService::setEnabled + onCheckedChange = onBridgeEnabledChange ) HorizontalDivider( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 173ab726..30b9e10e 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -342,8 +342,7 @@ private fun MainHeader( val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() - val bridgeEnabled by com.bitchat.android.services.bridge.MeshBridgeService.isEnabled.collectAsStateWithLifecycle() - val bridgedParticipants by com.bitchat.android.services.bridge.MeshBridgeService.bridgedParticipants.collectAsStateWithLifecycle() + val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle() // Bookmarks store for current geohash toggle (iOS parity) val context = androidx.compose.ui.platform.LocalContext.current @@ -447,7 +446,7 @@ private fun MainHeader( ) Spacer(modifier = Modifier.width(2.dp)) - if (bridgeEnabled) { + if (bridgeUiState.enabled) { Icon( imageVector = Icons.Filled.Public, contentDescription = stringResource(R.string.cd_mesh_bridge_active), @@ -458,7 +457,7 @@ private fun MainHeader( PeerCounter( connectedPeers = connectedPeers.filter { it != viewModel.myPeerID }, - bridgedPeopleCount = bridgedParticipants.size, + bridgedPeopleCount = bridgeUiState.participants.size, joinedChannels = joinedChannels, hasUnreadChannels = hasUnreadChannels, isConnected = isConnected, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 6aae7f3a..f180472d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -60,8 +60,7 @@ fun ChatScreen(viewModel: ChatViewModel) { val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle() val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle() val legacyPrivateMediaConsent by viewModel.legacyPrivateMediaConsent.collectAsStateWithLifecycle() - val bridgeEnabled by com.bitchat.android.services.bridge.MeshBridgeService.isEnabled.collectAsStateWithLifecycle() - val nearbyOnly by com.bitchat.android.services.bridge.MeshBridgeService.nearbyOnly.collectAsStateWithLifecycle() + val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle() var messageText by remember { mutableStateOf(TextFieldValue("")) } var showPasswordPrompt by remember { mutableStateOf(false) } @@ -240,11 +239,11 @@ fun ChatScreen(viewModel: ChatViewModel) { nickname = nickname, colorScheme = colorScheme, showMediaButtons = showMediaButtons, - showBridgeControls = bridgeEnabled && + showBridgeControls = bridgeUiState.enabled && currentChannel == null && selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location, - nearbyOnly = nearbyOnly, - onNearbyOnlyChange = com.bitchat.android.services.bridge.MeshBridgeService::setNearbyOnly + nearbyOnly = bridgeUiState.nearbyOnly, + onNearbyOnlyChange = viewModel::setBridgeNearbyOnly ) } @@ -532,6 +531,7 @@ private fun ChatDialogs( onMeshPeerListDismiss: () -> Unit, ) { val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle() + val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle() // Password dialog PasswordPromptDialog( @@ -548,7 +548,9 @@ private fun ChatDialogs( AboutSheet( isPresented = showAppInfo, onDismiss = onAppInfoDismiss, - onShowDebug = { showDebugSheet = true } + onShowDebug = { showDebugSheet = true }, + bridgeEnabled = bridgeUiState.enabled, + onBridgeEnabledChange = viewModel::setBridgeEnabled ) if (showDebugSheet) { com.bitchat.android.ui.debug.DebugSettingsSheet( 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 16faaf9b..f859a5db 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -9,6 +9,9 @@ import com.bitchat.android.favorites.FavoritesPersistenceService import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.mesh.MeshService @@ -30,6 +33,8 @@ import com.bitchat.android.noise.NoiseSession import com.bitchat.android.services.ContactDirectory import com.bitchat.android.services.ContactIdentityResolver import com.bitchat.android.util.hexEncodedString +import com.bitchat.android.services.bridge.BridgeUiState +import com.bitchat.android.services.bridge.MeshBridgeService /** * Refactored ChatViewModel - Main coordinator for bitchat functionality @@ -202,6 +207,21 @@ class ChatViewModel( val geohashPeople: StateFlow> = state.geohashPeople val teleportedGeo: StateFlow> = state.teleportedGeo val geohashParticipantCounts: StateFlow> = state.geohashParticipantCounts + val bridgeUiState: StateFlow = combine( + MeshBridgeService.isEnabled, + MeshBridgeService.nearbyOnly, + MeshBridgeService.bridgedParticipants + ) { enabled, nearbyOnly, participants -> + BridgeUiState(enabled, nearbyOnly, participants) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = BridgeUiState( + enabled = MeshBridgeService.isEnabled.value, + nearbyOnly = MeshBridgeService.nearbyOnly.value, + participants = MeshBridgeService.bridgedParticipants.value + ) + ) val meshServiceFacade: MeshService get() = mesh val myPeerID: String @@ -340,7 +360,6 @@ class ChatViewModel( } override fun onCleared() { - super.onCleared() // Note: Mesh service lifecycle is now managed by MainActivity } @@ -852,6 +871,14 @@ class ChatViewModel( state.setShowMeshPeerList(true) } + fun setBridgeEnabled(enabled: Boolean) { + MeshBridgeService.setEnabled(enabled) + } + + fun setBridgeNearbyOnly(enabled: Boolean) { + MeshBridgeService.setNearbyOnly(enabled) + } + fun hideMeshPeerList() { state.setShowMeshPeerList(false) } @@ -954,6 +981,12 @@ class ChatViewModel( // MARK: - Emergency Clear fun panicClearAllData() { + viewModelScope.launch { + panicClearAllDataInternal() + } + } + + private suspend fun panicClearAllDataInternal() { Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data") // A pending one-shot downgrade confirmation must not survive panic or diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index 6f7c0182..b5cb68de 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -40,7 +40,7 @@ import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.services.ContactDirectory import com.bitchat.android.services.ContactIdentityResolver -import com.bitchat.android.services.bridge.MeshBridgeService +import com.bitchat.android.services.bridge.BridgedParticipant import com.bitchat.android.util.hexEncodedString @@ -69,8 +69,7 @@ fun MeshPeerListSheet( val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle() val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() - val bridgeEnabled by MeshBridgeService.isEnabled.collectAsStateWithLifecycle() - val bridgedParticipants by MeshBridgeService.bridgedParticipants.collectAsStateWithLifecycle() + val bridgeUiState by viewModel.bridgeUiState.collectAsStateWithLifecycle() val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle() val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() } @@ -183,9 +182,9 @@ fun MeshPeerListSheet( } ) - if (bridgeEnabled && bridgedParticipants.isNotEmpty()) { + if (bridgeUiState.enabled && bridgeUiState.participants.isNotEmpty()) { BridgedPeopleSection( - participants = bridgedParticipants, + participants = bridgeUiState.participants, colorScheme = colorScheme ) } @@ -225,7 +224,7 @@ fun MeshPeerListSheet( @Composable private fun BridgedPeopleSection( - participants: List, + participants: List, colorScheme: ColorScheme ) { Column(modifier = Modifier.padding(top = 16.dp)) { diff --git a/app/src/test/kotlin/com/bitchat/android/model/Tlv16CodecTest.kt b/app/src/test/kotlin/com/bitchat/android/model/Tlv16CodecTest.kt new file mode 100644 index 00000000..0ca771c8 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/Tlv16CodecTest.kt @@ -0,0 +1,30 @@ +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 Tlv16CodecTest { + @Test + fun `codec preserves ordered fields and unknown types`() { + val encoded = requireNotNull( + Tlv16Codec.encode( + Tlv16Codec.Field(1, byteArrayOf(1, 2)), + Tlv16Codec.Field(0x7F, byteArrayOf(3)) + ) + ) + + val decoded = requireNotNull(Tlv16Codec.decode(encoded)) + assertEquals(listOf(1, 0x7F), decoded.map { it.type }) + assertArrayEquals(byteArrayOf(1, 2), decoded[0].value) + assertArrayEquals(byteArrayOf(3), decoded[1].value) + } + + @Test + fun `codec rejects truncated framing and oversized values`() { + assertNull(Tlv16Codec.decode(byteArrayOf(1, 0))) + assertNull(Tlv16Codec.decode(byteArrayOf(1, 0, 2, 1))) + assertNull(Tlv16Codec.encode(Tlv16Codec.Field(1, ByteArray(0x1_0000)))) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrPublishTrackerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPublishTrackerTest.kt new file mode 100644 index 00000000..bddf648f --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPublishTrackerTest.kt @@ -0,0 +1,35 @@ +package com.bitchat.android.nostr + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class NostrPublishTrackerTest { + @Test + fun `first relay acceptance completes publication`() = runBlocking { + val tracker = NostrPublishTracker() + val result = tracker.begin("event", setOf("relay-a", "relay-b")) + + tracker.record("event", "relay-a", accepted = true, message = null) + + assertEquals(NostrPublishResult.Accepted("relay-a"), result.await()) + } + + @Test + fun `publication is rejected only after every target rejects`() = runBlocking { + val tracker = NostrPublishTracker() + val result = tracker.begin("event", setOf("relay-a", "relay-b")) + + tracker.record("event", "relay-a", accepted = false, message = "duplicate") + assertFalse(result.isCompleted) + tracker.record("event", "relay-b", accepted = false, message = "blocked") + + assertEquals( + NostrPublishResult.Rejected( + mapOf("relay-a" to "duplicate", "relay-b" to "blocked") + ), + result.await() + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/bridge/PrekeyManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/services/bridge/PrekeyManagerTest.kt new file mode 100644 index 00000000..c92bc4ef --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/services/bridge/PrekeyManagerTest.kt @@ -0,0 +1,129 @@ +package com.bitchat.android.services.bridge + +import com.bitchat.android.noise.CourierNoiseCrypto +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PrekeyManagerTest { + @Test + fun `message retry reuses assigned prekey and recipient grace key`() { + val now = 1_750_000_000_000L + val recipientIdentity = identity(seed = 1) + val senderIdentity = identity(seed = 65) + var generatedSeed = 100 + val recipient = manager(recipientIdentity) { + ByteArray(32) { index -> (generatedSeed + index).toByte() } + .also { generatedSeed += 1 } + } + val sender = manager(senderIdentity) + val bundle = requireNotNull(recipient.currentSignedBundle(now)) + + assertTrue( + sender.verifyAndIngest( + bundle, + expectedNoiseKey = requireNotNull(recipientIdentity.staticKey()).second, + announceBoundSigningKey = requireNotNull(recipientIdentity.signingKey()).second, + nowMs = now + ) + ) + + val payload = "offline hello".toByteArray() + val first = sender.seal( + payload, + messageId = "message-1", + recipientNoiseKey = requireNotNull(recipientIdentity.staticKey()).second, + recipientAdvertisesPrekeys = true, + nowMs = now + ) + val retry = sender.seal( + payload, + messageId = "message-1", + recipientNoiseKey = requireNotNull(recipientIdentity.staticKey()).second, + recipientAdvertisesPrekeys = true, + nowMs = now + 1 + ) + + assertNotNull(first.prekeyId) + assertEquals(first.prekeyId, retry.prekeyId) + val firstOpened = recipient.open(first.ciphertext, first.prekeyId, now + 2) + val retryOpened = recipient.open(retry.ciphertext, retry.prekeyId, now + 3) + assertArrayEquals(payload, firstOpened.payload) + assertArrayEquals(payload, retryOpened.payload) + assertTrue(firstOpened.consumedPrekey) + assertFalse(retryOpened.consumedPrekey) + + val nextMessage = sender.seal( + payload, + messageId = "message-2", + recipientNoiseKey = requireNotNull(recipientIdentity.staticKey()).second, + recipientAdvertisesPrekeys = true, + nowMs = now + 4 + ) + assertNotEquals(first.prekeyId, nextMessage.prekeyId) + } + + private fun manager( + identity: PrekeyIdentity, + randomBytes: () -> ByteArray = { ByteArray(32) { (it + 11).toByte() } } + ): PrekeyManager = + PrekeyManager( + identity = identity, + localStore = MemoryLocalPrekeyStore(), + peerStore = MemoryPeerPrekeyStore(), + randomBytes = randomBytes + ) + + private fun identity(seed: Int): PrekeyIdentity { + val staticPrivate = ByteArray(32) { (seed + it).toByte() } + val signingPrivate = Ed25519PrivateKeyParameters( + ByteArray(32) { (seed + 32 + it).toByte() }, + 0 + ) + return FakePrekeyIdentity( + static = staticPrivate to CourierNoiseCrypto.publicKey(staticPrivate), + signing = signingPrivate.encoded to signingPrivate.generatePublicKey().encoded + ) + } + + private class FakePrekeyIdentity( + private val static: Pair, + private val signing: Pair + ) : PrekeyIdentity { + override fun staticKey(): Pair = static + override fun signingKey(): Pair = signing + } + + private class MemoryLocalPrekeyStore : LocalPrekeyStore { + private var state = LocalPrekeyState() + + override fun load(): LocalPrekeyState = state + + override fun save(state: LocalPrekeyState) { + this.state = state + } + + override fun clear() { + state = LocalPrekeyState() + } + } + + private class MemoryPeerPrekeyStore : PeerPrekeyStore { + private var bundles = mutableMapOf() + + override fun load(): MutableMap = bundles + + override fun save(bundles: Map) { + this.bundles = bundles.toMutableMap() + } + + override fun clear() { + bundles.clear() + } + } +}