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 extends SignalRecord>... results)
+ {
+ Set inserts = new LinkedHashSet<>(localOnlyRecords);
+ Set deletes = new LinkedHashSet<>();
+
+ for (StorageRecordProcessor.Result extends SignalRecord> result : results) {
+ for (StorageRecordUpdate extends SignalRecord> 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;