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

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