Add sent story syncing.

This commit is contained in:
Alex Hart 2022-05-10 11:01:51 -03:00
parent 8ca0f4baf4
commit af9465fefe
29 changed files with 1311 additions and 236 deletions

View file

@ -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<RecipientId>
private lateinit var recipients11to20: List<RecipientId>
private lateinit var recipients6to15: List<RecipientId>
@ -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<RecipientId> {
return (1..count).map {
SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID()))

View file

@ -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<Cursor> 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<Cursor> 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<DistributionListPartialRecord> 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;
}

View file

@ -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));
}

View file

@ -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<String> = 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<DistributionListPartialRecord> {
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<RecipientId> = 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()}"
}
}

View file

@ -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<StoryResult> getOrderedStoryRecipientsAndIds();
public abstract @NonNull Reader getAllStoriesFor(@NonNull RecipientId recipientId);
public abstract @NonNull MessageId getStoryId(@NonNull RecipientId authorId, long sentTimestamp) throws NoSuchMessageException;

View file

@ -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();
}
}

View file

@ -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<Entry>
) {
/**
* Represents an entry in the proto manifest.
*/
data class Entry(
val recipientId: RecipientId,
val allowedToReply: Boolean = false,
val distributionLists: List<DistributionId> = 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<DistributionId> {
return entries.map { it.distributionLists }.flatten().toSet()
}
fun toRecipientsSet(): Set<SignalServiceStoryMessageRecipient> {
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<DistributionId, Long>): Set<Row> {
return entries.flatMap { getRowsForEntry(it, distributionIdToMessageIdMap) }.toSet()
}
private fun getRowsForEntry(entry: Entry, distributionIdToMessageIdMap: Map<DistributionId, Long>): List<Row> {
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<SignalServiceStoryMessageRecipient>): 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)
}
}
}

View file

@ -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<StoryResult> getOrderedStoryRecipientsAndIds() {
throw new UnsupportedOperationException();

View file

@ -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<RecipientId>, sentTimestamp: Long, allowsReplies: Boolean) {
fun insert(messageId: Long, recipientIds: Collection<RecipientId>, 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<RecipientId>): SentStorySyncManifest {
val localManifest: SentStorySyncManifest = getLocalManifest(sentTimestamp)
val entries: List<SentStorySyncManifest.Entry> = 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<RecipientId> {
// 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<RecipientId>()
val results: MutableSet<RecipientId> = 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<DistributionId, Long> = 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<SentStorySyncManifest.Row> = localManifest.flattenToRows(distributionIdToMessageId)
val remoteRows: Set<SentStorySyncManifest.Row> = remoteManifest.flattenToRows(distributionIdToMessageId)
if (localRows == remoteRows) {
return
}
val remoteOnly: List<SentStorySyncManifest.Row> = remoteRows.filterNot { localRows.contains(it) }
val changedInRemoteManifest: List<SentStorySyncManifest.Row> = remoteOnly.filter { (recipientId, messageId) -> localRows.any { it.messageId == messageId && it.recipientId == recipientId } }
val newInRemoteManifest: List<SentStorySyncManifest.Row> = 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<RecipientId, SentStorySyncManifest.Entry> = 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())
}
}

View file

@ -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;

View file

@ -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

View file

@ -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
)

View file

@ -12,5 +12,6 @@ data class DistributionListRecord(
val distributionId: DistributionId,
val allowsReplies: Boolean,
val members: List<RecipientId>,
val deletedAtTimestamp: Long
val deletedAtTimestamp: Long,
val isUnknown: Boolean
)

View file

@ -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());

View file

@ -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<SignalServiceStoryMessageRecipient>): 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<MultiDeviceStorySendSyncJob> {
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)
)
}
}
}

View file

@ -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<SignalServiceStoryMessageRecipient> 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);
}

View file

@ -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<RecipientId> 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,

View file

@ -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<SignalServiceStoryMessageRecipient> 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<SignalServiceStoryMessageRecipient> 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<SignalServiceStoryMessageRecipient> 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

View file

