Fix delivery acks and read receipts (#186)

* delivery ack and read receipt

* handle deliveryack

* fix uppercase id

* fix read receipts too
This commit is contained in:
callebtc
2025-07-25 17:54:12 +02:00
committed by GitHub
parent 534be7e613
commit 565e4ab33c
8 changed files with 274 additions and 92 deletions
@@ -66,7 +66,7 @@ class BluetoothMeshService(private val context: Context) {
// Wire up PacketProcessor reference for recursive handling in MessageHandler // Wire up PacketProcessor reference for recursive handling in MessageHandler
messageHandler.packetProcessor = packetProcessor messageHandler.packetProcessor = packetProcessor
sendPeriodicBroadcastAnnounce() 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() { private fun sendPeriodicBroadcastAnnounce() {
serviceScope.launch { serviceScope.launch {
while (isActive) { while (isActive) {
try { try {
delay(10000) // 10 seconds delay(30000) // 30 seconds
sendBroadcastAnnounce() sendBroadcastAnnounce()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error in periodic broadcast announce: ${e.message}") Log.e(TAG, "Error in periodic broadcast announce: ${e.message}")
@@ -356,9 +356,9 @@ class BluetoothMeshService(private val context: Context) {
return fragmentManager.handleFragment(packet) return fragmentManager.handleFragment(packet)
} }
override fun handleDeliveryAck(routed: RoutedPacket) { // override fun handleDeliveryAck(routed: RoutedPacket) {
serviceScope.launch { messageHandler.handleDeliveryAck(routed) } // serviceScope.launch { messageHandler.handleDeliveryAck(routed) }
} // }
override fun handleReadReceipt(routed: RoutedPacket) { override fun handleReadReceipt(routed: RoutedPacket) {
serviceScope.launch { messageHandler.handleReadReceipt(routed) } serviceScope.launch { messageHandler.handleReadReceipt(routed) }
@@ -552,7 +552,7 @@ class BluetoothMeshService(private val context: Context) {
originalMessageID = message.id, originalMessageID = message.id,
recipientID = myPeerID, recipientID = myPeerID,
recipientNickname = nickname, recipientNickname = nickname,
hopCount = 0u // Will be calculated during relay hopCount = 0u.toUByte() // Will be calculated during relay
) )
try { 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 * 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 * 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() 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)) connectionManager.broadcastPacket(RoutedPacket(announcePacket))
} }
} }
@@ -58,19 +58,12 @@ class MessageHandler(private val myPeerID: String) {
// Check if this is a delivery ACK with the new format // Check if this is a delivery ACK with the new format
if (typeMarker == MessageType.DELIVERY_ACK.value) { if (typeMarker == MessageType.DELIVERY_ACK.value) {
// Extract the ACK JSON data (skip the type marker) handleDeliveryAck(decryptedData)
val ackData = decryptedData.sliceArray(1 until decryptedData.size) Log.d(TAG, "Processed delivery ACK from $peerID")
// Decode the delivery ACK
val ack = DeliveryAck.decode(ackData)
if (ack != null) {
delegate?.onDeliveryAckReceived(ack)
Log.d(TAG, "Processed delivery ACK from $peerID")
return
}
} }
// Check for read receipt with type marker // 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) { if (typeMarker == MessageType.READ_RECEIPT.value) {
val receiptData = decryptedData.sliceArray(1 until decryptedData.size) val receiptData = decryptedData.sliceArray(1 until decryptedData.size)
val receipt = ReadReceipt.decode(receiptData) val receipt = ReadReceipt.decode(receiptData)
@@ -268,7 +261,7 @@ class MessageHandler(private val myPeerID: String) {
delegate?.onMessageReceived(message) delegate?.onMessageReceived(message)
// Send delivery ACK // Send delivery ACK
// delegate?.sendDeliveryAck(message, peerID) delegate?.sendDeliveryAck(message, peerID)
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -302,23 +295,13 @@ class MessageHandler(private val myPeerID: String) {
/** /**
* Handle delivery acknowledgment * Handle delivery acknowledgment
*/ */
suspend fun handleDeliveryAck(routed: RoutedPacket) { suspend fun handleDeliveryAck(decryptedData: ByteArray) {
val packet = routed.packet val ackData = decryptedData.sliceArray(1 until decryptedData.size)
val peerID = routed.peerID ?: "unknown" val ack = DeliveryAck.decode(ackData)
if (packet.recipientID != null && String(packet.recipientID).replace("\u0000", "") == myPeerID) { if (ack != null) {
try { delegate?.onDeliveryAckReceived(ack)
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}")
}
} }
// 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) { suspend fun handleReadReceipt(routed: RoutedPacket) {
val packet = routed.packet val packet = routed.packet
val peerID = routed.peerID ?: "unknown" val peerID = routed.peerID ?: "unknown"
if (packet.recipientID != null && String(packet.recipientID).replace("\u0000", "") == myPeerID) { val receipt = ReadReceipt.decode(routed.packet.payload)
try { if (receipt != null) {
val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID) delegate?.onReadReceiptReceived(receipt)
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}")
}
} }
// Read receipt relay is now handled by centralized PacketRelayManager return
} }
/** /**
@@ -139,7 +139,7 @@ class PacketProcessor(private val myPeerID: String) {
MessageType.NOISE_HANDSHAKE_INIT -> handleNoiseHandshake(routed, 1) MessageType.NOISE_HANDSHAKE_INIT -> handleNoiseHandshake(routed, 1)
MessageType.NOISE_HANDSHAKE_RESP -> handleNoiseHandshake(routed, 2) MessageType.NOISE_HANDSHAKE_RESP -> handleNoiseHandshake(routed, 2)
MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed) MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed)
MessageType.DELIVERY_ACK -> handleDeliveryAck(routed) //MessageType.DELIVERY_ACK -> handleDeliveryAck(routed) // custom packet type...
MessageType.READ_RECEIPT -> handleReadReceipt(routed) MessageType.READ_RECEIPT -> handleReadReceipt(routed)
else -> { else -> {
validPacket = false validPacket = false
@@ -234,11 +234,11 @@ class PacketProcessor(private val myPeerID: String) {
/** /**
* Handle delivery acknowledgment * Handle delivery acknowledgment
*/ */
private suspend fun handleDeliveryAck(routed: RoutedPacket) { // private suspend fun handleDeliveryAck(routed: RoutedPacket) {
val peerID = routed.peerID ?: "unknown" // val peerID = routed.peerID ?: "unknown"
Log.d(TAG, "Processing delivery ACK from ${formatPeerForLog(peerID)}") // Log.d(TAG, "Processing delivery ACK from ${formatPeerForLog(peerID)}")
delegate?.handleDeliveryAck(routed) // delegate?.handleDeliveryAck(routed)
} // }
/** /**
* Handle read receipt * Handle read receipt
@@ -313,7 +313,7 @@ interface PacketProcessorDelegate {
fun handleMessage(routed: RoutedPacket) fun handleMessage(routed: RoutedPacket)
fun handleLeave(routed: RoutedPacket) fun handleLeave(routed: RoutedPacket)
fun handleFragment(packet: BitchatPacket): BitchatPacket? fun handleFragment(packet: BitchatPacket): BitchatPacket?
fun handleDeliveryAck(routed: RoutedPacket) // fun handleDeliveryAck(routed: RoutedPacket)
fun handleReadReceipt(routed: RoutedPacket) fun handleReadReceipt(routed: RoutedPacket)
// Communication // Communication
@@ -46,7 +46,7 @@ sealed class DeliveryStatus : Parcelable {
*/ */
@Parcelize @Parcelize
data class BitchatMessage( data class BitchatMessage(
val id: String = UUID.randomUUID().toString(), val id: String = UUID.randomUUID().toString().uppercase(),
val sender: String, val sender: String,
val content: String, val content: String,
val timestamp: Date, val timestamp: Date,
@@ -1,12 +1,13 @@
package com.bitchat.android.model package com.bitchat.android.model
import android.os.Parcelable import android.os.Parcelable
import com.google.gson.GsonBuilder
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import com.bitchat.android.util.*
import java.util.* import java.util.*
/** /**
* Delivery acknowledgment structure - exact same as iOS version * Delivery acknowledgment structure - exact same as iOS version
* Uses binary encoding for efficient protocol communication
*/ */
@Parcelize @Parcelize
data class DeliveryAck( data class DeliveryAck(
@@ -15,29 +16,91 @@ data class DeliveryAck(
val recipientID: String, val recipientID: String,
val recipientNickname: String, val recipientNickname: String,
val timestamp: Date = Date(), val timestamp: Date = Date(),
val hopCount: UInt val hopCount: UByte
) : Parcelable { ) : Parcelable {
private val gson = GsonBuilder() // Primary constructor for creating new acks
.setDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") constructor(originalMessageID: String, recipientID: String, recipientNickname: String, hopCount: UByte) : this(
.create() originalMessageID = originalMessageID,
ackID = UUID.randomUUID().toString(),
fun encode(): ByteArray? { recipientID = recipientID,
return try { recipientNickname = recipientNickname,
gson.toJson(this).toByteArray(Charsets.UTF_8) timestamp = Date(),
} catch (e: Exception) { hopCount = hopCount
null )
/**
* 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 { companion object {
/**
* Decode from binary data matching iOS fromBinaryData implementation
*/
fun decode(data: ByteArray): DeliveryAck? { fun decode(data: ByteArray): DeliveryAck? {
return try { // Create defensive copy
val json = String(data, Charsets.UTF_8) val dataCopy = data.copyOf()
com.google.gson.Gson().fromJson(json, DeliveryAck::class.java)
} catch (e: Exception) { // Minimum size: 2 UUIDs (32) + recipientID (8) + hopCount (1) + timestamp (8) + min nickname
null 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
)
} }
} }
} }
@@ -2,10 +2,12 @@ package com.bitchat.android.model
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import com.bitchat.android.util.*
import java.util.* import java.util.*
/** /**
* Read receipt structure - exact same as iOS version * Read receipt structure - exact same as iOS version
* Uses binary encoding for efficient protocol communication
*/ */
@Parcelize @Parcelize
data class ReadReceipt( data class ReadReceipt(
@@ -15,23 +17,83 @@ data class ReadReceipt(
val readerNickname: String, val readerNickname: String,
val timestamp: Date = Date() val timestamp: Date = Date()
) : Parcelable { ) : 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 { * Encode to binary data matching iOS toBinaryData implementation
com.google.gson.Gson().toJson(this).toByteArray(Charsets.UTF_8) */
} catch (e: Exception) { fun encode(): ByteArray {
null 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 { companion object {
/**
* Decode from binary data matching iOS fromBinaryData implementation
*/
fun decode(data: ByteArray): ReadReceipt? { fun decode(data: ByteArray): ReadReceipt? {
return try { // Create defensive copy
val json = String(data, Charsets.UTF_8) val dataCopy = data.copyOf()
com.google.gson.Gson().fromJson(json, ReadReceipt::class.java)
} catch (e: Exception) { // Minimum size: 2 UUIDs (32) + readerID (8) + timestamp (8) + min nickname
null 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
)
} }
} }
} }
@@ -4,7 +4,7 @@ import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.util.* import android.util.Log
/** /**
* Message types - exact same as iOS version with Noise Protocol support * Message types - exact same as iOS version with Noise Protocol support
@@ -262,6 +262,7 @@ object BinaryProtocol {
return paddedData return paddedData
} catch (e: Exception) { } catch (e: Exception) {
Log.e("BinaryProtocol", "Error encoding packet type ${packet.type}: ${e.message}")
return null return null
} }
} }
@@ -23,6 +23,9 @@ class PrivateChatManager(
// Use centralized fingerprint management - NO LOCAL STORAGE // Use centralized fingerprint management - NO LOCAL STORAGE
private val fingerprintManager = PeerFingerprintManager.getInstance() private val fingerprintManager = PeerFingerprintManager.getInstance()
// Track received private messages that need read receipts
private val unreadReceivedMessages = mutableMapOf<String, MutableList<BitchatMessage>>()
// MARK: - Private Chat Lifecycle // MARK: - Private Chat Lifecycle
fun startPrivateChat(peerID: String, meshService: Any): Boolean { fun startPrivateChat(peerID: String, meshService: Any): Boolean {
@@ -46,6 +49,9 @@ class PrivateChatManager(
// Initialize chat if needed // Initialize chat if needed
messageManager.initializePrivateChat(peerID) messageManager.initializePrivateChat(peerID)
// Send read receipts for all unread messages from this peer
sendReadReceiptsForPeer(peerID, meshService)
return true return true
} }
@@ -242,16 +248,56 @@ class PrivateChatManager(
fun handleIncomingPrivateMessage(message: BitchatMessage) { fun handleIncomingPrivateMessage(message: BitchatMessage) {
message.senderPeerID?.let { senderPeerID -> message.senderPeerID?.let { senderPeerID ->
if (!isPeerBlocked(senderPeerID)) { if (!isPeerBlocked(senderPeerID)) {
// Add to private messages
messageManager.addPrivateMessage(senderPeerID, message) 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) { fun cleanupDisconnectedPeer(peerID: String) {
// End private chat if peer disconnected // End private chat if peer disconnected
if (state.getSelectedPrivateChatPeerValue() == peerID) { if (state.getSelectedPrivateChatPeerValue() == peerID) {
endPrivateChat() 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 // MARK: - Utility Functions
@@ -284,6 +330,9 @@ class PrivateChatManager(
state.setSelectedPrivateChatPeer(null) state.setSelectedPrivateChatPeer(null)
state.setUnreadPrivateMessages(emptySet()) state.setUnreadPrivateMessages(emptySet())
// Clear unread messages tracking
unreadReceivedMessages.clear()
// Clear fingerprints via centralized manager (only if needed for emergency clear) // Clear fingerprints via centralized manager (only if needed for emergency clear)
// Note: This will be handled by the parent PeerManager.clearAllPeers() // Note: This will be handled by the parent PeerManager.clearAllPeers()
} }