From 0e200b1fb6c6d0d6e66ea86f7bead665441d6caf Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 16 Mar 2021 10:01:46 -0400 Subject: [PATCH] Rewrite storage service change processing. --- app/src/main/AndroidManifest.xml | 4 - .../contacts/sync/DirectoryHelper.java | 2 +- .../securesms/database/Database.java | 6 - .../database/DatabaseContentProviders.java | 7 - .../securesms/database/DatabaseObserver.java | 2 - .../securesms/database/RecipientDatabase.java | 207 ++++++++- .../securesms/database/SearchDatabase.java | 17 +- .../database/StorageKeyDatabase.java | 47 +- .../securesms/database/ThreadDatabase.java | 14 +- .../securesms/jobs/JobManagerFactories.java | 1 + .../jobs/StorageAccountRestoreJob.java | 3 +- .../securesms/jobs/StorageSyncJob.java | 19 +- .../securesms/jobs/StorageSyncJobV2.java | 435 ++++++++++++++++++ .../StorageServiceMigrationJob.java | 4 +- .../securesms/pin/PinRestoreRepository.java | 2 +- .../registration/RegistrationUtil.java | 2 +- .../storage/AccountConflictMerger.java | 2 +- .../storage/AccountRecordProcessor.java | 177 +++++++ .../storage/ContactConflictMerger.java | 2 +- .../storage/ContactRecordProcessor.java | 172 +++++++ .../DefaultStorageRecordProcessor.java | 99 ++++ .../storage/GroupV1ConflictMerger.java | 2 +- .../storage/GroupV1RecordProcessor.java | 120 +++++ .../storage/GroupV2ConflictMerger.java | 2 +- .../storage/GroupV2RecordProcessor.java | 119 +++++ .../storage/StorageKeyGenerator.java | 11 + .../storage/StorageRecordProcessor.java | 67 +++ .../storage/StorageRecordUpdate.java | 47 ++ .../securesms/storage/StorageSyncHelper.java | 205 ++++----- .../storage/StorageSyncValidations.java | 8 +- .../securesms/util/FeatureFlags.java | 12 +- .../storage/ContactConflictMergerTest.java | 9 +- .../storage/GroupV1ConflictMergerTest.java | 7 +- .../storage/GroupV2ConflictMergerTest.java | 7 +- .../storage/StorageSyncHelperTest.java | 10 +- .../api/storage/SignalAccountRecord.java | 96 ++++ .../api/storage/SignalContactRecord.java | 71 +++ .../api/storage/SignalGroupV1Record.java | 44 ++ .../api/storage/SignalGroupV2Record.java | 44 ++ .../api/storage/SignalRecord.java | 2 + .../api/storage/SignalStorageRecord.java | 10 + .../signalservice/api/storage/StorageId.java | 4 + 42 files changed, 1913 insertions(+), 208 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJobV2.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/storage/ContactRecordProcessor.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/storage/DefaultStorageRecordProcessor.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1RecordProcessor.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2RecordProcessor.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/storage/StorageKeyGenerator.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordProcessor.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordUpdate.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 677e019ff5..bb0f020485 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -746,10 +746,6 @@ android:authorities="${applicationId}.database.conversation" android:exported="false" /> - - diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java index ada82849a2..fe772c7f8e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java @@ -194,7 +194,7 @@ public class DirectoryHelper { if (newRegisteredState != originalRegisteredState) { ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob()); - ApplicationDependencies.getJobManager().add(new StorageSyncJob()); + ApplicationDependencies.getJobManager().add(StorageSyncJob.create()); if (notifyOfNewUsers && newRegisteredState == RegisteredState.REGISTERED && recipient.resolve().isSystemContact()) { notifyNewUsers(context, Collections.singletonList(recipient.getId())); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Database.java b/app/src/main/java/org/thoughtcrime/securesms/database/Database.java index e890d669d4..f2ffb2ec09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Database.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Database.java @@ -61,7 +61,6 @@ public abstract class Database { protected void notifyConversationListListeners() { ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners(); - context.getContentResolver().notifyChange(DatabaseContentProviders.ConversationList.CONTENT_URI, null); } protected void notifyStickerListeners() { @@ -87,11 +86,6 @@ public abstract class Database { cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.Conversation.getVerboseUriForThread(threadId)); } - @Deprecated - protected void setNotifyConversationListListeners(Cursor cursor) { - cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.ConversationList.CONTENT_URI); - } - @Deprecated protected void setNotifyStickerListeners(Cursor cursor) { cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.Sticker.CONTENT_URI); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseContentProviders.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseContentProviders.java index ed22c13cc4..e0d14d585c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseContentProviders.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseContentProviders.java @@ -16,13 +16,6 @@ import org.thoughtcrime.securesms.BuildConfig; */ public class DatabaseContentProviders { - public static class ConversationList extends NoopContentProvider { - private static final String CONTENT_AUTHORITY = BuildConfig.APPLICATION_ID + ".database.conversationlist"; - private static final String CONTENT_URI_STRING = "content://" + CONTENT_AUTHORITY; - - public static final Uri CONTENT_URI = Uri.parse(CONTENT_URI_STRING); - } - public static class Conversation extends NoopContentProvider { private static final String CONTENT_AUTHORITY = BuildConfig.APPLICATION_ID + ".database.conversation"; private static final String CONTENT_URI_STRING = "content://" + CONTENT_AUTHORITY + "/"; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java index 8908cb87b3..aee9794c50 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java @@ -117,8 +117,6 @@ public final class DatabaseObserver { listener.onChanged(); } }); - - application.getContentResolver().notifyChange(DatabaseContentProviders.ConversationList.CONTENT_URI, null); } public void notifyPaymentListeners(@NonNull UUID paymentId) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index fd2d679df2..ea416d26b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -44,7 +44,7 @@ import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.storage.StorageSyncHelper; -import org.thoughtcrime.securesms.storage.StorageSyncHelper.RecordUpdate; +import org.thoughtcrime.securesms.storage.StorageRecordUpdate; import org.thoughtcrime.securesms.storage.StorageSyncModels; import org.thoughtcrime.securesms.util.Base64; import org.thoughtcrime.securesms.util.Bitmask; @@ -61,12 +61,15 @@ import org.whispersystems.libsignal.IdentityKey; import org.whispersystems.libsignal.InvalidKeyException; import org.whispersystems.libsignal.util.Pair; import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.libsignal.util.guava.Preconditions; import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.storage.SignalAccountRecord; 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.SignalRecord; +import org.whispersystems.signalservice.api.storage.SignalStorageRecord; import org.whispersystems.signalservice.api.storage.StorageId; import org.whispersystems.signalservice.api.util.UuidUtil; @@ -812,12 +815,170 @@ public class RecipientDatabase extends Database { } } - public boolean applyStorageSyncUpdates(@NonNull Collection contactInserts, - @NonNull Collection> contactUpdates, - @NonNull Collection groupV1Inserts, - @NonNull Collection> groupV1Updates, - @NonNull Collection groupV2Inserts, - @NonNull Collection> groupV2Updates) + public void applyStorageSyncContactInsert(@NonNull SignalContactRecord insert) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); + + ContentValues values = getValuesForStorageContact(insert, true); + long id = db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE); + RecipientId recipientId = null; + + if (id < 0) { + Log.w(TAG, "[applyStorageSyncContactInsert] Failed to insert. Possibly merging."); + recipientId = getAndPossiblyMerge(insert.getAddress().getUuid().get(), insert.getAddress().getNumber().get(), true); + db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(recipientId)); + } else { + recipientId = RecipientId.from(id); + } + + if (insert.getIdentityKey().isPresent()) { + try { + IdentityKey identityKey = new IdentityKey(insert.getIdentityKey().get(), 0); + + DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncModels.remoteToLocalIdentityStatus(insert.getIdentityState())); + } catch (InvalidKeyException e) { + Log.w(TAG, "Failed to process identity key during insert! Skipping.", e); + } + } + + threadDatabase.applyStorageSyncUpdate(recipientId, insert); + } + + public void applyStorageSyncContactUpdate(@NonNull StorageRecordUpdate update) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context); + ContentValues values = getValuesForStorageContact(update.getNew(), false); + + try { + int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_ID + " = ?", new String[]{Base64.encodeBytes(update.getOld().getId().getRaw())}); + if (updateCount < 1) { + throw new AssertionError("Had an update, but it didn't match any rows!"); + } + } catch (SQLiteConstraintException e) { + Log.w(TAG, "[applyStorageSyncContactUpdate] Failed to update a user by storageId."); + + RecipientId recipientId = getByColumn(STORAGE_SERVICE_ID, Base64.encodeBytes(update.getOld().getId().getRaw())).get(); + Log.w(TAG, "[applyStorageSyncContactUpdate] Found user " + recipientId + ". Possibly merging."); + + recipientId = getAndPossiblyMerge(update.getNew().getAddress().getUuid().orNull(), update.getNew().getAddress().getNumber().orNull(), true); + Log.w(TAG, "[applyStorageSyncContactUpdate] Merged into " + recipientId); + + db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(recipientId)); + } + + RecipientId recipientId = getByStorageKeyOrThrow(update.getNew().getId().getRaw()); + + if (StorageSyncHelper.profileKeyChanged(update)) { + ContentValues clearValues = new ContentValues(1); + clearValues.putNull(PROFILE_KEY_CREDENTIAL); + db.update(TABLE_NAME, clearValues, ID_WHERE, SqlUtil.buildArgs(recipientId)); + } + + try { + Optional oldIdentityRecord = identityDatabase.getIdentity(recipientId); + + if (update.getNew().getIdentityKey().isPresent()) { + IdentityKey identityKey = new IdentityKey(update.getNew().getIdentityKey().get(), 0); + DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncModels.remoteToLocalIdentityStatus(update.getNew().getIdentityState())); + } + + Optional newIdentityRecord = identityDatabase.getIdentity(recipientId); + + if ((newIdentityRecord.isPresent() && newIdentityRecord.get().getVerifiedStatus() == VerifiedStatus.VERIFIED) && + (!oldIdentityRecord.isPresent() || oldIdentityRecord.get().getVerifiedStatus() != VerifiedStatus.VERIFIED)) + { + IdentityUtil.markIdentityVerified(context, Recipient.resolved(recipientId), true, true); + } else if ((newIdentityRecord.isPresent() && newIdentityRecord.get().getVerifiedStatus() != VerifiedStatus.VERIFIED) && + (oldIdentityRecord.isPresent() && oldIdentityRecord.get().getVerifiedStatus() == VerifiedStatus.VERIFIED)) + { + IdentityUtil.markIdentityVerified(context, Recipient.resolved(recipientId), false, true); + } + } catch (InvalidKeyException e) { + Log.w(TAG, "Failed to process identity key during update! Skipping.", e); + } + + DatabaseFactory.getThreadDatabase(context).applyStorageSyncUpdate(recipientId, update.getNew()); + + Recipient.live(recipientId).refresh(); + } + + public void applyStorageSyncGroupV1Insert(@NonNull SignalGroupV1Record insert) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + long id = db.insertOrThrow(TABLE_NAME, null, getValuesForStorageGroupV1(insert)); + RecipientId recipientId = RecipientId.from(id); + + DatabaseFactory.getThreadDatabase(context).applyStorageSyncUpdate(recipientId, insert); + + Recipient.live(recipientId).refresh(); + } + + public void applyStorageSyncGroupV1Update(@NonNull StorageRecordUpdate update) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + ContentValues values = getValuesForStorageGroupV1(update.getNew()); + int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_ID + " = ?", new String[]{Base64.encodeBytes(update.getOld().getId().getRaw())}); + + if (updateCount < 1) { + throw new AssertionError("Had an update, but it didn't match any rows!"); + } + + Recipient recipient = Recipient.externalGroupExact(context, GroupId.v1orThrow(update.getOld().getGroupId())); + + DatabaseFactory.getThreadDatabase(context).applyStorageSyncUpdate(recipient.getId(), update.getNew()); + + recipient.live().refresh(); + } + + public void applyStorageSyncGroupV2Insert(@NonNull SignalGroupV2Record insert) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + GroupMasterKey masterKey = insert.getMasterKeyOrThrow(); + GroupId.V2 groupId = GroupId.v2(masterKey); + ContentValues values = getValuesForStorageGroupV2(insert); + long id = db.insertOrThrow(TABLE_NAME, null, values); + Recipient recipient = Recipient.externalGroupExact(context, groupId); + + Log.i(TAG, "Creating restore placeholder for " + groupId); + DatabaseFactory.getGroupDatabase(context) + .create(masterKey, + DecryptedGroup.newBuilder() + .setRevision(GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) + .build()); + + Log.i(TAG, "Scheduling request for latest group info for " + groupId); + + ApplicationDependencies.getJobManager().add(new RequestGroupV2InfoJob(groupId)); + + DatabaseFactory.getThreadDatabase(context).applyStorageSyncUpdate(recipient.getId(), insert); + + recipient.live().refresh(); + } + + public void applyStorageSyncGroupV2Update(@NonNull StorageRecordUpdate update) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + ContentValues values = getValuesForStorageGroupV2(update.getNew()); + int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_ID + " = ?", new String[]{Base64.encodeBytes(update.getOld().getId().getRaw())}); + + if (updateCount < 1) { + throw new AssertionError("Had an update, but it didn't match any rows!"); + } + + GroupMasterKey masterKey = update.getOld().getMasterKeyOrThrow(); + Recipient recipient = Recipient.externalGroupExact(context, GroupId.v2(masterKey)); + + DatabaseFactory.getThreadDatabase(context).applyStorageSyncUpdate(recipient.getId(), update.getNew()); + + recipient.live().refresh(); + } + + public boolean applyStorageSyncUpdates(@NonNull Collection contactInserts, + @NonNull Collection> contactUpdates, + @NonNull Collection groupV1Inserts, + @NonNull Collection> groupV1Updates, + @NonNull Collection groupV2Inserts, + @NonNull Collection> groupV2Updates) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context); @@ -889,7 +1050,7 @@ public class RecipientDatabase extends Database { needsRefresh.add(recipientId); } - for (RecordUpdate update : contactUpdates) { + for (StorageRecordUpdate update : contactUpdates) { ContentValues values = getValuesForStorageContact(update.getNew(), false); try { @@ -932,7 +1093,7 @@ public class RecipientDatabase extends Database { { IdentityUtil.markIdentityVerified(context, Recipient.resolved(recipientId), true, true); } else if ((newIdentityRecord.isPresent() && newIdentityRecord.get().getVerifiedStatus() != VerifiedStatus.VERIFIED) && - (oldIdentityRecord.isPresent() && oldIdentityRecord.get().getVerifiedStatus() == VerifiedStatus.VERIFIED)) + (oldIdentityRecord.isPresent() && oldIdentityRecord.get().getVerifiedStatus() == VerifiedStatus.VERIFIED)) { IdentityUtil.markIdentityVerified(context, Recipient.resolved(recipientId), false, true); } @@ -958,7 +1119,7 @@ public class RecipientDatabase extends Database { } } - for (RecordUpdate update : groupV1Updates) { + for (StorageRecordUpdate update : groupV1Updates) { ContentValues values = getValuesForStorageGroupV1(update.getNew()); int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_ID + " = ?", new String[]{Base64.encodeBytes(update.getOld().getId().getRaw())}); @@ -971,7 +1132,7 @@ public class RecipientDatabase extends Database { threadDatabase.applyStorageSyncUpdate(recipient.getId(), update.getNew()); needsRefresh.add(recipient.getId()); } - + for (SignalGroupV2Record insert : groupV2Inserts) { GroupMasterKey masterKey = insert.getMasterKeyOrThrow(); GroupId.V2 groupId = GroupId.v2(masterKey); @@ -988,9 +1149,9 @@ public class RecipientDatabase extends Database { Log.i(TAG, "Creating restore placeholder for " + groupId); DatabaseFactory.getGroupDatabase(context) .create(masterKey, - DecryptedGroup.newBuilder() - .setRevision(GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) - .build()); + DecryptedGroup.newBuilder() + .setRevision(GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) + .build()); Log.i(TAG, "Scheduling request for latest group info for " + groupId); @@ -1000,7 +1161,7 @@ public class RecipientDatabase extends Database { needsRefresh.add(recipient.getId()); } - for (RecordUpdate update : groupV2Updates) { + for (StorageRecordUpdate update : groupV2Updates) { ContentValues values = getValuesForStorageGroupV2(update.getNew()); int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_ID + " = ?", new String[]{Base64.encodeBytes(update.getOld().getId().getRaw())}); @@ -2571,6 +2732,22 @@ public class RecipientDatabase extends Database { } } + public void clearDirtyStateForRecords(@NonNull List records) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + Preconditions.checkArgument(db.inTransaction(), "Database should already be in a transaction."); + + ContentValues values = new ContentValues(); + values.put(DIRTY, DirtyState.CLEAN.getId()); + + String query = STORAGE_SERVICE_ID + " = ?"; + + for (SignalRecord record : records) { + String[] args = SqlUtil.buildArgs(Base64.encodeBytes(record.getId().getRaw())); + db.update(TABLE_NAME, values, query, args); + } + } + public void clearDirtyState(@NonNull List recipients) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); db.beginTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java index 6cf3793c73..e2ec1b91a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SearchDatabase.java @@ -133,11 +133,7 @@ public class SearchDatabase extends Database { return null; } - Cursor cursor = db.rawQuery(MESSAGES_QUERY, new String[] { fullTextSearchQuery, - fullTextSearchQuery }); - - setNotifyConversationListListeners(cursor); - return cursor; + return db.rawQuery(MESSAGES_QUERY, new String[] { fullTextSearchQuery, fullTextSearchQuery }); } public Cursor queryMessages(@NonNull String query, long threadId) { @@ -148,13 +144,10 @@ public class SearchDatabase extends Database { return null; } - Cursor cursor = db.rawQuery(MESSAGES_FOR_THREAD_QUERY, new String[] { fullTextSearchQuery, - String.valueOf(threadId), - fullTextSearchQuery, - String.valueOf(threadId) }); - - setNotifyConversationListListeners(cursor); - return cursor; + return db.rawQuery(MESSAGES_FOR_THREAD_QUERY, new String[] { fullTextSearchQuery, + String.valueOf(threadId), + fullTextSearchQuery, + String.valueOf(threadId) }); } private static String createFullTextSearchQuery(@NonNull String query) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/StorageKeyDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/StorageKeyDatabase.java index ddada97402..ad7c812448 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/StorageKeyDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/StorageKeyDatabase.java @@ -7,14 +7,20 @@ import android.database.Cursor; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.annimon.stream.Stream; + import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.util.Base64; +import org.thoughtcrime.securesms.util.SqlUtil; +import org.whispersystems.libsignal.util.guava.Preconditions; import org.whispersystems.signalservice.api.storage.SignalStorageRecord; import org.whispersystems.signalservice.api.storage.StorageId; +import org.whispersystems.signalservice.internal.storage.protos.SignalStorage; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.List; /** @@ -79,26 +85,39 @@ public class StorageKeyDatabase extends Database { db.beginTransaction(); try { - for (SignalStorageRecord insert : inserts) { - ContentValues values = new ContentValues(); - values.put(TYPE, insert.getType()); - values.put(STORAGE_ID, Base64.encodeBytes(insert.getId().getRaw())); - - db.insert(TABLE_NAME, null, values); - } - - String deleteQuery = STORAGE_ID + " = ?"; - - for (SignalStorageRecord delete : deletes) { - String[] args = new String[] { Base64.encodeBytes(delete.getId().getRaw()) }; - db.delete(TABLE_NAME, deleteQuery, args); - } + insert(inserts); + delete(Stream.of(deletes).map(SignalStorageRecord::getId).toList()); db.setTransactionSuccessful(); } finally { db.endTransaction(); } + } + public void insert(@NonNull Collection inserts) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + Preconditions.checkArgument(db.inTransaction(), "Must be in a transaction!"); + + for (SignalStorageRecord insert : inserts) { + ContentValues values = new ContentValues(); + values.put(TYPE, insert.getType()); + values.put(STORAGE_ID, Base64.encodeBytes(insert.getId().getRaw())); + + db.insert(TABLE_NAME, null, values); + } + } + + public void delete(@NonNull Collection deletes) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + String deleteQuery = STORAGE_ID + " = ?"; + + Preconditions.checkArgument(db.inTransaction(), "Must be in a transaction!"); + + for (StorageId id : deletes) { + String[] args = SqlUtil.buildArgs(Base64.encodeBytes(id.getRaw())); + db.delete(TABLE_NAME, deleteQuery, args); + } } public void deleteByType(int type) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index baca37633d..e387c1610a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -514,7 +514,6 @@ public class ThreadDatabase extends Database { } Cursor cursor = cursors.size() > 1 ? new MergeCursor(cursors.toArray(new Cursor[cursors.size()])) : cursors.get(0); - setNotifyConversationListListeners(cursor); return cursor; } @@ -707,8 +706,6 @@ public class ThreadDatabase extends Database { Cursor cursor = db.rawQuery(query, new String[]{}); - setNotifyConversationListListeners(cursor); - return cursor; } @@ -717,8 +714,6 @@ public class ThreadDatabase extends Database { String query = createQuery(ARCHIVED + " = ? AND " + MESSAGE_COUNT + " != 0", offset, limit, false); Cursor cursor = db.rawQuery(query, new String[]{archived}); - setNotifyConversationListListeners(cursor); - return cursor; } @@ -1225,12 +1220,13 @@ public class ThreadDatabase extends Database { private void applyStorageSyncUpdate(@NonNull RecipientId recipientId, boolean archived, boolean forcedUnread) { ContentValues values = new ContentValues(); - values.put(ARCHIVED, archived); + values.put(ARCHIVED, archived ? 1 : 0); + + Long threadId = getThreadIdFor(recipientId); if (forcedUnread) { values.put(READ, ReadStatus.FORCED_UNREAD.serialize()); } else { - Long threadId = getThreadIdFor(recipientId); if (threadId != null) { int unreadCount = DatabaseFactory.getMmsSmsDatabase(context).getUnreadCount(threadId); @@ -1240,6 +1236,10 @@ public class ThreadDatabase extends Database { } databaseHelper.getWritableDatabase().update(TABLE_NAME, values, RECIPIENT_ID + " = ?", SqlUtil.buildArgs(recipientId)); + + if (threadId != null) { + notifyConversationListeners(threadId); + } } public boolean update(long threadId, boolean unarchive) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 2b15d526a7..560585e3f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -141,6 +141,7 @@ public final class JobManagerFactories { put(StickerPackDownloadJob.KEY, new StickerPackDownloadJob.Factory()); put(StorageForcePushJob.KEY, new StorageForcePushJob.Factory()); put(StorageSyncJob.KEY, new StorageSyncJob.Factory()); + put(StorageSyncJobV2.KEY, new StorageSyncJobV2.Factory()); put(TrimThreadJob.KEY, new TrimThreadJob.Factory()); put(TypingSendJob.KEY, new TypingSendJob.Factory()); put(UpdateApkJob.KEY, new UpdateApkJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.java index 67013c0c16..10d8ff51ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.java @@ -99,8 +99,7 @@ public class StorageAccountRestoreJob extends BaseJob { Log.i(TAG, "Applying changes locally..."); - StorageId selfStorageId = StorageId.forAccount(Recipient.self().getStorageServiceId()); - StorageSyncHelper.applyAccountStorageSyncUpdates(context, selfStorageId, accountRecord, false); + StorageSyncHelper.applyAccountStorageSyncUpdates(context, Recipient.self(), accountRecord, false); JobManager jobManager = ApplicationDependencies.getJobManager(); 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 7ce9dc04ec..6f98f37ab6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java @@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper.WriteOperationResult import org.thoughtcrime.securesms.storage.StorageSyncModels; import org.thoughtcrime.securesms.storage.StorageSyncValidations; import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; @@ -70,7 +71,7 @@ public class StorageSyncJob extends BaseJob { private static final String TAG = Log.tag(StorageSyncJob.class); - public StorageSyncJob() { + private StorageSyncJob() { this(new Job.Parameters.Builder().addConstraint(NetworkConstraint.KEY) .setQueue(QUEUE_KEY) .setMaxInstancesForFactory(2) @@ -82,6 +83,22 @@ public class StorageSyncJob extends BaseJob { super(parameters); } + public static void enqueue() { + if (FeatureFlags.internalUser()) { + ApplicationDependencies.getJobManager().add(new StorageSyncJobV2()); + } else { + ApplicationDependencies.getJobManager().add(new StorageSyncJob()); + } + } + + public static @NonNull Job create() { + if (FeatureFlags.storageSyncV2()) { + return new StorageSyncJobV2(); + } else { + return new StorageSyncJob(); + } + } + @Override protected boolean shouldTrace() { return true; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJobV2.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJobV2.java new file mode 100644 index 0000000000..e62d48fe23 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJobV2.java @@ -0,0 +1,435 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; +import org.thoughtcrime.securesms.database.StorageKeyDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.storage.AccountRecordProcessor; +import org.thoughtcrime.securesms.storage.ContactRecordProcessor; +import org.thoughtcrime.securesms.storage.GroupV1RecordProcessor; +import org.thoughtcrime.securesms.storage.GroupV2RecordProcessor; +import org.thoughtcrime.securesms.storage.StorageRecordProcessor; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.KeyDifferenceResult; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.LocalWriteResult; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.WriteOperationResult; +import org.thoughtcrime.securesms.storage.StorageSyncModels; +import org.thoughtcrime.securesms.storage.StorageSyncValidations; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.api.storage.SignalAccountRecord; +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.SignalStorageManifest; +import org.whispersystems.signalservice.api.storage.SignalStorageRecord; +import org.whispersystems.signalservice.api.storage.StorageId; +import org.whispersystems.signalservice.api.storage.StorageKey; +import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** + * Does a full sync of our local storage state with the remote storage state. Will write any pending + * local changes and resolve any conflicts with remote storage. + * + * This should be performed whenever a change is made locally, or whenever we want to retrieve + * changes that have been made remotely. + * + * == Important Implementation Notes == + * + * - We want to use a transaction to guarantee atomicity of our changes and to prevent other threads + * from writing while the sync is happening. But that means we also need to be very careful with + * what happens inside the transaction. Namely, we *cannot* perform network activity inside the + * transaction. + * + * - This puts us in a funny situation where we have to get remote data, begin a transaction to + * resolve the sync, and then end the transaction (and therefore commit our changes) *before* + * we write the data remotely. Normally, this would be dangerous, as our view of the data could + * fall out of sync if the network request fails. However, because of how the sync works, as long + * as we don't update our local manifest version until after the network request succeeds, it + * should all sort itself out in the retry. Because if our network request failed, then we + * wouldn't have written all of the new keys, and we'll still see a bunch of remote-only keys that + * we'll merge with local data to generate another equally-valid set of remote changes. + * + * + * == Technical Overview == + * + * The Storage Service is, at it's core, a dumb key-value store. It stores various types of records, + * each of which is given an ID. It also stores a manifest, which has the complete list of all IDs. + * The manifest has a monotonically-increasing version associated with it. Whenever a change is + * made to the stored data, you upload a new manifest with the updated ID set. + * + * An ID corresponds to an unchanging snapshot of a record. That is, if the underlying record is + * updated, that update is performed by deleting the old ID/record and inserting a new one. This + * makes it easy to determine what's changed in a given version of a manifest -- simply diff the + * list of IDs in the manifest with the list of IDs we have locally. + * + * So, at it's core, syncing isn't all that complicated. + * - If we see the remote manifest version is newer than ours, then we grab the manifest and compute + * the diff in IDs. + * - Then, we fetch the actual records that correspond to the remote-only IDs. + * - Afterwards, we take those records and merge them into our local data store. + * - The merging process could result in changes that need to be written back to the service, so + * we write those back. + * - Finally, we look at any other local changes that were made (independent of the ID diff) and + * make sure those are written to the service. + * + * Of course, you'll notice that there's a lot of code to support that goal. That's mostly because + * converting local data into a format that can be compared with, merged, and eventually written + * back to both local and remote data stores is tiresome. There's also lots of general bookkeeping, + * error handling, cleanup scenarios, logging, etc. + */ +public class StorageSyncJobV2 extends BaseJob { + + public static final String KEY = "StorageSyncJobV2"; + public static final String QUEUE_KEY = "StorageSyncingJobs"; + + private static final String TAG = Log.tag(StorageSyncJobV2.class); + + StorageSyncJobV2() { + this(new Parameters.Builder().addConstraint(NetworkConstraint.KEY) + .setQueue(QUEUE_KEY) + .setMaxInstancesForFactory(2) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(3) + .build()); + } + + private StorageSyncJobV2(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws IOException, RetryLaterException { + if (!SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().hasOptedOut()) { + Log.i(TAG, "Doesn't have a PIN. Skipping."); + return; + } + + if (!TextSecurePreferences.isPushRegistered(context)) { + Log.i(TAG, "Not registered. Skipping."); + return; + } + + try { + boolean needsMultiDeviceSync = performSync(); + + if (TextSecurePreferences.isMultiDevice(context) && needsMultiDeviceSync) { + ApplicationDependencies.getJobManager().add(new MultiDeviceStorageSyncRequestJob()); + } + + SignalStore.storageServiceValues().onSyncCompleted(); + } catch (InvalidKeyException e) { + Log.w(TAG, "Failed to decrypt remote storage! Force-pushing and syncing the storage key to linked devices.", e); + + ApplicationDependencies.getJobManager().startChain(new MultiDeviceKeysUpdateJob()) + .then(new StorageForcePushJob()) + .then(new MultiDeviceStorageSyncRequestJob()) + .enqueue(); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException || e instanceof RetryLaterException; + } + + @Override + public void onFailure() { + } + + private boolean performSync() throws IOException, RetryLaterException, InvalidKeyException { + Recipient self = Recipient.self(); + SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context); + StorageKey storageServiceKey = SignalStore.storageServiceValues().getOrCreateStorageKey(); + + boolean needsMultiDeviceSync = false; + boolean needsForcePush = false; + long localManifestVersion = TextSecurePreferences.getStorageManifestVersion(context); + Optional remoteManifest = accountManager.getStorageManifestIfDifferentVersion(storageServiceKey, localManifestVersion); + long remoteManifestVersion = remoteManifest.transform(SignalStorageManifest::getVersion).or(localManifestVersion); + + Log.i(TAG, "Our version: " + localManifestVersion + ", their version: " + remoteManifestVersion); + + if (remoteManifest.isPresent() && remoteManifestVersion > localManifestVersion) { + Log.i(TAG, "[Remote Sync] Newer manifest version found!"); + + List localStorageIdsBeforeMerge = getAllLocalStorageIds(context, Recipient.self().fresh()); + KeyDifferenceResult keyDifference = StorageSyncHelper.findKeyDifference(remoteManifest.get().getStorageIds(), localStorageIdsBeforeMerge); + + + if (keyDifference.hasTypeMismatches()) { + Log.w(TAG, "[Remote Sync] Found type mismatches in the key sets! Scheduling a force push after this sync completes."); + needsForcePush = true; + } + + if (!keyDifference.isEmpty()) { + Log.i(TAG, "[Remote Sync] Retrieving records for key difference: " + keyDifference); + + List remoteOnly = accountManager.readStorageRecords(storageServiceKey, keyDifference.getRemoteOnlyKeys()); + List localOnly = buildLocalStorageRecords(context, self, keyDifference.getLocalOnlyKeys()); + + if (remoteOnly.size() != keyDifference.getRemoteOnlyKeys().size()) { + Log.w(TAG, "[Remote Sync] Could not find all remote-only records! Requested: " + keyDifference.getRemoteOnlyKeys().size() + ", Found: " + remoteOnly.size() + ". Scheduling a force push after this sync completes."); + needsForcePush = true; + } + + List remoteContacts = new LinkedList<>(); + List remoteGv1 = new LinkedList<>(); + List remoteGv2 = new LinkedList<>(); + List remoteAccount = new LinkedList<>(); + List remoteUnknown = new LinkedList<>(); + + for (SignalStorageRecord remote : remoteOnly) { + if (remote.getContact().isPresent()) { + remoteContacts.add(remote.getContact().get()); + } else if (remote.getGroupV1().isPresent()) { + remoteGv1.add(remote.getGroupV1().get()); + } else if (remote.getGroupV2().isPresent()) { + remoteGv2.add(remote.getGroupV2().get()); + } else if (remote.getAccount().isPresent()) { + remoteAccount.add(remote.getAccount().get()); + } else { + remoteUnknown.add(remote); + } + } + + WriteOperationResult mergeWriteOperation; + + SQLiteDatabase db = DatabaseFactory.getInstance(context).getRawDatabase(); + + db.beginTransaction(); + try { + StorageRecordProcessor.Result contactResult = new ContactRecordProcessor(context, self).process(remoteContacts, StorageSyncHelper.KEY_GENERATOR); + StorageRecordProcessor.Result gv1Result = new GroupV1RecordProcessor(context).process(remoteGv1, StorageSyncHelper.KEY_GENERATOR); + StorageRecordProcessor.Result gv2Result = new GroupV2RecordProcessor(context).process(remoteGv2, StorageSyncHelper.KEY_GENERATOR); + StorageRecordProcessor.Result accountResult = new AccountRecordProcessor(context, self).process(remoteAccount, StorageSyncHelper.KEY_GENERATOR); + + List unknownInserts = remoteUnknown; + List unknownDeletes = Stream.of(keyDifference.getLocalOnlyKeys()).filter(StorageId::isUnknown).toList(); + + storageKeyDatabase.insert(unknownInserts); + storageKeyDatabase.delete(unknownDeletes); + + List localStorageIdsAfterMerge = getAllLocalStorageIds(context, Recipient.self().fresh()); + + if (contactResult.isLocalOnly() && gv1Result.isLocalOnly() && gv2Result.isLocalOnly() && accountResult.isLocalOnly() && unknownInserts.isEmpty() && unknownDeletes.isEmpty()) { + Log.i(TAG, "Result: No remote updates/deletes"); + Log.i(TAG, "IDs : " + localStorageIdsBeforeMerge.size() + " IDs before merge, " + localStorageIdsAfterMerge.size() + " IDs after merge"); + } else { + Log.i(TAG, "Contacts: " + contactResult.toString()); + Log.i(TAG, "GV1 : " + gv1Result.toString()); + Log.i(TAG, "GV2 : " + gv2Result.toString()); + Log.i(TAG, "Account : " + accountResult.toString()); + Log.i(TAG, "Unknowns: " + unknownInserts.size() + " Inserts, " + unknownDeletes.size() + " Deletes"); + Log.i(TAG, "IDs : " + localStorageIdsBeforeMerge.size() + " IDs before merge, " + localStorageIdsAfterMerge.size() + " IDs after merge"); + } + + localOnly.removeAll(contactResult.getLocalMatches()); + localOnly.removeAll(gv1Result.getLocalMatches()); + localOnly.removeAll(gv2Result.getLocalMatches()); + localOnly.removeAll(accountResult.getLocalMatches()); + + recipientDatabase.clearDirtyStateForRecords(localOnly); + + Log.i(TAG, "[Remote Sync] After the conflict resolution, there are " + localOnly.size() + " local-only records remaining."); + + //noinspection unchecked Stop yelling at my beautiful method signatures + mergeWriteOperation = StorageSyncHelper.createWriteOperation(remoteManifest.get().getVersion(), localStorageIdsAfterMerge, localOnly, contactResult, gv1Result, gv2Result, accountResult); + + StorageSyncValidations.validate(mergeWriteOperation); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners(); + } + + if (!mergeWriteOperation.isEmpty()) { + Log.i(TAG, "[Remote Sync] WriteOperationResult :: " + mergeWriteOperation); + Log.i(TAG, "[Remote Sync] We have something to write remotely."); + + if (mergeWriteOperation.getManifest().getStorageIds().size() != remoteManifest.get().getStorageIds().size() + mergeWriteOperation.getInserts().size() - mergeWriteOperation.getDeletes().size()) { + Log.w(TAG, String.format(Locale.US, "[Remote Sync] Bad storage key management! originalRemoteKeys: %d, newRemoteKeys: %d, insertedKeys: %d, deletedKeys: %d", + remoteManifest.get().getStorageIds().size(), mergeWriteOperation.getManifest().getStorageIds().size(), mergeWriteOperation.getInserts().size(), mergeWriteOperation.getDeletes().size())); + } + + Optional conflict = accountManager.writeStorageRecords(storageServiceKey, mergeWriteOperation.getManifest(), mergeWriteOperation.getInserts(), mergeWriteOperation.getDeletes()); + + if (conflict.isPresent()) { + Log.w(TAG, "[Remote Sync] Hit a conflict when trying to resolve the conflict! Retrying."); + throw new RetryLaterException(); + } + + remoteManifestVersion = mergeWriteOperation.getManifest().getVersion(); + + needsMultiDeviceSync = true; + } else { + Log.i(TAG, "[Remote Sync] No remote writes needed."); + } + + Log.i(TAG, "[Remote Sync] Updating local manifest version to: " + remoteManifestVersion); + TextSecurePreferences.setStorageManifestVersion(context, remoteManifestVersion); + } else { + Log.i(TAG, "[Remote Sync] Remote version was newer, there were no remote-only keys."); + Log.i(TAG, "[Remote Sync] Updating local manifest version to: " + remoteManifest.get().getVersion()); + TextSecurePreferences.setStorageManifestVersion(context, remoteManifest.get().getVersion()); + } + } + + localManifestVersion = TextSecurePreferences.getStorageManifestVersion(context); + + List allLocalStorageKeys = getAllLocalStorageIds(context, self); + List pendingUpdates = recipientDatabase.getPendingRecipientSyncUpdates(); + List pendingInsertions = recipientDatabase.getPendingRecipientSyncInsertions(); + List pendingDeletions = recipientDatabase.getPendingRecipientSyncDeletions(); + Optional pendingAccountInsert = StorageSyncHelper.getPendingAccountSyncInsert(context, self); + Optional pendingAccountUpdate = StorageSyncHelper.getPendingAccountSyncUpdate(context, self); + Optional localWriteResult = StorageSyncHelper.buildStorageUpdatesForLocal(localManifestVersion, + allLocalStorageKeys, + pendingUpdates, + pendingInsertions, + pendingDeletions, + pendingAccountUpdate, + pendingAccountInsert); + + if (localWriteResult.isPresent()) { + Log.i(TAG, String.format(Locale.ENGLISH, "[Local Changes] Local changes present. %d updates, %d inserts, %d deletes, account update: %b, account insert: %b.", pendingUpdates.size(), pendingInsertions.size(), pendingDeletions.size(), pendingAccountUpdate.isPresent(), pendingAccountInsert.isPresent())); + + WriteOperationResult localWrite = localWriteResult.get().getWriteResult(); + StorageSyncValidations.validate(localWrite); + + Log.i(TAG, "[Local Changes] WriteOperationResult :: " + localWrite); + + if (localWrite.isEmpty()) { + throw new AssertionError("Decided there were local writes, but our write result was empty!"); + } + + Optional conflict = accountManager.writeStorageRecords(storageServiceKey, localWrite.getManifest(), localWrite.getInserts(), localWrite.getDeletes()); + + if (conflict.isPresent()) { + Log.w(TAG, "[Local Changes] Hit a conflict when trying to upload our local writes! Retrying."); + throw new RetryLaterException(); + } + + List clearIds = new ArrayList<>(pendingUpdates.size() + pendingInsertions.size() + pendingDeletions.size() + 1); + + clearIds.addAll(Stream.of(pendingUpdates).map(RecipientSettings::getId).toList()); + clearIds.addAll(Stream.of(pendingInsertions).map(RecipientSettings::getId).toList()); + clearIds.addAll(Stream.of(pendingDeletions).map(RecipientSettings::getId).toList()); + clearIds.add(Recipient.self().getId()); + + recipientDatabase.clearDirtyState(clearIds); + recipientDatabase.updateStorageKeys(localWriteResult.get().getStorageKeyUpdates()); + + needsMultiDeviceSync = true; + + Log.i(TAG, "[Local Changes] Updating local manifest version to: " + localWriteResult.get().getWriteResult().getManifest().getVersion()); + TextSecurePreferences.setStorageManifestVersion(context, localWriteResult.get().getWriteResult().getManifest().getVersion()); + } else { + Log.i(TAG, "[Local Changes] No local changes."); + } + + if (needsForcePush) { + Log.w(TAG, "Scheduling a force push."); + ApplicationDependencies.getJobManager().add(new StorageForcePushJob()); + } + + return needsMultiDeviceSync; + } + + private static @NonNull List getAllLocalStorageIds(@NonNull Context context, @NonNull Recipient self) { + return Util.concatenatedList(DatabaseFactory.getRecipientDatabase(context).getContactStorageSyncIds(), + Collections.singletonList(StorageId.forAccount(self.getStorageServiceId())), + DatabaseFactory.getStorageKeyDatabase(context).getAllKeys()); + } + + private static @NonNull List buildLocalStorageRecords(@NonNull Context context, @NonNull Recipient self, @NonNull List ids) { + RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context); + + List records = new ArrayList<>(ids.size()); + + for (StorageId id : ids) { + switch (id.getType()) { + case ManifestRecord.Identifier.Type.CONTACT_VALUE: + case ManifestRecord.Identifier.Type.GROUPV1_VALUE: + case ManifestRecord.Identifier.Type.GROUPV2_VALUE: + RecipientSettings settings = recipientDatabase.getByStorageId(id.getRaw()); + if (settings != null) { + if (settings.getGroupType() == RecipientDatabase.GroupType.SIGNAL_V2 && settings.getSyncExtras().getGroupMasterKey() == null) { + Log.w(TAG, "Missing master key on gv2 recipient"); + } else { + records.add(StorageSyncModels.localToRemoteRecord(settings)); + } + } else { + Log.w(TAG, "Missing local recipient model! Type: " + id.getType()); + } + break; + case ManifestRecord.Identifier.Type.ACCOUNT_VALUE: + if (!Arrays.equals(self.getStorageServiceId(), id.getRaw())) { + throw new AssertionError("Local storage ID doesn't match self!"); + } + records.add(StorageSyncHelper.buildAccountRecord(context, self)); + break; + default: + SignalStorageRecord unknown = storageKeyDatabase.getById(id.getRaw()); + if (unknown != null) { + records.add(unknown); + } else { + Log.w(TAG, "Missing local unknown model! Type: " + id.getType()); + } + break; + } + } + + return records; + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull StorageSyncJobV2 create(@NonNull Parameters parameters, @NonNull Data data) { + return new StorageSyncJobV2(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/StorageServiceMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/StorageServiceMigrationJob.java index ea1406e301..d1125b430d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/StorageServiceMigrationJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/StorageServiceMigrationJob.java @@ -41,12 +41,12 @@ public class StorageServiceMigrationJob extends MigrationJob { if (TextSecurePreferences.isMultiDevice(context)) { Log.i(TAG, "Multi-device."); - jobManager.startChain(new StorageSyncJob()) + jobManager.startChain(StorageSyncJob.create()) .then(new MultiDeviceKeysUpdateJob()) .enqueue(); } else { Log.i(TAG, "Single-device."); - jobManager.add(new StorageSyncJob()); + jobManager.add(StorageSyncJob.create()); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreRepository.java b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreRepository.java index 9ab5d810f4..4778440e8b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreRepository.java @@ -83,7 +83,7 @@ public class PinRestoreRepository { ApplicationDependencies.getJobManager().runSynchronously(new StorageAccountRestoreJob(), StorageAccountRestoreJob.LIFESPAN); stopwatch.split("AccountRestore"); - ApplicationDependencies.getJobManager().runSynchronously(new StorageSyncJob(), TimeUnit.SECONDS.toMillis(10)); + ApplicationDependencies.getJobManager().runSynchronously(StorageSyncJob.create(), TimeUnit.SECONDS.toMillis(10)); stopwatch.split("ContactRestore"); stopwatch.stop(TAG); diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationUtil.java b/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationUtil.java index e68119ceb1..839706384f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/RegistrationUtil.java @@ -31,7 +31,7 @@ public final class RegistrationUtil { { Log.i(TAG, "Marking registration completed.", new Throwable()); SignalStore.registrationValues().setRegistrationComplete(); - ApplicationDependencies.getJobManager().startChain(new StorageSyncJob()) + ApplicationDependencies.getJobManager().startChain(StorageSyncJob.create()) .then(new DirectoryRefreshJob(false)) .enqueue(); } else if (!SignalStore.registrationValues().isRegistrationComplete()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountConflictMerger.java b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountConflictMerger.java index d29271ac89..33f41d2f60 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountConflictMerger.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountConflictMerger.java @@ -46,7 +46,7 @@ class AccountConflictMerger implements StorageSyncHelper.ConflictMerger { + + private static final String TAG = Log.tag(AccountRecordProcessor.class); + + private final Context context; + private final RecipientDatabase recipientDatabase; + private final SignalAccountRecord localAccountRecord; + private final Recipient self; + + private boolean foundAccountRecord = false; + + public AccountRecordProcessor(@NonNull Context context, @NonNull Recipient self) { + this(context, self, StorageSyncHelper.buildAccountRecord(context, self).getAccount().get(), DatabaseFactory.getRecipientDatabase(context)); + } + + AccountRecordProcessor(@NonNull Context context, @NonNull Recipient self, @NonNull SignalAccountRecord localAccountRecord, @NonNull RecipientDatabase recipientDatabase) { + this.context = context; + this.self = self; + this.recipientDatabase = recipientDatabase; + this.localAccountRecord = localAccountRecord; + } + + /** + * We want to catch: + * - Multiple account records + */ + @Override + boolean isInvalid(@NonNull SignalAccountRecord remote) { + if (foundAccountRecord) { + Log.w(TAG, "Found an additional account record! Considering it invalid."); + return true; + } + + foundAccountRecord = true; + return false; + } + + @Override + public @NonNull Optional getMatching(@NonNull SignalAccountRecord record) { + return Optional.of(localAccountRecord); + } + + @Override + public @NonNull SignalAccountRecord merge(@NonNull SignalAccountRecord remote, @NonNull SignalAccountRecord local, @NonNull StorageKeyGenerator keyGenerator) { + String givenName; + String familyName; + + if (remote.getGivenName().isPresent() || remote.getFamilyName().isPresent()) { + givenName = remote.getGivenName().or(""); + familyName = remote.getFamilyName().or(""); + } else { + givenName = local.getGivenName().or(""); + familyName = local.getFamilyName().or(""); + } + + byte[] unknownFields = remote.serializeUnknownFields(); + String avatarUrlPath = remote.getAvatarUrlPath().or(local.getAvatarUrlPath()).or(""); + byte[] profileKey = remote.getProfileKey().or(local.getProfileKey()).orNull(); + boolean noteToSelfArchived = remote.isNoteToSelfArchived(); + boolean noteToSelfForcedUnread = remote.isNoteToSelfForcedUnread(); + boolean readReceipts = remote.isReadReceiptsEnabled(); + boolean typingIndicators = remote.isTypingIndicatorsEnabled(); + boolean sealedSenderIndicators = remote.isSealedSenderIndicatorsEnabled(); + boolean linkPreviews = remote.isLinkPreviewsEnabled(); + boolean unlisted = remote.isPhoneNumberUnlisted(); + List pinnedConversations = remote.getPinnedConversations(); + AccountRecord.PhoneNumberSharingMode phoneNumberSharingMode = remote.getPhoneNumberSharingMode(); + boolean preferContactAvatars = remote.isPreferContactAvatars(); + boolean matchesRemote = doParamsMatch(remote, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars); + boolean matchesLocal = doParamsMatch(local, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars); + + if (matchesRemote) { + return remote; + } else if (matchesLocal) { + return local; + } else { + return new SignalAccountRecord.Builder(keyGenerator.generate()) + .setUnknownFields(unknownFields) + .setGivenName(givenName) + .setFamilyName(familyName) + .setAvatarUrlPath(avatarUrlPath) + .setProfileKey(profileKey) + .setNoteToSelfArchived(noteToSelfArchived) + .setNoteToSelfForcedUnread(noteToSelfForcedUnread) + .setReadReceiptsEnabled(readReceipts) + .setTypingIndicatorsEnabled(typingIndicators) + .setSealedSenderIndicatorsEnabled(sealedSenderIndicators) + .setLinkPreviewsEnabled(linkPreviews) + .setUnlistedPhoneNumber(unlisted) + .setPhoneNumberSharingMode(phoneNumberSharingMode) + .setUnlistedPhoneNumber(unlisted) + .setPinnedConversations(pinnedConversations) + .setPreferContactAvatars(preferContactAvatars) + .build(); + } + } + + @Override + void insertLocal(@NonNull SignalAccountRecord record) { + throw new UnsupportedOperationException("We should always have a local AccountRecord, so we should never been inserting a new one."); + } + + @Override + void updateLocal(@NonNull StorageRecordUpdate update) { + Log.i(TAG, "Local account update: " + update.toString()); + StorageSyncHelper.applyAccountStorageSyncUpdates(context, self, update.getNew(), true); + } + + @Override + public int compare(@NonNull SignalAccountRecord lhs, @NonNull SignalAccountRecord rhs) { + return 0; + } + + private static boolean doParamsMatch(@NonNull SignalAccountRecord contact, + @Nullable byte[] unknownFields, + @NonNull String givenName, + @NonNull String familyName, + @NonNull String avatarUrlPath, + @Nullable byte[] profileKey, + boolean noteToSelfArchived, + boolean noteToSelfForcedUnread, + boolean readReceipts, + boolean typingIndicators, + boolean sealedSenderIndicators, + boolean linkPreviewsEnabled, + AccountRecord.PhoneNumberSharingMode phoneNumberSharingMode, + boolean unlistedPhoneNumber, + @NonNull List pinnedConversations, + boolean preferContactAvatars) + { + return Arrays.equals(contact.serializeUnknownFields(), unknownFields) && + Objects.equals(contact.getGivenName().or(""), givenName) && + Objects.equals(contact.getFamilyName().or(""), familyName) && + Objects.equals(contact.getAvatarUrlPath().or(""), avatarUrlPath) && + Arrays.equals(contact.getProfileKey().orNull(), profileKey) && + contact.isNoteToSelfArchived() == noteToSelfArchived && + contact.isNoteToSelfForcedUnread() == noteToSelfForcedUnread && + contact.isReadReceiptsEnabled() == readReceipts && + contact.isTypingIndicatorsEnabled() == typingIndicators && + contact.isSealedSenderIndicatorsEnabled() == sealedSenderIndicators && + contact.isLinkPreviewsEnabled() == linkPreviewsEnabled && + contact.getPhoneNumberSharingMode() == phoneNumberSharingMode && + contact.isPhoneNumberUnlisted() == unlistedPhoneNumber && + contact.isPreferContactAvatars() == preferContactAvatars && + Objects.equals(contact.getPinnedConversations(), pinnedConversations); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/ContactConflictMerger.java b/app/src/main/java/org/thoughtcrime/securesms/storage/ContactConflictMerger.java index 414f7c993d..a2d19c29fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/ContactConflictMerger.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/ContactConflictMerger.java @@ -96,7 +96,7 @@ class ContactConflictMerger implements StorageSyncHelper.ConflictMerger { + + private static final String TAG = Log.tag(ContactRecordProcessor.class); + + private final Recipient self; + private final RecipientDatabase recipientDatabase; + + public ContactRecordProcessor(@NonNull Context context, @NonNull Recipient self) { + this(self, DatabaseFactory.getRecipientDatabase(context)); + } + + ContactRecordProcessor(@NonNull Recipient self, @NonNull RecipientDatabase recipientDatabase) { + this.self = self; + this.recipientDatabase = recipientDatabase; + } + + /** + * Error cases: + * - You can't have a contact record without an address component. + * - You can't have a contact record for yourself. That should be an account record. + * + * Note: This method could be written more succinctly, but the logs are useful :) + */ + @Override + boolean isInvalid(@NonNull SignalContactRecord remote) { + SignalServiceAddress address = remote.getAddress(); + + if (address == null) { + Log.w(TAG, "No address on the ContentRecord -- marking as invalid."); + return true; + } else if ((self.getUuid().isPresent() && address.getUuid().equals(self.getUuid())) || + (self.getE164().isPresent() && address.getNumber().equals(self.getE164()))) + { + Log.w(TAG, "Found a ContactRecord for ourselves -- marking as invalid."); + return true; + } else { + return false; + } + } + + @Override + @NonNull Optional getMatching(@NonNull SignalContactRecord remote) { + SignalServiceAddress address = remote.getAddress(); + Optional byUuid = address.getUuid().isPresent() ? recipientDatabase.getByUuid(address.getUuid().get()) : Optional.absent(); + Optional byE164 = address.getNumber().isPresent() ? recipientDatabase.getByE164(address.getNumber().get()) : Optional.absent(); + + return byUuid.or(byE164).transform(recipientDatabase::getRecipientSettingsForSync) + .transform(StorageSyncModels::localToRemoteRecord) + .transform(r -> r.getContact().get()); + } + + @NonNull SignalContactRecord merge(@NonNull SignalContactRecord remote, @NonNull SignalContactRecord local, @NonNull StorageKeyGenerator keyGenerator) { + String givenName; + String familyName; + + if (remote.getGivenName().isPresent() || remote.getFamilyName().isPresent()) { + givenName = remote.getGivenName().or(""); + familyName = remote.getFamilyName().or(""); + } else { + givenName = local.getGivenName().or(""); + familyName = local.getFamilyName().or(""); + } + + byte[] unknownFields = remote.serializeUnknownFields(); + UUID uuid = remote.getAddress().getUuid().or(local.getAddress().getUuid()).orNull(); + String e164 = remote.getAddress().getNumber().or(local.getAddress().getNumber()).orNull(); + SignalServiceAddress address = new SignalServiceAddress(uuid, e164); + byte[] profileKey = remote.getProfileKey().or(local.getProfileKey()).orNull(); + String username = remote.getUsername().or(local.getUsername()).or(""); + IdentityState identityState = remote.getIdentityState(); + byte[] identityKey = remote.getIdentityKey().or(local.getIdentityKey()).orNull(); + boolean blocked = remote.isBlocked(); + boolean profileSharing = remote.isProfileSharingEnabled(); + boolean archived = remote.isArchived(); + boolean forcedUnread = remote.isForcedUnread(); + boolean matchesRemote = doParamsMatch(remote, unknownFields, address, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, archived, forcedUnread); + boolean matchesLocal = doParamsMatch(local, unknownFields, address, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, archived, forcedUnread); + + if (matchesRemote) { + return remote; + } else if (matchesLocal) { + return local; + } else { + return new SignalContactRecord.Builder(keyGenerator.generate(), address) + .setUnknownFields(unknownFields) + .setGivenName(givenName) + .setFamilyName(familyName) + .setProfileKey(profileKey) + .setUsername(username) + .setIdentityState(identityState) + .setIdentityKey(identityKey) + .setBlocked(blocked) + .setProfileSharingEnabled(profileSharing) + .setForcedUnread(forcedUnread) + .build(); + } + } + + @Override + void insertLocal(@NonNull SignalContactRecord record) { + recipientDatabase.applyStorageSyncContactInsert(record); + } + + @Override + void updateLocal(@NonNull StorageRecordUpdate update) { + Log.i(TAG, "Local contact update: " + update.toString()); + recipientDatabase.applyStorageSyncContactUpdate(update); + } + + @Override + public int compare(@NonNull SignalContactRecord lhs, @NonNull SignalContactRecord rhs) { + if (Objects.equals(lhs.getAddress().getUuid(), rhs.getAddress().getUuid()) || + Objects.equals(lhs.getAddress().getNumber(), rhs.getAddress().getNumber())) + { + return 0; + } else { + return lhs.getAddress().getIdentifier().compareTo(rhs.getAddress().getIdentifier()); + } + } + + private static boolean doParamsMatch(@NonNull SignalContactRecord contact, + @Nullable byte[] unknownFields, + @NonNull SignalServiceAddress address, + @NonNull String givenName, + @NonNull String familyName, + @Nullable byte[] profileKey, + @NonNull String username, + @Nullable IdentityState identityState, + @Nullable byte[] identityKey, + boolean blocked, + boolean profileSharing, + boolean archived, + boolean forcedUnread) + { + return Arrays.equals(contact.serializeUnknownFields(), unknownFields) && + Objects.equals(contact.getAddress(), address) && + Objects.equals(contact.getGivenName().or(""), givenName) && + Objects.equals(contact.getFamilyName().or(""), familyName) && + Arrays.equals(contact.getProfileKey().orNull(), profileKey) && + Objects.equals(contact.getUsername().or(""), username) && + Objects.equals(contact.getIdentityState(), identityState) && + Arrays.equals(contact.getIdentityKey().orNull(), identityKey) && + contact.isBlocked() == blocked && + contact.isProfileSharingEnabled() == profileSharing && + contact.isArchived() == archived && + contact.isForcedUnread() == forcedUnread; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/DefaultStorageRecordProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/storage/DefaultStorageRecordProcessor.java new file mode 100644 index 0000000000..32ff8e52cf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/DefaultStorageRecordProcessor.java @@ -0,0 +1,99 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.storage.SignalRecord; + +import java.io.IOException; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +/** + * An implementation of {@link StorageRecordProcessor} that solidifies a pattern and reduces + * duplicate code in individual implementations. + * + * Concerning the implementation of {@link #compare(Object, Object)}, it's purpose is to detect if + * two items would map to the same logical entity (i.e. they would correspond to the same record in + * our local store). We use it for a {@link TreeSet}, so mainly it's just important that the '0' + * case is correct. Other cases are whatever, just make it something stable. + */ +abstract class DefaultStorageRecordProcessor implements StorageRecordProcessor, Comparator { + + private static final String TAG = Log.tag(DefaultStorageRecordProcessor.class); + + /** + * One type of invalid remote data this handles is two records mapping to the same local data. We + * have to trim this bad data out, because if we don't, we'll upload an ID set that only has one + * of the IDs in it, but won't properly delete the dupes, which will then fail our validation + * checks. + * + * This is a bit tricky -- as we process records, ID's are written back to the local store, so we + * can't easily be like "oh multiple records are mapping to the same local storage ID". And in + * general we rely on SignalRecords to implement an equals() that includes the StorageId, so using + * a regular set is out. Instead, we use a {@link TreeSet}, which allows us to define a custom + * comparator for checking equality. Then we delegate to the subclass to tell us if two items are + * the same based on their actual data (i.e. two contacts having the same UUID, or two groups + * having the same MasterKey). + */ + @Override + public @NonNull Result process(@NonNull Collection remoteRecords, @NonNull StorageKeyGenerator keyGenerator) throws IOException { + List remoteDeletes = new LinkedList<>(); + List> remoteUpdates = new LinkedList<>(); + List localMatches = new LinkedList<>(); + Set matchedRecords = new TreeSet<>(this); + + for (E remote : remoteRecords) { + if (isInvalid(remote)) { + remoteDeletes.add(remote); + } else { + Optional local = getMatching(remote); + + if (local.isPresent()) { + E merged = merge(remote, local.get(), keyGenerator); + + localMatches.add(local.get()); + + if (matchedRecords.contains(local.get())) { + Log.w(TAG, "Multiple remote records map to the same local record! Marking this one for deletion. (Type: " + local.get().getClass().getSimpleName() + ")"); + remoteDeletes.add(remote); + } else { + matchedRecords.add(local.get()); + + if (!merged.equals(remote)) { + remoteUpdates.add(new StorageRecordUpdate<>(remote, merged)); + } + + if (!merged.equals(local.get())) { + updateLocal(new StorageRecordUpdate<>(local.get(), merged)); + } + } + } else { + insertLocal(remote); + } + } + } + + return new Result<>(remoteUpdates, remoteDeletes, localMatches); + } + + /** + * @return True if the record is invalid and should be removed from storage service, otherwise false. + */ + abstract boolean isInvalid(@NonNull E remote); + + /** + * Only records that pass the validity check (i.e. return false from {@link #isInvalid(SignalRecord)} + * make it to here, so you can assume all records are valid. + */ + abstract @NonNull Optional getMatching(@NonNull E remote); + + abstract @NonNull E merge(@NonNull E remote, @NonNull E local, @NonNull StorageKeyGenerator keyGenerator); + abstract void insertLocal(@NonNull E record) throws IOException; + abstract void updateLocal(@NonNull StorageRecordUpdate update); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1ConflictMerger.java b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1ConflictMerger.java index dbfd234e50..71220e5812 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1ConflictMerger.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1ConflictMerger.java @@ -44,7 +44,7 @@ final class GroupV1ConflictMerger implements StorageSyncHelper.ConflictMerger { + + private static final String TAG = Log.tag(GroupV1RecordProcessor.class); + + private final GroupDatabase groupDatabase; + private final RecipientDatabase recipientDatabase; + + public GroupV1RecordProcessor(@NonNull Context context) { + this(DatabaseFactory.getGroupDatabase(context), DatabaseFactory.getRecipientDatabase(context)); + } + + GroupV1RecordProcessor(@NonNull GroupDatabase groupDatabase, @NonNull RecipientDatabase recipientDatabase) { + this.groupDatabase = groupDatabase; + this.recipientDatabase = recipientDatabase; + } + + /** + * We want to catch: + * - Invalid group ID's + * - GV1 ID's that map to GV2 ID's, meaning we've already migrated them. + * + * Note: This method could be written more succinctly, but the logs are useful :) + */ + @Override + boolean isInvalid(@NonNull SignalGroupV1Record remote) { + try { + GroupId.V1 id = GroupId.v1(remote.getGroupId()); + Optional v2Record = groupDatabase.getGroup(id.deriveV2MigrationGroupId()); + + if (v2Record.isPresent()) { + Log.w(TAG, "We already have an upgraded V2 group for this V1 group -- marking as invalid."); + return true; + } else { + return false; + } + } catch (BadGroupIdException e) { + Log.w(TAG, "Bad Group ID -- marking as invalid."); + return true; + } + } + + @Override + @NonNull Optional getMatching(@NonNull SignalGroupV1Record record) { + GroupId.V1 groupId = GroupId.v1orThrow(record.getGroupId()); + + Optional recipientId = recipientDatabase.getByGroupId(groupId); + + return recipientId.transform(recipientDatabase::getRecipientSettingsForSync) + .transform(StorageSyncModels::localToRemoteRecord) + .transform(r -> r.getGroupV1().get()); + } + + @Override + @NonNull SignalGroupV1Record merge(@NonNull SignalGroupV1Record remote, @NonNull SignalGroupV1Record local, @NonNull StorageKeyGenerator keyGenerator) { + byte[] unknownFields = remote.serializeUnknownFields(); + boolean blocked = remote.isBlocked(); + boolean profileSharing = remote.isProfileSharingEnabled(); + boolean archived = remote.isArchived(); + boolean forcedUnread = remote.isForcedUnread(); + + boolean matchesRemote = Arrays.equals(unknownFields, remote.serializeUnknownFields()) && blocked == remote.isBlocked() && profileSharing == remote.isProfileSharingEnabled() && archived == remote.isArchived() && forcedUnread == remote.isForcedUnread(); + boolean matchesLocal = Arrays.equals(unknownFields, local.serializeUnknownFields()) && blocked == local.isBlocked() && profileSharing == local.isProfileSharingEnabled() && archived == local.isArchived() && forcedUnread == local.isForcedUnread(); + + if (matchesRemote) { + return remote; + } else if (matchesLocal) { + return local; + } else { + return new SignalGroupV1Record.Builder(keyGenerator.generate(), remote.getGroupId()) + .setUnknownFields(unknownFields) + .setBlocked(blocked) + .setProfileSharingEnabled(blocked) + .setForcedUnread(forcedUnread) + .build(); + } + } + + @Override + void insertLocal(@NonNull SignalGroupV1Record record) { + recipientDatabase.applyStorageSyncGroupV1Insert(record); + } + + @Override + void updateLocal(@NonNull StorageRecordUpdate update) { + Log.i(TAG, "Local GV1 update: " + update.toString()); + recipientDatabase.applyStorageSyncGroupV1Update(update); + } + + @Override + public int compare(@NonNull SignalGroupV1Record lhs, @NonNull SignalGroupV1Record rhs) { + if (Arrays.equals(lhs.getGroupId(), rhs.getGroupId())) { + return 0; + } else { + return lhs.getGroupId()[0] - rhs.getGroupId()[0]; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2ConflictMerger.java b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2ConflictMerger.java index 216025fa20..8f3104e9af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2ConflictMerger.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2ConflictMerger.java @@ -35,7 +35,7 @@ final class GroupV2ConflictMerger implements StorageSyncHelper.ConflictMerger { + + private static final String TAG = Log.tag(GroupV2RecordProcessor.class); + + private final Context context; + private final RecipientDatabase recipientDatabase; + private final Map gv1GroupsByExpectedGv2Id; + + public GroupV2RecordProcessor(@NonNull Context context) { + this(context, DatabaseFactory.getRecipientDatabase(context), DatabaseFactory.getGroupDatabase(context)); + } + + GroupV2RecordProcessor(@NonNull Context context, @NonNull RecipientDatabase recipientDatabase, @NonNull GroupDatabase groupDatabase) { + this.context = context; + this.recipientDatabase = recipientDatabase; + this.gv1GroupsByExpectedGv2Id = groupDatabase.getAllExpectedV2Ids(); + } + + @Override + boolean isInvalid(@NonNull SignalGroupV2Record remote) { + return remote.getMasterKeyBytes().length != GroupMasterKey.SIZE; + } + + @Override + @NonNull Optional getMatching(@NonNull SignalGroupV2Record record) { + GroupId.V2 groupId = GroupId.v2(record.getMasterKeyOrThrow()); + + Optional recipientId = recipientDatabase.getByGroupId(groupId); + + return recipientId.transform(recipientDatabase::getRecipientSettingsForSync) + .transform(StorageSyncModels::localToRemoteRecord) + .transform(r -> r.getGroupV2().get()); + } + + @Override + @NonNull SignalGroupV2Record merge(@NonNull SignalGroupV2Record remote, @NonNull SignalGroupV2Record local, @NonNull StorageKeyGenerator keyGenerator) { + byte[] unknownFields = remote.serializeUnknownFields(); + boolean blocked = remote.isBlocked(); + boolean profileSharing = remote.isProfileSharingEnabled(); + boolean archived = remote.isArchived(); + boolean forcedUnread = remote.isForcedUnread(); + + boolean matchesRemote = Arrays.equals(unknownFields, remote.serializeUnknownFields()) && blocked == remote.isBlocked() && profileSharing == remote.isProfileSharingEnabled() && archived == remote.isArchived() && forcedUnread == remote.isForcedUnread(); + boolean matchesLocal = Arrays.equals(unknownFields, local.serializeUnknownFields()) && blocked == local.isBlocked() && profileSharing == local.isProfileSharingEnabled() && archived == local.isArchived() && forcedUnread == local.isForcedUnread(); + + if (matchesRemote) { + return remote; + } else if (matchesLocal) { + return local; + } else { + return new SignalGroupV2Record.Builder(keyGenerator.generate(), remote.getMasterKeyBytes()) + .setUnknownFields(unknownFields) + .setBlocked(blocked) + .setProfileSharingEnabled(blocked) + .setArchived(archived) + .setForcedUnread(forcedUnread) + .build(); + } + } + + /** + * This contains a pretty big compromise: In the event that the new GV2 group we learned about + * was, in fact, a migrated V1 group we already knew about, we handle the migration here. This + * isn't great because the migration will likely result in network activity. And because this is + * all happening in a transaction, this could keep the transaction open for longer than we'd like. + * However, given that nearly all V1 groups have already been migrated, we're at a point where + * this event should be extraordinarily rare, and it didn't seem worth it to add a lot of + * complexity to accommodate this specific scenario. + */ + @Override + void insertLocal(@NonNull SignalGroupV2Record record) throws IOException { + GroupId.V2 actualV2Id = GroupId.v2(record.getMasterKeyOrThrow()); + GroupId.V1 possibleV1Id = gv1GroupsByExpectedGv2Id.get(actualV2Id); + + if (possibleV1Id != null) { + Log.i(TAG, "Discovered a new GV2 ID that is actually a migrated V1 group! Migrating now."); + GroupsV1MigrationUtil.performLocalMigration(context, possibleV1Id); + } else { + recipientDatabase.applyStorageSyncGroupV2Insert(record); + } + } + + @Override + void updateLocal(@NonNull StorageRecordUpdate update) { + Log.i(TAG, "Local GV2 update: " + update.toString()); + recipientDatabase.applyStorageSyncGroupV2Update(update); + } + + @Override + public int compare(@NonNull SignalGroupV2Record lhs, @NonNull SignalGroupV2Record rhs) { + if (Arrays.equals(lhs.getMasterKeyBytes(), rhs.getMasterKeyBytes())) { + return 0; + } else { + return lhs.getMasterKeyBytes()[0] - rhs.getMasterKeyBytes()[0]; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageKeyGenerator.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageKeyGenerator.java new file mode 100644 index 0000000000..7799b6caf5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageKeyGenerator.java @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; + +/** + * Generates a key for use with the storage service. + */ +interface StorageKeyGenerator { + @NonNull + byte[] generate(); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordProcessor.java new file mode 100644 index 0000000000..054ffabdb1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordProcessor.java @@ -0,0 +1,67 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.whispersystems.signalservice.api.storage.SignalRecord; +import org.whispersystems.signalservice.api.storage.SignalStorageRecord; + +import java.io.IOException; +import java.util.Collection; + +/** + * Handles processing a remote record, which involves: + * - Applying an local changes that need to be made base don the remote record + * - Returning a result with any remote updates/deletes that need to be applied after merging with + * the local record. + */ +public interface StorageRecordProcessor { + + @NonNull Result process(@NonNull Collection remoteRecords, @NonNull StorageKeyGenerator keyGenerator) throws IOException; + + final class Result { + private final Collection> remoteUpdates; + private final Collection remoteDeletes; + private final Collection localMatches; + + Result(@NonNull Collection> remoteUpdates, @NonNull Collection remoteDeletes, @NonNull Collection localMatches) { + this.remoteDeletes = remoteDeletes; + this.remoteUpdates = remoteUpdates; + this.localMatches = Stream.of(localMatches).map(SignalRecord::asStorageRecord).toList(); + } + + public @NonNull Collection getRemoteDeletes() { + return remoteDeletes; + } + + public @NonNull Collection> getRemoteUpdates() { + return remoteUpdates; + } + + public @NonNull Collection getLocalMatches() { + return localMatches; + } + + public boolean isLocalOnly() { + return remoteUpdates.isEmpty() && remoteDeletes.isEmpty(); + } + + @Override + public @NonNull String toString() { + if (isLocalOnly()) { + return "Empty"; + } + + StringBuilder builder = new StringBuilder(); + + builder.append(remoteDeletes.size()).append(" Deletes, ").append(remoteUpdates.size()).append(" Updates\n"); + + for (StorageRecordUpdate update : remoteUpdates) { + builder.append("- ").append(update.toString()).append("\n"); + } + + return super.toString(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordUpdate.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordUpdate.java new file mode 100644 index 0000000000..8deb401b59 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordUpdate.java @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; + +import org.whispersystems.signalservice.api.storage.SignalRecord; + +import java.util.Objects; + +/** + * Represents a pair of records: one old, and one new. The new record should replace the old. + */ +public class StorageRecordUpdate { + private final E oldRecord; + private final E newRecord; + + StorageRecordUpdate(@NonNull E oldRecord, @NonNull E newRecord) { + this.oldRecord = oldRecord; + this.newRecord = newRecord; + } + + public @NonNull E getOld() { + return oldRecord; + } + + public @NonNull E getNew() { + return newRecord; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + StorageRecordUpdate that = (StorageRecordUpdate) o; + return oldRecord.equals(that.oldRecord) && + newRecord.equals(that.newRecord); + } + + @Override + public int hashCode() { + return Objects.hash(oldRecord, newRecord); + } + + @Override + public @NonNull String toString() { + return newRecord.describeDiff(oldRecord); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java index 7cb2b02d4f..5678e6d381 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java @@ -54,9 +54,9 @@ public final class StorageSyncHelper { private static final String TAG = Log.tag(StorageSyncHelper.class); - private static final KeyGenerator KEY_GENERATOR = () -> Util.getSecretBytes(16); + public static final StorageKeyGenerator KEY_GENERATOR = () -> Util.getSecretBytes(16); - private static KeyGenerator keyGenerator = KEY_GENERATOR; + private static StorageKeyGenerator keyGenerator = KEY_GENERATOR; private static final long REFRESH_INTERVAL = TimeUnit.HOURS.toMillis(2); @@ -231,6 +231,7 @@ public final class StorageSyncHelper { remoteOnlyRawIds.remove(rawId); localOnlyRawIds.remove(rawId); hasTypeMismatch = true; + Log.w(TAG, "Remote type " + remote.getType() + " did not match local type " + local.getType() + "!"); } } @@ -287,18 +288,18 @@ public final class StorageSyncHelper { remoteInserts.addAll(Stream.of(groupV2MergeResult.remoteInserts).map(SignalStorageRecord::forGroupV2).toList()); remoteInserts.addAll(Stream.of(accountMergeResult.remoteInserts).map(SignalStorageRecord::forAccount).toList()); - Set> remoteUpdates = new HashSet<>(); + Set> remoteUpdates = new HashSet<>(); remoteUpdates.addAll(Stream.of(contactMergeResult.remoteUpdates) - .map(c -> new RecordUpdate<>(SignalStorageRecord.forContact(c.getOld()), SignalStorageRecord.forContact(c.getNew()))) + .map(c -> new StorageRecordUpdate<>(SignalStorageRecord.forContact(c.getOld()), SignalStorageRecord.forContact(c.getNew()))) .toList()); remoteUpdates.addAll(Stream.of(groupV1MergeResult.remoteUpdates) - .map(c -> new RecordUpdate<>(SignalStorageRecord.forGroupV1(c.getOld()), SignalStorageRecord.forGroupV1(c.getNew()))) + .map(c -> new StorageRecordUpdate<>(SignalStorageRecord.forGroupV1(c.getOld()), SignalStorageRecord.forGroupV1(c.getNew()))) .toList()); remoteUpdates.addAll(Stream.of(groupV2MergeResult.remoteUpdates) - .map(c -> new RecordUpdate<>(SignalStorageRecord.forGroupV2(c.getOld()), SignalStorageRecord.forGroupV2(c.getNew()))) + .map(c -> new StorageRecordUpdate<>(SignalStorageRecord.forGroupV2(c.getOld()), SignalStorageRecord.forGroupV2(c.getNew()))) .toList()); remoteUpdates.addAll(Stream.of(accountMergeResult.remoteUpdates) - .map(c -> new RecordUpdate<>(SignalStorageRecord.forAccount(c.getOld()), SignalStorageRecord.forAccount(c.getNew()))) + .map(c -> new StorageRecordUpdate<>(SignalStorageRecord.forAccount(c.getOld()), SignalStorageRecord.forAccount(c.getNew()))) .toList()); Set remoteDeletes = new HashSet<>(); @@ -331,11 +332,11 @@ public final class StorageSyncHelper { { List inserts = new ArrayList<>(); inserts.addAll(mergeResult.getRemoteInserts()); - inserts.addAll(Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getNew).toList()); + inserts.addAll(Stream.of(mergeResult.getRemoteUpdates()).map(StorageRecordUpdate::getNew).toList()); List deletes = new ArrayList<>(); deletes.addAll(Stream.of(mergeResult.getRemoteDeletes()).map(SignalRecord::getId).toList()); - deletes.addAll(Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getOld).map(SignalStorageRecord::getId).toList()); + deletes.addAll(Stream.of(mergeResult.getRemoteUpdates()).map(StorageRecordUpdate::getOld).map(SignalStorageRecord::getId).toList()); Set completeKeys = new HashSet<>(currentLocalStorageKeys); completeKeys.addAll(Stream.of(mergeResult.getAllNewRecords()).map(SignalRecord::getId).toList()); @@ -348,12 +349,37 @@ public final class StorageSyncHelper { return new WriteOperationResult(manifest, inserts, Stream.of(deletes).map(StorageId::getRaw).toList()); } + /** + * Assumes all changes have already been applied to local data. That means that keys will be + * taken as-is, and the rest of the arguments are used to form the insert/delete sets. + */ + public static @NonNull WriteOperationResult createWriteOperation(long currentManifestVersion, + @NonNull List allStorageKeys, + @NonNull List localOnlyRecords, + @NonNull StorageRecordProcessor.Result... results) + { + Set inserts = new LinkedHashSet<>(localOnlyRecords); + Set deletes = new LinkedHashSet<>(); + + for (StorageRecordProcessor.Result result : results) { + for (StorageRecordUpdate update : result.getRemoteUpdates()) { + inserts.add(update.getNew().asStorageRecord()); + deletes.add(update.getOld().getId()); + } + deletes.addAll(Stream.of(result.getRemoteDeletes()).map(SignalRecord::getId).toList()); + } + + SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, new ArrayList<>(allStorageKeys)); + + return new WriteOperationResult(manifest, new ArrayList<>(inserts), Stream.of(deletes).map(StorageId::getRaw).toList()); + } + public static @NonNull byte[] generateKey() { return keyGenerator.generate(); } @VisibleForTesting - static void setTestKeyGenerator(@Nullable KeyGenerator testKeyGenerator) { + static void setTestKeyGenerator(@Nullable StorageKeyGenerator testKeyGenerator) { keyGenerator = testKeyGenerator != null ? testKeyGenerator : KEY_GENERATOR; } @@ -363,8 +389,8 @@ public final class StorageSyncHelper { { Set localInserts = new HashSet<>(remoteOnlyRecords); Set remoteInserts = new HashSet<>(localOnlyRecords); - Set> localUpdates = new HashSet<>(); - Set> remoteUpdates = new HashSet<>(); + Set> localUpdates = new HashSet<>(); + Set> remoteUpdates = new HashSet<>(); Set remoteDeletes = new HashSet<>(merger.getInvalidEntries(remoteOnlyRecords)); remoteOnlyRecords.removeAll(remoteDeletes); @@ -377,11 +403,11 @@ public final class StorageSyncHelper { E merged = merger.merge(remote, local.get(), keyGenerator); if (!merged.equals(remote)) { - remoteUpdates.add(new RecordUpdate<>(remote, merged)); + remoteUpdates.add(new StorageRecordUpdate<>(remote, merged)); } if (!merged.equals(local.get())) { - localUpdates.add(new RecordUpdate<>(local.get(), merged)); + localUpdates.add(new StorageRecordUpdate<>(local.get(), merged)); } localInserts.remove(remote); @@ -392,7 +418,7 @@ public final class StorageSyncHelper { return new RecordMergeResult<>(localInserts, localUpdates, remoteInserts, remoteUpdates, remoteDeletes); } - public static boolean profileKeyChanged(RecordUpdate update) { + public static boolean profileKeyChanged(StorageRecordUpdate update) { return !OptionalUtil.byteArrayEquals(update.getOld().getProfileKey(), update.getNew().getProfileKey()); } @@ -439,15 +465,15 @@ public final class StorageSyncHelper { return SignalStorageRecord.forAccount(account); } - public static void applyAccountStorageSyncUpdates(@NonNull Context context, Optional> update) { + public static void applyAccountStorageSyncUpdates(@NonNull Context context, Optional> update) { if (!update.isPresent()) { return; } - applyAccountStorageSyncUpdates(context, StorageId.forAccount(Recipient.self().getStorageServiceId()), update.get().getNew(), true); + applyAccountStorageSyncUpdates(context, Recipient.self(), update.get().getNew(), true); } - public static void applyAccountStorageSyncUpdates(@NonNull Context context, @NonNull StorageId storageId, @NonNull SignalAccountRecord update, boolean fetchProfile) { - DatabaseFactory.getRecipientDatabase(context).applyStorageSyncUpdates(storageId, update); + public static void applyAccountStorageSyncUpdates(@NonNull Context context, @NonNull Recipient self, @NonNull SignalAccountRecord update, boolean fetchProfile) { + DatabaseFactory.getRecipientDatabase(context).applyStorageSyncUpdates(StorageId.forAccount(self.getStorageServiceId()), update); TextSecurePreferences.setReadReceiptsEnabled(context, update.isReadReceiptsEnabled()); TextSecurePreferences.setTypingIndicatorsEnabled(context, update.isTypingIndicatorsEnabled()); @@ -459,7 +485,7 @@ public final class StorageSyncHelper { SignalStore.paymentsValues().setEnabledAndEntropy(update.getPayments().isEnabled(), Entropy.fromBytes(update.getPayments().getEntropy().orNull())); if (fetchProfile && update.getAvatarUrlPath().isPresent()) { - ApplicationDependencies.getJobManager().add(new RetrieveProfileAvatarJob(Recipient.self(), update.getAvatarUrlPath().get())); + ApplicationDependencies.getJobManager().add(new RetrieveProfileAvatarJob(self, update.getAvatarUrlPath().get())); } } @@ -468,7 +494,7 @@ public final class StorageSyncHelper { Log.d(TAG, "Registration still ongoing. Ignore sync request."); return; } - ApplicationDependencies.getJobManager().add(new StorageSyncJob()); + ApplicationDependencies.getJobManager().add(StorageSyncJob.create()); } public static void scheduleRoutineSync() { @@ -515,35 +541,40 @@ public final class StorageSyncHelper { public boolean isEmpty() { return remoteOnlyKeys.isEmpty() && localOnlyKeys.isEmpty(); } + + @Override + public @NonNull String toString() { + return "remoteOnly: " + remoteOnlyKeys.size() + ", localOnly: " + localOnlyKeys.size() + ", hasTypeMismatches: " + hasTypeMismatches; + } } public static final class MergeResult { - private final Set localContactInserts; - private final Set> localContactUpdates; - private final Set localGroupV1Inserts; - private final Set> localGroupV1Updates; - private final Set localGroupV2Inserts; - private final Set> localGroupV2Updates; - private final Set localUnknownInserts; - private final Set localUnknownDeletes; - private final Optional> localAccountUpdate; - private final Set remoteInserts; - private final Set> remoteUpdates; - private final Set remoteDeletes; + private final Set localContactInserts; + private final Set> localContactUpdates; + private final Set localGroupV1Inserts; + private final Set> localGroupV1Updates; + private final Set localGroupV2Inserts; + private final Set> localGroupV2Updates; + private final Set localUnknownInserts; + private final Set localUnknownDeletes; + private final Optional> localAccountUpdate; + private final Set remoteInserts; + private final Set> remoteUpdates; + private final Set remoteDeletes; @VisibleForTesting - MergeResult(@NonNull Set localContactInserts, - @NonNull Set> localContactUpdates, - @NonNull Set localGroupV1Inserts, - @NonNull Set> localGroupV1Updates, - @NonNull Set localGroupV2Inserts, - @NonNull Set> localGroupV2Updates, - @NonNull Set localUnknownInserts, - @NonNull Set localUnknownDeletes, - @NonNull Optional> localAccountUpdate, - @NonNull Set remoteInserts, - @NonNull Set> remoteUpdates, - @NonNull Set remoteDeletes) + MergeResult(@NonNull Set localContactInserts, + @NonNull Set> localContactUpdates, + @NonNull Set localGroupV1Inserts, + @NonNull Set> localGroupV1Updates, + @NonNull Set localGroupV2Inserts, + @NonNull Set> localGroupV2Updates, + @NonNull Set localUnknownInserts, + @NonNull Set localUnknownDeletes, + @NonNull Optional> localAccountUpdate, + @NonNull Set remoteInserts, + @NonNull Set> remoteUpdates, + @NonNull Set remoteDeletes) { this.localContactInserts = localContactInserts; this.localContactUpdates = localContactUpdates; @@ -563,7 +594,7 @@ public final class StorageSyncHelper { return localContactInserts; } - public @NonNull Set> getLocalContactUpdates() { + public @NonNull Set> getLocalContactUpdates() { return localContactUpdates; } @@ -571,7 +602,7 @@ public final class StorageSyncHelper { return localGroupV1Inserts; } - public @NonNull Set> getLocalGroupV1Updates() { + public @NonNull Set> getLocalGroupV1Updates() { return localGroupV1Updates; } @@ -579,7 +610,7 @@ public final class StorageSyncHelper { return localGroupV2Inserts; } - public @NonNull Set> getLocalGroupV2Updates() { + public @NonNull Set> getLocalGroupV2Updates() { return localGroupV2Updates; } @@ -591,7 +622,7 @@ public final class StorageSyncHelper { return localUnknownDeletes; } - public @NonNull Optional> getLocalAccountUpdate() { + public @NonNull Optional> getLocalAccountUpdate() { return localAccountUpdate; } @@ -599,7 +630,7 @@ public final class StorageSyncHelper { return remoteInserts; } - public @NonNull Set> getRemoteUpdates() { + public @NonNull Set> getRemoteUpdates() { return remoteUpdates; } @@ -615,10 +646,10 @@ public final class StorageSyncHelper { records.addAll(localGroupV2Inserts); records.addAll(remoteInserts); records.addAll(localUnknownInserts); - records.addAll(Stream.of(localContactUpdates).map(RecordUpdate::getNew).toList()); - records.addAll(Stream.of(localGroupV1Updates).map(RecordUpdate::getNew).toList()); - records.addAll(Stream.of(localGroupV2Updates).map(RecordUpdate::getNew).toList()); - records.addAll(Stream.of(remoteUpdates).map(RecordUpdate::getNew).toList()); + records.addAll(Stream.of(localContactUpdates).map(StorageRecordUpdate::getNew).toList()); + records.addAll(Stream.of(localGroupV1Updates).map(StorageRecordUpdate::getNew).toList()); + records.addAll(Stream.of(localGroupV2Updates).map(StorageRecordUpdate::getNew).toList()); + records.addAll(Stream.of(remoteUpdates).map(StorageRecordUpdate::getNew).toList()); if (localAccountUpdate.isPresent()) records.add(localAccountUpdate.get().getNew()); return records; @@ -628,10 +659,10 @@ public final class StorageSyncHelper { Set records = new HashSet<>(); records.addAll(localUnknownDeletes); - records.addAll(Stream.of(localContactUpdates).map(RecordUpdate::getOld).toList()); - records.addAll(Stream.of(localGroupV1Updates).map(RecordUpdate::getOld).toList()); - records.addAll(Stream.of(localGroupV2Updates).map(RecordUpdate::getOld).toList()); - records.addAll(Stream.of(remoteUpdates).map(RecordUpdate::getOld).toList()); + records.addAll(Stream.of(localContactUpdates).map(StorageRecordUpdate::getOld).toList()); + records.addAll(Stream.of(localGroupV1Updates).map(StorageRecordUpdate::getOld).toList()); + records.addAll(Stream.of(localGroupV2Updates).map(StorageRecordUpdate::getOld).toList()); + records.addAll(Stream.of(remoteUpdates).map(StorageRecordUpdate::getOld).toList()); records.addAll(remoteDeletes); if (localAccountUpdate.isPresent()) records.add(localAccountUpdate.get().getOld()); @@ -705,50 +736,18 @@ public final class StorageSyncHelper { } } - public static class RecordUpdate { - private final E oldRecord; - private final E newRecord; - - RecordUpdate(@NonNull E oldRecord, @NonNull E newRecord) { - this.oldRecord = oldRecord; - this.newRecord = newRecord; - } - - public @NonNull E getOld() { - return oldRecord; - } - - public @NonNull E getNew() { - return newRecord; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - RecordUpdate that = (RecordUpdate) o; - return oldRecord.equals(that.oldRecord) && - newRecord.equals(that.newRecord); - } - - @Override - public int hashCode() { - return Objects.hash(oldRecord, newRecord); - } - } - private static class RecordMergeResult { - final Set localInserts; - final Set> localUpdates; - final Set remoteInserts; - final Set> remoteUpdates; - final Set remoteDeletes; + final Set localInserts; + final Set> localUpdates; + final Set remoteInserts; + final Set> remoteUpdates; + final Set remoteDeletes; - RecordMergeResult(@NonNull Set localInserts, - @NonNull Set> localUpdates, - @NonNull Set remoteInserts, - @NonNull Set> remoteUpdates, - @NonNull Set remoteDeletes) + RecordMergeResult(@NonNull Set localInserts, + @NonNull Set> localUpdates, + @NonNull Set remoteInserts, + @NonNull Set> remoteUpdates, + @NonNull Set remoteDeletes) { this.localInserts = localInserts; this.localUpdates = localUpdates; @@ -761,11 +760,7 @@ public final class StorageSyncHelper { interface ConflictMerger { @NonNull Optional getMatching(@NonNull E record); @NonNull Collection getInvalidEntries(@NonNull Collection remoteRecords); - @NonNull E merge(@NonNull E remote, @NonNull E local, @NonNull KeyGenerator keyGenerator); - } - - interface KeyGenerator { - @NonNull byte[] generate(); + @NonNull E merge(@NonNull E remote, @NonNull E local, @NonNull StorageKeyGenerator keyGenerator); } private static final class MultipleExistingAccountsException extends IllegalArgumentException {} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java index 8ce103350b..066c29b6c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java @@ -55,15 +55,15 @@ public final class StorageSyncValidations { throw new DuplicateRawIdError(); } + if (inserts.size() > insertSet.size()) { + throw new DuplicateInsertInWriteError(); + } + int accountCount = 0; for (StorageId id : manifest.getStorageIds()) { accountCount += id.getType() == ManifestRecord.Identifier.Type.ACCOUNT_VALUE ? 1 : 0; } - if (inserts.size() > insertSet.size()) { - throw new DuplicateInsertInWriteError(); - } - if (accountCount > 1) { throw new MultipleAccountError(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 614b069f89..49b44b2ec1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -74,6 +74,7 @@ public final class FeatureFlags { private static final String ANIMATED_STICKER_MIN_TOTAL_MEMORY = "android.animatedStickerMinTotalMemory"; private static final String MESSAGE_PROCESSOR_ALARM_INTERVAL = "android.messageProcessor.alarmIntervalMins"; private static final String MESSAGE_PROCESSOR_DELAY = "android.messageProcessor.foregroundDelayMs"; + private static final String STORAGE_SYNC_V2 = "android.storageSyncV2"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -104,7 +105,8 @@ public final class FeatureFlags { ANIMATED_STICKER_MIN_MEMORY, ANIMATED_STICKER_MIN_TOTAL_MEMORY, MESSAGE_PROCESSOR_ALARM_INTERVAL, - MESSAGE_PROCESSOR_DELAY + MESSAGE_PROCESSOR_DELAY, + STORAGE_SYNC_V2 ); @VisibleForTesting @@ -147,7 +149,8 @@ public final class FeatureFlags { ANIMATED_STICKER_MIN_TOTAL_MEMORY, MESSAGE_PROCESSOR_ALARM_INTERVAL, MESSAGE_PROCESSOR_DELAY, - GV1_FORCED_MIGRATE + GV1_FORCED_MIGRATE, + STORAGE_SYNC_V2 ); /** @@ -334,6 +337,11 @@ public final class FeatureFlags { return getInteger(ANIMATED_STICKER_MIN_TOTAL_MEMORY, (int) ByteUnit.GIGABYTES.toMegabytes(3)); } + /** Whether or not to use {@link org.thoughtcrime.securesms.jobs.StorageSyncJobV2}. */ + public static boolean storageSyncV2() { + return getBoolean(STORAGE_SYNC_V2, false); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/test/java/org/thoughtcrime/securesms/storage/ContactConflictMergerTest.java b/app/src/test/java/org/thoughtcrime/securesms/storage/ContactConflictMergerTest.java index 5dfd65da4d..453a99c892 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/storage/ContactConflictMergerTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/storage/ContactConflictMergerTest.java @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.storage; import org.junit.Test; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.storage.StorageSyncHelper.KeyGenerator; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.storage.SignalContactRecord; @@ -70,7 +69,7 @@ public class ContactConflictMergerTest { .setForcedUnread(true) .build(); - SignalContactRecord merged = new ContactConflictMerger(Collections.singletonList(local), SELF).merge(remote, local, mock(KeyGenerator.class)); + SignalContactRecord merged = new ContactConflictMerger(Collections.singletonList(local), SELF).merge(remote, local, mock(StorageKeyGenerator.class)); assertEquals(UUID_A, merged.getAddress().getUuid().get()); assertEquals(E164_A, merged.getAddress().getNumber().get()); @@ -103,7 +102,7 @@ public class ContactConflictMergerTest { .setUsername("username B") .setProfileSharingEnabled(false) .build(); - SignalContactRecord merged = new ContactConflictMerger(Collections.singletonList(local), SELF).merge(remote, local, mock(KeyGenerator.class)); + SignalContactRecord merged = new ContactConflictMerger(Collections.singletonList(local), SELF).merge(remote, local, mock(StorageKeyGenerator.class)); assertEquals(UUID_A, merged.getAddress().getUuid().get()); assertEquals(E164_B, merged.getAddress().getNumber().get()); @@ -133,7 +132,7 @@ public class ContactConflictMergerTest { .setFamilyName("BLast") .setProfileSharingEnabled(false) .build(); - SignalContactRecord merged = new ContactConflictMerger(Collections.singletonList(local), SELF).merge(remote, local, mock(KeyGenerator.class)); + SignalContactRecord merged = new ContactConflictMerger(Collections.singletonList(local), SELF).merge(remote, local, mock(StorageKeyGenerator.class)); assertEquals(remote, merged); } @@ -145,7 +144,7 @@ public class ContactConflictMergerTest { .setGivenName("AFirst") .setFamilyName("ALast") .build(); - SignalContactRecord merged = new ContactConflictMerger(Collections.singletonList(local), SELF).merge(remote, local, mock(KeyGenerator.class)); + SignalContactRecord merged = new ContactConflictMerger(Collections.singletonList(local), SELF).merge(remote, local, mock(StorageKeyGenerator.class)); assertEquals(local, merged); } diff --git a/app/src/test/java/org/thoughtcrime/securesms/storage/GroupV1ConflictMergerTest.java b/app/src/test/java/org/thoughtcrime/securesms/storage/GroupV1ConflictMergerTest.java index 6fa46d9301..f4ebdb9c54 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/storage/GroupV1ConflictMergerTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/storage/GroupV1ConflictMergerTest.java @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.storage; import org.junit.Test; import org.thoughtcrime.securesms.groups.GroupId; -import org.thoughtcrime.securesms.storage.StorageSyncHelper.KeyGenerator; import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; import java.util.Arrays; @@ -19,8 +18,8 @@ import static org.thoughtcrime.securesms.testutil.ZkGroupLibraryUtil.assumeZkGro public final class GroupV1ConflictMergerTest { - private static final byte[] GENERATED_KEY = byteArray(8675309); - private static final KeyGenerator KEY_GENERATOR = mock(KeyGenerator.class); + private static final byte[] GENERATED_KEY = byteArray(8675309); + private static final StorageKeyGenerator KEY_GENERATOR = mock(StorageKeyGenerator.class); static { when(KEY_GENERATOR.generate()).thenReturn(GENERATED_KEY); @@ -64,7 +63,7 @@ public final class GroupV1ConflictMergerTest { .setArchived(false) .build(); - SignalGroupV1Record merged = new GroupV1ConflictMerger(Collections.singletonList(local), id -> false).merge(remote, local, mock(KeyGenerator.class)); + SignalGroupV1Record merged = new GroupV1ConflictMerger(Collections.singletonList(local), id -> false).merge(remote, local, mock(StorageKeyGenerator.class)); assertEquals(remote, merged); } diff --git a/app/src/test/java/org/thoughtcrime/securesms/storage/GroupV2ConflictMergerTest.java b/app/src/test/java/org/thoughtcrime/securesms/storage/GroupV2ConflictMergerTest.java index d3bfd664d5..98fe335756 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/storage/GroupV2ConflictMergerTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/storage/GroupV2ConflictMergerTest.java @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.storage; import org.junit.Test; -import org.thoughtcrime.securesms.storage.StorageSyncHelper.KeyGenerator; import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; import java.util.Arrays; @@ -17,8 +16,8 @@ import static org.thoughtcrime.securesms.testutil.TestHelpers.byteArray; public final class GroupV2ConflictMergerTest { - private static final byte[] GENERATED_KEY = byteArray(8675309); - private static final KeyGenerator KEY_GENERATOR = mock(KeyGenerator.class); + private static final byte[] GENERATED_KEY = byteArray(8675309); + private static final StorageKeyGenerator KEY_GENERATOR = mock(StorageKeyGenerator.class); static { when(KEY_GENERATOR.generate()).thenReturn(GENERATED_KEY); @@ -62,7 +61,7 @@ public final class GroupV2ConflictMergerTest { .setArchived(false) .build(); - SignalGroupV2Record merged = new GroupV2ConflictMerger(Collections.singletonList(local)).merge(remote, local, mock(KeyGenerator.class)); + SignalGroupV2Record merged = new GroupV2ConflictMerger(Collections.singletonList(local)).merge(remote, local, mock(StorageKeyGenerator.class)); assertEquals(remote, merged); } diff --git a/app/src/test/java/org/thoughtcrime/securesms/storage/StorageSyncHelperTest.java b/app/src/test/java/org/thoughtcrime/securesms/storage/StorageSyncHelperTest.java index fcb69ba2c5..6077d668de 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/storage/StorageSyncHelperTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/storage/StorageSyncHelperTest.java @@ -569,12 +569,12 @@ public final class StorageSyncHelperTest { return new SignalGroupV2Record.Builder(byteArray(key), byteArray(groupId, 42)).setBlocked(blocked).setProfileSharingEnabled(profileSharing).build(); } - private static StorageSyncHelper.RecordUpdate update(E oldRecord, E newRecord) { - return new StorageSyncHelper.RecordUpdate<>(oldRecord, newRecord); + private static StorageRecordUpdate update(E oldRecord, E newRecord) { + return new StorageRecordUpdate<>(oldRecord, newRecord); } - private static StorageSyncHelper.RecordUpdate recordUpdate(E oldContact, E newContact) { - return new StorageSyncHelper.RecordUpdate<>(record(oldContact), record(newContact)); + private static StorageRecordUpdate recordUpdate(E oldContact, E newContact) { + return new StorageRecordUpdate<>(record(oldContact), record(newContact)); } private static SignalStorageRecord unknown(int key) { @@ -605,7 +605,7 @@ public final class StorageSyncHelperTest { return StorageId.forType(byteArray(val), UNKNOWN_TYPE); } - private static class TestGenerator implements StorageSyncHelper.KeyGenerator { + private static class TestGenerator implements StorageKeyGenerator { private final int[] keys; private int index = 0; diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java index 6939d57851..4d4dfa2e9b 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java @@ -12,6 +12,7 @@ import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; import java.util.ArrayList; +import java.util.LinkedList; import java.util.List; import java.util.Objects; @@ -52,6 +53,87 @@ public final class SignalAccountRecord implements SignalRecord { return id; } + @Override + public SignalStorageRecord asStorageRecord() { + return SignalStorageRecord.forAccount(this); + } + + @Override + public String describeDiff(SignalRecord other) { + if (other instanceof SignalAccountRecord) { + SignalAccountRecord that = (SignalAccountRecord) other; + List diff = new LinkedList<>(); + + if (!Objects.equals(this.givenName, that.givenName)) { + diff.add("GivenName"); + } + + if (!Objects.equals(this.familyName, that.familyName)) { + diff.add("FamilyName"); + } + + if (!OptionalUtil.byteArrayEquals(this.profileKey, that.profileKey)) { + diff.add("ProfileKey"); + } + + if (!Objects.equals(this.avatarUrlPath, that.avatarUrlPath)) { + diff.add("AvatarUrlPath"); + } + + if (!Objects.equals(this.isNoteToSelfArchived(), that.isNoteToSelfArchived())) { + diff.add("NoteToSelfArchived"); + } + + if (!Objects.equals(this.isNoteToSelfForcedUnread(), that.isNoteToSelfForcedUnread())) { + diff.add("NoteToSelfForcedUnread"); + } + + if (!Objects.equals(this.isReadReceiptsEnabled(), that.isReadReceiptsEnabled())) { + diff.add("ReadReceipts"); + } + + if (!Objects.equals(this.isTypingIndicatorsEnabled(), that.isTypingIndicatorsEnabled())) { + diff.add("TypingIndicators"); + } + + if (!Objects.equals(this.isSealedSenderIndicatorsEnabled(), that.isSealedSenderIndicatorsEnabled())) { + diff.add("SealedSenderIndicators"); + } + + if (!Objects.equals(this.isLinkPreviewsEnabled(), that.isLinkPreviewsEnabled())) { + diff.add("LinkPreviews"); + } + + if (!Objects.equals(this.getPhoneNumberSharingMode(), that.getPhoneNumberSharingMode())) { + diff.add("PhoneNumberSharingMode"); + } + + if (!Objects.equals(this.isPhoneNumberUnlisted(), that.isPhoneNumberUnlisted())) { + diff.add("PhoneNumberUnlisted"); + } + + if (!Objects.equals(this.pinnedConversations, that.pinnedConversations)) { + diff.add("PinnedConversations"); + } + + if (!Objects.equals(this.preferContactAvatars, that.preferContactAvatars)) { + diff.add("PreferContactAvatars"); + } + + if (!Objects.equals(this.payments, that.payments)) { + diff.add("PreferContactAvatars"); + } + + if (!Objects.equals(this.hasUnknownFields(), that.hasUnknownFields())) { + diff.add("UnknownFields"); + } + + return diff.toString(); + } else { + return "Different class. " + getClass().getSimpleName() + " | " + other.getClass().getSimpleName(); + } + } + public boolean hasUnknownFields() { return hasUnknownFields; } @@ -251,6 +333,20 @@ public final class SignalAccountRecord implements SignalRecord { public Optional getEntropy() { return entropy; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Payments payments = (Payments) o; + return enabled == payments.enabled && + OptionalUtil.byteArrayEquals(entropy, payments.entropy); + } + + @Override + public int hashCode() { + return Objects.hash(enabled, entropy); + } } public static final class Builder { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalContactRecord.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalContactRecord.java index 74948dc8d8..7a2b311a34 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalContactRecord.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalContactRecord.java @@ -10,6 +10,8 @@ import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.internal.storage.protos.ContactRecord; import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState; +import java.util.LinkedList; +import java.util.List; import java.util.Objects; public final class SignalContactRecord implements SignalRecord { @@ -43,6 +45,75 @@ public final class SignalContactRecord implements SignalRecord { return id; } + @Override + public SignalStorageRecord asStorageRecord() { + return SignalStorageRecord.forContact(this); + } + + @Override + public String describeDiff(SignalRecord other) { + if (other instanceof SignalContactRecord) { + SignalContactRecord that = (SignalContactRecord) other; + List diff = new LinkedList<>(); + + if (!Objects.equals(this.getAddress().getNumber(), that.getAddress().getNumber())) { + diff.add("E164"); + } + + if (!Objects.equals(this.getAddress().getUuid(), that.getAddress().getUuid())) { + diff.add("UUID"); + } + + if (!Objects.equals(this.givenName, that.givenName)) { + diff.add("GivenName"); + } + + if (!Objects.equals(this.familyName, that.familyName)) { + diff.add("FamilyName"); + } + + if (!OptionalUtil.byteArrayEquals(this.profileKey, that.profileKey)) { + diff.add("ProfileKey"); + } + + if (!Objects.equals(this.username, that.username)) { + diff.add("Username"); + } + + if (!OptionalUtil.byteArrayEquals(this.identityKey, that.identityKey)) { + diff.add("IdentityKey"); + } + + if (!Objects.equals(this.getIdentityState(), that.getIdentityState())) { + diff.add("IdentityState"); + } + + if (!Objects.equals(this.isBlocked(), that.isBlocked())) { + diff.add("Blocked"); + } + + if (!Objects.equals(this.isProfileSharingEnabled(), that.isProfileSharingEnabled())) { + diff.add("ProfileSharing"); + } + + if (!Objects.equals(this.isArchived(), that.isArchived())) { + diff.add("Archived"); + } + + if (!Objects.equals(this.isForcedUnread(), that.isForcedUnread())) { + diff.add("ForcedUnread"); + } + + if (!Objects.equals(this.hasUnknownFields(), that.hasUnknownFields())) { + diff.add("UnknownFields"); + } + + return diff.toString(); + } else { + return "Different class. " + getClass().getSimpleName() + " | " + other.getClass().getSimpleName(); + } + } + public boolean hasUnknownFields() { return hasUnknownFields; } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV1Record.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV1Record.java index 6b3b6ef4d2..b2da059f64 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV1Record.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV1Record.java @@ -5,6 +5,9 @@ import com.google.protobuf.ByteString; import org.whispersystems.signalservice.api.util.ProtoUtil; import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; import java.util.Objects; public final class SignalGroupV1Record implements SignalRecord { @@ -26,6 +29,47 @@ public final class SignalGroupV1Record implements SignalRecord { return id; } + @Override + public SignalStorageRecord asStorageRecord() { + return SignalStorageRecord.forGroupV1(this); + } + + @Override + public String describeDiff(SignalRecord other) { + if (other instanceof SignalGroupV1Record) { + SignalGroupV1Record that = (SignalGroupV1Record) other; + List diff = new LinkedList<>(); + + if (!Arrays.equals(this.groupId, that.groupId)) { + diff.add("MasterKey"); + } + + if (!Objects.equals(this.isBlocked(), that.isBlocked())) { + diff.add("Blocked"); + } + + if (!Objects.equals(this.isProfileSharingEnabled(), that.isProfileSharingEnabled())) { + diff.add("ProfileSharing"); + } + + if (!Objects.equals(this.isArchived(), that.isArchived())) { + diff.add("Archived"); + } + + if (!Objects.equals(this.isForcedUnread(), that.isForcedUnread())) { + diff.add("ForcedUnread"); + } + + if (!Objects.equals(this.hasUnknownFields(), that.hasUnknownFields())) { + diff.add("UnknownFields"); + } + + return diff.toString(); + } else { + return "Different class. " + getClass().getSimpleName() + " | " + other.getClass().getSimpleName(); + } + } + public boolean hasUnknownFields() { return hasUnknownFields; } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV2Record.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV2Record.java index fc0dcf4c0b..64cb59db61 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV2Record.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV2Record.java @@ -7,6 +7,9 @@ import org.signal.zkgroup.groups.GroupMasterKey; import org.whispersystems.signalservice.api.util.ProtoUtil; import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; import java.util.Objects; public final class SignalGroupV2Record implements SignalRecord { @@ -28,6 +31,47 @@ public final class SignalGroupV2Record implements SignalRecord { return id; } + @Override + public SignalStorageRecord asStorageRecord() { + return SignalStorageRecord.forGroupV2(this); + } + + @Override + public String describeDiff(SignalRecord other) { + if (other instanceof SignalGroupV2Record) { + SignalGroupV2Record that = (SignalGroupV2Record) other; + List diff = new LinkedList<>(); + + if (!Arrays.equals(this.getMasterKeyBytes(), that.getMasterKeyBytes())) { + diff.add("MasterKey"); + } + + if (!Objects.equals(this.isBlocked(), that.isBlocked())) { + diff.add("Blocked"); + } + + if (!Objects.equals(this.isProfileSharingEnabled(), that.isProfileSharingEnabled())) { + diff.add("ProfileSharing"); + } + + if (!Objects.equals(this.isArchived(), that.isArchived())) { + diff.add("Archived"); + } + + if (!Objects.equals(this.isForcedUnread(), that.isForcedUnread())) { + diff.add("ForcedUnread"); + } + + if (!Objects.equals(this.hasUnknownFields(), that.hasUnknownFields())) { + diff.add("UnknownFields"); + } + + return diff.toString(); + } else { + return "Different class. " + getClass().getSimpleName() + " | " + other.getClass().getSimpleName(); + } + } + public boolean hasUnknownFields() { return hasUnknownFields; } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalRecord.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalRecord.java index a857805eae..ebe060990d 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalRecord.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalRecord.java @@ -2,4 +2,6 @@ package org.whispersystems.signalservice.api.storage; public interface SignalRecord { StorageId getId(); + SignalStorageRecord asStorageRecord(); + String describeDiff(SignalRecord other); } 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 4e029f3fc3..3d9485ed53 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 @@ -67,6 +67,16 @@ public class SignalStorageRecord implements SignalRecord { return id; } + @Override + public SignalStorageRecord asStorageRecord() { + return this; + } + + @Override + public String describeDiff(SignalRecord other) { + return "Diffs not supported."; + } + public int getType() { return id.getType(); } 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 8819b8872b..3d9c4ccddc 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 @@ -29,6 +29,10 @@ public class StorageId { return new StorageId(type, raw); } + public boolean isUnknown() { + return !isKnownType(type); + } + private StorageId(int type, byte[] raw) { this.type = type; this.raw = raw;