Implement StoryDistributionListRecord and processing.

This commit is contained in:
Alex Hart
2022-03-25 14:27:03 -03:00
committed by Cody Henthorne
parent 2cd7462573
commit c359b0134a
21 changed files with 896 additions and 64 deletions

View File

@@ -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<String> = 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<DistributionListPartialRecord> {
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<DistributionListPartialRecord>()
@@ -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<RecipientId>): DistributionListId? {
fun createList(
name: String,
members: List<RecipientId>,
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<SignalStoryDistributionListRecord>) {
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()}"
}
}

View File

@@ -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<RecipientId, StorageId> = 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)
}

View File

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

View File

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

View File

@@ -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<SignalContactRecord> remoteContacts = new LinkedList<>();
List<SignalGroupV1Record> remoteGv1 = new LinkedList<>();
List<SignalGroupV2Record> remoteGv2 = new LinkedList<>();
List<SignalAccountRecord> remoteAccount = new LinkedList<>();
List<SignalStorageRecord> remoteUnknown = new LinkedList<>();
List<SignalContactRecord> remoteContacts = new LinkedList<>();
List<SignalGroupV1Record> remoteGv1 = new LinkedList<>();
List<SignalGroupV2Record> remoteGv2 = new LinkedList<>();
List<SignalAccountRecord> remoteAccount = new LinkedList<>();
List<SignalStorageRecord> remoteUnknown = new LinkedList<>();
List<SignalStoryDistributionListRecord> 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<SignalStorageRecord> unknownInserts = remoteUnknown;
List<StorageId> 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) {

View File

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

View File

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

View File

@@ -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<SignalStoryDistributionListRecord> {
private static final String TAG = Log.tag(StoryDistributionListRecordProcessor.class);
private boolean haveSeenMyStory;
/**
* At a minimum, we require:
* <ul>
* <li>A valid identifier</li>
* <li>A non-visually-empty name field OR a deleted at timestamp</li>
* </ul>
*/
@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<SignalStoryDistributionListRecord> 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<SignalServiceAddress> 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<SignalStoryDistributionListRecord> 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<SignalServiceAddress> 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();
}
}

View File

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

View File

@@ -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<RecipientId>): Single<RecipientId> {
@@ -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())

View File

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

View File

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

View File

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

View File

@@ -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<RecipientId>) {
@@ -23,6 +24,8 @@ class BaseStoryRecipientSelectionRepository {
newNotOld.forEach {
SignalDatabase.distributionLists.addMemberToList(distributionListId, it)
}
Stories.onStorySettingsChanged(distributionListId)
}
}