From af9465fefedc4bab39fa6efd70c1967ade8bfbf4 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 10 May 2022 11:01:51 -0300 Subject: [PATCH] Add sent story syncing. --- .../database/StorySendsDatabaseTest.kt | 321 ++++++++++++++++-- .../contacts/ContactsCursorLoader.java | 31 -- .../contacts/ContactsCursorRows.java | 16 - .../database/DistributionListDatabase.kt | 98 +++++- .../securesms/database/MessageDatabase.java | 1 + .../securesms/database/MmsDatabase.java | 11 +- .../database/SentStorySyncManifest.kt | 85 +++++ .../securesms/database/SmsDatabase.java | 5 + .../securesms/database/StorySendsDatabase.kt | 219 +++++++++++- .../securesms/database/ThreadDatabase.java | 10 +- .../helpers/SignalDatabaseMigrations.kt | 36 +- .../model/DistributionListPartialRecord.kt | 3 +- .../database/model/DistributionListRecord.kt | 3 +- .../securesms/jobs/JobManagerFactories.java | 1 + .../jobs/MultiDeviceStorySendSyncJob.kt | 110 ++++++ .../jobs/PushDistributionListSendJob.java | 9 +- .../securesms/jobs/RemoteDeleteSendJob.java | 39 ++- .../securesms/messages/GroupSendUtil.java | 31 +- .../messages/MessageContentProcessor.java | 239 ++++++++++--- .../securesms/recipients/LiveRecipient.java | 2 +- .../securesms/sms/MessageSender.java | 14 +- .../settings/story/PrivateStoryItem.kt | 2 +- .../securesms/util/GroupUtil.java | 5 +- .../api/SignalServiceMessageSender.java | 127 +++++-- .../api/messages/SignalServiceContent.java | 36 +- .../SignalServiceStoryMessageRecipient.java | 33 ++ .../multidevice/SentTranscriptMessage.java | 38 ++- .../api/push/DistributionId.java | 14 + .../src/main/proto/SignalService.proto | 8 + 29 files changed, 1311 insertions(+), 236 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/SentStorySyncManifest.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceStorySendSyncJob.kt create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceStoryMessageRecipient.java diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/StorySendsDatabaseTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/StorySendsDatabaseTest.kt index 3a7ffa77e5..14348f1617 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/StorySendsDatabaseTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/StorySendsDatabaseTest.kt @@ -1,21 +1,41 @@ package org.thoughtcrime.securesms.database import androidx.test.ext.junit.runners.AndroidJUnit4 +import junit.framework.TestCase.assertNull import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.containsInAnyOrder import org.hamcrest.Matchers.hasSize import org.hamcrest.Matchers.`is` +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.thoughtcrime.securesms.database.model.DistributionListId import org.thoughtcrime.securesms.database.model.StoryType +import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.api.push.DistributionId import org.whispersystems.signalservice.api.push.ServiceId import java.util.UUID @RunWith(AndroidJUnit4::class) class StorySendsDatabaseTest { + private val distributionId1 = DistributionId.from(UUID.randomUUID()) + private val distributionId2 = DistributionId.from(UUID.randomUUID()) + private val distributionId3 = DistributionId.from(UUID.randomUUID()) + + private lateinit var distributionList1: DistributionListId + private lateinit var distributionList2: DistributionListId + private lateinit var distributionList3: DistributionListId + + private lateinit var distributionListRecipient1: Recipient + private lateinit var distributionListRecipient2: Recipient + private lateinit var distributionListRecipient3: Recipient + private lateinit var recipients1to10: List private lateinit var recipients11to20: List private lateinit var recipients6to15: List @@ -31,22 +51,41 @@ class StorySendsDatabaseTest { fun setup() { storySends = SignalDatabase.storySends - messageId1 = MmsHelper.insert(storyType = StoryType.STORY_WITHOUT_REPLIES) - messageId2 = MmsHelper.insert(storyType = StoryType.STORY_WITH_REPLIES) - messageId3 = MmsHelper.insert(storyType = StoryType.STORY_WITHOUT_REPLIES) - recipients1to10 = makeRecipients(10) recipients11to20 = makeRecipients(10) + distributionList1 = SignalDatabase.distributionLists.createList("1", emptyList(), distributionId = distributionId1)!! + distributionList2 = SignalDatabase.distributionLists.createList("2", emptyList(), distributionId = distributionId2)!! + distributionList3 = SignalDatabase.distributionLists.createList("3", emptyList(), distributionId = distributionId3)!! + + distributionListRecipient1 = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(distributionList1)) + distributionListRecipient2 = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(distributionList2)) + distributionListRecipient3 = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(distributionList3)) + + messageId1 = MmsHelper.insert( + recipient = distributionListRecipient1, + storyType = StoryType.STORY_WITHOUT_REPLIES, + ) + + messageId2 = MmsHelper.insert( + recipient = distributionListRecipient2, + storyType = StoryType.STORY_WITH_REPLIES, + ) + + messageId3 = MmsHelper.insert( + recipient = distributionListRecipient3, + storyType = StoryType.STORY_WITHOUT_REPLIES, + ) + recipients6to15 = recipients1to10.takeLast(5) + recipients11to20.take(5) recipients6to10 = recipients1to10.takeLast(5) } @Test fun getRecipientsToSendTo_noOverlap() { - storySends.insert(messageId1, recipients1to10, 100, false) - storySends.insert(messageId2, recipients11to20, 200, true) - storySends.insert(messageId3, recipients1to10, 300, false) + storySends.insert(messageId1, recipients1to10, 100, false, distributionId1) + storySends.insert(messageId2, recipients11to20, 200, true, distributionId2) + storySends.insert(messageId3, recipients1to10, 300, false, distributionId3) val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, false) val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 200, true) @@ -60,8 +99,8 @@ class StorySendsDatabaseTest { @Test fun getRecipientsToSendTo_overlap() { - storySends.insert(messageId1, recipients1to10, 100, false) - storySends.insert(messageId2, recipients6to15, 100, true) + storySends.insert(messageId1, recipients1to10, 100, false, distributionId1) + storySends.insert(messageId2, recipients6to15, 100, true, distributionId2) val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, false) val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 100, true) @@ -78,9 +117,9 @@ class StorySendsDatabaseTest { val recipient1 = recipients1to10.first() val recipient2 = recipients11to20.first() - storySends.insert(messageId1, listOf(recipient1, recipient2), 100, false) - storySends.insert(messageId2, listOf(recipient1), 100, true) - storySends.insert(messageId3, listOf(recipient2), 100, true) + storySends.insert(messageId1, listOf(recipient1, recipient2), 100, false, distributionId1) + storySends.insert(messageId2, listOf(recipient1), 100, true, distributionId2) + storySends.insert(messageId3, listOf(recipient2), 100, true, distributionId3) val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, false) val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 100, true) @@ -97,8 +136,8 @@ class StorySendsDatabaseTest { @Test fun getRecipientsToSendTo_overlapWithEarlierMessage() { - storySends.insert(messageId1, recipients6to15, 100, true) - storySends.insert(messageId2, recipients1to10, 100, false) + storySends.insert(messageId1, recipients6to15, 100, true, distributionId1) + storySends.insert(messageId2, recipients1to10, 100, false, distributionId2) val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, true) val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 100, false) @@ -112,9 +151,9 @@ class StorySendsDatabaseTest { @Test fun getRemoteDeleteRecipients_noOverlap() { - storySends.insert(messageId1, recipients1to10, 100, false) - storySends.insert(messageId2, recipients11to20, 200, true) - storySends.insert(messageId3, recipients1to10, 300, false) + storySends.insert(messageId1, recipients1to10, 100, false, distributionId1) + storySends.insert(messageId2, recipients11to20, 200, true, distributionId2) + storySends.insert(messageId3, recipients1to10, 300, false, distributionId3) val recipientIdsForMessage1 = storySends.getRemoteDeleteRecipients(messageId1, 100) val recipientIdsForMessage2 = storySends.getRemoteDeleteRecipients(messageId2, 200) @@ -128,8 +167,8 @@ class StorySendsDatabaseTest { @Test fun getRemoteDeleteRecipients_overlapNoPreviousDeletes() { - storySends.insert(messageId1, recipients1to10, 200, false) - storySends.insert(messageId2, recipients6to15, 200, true) + storySends.insert(messageId1, recipients1to10, 200, false, distributionId1) + storySends.insert(messageId2, recipients6to15, 200, true, distributionId2) val recipientIdsForMessage1 = storySends.getRemoteDeleteRecipients(messageId1, 200) val recipientIdsForMessage2 = storySends.getRemoteDeleteRecipients(messageId2, 200) @@ -143,10 +182,10 @@ class StorySendsDatabaseTest { @Test fun getRemoteDeleteRecipients_overlapWithPreviousDeletes() { - storySends.insert(messageId1, recipients1to10, 200, false) + storySends.insert(messageId1, recipients1to10, 200, false, distributionId1) SignalDatabase.mms.markAsRemoteDelete(messageId1) - storySends.insert(messageId2, recipients6to15, 200, true) + storySends.insert(messageId2, recipients6to15, 200, true, distributionId2) val recipientIdsForMessage2 = storySends.getRemoteDeleteRecipients(messageId2, 200) @@ -156,7 +195,7 @@ class StorySendsDatabaseTest { @Test fun canReply_storyWithReplies() { - storySends.insert(messageId2, recipients1to10, 200, true) + storySends.insert(messageId2, recipients1to10, 200, true, distributionId2) val canReply = storySends.canReply(recipients1to10[0], 200) @@ -165,7 +204,7 @@ class StorySendsDatabaseTest { @Test fun canReply_storyWithoutReplies() { - storySends.insert(messageId1, recipients1to10, 200, false) + storySends.insert(messageId1, recipients1to10, 200, false, distributionId1) val canReply = storySends.canReply(recipients1to10[0], 200) @@ -174,8 +213,8 @@ class StorySendsDatabaseTest { @Test fun canReply_storyWithAndWithoutRepliesOverlap() { - storySends.insert(messageId1, recipients1to10, 200, false) - storySends.insert(messageId2, recipients6to10, 200, true) + storySends.insert(messageId1, recipients1to10, 200, false, distributionId1) + storySends.insert(messageId2, recipients6to10, 200, true, distributionId2) val message1OnlyRecipientCanReply = storySends.canReply(recipients1to10[0], 200) val message2RecipientCanReply = storySends.canReply(recipients6to10[0], 200) @@ -184,6 +223,238 @@ class StorySendsDatabaseTest { assertThat(message2RecipientCanReply, `is`(true)) } + @Test + fun givenASingleStory_whenIGetFullSentStorySyncManifest_thenIExpectNotNull() { + storySends.insert(messageId1, recipients1to10, 200, false, distributionId1) + + val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200) + + assertNotNull(manifest) + } + + @Test + fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory2_thenIExpectNull() { + storySends.insert(messageId1, recipients1to10, 200, false, distributionId1) + storySends.insert(messageId2, recipients1to10, 200, false, distributionId2) + + val manifest = storySends.getFullSentStorySyncManifest(messageId2, 200) + + assertNull(manifest) + } + + @Test + fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory1_thenIExpectOneManifestPerRecipient() { + storySends.insert(messageId1, recipients1to10, 200, false, distributionId1) + storySends.insert(messageId2, recipients1to10, 200, true, distributionId2) + + val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)!! + + assertEquals(recipients1to10, manifest.entries.map { it.recipientId }) + } + + @Test + fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory1_thenIExpectTwoListsPerRecipient() { + storySends.insert(messageId1, recipients1to10, 200, false, distributionId1) + storySends.insert(messageId2, recipients1to10, 200, true, distributionId2) + + val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)!! + + manifest.entries.forEach { entry -> + assertEquals(listOf(distributionId1, distributionId2), entry.distributionLists) + } + } + + @Test + fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory1_thenIExpectAllRecipientsCanReply() { + storySends.insert(messageId1, recipients1to10, 200, false, distributionId1) + storySends.insert(messageId2, recipients1to10, 200, true, distributionId2) + + val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)!! + + manifest.entries.forEach { entry -> + assertTrue(entry.allowedToReply) + } + } + + @Test + fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetFullSentStorySyncManifestForStory2_thenIExpectNonNullResult() { + storySends.insert(messageId1, recipients1to10, 200, false, distributionId1) + storySends.insert(messageId2, recipients1to10, 200, true, distributionId2) + SignalDatabase.mms.markAsRemoteDelete(messageId1) + + val manifest = storySends.getFullSentStorySyncManifest(messageId2, 200)!! + + assertNotNull(manifest) + } + + @Test + fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetRecipientIdsForManifestUpdate_thenIExpectOnlyRecipientsWithStory2() { + storySends.insert(messageId1, recipients1to10, 200, false, distributionId1) + storySends.insert(messageId1, recipients11to20, 200, false, distributionId1) + storySends.insert(messageId2, recipients1to10, 200, true, distributionId2) + SignalDatabase.mms.markAsRemoteDelete(messageId1) + + val recipientIds = storySends.getRecipientIdsForManifestUpdate(200, messageId1) + + assertEquals(recipients1to10.toHashSet(), recipientIds) + } + + @Test + fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetPartialSentStorySyncManifest_thenIExpectOnlyRecipientsThatHadStory1() { + storySends.insert(messageId1, recipients1to10, 200, false, distributionId1) + storySends.insert(messageId2, recipients1to10, 200, true, distributionId2) + storySends.insert(messageId2, recipients11to20, 200, true, distributionId2) + SignalDatabase.mms.markAsRemoteDelete(messageId1) + val recipientIds = storySends.getRecipientIdsForManifestUpdate(200, messageId1) + + val results = storySends.getSentStorySyncManifestForUpdate(200, recipientIds) + + val manifestRecipients = results.entries.map { it.recipientId } + assertEquals(recipients1to10, manifestRecipients) + } + + @Test + fun givenTwoStoriesAndTheOneThatAllowedRepliesIsRemoteDeleted_whenIGetPartialSentStorySyncManifest_thenIExpectAllowRepliesToBeTrue() { + storySends.insert(messageId1, recipients1to10, 200, false, distributionId1) + storySends.insert(messageId2, recipients1to10, 200, true, distributionId2) + SignalDatabase.mms.markAsRemoteDelete(messageId2) + val recipientIds = storySends.getRecipientIdsForManifestUpdate(200, messageId1) + + val results = storySends.getSentStorySyncManifestForUpdate(200, recipientIds) + + assertTrue(results.entries.all { it.allowedToReply }) + } + + @Test + fun givenEmptyManifest_whenIApplyRemoteManifest_thenNothingChanges() { + storySends.insert(messageId1, recipients1to10, 200, false, distributionId1) + val expected = storySends.getFullSentStorySyncManifest(messageId1, 200) + val emptyManifest = SentStorySyncManifest(emptyList()) + + storySends.applySentStoryManifest(emptyManifest, 200) + val result = storySends.getFullSentStorySyncManifest(messageId1, 200) + + assertEquals(expected, result) + } + + @Test + fun givenAnIdenticalManifest_whenIApplyRemoteManifest_thenNothingChanges() { + val messageId4 = MmsHelper.insert( + recipient = distributionListRecipient1, + storyType = StoryType.STORY_WITHOUT_REPLIES, + sentTimeMillis = 200 + ) + + storySends.insert(messageId4, recipients1to10, 200, false, distributionId1) + val expected = storySends.getFullSentStorySyncManifest(messageId4, 200) + + storySends.applySentStoryManifest(expected!!, 200) + val result = storySends.getFullSentStorySyncManifest(messageId4, 200) + + assertEquals(expected, result) + } + + @Test + fun givenAManifest_whenIApplyRemoteManifestWithoutOneList_thenIExpectMessageToBeMarkedRemoteDeleted() { + val messageId4 = MmsHelper.insert( + recipient = distributionListRecipient1, + storyType = StoryType.STORY_WITHOUT_REPLIES, + sentTimeMillis = 200 + ) + + val messageId5 = MmsHelper.insert( + recipient = distributionListRecipient2, + storyType = StoryType.STORY_WITHOUT_REPLIES, + sentTimeMillis = 200 + ) + + storySends.insert(messageId4, recipients1to10, 200, false, distributionId1) + val remote = storySends.getFullSentStorySyncManifest(messageId4, 200)!! + + storySends.insert(messageId5, recipients1to10, 200, false, distributionId2) + + storySends.applySentStoryManifest(remote, 200) + + assertTrue(SignalDatabase.mms.getMessageRecord(messageId5).isRemoteDelete) + } + + @Test + fun givenAManifest_whenIApplyRemoteManifestWithoutOneList_thenIExpectSharedMessageToNotBeMarkedRemoteDeleted() { + val messageId4 = MmsHelper.insert( + recipient = distributionListRecipient1, + storyType = StoryType.STORY_WITHOUT_REPLIES, + sentTimeMillis = 200 + ) + + val messageId5 = MmsHelper.insert( + recipient = distributionListRecipient2, + storyType = StoryType.STORY_WITHOUT_REPLIES, + sentTimeMillis = 200 + ) + + storySends.insert(messageId4, recipients1to10, 200, false, distributionId1) + val remote = storySends.getFullSentStorySyncManifest(messageId4, 200)!! + + storySends.insert(messageId5, recipients1to10, 200, false, distributionId2) + + storySends.applySentStoryManifest(remote, 200) + + assertFalse(SignalDatabase.mms.getMessageRecord(messageId4).isRemoteDelete) + } + + @Test + fun givenNoLocalEntries_whenIApplyRemoteManifest_thenIExpectLocalManifestToMatch() { + val messageId4 = MmsHelper.insert( + recipient = distributionListRecipient1, + storyType = StoryType.STORY_WITHOUT_REPLIES, + sentTimeMillis = 2000 + ) + + val remote = SentStorySyncManifest( + recipients1to10.map { + SentStorySyncManifest.Entry( + recipientId = it, + allowedToReply = true, + distributionLists = listOf(distributionId1) + ) + } + ) + + storySends.applySentStoryManifest(remote, 2000) + + val local = storySends.getFullSentStorySyncManifest(messageId4, 2000) + assertEquals(remote, local) + } + + @Test + fun givenNonStoryMessageAtSentTimestamp_whenIApplyRemoteManifest_thenIExpectLocalManifestToMatchAndNoCrashes() { + val messageId4 = MmsHelper.insert( + recipient = distributionListRecipient1, + storyType = StoryType.STORY_WITHOUT_REPLIES, + sentTimeMillis = 2000 + ) + + MmsHelper.insert( + recipient = Recipient.resolved(recipients1to10.first()), + sentTimeMillis = 2000 + ) + + val remote = SentStorySyncManifest( + recipients1to10.map { + SentStorySyncManifest.Entry( + recipientId = it, + allowedToReply = true, + distributionLists = listOf(distributionId1) + ) + } + ) + + storySends.applySentStoryManifest(remote, 2000) + + val local = storySends.getFullSentStorySyncManifest(messageId4, 2000) + assertEquals(remote, local) + } + private fun makeRecipients(count: Int): List { return (1..count).map { SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java index 9ed9777d60..08a6fdc0ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java @@ -27,10 +27,8 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord; import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.phonenumbers.NumberUtil; -import org.thoughtcrime.securesms.stories.Stories; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.UsernameUtil; @@ -57,7 +55,6 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader { public static final int FLAG_HIDE_NEW = 1 << 6; public static final int FLAG_HIDE_RECENT_HEADER = 1 << 7; public static final int FLAG_GROUPS_AFTER_CONTACTS = 1 << 8; - public static final int FLAG_STORIES = 1 << 9; public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS | FLAG_SELF; } @@ -88,7 +85,6 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader { addRecentGroupsSection(cursorList); addGroupsSection(cursorList); } else { - addStoriesSection(cursorList); addRecentsSection(cursorList); addContactsSection(cursorList); if (addGroupsAfterContacts(mode)) { @@ -167,19 +163,6 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader { } } - private void addStoriesSection(@NonNull List cursorList) { - if (!Stories.isFeatureEnabled() ||!storiesEnabled(mode)) { - return; - } - - Cursor stories = getStoriesCursor(); - - if (stories.getCount() > 0) { - cursorList.add(ContactsCursorRows.forStoriesHeader(getContext())); - cursorList.add(stories); - } - } - private void addNewNumberSection(@NonNull List cursorList) { if (FeatureFlags.usernames() && NumberUtil.isVisuallyValidNumberOrEmail(getFilter())) { cursorList.add(ContactsCursorRows.forPhoneNumberSearchHeader(getContext())); @@ -240,16 +223,6 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader { return groupContacts; } - private Cursor getStoriesCursor() { - MatrixCursor distributionListsCursor = ContactsCursorRows.createMatrixCursor(); - List distributionLists = SignalDatabase.distributionLists().getAllListsForContactSelectionUi(null, true); - for (final DistributionListPartialRecord distributionList : distributionLists) { - distributionListsCursor.addRow(ContactsCursorRows.forDistributionList(distributionList)); - } - - return distributionListsCursor; - } - private Cursor getNewNumberCursor() { return ContactsCursorRows.forNewNumber(getUnknownContactTitle(), getFilter()); } @@ -320,10 +293,6 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader { return flagSet(mode, DisplayMode.FLAG_GROUPS_AFTER_CONTACTS); } - private static boolean storiesEnabled(int mode) { - return flagSet(mode, DisplayMode.FLAG_STORIES); - } - private static boolean flagSet(int mode, int flag) { return (mode & flag) > 0; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorRows.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorRows.java index b032e2bbc2..d3729679a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorRows.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorRows.java @@ -9,8 +9,6 @@ import androidx.annotation.NonNull; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.GroupDatabase; -import org.thoughtcrime.securesms.database.SignalDatabase; -import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord; import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; import org.thoughtcrime.securesms.recipients.Recipient; import org.whispersystems.signalservice.api.util.OptionalUtil; @@ -86,16 +84,6 @@ public final class ContactsCursorRows { ""}; } - public static @NonNull Object[] forDistributionList(@NonNull DistributionListPartialRecord distributionListPartialRecord) { - return new Object[]{ distributionListPartialRecord.getRecipientId().serialize(), - distributionListPartialRecord.getName(), - SignalDatabase.distributionLists().getMemberCount(distributionListPartialRecord.getId()), - ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM, - "", - ContactRepository.NORMAL_TYPE, - ""}; - } - /** * Create a row for a contacts cursor for a new number the user is entering or has entered. */ @@ -130,10 +118,6 @@ public final class ContactsCursorRows { return matrixCursor; } - public static @NonNull MatrixCursor forStoriesHeader(@NonNull Context context) { - return forHeader(context.getString(R.string.ContactsCursorLoader_my_stories)); - } - public static @NonNull MatrixCursor forUsernameSearchHeader(@NonNull Context context) { return forHeader(context.getString(R.string.ContactsCursorLoader_username_search)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListDatabase.kt index cc5db18043..a43d0b6ab1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListDatabase.kt @@ -10,6 +10,7 @@ import org.signal.core.util.logging.Log import org.signal.core.util.requireLong import org.signal.core.util.requireNonNullString import org.signal.core.util.requireString +import org.signal.core.util.select import org.thoughtcrime.securesms.database.model.DistributionListId import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord import org.thoughtcrime.securesms.database.model.DistributionListRecord @@ -21,7 +22,6 @@ import org.thoughtcrime.securesms.util.Base64 import org.whispersystems.signalservice.api.push.DistributionId import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord import org.whispersystems.signalservice.api.util.UuidUtil -import java.lang.AssertionError import java.util.UUID /** @@ -36,6 +36,8 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si val CREATE_TABLE: Array = arrayOf(ListTable.CREATE_TABLE, MembershipTable.CREATE_TABLE) const val RECIPIENT_ID = ListTable.RECIPIENT_ID + const val DISTRIBUTION_ID = ListTable.DISTRIBUTION_ID + const val LIST_TABLE_NAME = ListTable.TABLE_NAME fun insertInitialDistributionListAtCreationTime(db: net.zetetic.database.sqlcipher.SQLiteDatabase) { val recipientId = db.insert( @@ -68,6 +70,7 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si const val RECIPIENT_ID = "recipient_id" const val ALLOWS_REPLIES = "allows_replies" const val DELETION_TIMESTAMP = "deletion_timestamp" + const val IS_UNKNOWN = "is_unknown" const val CREATE_TABLE = """ CREATE TABLE $TABLE_NAME ( @@ -76,7 +79,8 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si $DISTRIBUTION_ID TEXT UNIQUE NOT NULL, $RECIPIENT_ID INTEGER UNIQUE REFERENCES ${RecipientDatabase.TABLE_NAME} (${RecipientDatabase.ID}), $ALLOWS_REPLIES INTEGER DEFAULT 1, - $DELETION_TIMESTAMP INTEGER DEFAULT 0 + $DELETION_TIMESTAMP INTEGER DEFAULT 0, + $IS_UNKNOWN INTEGER DEFAULT 0 ) """ @@ -124,7 +128,8 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si id = DistributionListId.from(CursorUtil.requireLong(it, ListTable.ID)), name = CursorUtil.requireString(it, ListTable.NAME), allowsReplies = CursorUtil.requireBoolean(it, ListTable.ALLOWS_REPLIES), - recipientId = RecipientId.from(CursorUtil.requireLong(it, ListTable.RECIPIENT_ID)) + recipientId = RecipientId.from(CursorUtil.requireLong(it, ListTable.RECIPIENT_ID)), + isUnknown = CursorUtil.requireBoolean(it, ListTable.IS_UNKNOWN) ) ) } @@ -135,13 +140,13 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si fun getAllListsForContactSelectionUiCursor(query: String?, includeMyStory: Boolean): Cursor? { val db = readableDatabase - val projection = arrayOf(ListTable.ID, ListTable.NAME, ListTable.RECIPIENT_ID, ListTable.ALLOWS_REPLIES) + val projection = arrayOf(ListTable.ID, ListTable.NAME, ListTable.RECIPIENT_ID, ListTable.ALLOWS_REPLIES, ListTable.IS_UNKNOWN) val where = when { query.isNullOrEmpty() && includeMyStory -> ListTable.IS_NOT_DELETED query.isNullOrEmpty() -> "${ListTable.ID} != ? AND ${ListTable.IS_NOT_DELETED}" - includeMyStory -> "(${ListTable.NAME} GLOB ? OR ${ListTable.ID} == ?) AND ${ListTable.IS_NOT_DELETED}" - else -> "${ListTable.NAME} GLOB ? AND ${ListTable.ID} != ? AND ${ListTable.IS_NOT_DELETED}" + includeMyStory -> "(${ListTable.NAME} GLOB ? OR ${ListTable.ID} == ?) AND ${ListTable.IS_NOT_DELETED} AND NOT ${ListTable.IS_UNKNOWN}" + else -> "${ListTable.NAME} GLOB ? AND ${ListTable.ID} != ? AND ${ListTable.IS_NOT_DELETED} AND NOT ${ListTable.IS_UNKNOWN}" } val whereArgs = when { @@ -155,7 +160,7 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si fun getCustomListsForUi(): List { val db = readableDatabase - val projection = SqlUtil.buildArgs(ListTable.ID, ListTable.NAME, ListTable.RECIPIENT_ID, ListTable.ALLOWS_REPLIES) + val projection = SqlUtil.buildArgs(ListTable.ID, ListTable.NAME, ListTable.RECIPIENT_ID, ListTable.ALLOWS_REPLIES, ListTable.IS_UNKNOWN) val selection = "${ListTable.ID} != ${DistributionListId.MY_STORY_ID} AND ${ListTable.IS_NOT_DELETED}" return db.query(ListTable.TABLE_NAME, projection, selection, null, null, null, null)?.use { @@ -166,7 +171,8 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si id = DistributionListId.from(CursorUtil.requireLong(it, ListTable.ID)), name = CursorUtil.requireString(it, ListTable.NAME), allowsReplies = CursorUtil.requireBoolean(it, ListTable.ALLOWS_REPLIES), - recipientId = RecipientId.from(CursorUtil.requireLong(it, ListTable.RECIPIENT_ID)) + recipientId = RecipientId.from(CursorUtil.requireLong(it, ListTable.RECIPIENT_ID)), + isUnknown = CursorUtil.requireBoolean(it, ListTable.IS_UNKNOWN) ) ) } @@ -175,6 +181,50 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si } ?: emptyList() } + /** + * Gets or creates a distribution list for the given id. + * + * If the list does not exist, then a new list is created with a randomized name and populated with the members + * in the manifest. + * + * @return the recipient id of the list + */ + fun getOrCreateByDistributionId(distributionId: DistributionId, manifest: SentStorySyncManifest): RecipientId { + writableDatabase.beginTransaction() + try { + val distributionRecipientId = getRecipientIdByDistributionId(distributionId) + if (distributionRecipientId == null) { + val members: List = manifest.entries + .filter { it.distributionLists.contains(distributionId) } + .map { it.recipientId } + + val distributionListId = createList( + name = createUniqueNameForUnknownDistributionId(), + members = members, + distributionId = distributionId, + isUnknown = true + ) + + if (distributionListId == null) { + throw AssertionError("Failed to create distribution list for unknown id.") + } else { + val recipient = getRecipientId(distributionListId) + if (recipient == null) { + throw AssertionError("Failed to retrieve recipient for newly created list") + } else { + writableDatabase.setTransactionSuccessful() + return recipient + } + } + } + + writableDatabase.setTransactionSuccessful() + return distributionRecipientId + } finally { + writableDatabase.endTransaction() + } + } + /** * @return The id of the list if successful, otherwise null. If not successful, you can assume it was a name conflict. */ @@ -184,7 +234,8 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si distributionId: DistributionId = DistributionId.from(UUID.randomUUID()), allowsReplies: Boolean = true, deletionTimestamp: Long = 0L, - storageId: ByteArray? = null + storageId: ByteArray? = null, + isUnknown: Boolean = false ): DistributionListId? { val db = writableDatabase @@ -196,6 +247,7 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si put(ListTable.ALLOWS_REPLIES, if (deletionTimestamp == 0L) allowsReplies else false) putNull(ListTable.RECIPIENT_ID) put(ListTable.DELETION_TIMESTAMP, deletionTimestamp) + put(ListTable.IS_UNKNOWN, isUnknown) } val id = writableDatabase.insert(ListTable.TABLE_NAME, null, values) @@ -222,6 +274,21 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si } } + fun getRecipientIdByDistributionId(distributionId: DistributionId): RecipientId? { + return readableDatabase + .select(ListTable.RECIPIENT_ID) + .from(ListTable.TABLE_NAME) + .where("${ListTable.DISTRIBUTION_ID} = ?", distributionId.toString()) + .run() + .use { cursor -> + if (cursor.moveToFirst()) { + RecipientId.from(CursorUtil.requireLong(cursor, ListTable.RECIPIENT_ID)) + } else { + null + } + } + } + fun getStoryType(listId: DistributionListId): StoryType { readableDatabase.query(ListTable.TABLE_NAME, arrayOf(ListTable.ALLOWS_REPLIES), "${ListTable.ID} = ? AND ${ListTable.IS_NOT_DELETED}", SqlUtil.buildArgs(listId), null, null, null).use { cursor -> return if (cursor.moveToFirst()) { @@ -251,7 +318,8 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si distributionId = DistributionId.from(cursor.requireNonNullString(ListTable.DISTRIBUTION_ID)), allowsReplies = CursorUtil.requireBoolean(cursor, ListTable.ALLOWS_REPLIES), members = getMembers(id), - deletedAtTimestamp = 0L + deletedAtTimestamp = 0L, + isUnknown = CursorUtil.requireBoolean(cursor, ListTable.IS_UNKNOWN) ) } else { null @@ -270,7 +338,8 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si distributionId = DistributionId.from(cursor.requireNonNullString(ListTable.DISTRIBUTION_ID)), allowsReplies = CursorUtil.requireBoolean(cursor, ListTable.ALLOWS_REPLIES), members = getRawMembers(id), - deletedAtTimestamp = cursor.requireLong(ListTable.DELETION_TIMESTAMP) + deletedAtTimestamp = cursor.requireLong(ListTable.DELETION_TIMESTAMP), + isUnknown = CursorUtil.requireBoolean(cursor, ListTable.IS_UNKNOWN) ) } else { null @@ -461,7 +530,8 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si try { val listTableValues = contentValuesOf( ListTable.ALLOWS_REPLIES to update.new.allowsReplies(), - ListTable.NAME to update.new.name + ListTable.NAME to update.new.name, + ListTable.IS_UNKNOWN to false ) writableDatabase.update( @@ -493,4 +563,8 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si private fun createUniqueNameForDeletedStory(): String { return "DELETED-${UUID.randomUUID()}" } + + private fun createUniqueNameForUnknownDistributionId(): String { + return "DELETED-${UUID.randomUUID()}" + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java index 76181ec355..2c3bcf636a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java @@ -188,6 +188,7 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns public abstract boolean isStory(long messageId); public abstract @NonNull Reader getOutgoingStoriesTo(@NonNull RecipientId recipientId); public abstract @NonNull Reader getAllOutgoingStories(boolean reverse); + public abstract @NonNull Reader getAllOutgoingStoriesAt(long sentTimestamp); public abstract @NonNull List getOrderedStoryRecipientsAndIds(); public abstract @NonNull Reader getAllStoriesFor(@NonNull RecipientId recipientId); public abstract @NonNull MessageId getStoryId(@NonNull RecipientId authorId, long sentTimestamp) throws NoSuchMessageException; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index 0a9235ac6a..3c72a5ef99 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -584,6 +584,15 @@ public class MmsDatabase extends MessageDatabase { return new Reader(rawQuery(where, null, reverse, -1L)); } + @Override + public @NonNull MessageDatabase.Reader getAllOutgoingStoriesAt(long sentTimestamp) { + String where = IS_STORY_CLAUSE + " AND " + DATE_SENT + " = ? AND (" + getOutgoingTypeClause() + ")"; + String[] whereArgs = SqlUtil.buildArgs(sentTimestamp); + Cursor cursor = rawQuery(where, whereArgs, false, -1L); + + return new Reader(cursor); + } + @Override public @NonNull MessageDatabase.Reader getAllStoriesFor(@NonNull RecipientId recipientId) { long threadId = SignalDatabase.threads().getThreadIdIfExistsFor(recipientId); @@ -932,7 +941,7 @@ public class MmsDatabase extends MessageDatabase { public boolean hasMeaningfulMessage(long threadId) { SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - try (Cursor cursor = db.query(TABLE_NAME, new String[] { "1" }, THREAD_ID_WHERE, SqlUtil.buildArgs(threadId), null, null, null, "1")) { + try (Cursor cursor = db.query(TABLE_NAME, new String[] { "1" }, THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ?", SqlUtil.buildArgs(threadId, 0, 0), null, null, null, "1")) { return cursor != null && cursor.moveToFirst(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SentStorySyncManifest.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SentStorySyncManifest.kt new file mode 100644 index 0000000000..342e6ffdd4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SentStorySyncManifest.kt @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.database + +import androidx.annotation.WorkerThread +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessageRecipient +import org.whispersystems.signalservice.api.push.DistributionId +import org.whispersystems.signalservice.api.push.SignalServiceAddress + +/** + * Represents a list of, or update to a list of, who can access a story through what + * distribution lists, and whether they can reply. + */ +data class SentStorySyncManifest( + val entries: List +) { + + /** + * Represents an entry in the proto manifest. + */ + data class Entry( + val recipientId: RecipientId, + val allowedToReply: Boolean = false, + val distributionLists: List = emptyList() + ) + + /** + * Represents a flattened entry that is more convenient for detecting data changes. + */ + data class Row( + val recipientId: RecipientId, + val messageId: Long, + val allowsReplies: Boolean, + val distributionId: DistributionId + ) + + fun getDistributionIdSet(): Set { + return entries.map { it.distributionLists }.flatten().toSet() + } + + fun toRecipientsSet(): Set { + val recipients = Recipient.resolvedList(entries.map { it.recipientId }) + return recipients.map { recipient -> + val serviceId = recipient.requireServiceId() + val entry = entries.first { it.recipientId == recipient.id } + + SignalServiceStoryMessageRecipient( + SignalServiceAddress(serviceId), + entry.distributionLists.map { it.toString() }, + entry.allowedToReply + ) + }.toSet() + } + + fun flattenToRows(distributionIdToMessageIdMap: Map): Set { + return entries.flatMap { getRowsForEntry(it, distributionIdToMessageIdMap) }.toSet() + } + + private fun getRowsForEntry(entry: Entry, distributionIdToMessageIdMap: Map): List { + return entry.distributionLists.map { + Row( + recipientId = entry.recipientId, + allowsReplies = entry.allowedToReply, + messageId = distributionIdToMessageIdMap[it] ?: -1L, + distributionId = it + ) + }.filterNot { it.messageId == -1L } + } + + companion object { + @WorkerThread + @JvmStatic + fun fromRecipientsSet(recipientsSet: Set): SentStorySyncManifest { + val entries = recipientsSet.map { recipient -> + Entry( + recipientId = RecipientId.from(recipient.signalServiceAddress), + allowedToReply = recipient.isAllowedToReply, + distributionLists = recipient.distributionListIds.map { DistributionId.from(it) } + ) + } + + return SentStorySyncManifest(entries) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index da5f5001db..7ad51fc23a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -1401,6 +1401,11 @@ public class SmsDatabase extends MessageDatabase { throw new UnsupportedOperationException(); } + @Override + public @NonNull MessageDatabase.Reader getAllOutgoingStoriesAt(long sentTimestamp) { + throw new UnsupportedOperationException(); + } + @Override public @NonNull List getOrderedStoryRecipientsAndIds() { throw new UnsupportedOperationException(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/StorySendsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/StorySendsDatabase.kt index 6573da0197..5bad5ead95 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/StorySendsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/StorySendsDatabase.kt @@ -3,11 +3,15 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context import androidx.core.content.contentValuesOf +import org.signal.core.util.CursorUtil import org.signal.core.util.SqlUtil import org.signal.core.util.requireLong +import org.signal.core.util.select import org.signal.core.util.toInt +import org.signal.core.util.update import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.api.push.DistributionId /** * Sending to a distribution list is a bit trickier. When we send to multiple distribution lists with overlapping membership, we want to @@ -26,6 +30,7 @@ class StorySendsDatabase(context: Context, databaseHelper: SignalDatabase) : Dat const val RECIPIENT_ID = "recipient_id" const val SENT_TIMESTAMP = "sent_timestamp" const val ALLOWS_REPLIES = "allows_replies" + const val DISTRIBUTION_ID = "distribution_id" val CREATE_TABLE = """ CREATE TABLE $TABLE_NAME ( @@ -33,7 +38,8 @@ class StorySendsDatabase(context: Context, databaseHelper: SignalDatabase) : Dat $MESSAGE_ID INTEGER NOT NULL REFERENCES ${MmsDatabase.TABLE_NAME} (${MmsDatabase.ID}) ON DELETE CASCADE, $RECIPIENT_ID INTEGER NOT NULL REFERENCES ${RecipientDatabase.TABLE_NAME} (${RecipientDatabase.ID}) ON DELETE CASCADE, $SENT_TIMESTAMP INTEGER NOT NULL, - $ALLOWS_REPLIES INTEGER NOT NULL + $ALLOWS_REPLIES INTEGER NOT NULL, + $DISTRIBUTION_ID TEXT NOT NULL REFERENCES ${DistributionListDatabase.LIST_TABLE_NAME} (${DistributionListDatabase.DISTRIBUTION_ID}) ON DELETE CASCADE ) """.trimIndent() @@ -42,7 +48,7 @@ class StorySendsDatabase(context: Context, databaseHelper: SignalDatabase) : Dat """.trimIndent() } - fun insert(messageId: Long, recipientIds: Collection, sentTimestamp: Long, allowsReplies: Boolean) { + fun insert(messageId: Long, recipientIds: Collection, sentTimestamp: Long, allowsReplies: Boolean, distributionId: DistributionId) { val db = writableDatabase db.beginTransaction() @@ -52,11 +58,12 @@ class StorySendsDatabase(context: Context, databaseHelper: SignalDatabase) : Dat MESSAGE_ID to messageId, RECIPIENT_ID to id.serialize(), SENT_TIMESTAMP to sentTimestamp, - ALLOWS_REPLIES to allowsReplies.toInt() + ALLOWS_REPLIES to allowsReplies.toInt(), + DISTRIBUTION_ID to distributionId.toString() ) } - SqlUtil.buildBulkInsert(TABLE_NAME, arrayOf(MESSAGE_ID, RECIPIENT_ID, SENT_TIMESTAMP, ALLOWS_REPLIES), insertValues) + SqlUtil.buildBulkInsert(TABLE_NAME, arrayOf(MESSAGE_ID, RECIPIENT_ID, SENT_TIMESTAMP, ALLOWS_REPLIES, DISTRIBUTION_ID), insertValues) .forEach { query -> db.execSQL(query.where, query.whereArgs) } db.setTransactionSuccessful() @@ -177,4 +184,208 @@ class StorySendsDatabase(context: Context, databaseHelper: SignalDatabase) : Dat writableDatabase.update(TABLE_NAME, values, query, args) } + + /** + * Gets the manifest for a given story, or null if the story should NOT be the one reporting the manifest. + */ + fun getFullSentStorySyncManifest(messageId: Long, sentTimestamp: Long): SentStorySyncManifest? { + val firstMessageId: Long = readableDatabase.select(MESSAGE_ID) + .from(TABLE_NAME) + .where( + """ + $SENT_TIMESTAMP = ? AND + (SELECT ${MmsDatabase.REMOTE_DELETED} FROM ${MmsDatabase.TABLE_NAME} WHERE ${MmsDatabase.ID} = $MESSAGE_ID) = 0 + """.trimIndent(), + sentTimestamp + ) + .orderBy(MESSAGE_ID) + .limit(1) + .run() + .use { + if (it.moveToFirst()) { + CursorUtil.requireLong(it, MESSAGE_ID) + } else { + -1L + } + } + + if (firstMessageId == -1L || firstMessageId != messageId) { + return null + } + + return getLocalManifest(sentTimestamp) + } + + /** + * Gets the manifest after a change to the available distribution lists occurs. This will only include the recipients + * as specified by onlyInclude, and is meant to represent a delta rather than an entire manifest. + */ + fun getSentStorySyncManifestForUpdate(sentTimestamp: Long, onlyInclude: Set): SentStorySyncManifest { + val localManifest: SentStorySyncManifest = getLocalManifest(sentTimestamp) + val entries: List = localManifest.entries.filter { it.recipientId in onlyInclude } + + return SentStorySyncManifest(entries) + } + + /** + * Manifest updates should only include the specific recipients who have changes (normally, one less distribution list), + * and of those, only the ones that have a non-empty set of distribution lists. + * + * @return A set of recipients who were able to receive the deleted story, and still have other stories at the same timestamp. + */ + fun getRecipientIdsForManifestUpdate(sentTimestamp: Long, deletedMessageId: Long): Set { + // language=sql + val query = """ + SELECT $RECIPIENT_ID + FROM $TABLE_NAME + WHERE $SENT_TIMESTAMP = ? + AND $RECIPIENT_ID IN ( + SELECT $RECIPIENT_ID + FROM $TABLE_NAME + WHERE $MESSAGE_ID = ? + ) + AND $MESSAGE_ID IN ( + SELECT ${MmsDatabase.ID} + FROM ${MmsDatabase.TABLE_NAME} + WHERE ${MmsDatabase.REMOTE_DELETED} = 0 + ) + """.trimIndent() + + return readableDatabase.rawQuery(query, arrayOf(sentTimestamp, deletedMessageId)).use { cursor -> + if (cursor.count == 0) emptyList() + + val results: MutableSet = hashSetOf() + while (cursor.moveToNext()) { + results.add(RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID))) + } + + results + } + } + + /** + * Applies the given manifest to the local database. This method will: + * + * 1. Generate the local manifest + * 1. Gather the unique collective distribution id set from remote and local manifests + * 1. Flatten both manifests into a set of Rows + * 1. For each changed manifest row in remote, update the corresponding row in local + * 1. For each new manifest row in remote, update the corresponding row in local + * 1. For each unique message id in local not present in remote, we can assume that the message can be marked deleted. + */ + fun applySentStoryManifest(remoteManifest: SentStorySyncManifest, sentTimestamp: Long) { + if (remoteManifest.entries.isEmpty()) { + return + } + + writableDatabase.beginTransaction() + try { + val localManifest: SentStorySyncManifest = getLocalManifest(sentTimestamp) + + val query = """ + SELECT ${MmsDatabase.TABLE_NAME}.${MmsDatabase.ID} as $MESSAGE_ID, ${DistributionListDatabase.DISTRIBUTION_ID} + FROM ${MmsDatabase.TABLE_NAME} + INNER JOIN ${DistributionListDatabase.LIST_TABLE_NAME} ON ${DistributionListDatabase.RECIPIENT_ID} = ${MmsDatabase.RECIPIENT_ID} + WHERE ${MmsDatabase.DATE_SENT} = $sentTimestamp AND ${DistributionListDatabase.DISTRIBUTION_ID} IS NOT NULL + """.trimIndent() + + val distributionIdToMessageId = readableDatabase.query(query).use { cursor -> + val results: MutableMap = mutableMapOf() + + while (cursor.moveToNext()) { + val distributionId = DistributionId.from(CursorUtil.requireString(cursor, DistributionListDatabase.DISTRIBUTION_ID)) + val messageId = CursorUtil.requireLong(cursor, MESSAGE_ID) + + results[distributionId] = messageId + } + + results + } + + val localRows: Set = localManifest.flattenToRows(distributionIdToMessageId) + val remoteRows: Set = remoteManifest.flattenToRows(distributionIdToMessageId) + + if (localRows == remoteRows) { + return + } + + val remoteOnly: List = remoteRows.filterNot { localRows.contains(it) } + val changedInRemoteManifest: List = remoteOnly.filter { (recipientId, messageId) -> localRows.any { it.messageId == messageId && it.recipientId == recipientId } } + val newInRemoteManifest: List = remoteOnly.filterNot { (recipientId, messageId) -> localRows.any { it.messageId == messageId && it.recipientId == recipientId } } + + changedInRemoteManifest + .forEach { (recipientId, messageId, allowsReplies, distributionId) -> + writableDatabase.update(TABLE_NAME) + .values( + contentValuesOf( + ALLOWS_REPLIES to allowsReplies, + RECIPIENT_ID to recipientId.toLong(), + SENT_TIMESTAMP to sentTimestamp, + MESSAGE_ID to messageId, + DISTRIBUTION_ID to distributionId.toString() + ) + ) + } + + newInRemoteManifest + .forEach { (recipientId, messageId, allowsReplies, distributionId) -> + writableDatabase.insert( + TABLE_NAME, + null, + contentValuesOf( + ALLOWS_REPLIES to allowsReplies, + RECIPIENT_ID to recipientId.toLong(), + SENT_TIMESTAMP to sentTimestamp, + MESSAGE_ID to messageId, + DISTRIBUTION_ID to distributionId.toString() + ) + ) + } + + val messagesWithoutAnyReceivers = localRows.map { it.messageId }.distinct() - remoteRows.map { it.messageId }.distinct() + messagesWithoutAnyReceivers.forEach { SignalDatabase.mms.markAsRemoteDelete(it) } + + writableDatabase.setTransactionSuccessful() + } finally { + writableDatabase.endTransaction() + } + } + + private fun getLocalManifest(sentTimestamp: Long): SentStorySyncManifest { + val entries = readableDatabase.rawQuery( + // language=sql + """ + SELECT + $RECIPIENT_ID, + $ALLOWS_REPLIES, + $DISTRIBUTION_ID + FROM $TABLE_NAME + WHERE $TABLE_NAME.$SENT_TIMESTAMP = ? AND ( + SELECT ${MmsDatabase.REMOTE_DELETED} + FROM ${MmsDatabase.TABLE_NAME} + WHERE ${MmsDatabase.ID} = $TABLE_NAME.$MESSAGE_ID + ) = 0 + """.trimIndent(), + arrayOf(sentTimestamp) + ).use { cursor -> + val results: MutableMap = mutableMapOf() + while (cursor.moveToNext()) { + val recipientId = RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)) + val distributionId = DistributionId.from(CursorUtil.requireString(cursor, DISTRIBUTION_ID)) + val allowsReplies = CursorUtil.requireBoolean(cursor, ALLOWS_REPLIES) + val entry = results[recipientId]?.let { + it.copy( + allowedToReply = it.allowedToReply or allowsReplies, + distributionLists = it.distributionLists + distributionId + ) + } ?: SentStorySyncManifest.Entry(recipientId, canReply(recipientId, sentTimestamp), listOf(distributionId)) + + results[recipientId] = entry + } + + results + } + + return SentStorySyncManifest(entries.values.toList()) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 6036fa09cb..8f6cd644ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -1300,19 +1300,23 @@ public class ThreadDatabase extends Database { private boolean update(long threadId, boolean unarchive, boolean allowDeletion, boolean notifyListeners) { MmsSmsDatabase mmsSmsDatabase = SignalDatabase.mmsSms(); boolean meaningfulMessages = mmsSmsDatabase.hasMeaningfulMessage(threadId); + boolean isPinned = getPinnedThreadIds().contains(threadId); + boolean shouldDelete = allowDeletion && !isPinned && !SignalDatabase.mms().containsStories(threadId); if (!meaningfulMessages) { - if (allowDeletion) { + if (shouldDelete) { deleteConversation(threadId); + return true; + } else if (!isPinned) { + return false; } - return true; } MessageRecord record; try { record = mmsSmsDatabase.getConversationSnippet(threadId); } catch (NoSuchMessageException e) { - if (allowDeletion && !SignalDatabase.mms().containsStories(threadId)) { + if (shouldDelete) { deleteConversation(threadId); } return true; 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 1ba3611906..31933ca8af 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 @@ -196,8 +196,9 @@ object SignalDatabaseMigrations { private const val CDS_V2 = 140 private const val GROUP_SERVICE_ID = 141 private const val QUOTE_TYPE = 142 + private const val STORY_SYNCS = 143 - const val DATABASE_VERSION = 142 + const val DATABASE_VERSION = 143 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { @@ -2533,6 +2534,39 @@ object SignalDatabaseMigrations { if (oldVersion < QUOTE_TYPE) { db.execSQL("ALTER TABLE mms ADD COLUMN quote_type INTEGER DEFAULT 0") } + + if (oldVersion < STORY_SYNCS) { + db.execSQL("ALTER TABLE distribution_list ADD COLUMN is_unknown INTEGER DEFAULT 0") + + db.execSQL( + """ + CREATE TABLE story_sends_tmp ( + _id INTEGER PRIMARY KEY, + message_id INTEGER NOT NULL REFERENCES mms (_id) ON DELETE CASCADE, + recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE, + sent_timestamp INTEGER NOT NULL, + allows_replies INTEGER NOT NULL, + distribution_id TEXT NOT NULL REFERENCES distribution_list (distribution_id) ON DELETE CASCADE + ) + """.trimIndent() + ) + + db.execSQL( + """ + INSERT INTO story_sends_tmp (_id, message_id, recipient_id, sent_timestamp, allows_replies, distribution_id) + SELECT story_sends._id, story_sends.message_id, story_sends.recipient_id, story_sends.sent_timestamp, story_sends.allows_replies, distribution_list.distribution_id + FROM story_sends + INNER JOIN mms ON story_sends.message_id = mms._id + INNER JOIN distribution_list ON distribution_list.recipient_id = mms.address + """.trimIndent() + ) + + db.execSQL("DROP TABLE story_sends") + db.execSQL("DROP INDEX IF EXISTS story_sends_recipient_id_sent_timestamp_allows_replies_index") + + db.execSQL("ALTER TABLE story_sends_tmp RENAME TO story_sends") + db.execSQL("CREATE INDEX story_sends_recipient_id_sent_timestamp_allows_replies_index ON story_sends (recipient_id, sent_timestamp, allows_replies)") + } } @JvmStatic diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListPartialRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListPartialRecord.kt index 06283263a4..2f9aa53f79 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListPartialRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListPartialRecord.kt @@ -6,5 +6,6 @@ data class DistributionListPartialRecord( val id: DistributionListId, val name: CharSequence, val recipientId: RecipientId, - val allowsReplies: Boolean + val allowsReplies: Boolean, + val isUnknown: Boolean ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListRecord.kt index 56d9349e75..50a46c4551 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListRecord.kt @@ -12,5 +12,6 @@ data class DistributionListRecord( val distributionId: DistributionId, val allowsReplies: Boolean, val members: List, - val deletedAtTimestamp: Long + val deletedAtTimestamp: Long, + val isUnknown: Boolean ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 937c513ac6..dd410b63fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -170,6 +170,7 @@ public final class JobManagerFactories { put(SendReadReceiptJob.KEY, new SendReadReceiptJob.Factory(application)); put(SendRetryReceiptJob.KEY, new SendRetryReceiptJob.Factory()); put(SendViewedReceiptJob.KEY, new SendViewedReceiptJob.Factory(application)); + put(MultiDeviceStorySendSyncJob.KEY, new MultiDeviceStorySendSyncJob.Factory()); put(ServiceOutageDetectionJob.KEY, new ServiceOutageDetectionJob.Factory()); put(SmsReceiveJob.KEY, new SmsReceiveJob.Factory()); put(SmsSendJob.KEY, new SmsSendJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceStorySendSyncJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceStorySendSyncJob.kt new file mode 100644 index 0000000000..975cd93791 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceStorySendSyncJob.kt @@ -0,0 +1,110 @@ +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.jobmanager.Data +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.recipients.Recipient +import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessageRecipient +import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage +import org.whispersystems.signalservice.api.push.SignalServiceAddress +import java.lang.Exception +import java.util.Optional +import java.util.concurrent.TimeUnit + +/** + * Transmits a sent sync transcript to linked devices containing the story sync manifest for the given sent timestamp. + * The transmitted message is sent as a recipient update, and will only contain affected recipients that still have a + * live story for the given timestamp. + */ +class MultiDeviceStorySendSyncJob private constructor(parameters: Parameters, private val sentTimestamp: Long, private val deletedMessageId: Long) : BaseJob(parameters) { + + companion object { + const val KEY = "MultiDeviceStorySendSyncJob" + + private val TAG = Log.tag(MultiDeviceStorySendSyncJob::class.java) + + private const val DATA_SENT_TIMESTAMP = "sent.timestamp" + private const val DATA_DELETED_MESSAGE_ID = "deleted.message.id" + + @JvmStatic + fun create(sentTimestamp: Long, deletedMessageId: Long): MultiDeviceStorySendSyncJob { + return MultiDeviceStorySendSyncJob( + parameters = Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setQueue(KEY) + .build(), + sentTimestamp = sentTimestamp, + deletedMessageId = deletedMessageId + ) + } + } + + override fun serialize(): Data { + return Data.Builder() + .putLong(DATA_SENT_TIMESTAMP, sentTimestamp) + .putLong(DATA_DELETED_MESSAGE_ID, deletedMessageId) + .build() + } + + override fun getFactoryKey(): String = KEY + + override fun onRun() { + val recipientIds = SignalDatabase.storySends.getRecipientIdsForManifestUpdate(sentTimestamp, deletedMessageId) + if (recipientIds.isEmpty()) { + Log.i(TAG, "No recipients requiring a manifest update. Dropping.") + return + } + + val updateManifest = SignalDatabase.storySends.getSentStorySyncManifestForUpdate(sentTimestamp, recipientIds) + + if (updateManifest.entries.isEmpty()) { + Log.i(TAG, "No entries in updated manifest. Dropping.") + return + } + + val recipientsSet = updateManifest.toRecipientsSet() + val transcriptMessage: SignalServiceSyncMessage = SignalServiceSyncMessage.forSentTranscript(buildSentTranscript(recipientsSet)) + val sendMessageResult = ApplicationDependencies.getSignalServiceMessageSender().sendSyncMessage(transcriptMessage, Optional.empty()) + + if (!sendMessageResult.isSuccess) { + throw RetryableException() + } + } + + override fun onShouldRetry(e: Exception): Boolean { + return e is RetryableException + } + + private fun buildSentTranscript(recipientsSet: Set): SentTranscriptMessage { + return SentTranscriptMessage( + Optional.of(SignalServiceAddress(Recipient.self().requireServiceId())), + sentTimestamp, + Optional.empty(), + 0, + emptyMap(), + true, + Optional.empty(), + recipientsSet + ) + } + + override fun onFailure() = Unit + + class RetryableException : Exception() + + class Factory : Job.Factory { + override fun create(parameters: Parameters, data: Data): MultiDeviceStorySendSyncJob { + return MultiDeviceStorySendSyncJob( + parameters = parameters, + sentTimestamp = data.getLong(DATA_SENT_TIMESTAMP), + deletedMessageId = data.getLong(DATA_DELETED_MESSAGE_ID) + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java index c8fb50b612..8ed3f9f17d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java @@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.attachments.DatabaseAttachment; import org.thoughtcrime.securesms.database.GroupReceiptDatabase; import org.thoughtcrime.securesms.database.MessageDatabase; import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.SentStorySyncManifest; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.NetworkFailure; @@ -36,6 +37,7 @@ import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; import org.whispersystems.signalservice.api.messages.SendMessageResult; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessageRecipient; import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; import java.io.IOException; @@ -175,6 +177,7 @@ public final class PushDistributionListSendJob extends PushSendJob { Log.i(TAG, JobLogger.format(this, "Finished send.")); PushGroupSendJob.processGroupMessageResults(context, messageId, -1, null, message, results, targets, Collections.emptyList(), existingNetworkFailures, existingIdentityMismatches); + } catch (UntrustedIdentityException | UndeliverableMessageException e) { warn(TAG, String.valueOf(message.getSentTimeMillis()), e); database.markAsSentFailed(messageId); @@ -206,7 +209,11 @@ public final class PushDistributionListSendJob extends PushSendJob { } else { throw new UndeliverableMessageException("No attachment on non-text story."); } - return GroupSendUtil.sendStoryMessage(context, message.getRecipient().requireDistributionListId(), destinations, isRecipientUpdate, new MessageId(messageId, true), message.getSentTimeMillis(), storyMessage); + + SentStorySyncManifest manifest = SignalDatabase.storySends().getFullSentStorySyncManifest(messageId, message.getSentTimeMillis()); + Set manifestCollection = manifest != null ? manifest.toRecipientsSet() : Collections.emptySet(); + + return GroupSendUtil.sendStoryMessage(context, message.getRecipient().requireDistributionListId(), destinations, isRecipientUpdate, new MessageId(messageId, true), message.getSentTimeMillis(), storyMessage, manifestCollection); } catch (ServerRejectedException e) { throw new UndeliverableMessageException(e); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java index 877f833a36..18054c6212 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java @@ -1,21 +1,22 @@ package org.thoughtcrime.securesms.jobs; -import android.content.Context; - import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; import com.annimon.stream.Stream; +import org.signal.core.util.SetUtil; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.database.MessageDatabase; import org.thoughtcrime.securesms.database.NoSuchMessageException; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.messages.GroupSendUtil; import org.thoughtcrime.securesms.net.NotPushRegisteredException; import org.thoughtcrime.securesms.recipients.Recipient; @@ -23,7 +24,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.transport.RetryLaterException; import org.thoughtcrime.securesms.util.GroupUtil; -import org.signal.core.util.SetUtil; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.signalservice.api.crypto.ContentHint; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; @@ -53,9 +53,7 @@ public class RemoteDeleteSendJob extends BaseJob { @WorkerThread - public static @NonNull RemoteDeleteSendJob create(@NonNull Context context, - long messageId, - boolean isMms) + public static @NonNull JobManager.Chain create(long messageId, boolean isMms) throws NoSuchMessageException { MessageRecord message = isMms ? SignalDatabase.mms().getMessageRecord(messageId) @@ -70,6 +68,9 @@ public class RemoteDeleteSendJob extends BaseJob { List recipients; if (conversationRecipient.isDistributionList()) { recipients = SignalDatabase.storySends().getRemoteDeleteRecipients(message.getId(), message.getTimestamp()); + if (recipients.isEmpty()) { + return ApplicationDependencies.getJobManager().startChain(MultiDeviceStorySendSyncJob.create(message.getDateSent(), messageId)); + } } else { recipients = conversationRecipient.isGroup() ? Stream.of(conversationRecipient.getParticipants()).map(Recipient::getId).toList() : Stream.of(conversationRecipient.getId()).toList(); @@ -77,15 +78,23 @@ public class RemoteDeleteSendJob extends BaseJob { recipients.remove(Recipient.self().getId()); - return new RemoteDeleteSendJob(messageId, - isMms, - recipients, - recipients.size(), - new Parameters.Builder() - .setQueue(conversationRecipient.getId().toQueueKey()) - .setLifespan(TimeUnit.DAYS.toMillis(1)) - .setMaxAttempts(Parameters.UNLIMITED) - .build()); + RemoteDeleteSendJob sendJob = new RemoteDeleteSendJob(messageId, + isMms, + recipients, + recipients.size(), + new Parameters.Builder() + .setQueue(conversationRecipient.getId().toQueueKey()) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build()); + + if (conversationRecipient.isDistributionList()) { + return ApplicationDependencies.getJobManager() + .startChain(sendJob) + .then(MultiDeviceStorySendSyncJob.create(message.getDateSent(), messageId)); + } else { + return ApplicationDependencies.getJobManager().startChain(sendJob); + } } private RemoteDeleteSendJob(long messageId, diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java b/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java index 1d92c9596a..3df99aaf2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java @@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.crypto.SenderKeyUtil; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; import org.thoughtcrime.securesms.database.MessageSendLogDatabase; +import org.thoughtcrime.securesms.database.SentStorySyncManifest; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.DistributionListId; import org.thoughtcrime.securesms.database.model.MessageId; @@ -38,6 +39,7 @@ import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; import org.whispersystems.signalservice.api.messages.SendMessageResult; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessageRecipient; import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; import org.whispersystems.signalservice.api.push.DistributionId; @@ -49,6 +51,7 @@ import org.whispersystems.signalservice.internal.push.http.PartialSendCompleteLi import java.io.IOException; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; @@ -157,7 +160,8 @@ public final class GroupSendUtil { boolean isRecipientUpdate, @NonNull MessageId messageId, long sentTimestamp, - @NonNull SignalServiceStoryMessage message) + @NonNull SignalServiceStoryMessage message, + @NonNull Set manifest) throws IOException, UntrustedIdentityException { return sendMessage( @@ -167,7 +171,7 @@ public final class GroupSendUtil { messageId, allTargets, isRecipientUpdate, - new StorySendOperation(messageId, null, sentTimestamp, message), + new StorySendOperation(messageId, null, sentTimestamp, message, manifest), null); } @@ -193,7 +197,7 @@ public final class GroupSendUtil { messageId, allTargets, isRecipientUpdate, - new StorySendOperation(messageId, groupId, sentTimestamp, message), + new StorySendOperation(messageId, groupId, sentTimestamp, message, Collections.emptySet()), null); } @@ -605,16 +609,23 @@ public final class GroupSendUtil { public static class StorySendOperation implements SendOperation { - private final MessageId relatedMessageId; - private final GroupId groupId; - private final long sentTimestamp; - private final SignalServiceStoryMessage message; + private final MessageId relatedMessageId; + private final GroupId groupId; + private final long sentTimestamp; + private final SignalServiceStoryMessage message; + private final Set manifest; - public StorySendOperation(@NonNull MessageId relatedMessageId, @Nullable GroupId groupId, long sentTimestamp, @NonNull SignalServiceStoryMessage message) { + public StorySendOperation(@NonNull MessageId relatedMessageId, + @Nullable GroupId groupId, + long sentTimestamp, + @NonNull SignalServiceStoryMessage message, + @NonNull Set manifest) + { this.relatedMessageId = relatedMessageId; this.groupId = groupId; this.sentTimestamp = sentTimestamp; this.message = message; + this.manifest = manifest; } @Override @@ -625,7 +636,7 @@ public final class GroupSendUtil { boolean isRecipientUpdate) throws NoSessionException, UntrustedIdentityException, InvalidKeyException, IOException, InvalidRegistrationIdException { - return messageSender.sendGroupStory(distributionId, Optional.ofNullable(groupId).map(GroupId::getDecodedId), targets, access, message, getSentTimestamp()); + return messageSender.sendGroupStory(distributionId, Optional.ofNullable(groupId).map(GroupId::getDecodedId), targets, access, message, getSentTimestamp(), manifest); } @Override @@ -637,7 +648,7 @@ public final class GroupSendUtil { @Nullable CancelationSignal cancelationSignal) throws IOException, UntrustedIdentityException { - return messageSender.sendStory(targets, access, message, getSentTimestamp()); + return messageSender.sendStory(targets, access, message, getSentTimestamp(), manifest); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java index a1178dc21d..1031338a87 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -39,14 +39,17 @@ import org.thoughtcrime.securesms.database.GroupReceiptDatabase.GroupReceiptInfo import org.thoughtcrime.securesms.database.MessageDatabase; import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult; import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId; +import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.NoSuchMessageException; import org.thoughtcrime.securesms.database.PaymentDatabase; import org.thoughtcrime.securesms.database.PaymentMetaDataUtil; import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.SentStorySyncManifest; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.StickerDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.DistributionListId; import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.database.model.MessageLogEntry; @@ -513,11 +516,11 @@ public final class MessageContentProcessor { private static @Nullable SignalServiceGroupContext getGroupContextIfPresent(@NonNull SignalServiceContent content) { if (content.getDataMessage().isPresent() && content.getDataMessage().get().getGroupContext().isPresent()) { return content.getDataMessage().get().getGroupContext().get(); - } else if (content.getSyncMessage().isPresent() && + } else if (content.getSyncMessage().isPresent() && content.getSyncMessage().get().getSent().isPresent() && - content.getSyncMessage().get().getSent().get().getMessage().getGroupContext().isPresent()) + content.getSyncMessage().get().getSent().get().getDataMessage().get().getGroupContext().isPresent()) { - return content.getSyncMessage().get().getSent().get().getMessage().getGroupContext().get(); + return content.getSyncMessage().get().getSent().get().getDataMessage().get().getGroupContext().get(); } else { return null; } @@ -1203,9 +1206,15 @@ public final class MessageContentProcessor { try { GroupDatabase groupDatabase = SignalDatabase.groups(); - if (message.getMessage().isGroupV2Message()) { - GroupId.V2 groupId = GroupId.v2(message.getMessage().getGroupContext().get().getGroupV2().get().getMasterKey()); - if (handleGv2PreProcessing(groupId, content, message.getMessage().getGroupContext().get().getGroupV2().get(), senderRecipient)) { + if (message.getStoryMessage().isPresent() || !message.getStoryMessageRecipients().isEmpty()) { + handleSynchronizeSentStoryMessage(message, content.getTimestamp()); + return; + } + + SignalServiceDataMessage dataMessage = message.getDataMessage().get(); + if (dataMessage.isGroupV2Message()) { + GroupId.V2 groupId = GroupId.v2(dataMessage.getGroupContext().get().getGroupV2().get().getMasterKey()); + if (handleGv2PreProcessing(groupId, content, dataMessage.getGroupContext().get().getGroupV2().get(), senderRecipient)) { return; } } @@ -1214,38 +1223,38 @@ public final class MessageContentProcessor { if (message.isRecipientUpdate()) { handleGroupRecipientUpdate(message, content.getTimestamp()); - } else if (message.getMessage().isEndSession()) { + } else if (dataMessage.isEndSession()) { threadId = handleSynchronizeSentEndSessionMessage(message, content.getTimestamp()); - } else if (message.getMessage().isGroupV1Update()) { - Long gv1ThreadId = GroupV1MessageProcessor.process(context, content, message.getMessage(), true); + } else if (dataMessage.isGroupV1Update()) { + Long gv1ThreadId = GroupV1MessageProcessor.process(context, content, dataMessage, true); threadId = gv1ThreadId == null ? -1 : gv1ThreadId; - } else if (message.getMessage().isGroupV2Update()) { + } else if (dataMessage.isGroupV2Update()) { handleSynchronizeSentGv2Update(content, message); threadId = SignalDatabase.threads().getOrCreateThreadIdFor(getSyncMessageDestination(message)); - } else if (Build.VERSION.SDK_INT > 19 && message.getMessage().getGroupCallUpdate().isPresent()) { - handleGroupCallUpdateMessage(content, message.getMessage(), GroupUtil.idFromGroupContext(message.getMessage().getGroupContext()), senderRecipient); - } else if (message.getMessage().isEmptyGroupV2Message()) { + } else if (Build.VERSION.SDK_INT > 19 && dataMessage.getGroupCallUpdate().isPresent()) { + handleGroupCallUpdateMessage(content, dataMessage, GroupUtil.idFromGroupContext(dataMessage.getGroupContext()), senderRecipient); + } else if (dataMessage.isEmptyGroupV2Message()) { warn(content.getTimestamp(), "Empty GV2 message! Doing nothing."); - } else if (message.getMessage().isExpirationUpdate()) { + } else if (dataMessage.isExpirationUpdate()) { threadId = handleSynchronizeSentExpirationUpdate(message); - } else if (message.getMessage().getStoryContext().isPresent()) { + } else if (dataMessage.getStoryContext().isPresent()) { threadId = handleSynchronizeSentStoryReply(message, content.getTimestamp()); - } else if (message.getMessage().getReaction().isPresent()) { - handleReaction(content, message.getMessage(), senderRecipient); + } else if (dataMessage.getReaction().isPresent()) { + handleReaction(content, dataMessage, senderRecipient); threadId = SignalDatabase.threads().getOrCreateThreadIdFor(getSyncMessageDestination(message)); - } else if (message.getMessage().getRemoteDelete().isPresent()) { - handleRemoteDelete(content, message.getMessage(), senderRecipient); - } else if (message.getMessage().getAttachments().isPresent() || message.getMessage().getQuote().isPresent() || message.getMessage().getPreviews().isPresent() || message.getMessage().getSticker().isPresent() || message.getMessage().isViewOnce() || message.getMessage().getMentions().isPresent()) { + } else if (dataMessage.getRemoteDelete().isPresent()) { + handleRemoteDelete(content, dataMessage, senderRecipient); + } else if (dataMessage.getAttachments().isPresent() || dataMessage.getQuote().isPresent() || dataMessage.getPreviews().isPresent() || dataMessage.getSticker().isPresent() || dataMessage.isViewOnce() || dataMessage.getMentions().isPresent()) { threadId = handleSynchronizeSentMediaMessage(message, content.getTimestamp()); } else { threadId = handleSynchronizeSentTextMessage(message, content.getTimestamp()); } - if (message.getMessage().getGroupContext().isPresent() && groupDatabase.isUnknownGroup(GroupUtil.idFromGroupContext(message.getMessage().getGroupContext().get()))) { - handleUnknownGroupMessage(content, message.getMessage().getGroupContext().get(), senderRecipient); + if (dataMessage.getGroupContext().isPresent() && groupDatabase.isUnknownGroup(GroupUtil.idFromGroupContext(dataMessage.getGroupContext().get()))) { + handleUnknownGroupMessage(content, dataMessage.getGroupContext().get(), senderRecipient); } - if (message.getMessage().getProfileKey().isPresent()) { + if (dataMessage.getProfileKey().isPresent()) { Recipient recipient = getSyncMessageDestination(message); if (recipient != null && !recipient.isSystemContact() && !recipient.isProfileSharing()) { @@ -1275,8 +1284,9 @@ public final class MessageContentProcessor { { log(content.getTimestamp(), "Synchronize sent GV2 update for message with timestamp " + message.getTimestamp()); - SignalServiceGroupV2 signalServiceGroupV2 = message.getMessage().getGroupContext().get().getGroupV2().get(); - GroupId.V2 groupIdV2 = GroupId.v2(signalServiceGroupV2.getMasterKey()); + SignalServiceDataMessage dataMessage = message.getDataMessage().get(); + SignalServiceGroupV2 signalServiceGroupV2 = dataMessage.getGroupContext().get().getGroupV2().get(); + GroupId.V2 groupIdV2 = GroupId.v2(signalServiceGroupV2.getMasterKey()); if (!updateGv2GroupFromServerOrP2PChange(content, signalServiceGroupV2)) { log(String.valueOf(content.getTimestamp()), "Ignoring GV2 message for group we are not currently in " + groupIdV2); @@ -1873,14 +1883,14 @@ public final class MessageContentProcessor { OutgoingExpirationUpdateMessage expirationUpdateMessage = new OutgoingExpirationUpdateMessage(recipient, message.getTimestamp(), - TimeUnit.SECONDS.toMillis(message.getMessage().getExpiresInSeconds())); + TimeUnit.SECONDS.toMillis(message.getDataMessage().get().getExpiresInSeconds())); long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient); long messageId = database.insertMessageOutbox(expirationUpdateMessage, threadId, false, null); database.markAsSent(messageId, true); - SignalDatabase.recipients().setExpireMessages(recipient.getId(), message.getMessage().getExpiresInSeconds()); + SignalDatabase.recipients().setExpireMessages(recipient.getId(), message.getDataMessage().get().getExpiresInSeconds()); return threadId; } @@ -1899,9 +1909,9 @@ public final class MessageContentProcessor { log(envelopeTimestamp, "Synchronize sent story reply for " + message.getTimestamp()); try { - Optional reaction = message.getMessage().getReaction(); + Optional reaction = message.getDataMessage().get().getReaction(); ParentStoryId parentStoryId; - SignalServiceDataMessage.StoryContext storyContext = message.getMessage().getStoryContext().get(); + SignalServiceDataMessage.StoryContext storyContext = message.getDataMessage().get().getStoryContext().get(); MessageDatabase database = SignalDatabase.mms(); Recipient recipient = getSyncMessageDestination(message); QuoteModel quoteModel = null; @@ -1916,10 +1926,10 @@ public final class MessageContentProcessor { if (reaction.isPresent() && EmojiUtil.isEmoji(reaction.get().getEmoji())) { body = reaction.get().getEmoji(); } else { - body = message.getMessage().getBody().orElse(null); + body = message.getDataMessage().get().getBody().orElse(null); } - if (message.getMessage().getGroupContext().isPresent()) { + if (message.getDataMessage().get().getGroupContext().isPresent()) { parentStoryId = new ParentStoryId.GroupReply(storyMessageId.getId()); } else if (groupStory || SignalDatabase.storySends().canReply(storyAuthorRecipient, storyContext.getSentTimestamp())) { parentStoryId = new ParentStoryId.DirectReply(storyMessageId.getId()); @@ -1946,18 +1956,18 @@ public final class MessageContentProcessor { ThreadDatabase.DistributionTypes.DEFAULT, StoryType.NONE, parentStoryId, - message.getMessage().getReaction().isPresent(), + message.getDataMessage().get().getReaction().isPresent(), quoteModel, Collections.emptyList(), Collections.emptyList(), - getMentions(message.getMessage().getMentions()).orElse(Collections.emptyList()), + getMentions(message.getDataMessage().get().getMentions()).orElse(Collections.emptyList()), Collections.emptySet(), Collections.emptySet(), null); mediaMessage = new OutgoingSecureMediaMessage(mediaMessage); - if (recipient.getExpiresInSeconds() != message.getMessage().getExpiresInSeconds()) { + if (recipient.getExpiresInSeconds() != message.getDataMessage().get().getExpiresInSeconds()) { handleSynchronizeSentExpirationUpdate(message); } @@ -1976,13 +1986,13 @@ public final class MessageContentProcessor { database.markAsSent(messageId, true); - if (message.getMessage().getExpiresInSeconds() > 0) { + if (message.getDataMessage().get().getExpiresInSeconds() > 0) { database.markExpireStarted(messageId, message.getExpirationStartTimestamp()); ApplicationDependencies.getExpiringMessageManager() .scheduleDeletion(messageId, true, message.getExpirationStartTimestamp(), - TimeUnit.SECONDS.toMillis(message.getMessage().getExpiresInSeconds())); + TimeUnit.SECONDS.toMillis(message.getDataMessage().get().getExpiresInSeconds())); } if (recipient.isSelf()) { @@ -2003,6 +2013,129 @@ public final class MessageContentProcessor { } } + private void handleSynchronizeSentStoryMessage(@NonNull SentTranscriptMessage message, long envelopeTimestamp) throws MmsException { + log(envelopeTimestamp, "Synchronize sent story message for " + message.getTimestamp()); + + SentStorySyncManifest manifest = SentStorySyncManifest.fromRecipientsSet(message.getStoryMessageRecipients()); + + if (message.isRecipientUpdate()) { + log(envelopeTimestamp, "Processing recipient update for story message and exiting..."); + SignalDatabase.storySends().applySentStoryManifest(manifest, message.getTimestamp()); + return; + } + + SignalServiceStoryMessage storyMessage = message.getStoryMessage().get(); + Set distributionIds = manifest.getDistributionIdSet(); + Optional groupId = storyMessage.getGroupContext().map(it -> GroupId.v2(it.getMasterKey())); + String textStoryBody = storyMessage.getTextAttachment().map(this::serializeTextAttachment).orElse(null); + StoryType storyType = getStoryType(storyMessage); + List linkPreviews = getLinkPreviews(storyMessage.getTextAttachment().flatMap(t -> t.getPreview().map(Collections::singletonList)), + "", + true).orElse(Collections.emptyList()); + List attachments = PointerAttachment.forPointers(storyMessage.getFileAttachment() + .map(SignalServiceAttachment::asPointer) + .map(Collections::singletonList)); + + for (final DistributionId distributionId : distributionIds) { + RecipientId distributionRecipientId = SignalDatabase.distributionLists().getOrCreateByDistributionId(distributionId, manifest); + Recipient distributionListRecipient = Recipient.resolved(distributionRecipientId); + insertSentStoryMessage(message, distributionListRecipient, textStoryBody, attachments, message.getTimestamp(), storyType, linkPreviews); + } + + if (groupId.isPresent()) { + Optional groupRecipient = SignalDatabase.recipients().getByGroupId(groupId.get()); + if (groupRecipient.isPresent()) { + insertSentStoryMessage(message, Recipient.resolved(groupRecipient.get()), textStoryBody, attachments, message.getTimestamp(), storyType, linkPreviews); + } + } + + SignalDatabase.storySends().applySentStoryManifest(manifest, message.getTimestamp()); + } + + private void insertSentStoryMessage(@NonNull SentTranscriptMessage message, + @NonNull Recipient recipient, + @Nullable String textStoryBody, + @NonNull List pendingAttachments, + long sentAtTimestamp, + @NonNull StoryType storyType, + @NonNull List linkPreviews) + throws MmsException + { + OutgoingMediaMessage mediaMessage = new OutgoingMediaMessage(recipient, + textStoryBody, + pendingAttachments, + sentAtTimestamp, + -1, + 0, + false, + ThreadDatabase.DistributionTypes.DEFAULT, + storyType, + null, + false, + null, + Collections.emptyList(), + linkPreviews, + Collections.emptyList(), + Collections.emptySet(), + Collections.emptySet(), + null); + + mediaMessage = new OutgoingSecureMediaMessage(mediaMessage); + + MmsDatabase database = SignalDatabase.mms(); + long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient); + + long messageId; + List attachments; + + database.beginTransaction(); + try { + messageId = database.insertMessageOutbox(mediaMessage, threadId, false, GroupReceiptDatabase.STATUS_UNKNOWN, null); + + if (recipient.isGroup()) { + updateGroupReceiptStatus(message, messageId, recipient.requireGroupId()); + } else { + database.markUnidentified(messageId, isUnidentified(message, recipient)); + } + + database.markAsSent(messageId, true); + + List allAttachments = SignalDatabase.attachments().getAttachmentsForMessage(messageId); + + attachments = Stream.of(allAttachments).filterNot(Attachment::isSticker).toList(); + + if (recipient.isSelf()) { + SyncMessageId id = new SyncMessageId(recipient.getId(), message.getTimestamp()); + SignalDatabase.mmsSms().incrementDeliveryReceiptCount(id, System.currentTimeMillis()); + SignalDatabase.mmsSms().incrementReadReceiptCount(id, System.currentTimeMillis()); + } + + database.setTransactionSuccessful(); + } finally { + database.endTransaction(); + } + + for (DatabaseAttachment attachment : attachments) { + ApplicationDependencies.getJobManager().add(new AttachmentDownloadJob(messageId, attachment.getAttachmentId(), false)); + } + } + + private @NonNull StoryType getStoryType(SignalServiceStoryMessage storyMessage) { + if (storyMessage.getAllowsReplies().orElse(false)) { + if (storyMessage.getTextAttachment().isPresent()) { + return StoryType.TEXT_STORY_WITH_REPLIES; + } else { + return StoryType.STORY_WITH_REPLIES; + } + } else { + if (storyMessage.getTextAttachment().isPresent()) { + return StoryType.TEXT_STORY_WITHOUT_REPLIES; + } else { + return StoryType.STORY_WITHOUT_REPLIES; + } + } + } + private long handleSynchronizeSentMediaMessage(@NonNull SentTranscriptMessage message, long envelopeTimestamp) throws MmsException, BadGroupIdException { @@ -2010,26 +2143,26 @@ public final class MessageContentProcessor { MessageDatabase database = SignalDatabase.mms(); Recipient recipients = getSyncMessageDestination(message); - Optional quote = getValidatedQuote(message.getMessage().getQuote()); - Optional sticker = getStickerAttachment(message.getMessage().getSticker()); - Optional> sharedContacts = getContacts(message.getMessage().getSharedContacts()); - Optional> previews = getLinkPreviews(message.getMessage().getPreviews(), message.getMessage().getBody().orElse(""), false); - Optional> mentions = getMentions(message.getMessage().getMentions()); - Optional giftBadge = getGiftBadge(message.getMessage().getGiftBadge()); - boolean viewOnce = message.getMessage().isViewOnce(); + Optional quote = getValidatedQuote(message.getDataMessage().get().getQuote()); + Optional sticker = getStickerAttachment(message.getDataMessage().get().getSticker()); + Optional> sharedContacts = getContacts(message.getDataMessage().get().getSharedContacts()); + Optional> previews = getLinkPreviews(message.getDataMessage().get().getPreviews(), message.getDataMessage().get().getBody().orElse(""), false); + Optional> mentions = getMentions(message.getDataMessage().get().getMentions()); + Optional giftBadge = getGiftBadge(message.getDataMessage().get().getGiftBadge()); + boolean viewOnce = message.getDataMessage().get().isViewOnce(); List syncAttachments = viewOnce ? Collections.singletonList(new TombstoneAttachment(MediaUtil.VIEW_ONCE, false)) - : PointerAttachment.forPointers(message.getMessage().getAttachments()); + : PointerAttachment.forPointers(message.getDataMessage().get().getAttachments()); if (sticker.isPresent()) { syncAttachments.add(sticker.get()); } OutgoingMediaMessage mediaMessage = new OutgoingMediaMessage(recipients, - message.getMessage().getBody().orElse(null), + message.getDataMessage().get().getBody().orElse(null), syncAttachments, message.getTimestamp(), -1, - TimeUnit.SECONDS.toMillis(message.getMessage().getExpiresInSeconds()), + TimeUnit.SECONDS.toMillis(message.getDataMessage().get().getExpiresInSeconds()), viewOnce, ThreadDatabase.DistributionTypes.DEFAULT, StoryType.NONE, @@ -2045,7 +2178,7 @@ public final class MessageContentProcessor { mediaMessage = new OutgoingSecureMediaMessage(mediaMessage); - if (recipients.getExpiresInSeconds() != message.getMessage().getExpiresInSeconds()) { + if (recipients.getExpiresInSeconds() != message.getDataMessage().get().getExpiresInSeconds()) { handleSynchronizeSentExpirationUpdate(message); } @@ -2072,13 +2205,13 @@ public final class MessageContentProcessor { stickerAttachments = Stream.of(allAttachments).filter(Attachment::isSticker).toList(); attachments = Stream.of(allAttachments).filterNot(Attachment::isSticker).toList(); - if (message.getMessage().getExpiresInSeconds() > 0) { + if (message.getDataMessage().get().getExpiresInSeconds() > 0) { database.markExpireStarted(messageId, message.getExpirationStartTimestamp()); ApplicationDependencies.getExpiringMessageManager() .scheduleDeletion(messageId, true, message.getExpirationStartTimestamp(), - TimeUnit.SECONDS.toMillis(message.getMessage().getExpiresInSeconds())); + TimeUnit.SECONDS.toMillis(message.getDataMessage().get().getExpiresInSeconds())); } if (recipients.isSelf()) { @@ -2204,10 +2337,10 @@ public final class MessageContentProcessor { log(envelopeTimestamp, "Synchronize sent text message for " + message.getTimestamp()); Recipient recipient = getSyncMessageDestination(message); - String body = message.getMessage().getBody().orElse(""); - long expiresInMillis = TimeUnit.SECONDS.toMillis(message.getMessage().getExpiresInSeconds()); + String body = message.getDataMessage().get().getBody().orElse(""); + long expiresInMillis = TimeUnit.SECONDS.toMillis(message.getDataMessage().get().getExpiresInSeconds()); - if (recipient.getExpiresInSeconds() != message.getMessage().getExpiresInSeconds()) { + if (recipient.getExpiresInSeconds() != message.getDataMessage().get().getExpiresInSeconds()) { handleSynchronizeSentExpirationUpdate(message); } @@ -2862,7 +2995,7 @@ public final class MessageContentProcessor { private Recipient getSyncMessageDestination(@NonNull SentTranscriptMessage message) throws BadGroupIdException { - return getGroupRecipient(message.getMessage().getGroupContext()).orElseGet(() -> Recipient.externalPush(message.getDestination().get())); + return getGroupRecipient(message.getDataMessage().get().getGroupContext()).orElseGet(() -> Recipient.externalPush(message.getDestination().get())); } private Recipient getMessageDestination(@NonNull SignalServiceContent content) throws BadGroupIdException { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java index db6c6ae2db..d5f5a312ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/LiveRecipient.java @@ -251,7 +251,7 @@ public final class LiveRecipient { // TODO [stories] We'll have to see what the perf is like for very large distribution lists. We may not be able to support fetching all the members. if (groupRecord != null) { - String title = groupRecord.getName(); + String title = groupRecord.isUnknown() ? null : groupRecord.getName(); List members = Stream.of(groupRecord.getMembers()).filterNot(RecipientId::isUnknown).map(this::fetchAndCacheRecipientFromDisk).toList(); return RecipientDetails.forDistributionList(title, members, record); diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java index fa8e3e45c8..c9e5e5823e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java @@ -77,6 +77,7 @@ import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.util.ParcelUtil; import org.thoughtcrime.securesms.util.SignalLocalMetrics; import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.signalservice.api.push.DistributionId; import org.whispersystems.signalservice.api.util.Preconditions; import java.io.IOException; @@ -86,6 +87,7 @@ import java.util.Collection; import java.util.Collections; import java.util.LinkedList; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; @@ -164,8 +166,9 @@ public class MessageSender { Recipient recipient = message.getRecipient(); if (recipient.isDistributionList()) { - List members = SignalDatabase.distributionLists().getMembers(recipient.requireDistributionListId()); - SignalDatabase.storySends().insert(messageId, members, message.getSentTimeMillis(), message.getStoryType().isStoryWithReplies()); + DistributionId distributionId = Objects.requireNonNull(SignalDatabase.distributionLists().getDistributionId(recipient.requireDistributionListId())); + List members = SignalDatabase.distributionLists().getMembers(recipient.requireDistributionListId()); + SignalDatabase.storySends().insert(messageId, members, message.getSentTimeMillis(), message.getStoryType().isStoryWithReplies(), distributionId); } } @@ -381,8 +384,9 @@ public class MessageSender { Recipient recipient = message.getRecipient(); if (recipient.isDistributionList()) { - List members = SignalDatabase.distributionLists().getMembers(recipient.requireDistributionListId()); - SignalDatabase.storySends().insert(messageId, members, message.getSentTimeMillis(), message.getStoryType().isStoryWithReplies()); + List members = SignalDatabase.distributionLists().getMembers(recipient.requireDistributionListId()); + DistributionId distributionId = Objects.requireNonNull(SignalDatabase.distributionLists().getDistributionId(recipient.requireDistributionListId())); + SignalDatabase.storySends().insert(messageId, members, message.getSentTimeMillis(), message.getStoryType().isStoryWithReplies(), distributionId); } } @@ -471,7 +475,7 @@ public class MessageSender { db.markAsSending(messageId); try { - ApplicationDependencies.getJobManager().add(RemoteDeleteSendJob.create(context, messageId, isMms)); + RemoteDeleteSendJob.create(messageId, isMms).enqueue(); onMessageSent(); } catch (NoSuchMessageException e) { Log.w(TAG, "[sendRemoteDelete] Could not find message! Ignoring."); diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/PrivateStoryItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/PrivateStoryItem.kt index ed55cf8710..83902ef938 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/PrivateStoryItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/PrivateStoryItem.kt @@ -118,7 +118,7 @@ object PrivateStoryItem { override fun bind(model: PartialModel) { itemView.setOnClickListener { model.onClick(model) } - label.text = model.privateStoryItemData.name + label.text = if (model.privateStoryItemData.isUnknown) context.getString(R.string.MessageRecord_unknown) else model.privateStoryItemData.name } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java index 9eecc506f1..ac20cdce6f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java @@ -51,9 +51,10 @@ public final class GroupUtil { return content.getDataMessage().get().getGroupContext().get(); } else if (content.getSyncMessage().isPresent() && content.getSyncMessage().get().getSent().isPresent() && - content.getSyncMessage().get().getSent().get().getMessage().getGroupContext().isPresent()) + content.getSyncMessage().get().getSent().get().getDataMessage().isPresent() && + content.getSyncMessage().get().getSent().get().getDataMessage().get().getGroupContext().isPresent()) { - return content.getSyncMessage().get().getSent().get().getMessage().getGroupContext().get(); + return content.getSyncMessage().get().getSent().get().getDataMessage().get().getGroupContext().get(); } else if (content.getStoryMessage().isPresent() && content.getStoryMessage().get().getGroupContext().isPresent()) { try { return SignalServiceGroupContext.create(null, content.getStoryMessage().get().getGroupContext().get()); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index 0485a3a0f8..687fa86ccd 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -42,6 +42,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; import org.whispersystems.signalservice.api.messages.SignalServicePreview; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessageRecipient; import org.whispersystems.signalservice.api.messages.SignalServiceTextAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; import org.whispersystems.signalservice.api.messages.calls.AnswerMessage; @@ -252,32 +253,43 @@ public class SignalServiceMessageSender { sendGroupMessage(distributionId, recipients, unidentifiedAccess, message.getTimestamp(), content, ContentHint.IMPLICIT, message.getGroupId(), true, SenderKeyGroupEvents.EMPTY); } - public List sendStory(List recipients, - List> unidentifiedAccess, - SignalServiceStoryMessage message, - long timestamp) + public List sendStory(List recipients, + List> unidentifiedAccess, + SignalServiceStoryMessage message, + long timestamp, + Set manifest) throws IOException, UntrustedIdentityException { - Content content = createStoryContent(message); - EnvelopeContent envelopeContent = EnvelopeContent.encrypted(content, ContentHint.RESENDABLE, Optional.empty()); + Content content = createStoryContent(message); + EnvelopeContent envelopeContent = EnvelopeContent.encrypted(content, ContentHint.RESENDABLE, Optional.empty()); + List sendMessageResults = sendMessage(recipients, getTargetUnidentifiedAccess(unidentifiedAccess), timestamp, envelopeContent, false, null, null); + SignalServiceSyncMessage syncMessage = createSelfSendSyncMessageForStory(message, timestamp, manifest); - return sendMessage(recipients, getTargetUnidentifiedAccess(unidentifiedAccess), timestamp, envelopeContent, false, null, null); + sendSyncMessage(syncMessage, Optional.empty()); + + return sendMessageResults; } /** * Send a typing indicator to a group using sender key. Doesn't bother with return results, since these are best-effort. * @return */ - public List sendGroupStory(DistributionId distributionId, - Optional groupId, - List recipients, - List unidentifiedAccess, - SignalServiceStoryMessage message, - long timestamp) + public List sendGroupStory(DistributionId distributionId, + Optional groupId, + List recipients, + List unidentifiedAccess, + SignalServiceStoryMessage message, + long timestamp, + Set manifest) throws IOException, UntrustedIdentityException, InvalidKeyException, NoSessionException, InvalidRegistrationIdException { - Content content = createStoryContent(message); - return sendGroupMessage(distributionId, recipients, unidentifiedAccess, timestamp, content, ContentHint.RESENDABLE, groupId, false, SenderKeyGroupEvents.EMPTY); + Content content = createStoryContent(message); + List sendMessageResults = sendGroupMessage(distributionId, recipients, unidentifiedAccess, timestamp, content, ContentHint.RESENDABLE, groupId, false, SenderKeyGroupEvents.EMPTY); + SignalServiceSyncMessage syncMessage = createSelfSendSyncMessageForStory(message, timestamp, manifest); + + sendSyncMessage(syncMessage, Optional.empty()); + + return sendMessageResults; } @@ -362,7 +374,7 @@ public class SignalServiceMessageSender { sendEvents.onMessageSent(); if (result.getSuccess() != null && result.getSuccess().isNeedsSync()) { - Content syncMessage = createMultiDeviceSentTranscriptContent(content, Optional.of(recipient), timestamp, Collections.singletonList(result), false); + Content syncMessage = createMultiDeviceSentTranscriptContent(content, Optional.of(recipient), timestamp, Collections.singletonList(result), false, Collections.emptySet()); EnvelopeContent syncMessageContent = EnvelopeContent.encrypted(syncMessage, ContentHint.IMPLICIT, Optional.empty()); sendMessage(localAddress, Optional.empty(), timestamp, syncMessageContent, false, null); @@ -446,7 +458,7 @@ public class SignalServiceMessageSender { sendEvents.onMessageSent(); if (store.isMultiDevice()) { - Content syncMessage = createMultiDeviceSentTranscriptContent(content, Optional.empty(), message.getTimestamp(), results, isRecipientUpdate); + Content syncMessage = createMultiDeviceSentTranscriptContent(content, Optional.empty(), message.getTimestamp(), results, isRecipientUpdate, Collections.emptySet()); EnvelopeContent syncMessageContent = EnvelopeContent.encrypted(syncMessage, ContentHint.IMPLICIT, Optional.empty()); sendMessage(localAddress, Optional.empty(), message.getTimestamp(), syncMessageContent, false, null); @@ -496,7 +508,7 @@ public class SignalServiceMessageSender { recipient = Optional.of(recipients.get(0)); } - Content syncMessage = createMultiDeviceSentTranscriptContent(content, recipient, timestamp, results, isRecipientUpdate); + Content syncMessage = createMultiDeviceSentTranscriptContent(content, recipient, timestamp, results, isRecipientUpdate, Collections.emptySet()); EnvelopeContent syncMessageContent = EnvelopeContent.encrypted(syncMessage, ContentHint.IMPLICIT, Optional.empty()); sendMessage(localAddress, Optional.empty(), timestamp, syncMessageContent, false, null); @@ -791,6 +803,16 @@ public class SignalServiceMessageSender { return container.setReceiptMessage(builder).build(); } + private Content createMessageContent(SentTranscriptMessage transcriptMessage) throws IOException { + if (transcriptMessage.getStoryMessage().isPresent()) { + return createStoryContent(transcriptMessage.getStoryMessage().get()); + } else if (transcriptMessage.getDataMessage().isPresent()) { + return createMessageContent(transcriptMessage.getDataMessage().get()); + } else { + return null; + } + } + private Content createMessageContent(SignalServiceDataMessage message) throws IOException { Content.Builder container = Content.newBuilder(); DataMessage.Builder builder = DataMessage.newBuilder(); @@ -1108,27 +1130,30 @@ public class SignalServiceMessageSender { private Content createMultiDeviceSentTranscriptContent(SentTranscriptMessage transcript, boolean unidentifiedAccess) throws IOException { SignalServiceAddress address = transcript.getDestination().get(); - Content content = createMessageContent(transcript.getMessage()); - SendMessageResult result = SendMessageResult.success(address, Collections.emptyList(), unidentifiedAccess, true, -1, Optional.of(content)); + Content content = createMessageContent(transcript); + SendMessageResult result = SendMessageResult.success(address, Collections.emptyList(), unidentifiedAccess, true, -1, Optional.ofNullable(content)); + return createMultiDeviceSentTranscriptContent(content, Optional.of(address), transcript.getTimestamp(), Collections.singletonList(result), - false); + transcript.isRecipientUpdate(), + transcript.getStoryMessageRecipients()); } private Content createMultiDeviceSentTranscriptContent(Content content, Optional recipient, long timestamp, List sendMessageResults, - boolean isRecipientUpdate) + boolean isRecipientUpdate, + Set storyMessageRecipients) { - Content.Builder container = Content.newBuilder(); - SyncMessage.Builder syncMessage = createSyncMessageBuilder(); - SyncMessage.Sent.Builder sentMessage = SyncMessage.Sent.newBuilder(); - DataMessage dataMessage = content.getDataMessage(); + Content.Builder container = Content.newBuilder(); + SyncMessage.Builder syncMessage = createSyncMessageBuilder(); + SyncMessage.Sent.Builder sentMessage = SyncMessage.Sent.newBuilder(); + DataMessage dataMessage = content != null && content.hasDataMessage() ? content.getDataMessage() : null; + StoryMessage storyMessage = content != null && content.hasStoryMessage() ? content.getStoryMessage() : null; sentMessage.setTimestamp(timestamp); - sentMessage.setMessage(dataMessage); for (SendMessageResult result : sendMessageResults) { if (result.getSuccess() != null) { @@ -1144,19 +1169,38 @@ public class SignalServiceMessageSender { sentMessage.setDestinationUuid(recipient.get().getServiceId().toString()); } - if (dataMessage.getExpireTimer() > 0) { - sentMessage.setExpirationStartTimestamp(System.currentTimeMillis()); + if (dataMessage != null) { + sentMessage.setMessage(dataMessage); + if (dataMessage.getExpireTimer() > 0) { + sentMessage.setExpirationStartTimestamp(System.currentTimeMillis()); + } + + if (dataMessage.getIsViewOnce()) { + dataMessage = dataMessage.toBuilder().clearAttachments().build(); + sentMessage.setMessage(dataMessage); + } } - if (dataMessage.getIsViewOnce()) { - dataMessage = dataMessage.toBuilder().clearAttachments().build(); - sentMessage.setMessage(dataMessage); + if (storyMessage != null) { + sentMessage.setStoryMessage(storyMessage); } + sentMessage.addAllStoryMessageRecipients(storyMessageRecipients.stream() + .map(this::createStoryMessageRecipient) + .collect(Collectors.toSet())); + sentMessage.setIsRecipientUpdate(isRecipientUpdate); return container.setSyncMessage(syncMessage.setSent(sentMessage)).build(); } + + private SyncMessage.Sent.StoryMessageRecipient createStoryMessageRecipient(SignalServiceStoryMessageRecipient storyMessageRecipient) { + return SyncMessage.Sent.StoryMessageRecipient.newBuilder() + .addAllDistributionListIds(storyMessageRecipient.getDistributionListIds()) + .setDestinationUuid(storyMessageRecipient.getSignalServiceAddress().getIdentifier()) + .setIsAllowedToReply(storyMessageRecipient.isAllowedToReply()) + .build(); + } private Content createMultiDeviceReadContent(List readMessages) { Content.Builder container = Content.newBuilder(); @@ -1580,13 +1624,28 @@ public class SignalServiceMessageSender { return results; } + private SignalServiceSyncMessage createSelfSendSyncMessageForStory(SignalServiceStoryMessage message, long sentTimestamp, Set manifest) { + SentTranscriptMessage transcript = new SentTranscriptMessage(Optional.of(localAddress), + sentTimestamp, + Optional.empty(), + 0, + Collections.singletonMap(localAddress, false), + false, + Optional.of(message), + manifest); + + return SignalServiceSyncMessage.forSentTranscript(transcript); + } + private SignalServiceSyncMessage createSelfSendSyncMessage(SignalServiceDataMessage message) { SentTranscriptMessage transcript = new SentTranscriptMessage(Optional.of(localAddress), message.getTimestamp(), - message, + Optional.of(message), message.getExpiresInSeconds(), Collections.singletonMap(localAddress, false), - false); + false, + Optional.empty(), + Collections.emptySet()); return SignalServiceSyncMessage.forSentTranscript(transcript); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java index 4ba252fdd0..74192472cc 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java @@ -58,11 +58,14 @@ import org.whispersystems.signalservice.internal.serialize.SignalServiceMetadata import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; public final class SignalServiceContent { @@ -674,14 +677,21 @@ public final class SignalServiceContent { throws ProtocolInvalidKeyException, UnsupportedDataMessageException, InvalidMessageStructureException { if (content.hasSent()) { - Map unidentifiedStatuses = new HashMap<>(); - SignalServiceProtos.SyncMessage.Sent sentContent = content.getSent(); - SignalServiceDataMessage dataMessage = createSignalServiceMessage(metadata, sentContent.getMessage()); - Optional address = SignalServiceAddress.isValidAddress(sentContent.getDestinationUuid()) - ? Optional.of(new SignalServiceAddress(ServiceId.parseOrThrow(sentContent.getDestinationUuid()))) - : Optional.empty(); + Map unidentifiedStatuses = new HashMap<>(); + SignalServiceProtos.SyncMessage.Sent sentContent = content.getSent(); + Optional dataMessage = sentContent.hasMessage() ? Optional.of(createSignalServiceMessage(metadata, sentContent.getMessage())) : Optional.empty(); + Optional storyMessage = sentContent.hasStoryMessage() ? Optional.of(createStoryMessage(sentContent.getStoryMessage())) : Optional.empty(); + Optional address = SignalServiceAddress.isValidAddress(sentContent.getDestinationUuid()) + ? Optional.of(new SignalServiceAddress(ServiceId.parseOrThrow(sentContent.getDestinationUuid()))) + : Optional.empty(); + Set recipientManifest = sentContent.getStoryMessageRecipientsList() + .stream() + .map(SignalServiceContent::createSignalServiceStoryMessageRecipient) + .collect(Collectors.toSet()); - if (!address.isPresent() && !dataMessage.getGroupContext().isPresent()) { + if (!address.isPresent() && + !dataMessage.flatMap(SignalServiceDataMessage::getGroupContext).isPresent() && + !storyMessage.flatMap(SignalServiceStoryMessage::getGroupContext).isPresent()) { throw new InvalidMessageStructureException("SyncMessage missing both destination and group ID!"); } @@ -699,7 +709,9 @@ public final class SignalServiceContent { dataMessage, sentContent.getExpirationStartTimestamp(), unidentifiedStatuses, - sentContent.getIsRecipientUpdate())); + sentContent.getIsRecipientUpdate(), + storyMessage, + recipientManifest)); } if (content.hasRequest()) { @@ -913,6 +925,14 @@ public final class SignalServiceContent { return SignalServiceSyncMessage.empty(); } + private static SignalServiceStoryMessageRecipient createSignalServiceStoryMessageRecipient(SignalServiceProtos.SyncMessage.Sent.StoryMessageRecipient storyMessageRecipient) { + return new SignalServiceStoryMessageRecipient( + new SignalServiceAddress(ServiceId.parseOrThrow(storyMessageRecipient.getDestinationUuid())), + storyMessageRecipient.getDistributionListIdsList(), + storyMessageRecipient.getIsAllowedToReply() + ); + } + private static SignalServiceCallMessage createCallMessage(SignalServiceProtos.CallMessage content) { boolean isMultiRing = content.getMultiRing(); Integer destinationDeviceId = content.hasDestinationDeviceId() ? content.getDestinationDeviceId() : null; diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceStoryMessageRecipient.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceStoryMessageRecipient.java new file mode 100644 index 0000000000..3c5b94292e --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceStoryMessageRecipient.java @@ -0,0 +1,33 @@ +package org.whispersystems.signalservice.api.messages; + +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.util.List; + +public class SignalServiceStoryMessageRecipient { + + private final SignalServiceAddress signalServiceAddress; + private final List distributionListIds; + private final boolean isAllowedToReply; + + public SignalServiceStoryMessageRecipient(SignalServiceAddress signalServiceAddress, + List distributionListIds, + boolean isAllowedToReply) + { + this.signalServiceAddress = signalServiceAddress; + this.distributionListIds = distributionListIds; + this.isAllowedToReply = isAllowedToReply; + } + + public List getDistributionListIds() { + return distributionListIds; + } + + public SignalServiceAddress getSignalServiceAddress() { + return signalServiceAddress; + } + + public boolean isAllowedToReply() { + return isAllowedToReply; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SentTranscriptMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SentTranscriptMessage.java index adaf91fb7a..a1b741f24c 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SentTranscriptMessage.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/SentTranscriptMessage.java @@ -8,6 +8,8 @@ package org.whispersystems.signalservice.api.messages.multidevice; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessageRecipient; import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.push.SignalServiceAddress; @@ -18,21 +20,25 @@ import java.util.Set; public class SentTranscriptMessage { - private final Optional destination; - private final long timestamp; - private final long expirationStartTimestamp; - private final SignalServiceDataMessage message; - private final Map unidentifiedStatusBySid; - private final Map unidentifiedStatusByE164; - private final Set recipients; - private final boolean isRecipientUpdate; + private final Optional destination; + private final long timestamp; + private final long expirationStartTimestamp; + private final Optional message; + private final Map unidentifiedStatusBySid; + private final Map unidentifiedStatusByE164; + private final Set recipients; + private final boolean isRecipientUpdate; + private final Optional storyMessage; + private final Set storyMessageRecipients; public SentTranscriptMessage(Optional destination, long timestamp, - SignalServiceDataMessage message, + Optional message, long expirationStartTimestamp, Map unidentifiedStatus, - boolean isRecipientUpdate) + boolean isRecipientUpdate, + Optional storyMessage, + Set storyMessageRecipients) { this.destination = destination; this.timestamp = timestamp; @@ -42,6 +48,8 @@ public class SentTranscriptMessage { this.unidentifiedStatusByE164 = new HashMap<>(); this.recipients = unidentifiedStatus.keySet(); this.isRecipientUpdate = isRecipientUpdate; + this.storyMessage = storyMessage; + this.storyMessageRecipients = storyMessageRecipients; for (Map.Entry entry : unidentifiedStatus.entrySet()) { unidentifiedStatusBySid.put(entry.getKey().getServiceId().toString(), entry.getValue()); @@ -64,10 +72,18 @@ public class SentTranscriptMessage { return expirationStartTimestamp; } - public SignalServiceDataMessage getMessage() { + public Optional getDataMessage() { return message; } + public Optional getStoryMessage() { + return storyMessage; + } + + public Set getStoryMessageRecipients() { + return storyMessageRecipients; + } + public boolean isUnidentified(ServiceId serviceId) { return isUnidentified(serviceId.toString()); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/DistributionId.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/DistributionId.java index 4a4544917e..7673323a04 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/DistributionId.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/DistributionId.java @@ -2,6 +2,7 @@ package org.whispersystems.signalservice.api.push; import org.whispersystems.signalservice.api.util.UuidUtil; +import java.util.Objects; import java.util.UUID; /** @@ -40,4 +41,17 @@ public final class DistributionId { public String toString() { return uuid.toString(); } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final DistributionId that = (DistributionId) o; + return Objects.equals(uuid, that.uuid); + } + + @Override + public int hashCode() { + return Objects.hash(uuid); + } } diff --git a/libsignal/service/src/main/proto/SignalService.proto b/libsignal/service/src/main/proto/SignalService.proto index 6b5562dc6a..d692a6c606 100644 --- a/libsignal/service/src/main/proto/SignalService.proto +++ b/libsignal/service/src/main/proto/SignalService.proto @@ -427,6 +427,12 @@ message SyncMessage { optional bool unidentified = 2; } + message StoryMessageRecipient { + optional string destinationUuid = 1; + repeated string distributionListIds = 2; + optional bool isAllowedToReply = 3; + } + reserved /*destinationE164*/ 1; optional string destinationUuid = 7; optional uint64 timestamp = 2; @@ -434,6 +440,8 @@ message SyncMessage { optional uint64 expirationStartTimestamp = 4; repeated UnidentifiedDeliveryStatus unidentifiedStatus = 5; optional bool isRecipientUpdate = 6 [default = false]; + optional StoryMessage storyMessage = 8; + repeated StoryMessageRecipient storyMessageRecipients = 9; } message Contacts {