diff --git a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt index a4171a6678..f30b4aef98 100644 --- a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -184,6 +184,14 @@ interface StorageProtocol { runThreadUpdate: Boolean ): MessageId? fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean = false, updateNotification: Boolean = true) + + /** + * Marks the conversation as read up to and including the message with [messageId]. It will + * take the reactions associated with messages prior to and including that message into account. + * + * It will not do anything if the last seen of this thread is already set in the future. + */ + fun markConversationAsReadUpToMessage(messageId: MessageId) fun markConversationAsUnread(threadId: Long) fun getLastSeen(threadId: Long): Long fun ensureMessageHashesAreSender(hashes: Set, sender: String, closedGroupId: String): Boolean diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 76729db389..6de900eb76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -71,6 +71,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first @@ -324,7 +325,11 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, // Search val searchViewModel: SearchViewModel by viewModels() - private val bufferedLastSeenChannel = Channel(capacity = 512, onBufferOverflow = BufferOverflow.DROP_OLDEST) + // The channel where we buffer the last seen data to be saved in the db + // The data can be: + // 1. A `MessageId`, which indicates what message we should mark the last seen until (inclusive of reactions) + // 2. A `Long` (timestamp), which indicates we should mark all messages until that timestamp as seen + private val bufferedLastSeenChannel = Channel(capacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST) private var emojiPickerVisible = false @@ -598,21 +603,32 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, reactionDelegate = ConversationReactionDelegate(reactionOverlayStub) reactionDelegate.setOnReactionSelectedListener(this) lifecycleScope.launch { - // only update the conversation every 3 seconds maximum - // channel is rendezvous and shouldn't block on try send calls as often as we want - bufferedLastSeenChannel.receiveAsFlow() - .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) - .collectLatest { - withContext(Dispatchers.IO) { - try { - if (it > storage.getLastSeen(viewModel.threadId)) { - storage.markConversationAsRead(viewModel.threadId, it) + @Suppress("OPT_IN_USAGE") + bufferedLastSeenChannel.receiveAsFlow() + .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) + .distinctUntilChanged() + .debounce(500L) + .collectLatest { + withContext(Dispatchers.Default) { + try { + when (it) { + is Long -> { + if (storage.getLastSeen(viewModel.threadId) < it) { + storage.markConversationAsRead(viewModel.threadId, it) + } } - } catch (e: Exception) { - Log.e(TAG, "bufferedLastSeenChannel collectLatest", e) + + is MessageId -> { + storage.markConversationAsReadUpToMessage(it) + } + + else -> error("Unsupported type sent to bufferedLastSeenChannel: $it") } + } catch (e: Exception) { + Log.e(TAG, "Error handling last seen", e) } } + } } lifecycleScope.launch { @@ -1376,19 +1392,16 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, val maybeTargetVisiblePosition = layoutManager?.findLastVisibleItemPosition() val targetVisiblePosition = maybeTargetVisiblePosition ?: RecyclerView.NO_POSITION if (!firstLoad.get() && targetVisiblePosition != RecyclerView.NO_POSITION) { - val timestampToSend: Long? = if (binding.conversationRecyclerView.isFullyScrolled) { - // We are at the bottom, so mark "now" as the last seen time - clock.currentTimeMills() + if (binding.conversationRecyclerView.isFullyScrolled) { + adapter.getMessageIdAt(targetVisiblePosition)?.let { lastSeenMessageId -> + bufferedLastSeenChannel.trySend(lastSeenMessageId) + } } else { - // We are not at the bottom, so just mark the timestamp of the last visible message - adapter.getTimestampForItemAt(targetVisiblePosition) - } - - timestampToSend?.let { - bufferedLastSeenChannel.trySend(it).apply { - if (isFailure) Log.e(TAG, "trySend failed", exceptionOrNull()) + adapter.getMessageTimestampAt(targetVisiblePosition)?.let { timestamp -> + bufferedLastSeenChannel.trySend(timestamp) } } + } val layoutUnreadCount = layoutManager?.let { (it.itemCount - 1) - it.findLastVisibleItemPosition() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt index 9f94e9f06d..21efaf7767 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt @@ -19,7 +19,6 @@ import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.dependencies.DatabaseComponent import java.util.concurrent.atomic.AtomicLong -import kotlin.math.max import kotlin.math.min class ConversationAdapter( @@ -261,18 +260,20 @@ class ConversationAdapter( notifyDataSetChanged() } - fun getTimestampForItemAt(firstVisiblePosition: Int): Long? { + fun getMessageIdAt(position: Int): MessageId? { val cursor = this.cursor ?: return null - if (!cursor.moveToPosition(firstVisiblePosition)) return null - val message = messageDB.readerFor(cursor).current ?: return null - if (message.reactions.isEmpty()) { - // If the message has no reactions, we can use the timestamp directly - return message.timestamp - } + if (!cursor.moveToPosition(position)) return null + + val id = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.ID)) + val isMms = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsDatabase.TRANSPORT)) == MmsSmsDatabase.MMS_TRANSPORT + return MessageId(id, isMms) + } + + fun getMessageTimestampAt(position: Int): Long? { + val cursor = this.cursor ?: return null + if (!cursor.moveToPosition(position)) return null - // Otherwise, we will need to take the reaction timestamp into account - val maxReactionTimestamp = message.reactions.maxOf { it.dateReceived } - return max(message.timestamp, maxReactionTimestamp) + return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_SENT)) } companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 652b053e82..038c7fc6f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -322,6 +322,28 @@ public MessageRecord getLastMessage(long threadId, boolean includeReactions, boo } } + /** + * Get the maximum timestamp in a thread up to (and including) the message with the given ID. + * Useful for determining the last read timestamp in a thread. + *

