feat: Implement read receipt handling and mark all outgoing messages as read

This commit is contained in:
2026-01-13 04:10:07 +05:00
parent cdb3d7ab9e
commit e44bed4fa8
5 changed files with 59 additions and 27 deletions

View File

@@ -262,16 +262,20 @@ class MessageRepository private constructor(private val context: Context) {
/** /**
* Обработка прочтения * Обработка прочтения
* В Desktop PacketRead сообщает что собеседник прочитал наши сообщения
* fromPublicKey - кто прочитал (собеседник)
*/ */
suspend fun handleRead(packet: PacketRead) { suspend fun handleRead(packet: PacketRead) {
val account = currentAccount ?: return val account = currentAccount ?: return
messageDao.markAsRead(account, packet.messageId)
// Обновляем кэш // Отмечаем все наши исходящие сообщения к этому собеседнику как прочитанные
messageDao.markAllAsRead(account, packet.fromPublicKey)
// Обновляем кэш - все исходящие сообщения помечаем как прочитанные
val dialogKey = getDialogKey(packet.fromPublicKey) val dialogKey = getDialogKey(packet.fromPublicKey)
messageCache[dialogKey]?.let { flow -> messageCache[dialogKey]?.let { flow ->
flow.value = flow.value.map { msg -> flow.value = flow.value.map { msg ->
if (msg.messageId == packet.messageId) msg.copy(isRead = true) if (msg.isFromMe && !msg.isRead) msg.copy(isRead = true)
else msg else msg
} }
} }

View File

@@ -221,6 +221,16 @@ interface MessageDao {
*/ */
@Query("SELECT EXISTS(SELECT 1 FROM messages WHERE account = :account AND message_id = :messageId)") @Query("SELECT EXISTS(SELECT 1 FROM messages WHERE account = :account AND message_id = :messageId)")
suspend fun messageExists(account: String, messageId: String): Boolean suspend fun messageExists(account: String, messageId: String): Boolean
/**
* Отметить все исходящие сообщения к собеседнику как прочитанные (delivered=3)
* Используется когда приходит PacketRead от собеседника
*/
@Query("""
UPDATE messages SET delivered = 3
WHERE account = :account AND to_public_key = :opponent AND from_me = 1 AND delivered < 3
""")
suspend fun markAllAsRead(account: String, opponent: String)
} }
/** /**

View File

@@ -335,17 +335,18 @@ class PacketMessage : Packet() {
/** /**
* Read packet (ID: 0x07) * Read packet (ID: 0x07)
* Уведомление о прочтении сообщения * Уведомление о прочтении сообщения
* Порядок полей как в Desktop: privateKey, fromPublicKey, toPublicKey
*/ */
class PacketRead : Packet() { class PacketRead : Packet() {
var messageId: String = "" var privateKey: String = ""
var fromPublicKey: String = "" var fromPublicKey: String = ""
var toPublicKey: String = "" var toPublicKey: String = ""
var privateKey: String = ""
override fun getPacketId(): Int = 0x07 override fun getPacketId(): Int = 0x07
override fun receive(stream: Stream) { override fun receive(stream: Stream) {
messageId = stream.readString() // Desktop: privateKey, fromPublicKey, toPublicKey
privateKey = stream.readString()
fromPublicKey = stream.readString() fromPublicKey = stream.readString()
toPublicKey = stream.readString() toPublicKey = stream.readString()
} }
@@ -353,10 +354,10 @@ class PacketRead : Packet() {
override fun send(): Stream { override fun send(): Stream {
val stream = Stream() val stream = Stream()
stream.writeInt16(getPacketId()) stream.writeInt16(getPacketId())
stream.writeString(messageId) // Desktop: privateKey, fromPublicKey, toPublicKey
stream.writeString(privateKey)
stream.writeString(fromPublicKey) stream.writeString(fromPublicKey)
stream.writeString(toPublicKey) stream.writeString(toPublicKey)
stream.writeString(privateKey)
return stream return stream
} }
} }
@@ -422,6 +423,7 @@ class PacketTyping : Packet() {
/** /**
* Chunk packet (ID: 0x09) * Chunk packet (ID: 0x09)
* Для разбиения больших пакетов на части (как в Desktop) * Для разбиения больших пакетов на части (как в Desktop)
* ВАЖНО: chunkIndex и totalChunks - Int16, не Int32!
*/ */
class PacketChunk : Packet() { class PacketChunk : Packet() {
var chunkId: String = "" var chunkId: String = ""
@@ -432,18 +434,20 @@ class PacketChunk : Packet() {
override fun getPacketId(): Int = 0x09 override fun getPacketId(): Int = 0x09
override fun receive(stream: Stream) { override fun receive(stream: Stream) {
// В Desktop: readInt16 для index и total
chunkIndex = stream.readInt16()
totalChunks = stream.readInt16()
chunkId = stream.readString() chunkId = stream.readString()
chunkIndex = stream.readInt32()
totalChunks = stream.readInt32()
data = stream.readBytes() data = stream.readBytes()
} }
override fun send(): Stream { override fun send(): Stream {
val stream = Stream() val stream = Stream()
stream.writeInt16(getPacketId()) stream.writeInt16(getPacketId())
// В Desktop: writeInt16 для index и total
stream.writeInt16(chunkIndex)
stream.writeInt16(totalChunks)
stream.writeString(chunkId) stream.writeString(chunkId)
stream.writeInt32(chunkIndex)
stream.writeInt32(totalChunks)
stream.writeBytes(data) stream.writeBytes(data)
return stream return stream
} }

View File

@@ -87,9 +87,10 @@ object ProtocolManager {
} }
// Обработчик прочтения (0x07) // Обработчик прочтения (0x07)
// В Desktop PacketRead не содержит messageId - сообщает что собеседник прочитал сообщения
waitPacket(0x07) { packet -> waitPacket(0x07) { packet ->
val readPacket = packet as PacketRead val readPacket = packet as PacketRead
addLog("✓✓ Read: ${readPacket.messageId.take(16)}...") addLog("✓✓ Read from: ${readPacket.fromPublicKey.take(16)}...")
scope.launch { scope.launch {
messageRepository?.handleRead(readPacket) messageRepository?.handleRead(readPacket)

View File

@@ -134,17 +134,28 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
// Прочитано // Прочитано - пакет сообщает что собеседник прочитал наши сообщения
// В Desktop нет messageId - просто отмечаем все исходящие сообщения как прочитанные
ProtocolManager.waitPacket(0x07) { packet -> ProtocolManager.waitPacket(0x07) { packet ->
val readPacket = packet as PacketRead val readPacket = packet as PacketRead
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
// Обновляем в БД // Если fromPublicKey == наш собеседник, значит он прочитал наши сообщения
updateMessageStatusInDb(readPacket.messageId, 3) // READ if (readPacket.fromPublicKey == opponentKey) {
// Обновляем UI // Обновляем все непрочитанные исходящие сообщения в БД
val account = myPublicKey ?: return@launch
val opponent = opponentKey ?: return@launch
messageDao.markAllAsRead(account, opponent)
// Обновляем UI - все исходящие сообщения помечаем как прочитанные
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
updateMessageStatus(readPacket.messageId, MessageStatus.READ) _messages.value = _messages.value.map { msg ->
if (msg.isOutgoing && msg.status != MessageStatus.READ) {
msg.copy(status = MessageStatus.READ)
} else msg
}
}
ProtocolManager.addLog("✓✓ Read receipt from: ${readPacket.fromPublicKey.take(8)}...")
} }
ProtocolManager.addLog("✓✓ Read: ${readPacket.messageId.take(8)}...")
} }
} }
@@ -762,29 +773,31 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** /**
* 👁️ Отправить подтверждение о прочтении сообщения * 👁️ Отправить подтверждение о прочтении сообщения
* В Desktop PacketRead не содержит messageId - он просто сообщает что мы прочитали сообщения
*/ */
fun sendReadReceipt(messageId: String, senderPublicKey: String) { fun sendReadReceipt(messageId: String, senderPublicKey: String) {
// Не отправляем повторно // Не отправляем повторно для этого собеседника
if (sentReadReceipts.contains(messageId)) return val receiptKey = senderPublicKey
if (sentReadReceipts.contains(receiptKey)) return
val sender = myPublicKey ?: return val sender = myPublicKey ?: return
val privateKey = myPrivateKey ?: return val privateKey = myPrivateKey ?: return
sentReadReceipts.add(messageId) sentReadReceipts.add(receiptKey)
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
// Desktop формат: privateKey, fromPublicKey, toPublicKey
val packet = PacketRead().apply { val packet = PacketRead().apply {
this.messageId = messageId
fromPublicKey = sender
toPublicKey = senderPublicKey
this.privateKey = privateKeyHash this.privateKey = privateKeyHash
fromPublicKey = sender // Мы (кто прочитал)
toPublicKey = senderPublicKey // Кому отправляем уведомление
} }
ProtocolManager.send(packet) ProtocolManager.send(packet)
ProtocolManager.addLog("👁️ Read receipt sent for: ${messageId.take(8)}...") ProtocolManager.addLog("👁️ Read receipt sent to: ${senderPublicKey.take(8)}...")
// Обновляем в БД что сообщение прочитано // Обновляем в БД что сообщение прочитано
updateMessageReadInDb(messageId) updateMessageReadInDb(messageId)