From 50ec14fb282db4fd409e6437e43a92946230caeb Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 19 Aug 2026 13:16:20 -0300 Subject: [PATCH] Fix read syncs not marking older messages read after being offline. --- .../securesms/database/MessageTable.kt | 7 +- .../MessageTableTest_readSyncTimestamps.kt | 112 ++++++++++++++++++ 2 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/org/thoughtcrime/securesms/database/MessageTableTest_readSyncTimestamps.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index c3d2ca591e..b6ca237468 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -5720,7 +5720,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat val threads: MutableList = LinkedList() readableDatabase - .select(ID, TYPE, THREAD_ID, EXPIRES_IN, EXPIRE_STARTED, LATEST_REVISION_ID) + .select(ID, TYPE, THREAD_ID, DATE_RECEIVED, EXPIRES_IN, EXPIRE_STARTED, LATEST_REVISION_ID) .from(TABLE_NAME) .where("$DATE_SENT = ? AND ($FROM_RECIPIENT_ID = ? OR ($FROM_RECIPIENT_ID = ? AND $outgoingTypeClause))", messageId.timetamp, messageId.recipientId, Recipient.self().id) .run() @@ -5728,6 +5728,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat val id = cursor.requireLong(ID) val type = cursor.requireLong(TYPE) val threadId = cursor.requireLong(THREAD_ID) + val dateReceived = cursor.requireLong(DATE_RECEIVED) val expiresIn = cursor.requireLong(EXPIRES_IN) val expireStarted = cursor.requireLong(EXPIRE_STARTED).let { if (it > 0) { @@ -5763,9 +5764,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat val latest: Long? = threadToLatestRead[threadId] threadToLatestRead[threadId] = if (latest != null) { - max(latest, messageId.timetamp) + max(latest, dateReceived) } else { - messageId.timetamp + dateReceived } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/MessageTableTest_readSyncTimestamps.kt b/app/src/test/java/org/thoughtcrime/securesms/database/MessageTableTest_readSyncTimestamps.kt new file mode 100644 index 0000000000..5f7adcb314 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/database/MessageTableTest_readSyncTimestamps.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.database + +import android.app.Application +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.thoughtcrime.securesms.mms.IncomingMessage +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.testutil.RecipientTestRule +import org.whispersystems.signalservice.internal.push.SyncMessage + +/** + * Verifies that read syncs from a linked device mark the whole thread read up to the synced message, including when + * the messages were received long after they were sent (e.g. a backlog drained after the device was offline). + */ +@Suppress("ClassName") +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class MessageTableTest_readSyncTimestamps { + + @get:Rule + val recipients = RecipientTestRule() + + private val messages: MessageTable + get() = SignalDatabase.messages + + private lateinit var senderId: RecipientId + private var threadId: Long = 0 + + @Before + fun setUp() { + senderId = recipients.createRecipient("Sender Name") + threadId = SignalDatabase.threads.getOrCreateThreadIdFor(senderId, false, ThreadTable.DistributionTypes.DEFAULT) + } + + @Test + fun latestReadIsTrackedByDateReceived() { + insertIncoming(sentTime = 1000, receivedTime = 5000) + insertIncoming(sentTime = 1001, receivedTime = 5001) + + val threadToLatestRead: MutableMap = mutableMapOf() + messages.setTimestampReadFromSyncMessage(listOf(readSync(1001)), 1001, threadToLatestRead) + + assertThat(threadToLatestRead[threadId]).isEqualTo(5001) + } + + @Test + fun readSyncMarksOlderMessagesReadWhenReceivedLongAfterBeingSent() { + insertIncoming(sentTime = 1000, receivedTime = 5000) + insertIncoming(sentTime = 1001, receivedTime = 5001) + insertIncoming(sentTime = 1002, receivedTime = 5002) + + assertThat(messages.getUnreadCount(threadId)).isEqualTo(3) + + // A linked device only tells us about the newest message it read. Everything older in the thread is implied. + syncRead(1002) + + assertThat(messages.getUnreadCount(threadId)).isEqualTo(0) + } + + @Test + fun readSyncLeavesNewerMessagesUnread() { + insertIncoming(sentTime = 1000, receivedTime = 5000) + insertIncoming(sentTime = 1001, receivedTime = 5001) + insertIncoming(sentTime = 1002, receivedTime = 5002) + + syncRead(1001) + + assertThat(messages.getUnreadCount(threadId)).isEqualTo(1) + assertThat(messages.getOldestUnread(threadId)?.dateReceived).isEqualTo(5002) + } + + // region helpers + + private fun insertIncoming(sentTime: Long, receivedTime: Long): Long { + val message = IncomingMessage( + type = MessageType.NORMAL, + from = senderId, + sentTimeMillis = sentTime, + serverTimeMillis = sentTime, + receivedTimeMillis = receivedTime, + body = "msg $sentTime" + ) + return messages.insertMessageInbox(message, threadId).get().messageId + } + + private fun syncRead(sentTime: Long) { + val threadToLatestRead: MutableMap = mutableMapOf() + messages.setTimestampReadFromSyncMessage(listOf(readSync(sentTime)), sentTime, threadToLatestRead) + SignalDatabase.threads.setReadSince(threadToLatestRead) + } + + private fun readSync(sentTime: Long): SyncMessage.Read { + return SyncMessage.Read( + senderAci = Recipient.resolved(senderId).requireAci().toString(), + timestamp = sentTime + ) + } + + // endregion +}