mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-21 01:05:11 +01:00
Fix unread divider placement and scroll-to-unread on conversation open.
This commit is contained in:
committed by
Cody Henthorne
parent
337afb11db
commit
7cce504f16
+393
@@ -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
|
||||
}
|
||||
|
||||
+11
-8
@@ -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) {
|
||||
|
||||
+22
-14
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-50
@@ -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()
|
||||
}
|
||||
}
|
||||
+198
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user