diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/UnreadDividerInstrumentationTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/UnreadDividerInstrumentationTest.kt new file mode 100644 index 0000000000..7293758b06 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/UnreadDividerInstrumentationTest.kt @@ -0,0 +1,393 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import android.app.Activity +import android.app.Application +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isGreaterThan +import assertk.assertions.isGreaterThanOrEqualTo +import assertk.assertions.isLessThan +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.thoughtcrime.securesms.MainActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.conversation.ConversationIntents +import org.thoughtcrime.securesms.database.MessageType +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.mms.IncomingMessage +import org.thoughtcrime.securesms.mms.OutgoingMessage +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.testing.SignalActivityRule +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * End-to-end UI test of the unread divider. Seeds a thread with many unread messages and opens it via the notification + * path (which enters the conversation with no explicit jump point — functionally "open a chat with X unread"), then + * verifies the real pipeline (repository -> view model -> fragment -> decoration) anchors the divider to the oldest + * unread message and scrolls there rather than opening at the bottom. + * + * The launch harness mirrors [org.thoughtcrime.securesms.main.MainNavigationLaunchTest]: ActivityScenario can't track + * MainActivity launched with a custom-action intent, so we start it via Application#startActivity and observe lifecycle + * callbacks instead. + */ +@RunWith(AndroidJUnit4::class) +class UnreadDividerInstrumentationTest { + + @get:Rule + val harness = SignalActivityRule(othersCount = 2) + + @Test + fun opensScrolledToOldestUnreadWithCorrectDividerState() { + val recipientId = harness.others.first() + SignalDatabase.recipients.setProfileSharing(recipientId, true) + val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId)) + + val totalUnread = 50 + val oldestSentTime = 1000L + var oldestUnreadId = -1L + for (i in 0 until totalUnread) { + val id = insertIncoming(threadId, recipientId, time = oldestSentTime + i, body = "unread $i") + if (i == 0) { + oldestUnreadId = id + } + } + + // Derive expectations from the DB the same way the app does, so the test is robust to any extra system rows. + val expectedUnreadCount = SignalDatabase.messages.getUnreadCount(threadId) + val firstUnreadPosition = SignalDatabase.messages.getMessagePositionByDateReceivedTimestamp(threadId, oldestSentTime, false) + + launch(recipientId).use { launched -> + val result = await(timeoutMs = 20_000, description = "conversation scrolled to oldest unread") { + val fragment = launched.latestConversationFragment() ?: return@await null + val recycler = fragment.view?.findViewById(R.id.conversation_item_recycler) ?: return@await null + val decoration = recycler.conversationItemDecorations() ?: return@await null + val state = decoration.unreadStateForTesting as? ConversationItemDecorations.UnreadState.CompleteUnreadState ?: return@await null + val view = recycler.layoutManager?.findViewByPosition(firstUnreadPosition) ?: return@await null + Observed(state.unreadCount, state.firstUnreadId, view.top, recycler.height) + } + + assertThat(result.unreadCount).isEqualTo(expectedUnreadCount) + assertThat(result.firstUnreadId).isEqualTo(oldestUnreadId) + // The oldest unread is laid out in the top half -> we scrolled up to it instead of opening at the bottom (where, + // with this many messages, it would be off-screen above and findViewByPosition would have returned null). + assertThat(result.firstUnreadTop).isGreaterThanOrEqualTo(0) + assertThat(result.firstUnreadTop).isLessThan(result.recyclerHeight / 2) + } + } + + @Test + fun fullyReadConversationOpensAtBottomWithoutDivider() { + val recipientId = harness.others.first() + SignalDatabase.recipients.setProfileSharing(recipientId, true) + val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId)) + + val total = 50 + for (i in 0 until total) { + insertIncoming(threadId, recipientId, time = 1000L + i, body = "read $i") + } + SignalDatabase.threads.setRead(threadId) + // Precondition: nothing is unread, so there should be no divider. + assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isEqualTo(0) + + launch(recipientId).use { launched -> + val result = await(timeoutMs = 20_000, description = "fully-read conversation opened at the bottom") { + val fragment = launched.latestConversationFragment() ?: return@await null + val recycler = fragment.view?.findViewById(R.id.conversation_item_recycler) ?: return@await null + val decoration = recycler.conversationItemDecorations() ?: return@await null + // The newest message is position 0; if it's laid out, the list loaded and settled at the bottom. + val newest = recycler.layoutManager?.findViewByPosition(0) ?: return@await null + BottomObserved(decoration.unreadStateForTesting, newest.bottom, recycler.height) + } + + assertThat(result.unreadState).isEqualTo(ConversationItemDecorations.UnreadState.None) + // Newest message sits in the lower half -> opened at the bottom (with this many messages it would be off-screen + // below if we'd opened at the top). + assertThat(result.newestBottom).isGreaterThan(result.recyclerHeight / 2) + } + } + + @Test + fun outgoingMessageNewerThanUnreadClearsDivider() { + val recipientId = harness.others.first() + SignalDatabase.recipients.setProfileSharing(recipientId, true) + val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId)) + + // A few unread incoming messages, then a newer outgoing reply. Kept small so all rows load in the initial page. + insertIncoming(threadId, recipientId, time = 1000L, body = "unread 0") + insertIncoming(threadId, recipientId, time = 1001L, body = "unread 1") + insertIncoming(threadId, recipientId, time = 1002L, body = "unread 2") + val outgoing = OutgoingMessage.text( + threadRecipient = Recipient.resolved(recipientId), + body = "my reply", + expiresIn = 0, + sentTimeMillis = 1003L + ) + SignalDatabase.messages.insertMessageOutbox(outgoing, threadId) + + // Precondition: the messages are still unread at the DB level, so the divider would show if it weren't for the + // newer outgoing message clearing it. + assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isGreaterThan(0) + + launch(recipientId).use { launched -> + val cleared = await(timeoutMs = 20_000, description = "divider cleared by newer outgoing message") { + val fragment = launched.latestConversationFragment() ?: return@await null + val recycler = fragment.view?.findViewById(R.id.conversation_item_recycler) ?: return@await null + val decoration = recycler.conversationItemDecorations() ?: return@await null + // Wait until the list has loaded (outgoing at position 0 laid out) before reading the resolved state. + recycler.layoutManager?.findViewByPosition(0) ?: return@await null + if (decoration.unreadStateForTesting == ConversationItemDecorations.UnreadState.None) true else null + } + + assertThat(cleared).isEqualTo(true) + } + } + + @Test + fun scrollingToBottomMarksEverythingReadAndDrainsUnreadCount() { + val recipientId = harness.others.first() + SignalDatabase.recipients.setProfileSharing(recipientId, true) + val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId)) + + val total = 50 + for (i in 0 until total) { + insertIncoming(threadId, recipientId, time = 1000L + i, body = "unread $i") + } + + launch(recipientId).use { launched -> + // getUnreadCount is the shared source for the chat-list badge and the scroll-to-bottom button's count, so + // asserting on it verifies the number the user sees updating as they scroll. + await(timeoutMs = 20_000, description = "conversation loaded") { + val recycler = launched.latestConversationFragment()?.view?.findViewById(R.id.conversation_item_recycler) + if ((recycler?.childCount ?: 0) > 0) true else null + } + assertThat(SignalDatabase.messages.getUnreadCount(threadId)).isGreaterThan(0) + + // Jump to the newest message; revealing it marks every earlier message read (MarkReadHelper.onViewsRevealed). + runOnMain { + launched.latestConversationFragment()?.view?.findViewById(R.id.conversation_item_recycler)?.scrollToPosition(0) + } + + // Scrolling through the thread drains the unread count to 0. + await(timeoutMs = 20_000, description = "unread count reaches 0 after scrolling to the bottom") { + if (SignalDatabase.messages.getUnreadCount(threadId) == 0) true else null + } + } + } + + @Test + fun scrollingPartwayLeavesExactlyTheUnreadMessagesBelowTheViewport() { + val recipientId = harness.others.first() + SignalDatabase.recipients.setProfileSharing(recipientId, true) + val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId)) + + val total = 50 + for (i in 0 until total) { + insertIncoming(threadId, recipientId, time = 1000L + i, body = "unread $i") + } + + launch(recipientId).use { launched -> + await(timeoutMs = 20_000, description = "conversation loaded") { + val recycler = launched.latestConversationFragment()?.view?.findViewById(R.id.conversation_item_recycler) + if ((recycler?.childCount ?: 0) > 0) true else null + } + + // The chat opens at the oldest unread (near the top); scroll down to roughly the middle. + runOnMain { + val recycler = launched.latestConversationFragment()?.view?.findViewById(R.id.conversation_item_recycler) + (recycler?.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(total / 2, 0) + } + + // Once mark-read settles, the unread count must equal the index of the newest visible message — i.e. exactly the + // messages still below the viewport (reverse layout: position 0 = newest, so index N = N newer messages). This is + // the number the scroll-to-bottom button and chat-list badge show; it must not over- or under-count mid-scroll. + val stableCount = awaitStableUnreadCount(threadId) + val newestVisiblePosition = await(timeoutMs = 5_000, description = "newest visible position") { + val recycler = launched.latestConversationFragment()?.view?.findViewById(R.id.conversation_item_recycler) + (recycler?.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()?.takeIf { it >= 0 } + } + + assertThat(stableCount).isEqualTo(newestVisiblePosition) + // Sanity: we exercised a genuine mid-scroll point, not the very top or bottom. + assertThat(stableCount).isGreaterThan(0) + assertThat(stableCount).isLessThan(total) + } + } + + /** Polls [MessageTable.getUnreadCount] until it holds steady (mark-read is debounced + async), then returns it. */ + private fun awaitStableUnreadCount(threadId: Long, timeoutMs: Long = 20_000): Int { + val deadline = System.currentTimeMillis() + timeoutMs + var last = Int.MIN_VALUE + var stableSince = System.currentTimeMillis() + while (System.currentTimeMillis() < deadline) { + val current = SignalDatabase.messages.getUnreadCount(threadId) + if (current == last) { + if (System.currentTimeMillis() - stableSince >= 500) { + return current + } + } else { + last = current + stableSince = System.currentTimeMillis() + } + Thread.sleep(100) + } + throw AssertionError("Unread count never stabilized (last observed = $last)") + } + + private data class BottomObserved( + val unreadState: ConversationItemDecorations.UnreadState, + val newestBottom: Int, + val recyclerHeight: Int + ) + + private fun insertIncoming(threadId: Long, from: RecipientId, time: Long, body: String): Long { + val message = IncomingMessage( + type = MessageType.NORMAL, + from = from, + sentTimeMillis = time, + serverTimeMillis = time, + receivedTimeMillis = time, + body = body + ) + return SignalDatabase.messages.insertMessageInbox(message, threadId).get().messageId + } + + private data class Observed( + val unreadCount: Int, + val firstUnreadId: Long, + val firstUnreadTop: Int, + val recyclerHeight: Int + ) + + private fun RecyclerView.conversationItemDecorations(): ConversationItemDecorations? { + for (i in 0 until itemDecorationCount) { + val decoration = getItemDecorationAt(i) + if (decoration is ConversationItemDecorations) { + return decoration + } + } + return null + } + + private fun runOnMain(block: () -> Unit) { + InstrumentationRegistry.getInstrumentation().runOnMainSync { block() } + } + + /** Polls [block] on the main thread until it returns non-null, failing after [timeoutMs]. */ + private fun await(timeoutMs: Long, pollMs: Long = 100, description: String, block: () -> T?): T { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + var value: T? = null + InstrumentationRegistry.getInstrumentation().runOnMainSync { value = block() } + if (value != null) { + return value!! + } + Thread.sleep(pollMs) + } + throw AssertionError("Timed out after ${timeoutMs}ms waiting for $description") + } + + private fun launch(recipientId: RecipientId): Launched { + val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application + val resumed = CountDownLatch(1) + val conversationFragments: MutableList = Collections.synchronizedList(mutableListOf()) + val allActivities: MutableList = Collections.synchronizedList(mutableListOf()) + + val fragmentCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() { + override fun onFragmentCreated(fm: FragmentManager, f: Fragment, savedInstanceState: Bundle?) { + if (f is ConversationFragment) { + conversationFragments.add(f) + } + } + + override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) { + if (f is ConversationFragment) { + conversationFragments.remove(f) + } + } + } + + val activityCallbacks = object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + allActivities.add(activity) + if (activity is MainActivity) { + activity.supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentCallbacks, true) + } + } + + override fun onActivityResumed(activity: Activity) { + if (activity is MainActivity) { + resumed.countDown() + } + } + + override fun onActivityStarted(activity: Activity) = Unit + override fun onActivityPaused(activity: Activity) = Unit + override fun onActivityStopped(activity: Activity) = Unit + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) { + allActivities.remove(activity) + } + } + app.registerActivityLifecycleCallbacks(activityCallbacks) + + // Open the conversation the way a notification tap does: a conversation intent with no starting position. + val conversationIntent = ConversationIntents.createBuilder(harness.context, recipientId, -1L).blockingGet().build() + val intent = Intent(harness.context, MainActivity::class.java).apply { + action = ConversationIntents.ACTION + putExtras(conversationIntent) + // Application#startActivity from a non-Activity context requires a new task. + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + try { + app.startActivity(intent) + } catch (t: Throwable) { + app.unregisterActivityLifecycleCallbacks(activityCallbacks) + throw t + } + + if (!resumed.await(15, TimeUnit.SECONDS)) { + app.unregisterActivityLifecycleCallbacks(activityCallbacks) + throw AssertionError("MainActivity did not reach RESUMED within 15s") + } + + return Launched(conversationFragments, app, activityCallbacks, allActivities) + } + + private class Launched( + private val conversationFragments: List, + private val app: Application, + private val callbacks: Application.ActivityLifecycleCallbacks, + private val allActivities: MutableList + ) : AutoCloseable { + + fun latestConversationFragment(): ConversationFragment? = synchronized(conversationFragments) { conversationFragments.lastOrNull() } + + override fun close() { + val toFinish = synchronized(allActivities) { allActivities.toList() } + if (toFinish.isNotEmpty()) { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + toFinish.forEach { it.finish() } + } + } + app.unregisterActivityLifecycleCallbacks(callbacks) + } + } +} diff --git a/app/src/main/java/androidx/recyclerview/widget/ConversationLayoutManager.kt b/app/src/main/java/androidx/recyclerview/widget/ConversationLayoutManager.kt index cd1b252301..64376cf34b 100644 --- a/app/src/main/java/androidx/recyclerview/widget/ConversationLayoutManager.kt +++ b/app/src/main/java/androidx/recyclerview/widget/ConversationLayoutManager.kt @@ -24,6 +24,11 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context, private var afterScroll: (() -> Unit)? = null + // Backing state for scrollToPositionTopAligned; alignTopCorrected guards the one-shot corrective re-scroll. + private var alignTopPosition: Int = RecyclerView.NO_POSITION + private var alignTopInset: Int = 0 + private var alignTopCorrected: Boolean = false + override fun supportsPredictiveItemAnimations(): Boolean { return false } @@ -34,9 +39,23 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context, */ fun scrollToPositionWithOffset(position: Int, offset: Int, afterScroll: () -> Unit) { this.afterScroll = afterScroll + alignTopPosition = RecyclerView.NO_POSITION super.scrollToPositionWithOffset(position, offset) } + /** + * Scroll so [position]'s decorated top (including any top decoration, e.g. the unread divider) lands [topInset] px + * below the top of the recycler. [afterScroll] fires once the alignment settles. + */ + fun scrollToPositionTopAligned(position: Int, topInset: Int, afterScroll: () -> Unit) { + this.afterScroll = afterScroll + alignTopPosition = position + alignTopInset = topInset + alignTopCorrected = false + // Rough first pass: the exact offset needs the item's height, which isn't known until it's laid out (see onLayoutCompleted). + super.scrollToPositionWithOffset(position, height - topInset) + } + /** * If a scroll to position request is made and a layout pass occurs prior to the list being populated with via the data source, * the base implementation clears the request as if it was never made. @@ -64,10 +83,26 @@ class ConversationLayoutManager(context: Context) : LinearLayoutManager(context, } else { scrollToPosition(pendingScrollPosition) } - } else { - afterScroll?.invoke() - afterScroll = null + return } + + // The target is now laid out, so its height is known. Correct the offset once so the decorated top sits at the + // requested inset, then let the next layout settle before notifying via afterScroll. + if (alignTopPosition != RecyclerView.NO_POSITION && !alignTopCorrected) { + val target = findViewByPosition(alignTopPosition) + if (target != null) { + alignTopCorrected = true + if (getDecoratedTop(target) != alignTopInset) { + val correctedOffset = (height - paddingBottom) - alignTopInset - getDecoratedMeasuredHeight(target) + super.scrollToPositionWithOffset(alignTopPosition, correctedOffset) + return + } + } + } + + afterScroll?.invoke() + afterScroll = null + alignTopPosition = RecyclerView.NO_POSITION } companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.kt index fb3419f9ed..2a78d2cf8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationData.kt @@ -9,8 +9,8 @@ import org.thoughtcrime.securesms.recipients.Recipient data class ConversationData( val threadRecipient: Recipient, val threadId: Long, - val lastSeen: Long, - val lastSeenPosition: Int, + val firstUnreadId: Long, + val firstUnreadPosition: Int, val lastScrolledPosition: Int, val jumpToPosition: Int, val threadSize: Int, @@ -24,14 +24,14 @@ data class ConversationData( return jumpToPosition >= 0 } - fun shouldScrollToLastSeen(): Boolean { - return lastSeenPosition > 0 + fun shouldScrollToFirstUnread(): Boolean { + return firstUnreadPosition > 0 } fun getStartPosition(): Int { return when { shouldJumpToMessage() -> jumpToPosition - messageRequestData.isMessageRequestAccepted && shouldScrollToLastSeen() -> lastSeenPosition + messageRequestData.isMessageRequestAccepted && shouldScrollToFirstUnread() -> firstUnreadPosition messageRequestData.isMessageRequestAccepted -> lastScrolledPosition else -> threadSize } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java index 52da1d8fd6..e22c8709b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java @@ -50,8 +50,10 @@ public class ConversationRepository { public @NonNull ConversationData getConversationData(long threadId, @NonNull Recipient conversationRecipient, int jumpToPosition) { ThreadTable.ConversationMetadata metadata = SignalDatabase.threads().getConversationMetadata(threadId); int threadSize = SignalDatabase.messages().getMessageCountForThread(threadId); - long lastSeen = metadata.getLastSeen(); - int lastSeenPosition = 0; + MessageTable.OldestUnread oldestUnread = metadata.getUnreadCount() > 0 ? SignalDatabase.messages().getOldestUnread(threadId) : null; + long firstUnreadId = oldestUnread != null ? oldestUnread.getId() : -1; + long firstUnreadDateReceived = oldestUnread != null ? oldestUnread.getDateReceived() : 0; + int firstUnreadPosition = 0; long lastScrolled = metadata.getLastScrolled(); int lastScrolledPosition = 0; boolean isMessageRequestAccepted = RecipientUtil.isMessageRequestAccepted(threadId); @@ -59,15 +61,16 @@ public class ConversationRepository { ConversationData.MessageRequestData messageRequestData = new ConversationData.MessageRequestData(isMessageRequestAccepted, isConversationHidden); boolean showUniversalExpireTimerUpdate = false; - if (lastSeen > 0) { - lastSeenPosition = SignalDatabase.messages().getMessagePositionByDateReceivedTimestamp(threadId, lastSeen, false); + if (firstUnreadDateReceived > 0) { + firstUnreadPosition = SignalDatabase.messages().getMessagePositionByDateReceivedTimestamp(threadId, firstUnreadDateReceived, false); } - if (lastSeenPosition <= 0) { - lastSeen = 0; + if (firstUnreadPosition <= 0) { + firstUnreadId = -1; + firstUnreadDateReceived = 0; } - if (lastSeen == 0 && lastScrolled > 0) { + if (firstUnreadDateReceived == 0 && lastScrolled > 0) { lastScrolledPosition = SignalDatabase.messages().getMessagePositionByDateReceivedTimestamp(threadId, lastScrolled, true); } @@ -108,7 +111,7 @@ public class ConversationRepository { showUniversalExpireTimerUpdate = true; } - return new ConversationData(conversationRecipient, threadId, lastSeen, lastSeenPosition, lastScrolledPosition, jumpToPosition, threadSize, messageRequestData, showUniversalExpireTimerUpdate, metadata.getUnreadCount(), groupMemberAcis); + return new ConversationData(conversationRecipient, threadId, firstUnreadId, firstUnreadPosition, lastScrolledPosition, jumpToPosition, threadSize, messageRequestData, showUniversalExpireTimerUpdate, metadata.getUnreadCount(), groupMemberAcis); } public void markGiftBadgeRevealed(long messageId) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index e795e7c3db..68e3ec65fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -1172,7 +1172,7 @@ class ConversationFragment : .doOnSuccess { state -> SignalLocalMetrics.ConversationOpen.onDataLoaded() conversationItemDecorations.selfRecipientId = Recipient.self().id - conversationItemDecorations.setFirstUnreadCount(state.meta.unreadCount) + conversationItemDecorations.setUnreadState(state.meta.unreadCount, state.meta.firstUnreadId) colorizer.onGroupMembershipChanged(state.meta.groupMemberAcis) } .observeOn(AndroidSchedulers.mainThread()) @@ -3238,20 +3238,28 @@ class ConversationFragment : val toolbarOffset = rect.bottom binding.toolbar.viewTreeObserver.removeOnGlobalLayoutListener(this) - val offset = when { - meta.getStartPosition() == 0 -> 0 - meta.shouldJumpToMessage() -> (binding.conversationItemRecycler.height - toolbarOffset) / 4 - meta.shouldScrollToLastSeen() -> binding.conversationItemRecycler.height - toolbarOffset - else -> binding.conversationItemRecycler.height - } + val startPosition = meta.getStartPosition() + Log.d(TAG, "Scrolling to start position $startPosition") - Log.d(TAG, "Scrolling to start position ${meta.getStartPosition()}") - layoutManager.scrollToPositionWithOffset(meta.getStartPosition(), offset) { - animationsAllowed = true - markReadHelper.stopIgnoringViewReveals(MarkReadHelper.getLatestTimestamp(adapter, layoutManager).orNull()) - if (meta.shouldJumpToMessage()) { - binding.conversationItemRecycler.post { - adapter.pulseAtPosition(meta.getStartPosition()) + if (meta.shouldScrollToFirstUnread()) { + // Land the divider just below the toolbar. + layoutManager.scrollToPositionTopAligned(startPosition, toolbarOffset) { + animationsAllowed = true + markReadHelper.stopIgnoringViewReveals(MarkReadHelper.getLatestTimestamp(adapter, layoutManager).orNull()) + } + } else { + val offset = when { + startPosition == 0 -> 0 + meta.shouldJumpToMessage() -> (binding.conversationItemRecycler.height - toolbarOffset) / 4 + else -> binding.conversationItemRecycler.height + } + layoutManager.scrollToPositionWithOffset(startPosition, offset) { + animationsAllowed = true + markReadHelper.stopIgnoringViewReveals(MarkReadHelper.getLatestTimestamp(adapter, layoutManager).orNull()) + if (meta.shouldJumpToMessage()) { + binding.conversationItemRecycler.post { + adapter.pulseAtPosition(startPosition) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationItemDecorations.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationItemDecorations.kt index b1955518e2..b98f0623a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationItemDecorations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationItemDecorations.kt @@ -10,6 +10,7 @@ import android.graphics.Rect import android.view.LayoutInflater import android.view.View import android.widget.TextView +import androidx.annotation.VisibleForTesting import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import org.thoughtcrime.securesms.R @@ -45,6 +46,11 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch unreadViewHolder?.bind() } + /** The current unread-divider state. Exposed for instrumentation tests asserting end-to-end divider behavior. */ + @get:VisibleForTesting + val unreadStateForTesting: UnreadState + get() = unreadState + var currentItems: List = emptyList() set(value) { field = value @@ -119,31 +125,24 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch } } - /** Must be called before first setting of [currentItems] */ - fun setFirstUnreadCount(unreadCount: Int) { - if (unreadState == UnreadState.None && unreadCount > 0) { - unreadState = UnreadState.InitialUnreadState(unreadCount) + /** + * Must be called before first setting of [currentItems]. [firstUnreadId] is the row id of the oldest unread message, + * used as the unread divider's anchor. + */ + fun setUnreadState(unreadCount: Int, firstUnreadId: Long) { + if (unreadState == UnreadState.None && unreadCount > 0 && firstUnreadId > 0) { + unreadState = UnreadState.CompleteUnreadState(unreadCount = unreadCount, firstUnreadId = firstUnreadId) } } /** - * If [unreadState] is [UnreadState.InitialUnreadState] we need to determine the first unread timestamp based on - * initial unread count. - * - * Once in [UnreadState.CompleteUnreadState], need to update the unread count based on new incoming messages since - * the first unread timestamp. If an outgoing message is found in this range the unread state is cleared completely, - * which causes the unread divider to be removed. + * Recomputes the unread count from newer messages up to the first unread message. If an outgoing message is found in + * that range the unread state is cleared, removing the divider. */ private fun updateUnreadState(items: List) { val state: UnreadState = unreadState - if (state is UnreadState.InitialUnreadState) { - val firstUnread: ConversationMessageElement? = findFirstUnreadStartingAt(items, (state.unreadCount - 1).coerceIn(items.indices), state.unreadCount) - val timestamp = firstUnread?.timestamp() - if (timestamp != null) { - unreadState = UnreadState.CompleteUnreadState(unreadCount = state.unreadCount, firstUnreadTimestamp = timestamp) - } - } else if (state is UnreadState.CompleteUnreadState) { + if (state is UnreadState.CompleteUnreadState) { var newUnreadCount = 0 for (element in items) { if (element is ConversationMessageElement) { @@ -155,7 +154,7 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch newUnreadCount++ } - if (element.timestamp() == state.firstUnreadTimestamp) { + if (element.conversationMessage.messageRecord.id == state.firstUnreadId) { unreadState = state.copy(unreadCount = max(state.unreadCount, newUnreadCount)) break } @@ -165,30 +164,6 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch } } - /** - * Attempt to find the "first" unread message, searching a range of 20 items in the list starting at index `unreadCount - 1`. The - * search helps us skip over interspersed read messages like chat events that could mess up the location of the header. - */ - private fun findFirstUnreadStartingAt(items: List, startingIndex: Int, unreadCount: Int): ConversationMessageElement? { - val endingIndex = (startingIndex + 20).coerceAtMost(items.lastIndex) - var targetUnread: ConversationMessageElement? = null - var runningUnreadCount = 0 - - for (index in startingIndex..endingIndex) { - val item = items[index] as? ConversationMessageElement - if ((item?.conversationMessage?.messageRecord as? MmsMessageRecord)?.isRead == false) { - targetUnread = item - runningUnreadCount++ - } - - if (runningUnreadCount >= unreadCount) { - break - } - } - - return targetUnread ?: items[startingIndex] as? ConversationMessageElement - } - /** * Only include message that would normally count towards unread count when updating the banner while new messages * come in while viewing the chat. @@ -201,6 +176,9 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch * Note 2: The caller should've already checked [MmsMessageRecord.isOutgoing] before calling this but some outgoing * messages don't use the outgoing types like an outgoing group call, so filter on the [MmsMessageRecord.fromRecipient] * here as well. + * + * Note 3: Only actually-unread rows count -- some inbox-type events are inserted already-read (e.g. identity updates), + * and counting them would inflate the banner past the thread's stored unread count. */ private fun MmsMessageRecord.countsTowardsUnread(): Boolean { val likelyIncoming = MessageTypes.isInboxType(this.type) || @@ -208,16 +186,15 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch MessageTypes.isIncomingAudioCall(this.type) || MessageTypes.isIncomingVideoCall(this.type) - return likelyIncoming && !MessageTypes.isGroupUpdate(this.type) && this.fromRecipient.id != selfRecipientId + return likelyIncoming && !this.isRead && !MessageTypes.isGroupUpdate(this.type) && this.fromRecipient.id != selfRecipientId } private fun isFirstUnread(bindingAdapterPosition: Int): Boolean { val state = unreadState return state is UnreadState.CompleteUnreadState && - state.firstUnreadTimestamp != null && bindingAdapterPosition in currentItems.indices && - (currentItems[bindingAdapterPosition] as? ConversationMessageElement)?.timestamp() == state.firstUnreadTimestamp + (currentItems[bindingAdapterPosition] as? ConversationMessageElement)?.conversationMessage?.messageRecord?.id == state.firstUnreadId } /** @@ -365,10 +342,7 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch /** Unread state hasn't been initialized or there are 0 unreads upon entering the conversation */ object None : UnreadState() - /** On first load of data, there is at least 1 unread message but we don't know the 'position' in the list yet */ - data class InitialUnreadState(val unreadCount: Int) : UnreadState() - - /** We have at least one unread and know the timestamp of the first unread message and thus 'position' for the header */ - data class CompleteUnreadState(val unreadCount: Int, val firstUnreadTimestamp: Long? = null) : UnreadState() + /** We have at least one unread and know the row id of the first unread message, used to position the header */ + data class CompleteUnreadState(val unreadCount: Int, val firstUnreadId: Long) : UnreadState() } } 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 001e8e62d6..219d3cd527 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -2670,6 +2670,30 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } } + /** + * The oldest unread message as displayed in the thread (latest revision, not collapsed, not pinned), or null if there + * are none. Anchors the unread divider ([OldestUnread.id]) and its scroll position ([OldestUnread.dateReceived]); this + * is a separate query from the unread count and is not expected to select an identical row set. + */ + fun getOldestUnread(threadId: Long): OldestUnread? { + val pinnedMessageClause = "($TYPE & ${MessageTypes.SPECIAL_TYPES_MASK}) != ${MessageTypes.SPECIAL_TYPE_PINNED_MESSAGE}" + // The redundant "($READ = 0 OR $REACTIONS_UNREAD = 1 OR $VOTES_UNREAD = 1)" term lets the planner use the partial + // index to satisfy ORDER BY $DATE_RECEIVED without a sort (same trick as setMessagesReadSince). + return readableDatabase + .select(ID, DATE_RECEIVED) + .from("$TABLE_NAME INDEXED BY $INDEX_THREAD_DATE_RECEIVED_UNREAD") + .where("$THREAD_ID = ? AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND ($READ = 0 OR $REACTIONS_UNREAD = 1 OR $VOTES_UNREAD = 1) AND $READ = 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL AND $COLLAPSED_STATE != ${CollapsedState.COLLAPSED.id} AND $pinnedMessageClause", threadId) + .orderBy("$DATE_RECEIVED ASC") + .limit(1) + .run() + .readToSingleObject { cursor -> + OldestUnread( + id = cursor.requireLong(ID), + dateReceived = cursor.requireLong(DATE_RECEIVED) + ) + } + } + fun getUnreadMentionCount(threadId: Long): Int { return readableDatabase .count() @@ -6557,6 +6581,11 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat val threadId: Long ) + data class OldestUnread( + val id: Long, + val dateReceived: Long + ) + data class Duplicate( val id: Long, val dateSent: Long, diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/ApiPlugin.kt b/app/src/spinner/java/org/thoughtcrime/securesms/ApiPlugin.kt index 77a1775a89..5c47c7bbb0 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/ApiPlugin.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/ApiPlugin.kt @@ -39,6 +39,16 @@ class ApiPlugin : Plugin { companion object { private val TAG = Log.tag(ApiPlugin::class.java) const val PATH = "/api" + + /** Incoming [MessageType]s that [createMessage] can insert. Others need extra context (group/payment/etc.) we don't supply. */ + private val SUPPORTED_INCOMING_TYPES = listOf( + MessageType.NORMAL, + MessageType.IDENTITY_UPDATE, + MessageType.IDENTITY_VERIFIED, + MessageType.IDENTITY_DEFAULT, + MessageType.CONTACT_JOINED, + MessageType.EXPIRATION_UPDATE + ) } override val name: String = "APIs" @@ -93,7 +103,9 @@ class ApiPlugin : Plugin { Param("fromRecipientId", "1"), Param("toRecipientId", "1"), Param("body", "Hello"), - Param("outgoing", "false") + Param("outgoing", "false"), + Param("type", "NORMAL", placeholder = "NORMAL|IDENTITY_UPDATE|IDENTITY_VERIFIED|IDENTITY_DEFAULT|CONTACT_JOINED|EXPIRATION_UPDATE"), + Param("timestamp", "", placeholder = "blank = now (epoch millis)") ) ) ) @@ -492,28 +504,54 @@ class ApiPlugin : Plugin { ?: return PluginResult.ErrorResult(message = "Missing or invalid 'toRecipientId' parameter") val body = parameters["body"]?.firstOrNull() ?: "" val outgoing = parameters.boolOrDefault("outgoing", false) + val timestamp = parameters["timestamp"]?.firstOrNull()?.takeIf { it.isNotBlank() }?.toLongOrNull() ?: System.currentTimeMillis() + + val typeParam = parameters["type"]?.firstOrNull()?.takeIf { it.isNotBlank() } + val messageType = if (typeParam != null) { + MessageType.entries.find { it.name.equals(typeParam, ignoreCase = true) } + ?: return PluginResult.ErrorResult(message = "Unknown message type '$typeParam'. Supported: ${SUPPORTED_INCOMING_TYPES.joinToString { it.name }}") + } else { + MessageType.NORMAL + } return try { - val now = System.currentTimeMillis() if (outgoing) { + if (messageType != MessageType.NORMAL) { + return PluginResult.ErrorResult(message = "'type' is only supported for incoming messages; outgoing messages are always NORMAL text") + } val threadRecipient = Recipient.resolved(RecipientId.from(toRecipientId)) val outgoingMessage = OutgoingMessage.text( threadRecipient = threadRecipient, body = body, expiresIn = 0, - sentTimeMillis = now + sentTimeMillis = timestamp ) val result = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId) CreateMessageResponse(result.messageId).toJsonResult() } else { - val incomingMessage = IncomingMessage( - type = MessageType.NORMAL, - from = RecipientId.from(fromRecipientId), - sentTimeMillis = now, - serverTimeMillis = now, - receivedTimeMillis = now, - body = body - ) + val from = RecipientId.from(fromRecipientId) + val incomingMessage = when (messageType) { + MessageType.NORMAL -> IncomingMessage( + type = MessageType.NORMAL, + from = from, + sentTimeMillis = timestamp, + serverTimeMillis = timestamp, + receivedTimeMillis = timestamp, + body = body + ) + MessageType.IDENTITY_UPDATE -> IncomingMessage.identityUpdate(from, timestamp, null) + MessageType.IDENTITY_VERIFIED -> IncomingMessage.identityVerified(from, timestamp, null) + MessageType.IDENTITY_DEFAULT -> IncomingMessage.identityDefault(from, timestamp, null) + MessageType.CONTACT_JOINED -> IncomingMessage.contactJoined(from, timestamp) + MessageType.EXPIRATION_UPDATE -> IncomingMessage( + type = MessageType.EXPIRATION_UPDATE, + from = from, + sentTimeMillis = timestamp, + serverTimeMillis = timestamp, + receivedTimeMillis = timestamp + ) + else -> return PluginResult.ErrorResult(message = "Message type '${messageType.name}' is not supported for insertion via the API. Supported: ${SUPPORTED_INCOMING_TYPES.joinToString { it.name }}") + } val inserted = SignalDatabase.messages.insertMessageInbox(incomingMessage, candidateThreadId = threadId) if (inserted.isPresent) { CreateMessageResponse(inserted.get().messageId).toJsonResult() diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/ConversationDataTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/ConversationDataTest.kt new file mode 100644 index 0000000000..cb992dd3ff --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/ConversationDataTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation + +import android.app.Application +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import io.mockk.mockk +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class ConversationDataTest { + + private fun build( + firstUnreadId: Long = -1, + firstUnreadPosition: Int = 0, + lastScrolledPosition: Int = 0, + jumpToPosition: Int = -1, + threadSize: Int = 100, + isMessageRequestAccepted: Boolean = true + ): ConversationData { + return ConversationData( + threadRecipient = mockk(), + threadId = 1L, + firstUnreadId = firstUnreadId, + firstUnreadPosition = firstUnreadPosition, + lastScrolledPosition = lastScrolledPosition, + jumpToPosition = jumpToPosition, + threadSize = threadSize, + messageRequestData = ConversationData.MessageRequestData(isMessageRequestAccepted = isMessageRequestAccepted, isHidden = false), + showUniversalExpireTimerMessage = false, + unreadCount = if (firstUnreadPosition > 0) 1 else 0, + groupMemberAcis = emptyList() + ) + } + + @Test + fun `getStartPosition prefers a jump target over everything else`() { + val data = build(jumpToPosition = 42, firstUnreadPosition = 10, lastScrolledPosition = 5) + assertThat(data.getStartPosition()).isEqualTo(42) + } + + @Test + fun `getStartPosition uses firstUnreadPosition when accepted and there is unread`() { + val data = build(firstUnreadPosition = 7, lastScrolledPosition = 5) + assertThat(data.getStartPosition()).isEqualTo(7) + } + + @Test + fun `getStartPosition falls back to lastScrolledPosition when accepted with no unread`() { + val data = build(firstUnreadPosition = 0, lastScrolledPosition = 5) + assertThat(data.getStartPosition()).isEqualTo(5) + } + + @Test + fun `getStartPosition uses threadSize (bottom) when the message request is not accepted`() { + val data = build(firstUnreadPosition = 7, lastScrolledPosition = 5, threadSize = 99, isMessageRequestAccepted = false) + assertThat(data.getStartPosition()).isEqualTo(99) + } + + @Test + fun `shouldScrollToFirstUnread is true only for a positive position`() { + assertThat(build(firstUnreadPosition = 1).shouldScrollToFirstUnread()).isTrue() + assertThat(build(firstUnreadPosition = 0).shouldScrollToFirstUnread()).isFalse() + } + + @Test + fun `shouldJumpToMessage is true for a non-negative position`() { + assertThat(build(jumpToPosition = 0).shouldJumpToMessage()).isTrue() + assertThat(build(jumpToPosition = -1).shouldJumpToMessage()).isFalse() + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/MessageTableTest_oldestUnread.kt b/app/src/test/java/org/thoughtcrime/securesms/database/MessageTableTest_oldestUnread.kt new file mode 100644 index 0000000000..faff2ba59a --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/database/MessageTableTest_oldestUnread.kt @@ -0,0 +1,198 @@ +/* + * 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 assertk.assertions.isNotNull +import assertk.assertions.isNull +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.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.mms.IncomingMessage +import org.thoughtcrime.securesms.mms.OutgoingMessage +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.testutil.RecipientTestRule + +/** + * Verifies [MessageTable.getOldestUnread] — the unread-divider anchor — and that it stays in agreement with + * [MessageTable.getUnreadCount] and [MessageTable.getMessagePositionByDateReceivedTimestamp] across reads, edits, + * and special message types. + */ +@Suppress("ClassName") +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class MessageTableTest_oldestUnread { + + @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 returnsNullWhenThreadHasNoMessages() { + assertThat(messages.getOldestUnread(threadId)).isNull() + } + + @Test + fun returnsNullWhenAllMessagesAreRead() { + val id = insertIncoming(time = 1000) + markRead(id) + + assertThat(messages.getOldestUnread(threadId)).isNull() + } + + @Test + fun returnsTheOldestUnreadByDateReceived() { + val first = insertIncoming(time = 1000) + insertIncoming(time = 1001) + insertIncoming(time = 1002) + + val oldest = messages.getOldestUnread(threadId) + assertThat(oldest?.id).isEqualTo(first) + assertThat(oldest?.dateReceived).isEqualTo(1000) + } + + @Test + fun skipsReadMessagesAndReturnsTheNextUnread() { + val first = insertIncoming(time = 1000) + val second = insertIncoming(time = 1001) + markRead(first) + + assertThat(messages.getOldestUnread(threadId)?.id).isEqualTo(second) + } + + @Test + fun ignoresOutgoingMessages() { + insertOutgoing(time = 1000) + assertThat(messages.getOldestUnread(threadId)).isNull() + + val incoming = insertIncoming(time = 1001) + assertThat(messages.getOldestUnread(threadId)?.id).isEqualTo(incoming) + } + + @Test + fun excludesPinnedMessagesToMatchUnreadCount() { + val pinned = insertIncoming(time = 1000) + val normal = insertIncoming(time = 1001) + setPinned(pinned) + + assertThat(messages.getUnreadCount(threadId)).isEqualTo(1) + assertThat(messages.getOldestUnread(threadId)?.id).isEqualTo(normal) + } + + @Test + fun anchorsToTheLatestRevisionOfAnEditedMessage() { + insertIncoming(time = 1000) + val edit = insertEdit(originalSentTimestamp = 1000, editTime = 1001) + + assertThat(messages.getOldestUnread(threadId)?.id).isEqualTo(edit) + } + + @Test + fun scrollPositionFromAnchorMatchesItsDisplayIndex() { + insertIncoming(time = 1000) + insertIncoming(time = 1001) + insertIncoming(time = 1002) + + val oldest = messages.getOldestUnread(threadId) + assertThat(oldest).isNotNull() + + // The oldest of three displayed messages has two newer messages, so it sits at index 2. + val position = messages.getMessagePositionByDateReceivedTimestamp(threadId, oldest!!.dateReceived, false) + assertThat(position).isEqualTo(2) + } + + @Test + fun unreadCountAndAnchorStayConsistentAsMessagesAreRead() { + val a = insertIncoming(time = 1000) + val b = insertIncoming(time = 1001) + val c = insertIncoming(time = 1002) + + assertThat(messages.getUnreadCount(threadId)).isEqualTo(3) + assertThat(messages.getOldestUnread(threadId)?.id).isEqualTo(a) + + markRead(a) + assertThat(messages.getUnreadCount(threadId)).isEqualTo(2) + assertThat(messages.getOldestUnread(threadId)?.id).isEqualTo(b) + + markRead(b) + markRead(c) + assertThat(messages.getUnreadCount(threadId)).isEqualTo(0) + assertThat(messages.getOldestUnread(threadId)).isNull() + } + + // region helpers + + private fun insertIncoming(time: Long): Long { + val message = IncomingMessage( + type = MessageType.NORMAL, + from = senderId, + sentTimeMillis = time, + serverTimeMillis = time, + receivedTimeMillis = time, + body = "msg $time" + ) + return messages.insertMessageInbox(message, threadId).get().messageId + } + + private fun insertOutgoing(time: Long): Long { + val message = OutgoingMessage.text( + threadRecipient = Recipient.resolved(senderId), + body = "out $time", + expiresIn = 0, + sentTimeMillis = time + ) + return messages.insertMessageOutbox(message, threadId).messageId + } + + private fun insertEdit(originalSentTimestamp: Long, editTime: Long): Long { + val target = messages.getMessageFor(originalSentTimestamp, senderId) as MmsMessageRecord + val editMessage = IncomingMessage( + type = MessageType.NORMAL, + from = senderId, + sentTimeMillis = editTime, + serverTimeMillis = editTime, + receivedTimeMillis = editTime, + body = "edited at $editTime" + ) + return messages.insertEditMessageInbox(editMessage, target).get().messageId + } + + private fun markRead(messageId: Long) { + SignalDatabase.writableDatabase.execSQL( + "UPDATE ${MessageTable.TABLE_NAME} SET ${MessageTable.READ} = 1 WHERE ${MessageTable.ID} = ?", + arrayOf(messageId) + ) + } + + private fun setPinned(messageId: Long) { + val mask = MessageTypes.SPECIAL_TYPES_MASK + val pinned = MessageTypes.SPECIAL_TYPE_PINNED_MESSAGE + SignalDatabase.writableDatabase.execSQL( + "UPDATE ${MessageTable.TABLE_NAME} SET ${MessageTable.TYPE} = (${MessageTable.TYPE} & ~$mask) | $pinned WHERE ${MessageTable.ID} = ?", + arrayOf(messageId) + ) + } + + // endregion +}