Fix unread divider placement and scroll-to-unread on conversation open.

This commit is contained in:
Alex Hart
2026-06-09 17:21:47 -04:00
committed by Cody Henthorne
parent 337afb11db
commit 7cce504f16
10 changed files with 850 additions and 91 deletions
@@ -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<RecyclerView>(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<RecyclerView>(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<RecyclerView>(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<RecyclerView>(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<RecyclerView>(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<RecyclerView>(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<RecyclerView>(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<RecyclerView>(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 <T> 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<ConversationFragment> = Collections.synchronizedList(mutableListOf())
val allActivities: MutableList<Activity> = 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<ConversationFragment>,
private val app: Application,
private val callbacks: Application.ActivityLifecycleCallbacks,
private val allActivities: MutableList<Activity>
) : 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)
}
}
}
@@ -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 {
@@ -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
}
@@ -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) {
@@ -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)
}
}
}
}
@@ -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<ConversationElement?> = 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<ConversationElement?>) {
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<ConversationElement?>, 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()
}
}
@@ -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,
@@ -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()
@@ -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()
}
}
@@ -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
}