+ * This method will also consider the reactions associated with messages in the thread. + * If a reaction has a timestamp greater than the message timestamp, it will be taken into account. + * + * @param messageId The message ID up to which to search. + * @return A pair of maximum timestamp in mills and thread ID, or null if no messages are found. + */ + @Nullable + public Pair getMaxTimestampInThreadUpTo(@NonNull final MessageId messageId) { + Pair query = MmsSmsDatabaseSQLKt.buildMaxTimestampInThreadUpToQuery(messageId); + try (Cursor cursor = getReadableDatabase().rawQuery(query.getFirst(), query.getSecond())) { + if (cursor != null && cursor.moveToFirst()) { + return new Pair<>(cursor.getLong(0), cursor.getLong(1)); + } else { + return null; + } + } + } + private String buildOutgoingConditionForNotifications() { return "(" + TRANSPORT + " = '" + MMS_TRANSPORT + "' AND " + "(" + MESSAGE_BOX + " & " + MmsSmsColumns.Types.BASE_TYPE_MASK + ") IN (" + buildOutgoingTypesList() + "))" + diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseSQL.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseSQL.kt index bc77f39d23..59ab96dc09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseSQL.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseSQL.kt @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.database +import org.thoughtcrime.securesms.database.model.MessageId + /** * Build a combined query to fetch both MMS and SMS messages in one go, the high level idea is to * use a UNION between two SELECT statements, one for MMS and one for SMS. And they will need @@ -236,3 +238,57 @@ fun buildMmsSmsCombinedQuery( $limitStatement """ } + +/** + * Build a query to get the maximum timestamp (date sent) in a thread up to and including + * the timestamp of the given message ID. + * + * This query will also look at reactions associated with messages in the thread + * to ensure that if there are reactions with later timestamps, they are considered + * as well. + * + * @return A pair containing the SQL query string and an array of parameters to bind. + * The query will return at most one row of "maxTimestamp", "threadId". + */ +fun buildMaxTimestampInThreadUpToQuery(id: MessageId): Pair> { + val msgTable = if (id.mms) MmsDatabase.TABLE_NAME else SmsDatabase.TABLE_NAME + val dateSentColumn = if (id.mms) MmsDatabase.DATE_SENT else SmsDatabase.DATE_SENT + val threadIdColumn = if (id.mms) MmsSmsColumns.THREAD_ID else SmsDatabase.THREAD_ID + + // The query below does this: + // 1. Query the given message, find out its thread id and its date sent + // 2. Find all the messages in this thread before this messages (using result from step 1) + // 3. With this message + earlier messages, grab all the reactions associated with them + // 4. Look at the max date among the reactions returned from step 3 + // 5. Return the max between this message's date and the max reaction date + return """ + SELECT + MAX( + mainMessage.$dateSentColumn, + IFNULL( + ( + SELECT MAX(r.${ReactionDatabase.DATE_SENT}) + FROM ${ReactionDatabase.TABLE_NAME} r + INDEXED BY reaction_message_id_is_mms_index + WHERE (r.${ReactionDatabase.MESSAGE_ID}, r.${ReactionDatabase.IS_MMS}) IN ( + SELECT s.${MmsSmsColumns.ID}, FALSE + FROM ${SmsDatabase.TABLE_NAME} s + WHERE s.${SmsDatabase.THREAD_ID} = mainMessage.${threadIdColumn} AND + s.${SmsDatabase.DATE_SENT} <= mainMessage.$dateSentColumn + + UNION ALL + + SELECT m.${MmsSmsColumns.ID}, TRUE + FROM ${MmsDatabase.TABLE_NAME} m + WHERE m.${MmsSmsColumns.THREAD_ID} = mainMessage.${threadIdColumn} AND + m.${MmsDatabase.DATE_SENT} <= mainMessage.$dateSentColumn + ) + ), + 0 + ) + ) AS maxTimestamp, + mainMessage.$threadIdColumn AS threadId + FROM $msgTable mainMessage + WHERE mainMessage.${MmsSmsColumns.ID} = ? + """ to arrayOf(id.id) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 652440761f..564a2a8234 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -7,6 +7,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.MutableConversationVolatileConfig +import network.loki.messenger.libsession_util.ReadableUserGroupsConfig import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.Bytes import network.loki.messenger.libsession_util.util.Conversation @@ -51,6 +52,7 @@ import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.isCommunity +import org.session.libsession.utilities.isCommunityInbox import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData import org.session.libsession.utilities.upsertContact @@ -73,9 +75,12 @@ import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.util.DateUtils.Companion.secondsToInstant import org.thoughtcrime.securesms.util.FilenameUtils import org.thoughtcrime.securesms.util.SessionMetaProtocol +import java.time.Instant +import java.time.ZoneId import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton +import kotlin.math.max import network.loki.messenger.libsession_util.util.GroupMember as LibSessionGroupMember private const val TAG = "Storage" @@ -193,66 +198,98 @@ open class Storage @Inject constructor( override fun markConversationAsRead(threadId: Long, lastSeenTime: Long, force: Boolean, updateNotification: Boolean) { val threadDb = threadDatabase - getRecipientForThread(threadId)?.let { recipient -> - // don't set the last read in the volatile if we didn't set it in the DB - if (!threadDb.markAllAsRead(threadId, lastSeenTime, force, updateNotification) && !force) return + val threadAddress = threadDb.getRecipientForThreadId(threadId) ?: return + // don't set the last read in the volatile if we didn't set it in the DB + if (!threadDb.markAllAsRead(threadId, lastSeenTime, force, updateNotification) && !force) return - // don't process configs for inbox recipients - if (recipient.isCommunityInboxRecipient) return + // don't process configs for inbox recipients + if (threadAddress.isCommunityInbox) return - val currentLastRead = threadDb.getLastSeenAndHasSent(threadId).first() + val currentLastRead = threadDb.getLastSeenAndHasSent(threadId).first() - configFactory.withMutableUserConfigs { configs -> - val config = configs.convoInfoVolatile - val convo = getConvo(recipient, config) ?: return@withMutableUserConfigs - convo.lastRead = lastSeenTime + configFactory.withMutableUserConfigs { configs -> + val config = configs.convoInfoVolatile + val convo = getConvo( + threadAddress = threadAddress, + config = config, + groupConfig = configs.userGroups + ) ?: return@withMutableUserConfigs + convo.lastRead = lastSeenTime + + if(convo.unread){ + convo.unread = lastSeenTime < currentLastRead + } - if(convo.unread){ - convo.unread = lastSeenTime < currentLastRead - } + config.set(convo) + } + } - config.set(convo) + override fun markConversationAsReadUpToMessage(messageId: MessageId) { + val maxTimestampMillsAndThreadId = mmsSmsDatabase.getMaxTimestampInThreadUpTo(messageId) + if (maxTimestampMillsAndThreadId != null) { + val threadId = maxTimestampMillsAndThreadId.second + val maxTimestamp = maxTimestampMillsAndThreadId.first + if (getLastSeen(threadId) < maxTimestamp) { + Log.d(TAG, "Marking last seen for thread $threadId as ${Instant.ofEpochMilli(maxTimestamp).atZone( + ZoneId.systemDefault())}") + markConversationAsRead( + threadId = threadId, + lastSeenTime = maxTimestamp, + force = false, + updateNotification = true + ) } } } override fun markConversationAsUnread(threadId: Long) { - getRecipientForThread(threadId)?.let { recipient -> - // don't process configs for inbox recipients - if (recipient.isCommunityInboxRecipient) return + val threadAddress = threadDatabase.getRecipientForThreadId(threadId) ?: return - configFactory.withMutableUserConfigs { configs -> - val config = configs.convoInfoVolatile - val convo = getConvo(recipient, config) ?: return@withMutableUserConfigs + // don't process configs for inbox recipients + if (threadAddress.isCommunityInbox) return - convo.unread = true - config.set(convo) - } + configFactory.withMutableUserConfigs { configs -> + val config = configs.convoInfoVolatile + val convo = getConvo( + threadAddress = threadAddress, + config = config, + groupConfig = configs.userGroups + ) ?: return@withMutableUserConfigs + + convo.unread = true + config.set(convo) } } - private fun getConvo(recipient: Recipient, config: MutableConversationVolatileConfig) : Conversation? { - return when (recipient.address) { + private fun getConvo( + threadAddress: Address, + config: MutableConversationVolatileConfig, + groupConfig: ReadableUserGroupsConfig + ) : Conversation? { + return when (threadAddress) { // recipient closed group - is Address.LegacyGroup -> config.getOrConstructLegacyGroup(recipient.address.groupPublicKeyHex) - is Address.Group -> config.getOrConstructClosedGroup(recipient.address.accountId.hexString) + is Address.LegacyGroup -> config.getOrConstructLegacyGroup(threadAddress.groupPublicKeyHex) + is Address.Group -> config.getOrConstructClosedGroup(threadAddress.accountId.hexString) // recipient is open group is Address.Community -> { - val og = recipient.data as? RecipientData.Community ?: return null + val og = groupConfig.getCommunityInfo( + baseUrl = threadAddress.serverUrl, + room = threadAddress.room, + ) ?: return null config.getOrConstructCommunity( - baseUrl = recipient.address.serverUrl, - room = recipient.address.room, - pubKeyHex = og.serverPubKey, + baseUrl = threadAddress.serverUrl, + room = threadAddress.room, + pubKeyHex = og.community.pubKeyHex, ) } is Address.CommunityBlindedId -> { - config.getOrConstructedBlindedOneToOne(recipient.address.blindedId.blindedId.hexString) + config.getOrConstructedBlindedOneToOne(threadAddress.blindedId.blindedId.hexString) } // otherwise recipient is one to one is Address.Standard -> { - config.getOrConstructOneToOne(recipient.address.accountId.hexString) + config.getOrConstructOneToOne(threadAddress.accountId.hexString) } - else -> throw NullPointerException("Weren't expecting to have a convo with address ${recipient.address}") + else -> throw NullPointerException("Weren't expecting to have a convo with address ${threadAddress}") } }