@ -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<SignalServiceDataMessage.Reaction> reaction = message.getMessage().getReaction();
Optional<SignalServiceDataMessage.Reaction> 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<DistributionId> distributionIds = manifest.getDistributionIdSet();
Optional<GroupId> groupId = storyMessage.getGroupContext().map(it -> GroupId.v2(it.getMasterKey()));
String textStoryBody = storyMessage.getTextAttachment().map(this::serializeTextAttachment).orElse(null);
StoryType storyType = getStoryType(storyMessage);
List<LinkPreview> linkPreviews = getLinkPreviews(storyMessage.getTextAttachment().flatMap(t -> t.getPreview().map(Collections::singletonList)),
"",
true).orElse(Collections.emptyList());
List<Attachment> 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<RecipientId> 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<Attachment> pendingAttachments,
long sentAtTimestamp,
@NonNull StoryType storyType,
@NonNull List<LinkPreview> 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<DatabaseAttachment> 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<DatabaseAttachment> 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<QuoteModel> quote = getValidatedQuote(message.getMessage().getQuote());
Optional<Attachment> sticker = getStickerAttachment(message.getMessage().getSticker());
Optional<List<Contact>> sharedContacts = getContacts(message.getMessage().getSharedContacts());
Optional<List<LinkPreview>> previews = getLinkPreviews(message.getMessage().getPreviews(), message.getMessage().getBody().orElse(""), false);
Optional<List<Mention>> mentions = getMentions(message.getMessage().getMentions());
Optional<GiftBadge> giftBadge = getGiftBadge(message.getMessage().getGiftBadge());
boolean viewOnce = message.getMessage().isViewOnce();
Optional<QuoteModel> quote = getValidatedQuote(message.getDataMessage().get().getQuote());
Optional<Attachment> sticker = getStickerAttachment(message.getDataMessage().get().getSticker());
Optional<List<Contact>> sharedContacts = getContacts(message.getDataMessage().get().getSharedContacts());
Optional<List<LinkPreview>> previews = getLinkPreviews(message.getDataMessage().get().getPreviews(), message.getDataMessage().get().getBody().orElse(""), false);
Optional<List<Mention>> mentions = getMentions(message.getDataMessage().get().getMentions());
Optional<GiftBadge> giftBadge = getGiftBadge(message.getDataMessage().get().getGiftBadge());
boolean viewOnce = message.getDataMessage().get().isViewOnce();
List<Attachment> 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 {

View file

@ -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<Recipient> members = Stream.of(groupRecord.getMembers()).filterNot(RecipientId::isUnknown).map(this::fetchAndCacheRecipientFromDisk).toList();
return RecipientDetails.forDistributionList(title, members, record);

View file

@ -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<RecipientId> 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<RecipientId> 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<RecipientId> members = SignalDatabase.distributionLists().getMembers(recipient.requireDistributionListId());
SignalDatabase.storySends().insert(messageId, members, message.getSentTimeMillis(), message.getStoryType().isStoryWithReplies());
List<RecipientId> 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.");

View file

@ -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
}
}
}

View file

@ -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());

View file

@ -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<SendMessageResult> sendStory(List<SignalServiceAddress> recipients,
List<Optional<UnidentifiedAccessPair>> unidentifiedAccess,
SignalServiceStoryMessage message,
long timestamp)
public List<SendMessageResult> sendStory(List<SignalServiceAddress> recipients,
List<Optional<UnidentifiedAccessPair>> unidentifiedAccess,
SignalServiceStoryMessage message,
long timestamp,
Set<SignalServiceStoryMessageRecipient> 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<SendMessageResult> 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<SendMessageResult> sendGroupStory(DistributionId distributionId,
Optional<byte[]> groupId,
List<SignalServiceAddress> recipients,
List<UnidentifiedAccess> unidentifiedAccess,
SignalServiceStoryMessage message,
long timestamp)
public List<SendMessageResult> sendGroupStory(DistributionId distributionId,
Optional<byte[]> groupId,
List<SignalServiceAddress> recipients,
List<UnidentifiedAccess> unidentifiedAccess,
SignalServiceStoryMessage message,
long timestamp,
Set<SignalServiceStoryMessageRecipient> 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<SendMessageResult> 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<SignalServiceAddress> recipient,
long timestamp, List<SendMessageResult> sendMessageResults,
boolean isRecipientUpdate)
boolean isRecipientUpdate,
Set<SignalServiceStoryMessageRecipient> 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<ReadMessage> readMessages) {
Content.Builder container = Content.newBuilder();
@ -1580,13 +1624,28 @@ public class SignalServiceMessageSender {
return results;
}
private SignalServiceSyncMessage createSelfSendSyncMessageForStory(SignalServiceStoryMessage message, long sentTimestamp, Set<SignalServiceStoryMessageRecipient> 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);
}

