diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListDatabase.kt index 2eda2ee3e..2cd071a9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListDatabase.kt @@ -6,6 +6,7 @@ import android.database.Cursor import androidx.core.content.contentValuesOf import org.signal.core.util.CursorUtil import org.signal.core.util.SqlUtil +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 @@ -14,9 +15,12 @@ import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord import org.thoughtcrime.securesms.database.model.DistributionListRecord import org.thoughtcrime.securesms.database.model.StoryType import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.storage.StorageRecordUpdate import org.thoughtcrime.securesms.storage.StorageSyncHelper 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.util.UUID /** @@ -25,6 +29,8 @@ import java.util.UUID class DistributionListDatabase constructor(context: Context?, databaseHelper: SignalDatabase?) : Database(context, databaseHelper) { companion object { + private val TAG = Log.tag(DistributionListDatabase::class.java) + @JvmField val CREATE_TABLE: Array = arrayOf(ListTable.CREATE_TABLE, MembershipTable.CREATE_TABLE) @@ -34,18 +40,18 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si val recipientId = db.insert( RecipientDatabase.TABLE_NAME, null, contentValuesOf( + RecipientDatabase.GROUP_TYPE to RecipientDatabase.GroupType.DISTRIBUTION_LIST.id, RecipientDatabase.DISTRIBUTION_LIST_ID to DistributionListId.MY_STORY_ID, RecipientDatabase.STORAGE_SERVICE_ID to Base64.encodeBytes(StorageSyncHelper.generateKey()), RecipientDatabase.PROFILE_SHARING to 1 ) ) - val listUUID = UUID.randomUUID().toString() db.insert( ListTable.TABLE_NAME, null, contentValuesOf( ListTable.ID to DistributionListId.MY_STORY_ID, - ListTable.NAME to listUUID, - ListTable.DISTRIBUTION_ID to listUUID, + ListTable.NAME to DistributionId.MY_STORY.toString(), + ListTable.DISTRIBUTION_ID to DistributionId.MY_STORY.toString(), ListTable.RECIPIENT_ID to recipientId ) ) @@ -60,6 +66,7 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si const val DISTRIBUTION_ID = "distribution_id" const val RECIPIENT_ID = "recipient_id" const val ALLOWS_REPLIES = "allows_replies" + const val DELETION_TIMESTAMP = "deletion_timestamp" const val CREATE_TABLE = """ CREATE TABLE $TABLE_NAME ( @@ -67,9 +74,12 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si $NAME TEXT UNIQUE NOT NULL, $DISTRIBUTION_ID TEXT UNIQUE NOT NULL, $RECIPIENT_ID INTEGER UNIQUE REFERENCES ${RecipientDatabase.TABLE_NAME} (${RecipientDatabase.ID}), - $ALLOWS_REPLIES INTEGER DEFAULT 1 + $ALLOWS_REPLIES INTEGER DEFAULT 1, + $DELETION_TIMESTAMP INTEGER DEFAULT 0 ) """ + + const val IS_NOT_DELETED = "$DELETION_TIMESTAMP == 0" } private object MembershipTable { @@ -127,10 +137,10 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si val projection = arrayOf(ListTable.ID, ListTable.NAME, ListTable.RECIPIENT_ID, ListTable.ALLOWS_REPLIES) val where = when { - query.isNullOrEmpty() && includeMyStory -> null - query.isNullOrEmpty() -> "${ListTable.ID} != ?" - includeMyStory -> "${ListTable.NAME} LIKE ? OR ${ListTable.ID} == ?" - else -> "${ListTable.NAME} LIKE ? AND ${ListTable.ID} != ?" + query.isNullOrEmpty() && includeMyStory -> ListTable.IS_NOT_DELETED + query.isNullOrEmpty() -> "${ListTable.ID} != ? AND ${ListTable.IS_NOT_DELETED}" + includeMyStory -> "(${ListTable.NAME} LIKE ? OR ${ListTable.ID} == ?) AND ${ListTable.IS_NOT_DELETED}" + else -> "${ListTable.NAME} LIKE ? AND ${ListTable.ID} != ? AND ${ListTable.IS_NOT_DELETED}" } val whereArgs = when { @@ -145,7 +155,7 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si fun getCustomListsForUi(): List { val db = readableDatabase val projection = SqlUtil.buildArgs(ListTable.ID, ListTable.NAME, ListTable.RECIPIENT_ID, ListTable.ALLOWS_REPLIES) - val selection = "${ListTable.ID} != ${DistributionListId.MY_STORY_ID}" + 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 { val results = mutableListOf() @@ -167,15 +177,23 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si /** * @return The id of the list if successful, otherwise null. If not successful, you can assume it was a name conflict. */ - fun createList(name: String, members: List): DistributionListId? { + fun createList( + name: String, + members: List, + distributionId: DistributionId = DistributionId.from(UUID.randomUUID()), + allowsReplies: Boolean = true, + deletionTimestamp: Long = 0L + ): DistributionListId? { val db = writableDatabase db.beginTransaction() try { val values = ContentValues().apply { - put(ListTable.NAME, name) - put(ListTable.DISTRIBUTION_ID, UUID.randomUUID().toString()) + put(ListTable.NAME, if (deletionTimestamp == 0L) name else createUniqueNameForDeletedStory()) + put(ListTable.DISTRIBUTION_ID, distributionId.toString()) + put(ListTable.ALLOWS_REPLIES, if (deletionTimestamp == 0L) allowsReplies else false) putNull(ListTable.RECIPIENT_ID) + put(ListTable.DELETION_TIMESTAMP, deletionTimestamp) } val id = writableDatabase.insert(ListTable.TABLE_NAME, null, values) @@ -203,7 +221,7 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si } fun getStoryType(listId: DistributionListId): StoryType { - readableDatabase.query(ListTable.TABLE_NAME, arrayOf(ListTable.ALLOWS_REPLIES), "${ListTable.ID} = ?", SqlUtil.buildArgs(listId), null, null, null).use { cursor -> + 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()) { if (CursorUtil.requireBoolean(cursor, ListTable.ALLOWS_REPLIES)) { StoryType.STORY_WITH_REPLIES @@ -217,10 +235,29 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si } fun setAllowsReplies(listId: DistributionListId, allowsReplies: Boolean) { - writableDatabase.update(ListTable.TABLE_NAME, contentValuesOf(ListTable.ALLOWS_REPLIES to allowsReplies), "${ListTable.ID} = ?", SqlUtil.buildArgs(listId)) + writableDatabase.update(ListTable.TABLE_NAME, contentValuesOf(ListTable.ALLOWS_REPLIES to allowsReplies), "${ListTable.ID} = ? AND ${ListTable.IS_NOT_DELETED}", SqlUtil.buildArgs(listId)) } fun getList(listId: DistributionListId): DistributionListRecord? { + readableDatabase.query(ListTable.TABLE_NAME, null, "${ListTable.ID} = ? AND ${ListTable.IS_NOT_DELETED}", SqlUtil.buildArgs(listId), null, null, null).use { cursor -> + return if (cursor.moveToFirst()) { + val id: DistributionListId = DistributionListId.from(cursor.requireLong(ListTable.ID)) + + DistributionListRecord( + id = id, + name = cursor.requireNonNullString(ListTable.NAME), + distributionId = DistributionId.from(cursor.requireNonNullString(ListTable.DISTRIBUTION_ID)), + allowsReplies = CursorUtil.requireBoolean(cursor, ListTable.ALLOWS_REPLIES), + members = getMembers(id), + deletedAtTimestamp = 0L + ) + } else { + null + } + } + } + + fun getListForStorageSync(listId: DistributionListId): DistributionListRecord? { readableDatabase.query(ListTable.TABLE_NAME, null, "${ListTable.ID} = ?", SqlUtil.buildArgs(listId), null, null, null).use { cursor -> return if (cursor.moveToFirst()) { val id: DistributionListId = DistributionListId.from(cursor.requireLong(ListTable.ID)) @@ -230,7 +267,8 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si name = cursor.requireNonNullString(ListTable.NAME), distributionId = DistributionId.from(cursor.requireNonNullString(ListTable.DISTRIBUTION_ID)), allowsReplies = CursorUtil.requireBoolean(cursor, ListTable.ALLOWS_REPLIES), - members = getMembers(id) + members = getRawMembers(id), + deletedAtTimestamp = cursor.requireLong(ListTable.DELETION_TIMESTAMP) ) } else { null @@ -239,7 +277,7 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si } fun getDistributionId(listId: DistributionListId): DistributionId? { - readableDatabase.query(ListTable.TABLE_NAME, arrayOf(ListTable.DISTRIBUTION_ID), "${ListTable.ID} = ?", SqlUtil.buildArgs(listId), null, null, null).use { cursor -> + readableDatabase.query(ListTable.TABLE_NAME, arrayOf(ListTable.DISTRIBUTION_ID), "${ListTable.ID} = ? AND ${ListTable.IS_NOT_DELETED}", SqlUtil.buildArgs(listId), null, null, null).use { cursor -> return if (cursor.moveToFirst()) { DistributionId.from(cursor.requireString(ListTable.DISTRIBUTION_ID)) } else { @@ -318,7 +356,130 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si writableDatabase.update(MembershipTable.TABLE_NAME, values, "${MembershipTable.RECIPIENT_ID} = ?", SqlUtil.buildArgs(oldId)) } - fun deleteList(distributionListId: DistributionListId) { - writableDatabase.delete(ListTable.TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(distributionListId)) + fun deleteList(distributionListId: DistributionListId, deletionTimestamp: Long = System.currentTimeMillis()) { + writableDatabase.update( + ListTable.TABLE_NAME, + contentValuesOf( + ListTable.NAME to createUniqueNameForDeletedStory(), + ListTable.ALLOWS_REPLIES to false, + ListTable.DELETION_TIMESTAMP to deletionTimestamp + ), + ID_WHERE, + SqlUtil.buildArgs(distributionListId) + ) + + writableDatabase.delete( + MembershipTable.TABLE_NAME, + "${MembershipTable.LIST_ID} = ?", + SqlUtil.buildArgs(distributionListId) + ) + } + + fun getRecipientIdForSyncRecord(record: SignalStoryDistributionListRecord): RecipientId? { + val uuid: UUID = UuidUtil.parseOrNull(record.identifier) ?: return null + val distributionId = DistributionId.from(uuid) + + return readableDatabase.query( + ListTable.TABLE_NAME, + arrayOf(ListTable.RECIPIENT_ID), + "${ListTable.DISTRIBUTION_ID} = ?", + SqlUtil.buildArgs(distributionId.toString()), + null, null, null + )?.use { cursor -> + if (cursor.moveToFirst()) { + RecipientId.from(CursorUtil.requireLong(cursor, ListTable.RECIPIENT_ID)) + } else { + null + } + } + } + + fun getRecipientId(distributionListId: DistributionListId): RecipientId? { + return readableDatabase.query( + ListTable.TABLE_NAME, + arrayOf(ListTable.RECIPIENT_ID), + "${ListTable.ID} = ?", + SqlUtil.buildArgs(distributionListId), + null, null, null + )?.use { cursor -> + if (cursor.moveToFirst()) { + RecipientId.from(CursorUtil.requireLong(cursor, ListTable.RECIPIENT_ID)) + } else { + null + } + } + } + + fun applyStorageSyncStoryDistributionListInsert(insert: SignalStoryDistributionListRecord) { + createList( + name = insert.name, + members = insert.recipients.map(RecipientId::from), + distributionId = DistributionId.from(UuidUtil.parseOrThrow(insert.identifier)), + allowsReplies = insert.allowsReplies(), + deletionTimestamp = insert.deletedAtTimestamp + ) + } + + fun applyStorageSyncStoryDistributionListUpdate(update: StorageRecordUpdate) { + val distributionId = DistributionId.from(UuidUtil.parseOrThrow(update.new.identifier)) + + val distributionListId: DistributionListId? = readableDatabase.query(ListTable.TABLE_NAME, arrayOf(ListTable.ID), "${ListTable.DISTRIBUTION_ID} = ?", SqlUtil.buildArgs(distributionId.toString()), null, null, null).use { cursor -> + if (cursor == null || !cursor.moveToFirst()) { + null + } else { + DistributionListId.from(CursorUtil.requireLong(cursor, ListTable.ID)) + } + } + + if (distributionListId == null) { + Log.w(TAG, "Cannot find required distribution list.") + return + } + + if (update.new.deletedAtTimestamp > 0L) { + if (distributionId.asUuid().equals(DistributionId.MY_STORY.asUuid())) { + Log.w(TAG, "Refusing to delete My Story.") + return + } + + deleteList(distributionListId, update.new.deletedAtTimestamp) + return + } + + writableDatabase.beginTransaction() + try { + val listTableValues = contentValuesOf( + ListTable.ALLOWS_REPLIES to update.new.allowsReplies(), + ListTable.NAME to update.new.name + ) + + writableDatabase.update( + ListTable.TABLE_NAME, + listTableValues, + "${ListTable.DISTRIBUTION_ID} = ?", + SqlUtil.buildArgs(distributionId.toString()) + ) + + val currentlyInDistributionList = getRawMembers(distributionListId).toSet() + val shouldBeInDistributionList = update.new.recipients.map(RecipientId::from).toSet() + val toRemove = currentlyInDistributionList - shouldBeInDistributionList + val toAdd = shouldBeInDistributionList - currentlyInDistributionList + + toRemove.forEach { + removeMemberFromList(distributionListId, it) + } + + toAdd.forEach { + addMemberToList(distributionListId, it) + } + + writableDatabase.setTransactionSuccessful() + } finally { + writableDatabase.endTransaction() + } + } + + private fun createUniqueNameForDeletedStory(): String { + return "DELETED-${UUID.randomUUID()}" } } 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 dc12eb059..8e5ae8d98 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt @@ -593,6 +593,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : DISTRIBUTION_LIST_ID, distributionListId.serialize(), ContentValues().apply { + put(GROUP_TYPE, GroupType.DISTRIBUTION_LIST.id) put(DISTRIBUTION_LIST_ID, distributionListId.serialize()) put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey())) put(PROFILE_SHARING, 1) @@ -1071,10 +1072,10 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : $STORAGE_SERVICE_ID NOT NULL AND ( ($GROUP_TYPE = ? AND $SERVICE_ID NOT NULL AND $ID != ?) OR - $GROUP_TYPE IN (?) + $GROUP_TYPE IN (?, ?) ) """.trimIndent() - val args = SqlUtil.buildArgs(GroupType.NONE.id, Recipient.self().id, GroupType.SIGNAL_V1.id) + val args = SqlUtil.buildArgs(GroupType.NONE.id, Recipient.self().id, GroupType.SIGNAL_V1.id, GroupType.DISTRIBUTION_LIST.id) val out: MutableMap = HashMap() readableDatabase.query(TABLE_NAME, arrayOf(ID, STORAGE_SERVICE_ID, GROUP_TYPE), query, args, null, null, null).use { cursor -> @@ -1087,6 +1088,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : when (groupType) { GroupType.NONE -> out[id] = StorageId.forContact(key) GroupType.SIGNAL_V1 -> out[id] = StorageId.forGroupV1(key) + GroupType.DISTRIBUTION_LIST -> out[id] = StorageId.forStoryDistributionList(key) else -> throw AssertionError() } } @@ -2504,7 +2506,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : } val query = "$ID = ? AND ($GROUP_TYPE IN (?, ?, ?) OR $REGISTERED = ?)" - val args = SqlUtil.buildArgs(recipientId, GroupType.SIGNAL_V1.id, GroupType.SIGNAL_V2.id, GroupType.DISTRIBUTION_LIST, RegisteredState.REGISTERED.id) + val args = SqlUtil.buildArgs(recipientId, GroupType.SIGNAL_V1.id, GroupType.SIGNAL_V2.id, GroupType.DISTRIBUTION_LIST.id, RegisteredState.REGISTERED.id) 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 bd4195b85..d841f77b9 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 @@ -26,7 +26,6 @@ import org.thoughtcrime.securesms.conversation.colors.ChatColors import org.thoughtcrime.securesms.conversation.colors.ChatColorsMapper.entrySet import org.thoughtcrime.securesms.database.KeyValueDatabase import org.thoughtcrime.securesms.database.RecipientDatabase -import org.thoughtcrime.securesms.database.model.DistributionListId import org.thoughtcrime.securesms.database.model.databaseprotos.ReactionList import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.groups.GroupId @@ -196,8 +195,9 @@ object SignalDatabaseMigrations { private const val GROUP_STORIES = 134 private const val MMS_COUNT_INDEX = 135 private const val STORY_SENDS = 136 + private const val STORY_TYPE_AND_DISTRIBUTION = 137 - const val DATABASE_VERSION = 136 + const val DATABASE_VERSION = 137 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { @@ -2458,7 +2458,7 @@ object SignalDatabaseMigrations { val recipientId = db.insert( "recipient", null, contentValuesOf( - "distribution_list_id" to DistributionListId.MY_STORY_ID, + "distribution_list_id" to 1L, "storage_service_key" to Base64.encodeBytes(StorageSyncHelper.generateKey()), "profile_sharing" to 1 ) @@ -2468,7 +2468,7 @@ object SignalDatabaseMigrations { db.insert( "distribution_list", null, contentValuesOf( - "_id" to DistributionListId.MY_STORY_ID, + "_id" to 1L, "name" to listUUID, "distribution_id" to listUUID, "recipient_id" to recipientId @@ -2503,6 +2503,27 @@ object SignalDatabaseMigrations { db.execSQL("CREATE INDEX story_sends_recipient_id_sent_timestamp_allows_replies_index ON story_sends (recipient_id, sent_timestamp, allows_replies)") } + + if (oldVersion < STORY_TYPE_AND_DISTRIBUTION) { + db.execSQL("ALTER TABLE distribution_list ADD COLUMN deletion_timestamp INTEGER DEFAULT 0") + + db.execSQL( + """ + UPDATE recipient + SET group_type = 4 + WHERE distribution_list_id IS NOT NULL + """.trimIndent() + ) + + db.execSQL( + """ + UPDATE distribution_list + SET name = '00000000-0000-0000-0000-000000000000', + distribution_id = '00000000-0000-0000-0000-000000000000' + WHERE _id = 1 + """.trimIndent() + ) + } } @JvmStatic diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListRecord.kt index 73af27788..56d9349e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DistributionListRecord.kt @@ -11,5 +11,6 @@ data class DistributionListRecord( val name: String, val distributionId: DistributionId, val allowsReplies: Boolean, - val members: List + val members: List, + val deletedAtTimestamp: Long ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java index b46075418..acd7266df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java @@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper.IdDifferenceResult; import org.thoughtcrime.securesms.storage.StorageSyncHelper.WriteOperationResult; import org.thoughtcrime.securesms.storage.StorageSyncModels; import org.thoughtcrime.securesms.storage.StorageSyncValidations; +import org.thoughtcrime.securesms.storage.StoryDistributionListRecordProcessor; import org.thoughtcrime.securesms.transport.RetryLaterException; import org.thoughtcrime.securesms.util.Stopwatch; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -48,10 +49,12 @@ import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; import org.whispersystems.signalservice.api.storage.SignalRecord; import org.whispersystems.signalservice.api.storage.SignalStorageManifest; import org.whispersystems.signalservice.api.storage.SignalStorageRecord; +import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord; import org.whispersystems.signalservice.api.storage.StorageId; import org.whispersystems.signalservice.api.storage.StorageKey; import org.whispersystems.signalservice.internal.push.SignalServiceProtos; import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord; +import org.whispersystems.signalservice.internal.storage.protos.StoryDistributionListRecord; import java.io.IOException; import java.util.ArrayList; @@ -269,11 +272,12 @@ public class StorageSyncJob extends BaseJob { Log.w(TAG, "[Remote Sync] Could not find all remote-only records! Requested: " + idDifference.getRemoteOnlyIds().size() + ", Found: " + remoteOnly.size() + ". These stragglers should naturally get deleted during the sync."); } - List remoteContacts = new LinkedList<>(); - List remoteGv1 = new LinkedList<>(); - List remoteGv2 = new LinkedList<>(); - List remoteAccount = new LinkedList<>(); - List remoteUnknown = new LinkedList<>(); + List remoteContacts = new LinkedList<>(); + List remoteGv1 = new LinkedList<>(); + List remoteGv2 = new LinkedList<>(); + List remoteAccount = new LinkedList<>(); + List remoteUnknown = new LinkedList<>(); + List remoteStoryDistributionLists = new LinkedList<>(); for (SignalStorageRecord remote : remoteOnly) { if (remote.getContact().isPresent()) { @@ -284,6 +288,8 @@ public class StorageSyncJob extends BaseJob { remoteGv2.add(remote.getGroupV2().get()); } else if (remote.getAccount().isPresent()) { remoteAccount.add(remote.getAccount().get()); + } else if (remote.getStoryDistributionList().isPresent()) { + remoteStoryDistributionLists.add(remote.getStoryDistributionList().get()); } else if (remote.getId().isUnknown()) { remoteUnknown.add(remote); } else { @@ -302,6 +308,7 @@ public class StorageSyncJob extends BaseJob { new GroupV2RecordProcessor(context).process(remoteGv2, StorageSyncHelper.KEY_GENERATOR); self = freshSelf(); new AccountRecordProcessor(context, self).process(remoteAccount, StorageSyncHelper.KEY_GENERATOR); + new StoryDistributionListRecordProcessor().process(remoteStoryDistributionLists, StorageSyncHelper.KEY_GENERATOR); List unknownInserts = remoteUnknown; List unknownDeletes = Stream.of(idDifference.getLocalOnlyIds()).filter(StorageId::isUnknown).toList(); @@ -424,6 +431,16 @@ public class StorageSyncJob extends BaseJob { } records.add(StorageSyncHelper.buildAccountRecord(context, self)); break; + case ManifestRecord.Identifier.Type.STORY_DISTRIBUTION_LIST_VALUE: + RecipientRecord record = recipientDatabase.getByStorageId(id.getRaw()); + if (record != null) { + if (record.getDistributionListId() != null) { + records.add(StorageSyncModels.localToRemoteRecord(record)); + } else { + throw new MissingRecipientModelError("Missing local recipient model! Type: " + id.getType()); + } + } + break; default: SignalStorageRecord unknown = storageIdDatabase.getById(id.getRaw()); if (unknown != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java index f2b2f2492..fe6596187 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -100,9 +100,10 @@ public class ApplicationMigrations { static final int PNI_IDENTITY = 56; static final int PNI_IDENTITY_2 = 57; static final int PNI_IDENTITY_3 = 58; + static final int STORY_DISTRIBUTION_LIST_SYNC = 59; } - public static final int CURRENT_VERSION = 58; + public static final int CURRENT_VERSION = 59; /** * This *must* be called after the {@link JobManager} has been instantiated, but *before* the call @@ -436,6 +437,10 @@ public class ApplicationMigrations { jobs.put(Version.PNI_IDENTITY_3, new PniAccountInitializationMigrationJob()); } + if (lastSeenVersion < Version.STORY_DISTRIBUTION_LIST_SYNC) { + jobs.put(Version.STORY_DISTRIBUTION_LIST_SYNC, new StorageServiceMigrationJob()); + } + return jobs; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java index ad49230af..986710b1e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java @@ -4,13 +4,19 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.annimon.stream.Stream; +import com.google.protobuf.ByteString; import org.signal.libsignal.zkgroup.groups.GroupMasterKey; import org.thoughtcrime.securesms.database.IdentityDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.DistributionListId; +import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord; +import org.thoughtcrime.securesms.database.model.DistributionListRecord; import org.thoughtcrime.securesms.database.model.RecipientRecord; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues; +import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.subscription.Subscriber; import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.push.SignalServiceAddress; @@ -19,11 +25,14 @@ import org.whispersystems.signalservice.api.storage.SignalContactRecord; import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; import org.whispersystems.signalservice.api.storage.SignalStorageRecord; +import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord; import org.whispersystems.signalservice.api.subscriptions.SubscriberId; +import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState; import java.util.List; +import java.util.stream.Collectors; public final class StorageSyncModels { @@ -47,10 +56,11 @@ public final class StorageSyncModels { public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientRecord settings, @NonNull byte[] rawStorageId) { switch (settings.getGroupType()) { - case NONE: return SignalStorageRecord.forContact(localToRemoteContact(settings, rawStorageId)); - case SIGNAL_V1: return SignalStorageRecord.forGroupV1(localToRemoteGroupV1(settings, rawStorageId)); - case SIGNAL_V2: return SignalStorageRecord.forGroupV2(localToRemoteGroupV2(settings, rawStorageId, settings.getSyncExtras().getGroupMasterKey())); - default: throw new AssertionError("Unsupported type!"); + case NONE: return SignalStorageRecord.forContact(localToRemoteContact(settings, rawStorageId)); + case SIGNAL_V1: return SignalStorageRecord.forGroupV1(localToRemoteGroupV1(settings, rawStorageId)); + case SIGNAL_V2: return SignalStorageRecord.forGroupV2(localToRemoteGroupV2(settings, rawStorageId, settings.getSyncExtras().getGroupMasterKey())); + case DISTRIBUTION_LIST: return SignalStorageRecord.forStoryDistributionList(localToRemoteStoryDistributionList(settings, rawStorageId)); + default: throw new AssertionError("Unsupported type!"); } } @@ -161,6 +171,38 @@ public final class StorageSyncModels { .build(); } + private static @NonNull SignalStoryDistributionListRecord localToRemoteStoryDistributionList(@NonNull RecipientRecord recipient, @NonNull byte[] rawStorageId) { + DistributionListId distributionListId = recipient.getDistributionListId(); + + if (distributionListId == null) { + throw new AssertionError("Must have a distributionListId!"); + } + + DistributionListRecord record = SignalDatabase.distributionLists().getListForStorageSync(distributionListId); + if (record == null) { + throw new AssertionError("Must have a distribution list record!"); + } + + if (record.getDeletedAtTimestamp() > 0L) { + return new SignalStoryDistributionListRecord.Builder(rawStorageId, recipient.getSyncExtras().getStorageProto()) + .setIdentifier(UuidUtil.toByteArray(record.getDistributionId().asUuid())) + .setDeletedAtTimestamp(record.getDeletedAtTimestamp()) + .build(); + } + + return new SignalStoryDistributionListRecord.Builder(rawStorageId, recipient.getSyncExtras().getStorageProto()) + .setIdentifier(UuidUtil.toByteArray(record.getDistributionId().asUuid())) + .setName(record.getName()) + .setRecipients(record.getMembers().stream() + .map(Recipient::resolved) + .filter(Recipient::hasServiceId) + .map(Recipient::requireServiceId) + .map(SignalServiceAddress::new) + .collect(Collectors.toList())) + .setAllowsReplies(record.getAllowsReplies()) + .build(); + } + public static @NonNull IdentityDatabase.VerifiedStatus remoteToLocalIdentityStatus(@NonNull IdentityState identityState) { switch (identityState) { case VERIFIED: return IdentityDatabase.VerifiedStatus.VERIFIED; diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessor.java new file mode 100644 index 000000000..5551fc554 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessor.java @@ -0,0 +1,144 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.StringUtil; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.RecipientRecord; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.signalservice.api.push.DistributionId; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +public class StoryDistributionListRecordProcessor extends DefaultStorageRecordProcessor { + + private static final String TAG = Log.tag(StoryDistributionListRecordProcessor.class); + + private boolean haveSeenMyStory; + + /** + * At a minimum, we require: + *
    + *
  • A valid identifier
  • + *
  • A non-visually-empty name field OR a deleted at timestamp
  • + *
+ */ + @Override + boolean isInvalid(@NonNull SignalStoryDistributionListRecord remote) { + UUID remoteUuid = UuidUtil.parseOrNull(remote.getIdentifier()); + if (remoteUuid == null) { + Log.d(TAG, "Bad distribution list identifier -- marking as invalid"); + return true; + } + + boolean isMyStory = remoteUuid.equals(DistributionId.MY_STORY.asUuid()); + if (haveSeenMyStory && isMyStory) { + Log.w(TAG, "Found an additional MyStory record -- marking as invalid"); + return true; + } + + haveSeenMyStory |= isMyStory; + + if (remote.getDeletedAtTimestamp() > 0L) { + if (isMyStory) { + Log.w(TAG, "Refusing to delete My Story -- marking as invalid"); + return true; + } else { + return false; + } + } + + if (StringUtil.isVisuallyEmpty(remote.getName())) { + Log.d(TAG, "Bad distribution list name (visually empty) -- marking as invalid"); + return true; + } + + return false; + } + + @Override + @NonNull Optional getMatching(@NonNull SignalStoryDistributionListRecord remote, @NonNull StorageKeyGenerator keyGenerator) { + RecipientId matching = SignalDatabase.distributionLists().getRecipientIdForSyncRecord(remote); + if (matching != null) { + RecipientRecord recordForSync = SignalDatabase.recipients().getRecordForSync(matching); + if (recordForSync == null) { + throw new IllegalStateException("Found matching recipient but couldn't generate record for sync."); + } + + return StorageSyncModels.localToRemoteRecord(recordForSync).getStoryDistributionList(); + } else { + return Optional.empty(); + } + } + + @Override + @NonNull SignalStoryDistributionListRecord merge(@NonNull SignalStoryDistributionListRecord remote, @NonNull SignalStoryDistributionListRecord local, @NonNull StorageKeyGenerator keyGenerator) { + byte[] unknownFields = remote.serializeUnknownFields(); + byte[] identifier = remote.getIdentifier(); + String name = remote.getName(); + List recipients = remote.getRecipients(); + long deletedAtTimestamp = remote.getDeletedAtTimestamp(); + boolean allowsReplies = remote.allowsReplies(); + + boolean matchesRemote = doParamsMatch(remote, unknownFields, identifier, name, recipients, deletedAtTimestamp, allowsReplies); + boolean matchesLocal = doParamsMatch(local, unknownFields, identifier, name, recipients, deletedAtTimestamp, allowsReplies); + + if (matchesRemote) { + return remote; + } else if (matchesLocal) { + return local; + } else { + return new SignalStoryDistributionListRecord.Builder(keyGenerator.generate(), unknownFields) + .setIdentifier(identifier) + .setName(name) + .setRecipients(recipients) + .setDeletedAtTimestamp(deletedAtTimestamp) + .setAllowsReplies(allowsReplies) + .build(); + } + } + + @Override + void insertLocal(@NonNull SignalStoryDistributionListRecord record) throws IOException { + SignalDatabase.distributionLists().applyStorageSyncStoryDistributionListInsert(record); + } + + @Override + void updateLocal(@NonNull StorageRecordUpdate update) { + SignalDatabase.distributionLists().applyStorageSyncStoryDistributionListUpdate(update); + } + + @Override + public int compare(SignalStoryDistributionListRecord o1, SignalStoryDistributionListRecord o2) { + if (Arrays.equals(o1.getIdentifier(), o2.getIdentifier())) { + return 0; + } else { + return 1; + } + } + + private boolean doParamsMatch(@NonNull SignalStoryDistributionListRecord record, + @Nullable byte[] unknownFields, + @Nullable byte[] identifier, + @Nullable String name, + @NonNull List recipients, + long deletedAtTimestamp, + boolean allowsReplies) { + return Arrays.equals(unknownFields, record.serializeUnknownFields()) && + Arrays.equals(identifier, record.getIdentifier()) && + Objects.equals(name, record.getName()) && + Objects.equals(recipients, record.getRecipients()) && + deletedAtTimestamp == record.getDeletedAtTimestamp() && + allowsReplies == record.allowsReplies(); + } +} 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 cd7f1ba43..159cea7e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt @@ -6,6 +6,7 @@ import io.reactivex.rxjava3.core.Completable import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.contacts.HeaderAction 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 @@ -14,6 +15,7 @@ import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientUtil import org.thoughtcrime.securesms.sms.MessageSender +import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.util.BottomSheetUtil import org.thoughtcrime.securesms.util.FeatureFlags @@ -51,4 +53,16 @@ object Stories { return RecipientUtil.getEligibleForSending(recipientIds.map(Recipient::resolved)) } + + @WorkerThread + fun onStorySettingsChanged(distributionListId: DistributionListId) { + val recipientId = SignalDatabase.distributionLists.getRecipientId(distributionListId) ?: error("Cannot find recipient id for distribution list.") + onStorySettingsChanged(recipientId) + } + + @WorkerThread + fun onStorySettingsChanged(storyRecipientId: RecipientId) { + SignalDatabase.recipients.markNeedsSync(storyRecipientId) + StorageSyncHelper.scheduleSyncForDataChange() + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersRepository.kt index f95208c51..06af60e68 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersRepository.kt @@ -4,6 +4,7 @@ import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.stories.Stories class CreateStoryWithViewersRepository { fun createList(name: CharSequence, members: Set): Single { @@ -12,6 +13,7 @@ class CreateStoryWithViewersRepository { if (result == null) { it.onError(Exception("Null result, due to a duplicated name.")) } else { + Stories.onStorySettingsChanged(result) it.onSuccess(SignalDatabase.recipients.getOrInsertFromDistributionListId(result)) } }.subscribeOn(Schedulers.io()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsRepository.kt index a465a73f0..386c8b72e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/PrivateStorySettingsRepository.kt @@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.DistributionListId import org.thoughtcrime.securesms.database.model.DistributionListRecord import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.stories.Stories class PrivateStorySettingsRepository { fun getRecord(distributionListId: DistributionListId): Single { @@ -18,12 +19,14 @@ class PrivateStorySettingsRepository { fun removeMember(distributionListId: DistributionListId, member: RecipientId): Completable { return Completable.fromAction { SignalDatabase.distributionLists.removeMemberFromList(distributionListId, member) + Stories.onStorySettingsChanged(distributionListId) }.subscribeOn(Schedulers.io()) } fun delete(distributionListId: DistributionListId): Completable { return Completable.fromAction { SignalDatabase.distributionLists.deleteList(distributionListId) + Stories.onStorySettingsChanged(distributionListId) }.subscribeOn(Schedulers.io()) } @@ -36,6 +39,7 @@ class PrivateStorySettingsRepository { fun setRepliesAndReactionsEnabled(distributionListId: DistributionListId, repliesAndReactionsEnabled: Boolean): Completable { return Completable.fromAction { SignalDatabase.distributionLists.setAllowsReplies(distributionListId, repliesAndReactionsEnabled) + Stories.onStorySettingsChanged(distributionListId) }.subscribeOn(Schedulers.io()) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/name/EditStoryNameRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/name/EditStoryNameRepository.kt index 33e0f3520..2e01d6398 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/name/EditStoryNameRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/custom/name/EditStoryNameRepository.kt @@ -4,6 +4,7 @@ import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.schedulers.Schedulers import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.stories.Stories class EditStoryNameRepository { fun save(privateStoryId: DistributionListId, name: CharSequence): Completable { @@ -13,6 +14,8 @@ class EditStoryNameRepository { } if (SignalDatabase.distributionLists.setName(privateStoryId, name.toString())) { + Stories.onStorySettingsChanged(privateStoryId) + it.onComplete() } else { it.onError(Exception("Could not update story name.")) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsRepository.kt index 59f31c52e..608a814f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsRepository.kt @@ -5,6 +5,7 @@ import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.stories.Stories class MyStorySettingsRepository { @@ -23,6 +24,7 @@ class MyStorySettingsRepository { fun setRepliesAndReactionsEnabled(repliesAndReactionsEnabled: Boolean): Completable { return Completable.fromAction { SignalDatabase.distributionLists.setAllowsReplies(DistributionListId.MY_STORY, repliesAndReactionsEnabled) + Stories.onStorySettingsChanged(DistributionListId.MY_STORY) }.subscribeOn(Schedulers.io()) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionRepository.kt index 69989ee27..effc964fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionRepository.kt @@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.DistributionListId import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.stories.Stories class BaseStoryRecipientSelectionRepository { fun updateDistributionListMembership(distributionListId: DistributionListId, recipients: Set) { @@ -23,6 +24,8 @@ class BaseStoryRecipientSelectionRepository { newNotOld.forEach { SignalDatabase.distributionLists.addMemberToList(distributionListId, it) } + + Stories.onStorySettingsChanged(distributionListId) } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessorTest.kt new file mode 100644 index 000000000..53bb09f57 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessorTest.kt @@ -0,0 +1,193 @@ +package org.thoughtcrime.securesms.storage + +import com.google.protobuf.ByteString +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.Test +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.testutil.EmptyLogger +import org.whispersystems.signalservice.api.push.DistributionId +import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord +import org.whispersystems.signalservice.api.storage.StorageId +import org.whispersystems.signalservice.api.util.UuidUtil +import org.whispersystems.signalservice.internal.storage.protos.StoryDistributionListRecord +import java.util.UUID + +class StoryDistributionListRecordProcessorTest { + + companion object { + val STORAGE_ID: StorageId = StorageId.forStoryDistributionList(byteArrayOf(1, 2, 3, 4)) + + @JvmStatic + @BeforeClass + fun setUpClass() { + Log.initialize(EmptyLogger()) + } + } + + private val testSubject = StoryDistributionListRecordProcessor() + + @Test + fun `Given a proto without an identifier, when I isInvalid, then I expect true`() { + // GIVEN + val proto = StoryDistributionListRecord.getDefaultInstance() + val record = SignalStoryDistributionListRecord(STORAGE_ID, proto) + + // WHEN + val result = testSubject.isInvalid(record) + + // THEN + assertTrue(result) + } + + @Test + fun `Given a proto with an identifier that is not a UUID, when I isInvalid, then I expect true`() { + // GIVEN + val proto = StoryDistributionListRecord + .getDefaultInstance() + .toBuilder() + .setIdentifier(ByteString.copyFrom("Greetings, fellow UUIDs".encodeToByteArray())) + .build() + + val record = SignalStoryDistributionListRecord(STORAGE_ID, proto) + + // WHEN + val result = testSubject.isInvalid(record) + + // THEN + assertTrue(result) + } + + @Test + fun `Given a proto without a name or deletion timestamp, when I isInvalid, then I expect true`() { + // GIVEN + val proto = StoryDistributionListRecord + .getDefaultInstance() + .toBuilder() + .setIdentifier(UuidUtil.toByteString(UUID.randomUUID())) + .build() + + val record = SignalStoryDistributionListRecord(STORAGE_ID, proto) + + // WHEN + val result = testSubject.isInvalid(record) + + // THEN + assertTrue(result) + } + + @Test + fun `Given a proto with a deletion timestamp, when I isInvalid, then I expect false`() { + // GIVEN + val proto = StoryDistributionListRecord + .getDefaultInstance() + .toBuilder() + .setIdentifier(UuidUtil.toByteString(UUID.randomUUID())) + .setDeletedAtTimestamp(1) + .build() + + val record = SignalStoryDistributionListRecord(STORAGE_ID, proto) + + // WHEN + val result = testSubject.isInvalid(record) + + // THEN + assertFalse(result) + } + + @Test + fun `Given a proto that is MyStory with a deletion timestamp, when I isInvalid, then I expect true`() { + // GIVEN + val proto = StoryDistributionListRecord + .getDefaultInstance() + .toBuilder() + .setIdentifier(UuidUtil.toByteString(DistributionId.MY_STORY.asUuid())) + .setDeletedAtTimestamp(1) + .build() + + val record = SignalStoryDistributionListRecord(STORAGE_ID, proto) + + // WHEN + val result = testSubject.isInvalid(record) + + // THEN + assertTrue(result) + } + + @Test + fun `Given a validated proto that is MyStory, when I isInvalid with another MyStory, then I expect true`() { + // GIVEN + val proto = StoryDistributionListRecord + .getDefaultInstance() + .toBuilder() + .setIdentifier(UuidUtil.toByteString(DistributionId.MY_STORY.asUuid())) + .setDeletedAtTimestamp(1) + .build() + + val record = SignalStoryDistributionListRecord(STORAGE_ID, proto) + testSubject.isInvalid(record) + + // WHEN + val result = testSubject.isInvalid(record) + + // THEN + assertTrue(result) + } + + @Test + fun `Given a proto with a visible name, when I isInvalid, then I expect false`() { + // GIVEN + val proto = StoryDistributionListRecord + .getDefaultInstance() + .toBuilder() + .setIdentifier(UuidUtil.toByteString(UUID.randomUUID())) + .setName("A visible name") + .build() + + val record = SignalStoryDistributionListRecord(STORAGE_ID, proto) + + // WHEN + val result = testSubject.isInvalid(record) + + // THEN + assertFalse(result) + } + + @Test + fun `Given a proto without a name, when I isInvalid, then I expect false`() { + // GIVEN + val proto = StoryDistributionListRecord + .getDefaultInstance() + .toBuilder() + .setIdentifier(UuidUtil.toByteString(UUID.randomUUID())) + .build() + + val record = SignalStoryDistributionListRecord(STORAGE_ID, proto) + + // WHEN + val result = testSubject.isInvalid(record) + + // THEN + assertTrue(result) + } + + @Test + fun `Given a proto without a visible name, when I isInvalid, then I expect true`() { + // GIVEN + val proto = StoryDistributionListRecord + .getDefaultInstance() + .toBuilder() + .setIdentifier(UuidUtil.toByteString(UUID.randomUUID())) + .setName(" ") + .build() + + val record = SignalStoryDistributionListRecord(STORAGE_ID, proto) + + // WHEN + val result = testSubject.isInvalid(record) + + // THEN + assertTrue(result) + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/DistributionId.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/DistributionId.java index b369f374b..4a4544917 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/DistributionId.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/DistributionId.java @@ -12,6 +12,8 @@ import java.util.UUID; */ public final class DistributionId { + public static final DistributionId MY_STORY = DistributionId.from("00000000-0000-0000-0000-000000000000"); + private final UUID uuid; public static DistributionId from(String id) { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageModels.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageModels.java index d2bfdbe40..fc06627f7 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageModels.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageModels.java @@ -44,6 +44,8 @@ public final class SignalStorageModels { return SignalStorageRecord.forGroupV2(id, new SignalGroupV2Record(id, record.getGroupV2())); } else if (record.hasAccount() && type == ManifestRecord.Identifier.Type.ACCOUNT_VALUE) { return SignalStorageRecord.forAccount(id, new SignalAccountRecord(id, record.getAccount())); + } else if (record.hasStoryDistributionList() && type == ManifestRecord.Identifier.Type.STORY_DISTRIBUTION_LIST_VALUE) { + return SignalStorageRecord.forStoryDistributionList(id, new SignalStoryDistributionListRecord(id, record.getStoryDistributionList())); } else { if (StorageId.isKnownType(type)) { Log.w(TAG, "StorageId is of known type (" + type + "), but the data is bad! Falling back to unknown."); @@ -63,6 +65,8 @@ public final class SignalStorageModels { builder.setGroupV2(record.getGroupV2().get().toProto()); } else if (record.getAccount().isPresent()) { builder.setAccount(record.getAccount().get().toProto()); + } else if (record.getStoryDistributionList().isPresent()) { + builder.setStoryDistributionList(record.getStoryDistributionList().get().toProto()); } else { throw new InvalidStorageWriteError(); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageRecord.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageRecord.java index bf68db412..5e21e33d5 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageRecord.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageRecord.java @@ -6,18 +6,27 @@ import java.util.Optional; public class SignalStorageRecord implements SignalRecord { - private final StorageId id; - private final Optional contact; - private final Optional groupV1; - private final Optional groupV2; - private final Optional account; + private final StorageId id; + private final Optional storyDistributionList; + private final Optional contact; + private final Optional groupV1; + private final Optional groupV2; + private final Optional account; + + public static SignalStorageRecord forStoryDistributionList(SignalStoryDistributionListRecord storyDistributionList) { + return forStoryDistributionList(storyDistributionList.getId(), storyDistributionList); + } + + public static SignalStorageRecord forStoryDistributionList(StorageId key, SignalStoryDistributionListRecord storyDistributionList) { + return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(storyDistributionList)); + } public static SignalStorageRecord forContact(SignalContactRecord contact) { return forContact(contact.getId(), contact); } public static SignalStorageRecord forContact(StorageId key, SignalContactRecord contact) { - return new SignalStorageRecord(key, Optional.of(contact), Optional.empty(), Optional.empty(), Optional.empty()); + return new SignalStorageRecord(key, Optional.of(contact), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); } public static SignalStorageRecord forGroupV1(SignalGroupV1Record groupV1) { @@ -25,7 +34,7 @@ public class SignalStorageRecord implements SignalRecord { } public static SignalStorageRecord forGroupV1(StorageId key, SignalGroupV1Record groupV1) { - return new SignalStorageRecord(key, Optional.empty(), Optional.of(groupV1), Optional.empty(), Optional.empty()); + return new SignalStorageRecord(key, Optional.empty(), Optional.of(groupV1), Optional.empty(), Optional.empty(), Optional.empty()); } public static SignalStorageRecord forGroupV2(SignalGroupV2Record groupV2) { @@ -33,7 +42,7 @@ public class SignalStorageRecord implements SignalRecord { } public static SignalStorageRecord forGroupV2(StorageId key, SignalGroupV2Record groupV2) { - return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.of(groupV2), Optional.empty()); + return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.of(groupV2), Optional.empty(), Optional.empty()); } public static SignalStorageRecord forAccount(SignalAccountRecord account) { @@ -41,24 +50,26 @@ public class SignalStorageRecord implements SignalRecord { } public static SignalStorageRecord forAccount(StorageId key, SignalAccountRecord account) { - return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(account)); + return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(account), Optional.empty()); } public static SignalStorageRecord forUnknown(StorageId key) { - return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); + return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); } private SignalStorageRecord(StorageId id, Optional contact, Optional groupV1, Optional groupV2, - Optional account) + Optional account, + Optional storyDistributionList) { - this.id = id; - this.contact = contact; - this.groupV1 = groupV1; - this.groupV2 = groupV2; - this.account = account; + this.id = id; + this.contact = contact; + this.groupV1 = groupV1; + this.groupV2 = groupV2; + this.account = account; + this.storyDistributionList = storyDistributionList; } @Override @@ -96,8 +107,12 @@ public class SignalStorageRecord implements SignalRecord { return account; } + public Optional getStoryDistributionList() { + return storyDistributionList; + } + public boolean isUnknown() { - return !contact.isPresent() && !groupV1.isPresent() && !groupV2.isPresent() && !account.isPresent(); + return !contact.isPresent() && !groupV1.isPresent() && !groupV2.isPresent() && !account.isPresent() && !storyDistributionList.isPresent(); } @Override @@ -108,11 +123,12 @@ public class SignalStorageRecord implements SignalRecord { return Objects.equals(id, that.id) && Objects.equals(contact, that.contact) && Objects.equals(groupV1, that.groupV1) && - Objects.equals(groupV2, that.groupV2); + Objects.equals(groupV2, that.groupV2) && + Objects.equals(storyDistributionList, that.storyDistributionList); } @Override public int hashCode() { - return Objects.hash(id, contact, groupV1, groupV2); + return Objects.hash(id, contact, groupV1, groupV2, storyDistributionList); } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStoryDistributionListRecord.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStoryDistributionListRecord.java new file mode 100644 index 000000000..91132dc9a --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStoryDistributionListRecord.java @@ -0,0 +1,182 @@ +package org.whispersystems.signalservice.api.storage; + +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; + +import org.signal.libsignal.protocol.logging.Log; +import org.whispersystems.signalservice.api.push.ServiceId; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.util.ProtoUtil; +import org.whispersystems.signalservice.internal.storage.protos.StoryDistributionListRecord; + +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +public class SignalStoryDistributionListRecord implements SignalRecord { + + private static final String TAG = SignalStoryDistributionListRecord.class.getSimpleName(); + + private final StorageId id; + private final StoryDistributionListRecord proto; + private final boolean hasUnknownFields; + private final List recipients; + + public SignalStoryDistributionListRecord(StorageId id, StoryDistributionListRecord proto) { + this.id = id; + this.proto = proto; + this.hasUnknownFields = ProtoUtil.hasUnknownFields(proto); + this.recipients = proto.getRecipientUuidsList() + .stream() + .map(ServiceId::parseOrNull) + .filter(Objects::nonNull) + .map(SignalServiceAddress::new) + .collect(Collectors.toList()); + } + + @Override + public StorageId getId() { + return id; + } + + @Override + public SignalStorageRecord asStorageRecord() { + return SignalStorageRecord.forStoryDistributionList(this); + } + + public StoryDistributionListRecord toProto() { + return proto; + } + + public byte[] serializeUnknownFields() { + return hasUnknownFields ? proto.toByteArray() : null; + } + + public byte[] getIdentifier() { + return proto.getIdentifier().toByteArray(); + } + + public String getName() { + return proto.getName(); + } + + public List getRecipients() { + return recipients; + } + + public long getDeletedAtTimestamp() { + return proto.getDeletedAtTimestamp(); + } + + public boolean allowsReplies() { + return proto.getAllowsReplies(); + } + + @Override + public String describeDiff(SignalRecord other) { + if (other instanceof SignalStoryDistributionListRecord) { + SignalStoryDistributionListRecord that = (SignalStoryDistributionListRecord) other; + List diff = new LinkedList<>(); + + if (!Arrays.equals(this.id.getRaw(), that.id.getRaw())) { + diff.add("ID"); + } + + if (!Arrays.equals(this.getIdentifier(), that.getIdentifier())) { + diff.add("Identifier"); + } + + if (!Objects.equals(this.getName(), that.getName())) { + diff.add("Name"); + } + + if (!Objects.equals(this.recipients, that.recipients)) { + diff.add("RecipientUuids"); + } + + if (this.getDeletedAtTimestamp() != that.getDeletedAtTimestamp()) { + diff.add("DeletedAtTimestamp"); + } + + if (this.allowsReplies() != that.allowsReplies()) { + diff.add("AllowsReplies"); + } + + return diff.toString(); + } else { + return "Different class. " + getClass().getSimpleName() + " | " + other.getClass().getSimpleName(); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SignalStoryDistributionListRecord that = (SignalStoryDistributionListRecord) o; + return id.equals(that.id) && + proto.equals(that.proto); + } + + @Override + public int hashCode() { + return Objects.hash(id, proto); + } + + public static final class Builder { + private final StorageId id; + private final StoryDistributionListRecord.Builder builder; + + public Builder(byte[] rawId, byte[] serializedUnknowns) { + this.id = StorageId.forStoryDistributionList(rawId); + + if (serializedUnknowns != null) { + this.builder = parseUnknowns(serializedUnknowns); + } else { + this.builder = StoryDistributionListRecord.newBuilder(); + } + } + + public Builder setIdentifier(byte[] identifier) { + builder.setIdentifier(ByteString.copyFrom(identifier)); + return this; + } + + public Builder setName(String name) { + builder.setName(name); + return this; + } + + public Builder setRecipients(List recipients) { + builder.clearRecipientUuids(); + builder.addAllRecipientUuids(recipients.stream() + .map(SignalServiceAddress::getIdentifier) + .collect(Collectors.toList())); + return this; + } + + public Builder setDeletedAtTimestamp(long deletedAtTimestamp) { + builder.setDeletedAtTimestamp(deletedAtTimestamp); + return this; + } + + public Builder setAllowsReplies(boolean allowsReplies) { + builder.setAllowsReplies(allowsReplies); + return this; + } + + public SignalStoryDistributionListRecord build() { + return new SignalStoryDistributionListRecord(id, builder.build()); + } + + private static StoryDistributionListRecord.Builder parseUnknowns(byte[] serializedUnknowns) { + try { + return StoryDistributionListRecord.parseFrom(serializedUnknowns).toBuilder(); + } catch (InvalidProtocolBufferException e) { + Log.w(TAG, "Failed to combine unknown fields!", e); + return StoryDistributionListRecord.newBuilder(); + } + } + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/StorageId.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/StorageId.java index 1569ac58b..dc3082d8c 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/StorageId.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/StorageId.java @@ -23,6 +23,10 @@ public class StorageId { return new StorageId(ManifestRecord.Identifier.Type.GROUPV2_VALUE, Preconditions.checkNotNull(raw)); } + public static StorageId forStoryDistributionList(byte[] raw) { + return new StorageId(ManifestRecord.Identifier.Type.STORY_DISTRIBUTION_LIST_VALUE, Preconditions.checkNotNull(raw)); + } + public static StorageId forAccount(byte[] raw) { return new StorageId(ManifestRecord.Identifier.Type.ACCOUNT_VALUE, Preconditions.checkNotNull(raw)); } diff --git a/libsignal/service/src/main/proto/StorageService.proto b/libsignal/service/src/main/proto/StorageService.proto index 34cc6ea0b..8202330d5 100644 --- a/libsignal/service/src/main/proto/StorageService.proto +++ b/libsignal/service/src/main/proto/StorageService.proto @@ -38,11 +38,12 @@ message WriteOperation { message ManifestRecord { message Identifier { enum Type { - UNKNOWN = 0; - CONTACT = 1; - GROUPV1 = 2; - GROUPV2 = 3; - ACCOUNT = 4; + UNKNOWN = 0; + CONTACT = 1; + GROUPV1 = 2; + GROUPV2 = 3; + ACCOUNT = 4; + STORY_DISTRIBUTION_LIST = 5; } bytes raw = 1; @@ -55,10 +56,11 @@ message ManifestRecord { message StorageRecord { oneof record { - ContactRecord contact = 1; - GroupV1Record groupV1 = 2; - GroupV2Record groupV2 = 3; - AccountRecord account = 4; + ContactRecord contact = 1; + GroupV1Record groupV1 = 2; + GroupV2Record groupV2 = 3; + AccountRecord account = 4; + StoryDistributionListRecord storyDistributionList = 5; } } @@ -156,3 +158,11 @@ message AccountRecord { bool displayBadgesOnProfile = 23; bool subscriptionManuallyCancelled = 24; } + +message StoryDistributionListRecord { + bytes identifier = 1; + string name = 2; + repeated string recipientUuids = 3; + uint64 deletedAtTimestamp = 4; + bool allowsReplies = 5; +}