package org.thoughtcrime.securesms.storage; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.annimon.stream.Collectors; import com.annimon.stream.Stream; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob; import org.thoughtcrime.securesms.jobs.StorageSyncJob; import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.Base64; import org.thoughtcrime.securesms.util.SetUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; 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.SignalStorageManifest; import org.whispersystems.signalservice.api.storage.SignalStorageRecord; import org.whispersystems.signalservice.api.storage.StorageId; import org.whispersystems.signalservice.api.util.OptionalUtil; import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.concurrent.TimeUnit; public final class StorageSyncHelper { private static final String TAG = Log.tag(StorageSyncHelper.class); private static final KeyGenerator KEY_GENERATOR = () -> Util.getSecretBytes(16); private static KeyGenerator keyGenerator = KEY_GENERATOR; private static final long REFRESH_INTERVAL = TimeUnit.HOURS.toMillis(2); /** * Given the local state of pending storage mutations, this will generate a result that will * include that data that needs to be written to the storage service, as well as any changes you * need to write back to local storage (like storage keys that might have changed for updated * contacts). * * @param currentManifestVersion What you think the version is locally. * @param currentLocalKeys All local keys you have. This assumes that 'inserts' were given keys * already, and that deletes still have keys. * @param updates Contacts that have been altered. * @param inserts Contacts that have been inserted (or newly marked as registered). * @param deletes Contacts that are no longer registered. * * @return If changes need to be written, then it will return those changes. If no changes need * to be written, this will return {@link Optional#absent()}. */ public static @NonNull Optional buildStorageUpdatesForLocal(long currentManifestVersion, @NonNull List currentLocalKeys, @NonNull List updates, @NonNull List inserts, @NonNull List deletes, @NonNull Optional accountUpdate, @NonNull Optional accountInsert) { int accountCount = Stream.of(currentLocalKeys) .filter(id -> id.getType() == ManifestRecord.Identifier.Type.ACCOUNT_VALUE) .toList() .size(); if (accountCount > 1) { throw new MultipleExistingAccountsException(); } Optional accountId = Optional.fromNullable(Stream.of(currentLocalKeys) .filter(id -> id.getType() == ManifestRecord.Identifier.Type.ACCOUNT_VALUE) .findFirst() .orElse(null)); if (accountId.isPresent() && accountInsert.isPresent() && !accountInsert.get().getId().equals(accountId.get())) { throw new InvalidAccountInsertException(); } if (accountId.isPresent() && accountUpdate.isPresent() && !accountUpdate.get().getId().equals(accountId.get())) { throw new InvalidAccountUpdateException(); } if (accountUpdate.isPresent() && accountInsert.isPresent()) { throw new InvalidAccountDualInsertUpdateException(); } Set completeIds = new LinkedHashSet<>(currentLocalKeys); Set storageInserts = new LinkedHashSet<>(); Set storageDeletes = new LinkedHashSet<>(); Map storageKeyUpdates = new HashMap<>(); for (RecipientSettings insert : inserts) { if (insert.getGroupType() == RecipientDatabase.GroupType.SIGNAL_V2 && insert.getSyncExtras().getGroupMasterKey() == null) { Log.w(TAG, "Missing master key on gv2 recipient"); continue; } storageInserts.add(StorageSyncModels.localToRemoteRecord(insert)); switch (insert.getGroupType()) { case NONE: completeIds.add(StorageId.forContact(insert.getStorageId())); break; case SIGNAL_V1: completeIds.add(StorageId.forGroupV1(insert.getStorageId())); break; case SIGNAL_V2: completeIds.add(StorageId.forGroupV2(insert.getStorageId())); break; default: throw new AssertionError("Unsupported type!"); } } if (accountInsert.isPresent()) { storageInserts.add(SignalStorageRecord.forAccount(accountInsert.get())); completeIds.add(accountInsert.get().getId()); } for (RecipientSettings delete : deletes) { byte[] key = Objects.requireNonNull(delete.getStorageId()); storageDeletes.add(ByteBuffer.wrap(key)); completeIds.remove(StorageId.forContact(key)); } for (RecipientSettings update : updates) { StorageId oldId; StorageId newId; switch (update.getGroupType()) { case NONE: oldId = StorageId.forContact(update.getStorageId()); newId = StorageId.forContact(generateKey()); break; case SIGNAL_V1: oldId = StorageId.forGroupV1(update.getStorageId()); newId = StorageId.forGroupV1(generateKey()); break; case SIGNAL_V2: oldId = StorageId.forGroupV2(update.getStorageId()); newId = StorageId.forGroupV2(generateKey()); break; default: throw new AssertionError("Unsupported type!"); } storageInserts.add(StorageSyncModels.localToRemoteRecord(update, newId.getRaw())); storageDeletes.add(ByteBuffer.wrap(oldId.getRaw())); completeIds.remove(oldId); completeIds.add(newId); storageKeyUpdates.put(update.getId(), newId.getRaw()); } if (accountUpdate.isPresent()) { StorageId oldId = accountUpdate.get().getId(); StorageId newId = StorageId.forAccount(generateKey()); storageInserts.add(SignalStorageRecord.forAccount(newId, accountUpdate.get())); storageDeletes.add(ByteBuffer.wrap(oldId.getRaw())); completeIds.remove(oldId); completeIds.add(newId); storageKeyUpdates.put(Recipient.self().getId(), newId.getRaw()); } if (storageInserts.isEmpty() && storageDeletes.isEmpty()) { return Optional.absent(); } else { List storageDeleteBytes = Stream.of(storageDeletes).map(ByteBuffer::array).toList(); List completeIdsBytes = new ArrayList<>(completeIds); SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, completeIdsBytes); WriteOperationResult writeOperationResult = new WriteOperationResult(manifest, new ArrayList<>(storageInserts), storageDeleteBytes); return Optional.of(new LocalWriteResult(writeOperationResult, storageKeyUpdates)); } } /** * Given a list of all the local and remote keys you know about, this will return a result telling * you which keys are exclusively remote and which are exclusively local. * * @param remoteKeys All remote keys available. * @param localKeys All local keys available. * * @return An object describing which keys are exclusive to the remote data set and which keys are * exclusive to the local data set. */ public static @NonNull KeyDifferenceResult findKeyDifference(@NonNull Collection remoteKeys, @NonNull Collection localKeys) { Map remoteByRawId = Stream.of(remoteKeys).collect(Collectors.toMap(id -> Base64.encodeBytes(id.getRaw()), id -> id)); Map localByRawId = Stream.of(localKeys).collect(Collectors.toMap(id -> Base64.encodeBytes(id.getRaw()), id -> id)); boolean hasTypeMismatch = remoteByRawId.size() != remoteKeys.size() || localByRawId.size() != localKeys.size(); Set remoteOnlyRawIds = SetUtil.difference(remoteByRawId.keySet(), localByRawId.keySet()); Set localOnlyRawIds = SetUtil.difference(localByRawId.keySet(), remoteByRawId.keySet()); Set sharedRawIds = SetUtil.intersection(localByRawId.keySet(), remoteByRawId.keySet()); for (String rawId : sharedRawIds) { StorageId remote = Objects.requireNonNull(remoteByRawId.get(rawId)); StorageId local = Objects.requireNonNull(localByRawId.get(rawId)); if (remote.getType() != local.getType()) { remoteOnlyRawIds.remove(rawId); localOnlyRawIds.remove(rawId); hasTypeMismatch = true; } } List remoteOnlyKeys = Stream.of(remoteOnlyRawIds).map(remoteByRawId::get).toList(); List localOnlyKeys = Stream.of(localOnlyRawIds).map(localByRawId::get).toList(); return new KeyDifferenceResult(remoteOnlyKeys, localOnlyKeys, hasTypeMismatch); } /** * Given two sets of storage records, this will resolve the data into a set of actions that need * to be applied to resolve the differences. This will handle discovering which records between * the two collections refer to the same contacts and are actually updates, which are brand new, * etc. * * @param remoteOnlyRecords Records that are only present remotely. * @param localOnlyRecords Records that are only present locally. * * @return A set of actions that should be applied to resolve the conflict. */ public static @NonNull MergeResult resolveConflict(@NonNull Collection remoteOnlyRecords, @NonNull Collection localOnlyRecords) { List remoteOnlyContacts = Stream.of(remoteOnlyRecords).filter(r -> r.getContact().isPresent()).map(r -> r.getContact().get()).toList(); List localOnlyContacts = Stream.of(localOnlyRecords).filter(r -> r.getContact().isPresent()).map(r -> r.getContact().get()).toList(); List remoteOnlyGroupV1 = Stream.of(remoteOnlyRecords).filter(r -> r.getGroupV1().isPresent()).map(r -> r.getGroupV1().get()).toList(); List localOnlyGroupV1 = Stream.of(localOnlyRecords).filter(r -> r.getGroupV1().isPresent()).map(r -> r.getGroupV1().get()).toList(); List remoteOnlyGroupV2 = Stream.of(remoteOnlyRecords).filter(r -> r.getGroupV2().isPresent()).map(r -> r.getGroupV2().get()).toList(); List localOnlyGroupV2 = Stream.of(localOnlyRecords).filter(r -> r.getGroupV2().isPresent()).map(r -> r.getGroupV2().get()).toList(); List remoteOnlyUnknowns = Stream.of(remoteOnlyRecords).filter(SignalStorageRecord::isUnknown).toList(); List localOnlyUnknowns = Stream.of(localOnlyRecords).filter(SignalStorageRecord::isUnknown).toList(); List remoteOnlyAccount = Stream.of(remoteOnlyRecords).filter(r -> r.getAccount().isPresent()).map(r -> r.getAccount().get()).toList(); List localOnlyAccount = Stream.of(localOnlyRecords).filter(r -> r.getAccount().isPresent()).map(r -> r.getAccount().get()).toList(); if (remoteOnlyAccount.size() > 0 && localOnlyAccount.isEmpty()) { throw new AssertionError("Found a remote-only account, but no local-only account!"); } if (localOnlyAccount.size() > 1) { throw new AssertionError("Multiple local accounts?"); } RecordMergeResult contactMergeResult = resolveRecordConflict(remoteOnlyContacts, localOnlyContacts, new ContactConflictMerger(localOnlyContacts, Recipient.self())); RecordMergeResult groupV1MergeResult = resolveRecordConflict(remoteOnlyGroupV1, localOnlyGroupV1, new GroupV1ConflictMerger(localOnlyGroupV1)); RecordMergeResult groupV2MergeResult = resolveRecordConflict(remoteOnlyGroupV2, localOnlyGroupV2, new GroupV2ConflictMerger(localOnlyGroupV2)); RecordMergeResult accountMergeResult = resolveRecordConflict(remoteOnlyAccount, localOnlyAccount, new AccountConflictMerger(localOnlyAccount.isEmpty() ? Optional.absent() : Optional.of(localOnlyAccount.get(0)))); Set remoteInserts = new HashSet<>(); remoteInserts.addAll(Stream.of(contactMergeResult.remoteInserts).map(SignalStorageRecord::forContact).toList()); remoteInserts.addAll(Stream.of(groupV1MergeResult.remoteInserts).map(SignalStorageRecord::forGroupV1).toList()); remoteInserts.addAll(Stream.of(groupV2MergeResult.remoteInserts).map(SignalStorageRecord::forGroupV2).toList()); remoteInserts.addAll(Stream.of(accountMergeResult.remoteInserts).map(SignalStorageRecord::forAccount).toList()); Set> remoteUpdates = new HashSet<>(); remoteUpdates.addAll(Stream.of(contactMergeResult.remoteUpdates) .map(c -> new RecordUpdate<>(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()))) .toList()); remoteUpdates.addAll(Stream.of(groupV2MergeResult.remoteUpdates) .map(c -> new RecordUpdate<>(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()))) .toList()); Set remoteDeletes = new HashSet<>(); remoteDeletes.addAll(contactMergeResult.remoteDeletes); remoteDeletes.addAll(groupV1MergeResult.remoteDeletes); remoteDeletes.addAll(groupV2MergeResult.remoteDeletes); remoteDeletes.addAll(accountMergeResult.remoteDeletes); return new MergeResult(contactMergeResult.localInserts, contactMergeResult.localUpdates, groupV1MergeResult.localInserts, groupV1MergeResult.localUpdates, groupV2MergeResult.localInserts, groupV2MergeResult.localUpdates, new LinkedHashSet<>(remoteOnlyUnknowns), new LinkedHashSet<>(localOnlyUnknowns), accountMergeResult.localUpdates.isEmpty() ? Optional.absent() : Optional.of(accountMergeResult.localUpdates.iterator().next()), remoteInserts, remoteUpdates, remoteDeletes); } /** * Assumes that the merge result has *not* yet been applied to the local data. That means that * this method will handle generating the correct final key set based on the merge result. */ public static @NonNull WriteOperationResult createWriteOperation(long currentManifestVersion, @NonNull List currentLocalStorageKeys, @NonNull MergeResult mergeResult) { List inserts = new ArrayList<>(); inserts.addAll(mergeResult.getRemoteInserts()); inserts.addAll(Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::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()); Set completeKeys = new HashSet<>(currentLocalStorageKeys); completeKeys.addAll(Stream.of(mergeResult.getAllNewRecords()).map(SignalRecord::getId).toList()); completeKeys.removeAll(Stream.of(mergeResult.getAllRemovedRecords()).map(SignalRecord::getId).toList()); completeKeys.addAll(Stream.of(inserts).map(SignalStorageRecord::getId).toList()); completeKeys.removeAll(deletes); SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, new ArrayList<>(completeKeys)); return new WriteOperationResult(manifest, inserts, Stream.of(deletes).map(StorageId::getRaw).toList()); } public static @NonNull byte[] generateKey() { return keyGenerator.generate(); } @VisibleForTesting static void setTestKeyGenerator(@Nullable KeyGenerator testKeyGenerator) { keyGenerator = testKeyGenerator; } private static @NonNull RecordMergeResult resolveRecordConflict(@NonNull Collection remoteOnlyRecords, @NonNull Collection localOnlyRecords, @NonNull ConflictMerger merger) { Set localInserts = new HashSet<>(remoteOnlyRecords); Set remoteInserts = new HashSet<>(localOnlyRecords); Set> localUpdates = new HashSet<>(); Set> remoteUpdates = new HashSet<>(); Set remoteDeletes = new HashSet<>(merger.getInvalidEntries(remoteOnlyRecords)); remoteOnlyRecords.removeAll(remoteDeletes); localInserts.removeAll(remoteDeletes); for (E remote : remoteOnlyRecords) { Optional local = merger.getMatching(remote); if (local.isPresent()) { E merged = merger.merge(remote, local.get(), keyGenerator); if (!merged.equals(remote)) { remoteUpdates.add(new RecordUpdate<>(remote, merged)); } if (!merged.equals(local.get())) { localUpdates.add(new RecordUpdate<>(local.get(), merged)); } localInserts.remove(remote); remoteInserts.remove(local.get()); } } return new RecordMergeResult<>(localInserts, localUpdates, remoteInserts, remoteUpdates, remoteDeletes); } public static boolean profileKeyChanged(RecordUpdate update) { return !OptionalUtil.byteArrayEquals(update.getOld().getProfileKey(), update.getNew().getProfileKey()); } public static Optional getPendingAccountSyncUpdate(@NonNull Context context, @NonNull Recipient self) { if (DatabaseFactory.getRecipientDatabase(context).getDirtyState(self.getId()) != RecipientDatabase.DirtyState.UPDATE) { return Optional.absent(); } return Optional.of(buildAccountRecord(context, self).getAccount().get()); } public static Optional getPendingAccountSyncInsert(@NonNull Context context, @NonNull Recipient self) { if (DatabaseFactory.getRecipientDatabase(context).getDirtyState(self.getId()) != RecipientDatabase.DirtyState.INSERT) { return Optional.absent(); } return Optional.of(buildAccountRecord(context, self).getAccount().get()); } public static SignalStorageRecord buildAccountRecord(@NonNull Context context, @NonNull Recipient self) { RecipientSettings settings = DatabaseFactory.getRecipientDatabase(context).getRecipientSettingsForSync(self.getId()); SignalAccountRecord account = new SignalAccountRecord.Builder(self.getStorageServiceId()) .setUnknownFields(settings != null ? settings.getSyncExtras().getStorageProto() : null) .setProfileKey(self.getProfileKey()) .setGivenName(self.getProfileName().getGivenName()) .setFamilyName(self.getProfileName().getFamilyName()) .setAvatarUrlPath(self.getProfileAvatar()) .setNoteToSelfArchived(settings != null && settings.getSyncExtras().isArchived()) .setNoteToSelfForcedUnread(settings != null && settings.getSyncExtras().isForcedUnread()) .setTypingIndicatorsEnabled(TextSecurePreferences.isTypingIndicatorsEnabled(context)) .setReadReceiptsEnabled(TextSecurePreferences.isReadReceiptsEnabled(context)) .setSealedSenderIndicatorsEnabled(TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context)) .setLinkPreviewsEnabled(SignalStore.settings().isLinkPreviewsEnabled()) .setUnlistedPhoneNumber(SignalStore.phoneNumberPrivacy().getPhoneNumberListingMode().isUnlisted()) .setPhoneNumberSharingMode(localToRemotePhoneNumberSharingMode(SignalStore.phoneNumberPrivacy().getPhoneNumberSharingMode())) .build(); return SignalStorageRecord.forAccount(account); } private static AccountRecord.PhoneNumberSharingMode localToRemotePhoneNumberSharingMode(PhoneNumberPrivacyValues.PhoneNumberSharingMode phoneNumberPhoneNumberSharingMode) { switch (phoneNumberPhoneNumberSharingMode) { case EVERYONE: return AccountRecord.PhoneNumberSharingMode.EVERYBODY; case CONTACTS: return AccountRecord.PhoneNumberSharingMode.CONTACTS_ONLY; case NOBODY : return AccountRecord.PhoneNumberSharingMode.NOBODY; default : throw new AssertionError(); } } private static PhoneNumberPrivacyValues.PhoneNumberSharingMode remoteToLocalPhoneNumberSharingMode(AccountRecord.PhoneNumberSharingMode phoneNumberPhoneNumberSharingMode) { switch (phoneNumberPhoneNumberSharingMode) { case EVERYBODY : return PhoneNumberPrivacyValues.PhoneNumberSharingMode.EVERYONE; case CONTACTS_ONLY: return PhoneNumberPrivacyValues.PhoneNumberSharingMode.CONTACTS; case NOBODY : return PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY; default : return PhoneNumberPrivacyValues.PhoneNumberSharingMode.CONTACTS; } } public static void applyAccountStorageSyncUpdates(@NonNull Context context, Optional> update) { if (!update.isPresent()) { return; } applyAccountStorageSyncUpdates(context, StorageId.forAccount(Recipient.self().getStorageServiceId()), 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); TextSecurePreferences.setReadReceiptsEnabled(context, update.isReadReceiptsEnabled()); TextSecurePreferences.setTypingIndicatorsEnabled(context, update.isTypingIndicatorsEnabled()); TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, update.isSealedSenderIndicatorsEnabled()); SignalStore.settings().setLinkPreviewsEnabled(update.isLinkPreviewsEnabled()); SignalStore.phoneNumberPrivacy().setPhoneNumberListingMode(update.isPhoneNumberUnlisted() ? PhoneNumberPrivacyValues.PhoneNumberListingMode.UNLISTED : PhoneNumberPrivacyValues.PhoneNumberListingMode.LISTED); SignalStore.phoneNumberPrivacy().setPhoneNumberSharingMode(remoteToLocalPhoneNumberSharingMode(update.getPhoneNumberSharingMode())); if (fetchProfile && update.getAvatarUrlPath().isPresent()) { ApplicationDependencies.getJobManager().add(new RetrieveProfileAvatarJob(Recipient.self(), update.getAvatarUrlPath().get())); } } public static void scheduleSyncForDataChange() { if (!SignalStore.registrationValues().isRegistrationComplete()) { Log.d(TAG, "Registration still ongoing. Ignore sync request."); return; } ApplicationDependencies.getJobManager().add(new StorageSyncJob()); } public static void scheduleRoutineSync() { long timeSinceLastSync = System.currentTimeMillis() - SignalStore.storageServiceValues().getLastSyncTime(); if (timeSinceLastSync > REFRESH_INTERVAL) { Log.d(TAG, "Scheduling a sync. Last sync was " + timeSinceLastSync + " ms ago."); scheduleSyncForDataChange(); } else { Log.d(TAG, "No need for sync. Last sync was " + timeSinceLastSync + " ms ago."); } } public static final class KeyDifferenceResult { private final List remoteOnlyKeys; private final List localOnlyKeys; private final boolean hasTypeMismatches; private KeyDifferenceResult(@NonNull List remoteOnlyKeys, @NonNull List localOnlyKeys, boolean hasTypeMismatches) { this.remoteOnlyKeys = remoteOnlyKeys; this.localOnlyKeys = localOnlyKeys; this.hasTypeMismatches = hasTypeMismatches; } public @NonNull List getRemoteOnlyKeys() { return remoteOnlyKeys; } public @NonNull List getLocalOnlyKeys() { return localOnlyKeys; } /** * @return True if there exist some keys that have matching raw ID's but different types, * otherwise false. */ public boolean hasTypeMismatches() { return hasTypeMismatches; } public boolean isEmpty() { return remoteOnlyKeys.isEmpty() && localOnlyKeys.isEmpty(); } } 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; @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) { this.localContactInserts = localContactInserts; this.localContactUpdates = localContactUpdates; this.localGroupV1Inserts = localGroupV1Inserts; this.localGroupV1Updates = localGroupV1Updates; this.localGroupV2Inserts = localGroupV2Inserts; this.localGroupV2Updates = localGroupV2Updates; this.localUnknownInserts = localUnknownInserts; this.localUnknownDeletes = localUnknownDeletes; this.localAccountUpdate = localAccountUpdate; this.remoteInserts = remoteInserts; this.remoteUpdates = remoteUpdates; this.remoteDeletes = remoteDeletes; } public @NonNull Set getLocalContactInserts() { return localContactInserts; } public @NonNull Set> getLocalContactUpdates() { return localContactUpdates; } public @NonNull Set getLocalGroupV1Inserts() { return localGroupV1Inserts; } public @NonNull Set> getLocalGroupV1Updates() { return localGroupV1Updates; } public @NonNull Set getLocalGroupV2Inserts() { return localGroupV2Inserts; } public @NonNull Set> getLocalGroupV2Updates() { return localGroupV2Updates; } public @NonNull Set getLocalUnknownInserts() { return localUnknownInserts; } public @NonNull Set getLocalUnknownDeletes() { return localUnknownDeletes; } public @NonNull Optional> getLocalAccountUpdate() { return localAccountUpdate; } public @NonNull Set getRemoteInserts() { return remoteInserts; } public @NonNull Set> getRemoteUpdates() { return remoteUpdates; } public @NonNull Set getRemoteDeletes() { return remoteDeletes; } @NonNull Set getAllNewRecords() { Set records = new HashSet<>(); records.addAll(localContactInserts); records.addAll(localGroupV1Inserts); 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()); if (localAccountUpdate.isPresent()) records.add(localAccountUpdate.get().getNew()); return records; } @NonNull Set getAllRemovedRecords() { 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(remoteDeletes); if (localAccountUpdate.isPresent()) records.add(localAccountUpdate.get().getOld()); return records; } @Override public @NonNull String toString() { return String.format(Locale.ENGLISH, "localContactInserts: %d, localContactUpdates: %d, localGroupV1Inserts: %d, localGroupV1Updates: %d, localGroupV2Inserts: %d, localGroupV2Updates: %d, localUnknownInserts: %d, localUnknownDeletes: %d, localAccountUpdate: %b, remoteInserts: %d, remoteUpdates: %d, remoteDeletes: %d", localContactInserts.size(), localContactUpdates.size(), localGroupV1Inserts.size(), localGroupV1Updates.size(), localGroupV2Inserts.size(), localGroupV2Updates.size(), localUnknownInserts.size(), localUnknownDeletes.size(), localAccountUpdate.isPresent(), remoteInserts.size(), remoteUpdates.size(), remoteDeletes.size()); } } public static final class WriteOperationResult { private final SignalStorageManifest manifest; private final List inserts; private final List deletes; private WriteOperationResult(@NonNull SignalStorageManifest manifest, @NonNull List inserts, @NonNull List deletes) { this.manifest = manifest; this.inserts = inserts; this.deletes = deletes; } public @NonNull SignalStorageManifest getManifest() { return manifest; } public @NonNull List getInserts() { return inserts; } public @NonNull List getDeletes() { return deletes; } public boolean isEmpty() { return inserts.isEmpty() && deletes.isEmpty(); } @Override public @NonNull String toString() { return String.format(Locale.ENGLISH, "ManifestVersion: %d, Total Keys: %d, Inserts: %d, Deletes: %d", manifest.getVersion(), manifest.getStorageIds().size(), inserts.size(), deletes.size()); } } public static class LocalWriteResult { private final WriteOperationResult writeResult; private final Map storageKeyUpdates; private LocalWriteResult(WriteOperationResult writeResult, Map storageKeyUpdates) { this.writeResult = writeResult; this.storageKeyUpdates = storageKeyUpdates; } public @NonNull WriteOperationResult getWriteResult() { return writeResult; } public @NonNull Map getStorageKeyUpdates() { return storageKeyUpdates; } } 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; RecordMergeResult(@NonNull Set localInserts, @NonNull Set> localUpdates, @NonNull Set remoteInserts, @NonNull Set> remoteUpdates, @NonNull Set remoteDeletes) { this.localInserts = localInserts; this.localUpdates = localUpdates; this.remoteInserts = remoteInserts; this.remoteUpdates = remoteUpdates; this.remoteDeletes = remoteDeletes; } } 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(); } private static final class MultipleExistingAccountsException extends IllegalArgumentException {} private static final class InvalidAccountInsertException extends IllegalArgumentException {} private static final class InvalidAccountUpdateException extends IllegalArgumentException {} private static final class InvalidAccountDualInsertUpdateException extends IllegalArgumentException {} }