View file

@ -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<SignalServiceAddress, Boolean> unidentifiedStatuses = new HashMap<>();
SignalServiceProtos.SyncMessage.Sent sentContent = content.getSent();
SignalServiceDataMessage dataMessage = createSignalServiceMessage(metadata, sentContent.getMessage());
Optional<SignalServiceAddress> address = SignalServiceAddress.isValidAddress(sentContent.getDestinationUuid())
? Optional.of(new SignalServiceAddress(ServiceId.parseOrThrow(sentContent.getDestinationUuid())))
: Optional.empty();
Map<SignalServiceAddress, Boolean> unidentifiedStatuses = new HashMap<>();
SignalServiceProtos.SyncMessage.Sent sentContent = content.getSent();
Optional<SignalServiceDataMessage> dataMessage = sentContent.hasMessage() ? Optional.of(createSignalServiceMessage(metadata, sentContent.getMessage())) : Optional.empty();
Optional<SignalServiceStoryMessage> storyMessage = sentContent.hasStoryMessage() ? Optional.of(createStoryMessage(sentContent.getStoryMessage())) : Optional.empty();
Optional<SignalServiceAddress> address = SignalServiceAddress.isValidAddress(sentContent.getDestinationUuid())
? Optional.of(new SignalServiceAddress(ServiceId.parseOrThrow(sentContent.getDestinationUuid())))
: Optional.empty();
Set<SignalServiceStoryMessageRecipient> 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;

View file

@ -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<String> distributionListIds;
private final boolean isAllowedToReply;
public SignalServiceStoryMessageRecipient(SignalServiceAddress signalServiceAddress,
List<String> distributionListIds,
boolean isAllowedToReply)
{
this.signalServiceAddress = signalServiceAddress;
this.distributionListIds = distributionListIds;
this.isAllowedToReply = isAllowedToReply;
}
public List<String> getDistributionListIds() {
return distributionListIds;
}
public SignalServiceAddress getSignalServiceAddress() {
return signalServiceAddress;
}
public boolean isAllowedToReply() {
return isAllowedToReply;
}
}

View file

@ -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<SignalServiceAddress> destination;
private final long timestamp;
private final long expirationStartTimestamp;
private final SignalServiceDataMessage message;
private final Map<String, Boolean> unidentifiedStatusBySid;
private final Map<String, Boolean> unidentifiedStatusByE164;
private final Set<SignalServiceAddress> recipients;
private final boolean isRecipientUpdate;
private final Optional<SignalServiceAddress> destination;
private final long timestamp;
private final long expirationStartTimestamp;
private final Optional<SignalServiceDataMessage> message;
private final Map<String, Boolean> unidentifiedStatusBySid;
private final Map<String, Boolean> unidentifiedStatusByE164;
private final Set<SignalServiceAddress> recipients;
private final boolean isRecipientUpdate;
private final Optional<SignalServiceStoryMessage> storyMessage;
private final Set<SignalServiceStoryMessageRecipient> storyMessageRecipients;
public SentTranscriptMessage(Optional<SignalServiceAddress> destination,
long timestamp,
SignalServiceDataMessage message,
Optional<SignalServiceDataMessage> message,
long expirationStartTimestamp,
Map<SignalServiceAddress, Boolean> unidentifiedStatus,
boolean isRecipientUpdate)
boolean isRecipientUpdate,
Optional<SignalServiceStoryMessage> storyMessage,
Set<SignalServiceStoryMessageRecipient> 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<SignalServiceAddress, Boolean> 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<SignalServiceDataMessage> getDataMessage() {
return message;
}
public Optional<SignalServiceStoryMessage> getStoryMessage() {
return storyMessage;
}
public Set<SignalServiceStoryMessageRecipient> getStoryMessageRecipients() {
return storyMessageRecipients;
}
public boolean isUnidentified(ServiceId serviceId) {
return isUnidentified(serviceId.toString());
}

View file

@ -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);
}
}

View file

@ -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 {