From 2f5cb5f0902f0ec14b1f4e2dc655b98d0fc1de02 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Mon, 28 Mar 2022 19:43:42 -0400 Subject: [PATCH] Add story distribution list deduplication handling. --- .../securesms/database/MmsHelper.kt | 55 +++++ .../database/StorySendsDatabaseTest.kt | 192 ++++++++++++++++++ .../securesms/database/MmsDatabase.java | 31 ++- .../securesms/database/RecipientDatabase.kt | 4 + .../securesms/database/SignalDatabase.kt | 8 + .../securesms/database/StorySendsDatabase.kt | 180 ++++++++++++++++ .../helpers/SignalDatabaseMigrations.kt | 19 +- .../jobs/PushDistributionListSendJob.java | 2 +- .../securesms/jobs/RemoteDeleteSendJob.java | 8 +- .../mediasend/v2/MediaSelectionRepository.kt | 5 +- .../text/send/TextStoryPostSendRepository.kt | 8 +- .../messages/MessageContentProcessor.java | 12 +- .../securesms/sms/MessageSender.java | 28 ++- .../thoughtcrime/securesms/stories/Stories.kt | 27 +-- .../securesms/stories/my/MyStoriesItem.kt | 4 +- .../securesms/SpinnerApplicationContext.kt | 3 +- .../securesms/database/IsStoryTransformer.kt | 17 ++ .../securesms/database/TestMms.kt | 19 +- 18 files changed, 565 insertions(+), 57 deletions(-) create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/database/MmsHelper.kt create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/database/StorySendsDatabaseTest.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/StorySendsDatabase.kt create mode 100644 app/src/spinner/java/org/thoughtcrime/securesms/database/IsStoryTransformer.kt diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/MmsHelper.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/MmsHelper.kt new file mode 100644 index 000000000..ae85eee77 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/MmsHelper.kt @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.database + +import org.thoughtcrime.securesms.database.model.StoryType +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage +import org.thoughtcrime.securesms.recipients.Recipient + +/** + * Helper methods for inserting an MMS message into the MMS table. + */ +object MmsHelper { + + fun insert( + recipient: Recipient = Recipient.UNKNOWN, + body: String = "body", + sentTimeMillis: Long = System.currentTimeMillis(), + subscriptionId: Int = -1, + expiresIn: Long = 0, + viewOnce: Boolean = false, + distributionType: Int = ThreadDatabase.DistributionTypes.DEFAULT, + threadId: Long = 1, + storyType: StoryType = StoryType.NONE + ): Long { + val message = OutgoingMediaMessage( + recipient, + body, + emptyList(), + sentTimeMillis, + subscriptionId, + expiresIn, + viewOnce, + distributionType, + storyType, + null, + false, + null, + emptyList(), + emptyList(), + emptyList(), + emptySet(), + emptySet() + ) + + return insert( + message = message, + threadId = threadId, + ) + } + + fun insert( + message: OutgoingMediaMessage, + threadId: Long + ): Long { + return SignalDatabase.mms.insertMessageOutbox(message, threadId, false, GroupReceiptDatabase.STATUS_UNKNOWN, null) + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/StorySendsDatabaseTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/StorySendsDatabaseTest.kt new file mode 100644 index 000000000..3a7ffa77e --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/StorySendsDatabaseTest.kt @@ -0,0 +1,192 @@ +package org.thoughtcrime.securesms.database + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.containsInAnyOrder +import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.`is` +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.thoughtcrime.securesms.database.model.StoryType +import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.api.push.ServiceId +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class StorySendsDatabaseTest { + + private lateinit var recipients1to10: List + private lateinit var recipients11to20: List + private lateinit var recipients6to15: List + private lateinit var recipients6to10: List + + private var messageId1: Long = 0 + private var messageId2: Long = 0 + private var messageId3: Long = 0 + + private lateinit var storySends: StorySendsDatabase + + @Before + 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) + + 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) + + val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, false) + val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 200, true) + + assertThat(recipientIdsForMessage1, hasSize(10)) + assertThat(recipientIdsForMessage1, containsInAnyOrder(*recipients1to10.toTypedArray())) + + assertThat(recipientIdsForMessage2, hasSize(10)) + assertThat(recipientIdsForMessage2, containsInAnyOrder(*recipients11to20.toTypedArray())) + } + + @Test + fun getRecipientsToSendTo_overlap() { + storySends.insert(messageId1, recipients1to10, 100, false) + storySends.insert(messageId2, recipients6to15, 100, true) + + val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, false) + val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 100, true) + + assertThat(recipientIdsForMessage1, hasSize(5)) + assertThat(recipientIdsForMessage1, containsInAnyOrder(*recipients1to10.take(5).toTypedArray())) + + assertThat(recipientIdsForMessage2, hasSize(10)) + assertThat(recipientIdsForMessage2, containsInAnyOrder(*recipients6to15.toTypedArray())) + } + + @Test + fun getRecipientsToSendTo_overlapAll() { + 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) + + val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, false) + val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 100, true) + val recipientIdsForMessage3 = storySends.getRecipientsToSendTo(messageId3, 100, true) + + assertThat(recipientIdsForMessage1, hasSize(0)) + + assertThat(recipientIdsForMessage2, hasSize(1)) + assertThat(recipientIdsForMessage2, containsInAnyOrder(recipient1)) + + assertThat(recipientIdsForMessage3, hasSize(1)) + assertThat(recipientIdsForMessage3, containsInAnyOrder(recipient2)) + } + + @Test + fun getRecipientsToSendTo_overlapWithEarlierMessage() { + storySends.insert(messageId1, recipients6to15, 100, true) + storySends.insert(messageId2, recipients1to10, 100, false) + + val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, true) + val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 100, false) + + assertThat(recipientIdsForMessage1, hasSize(10)) + assertThat(recipientIdsForMessage1, containsInAnyOrder(*recipients6to15.toTypedArray())) + + assertThat(recipientIdsForMessage2, hasSize(5)) + assertThat(recipientIdsForMessage2, containsInAnyOrder(*recipients1to10.take(5).toTypedArray())) + } + + @Test + fun getRemoteDeleteRecipients_noOverlap() { + storySends.insert(messageId1, recipients1to10, 100, false) + storySends.insert(messageId2, recipients11to20, 200, true) + storySends.insert(messageId3, recipients1to10, 300, false) + + val recipientIdsForMessage1 = storySends.getRemoteDeleteRecipients(messageId1, 100) + val recipientIdsForMessage2 = storySends.getRemoteDeleteRecipients(messageId2, 200) + + assertThat(recipientIdsForMessage1, hasSize(10)) + assertThat(recipientIdsForMessage1, containsInAnyOrder(*recipients1to10.toTypedArray())) + + assertThat(recipientIdsForMessage2, hasSize(10)) + assertThat(recipientIdsForMessage2, containsInAnyOrder(*recipients11to20.toTypedArray())) + } + + @Test + fun getRemoteDeleteRecipients_overlapNoPreviousDeletes() { + storySends.insert(messageId1, recipients1to10, 200, false) + storySends.insert(messageId2, recipients6to15, 200, true) + + val recipientIdsForMessage1 = storySends.getRemoteDeleteRecipients(messageId1, 200) + val recipientIdsForMessage2 = storySends.getRemoteDeleteRecipients(messageId2, 200) + + assertThat(recipientIdsForMessage1, hasSize(5)) + assertThat(recipientIdsForMessage1, containsInAnyOrder(*recipients1to10.take(5).toTypedArray())) + + assertThat(recipientIdsForMessage2, hasSize(5)) + assertThat(recipientIdsForMessage2, containsInAnyOrder(*recipients6to15.takeLast(5).toTypedArray())) + } + + @Test + fun getRemoteDeleteRecipients_overlapWithPreviousDeletes() { + storySends.insert(messageId1, recipients1to10, 200, false) + SignalDatabase.mms.markAsRemoteDelete(messageId1) + + storySends.insert(messageId2, recipients6to15, 200, true) + + val recipientIdsForMessage2 = storySends.getRemoteDeleteRecipients(messageId2, 200) + + assertThat(recipientIdsForMessage2, hasSize(10)) + assertThat(recipientIdsForMessage2, containsInAnyOrder(*recipients6to15.toTypedArray())) + } + + @Test + fun canReply_storyWithReplies() { + storySends.insert(messageId2, recipients1to10, 200, true) + + val canReply = storySends.canReply(recipients1to10[0], 200) + + assertThat(canReply, `is`(true)) + } + + @Test + fun canReply_storyWithoutReplies() { + storySends.insert(messageId1, recipients1to10, 200, false) + + val canReply = storySends.canReply(recipients1to10[0], 200) + + assertThat(canReply, `is`(false)) + } + + @Test + fun canReply_storyWithAndWithoutRepliesOverlap() { + storySends.insert(messageId1, recipients1to10, 200, false) + storySends.insert(messageId2, recipients6to10, 200, true) + + val message1OnlyRecipientCanReply = storySends.canReply(recipients1to10[0], 200) + val message2RecipientCanReply = storySends.canReply(recipients6to10[0], 200) + + assertThat(message1OnlyRecipientCanReply, `is`(false)) + assertThat(message2RecipientCanReply, `is`(true)) + } + + private fun makeRecipients(count: Int): List { + return (1..count).map { + SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index bb6631076..78569be0e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -133,7 +133,7 @@ public class MmsDatabase extends MessageDatabase { static final String MESSAGE_RANGES = "ranges"; public static final String VIEW_ONCE = "reveal_duration"; - static final String STORY_TYPE = "is_story"; + public static final String STORY_TYPE = "is_story"; static final String PARENT_STORY_ID = "parent_story_id"; public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + @@ -930,9 +930,25 @@ public class MmsDatabase extends MessageDatabase { if (messageUpdates.size() > 0 && receiptType == ReceiptType.DELIVERY) { earlyDeliveryReceiptCache.increment(messageId.getTimetamp(), messageId.getRecipientId(), timestamp); } - - return messageUpdates; } + + String columnName = receiptType.getColumnName(); + for (MessageId storyMessageId : SignalDatabase.storySends().getStoryMessagesFor(messageId)) { + database.execSQL("UPDATE " + TABLE_NAME + " SET " + + columnName + " = " + columnName + " + 1, " + + RECEIPT_TIMESTAMP + " = CASE " + + "WHEN " + columnName + " = 0 THEN MAX(" + RECEIPT_TIMESTAMP + ", ?) " + + "ELSE " + RECEIPT_TIMESTAMP + " " + + "END " + + "WHERE " + ID + " = ?", + SqlUtil.buildArgs(timestamp, storyMessageId.getId())); + + SignalDatabase.groupReceipts().update(messageId.getRecipientId(), storyMessageId.getId(), receiptType.getGroupStatus(), timestamp); + + messageUpdates.add(new MessageUpdate(-1, storyMessageId)); + } + + return messageUpdates; } @Override @@ -1879,6 +1895,15 @@ public class MmsDatabase extends MessageDatabase { receiptDatabase.insert(members, messageId, defaultReceiptStatus, message.getSentTimeMillis()); + for (RecipientId recipientId : earlyDeliveryReceipts.keySet()) { + receiptDatabase.update(recipientId, messageId, GroupReceiptDatabase.STATUS_DELIVERED, -1); + } + } else if (message.getRecipient().isDistributionList()) { + GroupReceiptDatabase receiptDatabase = SignalDatabase.groupReceipts(); + List members = SignalDatabase.distributionLists().getMembers(message.getRecipient().requireDistributionListId()); + + receiptDatabase.insert(members, messageId, defaultReceiptStatus, message.getSentTimeMillis()); + for (RecipientId recipientId : earlyDeliveryReceipts.keySet()) { receiptDatabase.update(recipientId, messageId, GroupReceiptDatabase.STATUS_DELIVERED, -1); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt index ae68576ee..b74bf93e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt @@ -53,6 +53,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase.Companion.notification import org.thoughtcrime.securesms.database.SignalDatabase.Companion.reactions import org.thoughtcrime.securesms.database.SignalDatabase.Companion.runPostSuccessfulTransaction import org.thoughtcrime.securesms.database.SignalDatabase.Companion.sessions +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.storySends import org.thoughtcrime.securesms.database.SignalDatabase.Companion.threads import org.thoughtcrime.securesms.database.model.DistributionListId import org.thoughtcrime.securesms.database.model.RecipientRecord @@ -2707,6 +2708,9 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : // DistributionLists distributionLists.remapRecipient(byE164, byAci) + // Story Sends + storySends.remapRecipient(byE164, byAci) + // Recipient Log.w(TAG, "Deleting recipient $byE164", true) db.delete(TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(byE164)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt index a44e06fe9..88fa2da81 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt @@ -70,6 +70,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data val notificationProfileDatabase: NotificationProfileDatabase = NotificationProfileDatabase(context, this) val donationReceiptDatabase: DonationReceiptDatabase = DonationReceiptDatabase(context, this) val distributionListDatabase: DistributionListDatabase = DistributionListDatabase(context, this) + val storySendsDatabase: StorySendsDatabase = StorySendsDatabase(context, this) override fun onOpen(db: net.zetetic.database.sqlcipher.SQLiteDatabase) { db.enableWriteAheadLogging() @@ -103,6 +104,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data db.execSQL(GroupCallRingDatabase.CREATE_TABLE) db.execSQL(ReactionDatabase.CREATE_TABLE) db.execSQL(DonationReceiptDatabase.CREATE_TABLE) + db.execSQL(StorySendsDatabase.CREATE_TABLE) executeStatements(db, SearchDatabase.CREATE_TABLE) executeStatements(db, RemappedRecordsDatabase.CREATE_TABLE) executeStatements(db, MessageSendLogDatabase.CREATE_TABLE) @@ -125,6 +127,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data executeStatements(db, GroupCallRingDatabase.CREATE_INDEXES) executeStatements(db, NotificationProfileDatabase.CREATE_INDEXES) executeStatements(db, DonationReceiptDatabase.CREATE_INDEXS) + db.execSQL(StorySendsDatabase.CREATE_INDEX) executeStatements(db, MessageSendLogDatabase.CREATE_TRIGGERS) executeStatements(db, ReactionDatabase.CREATE_TRIGGERS) @@ -480,5 +483,10 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data @get:JvmName("donationReceipts") val donationReceipts: DonationReceiptDatabase get() = instance!!.donationReceiptDatabase + + @get:JvmStatic + @get:JvmName("storySends") + val storySends: StorySendsDatabase + get() = instance!!.storySendsDatabase } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/StorySendsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/StorySendsDatabase.kt new file mode 100644 index 000000000..1b31682ee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/StorySendsDatabase.kt @@ -0,0 +1,180 @@ +package org.thoughtcrime.securesms.database + +import android.content.ContentValues +import android.content.Context +import androidx.core.content.contentValuesOf +import org.signal.core.util.SqlUtil +import org.signal.core.util.requireLong +import org.signal.core.util.toInt +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * Sending to a distribution list is a bit trickier. When we send to multiple distribution lists with overlapping membership, we want to + * show them as distinct items on the sending side, but as a single item on the receiving side. Basically, if Alice has two lists and Bob + * is on both, Bob should always see a story for “Alice” and not know that Alice has him in multiple lists. And when Bob views the story, + * Alice should update the UI to show a view in each list. To do this, we need to: + * 1. Only send a single copy of each story to a given recipient, while + * 2. Knowing which people would have gotten duplicate copies. + */ +class StorySendsDatabase(context: Context, databaseHelper: SignalDatabase) : Database(context, databaseHelper) { + + companion object { + const val TABLE_NAME = "story_sends" + const val ID = "_id" + const val MESSAGE_ID = "message_id" + const val RECIPIENT_ID = "recipient_id" + const val SENT_TIMESTAMP = "sent_timestamp" + const val ALLOWS_REPLIES = "allows_replies" + + val CREATE_TABLE = """ + CREATE TABLE $TABLE_NAME ( + $ID INTEGER PRIMARY KEY, + $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 + ) + """.trimIndent() + + val CREATE_INDEX = """ + CREATE INDEX story_sends_recipient_id_sent_timestamp_allows_replies_index ON $TABLE_NAME ($RECIPIENT_ID, $SENT_TIMESTAMP, $ALLOWS_REPLIES) + """.trimIndent() + } + + fun insert(messageId: Long, recipientIds: Collection, sentTimestamp: Long, allowsReplies: Boolean) { + val db = writableDatabase + + db.beginTransaction() + try { + val insertValues: List = recipientIds.map { id -> + contentValuesOf( + MESSAGE_ID to messageId, + RECIPIENT_ID to id.serialize(), + SENT_TIMESTAMP to sentTimestamp, + ALLOWS_REPLIES to allowsReplies.toInt() + ) + } + + SqlUtil.buildBulkInsert(TABLE_NAME, arrayOf(MESSAGE_ID, RECIPIENT_ID, SENT_TIMESTAMP, ALLOWS_REPLIES), insertValues) + .forEach { query -> db.execSQL(query.where, query.whereArgs) } + + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + fun getRecipientsToSendTo(messageId: Long, sentTimestamp: Long, allowsReplies: Boolean): List { + val recipientIds = mutableListOf() + + val query = """ + SELECT DISTINCT $RECIPIENT_ID + FROM $TABLE_NAME + WHERE + $MESSAGE_ID = $messageId + AND $RECIPIENT_ID NOT IN ( + SELECT $RECIPIENT_ID + FROM $TABLE_NAME + WHERE + $SENT_TIMESTAMP = $sentTimestamp + AND $MESSAGE_ID < $messageId + AND $ALLOWS_REPLIES >= ${allowsReplies.toInt()} + ) + AND $RECIPIENT_ID NOT IN ( + SELECT $RECIPIENT_ID + FROM $TABLE_NAME + WHERE + $SENT_TIMESTAMP = $sentTimestamp + AND $MESSAGE_ID > $messageId + AND $ALLOWS_REPLIES > ${allowsReplies.toInt()} + ) + """.trimIndent() + + readableDatabase.rawQuery(query, null).use { cursor -> + while (cursor.moveToNext()) { + recipientIds += RecipientId.from(cursor.requireLong(RECIPIENT_ID)) + } + } + + return recipientIds + } + + /** + * The weirdness with remote deletes and stories is that just because you remote-delete a story to List A doesn’t mean you + * send the delete to everyone on the list – some people have it through multiple lists. + * + * The general idea is to find all recipients for a story that still have a non-deleted copy of it. + */ + fun getRemoteDeleteRecipients(messageId: Long, sentTimestamp: Long): List { + val recipientIds = mutableListOf() + + val query = """ + SELECT $RECIPIENT_ID + FROM $TABLE_NAME + WHERE + $MESSAGE_ID = $messageId + AND $RECIPIENT_ID NOT IN ( + SELECT $RECIPIENT_ID + FROM $TABLE_NAME + WHERE $MESSAGE_ID != $messageId + AND $SENT_TIMESTAMP = $sentTimestamp + AND $MESSAGE_ID IN ( + SELECT ${MmsDatabase.ID} + FROM ${MmsDatabase.TABLE_NAME} + WHERE ${MmsDatabase.REMOTE_DELETED} = 0 + ) + ) + """.trimIndent() + + readableDatabase.rawQuery(query, null).use { cursor -> + while (cursor.moveToNext()) { + recipientIds += RecipientId.from(cursor.requireLong(RECIPIENT_ID)) + } + } + + return recipientIds + } + + fun canReply(recipientId: RecipientId, sentTimestamp: Long): Boolean { + readableDatabase.query( + TABLE_NAME, + arrayOf("1"), + "$RECIPIENT_ID = ? AND $SENT_TIMESTAMP = ? AND $ALLOWS_REPLIES = ?", + SqlUtil.buildArgs(recipientId, sentTimestamp, 1), + null, + null, + null + ).use { + return it.moveToFirst() + } + } + + fun getStoryMessagesFor(syncMessageId: MessageDatabase.SyncMessageId): Set { + val messageIds = mutableSetOf() + + readableDatabase.query( + TABLE_NAME, + arrayOf(MESSAGE_ID), + "$RECIPIENT_ID = ? AND $SENT_TIMESTAMP = ?", + SqlUtil.buildArgs(syncMessageId.recipientId, syncMessageId.timetamp), + null, + null, + null + ).use { cursor -> + while (cursor.moveToNext()) { + messageIds += MessageId(cursor.requireLong(MESSAGE_ID), true) + } + } + + return messageIds + } + + fun remapRecipient(oldId: RecipientId, newId: RecipientId) { + val query = "$RECIPIENT_ID = ?" + val args = SqlUtil.buildArgs(oldId) + val values = contentValuesOf(RECIPIENT_ID to newId.serialize()) + + writableDatabase.update(TABLE_NAME, values, query, args) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index cecdcc38a..bd4195b85 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -195,8 +195,9 @@ object SignalDatabaseMigrations { private const val ALLOW_STORY_REPLIES = 133 private const val GROUP_STORIES = 134 private const val MMS_COUNT_INDEX = 135 + private const val STORY_SENDS = 136 - const val DATABASE_VERSION = 135 + const val DATABASE_VERSION = 136 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { @@ -2486,6 +2487,22 @@ object SignalDatabaseMigrations { if (oldVersion < MMS_COUNT_INDEX) { db.execSQL("CREATE INDEX IF NOT EXISTS mms_thread_story_parent_story_index ON mms (thread_id, date_received, is_story, parent_story_id)") } + + if (oldVersion < STORY_SENDS) { + db.execSQL( + """ + CREATE TABLE story_sends ( + _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 + ) + """.trimIndent() + ) + + db.execSQL("CREATE INDEX story_sends_recipient_id_sent_timestamp_allows_replies_index ON story_sends (recipient_id, sent_timestamp, allows_replies)") + } } @JvmStatic diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java index 3ee033214..a6af08570 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java @@ -145,7 +145,7 @@ public final class PushDistributionListSendJob extends PushSendJob { List target; if (!existingNetworkFailures.isEmpty()) target = Stream.of(existingNetworkFailures).map(nf -> nf.getRecipientId(context)).distinct().map(Recipient::resolved).toList(); - else target = Stream.of(Stories.getRecipientsToSendTo(listRecipient.requireDistributionListId(), messageId)).distinctBy(Recipient::getId).toList(); + else target = Stream.of(Stories.getRecipientsToSendTo(messageId, message.getSentTimeMillis(), message.getStoryType().isStoryWithReplies())).distinctBy(Recipient::getId).toList(); List results = deliver(message, target); Log.i(TAG, JobLogger.format(this, "Finished send.")); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java index cb746d990..49537eec7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java @@ -34,6 +34,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; import java.io.IOException; +import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -72,12 +73,7 @@ public class RemoteDeleteSendJob extends BaseJob { List recipients; if (conversationRecipient.isDistributionList()) { - DistributionListId distributionListId = conversationRecipient.requireDistributionListId(); - - recipients = Stories.getRecipientsToSendTo(distributionListId, messageId) - .stream() - .map(Recipient::getId) - .collect(Collectors.toList()); + recipients = SignalDatabase.storySends().getRemoteDeleteRecipients(message.getId(), message.getTimestamp()); } else { recipients = conversationRecipient.isGroup() ? Stream.of(conversationRecipient.getParticipants()).map(Recipient::getId).toList() : Stream.of(conversationRecipient.getId()).toList(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt index a41ae5172..f9ac91581 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt @@ -201,6 +201,7 @@ class MediaSelectionRepository(context: Context) { private fun sendMessages(contacts: List, body: String, preUploadResults: Collection, mentions: List, isViewOnce: Boolean) { val broadcastMessages: MutableList = ArrayList(contacts.size) val storyMessages: MutableMap> = mutableMapOf() + val distributionListSentTimestamps: MutableMap = mutableMapOf() for (contact in contacts) { val recipient = Recipient.resolved(contact.recipientId) @@ -220,7 +221,7 @@ class MediaSelectionRepository(context: Context) { recipient, body, emptyList(), - System.currentTimeMillis(), + if (recipient.isDistributionList) distributionListSentTimestamps.getOrPut(preUploadResults.first()) { System.currentTimeMillis() } else System.currentTimeMillis(), -1, TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong()), isViewOnce, @@ -239,7 +240,7 @@ class MediaSelectionRepository(context: Context) { if (isStory && preUploadResults.size > 1) { preUploadResults.forEach { val list = storyMessages[it] ?: mutableListOf() - list.add(OutgoingSecureMediaMessage(message).withSentTimestamp(System.currentTimeMillis())) + list.add(OutgoingSecureMediaMessage(message).withSentTimestamp(if (recipient.isDistributionList) distributionListSentTimestamps.getOrPut(it) { System.currentTimeMillis() } else System.currentTimeMillis())) storyMessages[it] = list // XXX We must do this to avoid sending out messages to the same recipient with the same diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt index f2f1ecdf3..99f32c70f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.mediasend.v2.text.send -import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Single import org.signal.core.util.ThreadUtil import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey @@ -44,6 +43,7 @@ class TextStoryPostSendRepository { private fun performSend(contactSearchKey: Set, textStoryPostCreationState: TextStoryPostCreationState, linkPreview: LinkPreview?): Single { return Single.fromCallable { val messages: MutableList = mutableListOf() + val distributionListSentTimestamp = System.currentTimeMillis() for (contact in contactSearchKey) { val recipient = Recipient.resolved(contact.requireShareContact().recipientId.get()) @@ -63,7 +63,7 @@ class TextStoryPostSendRepository { recipient, serializeTextStoryState(textStoryPostCreationState), emptyList(), - System.currentTimeMillis(), + if (recipient.isDistributionList) distributionListSentTimestamp else System.currentTimeMillis(), -1, 0, false, @@ -83,9 +83,9 @@ class TextStoryPostSendRepository { ThreadUtil.sleep(5) } - messages.map { Stories.sendIndividualStory(it) } + Stories.sendTextStories(messages) }.flatMap { messages -> - Completable.concat(messages).toSingleDefault(TextStoryPostSendResult.Success) + messages.toSingleDefault(TextStoryPostSendResult.Success) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java index dd0718a1c..ceab295f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -1564,16 +1564,14 @@ public final class MessageContentProcessor { if (message.getGroupContext().isPresent()) { parentStoryId = new ParentStoryId.GroupReply(storyMessageId.getId()); - } else { + } else if (SignalDatabase.storySends().canReply(senderRecipient.getId(), storyContext.getSentTimestamp())) { MmsMessageRecord story = (MmsMessageRecord) database.getMessageRecord(storyMessageId.getId()); - if (!story.getStoryType().isStoryWithReplies()) { - warn(content.getTimestamp(), "Story has replies disabled. Dropping reply."); - return; - } - parentStoryId = new ParentStoryId.DirectReply(storyMessageId.getId()); - quoteModel = new QuoteModel(storyContext.getSentTimestamp(), storyAuthorRecipient, "", false, story.getSlideDeck().asAttachments(), Collections.emptyList()); + quoteModel = new QuoteModel(storyContext.getSentTimestamp(), storyAuthorRecipient, message.getBody().orElse(""), false, story.getSlideDeck().asAttachments(), Collections.emptyList()); + } else { + warn(content.getTimestamp(), "Story has replies disabled. Dropping reply."); + return; } } catch (NoSuchMessageException e) { warn(content.getTimestamp(), "Couldn't find story for reply.", e); diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java index 76eb994c5..a330be2b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java @@ -72,6 +72,7 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.service.ExpiringMessageManager; +import org.thoughtcrime.securesms.stories.Stories; import org.thoughtcrime.securesms.util.ParcelUtil; import org.thoughtcrime.securesms.util.SignalLocalMetrics; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -304,14 +305,9 @@ public class MessageSender { OutgoingSecureMediaMessage message = messages.get(i); Recipient recipient = message.getRecipient(); - if (isLocalSelfSend(context, recipient, false)) { - sendLocalMediaSelf(context, messageId); - } else if (recipient.isPushGroup()) { - jobManager.add(new PushGroupSendJob(messageId, recipient.getId(), null, true), messageDependsOnIds, recipient.getId().toQueueKey()); - } else if (recipient.isDistributionList()) { - jobManager.add(new PushDistributionListSendJob(messageId, recipient.getId(), true), messageDependsOnIds, recipient.getId().toQueueKey()); - } else { - jobManager.add(new PushMediaSendJob(messageId, recipient, true), messageDependsOnIds, recipient.getId().toQueueKey()); + if (recipient.isDistributionList()) { + List members = SignalDatabase.distributionLists().getMembers(recipient.requireDistributionListId()); + SignalDatabase.storySends().insert(messageId, members, message.getSentTimeMillis(), message.getStoryType().isStoryWithReplies()); } } @@ -319,9 +315,25 @@ public class MessageSender { mmsDatabase.setTransactionSuccessful(); } catch (MmsException e) { Log.w(TAG, "Failed to send messages.", e); + return; } finally { mmsDatabase.endTransaction(); } + + for (int i = 0; i < messageIds.size(); i++) { + long messageId = messageIds.get(i); + Recipient recipient = messages.get(i).getRecipient(); + + if (isLocalSelfSend(context, recipient, false)) { + sendLocalMediaSelf(context, messageId); + } else if (recipient.isPushGroup()) { + jobManager.add(new PushGroupSendJob(messageId, recipient.getId(), null, true), messageDependsOnIds, recipient.getId().toQueueKey()); + } else if (recipient.isDistributionList()) { + jobManager.add(new PushDistributionListSendJob(messageId, recipient.getId(), true), messageDependsOnIds, recipient.getId().toQueueKey()); + } else { + jobManager.add(new PushMediaSendJob(messageId, recipient, true), messageDependsOnIds, recipient.getId().toQueueKey()); + } + } } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt index b89f073fa..cd7f1ba43 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt @@ -5,13 +5,11 @@ import androidx.fragment.app.FragmentManager import io.reactivex.rxjava3.core.Completable import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.contacts.HeaderAction -import org.thoughtcrime.securesms.database.GroupReceiptDatabase.GroupReceiptInfo import org.thoughtcrime.securesms.database.SignalDatabase -import org.thoughtcrime.securesms.database.model.DistributionListId import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet -import org.thoughtcrime.securesms.mms.OutgoingMediaMessage +import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientUtil @@ -40,29 +38,16 @@ object Stories { } @WorkerThread - fun sendIndividualStory(message: OutgoingMediaMessage): Completable { + fun sendTextStories(messages: List): Completable { return Completable.create { emitter -> - MessageSender.send( - ApplicationDependencies.getApplication(), - message, - -1L, - false, - null - ) { - emitter.onComplete() - } + MessageSender.sendMediaBroadcast(ApplicationDependencies.getApplication(), messages, listOf(), listOf()) + emitter.onComplete() } } @JvmStatic - fun getRecipientsToSendTo(distributionListId: DistributionListId, messageId: Long): List { - val destinations: List = SignalDatabase.groupReceipts.getGroupReceiptInfo(messageId) - - val recipientIds: List = if (destinations.isNotEmpty()) { - destinations.map(GroupReceiptInfo::getRecipientId) - } else { - SignalDatabase.distributionLists.getMembers(distributionListId) - } + fun getRecipientsToSendTo(messageId: Long, sentTimestamp: Long, allowsReplies: Boolean): List { + val recipientIds: List = SignalDatabase.storySends.getRecipientsToSendTo(messageId, sentTimestamp, allowsReplies) return RecipientUtil.getEligibleForSending(recipientIds.map(Recipient::resolved)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesItem.kt index 0b276e190..fcc8a56ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesItem.kt @@ -88,8 +88,8 @@ object MyStoriesItem { viewCount.text = context.resources.getQuantityString( R.plurals.MyStories__d_views, - model.distributionStory.messageRecord.readReceiptCount, - model.distributionStory.messageRecord.readReceiptCount + model.distributionStory.messageRecord.viewedReceiptCount, + model.distributionStory.messageRecord.viewedReceiptCount ) if (STATUS_CHANGE in payload) { diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt b/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt index 44e865878..462a314cc 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt @@ -8,6 +8,7 @@ import org.signal.spinner.Spinner.DatabaseConfig import org.thoughtcrime.securesms.database.DatabaseMonitor import org.thoughtcrime.securesms.database.GV2Transformer import org.thoughtcrime.securesms.database.GV2UpdateTransformer +import org.thoughtcrime.securesms.database.IsStoryTransformer import org.thoughtcrime.securesms.database.JobDatabase import org.thoughtcrime.securesms.database.KeyValueDatabase import org.thoughtcrime.securesms.database.LocalMetricsDatabase @@ -39,7 +40,7 @@ class SpinnerApplicationContext : ApplicationContext() { linkedMapOf( "signal" to DatabaseConfig( db = SignalDatabase.rawDatabase, - columnTransformers = listOf(MessageBitmaskColumnTransformer, GV2Transformer, GV2UpdateTransformer) + columnTransformers = listOf(MessageBitmaskColumnTransformer, GV2Transformer, GV2UpdateTransformer, IsStoryTransformer) ), "jobmanager" to DatabaseConfig(db = JobDatabase.getInstance(this).sqlCipherDatabase), "keyvalue" to DatabaseConfig(db = KeyValueDatabase.getInstance(this).sqlCipherDatabase), diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/database/IsStoryTransformer.kt b/app/src/spinner/java/org/thoughtcrime/securesms/database/IsStoryTransformer.kt new file mode 100644 index 000000000..c6fb16a39 --- /dev/null +++ b/app/src/spinner/java/org/thoughtcrime/securesms/database/IsStoryTransformer.kt @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.database + +import android.database.Cursor +import org.signal.core.util.requireInt +import org.signal.spinner.ColumnTransformer +import org.thoughtcrime.securesms.database.model.StoryType.Companion.fromCode + +object IsStoryTransformer : ColumnTransformer { + override fun matches(tableName: String?, columnName: String): Boolean { + return columnName == MmsDatabase.STORY_TYPE && (tableName == null || tableName == MmsDatabase.TABLE_NAME) + } + + override fun transform(tableName: String?, columnName: String, cursor: Cursor): String { + val storyType = fromCode(cursor.requireInt(MmsDatabase.STORY_TYPE)) + return "${cursor.requireInt(MmsDatabase.STORY_TYPE)}

$storyType" + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt b/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt index 81ebb4a30..b7132d9a7 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt @@ -6,6 +6,7 @@ import com.google.android.mms.pdu_alt.PduHeaders import org.thoughtcrime.securesms.database.model.StoryType import org.thoughtcrime.securesms.mms.OutgoingMediaMessage import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId /** * Helper methods for inserting an MMS message into the MMS table. @@ -15,6 +16,7 @@ object TestMms { fun insert( db: SQLiteDatabase, recipient: Recipient = Recipient.UNKNOWN, + recipientId: RecipientId = Recipient.UNKNOWN.id, body: String = "body", sentTimeMillis: Long = System.currentTimeMillis(), receivedTimestampMillis: Long = System.currentTimeMillis(), @@ -51,6 +53,7 @@ object TestMms { return insert( db = db, message = message, + recipientId = recipientId, body = body, type = type, unread = unread, @@ -63,6 +66,7 @@ object TestMms { fun insert( db: SQLiteDatabase, message: OutgoingMediaMessage, + recipientId: RecipientId = message.recipient.id, body: String = message.body, type: Long = MmsSmsColumns.Types.BASE_INBOX_TYPE, unread: Boolean = false, @@ -81,7 +85,7 @@ object TestMms { put(MmsSmsColumns.SUBSCRIPTION_ID, message.subscriptionId) put(MmsSmsColumns.EXPIRES_IN, message.expiresIn) put(MmsDatabase.VIEW_ONCE, message.isViewOnce) - put(MmsSmsColumns.RECIPIENT_ID, message.recipient.id.serialize()) + put(MmsSmsColumns.RECIPIENT_ID, recipientId.serialize()) put(MmsSmsColumns.DELIVERY_RECEIPT_COUNT, 0) put(MmsSmsColumns.RECEIPT_TIMESTAMP, 0) put(MmsSmsColumns.VIEWED_RECEIPT_COUNT, if (viewed) 1 else 0) @@ -94,4 +98,17 @@ object TestMms { return db.insert(MmsDatabase.TABLE_NAME, null, contentValues) } + + fun markAsRemoteDelete(db: SQLiteDatabase, messageId: Long) { + val values = ContentValues() + values.put(MmsSmsColumns.REMOTE_DELETED, 1) + values.putNull(MmsSmsColumns.BODY) + values.putNull(MmsDatabase.QUOTE_BODY) + values.putNull(MmsDatabase.QUOTE_AUTHOR) + values.putNull(MmsDatabase.QUOTE_ATTACHMENT) + values.putNull(MmsDatabase.QUOTE_ID) + values.putNull(MmsDatabase.LINK_PREVIEWS) + values.putNull(MmsDatabase.SHARED_CONTACTS) + db.update(MmsDatabase.TABLE_NAME, values, Database.ID_WHERE, arrayOf(messageId.toString())) + } }