diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt index 44b2d9ad05..5862b7ad0b 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt @@ -358,5 +358,9 @@ class V2ConversationItemShapeTest { override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) = Unit override fun onViewPinnedMessage(messageId: Long) = Unit + + override fun onExpandEvents(messageId: Long) = Unit + + override fun onCollapseEvents(messageId: Long) = Unit } } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/CollapsingMessagesTests.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/CollapsingMessagesTests.kt new file mode 100644 index 0000000000..1808d395b1 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/CollapsingMessagesTests.kt @@ -0,0 +1,294 @@ +package org.thoughtcrime.securesms.database + +import androidx.core.content.contentValuesOf +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.signal.core.models.ServiceId.ACI +import org.thoughtcrime.securesms.mms.OutgoingMessage +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.testing.SignalDatabaseRule +import java.util.UUID +import kotlin.time.Duration.Companion.days + +@RunWith(AndroidJUnit4::class) +class CollapsingMessagesTests { + + private lateinit var message: MessageTable + private lateinit var thread: ThreadTable + + @Rule + @JvmField + val databaseRule = SignalDatabaseRule() + + private lateinit var alice: RecipientId + private var aliceThread: Long = 0 + + private lateinit var bob: RecipientId + + @Before + fun setUp() { + message = SignalDatabase.messages + message.deleteAllThreads() + + thread = SignalDatabase.threads + + alice = SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())) + aliceThread = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(alice)) + bob = SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())) + } + + @Test + fun givenCollapsibleMessage_whenIInsert_thenItBecomesHead() { + val messageId = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 1000L, false).messageId + + val msg = message.getMessageRecord(messageId) + assertEquals(CollapsedState.HEAD_COLLAPSED, msg.collapsedState) + assertEquals(messageId, msg.collapsedHeadId) + } + + @Test + fun givenSameCollapsibleTypes_whenIInsert_thenAllCollapseUnderHead() { + val messageId1 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 1000L, false).messageId + val messageId2 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 2000L, false).messageId + val messageId3 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 3000L, false).messageId + + val msg1 = message.getMessageRecord(messageId1) + val msg2 = message.getMessageRecord(messageId2) + val msg3 = message.getMessageRecord(messageId3) + + assertEquals(CollapsedState.HEAD_COLLAPSED, msg1.collapsedState) + assertEquals(messageId1, msg1.collapsedHeadId) + + assertEquals(CollapsedState.PENDING_COLLAPSED, msg2.collapsedState) + assertEquals(messageId1, msg2.collapsedHeadId) + + assertEquals(CollapsedState.PENDING_COLLAPSED, msg3.collapsedState) + assertEquals(messageId1, msg3.collapsedHeadId) + } + + @Test + fun givenDifferentCollapsedTypes_whenIInsert_thenNoCollapsing() { + val messageId1 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 1000L, false).messageId + val messageId2 = MmsHelper.insert(message = OutgoingMessage.identityVerifiedMessage(Recipient.resolved(alice), 2000L), threadId = aliceThread) + + val msg1 = message.getMessageRecord(messageId1) + val msg2 = message.getMessageRecord(messageId2) + + assertEquals(CollapsedState.HEAD_COLLAPSED, msg1.collapsedState) + assertEquals(messageId1, msg1.collapsedHeadId) + + assertEquals(CollapsedState.HEAD_COLLAPSED, msg2.collapsedState) + assertEquals(messageId2, msg2.collapsedHeadId) + } + + @Test + fun givenNonCollapsibleTypes_whenIInsert_thenNoCollapsing() { + val messageId = MmsHelper.insert(recipient = Recipient.resolved(alice), sentTimeMillis = 1000L) + + val msg = message.getMessageRecord(messageId) + assertEquals(CollapsedState.NONE, msg.collapsedState) + assertEquals(0, msg.collapsedHeadId) + } + + @Test + fun givenMessagesOnDifferentDays_whenIInsert_thenNoCollapsing() { + val messageId1 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 1000L, false).messageId + + message.writableDatabase.update( + MessageTable.TABLE_NAME, + contentValuesOf(MessageTable.DATE_RECEIVED to (System.currentTimeMillis() - 1.days.inWholeMilliseconds)), + "${MessageTable.ID} = ?", + arrayOf(messageId1.toString()) + ) + + val messageId2 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 2000L, false).messageId + + val msg2 = message.getMessageRecord(messageId2) + assertEquals(CollapsedState.HEAD_COLLAPSED, msg2.collapsedState) + assertEquals(messageId2, msg2.collapsedHeadId) + } + + @Test + fun givenRegularMessageBetweenCollapsed_whenIInsertCollapsed_thenNoCollapsing() { + val messageId1 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 1000L, false).messageId + val messageId2 = MmsHelper.insert(recipient = Recipient.resolved(alice), sentTimeMillis = 2000L) + val messageId3 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 3000L, false).messageId + + val msg1 = message.getMessageRecord(messageId1) + val msg2 = message.getMessageRecord(messageId2) + val msg3 = message.getMessageRecord(messageId3) + + assertEquals(CollapsedState.HEAD_COLLAPSED, msg1.collapsedState) + assertEquals(messageId1, msg1.collapsedHeadId) + + assertEquals(CollapsedState.NONE, msg2.collapsedState) + assertEquals(0, msg2.collapsedHeadId) + + assertEquals(CollapsedState.HEAD_COLLAPSED, msg3.collapsedState) + assertEquals(messageId3, msg3.collapsedHeadId) + } + + @Test + fun givenDifferentThreads_whenIInsertCollapsed_thenNoCollapsing() { + val messageId1 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 1000L, false).messageId + val messageId2 = message.insertCallLog(bob, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 2000L, false).messageId + + val msg1 = message.getMessageRecord(messageId1) + val msg2 = message.getMessageRecord(messageId2) + + assertEquals(CollapsedState.HEAD_COLLAPSED, msg1.collapsedState) + assertEquals(messageId1, msg1.collapsedHeadId) + + assertEquals(CollapsedState.HEAD_COLLAPSED, msg2.collapsedState) + assertEquals(messageId2, msg2.collapsedHeadId) + } + + @Test + fun givenCollapsedMessages_whenIDeleteFirstMessage_thenNextMessageBecomesHead() { + val messageId1 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 1000L, false).messageId + val messageId2 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 2000L, false).messageId + val messageId3 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 3000L, false).messageId + + message.deleteMessage(messageId1, aliceThread) + + val msg2 = message.getMessageRecord(messageId2) + val msg3 = message.getMessageRecord(messageId3) + + assertEquals(CollapsedState.HEAD_COLLAPSED, msg2.collapsedState) + assertEquals(messageId2, msg2.collapsedHeadId) + + assertEquals(CollapsedState.PENDING_COLLAPSED, msg3.collapsedState) + assertEquals(messageId2, msg3.collapsedHeadId) + } + + @Test + fun givenCollapsedMessages_whenIDeleteNonFirstMessage_thenFirstMessageStaysHead() { + val messageId1 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 1000L, false).messageId + val messageId2 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 2000L, false).messageId + val messageId3 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 3000L, false).messageId + + message.deleteMessage(messageId2, aliceThread) + + val msg1 = message.getMessageRecord(messageId1) + val msg3 = message.getMessageRecord(messageId3) + + assertEquals(CollapsedState.HEAD_COLLAPSED, msg1.collapsedState) + assertEquals(messageId1, msg1.collapsedHeadId) + + assertEquals(CollapsedState.PENDING_COLLAPSED, msg3.collapsedState) + assertEquals(messageId1, msg3.collapsedHeadId) + } + + @Test + fun givenTwoCollapsingTypes_whenIDeleteHeadOfFirstGroup_thenSecondGroupIsUnchanged() { + val call1 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 1000L, false) + val call2 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 2000L, false) + + val recipient = Recipient.resolved(alice) + val identity1Id = MmsHelper.insert(message = OutgoingMessage.identityVerifiedMessage(recipient, 3000L), threadId = call1.threadId) + val identity2Id = MmsHelper.insert(message = OutgoingMessage.identityVerifiedMessage(recipient, 4000L), threadId = call1.threadId) + + message.deleteMessage(call1.messageId, call1.threadId) + + val msgCall2 = message.getMessageRecord(call2.messageId) + assertEquals(CollapsedState.HEAD_COLLAPSED, msgCall2.collapsedState) + assertEquals(call2.messageId, msgCall2.collapsedHeadId) + + val msgIdentity1 = message.getMessageRecord(identity1Id) + val msgIdentity2 = message.getMessageRecord(identity2Id) + assertEquals(CollapsedState.HEAD_COLLAPSED, msgIdentity1.collapsedState) + assertEquals(identity1Id, msgIdentity1.collapsedHeadId) + assertEquals(CollapsedState.PENDING_COLLAPSED, msgIdentity2.collapsedState) + assertEquals(identity1Id, msgIdentity2.collapsedHeadId) + } + + @Test + fun givenPendingCollapsingEvents_whenIMarkSeenAtASpecificTime_thenEverythingBeforeThatTimeIsCollapsed() { + val call1 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 1000L, false) + val call2 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 2000L, false) + + message.collapsePendingCollapsibleEvents(aliceThread, System.currentTimeMillis()) + + val call3 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 3000L, false) + + val msgCall1 = message.getMessageRecord(call1.messageId) + val msgCall2 = message.getMessageRecord(call2.messageId) + val msgCall3 = message.getMessageRecord(call3.messageId) + + assertEquals(CollapsedState.HEAD_COLLAPSED, msgCall1.collapsedState) + assertEquals(CollapsedState.COLLAPSED, msgCall2.collapsedState) + assertEquals(CollapsedState.PENDING_COLLAPSED, msgCall3.collapsedState) + } + + @Test + fun givenPendingCollapsingEvents_whenIMarkAllAsSeen_thenEverythingIsCollapsed() { + val call1 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 1000L, false) + val call2 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 2000L, false) + + message.collapseAllPendingCollapsibleEvents() + + val call3 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 3000L, false) + + val msgCall1 = message.getMessageRecord(call1.messageId) + val msgCall2 = message.getMessageRecord(call2.messageId) + val msgCall3 = message.getMessageRecord(call3.messageId) + + assertEquals(CollapsedState.HEAD_COLLAPSED, msgCall1.collapsedState) + assertEquals(CollapsedState.COLLAPSED, msgCall2.collapsedState) + assertEquals(CollapsedState.PENDING_COLLAPSED, msgCall3.collapsedState) + } + + @Test + fun givenCollapsedEvents_whenITrimTheThreadByCount_thenIExpectANewHead() { + val call1 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 1000L, false) + val call2 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 2000L, false) + val call3 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 3000L, false) + val call4 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 4000L, false) + + val msgCall1 = message.getMessageRecord(call1.messageId) + val msgCall2 = message.getMessageRecord(call2.messageId) + + assertEquals(CollapsedState.HEAD_COLLAPSED, msgCall1.collapsedState) + assertEquals(CollapsedState.PENDING_COLLAPSED, msgCall2.collapsedState) + + thread.trimThread(threadId = aliceThread, syncThreadTrimDeletes = false, length = 2) + + val msgCall3 = message.getMessageRecord(call3.messageId) + val msgCall4 = message.getMessageRecord(call4.messageId) + + assertEquals(CollapsedState.HEAD_COLLAPSED, msgCall3.collapsedState) + assertEquals(CollapsedState.PENDING_COLLAPSED, msgCall4.collapsedState) + assertEquals(call3.messageId, msgCall4.collapsedHeadId) + } + + @Test + fun givenCollapsedEvents_whenITrimTheThreadByDate_thenIExpectANewHead() { + val call1 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 1000L, false) + val call2 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 2000L, false) + val trimBeforeDate = System.currentTimeMillis() + val call3 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 3000L, false) + val call4 = message.insertCallLog(alice, MessageTypes.INCOMING_AUDIO_CALL_TYPE, 4000L, false) + + message.collapsePendingCollapsibleEvents(aliceThread, System.currentTimeMillis()) + + val msgCall1 = message.getMessageRecord(call1.messageId) + val msgCall2 = message.getMessageRecord(call2.messageId) + + assertEquals(CollapsedState.HEAD_COLLAPSED, msgCall1.collapsedState) + assertEquals(CollapsedState.COLLAPSED, msgCall2.collapsedState) + + thread.trimThread(threadId = aliceThread, syncThreadTrimDeletes = false, trimBeforeDate = trimBeforeDate) + + val msgCall3 = message.getMessageRecord(call3.messageId) + val msgCall4 = message.getMessageRecord(call4.messageId) + + assertEquals(CollapsedState.HEAD_COLLAPSED, msgCall3.collapsedState) + assertEquals(CollapsedState.COLLAPSED, msgCall4.collapsedState) + assertEquals(call3.messageId, msgCall4.collapsedHeadId) + } +} diff --git a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt index 9dbcc6364b..a3658f2796 100644 --- a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt +++ b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt @@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationM import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey import org.thoughtcrime.securesms.conversation.v2.data.IncomingTextOnly import org.thoughtcrime.securesms.conversation.v2.data.OutgoingTextOnly +import org.thoughtcrime.securesms.database.CollapsedState import org.thoughtcrime.securesms.database.MessageTypes import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.StoryType @@ -122,6 +123,8 @@ class ConversationElementGenerator { false, 0, null, + CollapsedState.NONE, + 0, null, false ) diff --git a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt index 4530295248..12482957a5 100644 --- a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt +++ b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt @@ -353,5 +353,13 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra override fun onViewPinnedMessage(messageId: Long) { Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show() } + + override fun onExpandEvents(messageId: Long) { + Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show() + } + + override fun onCollapseEvents(messageId: Long) { + Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show() + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index 86fab60d23..2d31750fa1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -148,5 +148,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, void onViewPollClicked(long messageId); void onToggleVote(@NonNull PollRecord poll, @NonNull PollOption pollOption, Boolean isChecked); void onViewPinnedMessage(long messageId); + void onExpandEvents(long messageId); + void onCollapseEvents(long messageId); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java index b9ae8b1e86..08f8a6d5f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java @@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.conversation.mutiselect.Multiselect; import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectCollection; import org.thoughtcrime.securesms.conversation.v2.computed.FormattedDate; import org.thoughtcrime.securesms.database.BodyRangeUtil; +import org.thoughtcrime.securesms.database.CollapsedState; import org.thoughtcrime.securesms.database.MentionUtil; import org.thoughtcrime.securesms.database.NoSuchMessageException; import org.thoughtcrime.securesms.database.SignalDatabase; @@ -54,6 +55,7 @@ public class ConversationMessage { @Nullable private final MemberLabel memberLabel; @Nullable private final MemberLabel quoteMemberLabel; @Nullable private final Recipient deletedByRecipient; + private final int collapsedSize; private ConversationMessage(@NonNull MessageRecord messageRecord, @Nullable CharSequence body, @@ -65,7 +67,8 @@ public class ConversationMessage { @NonNull ComputedProperties computedProperties, @Nullable MemberLabel memberLabel, @Nullable MemberLabel quoteMemberLabel, - @Nullable Recipient deletedByRecipient) + @Nullable Recipient deletedByRecipient, + int collapsedSize) { this.messageRecord = messageRecord; this.hasBeenQuoted = hasBeenQuoted; @@ -77,6 +80,7 @@ public class ConversationMessage { this.memberLabel = memberLabel; this.quoteMemberLabel = quoteMemberLabel; this.deletedByRecipient = deletedByRecipient; + this.collapsedSize = collapsedSize; if (body != null) { this.body = SpannableString.valueOf(body); @@ -125,6 +129,10 @@ public class ConversationMessage { return deletedByRecipient; } + public int getCollapsedSize() { + return collapsedSize; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -282,6 +290,11 @@ public class ConversationMessage { MemberLabel quoteMemberLabel = getQuoteMemberLabel(messageRecord, threadRecipient, prefetchedLabels); Recipient deletedBy = messageRecord.getDeletedBy() != null ? Recipient.resolved(messageRecord.getDeletedBy()) : null; + int collapsedSize = 0; + if (CollapsedState.isHead(messageRecord.getCollapsedState())) { + collapsedSize = SignalDatabase.messages().getCollapsedCount(messageRecord.getId()); + } + return new ConversationMessage(messageRecord, styledAndMentionBody != null ? styledAndMentionBody : mentionsUpdate != null ? mentionsUpdate.getBody() : body, mentionsUpdate != null ? mentionsUpdate.getMentions() : null, @@ -292,7 +305,8 @@ public class ConversationMessage { new ComputedProperties(formattedDate), memberLabel, quoteMemberLabel, - deletedBy); + deletedBy, + collapsedSize); } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java index aa2b9b4d21..2cd2a4c3cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java @@ -12,11 +12,13 @@ import android.text.method.LinkMovementMethod; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; +import android.widget.Button; import android.widget.FrameLayout; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.StringRes; import androidx.appcompat.content.res.AppCompatResources; import androidx.core.content.ContextCompat; import androidx.lifecycle.LifecycleOwner; @@ -36,6 +38,8 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.conversation.colors.Colorizer; import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart; import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog; +import org.thoughtcrime.securesms.database.CollapsibleEvents; +import org.thoughtcrime.securesms.database.CollapsedState; import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil; import org.thoughtcrime.securesms.database.model.IdentityRecord; import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord; @@ -43,6 +47,7 @@ import org.thoughtcrime.securesms.database.model.LiveUpdateMessage; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.UpdateDescription; import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails; +import org.thoughtcrime.securesms.fonts.SignalSymbols; import org.thoughtcrime.securesms.groups.LiveGroup; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.LiveRecipient; @@ -93,6 +98,7 @@ public final class ConversationUpdateItem extends FrameLayout private MessageRecord messageRecord; private boolean isMessageRequestAccepted; private EventListener eventListener; + private Button collapsedButton; private final UpdateObserver updateObserver = new UpdateObserver(); @@ -124,9 +130,10 @@ public final class ConversationUpdateItem extends FrameLayout @Override public void onFinishInflate() { super.onFinishInflate(); - this.body = findViewById(R.id.conversation_update_body); - this.actionButton = findViewById(R.id.conversation_update_action); - this.background = findViewById(R.id.conversation_update_background); + this.body = findViewById(R.id.conversation_update_body); + this.actionButton = findViewById(R.id.conversation_update_action); + this.background = findViewById(R.id.conversation_update_background); + this.collapsedButton = findViewById(R.id.conversation_update_collapsed); body.setOnClickListener(v -> performClick()); body.setOnLongClickListener(v -> performLongClick()); @@ -210,6 +217,7 @@ public final class ConversationUpdateItem extends FrameLayout hasWallpaper); presentActionButton(hasWallpaper, conversationMessage.getMessageRecord().isReleaseChannelDonationRequest()); + presentCollapsedHead(conversationMessage.getMessageRecord().getCollapsedState()); updateSelectedState(); } @@ -442,7 +450,9 @@ public final class ConversationUpdateItem extends FrameLayout } private void setBodyText(@Nullable CharSequence text) { - if (text == null) { + if (CollapsedState.isCollapsed(conversationMessage.getMessageRecord().getCollapsedState()) && conversationMessage.getCollapsedSize() > 1) { + body.setVisibility(GONE); + } else if (text == null) { body.setVisibility(INVISIBLE); } else { body.setText(text); @@ -459,7 +469,10 @@ public final class ConversationUpdateItem extends FrameLayout setSelected(!Sets.intersection(multiselectParts, batchSelected).isEmpty()); - if (conversationMessage.getMessageRecord().isGroupV1MigrationEvent() && + if (CollapsedState.isCollapsed(conversationMessage.getMessageRecord().getCollapsedState()) && conversationMessage.getCollapsedSize() > 1) { + actionButton.setVisibility(GONE); + actionButton.setOnClickListener(null); + } else if (conversationMessage.getMessageRecord().isGroupV1MigrationEvent() && (!nextMessageRecord.isPresent() || !nextMessageRecord.get().isGroupV1MigrationEvent())) { actionButton.setText(R.string.ConversationUpdateItem_learn_more); @@ -807,6 +820,49 @@ public final class ConversationUpdateItem extends FrameLayout } } + private void presentCollapsedHead(CollapsedState collapsedState) { + CollapsibleEvents.CollapsibleType collapsibleType = CollapsibleEvents.getCollapsibleType(messageRecord.getType(), messageRecord.getMessageExtras()); + if (CollapsedState.isHead(collapsedState) && conversationMessage.getCollapsedSize() > 1 && collapsibleType != null) { + SpannableStringBuilder text = new SpannableStringBuilder() + .append(SignalSymbols.getSpannedString(getContext(), SignalSymbols.Weight.BOLD, getCollapsibleSymbol(collapsibleType), org.signal.core.ui.R.color.signal_colorOnSurfaceVariant)) + .append(" ") + .append(getContext().getString(getCollapsibleString(collapsibleType), conversationMessage.getCollapsedSize())) + .append(" ") + .append(SignalSymbols.getSpannedString(getContext(), SignalSymbols.Weight.BOLD, collapsedState == CollapsedState.HEAD_EXPANDED ? SignalSymbols.Glyph.CHEVRON_UP : SignalSymbols.Glyph.CHEVRON_DOWN, org.signal.core.ui.R.color.signal_colorOnSurfaceVariant)); + collapsedButton.setText(text); + collapsedButton.setOnClickListener(v -> { + if (eventListener != null) { + if (CollapsedState.isCollapsed(collapsedState)) { + eventListener.onExpandEvents(conversationMessage.getMessageRecord().getId()); + } else { + eventListener.onCollapseEvents(conversationMessage.getMessageRecord().getId()); + } + } else { + passthroughClickListener.onClick(v); + } + }); + collapsedButton.setVisibility(VISIBLE); + } else { + collapsedButton.setVisibility(GONE); + } + } + + private @StringRes int getCollapsibleString(CollapsibleEvents.CollapsibleType type) { + return switch (type) { + case CALL_EVENT -> R.string.CollapsedEvent__call_event; + case DISAPPEARING_TIMER -> R.string.CollapsedEvent__disappearing_timer; + case GROUP_UPDATE -> R.string.CollapsedEvent__group_update; + }; + } + + private SignalSymbols.Glyph getCollapsibleSymbol(CollapsibleEvents.CollapsibleType type) { + return switch (type) { + case CALL_EVENT -> SignalSymbols.Glyph.PHONE; + case DISAPPEARING_TIMER -> SignalSymbols.Glyph.TIMER; + case GROUP_UPDATE -> SignalSymbols.Glyph.GROUP; + }; + } + private static boolean isSameType(@NonNull MessageRecord current, @NonNull MessageRecord candidate) { return (current.isGroupUpdate() && candidate.isGroupUpdate()) || (current.isProfileChange() && candidate.isProfileChange()) || diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EmptyConversationAdapterListener.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EmptyConversationAdapterListener.kt index f0222bbb14..973f0034b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EmptyConversationAdapterListener.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EmptyConversationAdapterListener.kt @@ -94,4 +94,6 @@ object EmptyConversationAdapterListener : ConversationAdapter.ItemClickListener override fun onViewPollClicked(messageId: Long) = Unit override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean?) = Unit override fun onViewPinnedMessage(messageId: Long) = Unit + override fun onExpandEvents(messageId: Long) = Unit + override fun onCollapseEvents(messageId: Long) = Unit } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 8a2d483aa8..1ec64a49fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -811,6 +811,7 @@ class ConversationFragment : } override fun onDestroyView() { + viewModel.collapseAllEvents() keyboardEvents?.let { container.removeInputListener(it) container.removeKeyboardStateListener(it) @@ -3767,6 +3768,14 @@ class ConversationFragment : } } + override fun onExpandEvents(messageId: Long) { + viewModel.onExpandEvents(messageId) + } + + override fun onCollapseEvents(messageId: Long) { + viewModel.onCollapseEvents(messageId) + } + override fun onItemClick(item: MultiselectPart) { if (isActionModeStarted()) { adapter.toggleSelection(item) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt index cd39910ca6..67eca947ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt @@ -842,6 +842,18 @@ class ConversationRepository( .subscribeOn(Schedulers.io()) } + fun collapseEvents(messageId: Long) { + SignalDatabase.messages.collapseEvents(messageId) + } + + fun collapseAllEvents() { + SignalDatabase.messages.collapseAllEvents() + } + + fun expandEvents(messageId: Long) { + SignalDatabase.messages.expandEvents(messageId) + } + /** * Glide target for a contact photo which expects an error drawable, and publishes * the result to the given emitter. diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 7ccd7390b0..dc1192f7e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.asFlow import org.signal.core.models.ServiceId +import org.signal.core.util.concurrent.SignalDispatchers import org.signal.core.util.logging.Log import org.signal.core.util.orNull import org.signal.paging.ProxyPagingController @@ -433,6 +434,20 @@ class ConversationViewModel( .flowOn(Dispatchers.IO) } + fun onCollapseEvents(messageId: Long) { + viewModelScope.launch(Dispatchers.IO) { + repository.collapseEvents(messageId) + pagingController.onDataInvalidated() + } + } + + fun onExpandEvents(messageId: Long) { + viewModelScope.launch(Dispatchers.IO) { + repository.expandEvents(messageId) + pagingController.onDataInvalidated() + } + } + fun onChatBoundsChanged(bounds: Rect) { chatBounds.onNext(bounds) } @@ -785,6 +800,12 @@ class ConversationViewModel( _plaintextExportState.value = PlaintextExportState.None } + fun collapseAllEvents() { + viewModelScope.launch(SignalDispatchers.IO) { + repository.collapseAllEvents() + } + } + sealed interface PlaintextExportState { data object None : PlaintextExportState data object Preparing : PlaintextExportState diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt index 31f1225331..05dd0435cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/data/ConversationDataSource.kt @@ -97,7 +97,7 @@ class ConversationDataSource( val stopwatch = Stopwatch(title = "load($start, $length), thread $threadId", decimalPlaces = 2) var records: MutableList = ArrayList(length) - MessageTable.mmsReaderFor(SignalDatabase.messages.getConversation(threadId, start.toLong(), length.toLong())) + MessageTable.mmsReaderFor(SignalDatabase.messages.getConversation(threadId, start.toLong(), length.toLong(), filterCollapsed = true)) .use { reader -> reader.forEach { record -> if (cancellationSignal.isCanceled) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CollapsedState.kt b/app/src/main/java/org/thoughtcrime/securesms/database/CollapsedState.kt new file mode 100644 index 0000000000..febbb03b26 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CollapsedState.kt @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.database + +import org.signal.core.util.LongSerializer + +/** + * Tracks the collapsed state of a message. Non-update messages are always NONE, while + * update messages can either be the first update message of a collapsed set (HEAD_*) + * or part of the collapsed set (COLLAPSED/EXPANDED) + * + * eg in the message table: + * id | msg | collapsed_state | collapsed_head_id + * 1 | [Group Update 1] | HEAD_COLLAPSED | 1 + * 2 | [Group Update 3] | COLLAPSED | 1 + * 3 | Regular message | NONE | null + * 4 | [Group Update 4] | HEAD_COLLAPSED | 4 + * + * and when expanded, + * id | msg | collapsed_state | collapsed_head_id + * 1 | [Group Update 1] | HEAD_EXPANDED | 1 + * 2 | [Group Update 3] | EXPANDED | 1 + * 3 | Regular message | NONE | null + * 4 | [Group Update 4] | HEAD_COLLAPSED | 4 + */ +enum class CollapsedState(val id: Long) { + NONE(0), + HEAD_COLLAPSED(1), + HEAD_EXPANDED(2), + COLLAPSED(3), + EXPANDED(4), + PENDING_COLLAPSED(5); + + companion object Serializer : LongSerializer { + override fun serialize(data: CollapsedState): Long { + return data.id + } + + override fun deserialize(input: Long): CollapsedState { + return CollapsedState.entries.firstOrNull { it.id == input } ?: NONE + } + + @JvmStatic + fun isHead(state: CollapsedState): Boolean { + return state == HEAD_COLLAPSED || state == HEAD_EXPANDED + } + + @JvmStatic + fun isCollapsed(state: CollapsedState): Boolean { + return state == HEAD_COLLAPSED || state == COLLAPSED + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CollapsibleEvents.kt b/app/src/main/java/org/thoughtcrime/securesms/database/CollapsibleEvents.kt new file mode 100644 index 0000000000..51222b3080 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CollapsibleEvents.kt @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.database + +import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras + +/** + * Utility functions to track the different collapsing types and what type a message is + */ +object CollapsibleEvents { + + @JvmStatic + fun isCollapsibleType(type: Long, messageExtras: MessageExtras?): Boolean { + return getCollapsibleType(type, messageExtras) != null + } + + @JvmStatic + fun getCollapsibleType(type: Long, messageExtras: MessageExtras?): CollapsibleType? { + if (MessageTypes.isCallLog(type)) { + return CollapsibleType.CALL_EVENT + } + + if (MessageTypes.isExpirationTimerUpdate(type)) { + return CollapsibleType.DISAPPEARING_TIMER + } + + if (messageExtras?.gv2UpdateDescription != null) { + val groupChangeUpdate = messageExtras.gv2UpdateDescription.groupChangeUpdate + return if (groupChangeUpdate?.updates?.any { it.groupExpirationTimerUpdate != null } == true) { + CollapsibleType.DISAPPEARING_TIMER + } else if (groupChangeUpdate?.updates?.none { it.groupTerminateChangeUpdate != null } == true) { + CollapsibleType.GROUP_UPDATE + } else { + null + } + } + + if (MessageTypes.isProfileChange(type)) { + return CollapsibleType.GROUP_UPDATE + } + + if (MessageTypes.isIdentityUpdate(type) || MessageTypes.isIdentityVerified(type) || MessageTypes.isIdentityDefault(type)) { + return CollapsibleType.GROUP_UPDATE + } + + return null + } + + enum class CollapsibleType { + DISAPPEARING_TIMER, + GROUP_UPDATE, + CALL_EVENT + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 905f54bb11..c214bdbff1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -141,6 +141,7 @@ import org.thoughtcrime.securesms.revealable.ViewOnceExpirationInfo import org.thoughtcrime.securesms.revealable.ViewOnceUtil import org.thoughtcrime.securesms.sms.GroupV2UpdateMessageUtil import org.thoughtcrime.securesms.stories.Stories.isFeatureEnabled +import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.JsonUtils import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.MessageConstraintsUtil @@ -228,6 +229,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat const val DELETED_BY = "deleted_by" const val STORY_ARCHIVED = "story_archived" const val STARRED = "starred" + const val COLLAPSED_STATE = "collapsed_state" + const val COLLAPSED_HEAD_ID = "collapsed_head_id" const val QUOTE_NOT_PRESENT_ID = 0L const val QUOTE_TARGET_MISSING_ID = -1L @@ -301,7 +304,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat $PINNED_AT INTEGER DEFAULT 0, $DELETED_BY INTEGER DEFAULT NULL REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE, $STORY_ARCHIVED INTEGER DEFAULT 0, - $STARRED INTEGER DEFAULT 0 + $STARRED INTEGER DEFAULT 0, + $COLLAPSED_STATE INTEGER DEFAULT 0, + $COLLAPSED_HEAD_ID INTEGER DEFAULT 0 ) """ @@ -329,7 +334,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat "CREATE INDEX IF NOT EXISTS message_to_recipient_id_index ON $TABLE_NAME ($TO_RECIPIENT_ID)", "CREATE UNIQUE INDEX IF NOT EXISTS message_unique_sent_from_thread ON $TABLE_NAME ($DATE_SENT, $FROM_RECIPIENT_ID, $THREAD_ID)", // This index is created specifically for getting the number of messages in a thread and therefore needs to be kept in sync with that query - "CREATE INDEX IF NOT EXISTS $INDEX_THREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL", + "CREATE INDEX IF NOT EXISTS $INDEX_THREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL AND $COLLAPSED_STATE != ${CollapsedState.COLLAPSED.id}", // This index is created specifically for getting the number of unread messages in a thread and therefore needs to be kept in sync with that query "CREATE INDEX IF NOT EXISTS $INDEX_THREAD_UNREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $ORIGINAL_MESSAGE_ID IS NULL AND $READ = 0", "CREATE INDEX IF NOT EXISTS message_votes_unread_index ON $TABLE_NAME ($VOTES_UNREAD)", @@ -337,7 +342,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat "CREATE INDEX IF NOT EXISTS message_pinned_at_index ON $TABLE_NAME ($PINNED_AT)", "CREATE INDEX IF NOT EXISTS message_deleted_by_index ON $TABLE_NAME ($DELETED_BY)", "CREATE INDEX IF NOT EXISTS message_story_archived_index ON $TABLE_NAME ($STORY_ARCHIVED, $STORY_TYPE, $DATE_SENT) WHERE $STORY_TYPE > 0 AND $STORY_ARCHIVED > 0", - "CREATE INDEX IF NOT EXISTS message_starred_index ON $TABLE_NAME ($STARRED) WHERE $STARRED > 0" + "CREATE INDEX IF NOT EXISTS message_starred_index ON $TABLE_NAME ($STARRED) WHERE $STARRED > 0", + "CREATE INDEX IF NOT EXISTS message_collapsed_state_index ON $TABLE_NAME ($COLLAPSED_STATE)", + "CREATE INDEX IF NOT EXISTS message_collapsed_head_id_index ON $TABLE_NAME ($COLLAPSED_HEAD_ID)" ) private val MMS_PROJECTION_BASE = arrayOf( @@ -394,7 +401,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat VOTES_LAST_SEEN, PINNED_UNTIL, DELETED_BY, - STARRED + STARRED, + COLLAPSED_STATE, + COLLAPSED_HEAD_ID ) private val MMS_PROJECTION: Array = MMS_PROJECTION_BASE @@ -862,12 +871,13 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat val recipient = Recipient.resolved(recipientId) val threadIdResult = threads.getOrCreateThreadIdResultFor(recipient.id, recipient.isGroup) val threadId = threadIdResult.threadId + val dateReceived = System.currentTimeMillis() val values = contentValuesOf( FROM_RECIPIENT_ID to if (outgoing) Recipient.self().id.serialize() else recipientId.serialize(), FROM_DEVICE_ID to 1, TO_RECIPIENT_ID to if (outgoing) recipientId.serialize() else Recipient.self().id.serialize(), - DATE_RECEIVED to System.currentTimeMillis(), + DATE_RECEIVED to dateReceived, DATE_SENT to timestamp, READ to 1, TYPE to type, @@ -876,6 +886,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat val messageId = writableDatabase.insert(TABLE_NAME, null, values) + maybeCollapseMessage(db = writableDatabase, messageId = messageId, threadId = threadId, dateReceived = dateReceived, messageExtras = null, messageType = type) + threads.update(threadId, true) notifyConversationListeners(threadId) @@ -889,6 +901,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } fun updateCallLog(messageId: Long, type: Long) { + val message = getMessageRecordOrNull(messageId = messageId) writableDatabase .update(TABLE_NAME) .values( @@ -902,6 +915,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat threads.update(threadId, true) + if (message?.collapsedState == CollapsedState.NONE) { + maybeCollapseMessage(db = writableDatabase, messageId = messageId, threadId = threadId, dateReceived = message.dateReceived, messageExtras = message.messageExtras, messageType = type) + } + notifyConversationListeners(threadId) AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId)) } @@ -943,6 +960,12 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat ) val messageId = MessageId(db.insert(TABLE_NAME, null, values)) + + val isActiveCall = joinedUuids.isNotEmpty() || isIncomingGroupCallRingingOnLocalDevice + if (!isActiveCall) { + maybeCollapseMessage(db = db, messageId = messageId.id, threadId = threadId, dateReceived = timestamp, messageExtras = null, messageType = MessageTypes.GROUP_CALL_TYPE) + } + threads.incrementUnread(threadId, 1, 0) threads.update(threadId, true) @@ -1044,6 +1067,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat val query = buildTrueUpdateQuery(ID_WHERE, buildArgs(messageId), contentValues) val updated = db.update(TABLE_NAME, contentValues, query.where, query.whereArgs) > 0 + if (inCallUuids.isEmpty() && message.collapsedState == CollapsedState.NONE) { + maybeCollapseMessage(db = db, messageId = messageId, threadId = message.threadId, dateReceived = message.dateReceived, messageExtras = message.messageExtras, messageType = message.type) + } + if (updated) { notifyConversationListeners(message.threadId) } @@ -1091,6 +1118,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat val query = buildTrueUpdateQuery(ID_WHERE, buildArgs(record.id), contentValues) val updated = db.update(TABLE_NAME, contentValues, query.where, query.whereArgs) > 0 + if (inCallUuids.isEmpty() && record.collapsedState == CollapsedState.NONE) { + maybeCollapseMessage(db = db, messageId = record.id, threadId = record.threadId, dateReceived = record.dateReceived, messageExtras = record.messageExtras, messageType = record.type) + } + if (updated) { notifyConversationListeners(threadId) } @@ -1142,18 +1173,20 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat threadIdsToUpdate .filterNotNull() .forEach { threadId -> + val now = System.currentTimeMillis() val values = contentValuesOf( FROM_RECIPIENT_ID to recipient.id.serialize(), FROM_DEVICE_ID to 1, TO_RECIPIENT_ID to Recipient.self().id.serialize(), - DATE_RECEIVED to System.currentTimeMillis(), - DATE_SENT to System.currentTimeMillis(), + DATE_RECEIVED to now, + DATE_SENT to now, READ to 1, TYPE to MessageTypes.PROFILE_CHANGE_TYPE, THREAD_ID to threadId, MESSAGE_EXTRAS to extras.encode() ) - db.insert(TABLE_NAME, null, values) + val messageId = db.insert(TABLE_NAME, null, values) + maybeCollapseMessage(db = db, messageId = messageId, threadId = threadId, dateReceived = now, messageExtras = extras, messageType = MessageTypes.PROFILE_CHANGE_TYPE) notifyConversationListeners(threadId) TrimThreadJob.enqueueAsync(threadId) } @@ -1173,18 +1206,19 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat val threadId: Long? = SignalDatabase.threads.getThreadIdFor(recipient.id) if (threadId != null) { + val now = System.currentTimeMillis() val extras = MessageExtras( profileChangeDetails = ProfileChangeDetails(learnedProfileName = ProfileChangeDetails.LearnedProfileName(e164 = e164, username = username)) ) - writableDatabase + val messageId = writableDatabase .insertInto(TABLE_NAME) .values( FROM_RECIPIENT_ID to recipient.id.serialize(), FROM_DEVICE_ID to 1, TO_RECIPIENT_ID to Recipient.self().id.serialize(), - DATE_RECEIVED to System.currentTimeMillis(), - DATE_SENT to System.currentTimeMillis(), + DATE_RECEIVED to now, + DATE_SENT to now, READ to 1, TYPE to MessageTypes.PROFILE_CHANGE_TYPE, THREAD_ID to threadId, @@ -1192,6 +1226,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat ) .run() + maybeCollapseMessage(db = writableDatabase, messageId = messageId, threadId = threadId, dateReceived = now, messageExtras = extras, messageType = MessageTypes.PROFILE_CHANGE_TYPE) + notifyConversationListeners(threadId) } } @@ -1946,7 +1982,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat return readableDatabase .select("COUNT(*)") .from("$TABLE_NAME INDEXED BY $INDEX_THREAD_COUNT") - .where("$THREAD_ID = $threadId AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL") + .where("$THREAD_ID = $threadId AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL AND $COLLAPSED_STATE != ${CollapsedState.COLLAPSED.id}") .run() .readToSingleInt() } @@ -3002,6 +3038,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat return Optional.empty() } + maybeCollapseMessage(db = writableDatabase, messageId = messageId, threadId = threadId, dateReceived = retrieved.receivedTimeMillis, messageExtras = retrieved.messageExtras, messageType = type) + if (editedMessage != null) { writableDatabase.update(TABLE_NAME) .values(QUOTE_ID to retrieved.sentTimeMillis) @@ -3512,10 +3550,14 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat movePinnedDetailsToNewMessage(newMessageId = messageId, previousId = message.messageToEdit) } - threads.updateLastSeenAndMarkSentAndLastScrolledSilenty(threadId, dateReceived) + val hasCollapsed = maybeCollapseMessage(db = writableDatabase, messageId = messageId, threadId = threadId, dateReceived = dateReceived, messageExtras = message.messageExtras, messageType = type) + + if (!message.isIdentityVerified && !message.isIdentityDefault) { + threads.updateLastSeenAndMarkSentAndLastScrolledSilenty(threadId, dateReceived) + } if (!message.storyType.isStory) { - if (message.outgoingQuote == null && editedMessage == null) { + if (message.outgoingQuote == null && editedMessage == null && !hasCollapsed) { AppDependencies.databaseObserver.notifyMessageInsertObservers(threadId, MessageId(messageId)) } else { AppDependencies.databaseObserver.notifyConversationListeners(threadId) @@ -3543,6 +3585,54 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat ) } + /** + * Conditionally collapses a new message if it is the same [CollapsibleEvents.CollapsibleType] as the previous message on the same day. + * If it is not, but the new message is a collapsing type, mark it as a new collapsed head. Returns whether a message was collapsed. + */ + fun maybeCollapseMessage(db: SQLiteDatabase, messageId: Long, threadId: Long, dateReceived: Long, messageExtras: MessageExtras?, messageType: Long): Boolean { + if (!RemoteConfig.collapseEvents || !CollapsibleEvents.isCollapsibleType(messageType, messageExtras)) { + return false + } + + val currentType = CollapsibleEvents.getCollapsibleType(messageType, messageExtras)!! + val previousMessage = getMessageDirectlyBefore(messageId, threadId, dateReceived) + val previousType = previousMessage?.let { CollapsibleEvents.getCollapsibleType(previousMessage.type, previousMessage.messageExtras) } + + return if (previousType == currentType) { + db.update(TABLE_NAME) + .values( + COLLAPSED_STATE to CollapsedState.PENDING_COLLAPSED.id, + COLLAPSED_HEAD_ID to previousMessage.collapsedHeadId + ) + .where("$ID = ?", messageId) + .run() + true + } else { + db.update(TABLE_NAME) + .values( + COLLAPSED_STATE to CollapsedState.HEAD_COLLAPSED.id, + COLLAPSED_HEAD_ID to messageId + ) + .where("$ID = ?", messageId) + .run() + false + } + } + + // TODO(michelle): Maybe reduce to the fields you actually need instead of everything + private fun getMessageDirectlyBefore(messageId: Long, threadId: Long, dateReceived: Long): MessageRecord? { + val message = readableDatabase + .select(*MMS_PROJECTION) + .from(TABLE_NAME) + .where("$ID < ? AND $THREAD_ID = ?", messageId, threadId) + .orderBy("$DATE_RECEIVED DESC") + .limit(1) + .run() + .readToSingleObject { MmsReader(it).getCurrent() } + + return message?.takeIf { DateUtils.isSameDay(message.dateReceived, dateReceived) } + } + private fun hasAudioAttachment(attachments: List): Boolean { return attachments.any { MediaUtil.isAudio(it) } } @@ -3761,6 +3851,44 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat return deleteMessage(messageId, threadId) } + /** + * When an update gets deleted, check if it was the head of a set of collapsed events. If so, + * set the next element to be the new head, and change all elements' head reference to the new one. + */ + private fun reassignCollapsedHead(messageId: Long) { + val collapsedState = readableDatabase + .select(COLLAPSED_STATE) + .from(TABLE_NAME) + .where("$ID = ?", messageId) + .run() + .readToSingleObject { cursor -> CollapsedState.deserialize(cursor.requireLong(COLLAPSED_STATE)) } ?: CollapsedState.NONE + + if (CollapsedState.isHead(collapsedState)) { + val nextHead = readableDatabase + .select(ID) + .from(TABLE_NAME) + .where("$ID > ? AND $COLLAPSED_HEAD_ID = ?", messageId, messageId) + .orderBy("$DATE_RECEIVED ASC") + .limit(1) + .run() + .readToSingleLongOrNull() + + if (nextHead != null) { + writableDatabase.withinTransaction { db -> + db.update(TABLE_NAME) + .values(COLLAPSED_STATE to collapsedState.id) + .where("$ID = ?", nextHead) + .run() + + db.update(TABLE_NAME) + .values(COLLAPSED_HEAD_ID to nextHead) + .where("$COLLAPSED_HEAD_ID = ?", messageId) + .run() + } + } + } + } + @VisibleForTesting fun deleteMessage(messageId: Long, threadId: Long, notify: Boolean = true, updateThread: Boolean = true): Boolean { Log.d(TAG, "deleteMessage($messageId)") @@ -3770,6 +3898,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat mentions.deleteMentionsForMessage(messageId) disassociatePollFromPollTerminate(polls.getPollTerminateMessageId(messageId)) disassociatePinnedMessage(messageId) + reassignCollapsedHead(messageId) writableDatabase .delete(TABLE_NAME) @@ -4531,6 +4660,65 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat .run() } + fun collapsePendingCollapsibleEvents(threadId: Long, sinceTimestamp: Long) { + val where = if (sinceTimestamp > -1) { + "$THREAD_ID = ? AND $COLLAPSED_STATE = ? AND $DATE_RECEIVED <= $sinceTimestamp" + } else { + "$THREAD_ID = ? AND $COLLAPSED_STATE = ?" + } + + writableDatabase + .update(TABLE_NAME) + .values(COLLAPSED_STATE to CollapsedState.COLLAPSED.id) + .where(where, threadId, CollapsedState.PENDING_COLLAPSED.id) + .run() + } + + fun collapseAllPendingCollapsibleEvents() { + writableDatabase + .update(TABLE_NAME) + .values(COLLAPSED_STATE to CollapsedState.COLLAPSED.id) + .where("$COLLAPSED_STATE = ?", CollapsedState.PENDING_COLLAPSED.id) + .run() + } + + /** + * If the oldest message in a thread is [CollapsedState.COLLAPSED], [CollapsedState.PENDING_COLLAPSED], or [CollapsedState.EXPANDED], + * that means its head reference has been deleted in a previous operation. In that case, we promote the + * oldest message to be the HEAD and update any existing events that previously had the deleted head as a reference. + */ + fun fixPotentialDanglingCollapsibleEvent(threadId: Long) { + writableDatabase.withinTransaction { db -> + db.select(ID, COLLAPSED_STATE, COLLAPSED_HEAD_ID) + .from(TABLE_NAME) + .where("$THREAD_ID = ?", threadId) + .orderBy("$DATE_RECEIVED ASC") + .limit(1) + .run() + .use { cursor -> + if (cursor.moveToFirst()) { + val id = cursor.requireLong(ID) + val collapsedState = CollapsedState.deserialize(cursor.requireLong(COLLAPSED_STATE)) + val deletedHeadId = cursor.requireLong(COLLAPSED_HEAD_ID) + if (collapsedState == CollapsedState.COLLAPSED || collapsedState == CollapsedState.EXPANDED || collapsedState == CollapsedState.PENDING_COLLAPSED) { + val newState = if (collapsedState == CollapsedState.EXPANDED) CollapsedState.HEAD_EXPANDED.id else CollapsedState.HEAD_COLLAPSED.id + val updated = db.update(TABLE_NAME) + .values(COLLAPSED_STATE to newState) + .where("$ID = ?", id) + .run() + + db.update(TABLE_NAME) + .values(COLLAPSED_HEAD_ID to id) + .where("$COLLAPSED_HEAD_ID = ?", deletedHeadId) + .run() + + Log.i(TAG, "Found dangling collapsed set, reset head: $updated") + } + } + } + } + } + fun setNotifiedTimestamp(timestamp: Long, ids: List) { if (ids.isEmpty()) { return @@ -4781,7 +4969,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat return readableDatabase .select("COUNT(*)") .from(TABLE_NAME) - .where("$THREAD_ID = $threadId AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL AND $DATE_RECEIVED > $targetMessageDateReceived") + .where("$THREAD_ID = $threadId AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL AND $DATE_RECEIVED > $targetMessageDateReceived AND $COLLAPSED_STATE != ${CollapsedState.COLLAPSED.id}") .run() .readToSingleInt() } @@ -4799,7 +4987,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat return readableDatabase .select("COUNT(*)") .from(TABLE_NAME) - .where("$THREAD_ID = $threadId AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL AND $DATE_RECEIVED > $receivedTimestamp") + .where("$THREAD_ID = $threadId AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL AND $DATE_RECEIVED > $receivedTimestamp AND $COLLAPSED_STATE != ${CollapsedState.COLLAPSED.id}") .run() .readToSingleInt(-1) } @@ -4843,9 +5031,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat */ fun getMessagePositionInConversation(threadId: Long, groupStoryId: Long, receivedTimestamp: Long): Int { val selection = if (groupStoryId > 0) { - "$THREAD_ID = $threadId AND $DATE_RECEIVED < $receivedTimestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID = $groupStoryId AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL" + "$THREAD_ID = $threadId AND $DATE_RECEIVED < $receivedTimestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID = $groupStoryId AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL AND $COLLAPSED_STATE != ${CollapsedState.COLLAPSED.id}" } else { - "$THREAD_ID = $threadId AND $DATE_RECEIVED > $receivedTimestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL" + "$THREAD_ID = $threadId AND $DATE_RECEIVED > $receivedTimestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL AND $COLLAPSED_STATE != ${CollapsedState.COLLAPSED.id}" } return readableDatabase @@ -4871,7 +5059,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat return readableDatabase .select("COUNT(*)") .from(TABLE_NAME) - .where("$DATE_RECEIVED < $date AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL") + .where("$DATE_RECEIVED < $date AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL AND $COLLAPSED_STATE != ${CollapsedState.COLLAPSED.id}") .run() .readToSingleInt() } @@ -4889,7 +5077,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat return readableDatabase .select("COUNT(*)") .from(TABLE_NAME) - .where("$THREAD_ID = $threadId AND $DATE_RECEIVED ${if (inclusive) ">=" else ">"} $timestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL") + .where("$THREAD_ID = $threadId AND $DATE_RECEIVED ${if (inclusive) ">=" else ">"} $timestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL AND $COLLAPSED_STATE != ${CollapsedState.COLLAPSED.id}") .run() .readToSingleInt() } @@ -5380,15 +5568,23 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat /** * A cursor containing all of the messages in a given thread, in the proper order, respecting offset/limit. - * This does *not* have attachments in it. + * This does *not* have attachments in it. Use [filterCollapsed] to exclude collapsed events. */ - fun getConversation(threadId: Long, offset: Long = 0, limit: Long = 0, dateReceiveOrderBy: String = "DESC"): Cursor { + fun getConversation(threadId: Long, offset: Long = 0, limit: Long = 0, dateReceiveOrderBy: String = "DESC", filterCollapsed: Boolean = false): Cursor { val limitStr: String = if (limit > 0 || offset > 0) "$offset, $limit" else "" + var query = "$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ? AND $LATEST_REVISION_ID IS NULL" + val args = mutableListOf(threadId.toString(), 0.toString(), 0.toString(), (-1).toString()) + + if (filterCollapsed) { + query += " AND $COLLAPSED_STATE != ?" + args.add(CollapsedState.COLLAPSED.id.toString()) + } + return readableDatabase .select(*MMS_PROJECTION) .from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID") - .where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ? AND $LATEST_REVISION_ID IS NULL", threadId, 0, 0, -1) + .where(query, args.toTypedArray()) .orderBy("$DATE_RECEIVED $dateReceiveOrderBy") .limit(limitStr) .run() @@ -5979,6 +6175,70 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } } + /** + * Returns the number of updates that belong in a collapsed update set where [messageId] is the head (first update) in that set + * If an event is [PENDING_COLLAPSED], we do not want to consider it part of the count until it is seen. + */ + fun getCollapsedCount(messageId: Long): Int { + return readableDatabase + .count() + .from(TABLE_NAME) + .where("$COLLAPSED_HEAD_ID = ? AND $COLLAPSED_STATE != ?", messageId, CollapsedState.PENDING_COLLAPSED.id) + .run() + .readToSingleInt() + } + + /** + * Given a collapsed head, set it and all of the updates in that set, to expanded + */ + fun expandEvents(messageId: Long) { + writableDatabase.withinTransaction { db -> + db.update(TABLE_NAME) + .values(COLLAPSED_STATE to CollapsedState.HEAD_EXPANDED.id) + .where("$ID = ?", messageId) + .run() + + db.update(TABLE_NAME) + .values(COLLAPSED_STATE to CollapsedState.EXPANDED.id) + .where("$COLLAPSED_HEAD_ID = ? AND $COLLAPSED_STATE = ?", messageId, CollapsedState.COLLAPSED.id) + .run() + } + } + + /** + * Given an expanded head, set it and all of the updates in that set, to collapsed + */ + fun collapseEvents(messageId: Long) { + writableDatabase.withinTransaction { db -> + db.update(TABLE_NAME) + .values(COLLAPSED_STATE to CollapsedState.HEAD_COLLAPSED.id) + .where("$ID = ?", messageId) + .run() + + db.update(TABLE_NAME) + .values(COLLAPSED_STATE to CollapsedState.COLLAPSED.id) + .where("$COLLAPSED_HEAD_ID = ? AND $COLLAPSED_STATE = ?", messageId, CollapsedState.EXPANDED.id) + .run() + } + } + + /** + * Collapses any expanded events in a thread + */ + fun collapseAllEvents() { + writableDatabase.withinTransaction { db -> + db.update(TABLE_NAME) + .values(COLLAPSED_STATE to CollapsedState.HEAD_COLLAPSED.id) + .where("$COLLAPSED_STATE = ?", CollapsedState.HEAD_EXPANDED.id) + .run() + + db.update(TABLE_NAME) + .values(COLLAPSED_STATE to CollapsedState.COLLAPSED.id) + .where("$COLLAPSED_STATE = ?", CollapsedState.EXPANDED.id) + .run() + } + } + /** * Remove duplicate messages that were imported from a backup without the same sql constraint on the this table. * @@ -6451,6 +6711,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat val pinnedUntil = cursor.requireLong(PINNED_UNTIL) val deletedBy = cursor.requireLongOrNull(DELETED_BY)?.let { RecipientId.from(it) } val isStarred = cursor.requireBoolean(STARRED) + val collapsedState = CollapsedState.deserialize(cursor.requireLong(COLLAPSED_STATE)) + val collapsedHeadId = cursor.requireLong(COLLAPSED_HEAD_ID) val messageExtraBytes = cursor.requireBlob(MESSAGE_EXTRAS) val messageExtras = messageExtraBytes?.let { MessageExtras.ADAPTER.decode(it) } @@ -6546,6 +6808,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat isRead, pinnedUntil, deletedBy, + collapsedState, + collapsedHeadId, messageExtras, isStarred ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt index d886c96c7c..44192b5b63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt @@ -450,6 +450,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa if (deletes > 0) { Log.i(TAG, "Trimming deleted $deletes messages thread: $threadId") + messages.fixPotentialDanglingCollapsibleEvent(threadId) setLastScrolled(threadId, 0) val threadDeleted = update(threadId = threadId, unarchive = false, syncThreadDelete = syncThreadTrimDeletes) notifyConversationListeners(threadId) @@ -499,6 +500,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa messages.setAllReactionsSeen() messages.setAllVotesSeen() + messages.collapseAllPendingCollapsibleEvents() notifyConversationListListeners() return messageRecords @@ -561,6 +563,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa messages.setReactionsSeen(threadId, sinceTimestamp) messages.setVoteSeen(threadId, sinceTimestamp) + messages.collapsePendingCollapsibleEvents(threadId, sinceTimestamp) val unreadCount = messages.getUnreadCount(threadId) val unreadMentionsCount = messages.getUnreadMentionCount(threadId) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 49b7ba804d..fa27057820 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -165,6 +165,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V309_GroupTerminate import org.thoughtcrime.securesms.database.helpers.migration.V310_AddStarredColumn import org.thoughtcrime.securesms.database.helpers.migration.V311_AddAttachmentMediaOverviewSizeIndex import org.thoughtcrime.securesms.database.helpers.migration.V312_RefactorNameCollisionTables +import org.thoughtcrime.securesms.database.helpers.migration.V313_AddCollapsingUpdateColumns import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase /** @@ -337,10 +338,11 @@ object SignalDatabaseMigrations { 309 to V309_GroupTerminatedColumnMigration, 310 to V310_AddStarredColumn, 311 to V311_AddAttachmentMediaOverviewSizeIndex, - 312 to V312_RefactorNameCollisionTables + 312 to V312_RefactorNameCollisionTables, + 313 to V313_AddCollapsingUpdateColumns ) - const val DATABASE_VERSION = 312 + const val DATABASE_VERSION = 313 @JvmStatic fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V313_AddCollapsingUpdateColumns.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V313_AddCollapsingUpdateColumns.kt new file mode 100644 index 0000000000..575ad17028 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V313_AddCollapsingUpdateColumns.kt @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import org.thoughtcrime.securesms.database.SQLiteDatabase + +/** + * Adds the columns and indexes necessary for collapsing updates + */ +@Suppress("ClassName") +object V313_AddCollapsingUpdateColumns : SignalDatabaseMigration { + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL("ALTER TABLE message ADD COLUMN collapsed_state INTEGER DEFAULT 0") + db.execSQL("ALTER TABLE message ADD COLUMN collapsed_head_id INTEGER DEFAULT 0") + + db.execSQL("CREATE INDEX IF NOT EXISTS message_collapsed_state_index ON message (collapsed_state)") + db.execSQL("CREATE INDEX message_collapsed_head_id_index ON message (collapsed_head_id)") + + // Adjust existing index to disregard collapsed updates from the thread count + db.execSQL("DROP INDEX IF EXISTS message_thread_count_index") + db.execSQL("CREATE INDEX message_thread_count_index ON message (thread_id) WHERE story_type = 0 AND parent_story_id <= 0 AND scheduled_date = -1 AND latest_revision_id IS NULL AND collapsed_state != 3") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java index 297a5d701e..4d5c8bac18 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java @@ -7,6 +7,7 @@ import androidx.annotation.Nullable; import androidx.annotation.StringRes; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.CollapsedState; import org.thoughtcrime.securesms.fonts.SignalSymbols.Glyph; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.Recipient; @@ -60,6 +61,8 @@ public class InMemoryMessageRecord extends MessageRecord { 0, 0, null, + CollapsedState.NONE, + 0, null, false); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index cd6bc7bfbd..df520c385e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -43,6 +43,7 @@ import org.signal.archive.proto.GroupCreationUpdate; import org.thoughtcrime.securesms.components.emoji.EmojiProvider; import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser; import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView; +import org.thoughtcrime.securesms.database.CollapsedState; import org.thoughtcrime.securesms.database.MessageTypes; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.NetworkFailure; @@ -115,6 +116,8 @@ public abstract class MessageRecord extends DisplayRecord { private final int revisionNumber; private final long pinnedUntil; private final RecipientId deletedBy; + private final CollapsedState collapsedState; + private final long collapsedHeadId; private final MessageExtras messageExtras; private final boolean starred; @@ -139,6 +142,8 @@ public abstract class MessageRecord extends DisplayRecord { int revisionNumber, long pinnedUntil, @Nullable RecipientId deletedBy, + CollapsedState collapsedState, + long collapsedHeadId, @Nullable MessageExtras messageExtras, boolean starred) { @@ -162,6 +167,8 @@ public abstract class MessageRecord extends DisplayRecord { this.revisionNumber = revisionNumber; this.pinnedUntil = pinnedUntil; this.deletedBy = deletedBy; + this.collapsedState = collapsedState; + this.collapsedHeadId = collapsedHeadId; this.messageExtras = messageExtras; this.starred = starred; } @@ -818,6 +825,14 @@ public abstract class MessageRecord extends DisplayRecord { messageExtras.adminDeleteStatus.status == AdminDeleteStatus.Status.FAILED; } + public CollapsedState getCollapsedState() { + return collapsedState; + } + + public long getCollapsedHeadId() { + return collapsedHeadId; + } + public boolean isInMemoryMessageRecord() { return false; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java index 37972e42fa..aba3f0f158 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java @@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.attachments.AttachmentId; import org.thoughtcrime.securesms.attachments.DatabaseAttachment; import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.database.CallTable; +import org.thoughtcrime.securesms.database.CollapsedState; import org.thoughtcrime.securesms.database.MessageTable; import org.thoughtcrime.securesms.database.MessageTable.Status; import org.thoughtcrime.securesms.database.MessageTypes; @@ -121,13 +122,15 @@ public class MmsMessageRecord extends MessageRecord { boolean isRead, long pinnedUntil, @Nullable RecipientId deletedBy, + @NonNull CollapsedState collapsedState, + long collapsedHeadId, @Nullable MessageExtras messageExtras, boolean starred) { super(id, body, fromRecipient, fromDeviceId, toRecipient, dateSent, dateReceived, dateServer, threadId, Status.STATUS_NONE, hasDeliveryReceipt, mailbox, mismatches, failures, subscriptionId, expiresIn, expireStarted, expireTimerVersion, hasReadReceipt, - unidentified, reactions, notifiedTimestamp, viewed, receiptTimestamp, originalMessageId, revisionNumber, pinnedUntil, deletedBy, messageExtras, starred); + unidentified, reactions, notifiedTimestamp, viewed, receiptTimestamp, originalMessageId, revisionNumber, pinnedUntil, deletedBy, collapsedState, collapsedHeadId, messageExtras, starred); this.slideDeck = slideDeck; this.quote = quote; @@ -341,7 +344,7 @@ public class MmsMessageRecord extends MessageRecord { incomingType, getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras(), isStarred()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getCollapsedState(), getCollapsedHeadId(), getMessageExtras(), isStarred()); } public @NonNull MmsMessageRecord withReactions(@NonNull List reactions) { @@ -349,7 +352,7 @@ public class MmsMessageRecord extends MessageRecord { getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras(), isStarred()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getCollapsedState(), getCollapsedHeadId(), getMessageExtras(), isStarred()); } public @NonNull MmsMessageRecord withoutQuote() { @@ -357,7 +360,7 @@ public class MmsMessageRecord extends MessageRecord { getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), hasReadReceipt(), null, getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras(), isStarred()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getCollapsedState(), getCollapsedHeadId(), getMessageExtras(), isStarred()); } public @NonNull MmsMessageRecord withAttachments(@NonNull List attachments) { @@ -379,7 +382,7 @@ public class MmsMessageRecord extends MessageRecord { getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), hasReadReceipt(), quote, contacts, linkPreviews, isUnidentified(), getReactions(), mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras(), isStarred()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getCollapsedState(), getCollapsedHeadId(), getMessageExtras(), isStarred()); } public @NonNull MmsMessageRecord withPayment(@NonNull Payment payment) { @@ -387,7 +390,7 @@ public class MmsMessageRecord extends MessageRecord { getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras(), isStarred()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getCollapsedState(), getCollapsedHeadId(), getMessageExtras(), isStarred()); } @@ -396,7 +399,7 @@ public class MmsMessageRecord extends MessageRecord { getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getPoll(), getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras(), isStarred()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getCollapsedState(), getCollapsedHeadId(), getMessageExtras(), isStarred()); } public @NonNull MmsMessageRecord withPoll(@Nullable PollRecord poll) { @@ -404,7 +407,7 @@ public class MmsMessageRecord extends MessageRecord { getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), poll, getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getMessageExtras(), isStarred()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getDeletedBy(), getCollapsedState(), getCollapsedHeadId(), getMessageExtras(), isStarred()); } private static @NonNull List updateContacts(@NonNull List contacts, @NonNull Map attachmentIdMap) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt index a238f44563..5173961fea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt @@ -417,6 +417,14 @@ class MessageDetailsFragment : Fragment(), MessageDetailsAdapter.Callbacks { Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show() } + override fun onExpandEvents(messageId: Long) { + Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show() + } + + override fun onCollapseEvents(messageId: Long) { + Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show() + } + class Dialog : WrapperDialogFragment() { override fun getWrappedFragment(): Fragment { return MessageDetailsFragment().apply { diff --git a/app/src/main/java/org/thoughtcrime/securesms/starred/StarredMessagesActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/starred/StarredMessagesActivity.kt index fed29bcfda..53db9f8748 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/starred/StarredMessagesActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/starred/StarredMessagesActivity.kt @@ -421,4 +421,6 @@ private class StarredMessageClickListener( override fun onUpdateSignalClicked() = Unit override fun onViewPollClicked(messageId: Long) = Unit override fun onViewPinnedMessage(messageId: Long) = Unit + override fun onCollapseEvents(messageId: Long) = Unit + override fun onExpandEvents(messageId: Long) = Unit } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index ad0807bd7d..51614847d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -1315,5 +1315,16 @@ object RemoteConfig { hotSwappable = true ) + /** + * Whether to collapse update events + */ + @JvmStatic + @get:JvmName("collapseEvents") + val collapseEvents: Boolean by remoteBoolean( + key = "android.collapseEvents", + defaultValue = false, + hotSwappable = true + ) + // endregion } diff --git a/app/src/main/res/drawable/symbol_chevron_down_16.xml b/app/src/main/res/drawable/symbol_chevron_down_16.xml new file mode 100644 index 0000000000..56b4393ada --- /dev/null +++ b/app/src/main/res/drawable/symbol_chevron_down_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/symbol_chevron_up_16.xml b/app/src/main/res/drawable/symbol_chevron_up_16.xml new file mode 100644 index 0000000000..145711af0b --- /dev/null +++ b/app/src/main/res/drawable/symbol_chevron_up_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/symbol_timer_compact_16.xml b/app/src/main/res/drawable/symbol_timer_compact_16.xml new file mode 100644 index 0000000000..804eea6e73 --- /dev/null +++ b/app/src/main/res/drawable/symbol_timer_compact_16.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout/conversation_item_update.xml b/app/src/main/res/layout/conversation_item_update.xml index 794083634e..d39b501ebb 100644 --- a/app/src/main/res/layout/conversation_item_update.xml +++ b/app/src/main/res/layout/conversation_item_update.xml @@ -24,6 +24,20 @@ android:paddingStart="16dp" android:paddingEnd="16dp"> + + Options Update + + %1$d group updates + + %1$d disappearing message timer changes + + %1$d call events Play … Pause diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt b/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt index 6b5733f37e..ef22ec165a 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt @@ -8,6 +8,7 @@ import org.signal.spinner.Spinner import org.signal.spinner.Spinner.DatabaseConfig import org.signal.spinner.SpinnerLogger import org.thoughtcrime.securesms.database.AttachmentTransformer +import org.thoughtcrime.securesms.database.CollapsedStateTransformer import org.thoughtcrime.securesms.database.DatabaseMonitor import org.thoughtcrime.securesms.database.GV2Transformer import org.thoughtcrime.securesms.database.GV2UpdateTransformer @@ -74,7 +75,8 @@ class SpinnerApplicationContext : ApplicationContext() { RecipientTransformer, AttachmentTransformer, PollTransformer, - IdPopupTransformer + IdPopupTransformer, + CollapsedStateTransformer ) ), "jobmanager" to DatabaseConfig(db = { JobDatabase.getInstance(this).sqlCipherDatabase }, columnTransformers = listOf(TimestampTransformer)), diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/database/CollapsedStateTransformer.kt b/app/src/spinner/java/org/thoughtcrime/securesms/database/CollapsedStateTransformer.kt new file mode 100644 index 0000000000..f4e408b496 --- /dev/null +++ b/app/src/spinner/java/org/thoughtcrime/securesms/database/CollapsedStateTransformer.kt @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.database + +import android.database.Cursor +import org.signal.core.util.requireInt +import org.signal.core.util.requireLong +import org.signal.spinner.ColumnTransformer + +/** + * Transforms enum integers for [CollapsedState] into a human readable state + */ +object CollapsedStateTransformer : ColumnTransformer { + override fun matches(tableName: String?, columnName: String): Boolean { + return columnName == MessageTable.COLLAPSED_STATE && (tableName == null || tableName == MessageTable.TABLE_NAME) + } + + override fun transform(tableName: String?, columnName: String, cursor: Cursor): String? { + val state = CollapsedState.deserialize(cursor.requireLong(MessageTable.COLLAPSED_STATE)) + return "${cursor.requireInt(MessageTable.COLLAPSED_STATE)}

$state" + } +} diff --git a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt index 7c38132ad4..69c88a7947 100644 --- a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt +++ b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt @@ -210,6 +210,8 @@ object FakeMessageRecords { false, 0, deletedBy, + CollapsedState.NONE, + 0, null, false )