From 565e4ab33ce100d6ff8a96c3486bafd9b8680f3a Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 25 Jul 2025 17:54:12 +0200 Subject: [PATCH] Fix delivery acks and read receipts (#186) * delivery ack and read receipt * handle deliveryack * fix uppercase id * fix read receipts too --- .../android/mesh/BluetoothMeshService.kt | 63 +++++++++--- .../bitchat/android/mesh/MessageHandler.kt | 54 +++-------- .../bitchat/android/mesh/PacketProcessor.kt | 14 +-- .../bitchat/android/model/BitchatMessage.kt | 2 +- .../com/bitchat/android/model/DeliveryAck.kt | 97 +++++++++++++++---- .../com/bitchat/android/model/ReadReceipt.kt | 84 +++++++++++++--- .../android/protocol/BinaryProtocol.kt | 3 +- .../bitchat/android/ui/PrivateChatManager.kt | 49 ++++++++++ 8 files changed, 274 insertions(+), 92 deletions(-) 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 72ac05af..06a3f98b 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -66,7 +66,7 @@ class BluetoothMeshService(private val context: Context) { // Wire up PacketProcessor reference for recursive handling in MessageHandler messageHandler.packetProcessor = packetProcessor sendPeriodicBroadcastAnnounce() - startPeriodicDebugLogging() + // startPeriodicDebugLogging() } /** @@ -89,13 +89,13 @@ class BluetoothMeshService(private val context: Context) { } /** - * Send broadcast announcement every 10 seconds + * Send broadcast announcement every 30 seconds */ private fun sendPeriodicBroadcastAnnounce() { serviceScope.launch { while (isActive) { try { - delay(10000) // 10 seconds + delay(30000) // 30 seconds sendBroadcastAnnounce() } catch (e: Exception) { Log.e(TAG, "Error in periodic broadcast announce: ${e.message}") @@ -356,9 +356,9 @@ class BluetoothMeshService(private val context: Context) { return fragmentManager.handleFragment(packet) } - override fun handleDeliveryAck(routed: RoutedPacket) { - serviceScope.launch { messageHandler.handleDeliveryAck(routed) } - } +// override fun handleDeliveryAck(routed: RoutedPacket) { +// serviceScope.launch { messageHandler.handleDeliveryAck(routed) } +// } override fun handleReadReceipt(routed: RoutedPacket) { serviceScope.launch { messageHandler.handleReadReceipt(routed) } @@ -552,7 +552,7 @@ class BluetoothMeshService(private val context: Context) { originalMessageID = message.id, recipientID = myPeerID, recipientNickname = nickname, - hopCount = 0u // Will be calculated during relay + hopCount = 0u.toUByte() // Will be calculated during relay ) try { @@ -586,6 +586,47 @@ class BluetoothMeshService(private val context: Context) { } } + /** + * Send read receipt for a received private message + */ + fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) { + serviceScope.launch { + try { + Log.d(TAG, "Sending read receipt for message $messageID to $recipientPeerID") + + // Create the read receipt + val receipt = ReadReceipt( + originalMessageID = messageID, + readerID = myPeerID, + readerNickname = readerNickname + ) + + // Encode the receipt + val receiptData = receipt.encode() + + // Create inner read receipt packet + val innerPacket = BitchatPacket( + version = 1u, + type = MessageType.READ_RECEIPT.value, + senderID = hexStringToByteArray(myPeerID), + recipientID = hexStringToByteArray(recipientPeerID), + timestamp = System.currentTimeMillis().toULong(), + payload = receiptData, + signature = null, + ttl = 3u + ) + + // Encrypt the entire inner packet and send as NOISE_ENCRYPTED + encryptAndBroadcastNoisePacket(innerPacket, recipientPeerID) + + Log.d(TAG, "Sent read receipt for message $messageID to $recipientPeerID") + + } catch (e: Exception) { + Log.e(TAG, "Failed to send read receipt for message $messageID: ${e.message}") + } + } + } + /** * Encrypt a BitchatPacket and broadcast it as a NOISE_ENCRYPTED message * This is the correct protocol implementation - encrypt the entire packet, not just the payload @@ -644,14 +685,6 @@ class BluetoothMeshService(private val context: Context) { payload = nickname.toByteArray() ) - // Send multiple times for reliability - delay(Random.nextLong(0, 500)) - connectionManager.broadcastPacket(RoutedPacket(announcePacket)) - - delay(500 + Random.nextLong(0, 500)) - connectionManager.broadcastPacket(RoutedPacket(announcePacket)) - - delay(1000 + Random.nextLong(0, 500)) connectionManager.broadcastPacket(RoutedPacket(announcePacket)) } } 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 53896269..f9252634 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -58,19 +58,12 @@ class MessageHandler(private val myPeerID: String) { // Check if this is a delivery ACK with the new format if (typeMarker == MessageType.DELIVERY_ACK.value) { - // Extract the ACK JSON data (skip the type marker) - val ackData = decryptedData.sliceArray(1 until decryptedData.size) - - // Decode the delivery ACK - val ack = DeliveryAck.decode(ackData) - if (ack != null) { - delegate?.onDeliveryAckReceived(ack) - Log.d(TAG, "Processed delivery ACK from $peerID") - return - } + handleDeliveryAck(decryptedData) + Log.d(TAG, "Processed delivery ACK from $peerID") } // Check for read receipt with type marker + // NOTE: THIS DOESN'T WORK WITH IOS, IT SENDS AN INNER PACKET INSTEAD if (typeMarker == MessageType.READ_RECEIPT.value) { val receiptData = decryptedData.sliceArray(1 until decryptedData.size) val receipt = ReadReceipt.decode(receiptData) @@ -268,7 +261,7 @@ class MessageHandler(private val myPeerID: String) { delegate?.onMessageReceived(message) // Send delivery ACK - // delegate?.sendDeliveryAck(message, peerID) + delegate?.sendDeliveryAck(message, peerID) } } catch (e: Exception) { @@ -302,23 +295,13 @@ class MessageHandler(private val myPeerID: String) { /** * Handle delivery acknowledgment */ - suspend fun handleDeliveryAck(routed: RoutedPacket) { - val packet = routed.packet - val peerID = routed.peerID ?: "unknown" - if (packet.recipientID != null && String(packet.recipientID).replace("\u0000", "") == myPeerID) { - try { - val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID) - if (decryptedData != null) { - val ack = DeliveryAck.decode(decryptedData) - if (ack != null) { - delegate?.onDeliveryAckReceived(ack) - } - } - } catch (e: Exception) { - Log.e(TAG, "Failed to decrypt delivery ACK: ${e.message}") - } + suspend fun handleDeliveryAck(decryptedData: ByteArray) { + val ackData = decryptedData.sliceArray(1 until decryptedData.size) + val ack = DeliveryAck.decode(ackData) + if (ack != null) { + delegate?.onDeliveryAckReceived(ack) } - // Delivery ACK relay is now handled by centralized PacketRelayManager + return } /** @@ -327,20 +310,11 @@ class MessageHandler(private val myPeerID: String) { suspend fun handleReadReceipt(routed: RoutedPacket) { val packet = routed.packet val peerID = routed.peerID ?: "unknown" - if (packet.recipientID != null && String(packet.recipientID).replace("\u0000", "") == myPeerID) { - try { - val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID) - if (decryptedData != null) { - val receipt = ReadReceipt.decode(decryptedData) - if (receipt != null) { - delegate?.onReadReceiptReceived(receipt) - } - } - } catch (e: Exception) { - Log.e(TAG, "Failed to decrypt read receipt: ${e.message}") - } + val receipt = ReadReceipt.decode(routed.packet.payload) + if (receipt != null) { + delegate?.onReadReceiptReceived(receipt) } - // Read receipt relay is now handled by centralized PacketRelayManager + return } /** 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 1881141d..396c05ba 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -139,7 +139,7 @@ class PacketProcessor(private val myPeerID: String) { MessageType.NOISE_HANDSHAKE_INIT -> handleNoiseHandshake(routed, 1) MessageType.NOISE_HANDSHAKE_RESP -> handleNoiseHandshake(routed, 2) MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed) - MessageType.DELIVERY_ACK -> handleDeliveryAck(routed) + //MessageType.DELIVERY_ACK -> handleDeliveryAck(routed) // custom packet type... MessageType.READ_RECEIPT -> handleReadReceipt(routed) else -> { validPacket = false @@ -234,11 +234,11 @@ class PacketProcessor(private val myPeerID: String) { /** * Handle delivery acknowledgment */ - private suspend fun handleDeliveryAck(routed: RoutedPacket) { - val peerID = routed.peerID ?: "unknown" - Log.d(TAG, "Processing delivery ACK from ${formatPeerForLog(peerID)}") - delegate?.handleDeliveryAck(routed) - } +// private suspend fun handleDeliveryAck(routed: RoutedPacket) { +// val peerID = routed.peerID ?: "unknown" +// Log.d(TAG, "Processing delivery ACK from ${formatPeerForLog(peerID)}") +// delegate?.handleDeliveryAck(routed) +// } /** * Handle read receipt @@ -313,7 +313,7 @@ interface PacketProcessorDelegate { fun handleMessage(routed: RoutedPacket) fun handleLeave(routed: RoutedPacket) fun handleFragment(packet: BitchatPacket): BitchatPacket? - fun handleDeliveryAck(routed: RoutedPacket) +// fun handleDeliveryAck(routed: RoutedPacket) fun handleReadReceipt(routed: RoutedPacket) // Communication diff --git a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt index 5d043c12..9bfce6e9 100644 --- a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt +++ b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt @@ -46,7 +46,7 @@ sealed class DeliveryStatus : Parcelable { */ @Parcelize data class BitchatMessage( - val id: String = UUID.randomUUID().toString(), + val id: String = UUID.randomUUID().toString().uppercase(), val sender: String, val content: String, val timestamp: Date, diff --git a/app/src/main/java/com/bitchat/android/model/DeliveryAck.kt b/app/src/main/java/com/bitchat/android/model/DeliveryAck.kt index 5602bfc7..98f16d62 100644 --- a/app/src/main/java/com/bitchat/android/model/DeliveryAck.kt +++ b/app/src/main/java/com/bitchat/android/model/DeliveryAck.kt @@ -1,12 +1,13 @@ package com.bitchat.android.model import android.os.Parcelable -import com.google.gson.GsonBuilder import kotlinx.parcelize.Parcelize +import com.bitchat.android.util.* import java.util.* /** * Delivery acknowledgment structure - exact same as iOS version + * Uses binary encoding for efficient protocol communication */ @Parcelize data class DeliveryAck( @@ -15,29 +16,91 @@ data class DeliveryAck( val recipientID: String, val recipientNickname: String, val timestamp: Date = Date(), - val hopCount: UInt + val hopCount: UByte ) : Parcelable { - private val gson = GsonBuilder() - .setDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") - .create() - - fun encode(): ByteArray? { - return try { - gson.toJson(this).toByteArray(Charsets.UTF_8) - } catch (e: Exception) { - null + // Primary constructor for creating new acks + constructor(originalMessageID: String, recipientID: String, recipientNickname: String, hopCount: UByte) : this( + originalMessageID = originalMessageID, + ackID = UUID.randomUUID().toString(), + recipientID = recipientID, + recipientNickname = recipientNickname, + timestamp = Date(), + hopCount = hopCount + ) + + /** + * Encode to binary data matching iOS toBinaryData implementation + */ + fun encode(): ByteArray { + val builder = BinaryDataBuilder() + + // Append original message UUID + builder.appendUUID(originalMessageID) + + // Append ack ID UUID + builder.appendUUID(ackID) + + // Append recipient ID as 8-byte hex string + val recipientData = ByteArray(8) { 0 } + var tempID = recipientID + var index = 0 + + while (tempID.length >= 2 && index < 8) { + val hexByte = tempID.substring(0, 2) + val byte = hexByte.toIntOrNull(16)?.toByte() + if (byte != null) { + recipientData[index] = byte + } + tempID = tempID.substring(2) + index++ } + + builder.buffer.addAll(recipientData.toList()) + + // Append hop count (UInt8) + builder.appendUInt8(hopCount) + + // Append timestamp + builder.appendDate(timestamp) + + // Append recipient nickname as string + builder.appendString(recipientNickname) + + return builder.toByteArray() } companion object { + /** + * Decode from binary data matching iOS fromBinaryData implementation + */ fun decode(data: ByteArray): DeliveryAck? { - return try { - val json = String(data, Charsets.UTF_8) - com.google.gson.Gson().fromJson(json, DeliveryAck::class.java) - } catch (e: Exception) { - null - } + // Create defensive copy + val dataCopy = data.copyOf() + + // Minimum size: 2 UUIDs (32) + recipientID (8) + hopCount (1) + timestamp (8) + min nickname + if (dataCopy.size < 50) return null + + val offset = intArrayOf(0) + + val originalMessageID = dataCopy.readUUID(offset) ?: return null + val ackID = dataCopy.readUUID(offset) ?: return null + + val recipientIDData = dataCopy.readFixedBytes(offset, 8) ?: return null + val recipientID = recipientIDData.hexEncodedString() + + val hopCount = dataCopy.readUInt8(offset) ?: return null + val timestamp = dataCopy.readDate(offset) ?: return null + val recipientNickname = dataCopy.readString(offset) ?: return null + + return DeliveryAck( + originalMessageID = originalMessageID, + ackID = ackID, + recipientID = recipientID, + recipientNickname = recipientNickname, + timestamp = timestamp, + hopCount = hopCount + ) } } } diff --git a/app/src/main/java/com/bitchat/android/model/ReadReceipt.kt b/app/src/main/java/com/bitchat/android/model/ReadReceipt.kt index c5e4c509..74dbb676 100644 --- a/app/src/main/java/com/bitchat/android/model/ReadReceipt.kt +++ b/app/src/main/java/com/bitchat/android/model/ReadReceipt.kt @@ -2,10 +2,12 @@ package com.bitchat.android.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import com.bitchat.android.util.* import java.util.* /** * Read receipt structure - exact same as iOS version + * Uses binary encoding for efficient protocol communication */ @Parcelize data class ReadReceipt( @@ -15,23 +17,83 @@ data class ReadReceipt( val readerNickname: String, val timestamp: Date = Date() ) : Parcelable { + + // Primary constructor for creating new read receipts + constructor(originalMessageID: String, readerID: String, readerNickname: String) : this( + originalMessageID = originalMessageID, + receiptID = UUID.randomUUID().toString(), + readerID = readerID, + readerNickname = readerNickname, + timestamp = Date() + ) - fun encode(): ByteArray? { - return try { - com.google.gson.Gson().toJson(this).toByteArray(Charsets.UTF_8) - } catch (e: Exception) { - null + /** + * Encode to binary data matching iOS toBinaryData implementation + */ + fun encode(): ByteArray { + val builder = BinaryDataBuilder() + + // Append original message UUID + builder.appendUUID(originalMessageID) + + // Append receipt ID UUID + builder.appendUUID(receiptID) + + // Append reader ID as 8-byte hex string + val readerData = ByteArray(8) { 0 } + var tempID = readerID + var index = 0 + + while (tempID.length >= 2 && index < 8) { + val hexByte = tempID.substring(0, 2) + val byte = hexByte.toIntOrNull(16)?.toByte() + if (byte != null) { + readerData[index] = byte + } + tempID = tempID.substring(2) + index++ } + + builder.buffer.addAll(readerData.toList()) + + // Append timestamp + builder.appendDate(timestamp) + + // Append reader nickname as string + builder.appendString(readerNickname) + + return builder.toByteArray() } companion object { + /** + * Decode from binary data matching iOS fromBinaryData implementation + */ fun decode(data: ByteArray): ReadReceipt? { - return try { - val json = String(data, Charsets.UTF_8) - com.google.gson.Gson().fromJson(json, ReadReceipt::class.java) - } catch (e: Exception) { - null - } + // Create defensive copy + val dataCopy = data.copyOf() + + // Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname + if (dataCopy.size < 49) return null + + val offset = intArrayOf(0) + + val originalMessageID = dataCopy.readUUID(offset) ?: return null + val receiptID = dataCopy.readUUID(offset) ?: return null + + val readerIDData = dataCopy.readFixedBytes(offset, 8) ?: return null + val readerID = readerIDData.hexEncodedString() + + val timestamp = dataCopy.readDate(offset) ?: return null + val readerNickname = dataCopy.readString(offset) ?: return null + + return ReadReceipt( + originalMessageID = originalMessageID, + receiptID = receiptID, + readerID = readerID, + readerNickname = readerNickname, + timestamp = timestamp + ) } } } diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index a065c332..6bb6fe1e 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -4,7 +4,7 @@ import android.os.Parcelable import kotlinx.parcelize.Parcelize import java.nio.ByteBuffer import java.nio.ByteOrder -import java.util.* +import android.util.Log /** * Message types - exact same as iOS version with Noise Protocol support @@ -262,6 +262,7 @@ object BinaryProtocol { return paddedData } catch (e: Exception) { + Log.e("BinaryProtocol", "Error encoding packet type ${packet.type}: ${e.message}") return null } } diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index d50f71d9..27606bfe 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -23,6 +23,9 @@ class PrivateChatManager( // Use centralized fingerprint management - NO LOCAL STORAGE private val fingerprintManager = PeerFingerprintManager.getInstance() + // Track received private messages that need read receipts + private val unreadReceivedMessages = mutableMapOf>() + // MARK: - Private Chat Lifecycle fun startPrivateChat(peerID: String, meshService: Any): Boolean { @@ -46,6 +49,9 @@ class PrivateChatManager( // Initialize chat if needed messageManager.initializePrivateChat(peerID) + // Send read receipts for all unread messages from this peer + sendReadReceiptsForPeer(peerID, meshService) + return true } @@ -242,16 +248,56 @@ class PrivateChatManager( fun handleIncomingPrivateMessage(message: BitchatMessage) { message.senderPeerID?.let { senderPeerID -> if (!isPeerBlocked(senderPeerID)) { + // Add to private messages messageManager.addPrivateMessage(senderPeerID, message) + + // Track as unread for read receipt purposes + val unreadList = unreadReceivedMessages.getOrPut(senderPeerID) { mutableListOf() } + unreadList.add(message) + + Log.d(TAG, "Added received message ${message.id} from $senderPeerID to unread list (${unreadList.size} unread)") } } } + /** + * Send read receipts for all unread messages from a specific peer + * Called when the user focuses on a private chat + */ + fun sendReadReceiptsForPeer(peerID: String, meshService: Any) { + val unreadList = unreadReceivedMessages[peerID] + if (unreadList.isNullOrEmpty()) { + Log.d(TAG, "No unread messages to send read receipts for peer $peerID") + return + } + + Log.d(TAG, "Sending read receipts for ${unreadList.size} unread messages from $peerID") + + // Send read receipt for each unread message + unreadList.forEach { message -> + try { + val method = meshService::class.java.getDeclaredMethod("sendReadReceipt", String::class.java, String::class.java, String::class.java) + val myNickname = state.getNicknameValue() ?: "unknown" + method.invoke(meshService, message.id, peerID, myNickname) + Log.d(TAG, "Sent read receipt for message ${message.id} to $peerID") + } catch (e: Exception) { + Log.w(TAG, "Failed to send read receipt for message ${message.id}: ${e.message}") + } + } + + // Clear the unread list since we've sent read receipts + unreadReceivedMessages.remove(peerID) + } + fun cleanupDisconnectedPeer(peerID: String) { // End private chat if peer disconnected if (state.getSelectedPrivateChatPeerValue() == peerID) { endPrivateChat() } + + // Clean up unread messages for disconnected peer + unreadReceivedMessages.remove(peerID) + Log.d(TAG, "Cleaned up unread messages for disconnected peer $peerID") } // MARK: - Utility Functions @@ -284,6 +330,9 @@ class PrivateChatManager( state.setSelectedPrivateChatPeer(null) state.setUnreadPrivateMessages(emptySet()) + // Clear unread messages tracking + unreadReceivedMessages.clear() + // Clear fingerprints via centralized manager (only if needed for emergency clear) // Note: This will be handled by the parent PeerManager.clearAllPeers() }