diff --git a/app/build.gradle b/app/build.gradle index bd67709a6c..76bd44f12d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -350,7 +350,9 @@ dependencies { testImplementation 'org.powermock:powermock-classloading-xstream:1.6.1' testImplementation 'androidx.test:core:1.2.0' - testImplementation 'org.robolectric:robolectric:4.2' + testImplementation ('org.robolectric:robolectric:4.2') { + exclude group: 'com.google.protobuf', module: 'protobuf-java' + } testImplementation 'org.robolectric:shadows-multidex:4.2' androidTestImplementation 'androidx.test.ext:junit:1.1.1' diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/StorageSyncHelper.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/StorageSyncHelper.java deleted file mode 100644 index eab25c0ebe..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/StorageSyncHelper.java +++ /dev/null @@ -1,767 +0,0 @@ -package org.thoughtcrime.securesms.contacts.sync; - -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.IdentityDatabase; -import org.thoughtcrime.securesms.database.RecipientDatabase; -import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; -import org.thoughtcrime.securesms.logging.Log; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.util.GroupUtil; -import org.thoughtcrime.securesms.util.SetUtil; -import org.thoughtcrime.securesms.util.Util; -import org.whispersystems.libsignal.util.guava.Optional; -import org.whispersystems.signalservice.api.push.SignalServiceAddress; -import org.whispersystems.signalservice.api.storage.SignalContactRecord; -import org.whispersystems.signalservice.api.storage.SignalContactRecord.IdentityState; -import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; -import org.whispersystems.signalservice.api.storage.SignalStorageManifest; -import org.whispersystems.signalservice.api.storage.SignalStorageRecord; -import org.whispersystems.signalservice.api.util.OptionalUtil; - -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.UUID; - -import javax.crypto.KeyGenerator; - -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 testKeyGenerator = null; - - /** - * 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) - { - Set completeKeys = new LinkedHashSet<>(Stream.of(currentLocalKeys).map(ByteBuffer::wrap).toList()); - Set storageInserts = new LinkedHashSet<>(); - Set storageDeletes = new LinkedHashSet<>(); - Map storageKeyUpdates = new HashMap<>(); - - for (RecipientSettings insert : inserts) { - storageInserts.add(localToRemoteRecord(insert)); - } - - for (RecipientSettings delete : deletes) { - byte[] key = Objects.requireNonNull(delete.getStorageKey()); - storageDeletes.add(ByteBuffer.wrap(key)); - completeKeys.remove(ByteBuffer.wrap(key)); - } - - for (RecipientSettings update : updates) { - byte[] oldKey = Objects.requireNonNull(update.getStorageKey()); - byte[] newKey = generateKey(); - - storageInserts.add(localToRemoteRecord(update, newKey)); - storageDeletes.add(ByteBuffer.wrap(oldKey)); - completeKeys.remove(ByteBuffer.wrap(oldKey)); - completeKeys.add(ByteBuffer.wrap(newKey)); - storageKeyUpdates.put(update.getId(), newKey); - } - - if (storageInserts.isEmpty() && storageDeletes.isEmpty()) { - return Optional.absent(); - } else { - List contactDeleteBytes = Stream.of(storageDeletes).map(ByteBuffer::array).toList(); - List completeKeysBytes = Stream.of(completeKeys).map(ByteBuffer::array).toList(); - SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, completeKeysBytes); - WriteOperationResult writeOperationResult = new WriteOperationResult(manifest, new ArrayList<>(storageInserts), contactDeleteBytes); - - 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 List remoteKeys, - @NonNull List localKeys) - { - Set allRemoteKeys = Stream.of(remoteKeys).map(ByteBuffer::wrap).collect(LinkedHashSet::new, HashSet::add); - Set allLocalKeys = Stream.of(localKeys).map(ByteBuffer::wrap).collect(LinkedHashSet::new, HashSet::add); - - Set remoteOnlyKeys = SetUtil.difference(allRemoteKeys, allLocalKeys); - Set localOnlyKeys = SetUtil.difference(allLocalKeys, allRemoteKeys); - - return new KeyDifferenceResult(Stream.of(remoteOnlyKeys).map(ByteBuffer::array).toList(), - Stream.of(localOnlyKeys).map(ByteBuffer::array).toList()); - } - - /** - * 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 remoteOnlyUnknowns = Stream.of(remoteOnlyRecords).filter(SignalStorageRecord::isUnknown).toList(); - List localOnlyUnknowns = Stream.of(localOnlyRecords).filter(SignalStorageRecord::isUnknown).toList(); - - ContactRecordMergeResult contactMergeResult = resolveContactConflict(remoteOnlyContacts, localOnlyContacts); - GroupV1RecordMergeResult groupV1MergeResult = resolveGroupV1Conflict(remoteOnlyGroupV1, localOnlyGroupV1); - - Set remoteInserts = new HashSet<>(); - remoteInserts.addAll(Stream.of(contactMergeResult.remoteInserts).map(SignalStorageRecord::forContact).toList()); - remoteInserts.addAll(Stream.of(groupV1MergeResult.remoteInserts).map(SignalStorageRecord::forGroupV1).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()); - - return new MergeResult(contactMergeResult.localInserts, - contactMergeResult.localUpdates, - groupV1MergeResult.localInserts, - groupV1MergeResult.localUpdates, - new LinkedHashSet<>(remoteOnlyUnknowns), - new LinkedHashSet<>(localOnlyUnknowns), - remoteInserts, - remoteUpdates); - } - - /** - * 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) - { - Set completeKeys = new LinkedHashSet<>(Stream.of(currentLocalStorageKeys).map(ByteBuffer::wrap).toList()); - - for (SignalContactRecord insert : mergeResult.getLocalContactInserts()) { - completeKeys.add(ByteBuffer.wrap(insert.getKey())); - } - - for (SignalGroupV1Record insert : mergeResult.getLocalGroupV1Inserts()) { - completeKeys.add(ByteBuffer.wrap(insert.getKey())); - } - - for (SignalStorageRecord insert : mergeResult.getRemoteInserts()) { - completeKeys.add(ByteBuffer.wrap(insert.getKey())); - } - - for (SignalStorageRecord insert : mergeResult.getLocalUnknownInserts()) { - completeKeys.add(ByteBuffer.wrap(insert.getKey())); - } - - for (ContactUpdate update : mergeResult.getLocalContactUpdates()) { - completeKeys.remove(ByteBuffer.wrap(update.getOld().getKey())); - completeKeys.add(ByteBuffer.wrap(update.getNew().getKey())); - } - - for (GroupV1Update update : mergeResult.getLocalGroupV1Updates()) { - completeKeys.remove(ByteBuffer.wrap(update.getOld().getKey())); - completeKeys.add(ByteBuffer.wrap(update.getNew().getKey())); - } - - for (RecordUpdate update : mergeResult.getRemoteUpdates()) { - completeKeys.remove(ByteBuffer.wrap(update.getOld().getKey())); - completeKeys.add(ByteBuffer.wrap(update.getNew().getKey())); - } - - SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, Stream.of(completeKeys).map(ByteBuffer::array).toList()); - - List inserts = new ArrayList<>(); - inserts.addAll(mergeResult.getRemoteInserts()); - inserts.addAll(Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getNew).toList()); - - List deletes = Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getOld).map(SignalStorageRecord::getKey).toList(); - - return new WriteOperationResult(manifest, inserts, deletes); - } - - public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientSettings settings) { - if (settings.getStorageKey() == null) { - throw new AssertionError("Must have a storage key!"); - } - - return localToRemoteRecord(settings, settings.getStorageKey()); - } - - public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientSettings settings, @NonNull byte[] key) { - if (settings.getGroupType() == RecipientDatabase.GroupType.NONE) { - return SignalStorageRecord.forContact(localToRemoteContact(settings, key)); - } else if (settings.getGroupType() == RecipientDatabase.GroupType.SIGNAL_V1) { - return SignalStorageRecord.forGroupV1(localToRemoteGroupV1(settings, key)); - } else { - throw new AssertionError("Unsupported type!"); - } - } - - private static @NonNull SignalContactRecord localToRemoteContact(@NonNull RecipientSettings recipient, byte[] storageKey) { - if (recipient.getUuid() == null && recipient.getE164() == null) { - throw new AssertionError("Must have either a UUID or a phone number!"); - } - - return new SignalContactRecord.Builder(storageKey, new SignalServiceAddress(recipient.getUuid(), recipient.getE164())) - .setProfileKey(recipient.getProfileKey()) - .setGivenName(recipient.getProfileName().getGivenName()) - .setFamilyName(recipient.getProfileName().getFamilyName()) - .setBlocked(recipient.isBlocked()) - .setProfileSharingEnabled(recipient.isProfileSharing()) - .setIdentityKey(recipient.getIdentityKey()) - .setIdentityState(localToRemoteIdentityState(recipient.getIdentityStatus())) - .build(); - } - - private static @NonNull SignalGroupV1Record localToRemoteGroupV1(@NonNull RecipientSettings recipient, byte[] storageKey) { - if (recipient.getGroupId() == null) { - throw new AssertionError("Must have a groupId!"); - } - - return new SignalGroupV1Record.Builder(storageKey, GroupUtil.getDecodedIdOrThrow(recipient.getGroupId())) - .setBlocked(recipient.isBlocked()) - .setProfileSharingEnabled(recipient.isProfileSharing()) - .build(); - } - - public static @NonNull IdentityDatabase.VerifiedStatus remoteToLocalIdentityStatus(@NonNull IdentityState identityState) { - switch (identityState) { - case VERIFIED: return IdentityDatabase.VerifiedStatus.VERIFIED; - case UNVERIFIED: return IdentityDatabase.VerifiedStatus.UNVERIFIED; - default: return IdentityDatabase.VerifiedStatus.DEFAULT; - } - } - - public static @NonNull byte[] generateKey() { - if (testKeyGenerator != null) { - return testKeyGenerator.generate(); - } else { - return KEY_GENERATOR.generate(); - } - } - - @VisibleForTesting - static @NonNull SignalContactRecord mergeContacts(@NonNull SignalContactRecord remote, - @NonNull SignalContactRecord local) - { - 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); - String givenName = remote.getGivenName().or(local.getGivenName()).or(""); - String familyName = remote.getFamilyName().or(local.getFamilyName()).or(""); - 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(); - String nickname = local.getNickname().or(""); // TODO [greyson] Update this when we add real nickname support - boolean blocked = remote.isBlocked(); - boolean profileSharing = remote.isProfileSharingEnabled() || local.isProfileSharingEnabled(); - boolean matchesRemote = doParamsMatchContact(remote, address, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, nickname); - boolean matchesLocal = doParamsMatchContact(local, address, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, nickname); - - if (remote.getProtoVersion() > 0) { - Log.w(TAG, "Inbound model has version " + remote.getProtoVersion() + ", but our version is 0."); - } - - if (matchesRemote) { - return remote; - } else if (matchesLocal) { - return local; - } else { - return new SignalContactRecord.Builder(generateKey(), address) - .setGivenName(givenName) - .setFamilyName(familyName) - .setProfileKey(profileKey) - .setUsername(username) - .setIdentityState(identityState) - .setIdentityKey(identityKey) - .setBlocked(blocked) - .setProfileSharingEnabled(profileSharing) - .setNickname(nickname) - .build(); - } - } - - @VisibleForTesting - static @NonNull SignalGroupV1Record mergeGroupV1(@NonNull SignalGroupV1Record remote, - @NonNull SignalGroupV1Record local) - { - boolean blocked = remote.isBlocked(); - boolean profileSharing = remote.isProfileSharingEnabled() || local.isProfileSharingEnabled(); - - boolean matchesRemote = blocked == remote.isBlocked() && profileSharing == remote.isProfileSharingEnabled(); - boolean matchesLocal = blocked == local.isBlocked() && profileSharing == local.isProfileSharingEnabled(); - - if (matchesRemote) { - return remote; - } else if (matchesLocal) { - return local; - } else { - return new SignalGroupV1Record.Builder(generateKey(), remote.getGroupId()) - .setBlocked(blocked) - .setProfileSharingEnabled(blocked) - .build(); - } - } - - @VisibleForTesting - static void setTestKeyGenerator(@Nullable KeyGenerator keyGenerator) { - testKeyGenerator = keyGenerator; - } - - private static IdentityState localToRemoteIdentityState(@NonNull IdentityDatabase.VerifiedStatus local) { - switch (local) { - case VERIFIED: return IdentityState.VERIFIED; - case UNVERIFIED: return IdentityState.UNVERIFIED; - default: return IdentityState.DEFAULT; - } - } - - private static boolean doParamsMatchContact(@NonNull SignalContactRecord contact, - @NonNull SignalServiceAddress address, - @Nullable String givenName, - @Nullable String familyName, - @Nullable byte[] profileKey, - @Nullable String username, - @Nullable IdentityState identityState, - @Nullable byte[] identityKey, - boolean blocked, - boolean profileSharing, - @Nullable String nickname) - { - return 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 && - Objects.equals(contact.getNickname().or(""), nickname); - } - - private static @NonNull ContactRecordMergeResult resolveContactConflict(@NonNull Collection remoteOnlyRecords, - @NonNull Collection localOnlyRecords) - { - Map localByUuid = new HashMap<>(); - Map localByE164 = new HashMap<>(); - - for (SignalContactRecord contact : localOnlyRecords) { - if (contact.getAddress().getUuid().isPresent()) { - localByUuid.put(contact.getAddress().getUuid().get(), contact); - } - if (contact.getAddress().getNumber().isPresent()) { - localByE164.put(contact.getAddress().getNumber().get(), contact); - } - } - - Set localInserts = new LinkedHashSet<>(remoteOnlyRecords); - Set remoteInserts = new LinkedHashSet<>(localOnlyRecords); - Set localUpdates = new LinkedHashSet<>(); - Set remoteUpdates = new LinkedHashSet<>(); - - for (SignalContactRecord remote : remoteOnlyRecords) { - SignalContactRecord localUuid = remote.getAddress().getUuid().isPresent() ? localByUuid.get(remote.getAddress().getUuid().get()) : null; - SignalContactRecord localE164 = remote.getAddress().getNumber().isPresent() ? localByE164.get(remote.getAddress().getNumber().get()) : null; - - Optional local = Optional.fromNullable(localUuid).or(Optional.fromNullable(localE164)); - - if (local.isPresent()) { - SignalContactRecord merged = mergeContacts(remote, local.get()); - - if (!merged.equals(remote)) { - remoteUpdates.add(new ContactUpdate(remote, merged)); - } - - if (!merged.equals(local.get())) { - localUpdates.add(new ContactUpdate(local.get(), merged)); - } - - localInserts.remove(remote); - remoteInserts.remove(local.get()); - } - } - - return new ContactRecordMergeResult(localInserts, localUpdates, remoteInserts, remoteUpdates); - } - - private static @NonNull GroupV1RecordMergeResult resolveGroupV1Conflict(@NonNull Collection remoteOnlyRecords, - @NonNull Collection localOnlyRecords) - { - Map remoteByGroupId = Stream.of(remoteOnlyRecords).collect(Collectors.toMap(g -> GroupUtil.getEncodedId(g.getGroupId(), false), g -> g)); - Map localByGroupId = Stream.of(localOnlyRecords).collect(Collectors.toMap(g -> GroupUtil.getEncodedId(g.getGroupId(), false), g -> g)); - - Set localInserts = new LinkedHashSet<>(remoteOnlyRecords); - Set remoteInserts = new LinkedHashSet<>(localOnlyRecords); - Set localUpdates = new LinkedHashSet<>(); - Set remoteUpdates = new LinkedHashSet<>(); - - for (Map.Entry entry : remoteByGroupId.entrySet()) { - SignalGroupV1Record remote = entry.getValue(); - SignalGroupV1Record local = localByGroupId.get(entry.getKey()); - - if (local != null) { - SignalGroupV1Record merged = mergeGroupV1(remote, local); - - if (!merged.equals(remote)) { - remoteUpdates.add(new GroupV1Update(remote, merged)); - } - - if (!merged.equals(local)) { - localUpdates.add(new GroupV1Update(local, merged)); - } - - localInserts.remove(remote); - remoteInserts.remove(local); - } - } - - return new GroupV1RecordMergeResult(localInserts, localUpdates, remoteInserts, remoteUpdates); - } - - public static final class ContactUpdate { - private final SignalContactRecord oldContact; - private final SignalContactRecord newContact; - - ContactUpdate(@NonNull SignalContactRecord oldContact, @NonNull SignalContactRecord newContact) { - this.oldContact = oldContact; - this.newContact = newContact; - } - - public @NonNull SignalContactRecord getOld() { - return oldContact; - } - - public @NonNull SignalContactRecord getNew() { - return newContact; - } - - public boolean profileKeyChanged() { - return !OptionalUtil.byteArrayEquals(oldContact.getProfileKey(), newContact.getProfileKey()); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - ContactUpdate that = (ContactUpdate) o; - return oldContact.equals(that.oldContact) && - newContact.equals(that.newContact); - } - - @Override - public int hashCode() { - return Objects.hash(oldContact, newContact); - } - } - - public static final class GroupV1Update { - private final SignalGroupV1Record oldGroup; - private final SignalGroupV1Record newGroup; - - - public GroupV1Update(@NonNull SignalGroupV1Record oldGroup, @NonNull SignalGroupV1Record newGroup) { - this.oldGroup = oldGroup; - this.newGroup = newGroup; - } - - public @NonNull SignalGroupV1Record getOld() { - return oldGroup; - } - - public @NonNull SignalGroupV1Record getNew() { - return newGroup; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - GroupV1Update that = (GroupV1Update) o; - return oldGroup.equals(that.oldGroup) && - newGroup.equals(that.newGroup); - } - - @Override - public int hashCode() { - return Objects.hash(oldGroup, newGroup); - } - } - - @VisibleForTesting - static class RecordUpdate { - private final SignalStorageRecord oldRecord; - private final SignalStorageRecord newRecord; - - RecordUpdate(@NonNull SignalStorageRecord oldRecord, @NonNull SignalStorageRecord newRecord) { - this.oldRecord = oldRecord; - this.newRecord = newRecord; - } - - public @NonNull SignalStorageRecord getOld() { - return oldRecord; - } - - public @NonNull SignalStorageRecord 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); - } - } - - public static final class KeyDifferenceResult { - private final List remoteOnlyKeys; - private final List localOnlyKeys; - - private KeyDifferenceResult(@NonNull List remoteOnlyKeys, @NonNull List localOnlyKeys) { - this.remoteOnlyKeys = remoteOnlyKeys; - this.localOnlyKeys = localOnlyKeys; - } - - public @NonNull List getRemoteOnlyKeys() { - return remoteOnlyKeys; - } - - public @NonNull List getLocalOnlyKeys() { - return localOnlyKeys; - } - - 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 localUnknownInserts; - private final Set localUnknownDeletes; - private final Set remoteInserts; - private final Set remoteUpdates; - - @VisibleForTesting - MergeResult(@NonNull Set localContactInserts, - @NonNull Set localContactUpdates, - @NonNull Set localGroupV1Inserts, - @NonNull Set localGroupV1Updates, - @NonNull Set localUnknownInserts, - @NonNull Set localUnknownDeletes, - @NonNull Set remoteInserts, - @NonNull Set remoteUpdates) - { - this.localContactInserts = localContactInserts; - this.localContactUpdates = localContactUpdates; - this.localGroupV1Inserts = localGroupV1Inserts; - this.localGroupV1Updates = localGroupV1Updates; - this.localUnknownInserts = localUnknownInserts; - this.localUnknownDeletes = localUnknownDeletes; - this.remoteInserts = remoteInserts; - this.remoteUpdates = remoteUpdates; - } - - 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 getLocalUnknownInserts() { - return localUnknownInserts; - } - - public @NonNull Set getLocalUnknownDeletes() { - return localUnknownDeletes; - } - - public @NonNull Set getRemoteInserts() { - return remoteInserts; - } - - public @NonNull Set getRemoteUpdates() { - return remoteUpdates; - } - - @Override - public @NonNull String toString() { - return String.format(Locale.ENGLISH, - "localContactInserts: %d, localContactUpdates: %d, localGroupInserts: %d, localGroupUpdates: %d, localUnknownInserts: %d, localUnknownDeletes: %d, remoteInserts: %d, remoteUpdates: %d", - localContactInserts.size(), localContactUpdates.size(), localGroupV1Inserts.size(), localGroupV1Updates.size(), localUnknownInserts.size(), localUnknownDeletes.size(), remoteInserts.size(), remoteUpdates.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.getStorageKeys().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; - } - } - - private static final class ContactRecordMergeResult { - final Set localInserts; - final Set localUpdates; - final Set remoteInserts; - final Set remoteUpdates; - - ContactRecordMergeResult(@NonNull Set localInserts, - @NonNull Set localUpdates, - @NonNull Set remoteInserts, - @NonNull Set remoteUpdates) - { - this.localInserts = localInserts; - this.localUpdates = localUpdates; - this.remoteInserts = remoteInserts; - this.remoteUpdates = remoteUpdates; - } - } - - private static final class GroupV1RecordMergeResult { - final Set localInserts; - final Set localUpdates; - final Set remoteInserts; - final Set remoteUpdates; - - GroupV1RecordMergeResult(@NonNull Set localInserts, - @NonNull Set localUpdates, - @NonNull Set remoteInserts, - @NonNull Set remoteUpdates) - { - this.localInserts = localInserts; - this.localUpdates = localUpdates; - this.remoteInserts = remoteInserts; - this.remoteUpdates = remoteUpdates; - } - } - - interface KeyGenerator { - @NonNull byte[] generate(); - } -} 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 4468f62f91..241aa5e5ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -17,7 +17,9 @@ import net.sqlcipher.database.SQLiteDatabase; import org.signal.zkgroup.profiles.ProfileKey; import org.signal.zkgroup.profiles.ProfileKeyCredential; import org.thoughtcrime.securesms.color.MaterialColor; -import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.RecordUpdate; +import org.thoughtcrime.securesms.storage.StorageSyncModels; import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; @@ -39,6 +41,7 @@ import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.storage.SignalContactRecord; import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; +import org.whispersystems.signalservice.api.storage.StorageId; import org.whispersystems.signalservice.api.util.UuidUtil; import java.io.Closeable; @@ -92,7 +95,7 @@ public class RecipientDatabase extends Database { private static final String FORCE_SMS_SELECTION = "force_sms_selection"; private static final String UUID_CAPABILITY = "uuid_supported"; private static final String GROUPS_V2_CAPABILITY = "gv2_capability"; - private static final String STORAGE_SERVICE_KEY = "storage_service_key"; + private static final String STORAGE_SERVICE_ID = "storage_service_key"; private static final String DIRTY = "dirty"; private static final String PROFILE_GIVEN_NAME = "signal_profile_name"; private static final String PROFILE_FAMILY_NAME = "profile_family_name"; @@ -113,7 +116,7 @@ public class RecipientDatabase extends Database { UNIDENTIFIED_ACCESS_MODE, FORCE_SMS_SELECTION, UUID_CAPABILITY, GROUPS_V2_CAPABILITY, - STORAGE_SERVICE_KEY, DIRTY + STORAGE_SERVICE_ID, DIRTY }; private static final String[] RECIPIENT_FULL_PROJECTION = ArrayUtils.concat( @@ -282,7 +285,7 @@ public class RecipientDatabase extends Database { FORCE_SMS_SELECTION + " INTEGER DEFAULT 0, " + UUID_CAPABILITY + " INTEGER DEFAULT " + Recipient.Capability.UNKNOWN.serialize() + ", " + GROUPS_V2_CAPABILITY + " INTEGER DEFAULT " + Recipient.Capability.UNKNOWN.serialize() + ", " + - STORAGE_SERVICE_KEY + " TEXT UNIQUE DEFAULT NULL, " + + STORAGE_SERVICE_ID + " TEXT UNIQUE DEFAULT NULL, " + DIRTY + " INTEGER DEFAULT " + DirtyState.CLEAN.getId() + ");"; private static final String INSIGHTS_INVITEE_LIST = "SELECT " + TABLE_NAME + "." + ID + @@ -355,7 +358,7 @@ public class RecipientDatabase extends Database { } else { values.put(GROUP_TYPE, GroupType.SIGNAL_V1.getId()); values.put(DIRTY, DirtyState.INSERT.getId()); - values.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(StorageSyncHelper.generateKey())); + values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey())); } update(result.recipientId, values); @@ -399,28 +402,28 @@ public class RecipientDatabase extends Database { } public @NonNull List getPendingRecipientSyncUpdates() { - String query = DIRTY + " = ? AND " + STORAGE_SERVICE_KEY + " NOT NULL"; + String query = DIRTY + " = ? AND " + STORAGE_SERVICE_ID + " NOT NULL"; String[] args = new String[] { String.valueOf(DirtyState.UPDATE.getId()) }; return getRecipientSettings(query, args); } public @NonNull List getPendingRecipientSyncInsertions() { - String query = DIRTY + " = ? AND " + STORAGE_SERVICE_KEY + " NOT NULL"; + String query = DIRTY + " = ? AND " + STORAGE_SERVICE_ID + " NOT NULL"; String[] args = new String[] { String.valueOf(DirtyState.INSERT.getId()) }; return getRecipientSettings(query, args); } public @NonNull List getPendingRecipientSyncDeletions() { - String query = DIRTY + " = ? AND " + STORAGE_SERVICE_KEY + " NOT NULL"; + String query = DIRTY + " = ? AND " + STORAGE_SERVICE_ID + " NOT NULL"; String[] args = new String[] { String.valueOf(DirtyState.DELETE.getId()) }; return getRecipientSettings(query, args); } - public @Nullable RecipientSettings getByStorageSyncKey(@NonNull byte[] key) { - List result = getRecipientSettings(STORAGE_SERVICE_KEY + " = ?", new String[] { Base64.encodeBytes(key) }); + public @Nullable RecipientSettings getByStorageId(@NonNull byte[] storageId) { + List result = getRecipientSettings(STORAGE_SERVICE_ID + " = ?", new String[] { Base64.encodeBytes(storageId) }); if (result.size() > 0) { return result.get(0); @@ -429,16 +432,16 @@ public class RecipientDatabase extends Database { return null; } - public void applyStorageSyncKeyUpdates(@NonNull Map keys) { + public void applyStorageIdUpdates(@NonNull Map storageIds) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); db.beginTransaction(); try { String query = ID + " = ?"; - for (Map.Entry entry : keys.entrySet()) { + for (Map.Entry entry : storageIds.entrySet()) { ContentValues values = new ContentValues(); - values.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(entry.getValue())); + values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(entry.getValue().getRaw())); values.put(DIRTY, DirtyState.CLEAN.getId()); db.update(TABLE_NAME, values, query, new String[] { entry.getKey().serialize() }); @@ -449,10 +452,10 @@ public class RecipientDatabase extends Database { } } - public void applyStorageSyncUpdates(@NonNull Collection contactInserts, - @NonNull Collection contactUpdates, - @NonNull Collection groupV1Inserts, - @NonNull Collection groupV1Updates) + public void applyStorageSyncUpdates(@NonNull Collection contactInserts, + @NonNull Collection> contactUpdates, + @NonNull Collection groupV1Inserts, + @NonNull Collection> groupV1Updates) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context); @@ -482,7 +485,7 @@ public class RecipientDatabase extends Database { try { IdentityKey identityKey = new IdentityKey(insert.getIdentityKey().get(), 0); - DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncHelper.remoteToLocalIdentityStatus(insert.getIdentityState())); + DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncModels.remoteToLocalIdentityStatus(insert.getIdentityState())); IdentityUtil.markIdentityVerified(context, Recipient.resolved(recipientId), true, true); } catch (InvalidKeyException e) { Log.w(TAG, "Failed to process identity key during insert! Skipping.", e); @@ -495,17 +498,17 @@ public class RecipientDatabase extends Database { } } - for (StorageSyncHelper.ContactUpdate update : contactUpdates) { + for (RecordUpdate update : contactUpdates) { ContentValues values = getValuesForStorageContact(update.getNew()); - int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_KEY + " = ?", new String[]{Base64.encodeBytes(update.getOld().getKey())}); + 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!"); } - RecipientId recipientId = getByStorageKeyOrThrow(update.getNew().getKey()); + RecipientId recipientId = getByStorageKeyOrThrow(update.getNew().getId().getRaw()); - if (update.profileKeyChanged()) { + if (StorageSyncHelper.profileKeyChanged(update)) { clearProfileKeyCredential(recipientId); } @@ -514,7 +517,7 @@ public class RecipientDatabase extends Database { if (update.getNew().getIdentityKey().isPresent()) { IdentityKey identityKey = new IdentityKey(update.getNew().getIdentityKey().get(), 0); - DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncHelper.remoteToLocalIdentityStatus(update.getNew().getIdentityState())); + DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncModels.remoteToLocalIdentityStatus(update.getNew().getIdentityState())); } Optional newIdentityRecord = identityDatabase.getIdentity(recipientId); @@ -537,9 +540,9 @@ public class RecipientDatabase extends Database { db.insertOrThrow(TABLE_NAME, null, getValuesForStorageGroupV1(insert)); } - for (StorageSyncHelper.GroupV1Update update : groupV1Updates) { + for (RecordUpdate update : groupV1Updates) { ContentValues values = getValuesForStorageGroupV1(update.getNew()); - int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_KEY + " = ?", new String[]{Base64.encodeBytes(update.getOld().getKey())}); + 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!"); @@ -576,7 +579,7 @@ public class RecipientDatabase extends Database { private @NonNull RecipientId getByStorageKeyOrThrow(byte[] storageKey) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); - String query = STORAGE_SERVICE_KEY + " = ?"; + String query = STORAGE_SERVICE_ID + " = ?"; String[] args = new String[]{Base64.encodeBytes(storageKey)}; try (Cursor cursor = db.query(TABLE_NAME, ID_PROJECTION, query, args, null, null, null)) { @@ -607,7 +610,7 @@ public class RecipientDatabase extends Database { values.put(USERNAME, TextUtils.isEmpty(username) ? null : username); values.put(PROFILE_SHARING, contact.isProfileSharingEnabled() ? "1" : "0"); values.put(BLOCKED, contact.isBlocked() ? "1" : "0"); - values.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(contact.getKey())); + values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(contact.getId().getRaw())); values.put(DIRTY, DirtyState.CLEAN.getId()); return values; } @@ -618,7 +621,7 @@ public class RecipientDatabase extends Database { values.put(GROUP_TYPE, GroupType.SIGNAL_V1.getId()); values.put(PROFILE_SHARING, groupV1.isProfileSharingEnabled() ? "1" : "0"); values.put(BLOCKED, groupV1.isBlocked() ? "1" : "0"); - values.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(groupV1.getKey())); + values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(groupV1.getId().getRaw())); values.put(DIRTY, DirtyState.CLEAN.getId()); return values; } @@ -640,25 +643,31 @@ public class RecipientDatabase extends Database { /** * @return All storage keys, excluding the ones that need to be deleted. */ - public List getAllStorageSyncKeys() { + public List getAllStorageSyncKeys() { return new ArrayList<>(getAllStorageSyncKeysMap().values()); } /** * @return All storage keys, excluding the ones that need to be deleted. */ - public Map getAllStorageSyncKeysMap() { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - String query = STORAGE_SERVICE_KEY + " NOT NULL AND " + DIRTY + " != ?"; - String[] args = new String[]{String.valueOf(DirtyState.DELETE)}; - Map out = new HashMap<>(); + public @NonNull Map getAllStorageSyncKeysMap() { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String query = STORAGE_SERVICE_ID + " NOT NULL AND " + DIRTY + " != ?"; + String[] args = new String[]{String.valueOf(DirtyState.DELETE)}; + Map out = new HashMap<>(); - try (Cursor cursor = db.query(TABLE_NAME, new String[] { ID, STORAGE_SERVICE_KEY }, query, args, null, null, null)) { + try (Cursor cursor = db.query(TABLE_NAME, new String[] { ID, STORAGE_SERVICE_ID, GROUP_TYPE }, query, args, null, null, null)) { while (cursor != null && cursor.moveToNext()) { RecipientId id = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID))); - String encodedKey = cursor.getString(cursor.getColumnIndexOrThrow(STORAGE_SERVICE_KEY)); + String encodedKey = cursor.getString(cursor.getColumnIndexOrThrow(STORAGE_SERVICE_ID)); + GroupType groupType = GroupType.fromId(cursor.getInt(cursor.getColumnIndexOrThrow(GROUP_TYPE))); + byte[] key = Base64.decodeOrThrow(encodedKey); - out.put(id, Base64.decodeOrThrow(encodedKey)); + if (groupType == GroupType.NONE) { + out.put(id, StorageId.forContact(key)); + } else { + out.put(id, StorageId.forGroupV1(key)); + } } } @@ -699,7 +708,7 @@ public class RecipientDatabase extends Database { boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1; int uuidCapabilityValue = cursor.getInt(cursor.getColumnIndexOrThrow(UUID_CAPABILITY)); int groupsV2CapabilityValue = cursor.getInt(cursor.getColumnIndexOrThrow(GROUPS_V2_CAPABILITY)); - String storageKeyRaw = cursor.getString(cursor.getColumnIndexOrThrow(STORAGE_SERVICE_KEY)); + String storageKeyRaw = cursor.getString(cursor.getColumnIndexOrThrow(STORAGE_SERVICE_ID)); String identityKeyRaw = cursor.getString(cursor.getColumnIndexOrThrow(IDENTITY_KEY)); int identityStatusRaw = cursor.getInt(cursor.getColumnIndexOrThrow(IDENTITY_STATUS)); @@ -1106,7 +1115,7 @@ public class RecipientDatabase extends Database { contentValues.put(REGISTERED, registeredState.getId()); if (registeredState == RegisteredState.REGISTERED) { - contentValues.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(StorageSyncHelper.generateKey())); + contentValues.put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey())); } if (update(id, contentValues)) { @@ -1357,7 +1366,7 @@ public class RecipientDatabase extends Database { try { for (Map.Entry entry : keys.entrySet()) { ContentValues values = new ContentValues(); - values.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(entry.getValue())); + values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(entry.getValue())); db.update(TABLE_NAME, values, ID_WHERE, new String[] { entry.getKey().serialize() }); } @@ -1397,7 +1406,7 @@ public class RecipientDatabase extends Database { query += "(" + DIRTY + " < ? OR " + DIRTY + " = ?)"; args = SqlUtil.appendArg(args, String.valueOf(DirtyState.DELETE.getId())); - contentValues.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(StorageSyncHelper.generateKey())); + contentValues.put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey())); break; case DELETE: query += "(" + DIRTY + " < ? OR " + DIRTY + " = ?)"; @@ -1526,7 +1535,7 @@ public class RecipientDatabase extends Database { } private void markAllRelevantEntriesDirty() { - String query = SYSTEM_INFO_PENDING + " = ? AND " + STORAGE_SERVICE_KEY + " NOT NULL AND " + DIRTY + " < ?"; + String query = SYSTEM_INFO_PENDING + " = ? AND " + STORAGE_SERVICE_ID + " NOT NULL AND " + DIRTY + " < ?"; String[] args = new String[] { "1", String.valueOf(DirtyState.UPDATE.getId()) }; ContentValues values = new ContentValues(1); 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 f80b3a86b3..c94fae16b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/StorageKeyDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/StorageKeyDatabase.java @@ -12,11 +12,11 @@ import net.sqlcipher.database.SQLiteDatabase; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.util.Base64; import org.whispersystems.signalservice.api.storage.SignalStorageRecord; +import org.whispersystems.signalservice.api.storage.StorageId; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; import java.util.List; /** @@ -28,11 +28,11 @@ public class StorageKeyDatabase extends Database { private static final String TABLE_NAME = "storage_key"; private static final String ID = "_id"; private static final String TYPE = "type"; - private static final String KEY = "key"; + private static final String STORAGE_ID = "key"; - public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + - TYPE + " INTEGER, " + - KEY + " TEXT UNIQUE)"; + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + TYPE + " INTEGER, " + + STORAGE_ID + " TEXT UNIQUE)"; public static final String[] CREATE_INDEXES = new String[] { "CREATE INDEX IF NOT EXISTS storage_key_type_index ON " + TABLE_NAME + " (" + TYPE + ");" @@ -42,14 +42,15 @@ public class StorageKeyDatabase extends Database { super(context, databaseHelper); } - public List getAllKeys() { - List keys = new ArrayList<>(); + public List getAllKeys() { + List keys = new ArrayList<>(); try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null)) { while (cursor != null && cursor.moveToNext()) { - String keyEncoded = cursor.getString(cursor.getColumnIndexOrThrow(KEY)); + String keyEncoded = cursor.getString(cursor.getColumnIndexOrThrow(STORAGE_ID)); + int type = cursor.getInt(cursor.getColumnIndexOrThrow(TYPE)); try { - keys.add(Base64.decode(keyEncoded)); + keys.add(StorageId.forType(Base64.decode(keyEncoded), type)); } catch (IOException e) { throw new AssertionError(e); } @@ -59,14 +60,14 @@ public class StorageKeyDatabase extends Database { return keys; } - public @Nullable SignalStorageRecord getByKey(@NonNull byte[] key) { - String query = KEY + " = ?"; - String[] args = new String[] { Base64.encodeBytes(key) }; + public @Nullable SignalStorageRecord getById(@NonNull byte[] rawId) { + String query = STORAGE_ID + " = ?"; + String[] args = new String[] { Base64.encodeBytes(rawId) }; try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, query, args, null, null, null)) { if (cursor != null && cursor.moveToFirst()) { int type = cursor.getInt(cursor.getColumnIndexOrThrow(TYPE)); - return SignalStorageRecord.forUnknown(key, type); + return SignalStorageRecord.forUnknown(StorageId.forType(rawId, type)); } else { return null; } @@ -83,15 +84,15 @@ public class StorageKeyDatabase extends Database { for (SignalStorageRecord insert : inserts) { ContentValues values = new ContentValues(); values.put(TYPE, insert.getType()); - values.put(KEY, Base64.encodeBytes(insert.getKey())); + values.put(STORAGE_ID, Base64.encodeBytes(insert.getId().getRaw())); db.insert(TABLE_NAME, null, values); } - String deleteQuery = KEY + " = ?"; + String deleteQuery = STORAGE_ID + " = ?"; for (SignalStorageRecord delete : deletes) { - String[] args = new String[] { Base64.encodeBytes(delete.getKey()) }; + String[] args = new String[] { Base64.encodeBytes(delete.getId().getRaw()) }; db.delete(TABLE_NAME, deleteQuery, args); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 4e69924806..498bcb1c9f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -21,7 +21,7 @@ import net.sqlcipher.database.SQLiteDatabase; import net.sqlcipher.database.SQLiteDatabaseHook; import net.sqlcipher.database.SQLiteOpenHelper; -import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; import org.thoughtcrime.securesms.crypto.DatabaseSecret; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.AttachmentDatabase; @@ -57,7 +57,6 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import java.io.File; -import java.io.FilenameFilter; import java.util.List; public class SQLCipherOpenHelper extends SQLiteOpenHelper { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.java index 2731944214..2b8dbe81c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.java @@ -4,7 +4,8 @@ import androidx.annotation.NonNull; import com.annimon.stream.Stream; -import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.storage.StorageSyncModels; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.StorageKeyDatabase; @@ -16,12 +17,10 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.transport.RetryLaterException; -import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.InvalidKeyException; import org.whispersystems.signalservice.api.SignalServiceAccountManager; -import org.whispersystems.signalservice.api.kbs.MasterKey; +import org.whispersystems.signalservice.api.storage.StorageId; import org.whispersystems.signalservice.api.storage.StorageKey; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; import org.whispersystems.signalservice.api.storage.SignalStorageManifest; @@ -76,15 +75,15 @@ public class StorageForcePushJob extends BaseJob { RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context); - long currentVersion = accountManager.getStorageManifestVersion(); - Map oldStorageKeys = recipientDatabase.getAllStorageSyncKeysMap(); + long currentVersion = accountManager.getStorageManifestVersion(); + Map oldStorageKeys = recipientDatabase.getAllStorageSyncKeysMap(); - long newVersion = currentVersion + 1; - Map newStorageKeys = generateNewKeys(oldStorageKeys); - List inserts = Stream.of(oldStorageKeys.keySet()) + long newVersion = currentVersion + 1; + Map newStorageKeys = generateNewKeys(oldStorageKeys); + List inserts = Stream.of(oldStorageKeys.keySet()) .map(recipientDatabase::getRecipientSettings) .withoutNulls() - .map(s -> StorageSyncHelper.localToRemoteRecord(s, Objects.requireNonNull(newStorageKeys.get(s.getId())))) + .map(s -> StorageSyncModels.localToRemoteRecord(s, Objects.requireNonNull(newStorageKeys.get(s.getId())).getRaw())) .toList(); SignalStorageManifest manifest = new SignalStorageManifest(newVersion, new ArrayList<>(newStorageKeys.values())); @@ -110,7 +109,7 @@ public class StorageForcePushJob extends BaseJob { Log.i(TAG, "Force push succeeded. Updating local manifest version to: " + newVersion); TextSecurePreferences.setStorageManifestVersion(context, newVersion); - recipientDatabase.applyStorageSyncKeyUpdates(newStorageKeys); + recipientDatabase.applyStorageIdUpdates(newStorageKeys); storageKeyDatabase.deleteAll(); } @@ -123,11 +122,11 @@ public class StorageForcePushJob extends BaseJob { public void onFailure() { } - private static @NonNull Map generateNewKeys(@NonNull Map oldKeys) { - Map out = new HashMap<>(); + private static @NonNull Map generateNewKeys(@NonNull Map oldKeys) { + Map out = new HashMap<>(); - for (Map.Entry entry : oldKeys.entrySet()) { - out.put(entry.getKey(), StorageSyncHelper.generateKey()); + for (Map.Entry entry : oldKeys.entrySet()) { + out.put(entry.getKey(), entry.getValue().withNewBytes(StorageSyncHelper.generateKey())); } return out; 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 987a664898..9f2750f449 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java @@ -6,11 +6,12 @@ import androidx.annotation.NonNull; import com.annimon.stream.Stream; -import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper; -import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper.KeyDifferenceResult; -import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper.LocalWriteResult; -import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper.MergeResult; -import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper.WriteOperationResult; +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.MergeResult; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.WriteOperationResult; +import org.thoughtcrime.securesms.storage.StorageSyncModels; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; @@ -29,6 +30,7 @@ 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.storage.StorageId; import org.whispersystems.signalservice.api.storage.StorageKey; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; import org.whispersystems.signalservice.api.storage.SignalStorageManifest; @@ -36,7 +38,6 @@ import org.whispersystems.signalservice.api.storage.SignalStorageRecord; import java.io.IOException; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.concurrent.TimeUnit; @@ -145,8 +146,8 @@ public class StorageSyncJob extends BaseJob { if (remoteManifest.isPresent() && remoteManifestVersion > localManifestVersion) { Log.i(TAG, "[Remote Newer] Newer manifest version found!"); - List allLocalStorageKeys = getAllLocalStorageKeys(context); - KeyDifferenceResult keyDifference = StorageSyncHelper.findKeyDifference(remoteManifest.get().getStorageKeys(), allLocalStorageKeys); + List allLocalStorageKeys = getAllLocalStorageKeys(context); + KeyDifferenceResult keyDifference = StorageSyncHelper.findKeyDifference(remoteManifest.get().getStorageIds(), allLocalStorageKeys); if (!keyDifference.isEmpty()) { Log.i(TAG, "[Remote Newer] There's a difference in keys. Local-only: " + keyDifference.getLocalOnlyKeys().size() + ", Remote-only: " + keyDifference.getRemoteOnlyKeys().size()); @@ -162,9 +163,9 @@ public class StorageSyncJob extends BaseJob { Log.i(TAG, "[Remote Newer] WriteOperationResult :: " + writeOperationResult); Log.i(TAG, "[Remote Newer] We have something to write remotely."); - if (writeOperationResult.getManifest().getStorageKeys().size() != remoteManifest.get().getStorageKeys().size() + writeOperationResult.getInserts().size() - writeOperationResult.getDeletes().size()) { + if (writeOperationResult.getManifest().getStorageIds().size() != remoteManifest.get().getStorageIds().size() + writeOperationResult.getInserts().size() - writeOperationResult.getDeletes().size()) { Log.w(TAG, String.format(Locale.ENGLISH, "Bad storage key management! originalRemoteKeys: %d, newRemoteKeys: %d, insertedKeys: %d, deletedKeys: %d", - remoteManifest.get().getStorageKeys().size(), writeOperationResult.getManifest().getStorageKeys().size(), writeOperationResult.getInserts().size(), writeOperationResult.getDeletes().size())); + remoteManifest.get().getStorageIds().size(), writeOperationResult.getManifest().getStorageIds().size(), writeOperationResult.getInserts().size(), writeOperationResult.getDeletes().size())); } Optional conflict = accountManager.writeStorageRecords(storageServiceKey, writeOperationResult.getManifest(), writeOperationResult.getInserts(), writeOperationResult.getDeletes()); @@ -194,7 +195,7 @@ public class StorageSyncJob extends BaseJob { localManifestVersion = TextSecurePreferences.getStorageManifestVersion(context); - List allLocalStorageKeys = recipientDatabase.getAllStorageSyncKeys(); + List allLocalStorageKeys = recipientDatabase.getAllStorageSyncKeys(); List pendingUpdates = recipientDatabase.getPendingRecipientSyncUpdates(); List pendingInsertions = recipientDatabase.getPendingRecipientSyncInsertions(); List pendingDeletions = recipientDatabase.getPendingRecipientSyncDeletions(); @@ -242,21 +243,21 @@ public class StorageSyncJob extends BaseJob { return needsMultiDeviceSync; } - private static @NonNull List getAllLocalStorageKeys(@NonNull Context context) { + private static @NonNull List getAllLocalStorageKeys(@NonNull Context context) { return Util.concatenatedList(DatabaseFactory.getRecipientDatabase(context).getAllStorageSyncKeys(), DatabaseFactory.getStorageKeyDatabase(context).getAllKeys()); } - private static @NonNull List buildLocalStorageRecords(@NonNull Context context, @NonNull List keys) { + private static @NonNull List buildLocalStorageRecords(@NonNull Context context, @NonNull List ids) { RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context); - List records = new ArrayList<>(keys.size()); + List records = new ArrayList<>(ids.size()); - for (byte[] key : keys) { - SignalStorageRecord record = Optional.fromNullable(recipientDatabase.getByStorageSyncKey(key)) - .transform(StorageSyncHelper::localToRemoteRecord) - .or(() -> storageKeyDatabase.getByKey(key)); + for (StorageId id : ids) { + SignalStorageRecord record = Optional.fromNullable(recipientDatabase.getByStorageId(id.getRaw())) + .transform(StorageSyncModels::localToRemoteRecord) + .or(() -> storageKeyDatabase.getById(id.getRaw())); records.add(record); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/ContactConflictMerger.java b/app/src/main/java/org/thoughtcrime/securesms/storage/ContactConflictMerger.java new file mode 100644 index 0000000000..46c835c6df --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/ContactConflictMerger.java @@ -0,0 +1,109 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.storage.SignalContactRecord; +import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +class ContactConflictMerger implements StorageSyncHelper.ConflictMerger { + + private final Map localByUuid = new HashMap<>(); + private final Map localByE164 = new HashMap<>(); + + ContactConflictMerger(@NonNull Collection localOnly) { + for (SignalContactRecord contact : localOnly) { + if (contact.getAddress().getUuid().isPresent()) { + localByUuid.put(contact.getAddress().getUuid().get(), contact); + } + if (contact.getAddress().getNumber().isPresent()) { + localByE164.put(contact.getAddress().getNumber().get(), contact); + } + } + } + + @Override + public @NonNull Optional getMatching(@NonNull SignalContactRecord record) { + SignalContactRecord localUuid = record.getAddress().getUuid().isPresent() ? localByUuid.get(record.getAddress().getUuid().get()) : null; + SignalContactRecord localE164 = record.getAddress().getNumber().isPresent() ? localByE164.get(record.getAddress().getNumber().get()) : null; + + return Optional.fromNullable(localUuid).or(Optional.fromNullable(localE164)); + } + + @Override + public @NonNull SignalContactRecord merge(@NonNull SignalContactRecord remote, @NonNull SignalContactRecord local, @NonNull StorageSyncHelper.KeyGenerator 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(""); + } + + 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() || local.isProfileSharingEnabled(); + boolean archived = remote.isArchived(); + boolean matchesRemote = doParamsMatch(remote, address, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, archived); + boolean matchesLocal = doParamsMatch(local, address, givenName, familyName, profileKey, username, identityState, identityKey, blocked, profileSharing, archived); + + if (matchesRemote) { + return remote; + } else if (matchesLocal) { + return local; + } else { + return new SignalContactRecord.Builder(keyGenerator.generate(), address) + .setGivenName(givenName) + .setFamilyName(familyName) + .setProfileKey(profileKey) + .setUsername(username) + .setIdentityState(identityState) + .setIdentityKey(identityKey) + .setBlocked(blocked) + .setProfileSharingEnabled(profileSharing) + .build(); + } + } + + private static boolean doParamsMatch(@NonNull SignalContactRecord contact, + @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) + { + return 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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1ConflictMerger.java b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1ConflictMerger.java new file mode 100644 index 0000000000..05f54e7a77 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1ConflictMerger.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.util.GroupUtil; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; + +import java.util.Collection; +import java.util.Map; + +class GroupV1ConflictMerger implements StorageSyncHelper.ConflictMerger { + + private final Map localByGroupId; + + GroupV1ConflictMerger(@NonNull Collection localOnly) { + localByGroupId = Stream.of(localOnly).collect(Collectors.toMap(g -> GroupUtil.getEncodedId(g.getGroupId(), false), g -> g)); + } + + @Override + public @NonNull Optional getMatching(@NonNull SignalGroupV1Record record) { + return Optional.fromNullable(localByGroupId.get(GroupUtil.getEncodedId(record.getGroupId(), false))); + } + + @Override + public @NonNull SignalGroupV1Record merge(@NonNull SignalGroupV1Record remote, @NonNull SignalGroupV1Record local, @NonNull StorageSyncHelper.KeyGenerator keyGenerator) { + boolean blocked = remote.isBlocked(); + boolean profileSharing = remote.isProfileSharingEnabled() || local.isProfileSharingEnabled(); + boolean archived = remote.isArchived(); + + boolean matchesRemote = blocked == remote.isBlocked() && profileSharing == remote.isProfileSharingEnabled() && archived == remote.isArchived(); + boolean matchesLocal = blocked == local.isBlocked() && profileSharing == local.isProfileSharingEnabled() && archived == local.isArchived(); + + if (matchesRemote) { + return remote; + } else if (matchesLocal) { + return local; + } else { + return new SignalGroupV1Record.Builder(keyGenerator.generate(), remote.getGroupId()) + .setBlocked(blocked) + .setProfileSharingEnabled(blocked) + .build(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java new file mode 100644 index 0000000000..8c8b0a4a4a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java @@ -0,0 +1,474 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.SetUtil; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.storage.SignalContactRecord; +import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; +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 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.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +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; + + /** + * 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) + { + Set completeKeys = new LinkedHashSet<>(currentLocalKeys); + Set storageInserts = new LinkedHashSet<>(); + Set storageDeletes = new LinkedHashSet<>(); + Map storageKeyUpdates = new HashMap<>(); + + for (RecipientSettings insert : inserts) { + storageInserts.add(StorageSyncModels.localToRemoteRecord(insert)); + } + + for (RecipientSettings delete : deletes) { + byte[] key = Objects.requireNonNull(delete.getStorageKey()); + storageDeletes.add(ByteBuffer.wrap(key)); + completeKeys.remove(StorageId.forContact(key)); + } + + for (RecipientSettings update : updates) { + byte[] oldKey = Objects.requireNonNull(update.getStorageKey()); + byte[] newKey = generateKey(); + + storageInserts.add(StorageSyncModels.localToRemoteRecord(update, newKey)); + storageDeletes.add(ByteBuffer.wrap(oldKey)); + completeKeys.remove(StorageId.forContact(oldKey)); + completeKeys.add(StorageId.forContact(newKey)); + storageKeyUpdates.put(update.getId(), newKey); + } + + if (storageInserts.isEmpty() && storageDeletes.isEmpty()) { + return Optional.absent(); + } else { + List contactDeleteBytes = Stream.of(storageDeletes).map(ByteBuffer::array).toList(); + List completeKeysBytes = new ArrayList<>(completeKeys); + SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, completeKeysBytes); + WriteOperationResult writeOperationResult = new WriteOperationResult(manifest, new ArrayList<>(storageInserts), contactDeleteBytes); + + 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 List remoteKeys, + @NonNull List localKeys) + { + Set remoteOnlyKeys = SetUtil.difference(remoteKeys, localKeys); + Set localOnlyKeys = SetUtil.difference(localKeys, remoteKeys); + + return new KeyDifferenceResult(new ArrayList<>(remoteOnlyKeys), new ArrayList<>(localOnlyKeys)); + } + + /** + * 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 remoteOnlyUnknowns = Stream.of(remoteOnlyRecords).filter(SignalStorageRecord::isUnknown).toList(); + List localOnlyUnknowns = Stream.of(localOnlyRecords).filter(SignalStorageRecord::isUnknown).toList(); + + RecordMergeResult> contactMergeResult = resolveRecordConflict(remoteOnlyContacts, localOnlyContacts, new ContactConflictMerger(localOnlyContacts)); + RecordMergeResult> groupV1MergeResult = resolveRecordConflict(remoteOnlyGroupV1, localOnlyGroupV1, new GroupV1ConflictMerger(localOnlyGroupV1)); + + Set remoteInserts = new HashSet<>(); + remoteInserts.addAll(Stream.of(contactMergeResult.remoteInserts).map(SignalStorageRecord::forContact).toList()); + remoteInserts.addAll(Stream.of(groupV1MergeResult.remoteInserts).map(SignalStorageRecord::forGroupV1).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()); + + return new MergeResult(contactMergeResult.localInserts, + contactMergeResult.localUpdates, + groupV1MergeResult.localInserts, + groupV1MergeResult.localUpdates, + new LinkedHashSet<>(remoteOnlyUnknowns), + new LinkedHashSet<>(localOnlyUnknowns), + remoteInserts, + remoteUpdates); + } + + /** + * 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) + { + 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()); + + SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, new ArrayList<>(completeKeys)); + + List inserts = new ArrayList<>(); + inserts.addAll(mergeResult.getRemoteInserts()); + inserts.addAll(Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getNew).toList()); + + List deletes = Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getOld).map(SignalStorageRecord::getId).map(StorageId::getRaw).toList(); + + return new WriteOperationResult(manifest, inserts, deletes); + } + + 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 LinkedHashSet<>(remoteOnlyRecords); + Set remoteInserts = new LinkedHashSet<>(localOnlyRecords); + Set> localUpdates = new LinkedHashSet<>(); + Set> remoteUpdates = new LinkedHashSet<>(); + + 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); + } + + public static boolean profileKeyChanged(RecordUpdate update) { + return !OptionalUtil.byteArrayEquals(update.getOld().getProfileKey(), update.getNew().getProfileKey()); + } + + public static final class KeyDifferenceResult { + private final List remoteOnlyKeys; + private final List localOnlyKeys; + + private KeyDifferenceResult(@NonNull List remoteOnlyKeys, + @NonNull List localOnlyKeys) + { + this.remoteOnlyKeys = remoteOnlyKeys; + this.localOnlyKeys = localOnlyKeys; + } + + public @NonNull List getRemoteOnlyKeys() { + return remoteOnlyKeys; + } + + public @NonNull List getLocalOnlyKeys() { + return localOnlyKeys; + } + + 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 localUnknownInserts; + private final Set localUnknownDeletes; + private final Set remoteInserts; + private final Set> remoteUpdates; + + @VisibleForTesting + MergeResult(@NonNull Set localContactInserts, + @NonNull Set> localContactUpdates, + @NonNull Set localGroupV1Inserts, + @NonNull Set> localGroupV1Updates, + @NonNull Set localUnknownInserts, + @NonNull Set localUnknownDeletes, + @NonNull Set remoteInserts, + @NonNull Set> remoteUpdates) + { + this.localContactInserts = localContactInserts; + this.localContactUpdates = localContactUpdates; + this.localGroupV1Inserts = localGroupV1Inserts; + this.localGroupV1Updates = localGroupV1Updates; + this.localUnknownInserts = localUnknownInserts; + this.localUnknownDeletes = localUnknownDeletes; + this.remoteInserts = remoteInserts; + this.remoteUpdates = remoteUpdates; + } + + 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 getLocalUnknownInserts() { + return localUnknownInserts; + } + + public @NonNull Set getLocalUnknownDeletes() { + return localUnknownDeletes; + } + + public @NonNull Set getRemoteInserts() { + return remoteInserts; + } + + public @NonNull Set> getRemoteUpdates() { + return remoteUpdates; + } + + @NonNull Set getAllNewRecords() { + Set records = new HashSet<>(); + + records.addAll(localContactInserts); + records.addAll(localGroupV1Inserts); + 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(remoteUpdates).map(RecordUpdate::getNew).toList()); + + 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(remoteUpdates).map(RecordUpdate::getOld).toList()); + + return records; + } + + @Override + public @NonNull String toString() { + return String.format(Locale.ENGLISH, + "localContactInserts: %d, localContactUpdates: %d, localGroupInserts: %d, localGroupUpdates: %d, localUnknownInserts: %d, localUnknownDeletes: %d, remoteInserts: %d, remoteUpdates: %d", + localContactInserts.size(), localContactUpdates.size(), localGroupV1Inserts.size(), localGroupV1Updates.size(), localUnknownInserts.size(), localUnknownDeletes.size(), remoteInserts.size(), remoteUpdates.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; + + RecordMergeResult(@NonNull Set localInserts, + @NonNull Set localUpdates, + @NonNull Set remoteInserts, + @NonNull Set remoteUpdates) + { + this.localInserts = localInserts; + this.localUpdates = localUpdates; + this.remoteInserts = remoteInserts; + this.remoteUpdates = remoteUpdates; + } + } + + interface ConflictMerger { + @NonNull Optional getMatching(@NonNull E record); + @NonNull E merge(@NonNull E remote, @NonNull E local, @NonNull KeyGenerator keyGenerator); + } + + interface KeyGenerator { + @NonNull byte[] generate(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java new file mode 100644 index 0000000000..406d6105b7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.storage; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.database.IdentityDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.storage.SignalContactRecord; +import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; +import org.whispersystems.signalservice.api.storage.SignalStorageRecord; +import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState; + +public final class StorageSyncModels { + + private StorageSyncModels() {} + + public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientSettings settings) { + if (settings.getStorageKey() == null) { + throw new AssertionError("Must have a storage key!"); + } + + return localToRemoteRecord(settings, settings.getStorageKey()); + } + + public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientSettings settings, @NonNull byte[] rawStorageId) { + switch (settings.getGroupType()) { + case NONE: return SignalStorageRecord.forContact(localToRemoteContact(settings, rawStorageId)); + case SIGNAL_V1: return SignalStorageRecord.forGroupV1(localToRemoteGroupV1(settings, rawStorageId)); + default: throw new AssertionError("Unsupported type!"); + } + } + + private static @NonNull SignalContactRecord localToRemoteContact(@NonNull RecipientSettings recipient, byte[] rawStorageId) { + if (recipient.getUuid() == null && recipient.getE164() == null) { + throw new AssertionError("Must have either a UUID or a phone number!"); + } + + return new SignalContactRecord.Builder(rawStorageId, new SignalServiceAddress(recipient.getUuid(), recipient.getE164())) + .setProfileKey(recipient.getProfileKey()) + .setGivenName(recipient.getProfileName().getGivenName()) + .setFamilyName(recipient.getProfileName().getFamilyName()) + .setBlocked(recipient.isBlocked()) + .setProfileSharingEnabled(recipient.isProfileSharing()) + .setIdentityKey(recipient.getIdentityKey()) + .setIdentityState(localToRemoteIdentityState(recipient.getIdentityStatus())) + .build(); + } + + private static @NonNull SignalGroupV1Record localToRemoteGroupV1(@NonNull RecipientSettings recipient, byte[] rawStorageId) { + if (recipient.getGroupId() == null) { + throw new AssertionError("Must have a groupId!"); + } + + return new SignalGroupV1Record.Builder(rawStorageId, GroupUtil.getDecodedIdOrThrow(recipient.getGroupId())) + .setBlocked(recipient.isBlocked()) + .setProfileSharingEnabled(recipient.isProfileSharing()) + .build(); + } + + public static @NonNull IdentityDatabase.VerifiedStatus remoteToLocalIdentityStatus(@NonNull IdentityState identityState) { + switch (identityState) { + case VERIFIED: return IdentityDatabase.VerifiedStatus.VERIFIED; + case UNVERIFIED: return IdentityDatabase.VerifiedStatus.UNVERIFIED; + default: return IdentityDatabase.VerifiedStatus.DEFAULT; + } + } + + private static IdentityState localToRemoteIdentityState(@NonNull IdentityDatabase.VerifiedStatus local) { + switch (local) { + case VERIFIED: return IdentityState.VERIFIED; + case UNVERIFIED: return IdentityState.UNVERIFIED; + default: return IdentityState.DEFAULT; + } + } + +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/storage/ContactConflictMergerTest.java b/app/src/test/java/org/thoughtcrime/securesms/storage/ContactConflictMergerTest.java new file mode 100644 index 0000000000..fc889fdb5e --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/storage/ContactConflictMergerTest.java @@ -0,0 +1,132 @@ +package org.thoughtcrime.securesms.storage; + +import org.junit.Test; +import org.thoughtcrime.securesms.storage.ContactConflictMerger; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.KeyGenerator; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.storage.SignalContactRecord; +import org.whispersystems.signalservice.api.util.UuidUtil; +import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState; + +import java.util.Collections; +import java.util.UUID; + +import static junit.framework.TestCase.assertTrue; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.powermock.api.mockito.PowerMockito.mock; +import static org.thoughtcrime.securesms.testutil.TestHelpers.byteArray; + +public class ContactConflictMergerTest { + + private static final UUID UUID_A = UuidUtil.parseOrThrow("ebef429e-695e-4f51-bcc4-526a60ac68c7"); + private static final UUID UUID_B = UuidUtil.parseOrThrow("32119989-77fb-4e18-af70-81d55185c6b1"); + + private static final String E164_A = "+16108675309"; + private static final String E164_B = "+16101234567"; + + @Test + public void merge_alwaysPreferRemote_exceptProfileSharingIsEitherOr() { + SignalContactRecord remote = new SignalContactRecord.Builder(byteArray(1), new SignalServiceAddress(UUID_A, E164_A)) + .setBlocked(true) + .setIdentityKey(byteArray(2)) + .setIdentityState(IdentityState.VERIFIED) + .setProfileKey(byteArray(3)) + .setGivenName("AFirst") + .setFamilyName("ALast") + .setUsername("username A") + .setProfileSharingEnabled(false) + .setArchived(false) + .build(); + SignalContactRecord local = new SignalContactRecord.Builder(byteArray(2), new SignalServiceAddress(UUID_B, E164_B)) + .setBlocked(false) + .setIdentityKey(byteArray(99)) + .setIdentityState(IdentityState.DEFAULT) + .setProfileKey(byteArray(999)) + .setGivenName("BFirst") + .setFamilyName("BLast") + .setUsername("username B") + .setProfileSharingEnabled(true) + .setArchived(true) + .build(); + + SignalContactRecord merged = new ContactConflictMerger(Collections.singletonList(local)).merge(remote, local, mock(KeyGenerator.class)); + + assertEquals(UUID_A, merged.getAddress().getUuid().get()); + assertEquals(E164_A, merged.getAddress().getNumber().get()); + assertTrue(merged.isBlocked()); + assertArrayEquals(byteArray(2), merged.getIdentityKey().get()); + assertEquals(IdentityState.VERIFIED, merged.getIdentityState()); + assertArrayEquals(byteArray(3), merged.getProfileKey().get()); + assertEquals("AFirst", merged.getGivenName().get()); + assertEquals("ALast", merged.getFamilyName().get()); + assertEquals("username A", merged.getUsername().get()); + assertTrue(merged.isProfileSharingEnabled()); + assertFalse(merged.isArchived()); + } + + @Test + public void merge_fillInGaps_treatNamePartsAsOneUnit() { + SignalContactRecord remote = new SignalContactRecord.Builder(byteArray(1), new SignalServiceAddress(UUID_A, null)) + .setBlocked(true) + .setGivenName("AFirst") + .setFamilyName("") + .setProfileSharingEnabled(true) + .build(); + SignalContactRecord local = new SignalContactRecord.Builder(byteArray(2), new SignalServiceAddress(UUID_B, E164_B)) + .setBlocked(false) + .setIdentityKey(byteArray(2)) + .setProfileKey(byteArray(3)) + .setGivenName("BFirst") + .setFamilyName("BLast") + .setUsername("username B") + .setProfileSharingEnabled(false) + .build(); + SignalContactRecord merged = new ContactConflictMerger(Collections.singletonList(local)).merge(remote, local, mock(KeyGenerator.class)); + + assertEquals(UUID_A, merged.getAddress().getUuid().get()); + assertEquals(E164_B, merged.getAddress().getNumber().get()); + assertTrue(merged.isBlocked()); + assertArrayEquals(byteArray(2), merged.getIdentityKey().get()); + assertEquals(IdentityState.DEFAULT, merged.getIdentityState()); + assertArrayEquals(byteArray(3), merged.getProfileKey().get()); + assertEquals("AFirst", merged.getGivenName().get()); + assertFalse(merged.getFamilyName().isPresent()); + assertEquals("username B", merged.getUsername().get()); + assertTrue(merged.isProfileSharingEnabled()); + } + + @Test + public void merge_returnRemoteIfEndResultMatchesRemote() { + SignalContactRecord remote = new SignalContactRecord.Builder(byteArray(1), new SignalServiceAddress(UUID_A, E164_A)) + .setBlocked(true) + .setGivenName("AFirst") + .setFamilyName("") + .setUsername("username B") + .setProfileKey(byteArray(3)) + .setProfileSharingEnabled(true) + .build(); + SignalContactRecord local = new SignalContactRecord.Builder(byteArray(2), new SignalServiceAddress(null, E164_A)) + .setBlocked(false) + .setGivenName("BFirst") + .setFamilyName("BLast") + .setProfileSharingEnabled(false) + .build(); + SignalContactRecord merged = new ContactConflictMerger(Collections.singletonList(local)).merge(remote, local, mock(KeyGenerator.class)); + + assertEquals(remote, merged); + } + + @Test + public void merge_returnLocalIfEndResultMatchesLocal() { + SignalContactRecord remote = new SignalContactRecord.Builder(byteArray(1), new SignalServiceAddress(UUID_A, E164_A)).build(); + SignalContactRecord local = new SignalContactRecord.Builder(byteArray(2), new SignalServiceAddress(UUID_A, E164_A)) + .setGivenName("AFirst") + .setFamilyName("ALast") + .build(); + SignalContactRecord merged = new ContactConflictMerger(Collections.singletonList(local)).merge(remote, local, mock(KeyGenerator.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 new file mode 100644 index 0000000000..3eb276c446 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/storage/GroupV1ConflictMergerTest.java @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.storage; + +import org.junit.Test; +import org.thoughtcrime.securesms.storage.GroupV1ConflictMerger; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.KeyGenerator; +import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; + +import java.util.Collections; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.powermock.api.mockito.PowerMockito.mock; +import static org.powermock.api.mockito.PowerMockito.when; +import static org.thoughtcrime.securesms.testutil.TestHelpers.byteArray; + +public class GroupV1ConflictMergerTest { + + private static byte[] GENERATED_KEY = byteArray(8675309); + private static KeyGenerator KEY_GENERATOR = mock(KeyGenerator.class); + static { + when(KEY_GENERATOR.generate()).thenReturn(GENERATED_KEY); + } + + @Test + public void merge_alwaysPreferRemote_exceptProfileSharingIsEitherOr() { + SignalGroupV1Record remote = new SignalGroupV1Record.Builder(byteArray(1), byteArray(100)) + .setBlocked(false) + .setProfileSharingEnabled(false) + .setArchived(false) + .build(); + SignalGroupV1Record local = new SignalGroupV1Record.Builder(byteArray(2), byteArray(100)) + .setBlocked(true) + .setProfileSharingEnabled(true) + .setArchived(true) + .build(); + + SignalGroupV1Record merged = new GroupV1ConflictMerger(Collections.singletonList(local)).merge(remote, local, KEY_GENERATOR); + + assertArrayEquals(GENERATED_KEY, merged.getId().getRaw()); + assertArrayEquals(byteArray(100), merged.getGroupId()); + assertFalse(merged.isBlocked()); + assertFalse(merged.isArchived()); + } + + @Test + public void merge_returnRemoteIfEndResultMatchesRemote() { + SignalGroupV1Record remote = new SignalGroupV1Record.Builder(byteArray(1), byteArray(100)) + .setBlocked(false) + .setProfileSharingEnabled(true) + .setArchived(true) + .build(); + SignalGroupV1Record local = new SignalGroupV1Record.Builder(byteArray(2), byteArray(100)) + .setBlocked(true) + .setProfileSharingEnabled(false) + .setArchived(false) + .build(); + + SignalGroupV1Record merged = new GroupV1ConflictMerger(Collections.singletonList(local)).merge(remote, local, mock(KeyGenerator.class)); + + assertEquals(remote, merged); + } + + @Test + public void merge_returnLocalIfEndResultMatchesLocal() { + SignalGroupV1Record remote = new SignalGroupV1Record.Builder(byteArray(1), byteArray(100)) + .setBlocked(false) + .setProfileSharingEnabled(false) + .setArchived(false) + .build(); + SignalGroupV1Record local = new SignalGroupV1Record.Builder(byteArray(2), byteArray(100)) + .setBlocked(false) + .setProfileSharingEnabled(true) + .setArchived(false) + .build(); + + SignalGroupV1Record merged = new GroupV1ConflictMerger(Collections.singletonList(local)).merge(remote, local, mock(KeyGenerator.class)); + + assertEquals(local, merged); + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/contacts/sync/StorageSyncHelperTest.java b/app/src/test/java/org/thoughtcrime/securesms/storage/StorageSyncHelperTest.java similarity index 59% rename from app/src/test/java/org/thoughtcrime/securesms/contacts/sync/StorageSyncHelperTest.java rename to app/src/test/java/org/thoughtcrime/securesms/storage/StorageSyncHelperTest.java index d27b0336a2..dd484d8110 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/contacts/sync/StorageSyncHelperTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/storage/StorageSyncHelperTest.java @@ -1,34 +1,37 @@ -package org.thoughtcrime.securesms.contacts.sync; +package org.thoughtcrime.securesms.storage; import androidx.annotation.NonNull; import com.annimon.stream.Stream; -import com.google.common.collect.Sets; import org.junit.Before; import org.junit.Test; -import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper.KeyDifferenceResult; -import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper.MergeResult; -import org.thoughtcrime.securesms.util.Conversions; +import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.KeyDifferenceResult; +import org.thoughtcrime.securesms.storage.StorageSyncHelper.MergeResult; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.storage.SignalContactRecord; import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; 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; -import java.nio.ByteBuffer; -import java.util.ArrayList; +import java.util.Arrays; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.UUID; import static junit.framework.TestCase.assertTrue; -import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; +import static org.thoughtcrime.securesms.testutil.TestHelpers.assertByteListEquals; +import static org.thoughtcrime.securesms.testutil.TestHelpers.assertContentsEqual; +import static org.thoughtcrime.securesms.testutil.TestHelpers.byteArray; +import static org.thoughtcrime.securesms.testutil.TestHelpers.byteListOf; +import static org.thoughtcrime.securesms.testutil.TestHelpers.setOf; public final class StorageSyncHelperTest { @@ -52,23 +55,23 @@ public final class StorageSyncHelperTest { @Test public void findKeyDifference_allOverlap() { - KeyDifferenceResult result = StorageSyncHelper.findKeyDifference(byteListOf(1, 2, 3), byteListOf(1, 2, 3)); + KeyDifferenceResult result = StorageSyncHelper.findKeyDifference(keyListOf(1, 2, 3), keyListOf(1, 2, 3)); assertTrue(result.getLocalOnlyKeys().isEmpty()); assertTrue(result.getRemoteOnlyKeys().isEmpty()); } @Test public void findKeyDifference_noOverlap() { - KeyDifferenceResult result = StorageSyncHelper.findKeyDifference(byteListOf(1, 2, 3), byteListOf(4, 5, 6)); - assertByteListEquals(byteListOf(1, 2, 3), result.getRemoteOnlyKeys()); - assertByteListEquals(byteListOf(4, 5, 6), result.getLocalOnlyKeys()); + KeyDifferenceResult result = StorageSyncHelper.findKeyDifference(keyListOf(1, 2, 3), keyListOf(4, 5, 6)); + assertEquals(keyListOf(1, 2, 3), result.getRemoteOnlyKeys()); + assertEquals(keyListOf(4, 5, 6), result.getLocalOnlyKeys()); } @Test public void findKeyDifference_someOverlap() { - KeyDifferenceResult result = StorageSyncHelper.findKeyDifference(byteListOf(1, 2, 3), byteListOf(2, 3, 4)); - assertByteListEquals(byteListOf(1), result.getRemoteOnlyKeys()); - assertByteListEquals(byteListOf(4), result.getLocalOnlyKeys()); + KeyDifferenceResult result = StorageSyncHelper.findKeyDifference(keyListOf(1, 2, 3), keyListOf(2, 3, 4)); + assertEquals(keyListOf(1), result.getRemoteOnlyKeys()); + assertEquals(keyListOf(4), result.getLocalOnlyKeys()); } @Test @@ -188,99 +191,28 @@ public final class StorageSyncHelperTest { SignalGroupV1Record merge4 = groupV1(222, 1, true, true); assertEquals(setOf(remote3), result.getLocalContactInserts()); - // TODO [greyson] -// assertEquals(setOf(contactUpdate(local2, merge2)), result.getLocalContactUpdates()); + assertEquals(setOf(contactUpdate(local2, merge2)), result.getLocalContactUpdates()); assertEquals(setOf(groupV1Update(local4, merge4)), result.getLocalGroupV1Updates()); assertEquals(setOf(SignalStorageRecord.forContact(local3)), result.getRemoteInserts()); -// assertEquals(setOf(recordUpdate(remote1, merge1), recordUpdate(remote2, merge2), recordUpdate(remote4, merge4)), result.getRemoteUpdates()); + assertEquals(setOf(recordUpdate(remote1, merge1), recordUpdate(remote2, merge2), recordUpdate(remote4, merge4)), result.getRemoteUpdates()); assertEquals(setOf(unknownRemote), result.getLocalUnknownInserts()); assertEquals(setOf(unknownLocal), result.getLocalUnknownDeletes()); } - @Test - public void mergeContacts_alwaysPreferRemoteExceptNickname() { - SignalContactRecord remote = new SignalContactRecord.Builder(byteArray(1), new SignalServiceAddress(UUID_A, E164_A)) - .setBlocked(true) - .setIdentityKey(byteArray(2)) - .setIdentityState(SignalContactRecord.IdentityState.VERIFIED) - .setProfileKey(byteArray(3)) - .setGivenName("AFirst") - .setFamilyName("ALast") - .setUsername("username A") - .setNickname("nickname A") - .setProfileSharingEnabled(true) - .build(); - SignalContactRecord local = new SignalContactRecord.Builder(byteArray(2), new SignalServiceAddress(UUID_B, E164_B)) - .setBlocked(false) - .setIdentityKey(byteArray(99)) - .setIdentityState(SignalContactRecord.IdentityState.DEFAULT) - .setProfileKey(byteArray(999)) - .setGivenName("BFirst") - .setFamilyName("BLast") - .setUsername("username B") - .setNickname("nickname B") - .setProfileSharingEnabled(false) - .build(); - SignalContactRecord merged = StorageSyncHelper.mergeContacts(remote, local); - - assertEquals(UUID_A, merged.getAddress().getUuid().get()); - assertEquals(E164_A, merged.getAddress().getNumber().get()); - assertTrue(merged.isBlocked()); - assertArrayEquals(byteArray(2), merged.getIdentityKey().get()); - assertEquals(SignalContactRecord.IdentityState.VERIFIED, merged.getIdentityState()); - assertArrayEquals(byteArray(3), merged.getProfileKey().get()); - assertEquals("AFirst", merged.getGivenName().get()); - assertEquals("ALast", merged.getFamilyName().get()); - assertEquals("username A", merged.getUsername().get()); - assertEquals("nickname B", merged.getNickname().get()); - assertTrue(merged.isProfileSharingEnabled()); - } - - @Test - public void mergeContacts_fillInGaps() { - SignalContactRecord remote = new SignalContactRecord.Builder(byteArray(1), new SignalServiceAddress(UUID_A, null)) - .setBlocked(true) - .setGivenName("AFirst") - .setFamilyName("") - .setProfileSharingEnabled(true) - .build(); - SignalContactRecord local = new SignalContactRecord.Builder(byteArray(2), new SignalServiceAddress(UUID_B, E164_B)) - .setBlocked(false) - .setIdentityKey(byteArray(2)) - .setProfileKey(byteArray(3)) - .setGivenName("BFirst") - .setFamilyName("BLast") - .setUsername("username B") - .setProfileSharingEnabled(false) - .build(); - SignalContactRecord merged = StorageSyncHelper.mergeContacts(remote, local); - - assertEquals(UUID_A, merged.getAddress().getUuid().get()); - assertEquals(E164_B, merged.getAddress().getNumber().get()); - assertTrue(merged.isBlocked()); - assertArrayEquals(byteArray(2), merged.getIdentityKey().get()); - assertEquals(SignalContactRecord.IdentityState.DEFAULT, merged.getIdentityState()); - assertArrayEquals(byteArray(3), merged.getProfileKey().get()); - assertEquals("AFirst", merged.getGivenName().get()); - assertEquals("", merged.getFamilyName().get()); - assertEquals("username B", merged.getUsername().get()); - assertTrue(merged.isProfileSharingEnabled()); - } - @Test public void createWriteOperation_generic() { - List localKeys = byteListOf(1, 2, 3, 4, 100); - SignalContactRecord insert1 = contact(6, UUID_A, E164_A, "a" ); - SignalContactRecord old1 = contact(1, UUID_B, E164_B, "b" ); - SignalContactRecord new1 = contact(5, UUID_B, E164_B, "z" ); - SignalContactRecord insert2 = contact(7, UUID_C, E164_C, "c" ); - SignalContactRecord old2 = contact(2, UUID_D, E164_D, "d" ); - SignalContactRecord new2 = contact(8, UUID_D, E164_D, "z2"); - SignalGroupV1Record insert3 = groupV1(9, 1, true, true); - SignalGroupV1Record old3 = groupV1(100, 1, true, true); - SignalGroupV1Record new3 = groupV1(10, 1, false, true); - SignalStorageRecord unknownInsert = unknown(11); - SignalStorageRecord unknownDelete = unknown(12); + List localKeys = Arrays.asList(contactKey(1), contactKey(2), contactKey(3), contactKey(4), groupV1Key(100)); + SignalContactRecord insert1 = contact(6, UUID_A, E164_A, "a" ); + SignalContactRecord old1 = contact(1, UUID_B, E164_B, "b" ); + SignalContactRecord new1 = contact(5, UUID_B, E164_B, "z" ); + SignalContactRecord insert2 = contact(7, UUID_C, E164_C, "c" ); + SignalContactRecord old2 = contact(2, UUID_D, E164_D, "d" ); + SignalContactRecord new2 = contact(8, UUID_D, E164_D, "z2"); + SignalGroupV1Record insert3 = groupV1(9, 1, true, true ); + SignalGroupV1Record old3 = groupV1(100, 1, true, true ); + SignalGroupV1Record new3 = groupV1(10, 1, false, true); + SignalStorageRecord unknownInsert = unknown(11); + SignalStorageRecord unknownDelete = unknown(12); StorageSyncHelper.WriteOperationResult result = StorageSyncHelper.createWriteOperation(1, localKeys, @@ -294,7 +226,7 @@ public final class StorageSyncHelperTest { setOf(recordUpdate(old1, new1), recordUpdate(old3, new3)))); assertEquals(2, result.getManifest().getVersion()); - assertByteListEquals(byteListOf(3, 4, 5, 6, 7, 8, 9, 10, 11), result.getManifest().getStorageKeys()); + assertContentsEqual(Arrays.asList(contactKey(3), contactKey(4), contactKey(5), contactKey(6), contactKey(7), contactKey(8), groupV1Key(9), groupV1Key(10), unknownKey(11)), result.getManifest().getStorageIds()); assertTrue(recordSetOf(insert1, new1, insert3, new3).containsAll(result.getInserts())); assertEquals(4, result.getInserts().size()); assertByteListEquals(byteListOf(1, 100), result.getDeletes()); @@ -311,7 +243,7 @@ public final class StorageSyncHelperTest { assertEquals(a, b); assertEquals(a.hashCode(), b.hashCode()); - assertFalse(contactUpdate(a, b).profileKeyChanged()); + assertFalse(StorageSyncHelper.profileKeyChanged(contactUpdate(a, b))); } @Test @@ -326,13 +258,7 @@ public final class StorageSyncHelperTest { assertNotEquals(a, b); assertNotEquals(a.hashCode(), b.hashCode()); - assertTrue(contactUpdate(a, b).profileKeyChanged()); - } - - - @SafeVarargs - private static Set setOf(E... values) { - return Sets.newHashSet(values); + assertTrue(StorageSyncHelper.profileKeyChanged(contactUpdate(a, b))); } private static Set recordSetOf(SignalRecord... records) { @@ -340,11 +266,11 @@ public final class StorageSyncHelperTest { for (SignalRecord record : records) { if (record instanceof SignalContactRecord) { - storageRecords.add(SignalStorageRecord.forContact(record.getKey(), (SignalContactRecord) record)); + storageRecords.add(SignalStorageRecord.forContact(record.getId(), (SignalContactRecord) record)); } else if (record instanceof SignalGroupV1Record) { - storageRecords.add(SignalStorageRecord.forGroupV1(record.getKey(), (SignalGroupV1Record) record)); + storageRecords.add(SignalStorageRecord.forGroupV1(record.getId(), (SignalGroupV1Record) record)); } else { - storageRecords.add(SignalStorageRecord.forUnknown(record.getKey(), UNKNOWN_TYPE)); + storageRecords.add(SignalStorageRecord.forUnknown(record.getId())); } } @@ -355,7 +281,7 @@ public final class StorageSyncHelperTest { LinkedHashSet storageRecords = new LinkedHashSet<>(); for (SignalGroupV1Record contactRecord : groupRecords) { - storageRecords.add(SignalStorageRecord.forGroupV1(contactRecord.getKey(), contactRecord)); + storageRecords.add(SignalStorageRecord.forGroupV1(contactRecord.getId(), contactRecord)); } return storageRecords; @@ -386,47 +312,40 @@ public final class StorageSyncHelperTest { return new SignalGroupV1Record.Builder(byteArray(key), byteArray(groupId)).setBlocked(blocked).setProfileSharingEnabled(profileSharing).build(); } - private static StorageSyncHelper.ContactUpdate contactUpdate(SignalContactRecord oldContact, SignalContactRecord newContact) { - return new StorageSyncHelper.ContactUpdate(oldContact, newContact); + private static StorageSyncHelper.RecordUpdate contactUpdate(SignalContactRecord oldContact, SignalContactRecord newContact) { + return new StorageSyncHelper.RecordUpdate<>(oldContact, newContact); } - private static StorageSyncHelper.GroupV1Update groupV1Update(SignalGroupV1Record oldGroup, SignalGroupV1Record newGroup) { - return new StorageSyncHelper.GroupV1Update(oldGroup, newGroup); + private static StorageSyncHelper.RecordUpdate groupV1Update(SignalGroupV1Record oldGroup, SignalGroupV1Record newGroup) { + return new StorageSyncHelper.RecordUpdate<>(oldGroup, newGroup); } private static StorageSyncHelper.RecordUpdate recordUpdate(SignalContactRecord oldContact, SignalContactRecord newContact) { - return new StorageSyncHelper.RecordUpdate(SignalStorageRecord.forContact(oldContact), SignalStorageRecord.forContact(newContact)); + return new StorageSyncHelper.RecordUpdate<>(SignalStorageRecord.forContact(oldContact), SignalStorageRecord.forContact(newContact)); } private static StorageSyncHelper.RecordUpdate recordUpdate(SignalGroupV1Record oldGroup, SignalGroupV1Record newGroup) { - return new StorageSyncHelper.RecordUpdate(SignalStorageRecord.forGroupV1(oldGroup), SignalStorageRecord.forGroupV1(newGroup)); + return new StorageSyncHelper.RecordUpdate<>(SignalStorageRecord.forGroupV1(oldGroup), SignalStorageRecord.forGroupV1(newGroup)); } private static SignalStorageRecord unknown(int key) { - return SignalStorageRecord.forUnknown(byteArray(key), UNKNOWN_TYPE); + return SignalStorageRecord.forUnknown(StorageId.forType(byteArray(key), UNKNOWN_TYPE)); } - private static List byteListOf(int... vals) { - List list = new ArrayList<>(vals.length); - - for (int i = 0; i < vals.length; i++) { - list.add(Conversions.intToByteArray(vals[i])); - - } - return list; + private static List keyListOf(int... vals) { + return Stream.of(byteListOf(vals)).map(b -> StorageId.forType(b, 1)).toList(); } - private static byte[] byteArray(int a) { - return Conversions.intToByteArray(a); + private static StorageId contactKey(int val) { + return StorageId.forContact(byteArray(val)); } - private static void assertByteListEquals(List a, List b) { - assertEquals(a.size(), b.size()); + private static StorageId groupV1Key(int val) { + return StorageId.forGroupV1(byteArray(val)); + } - List aBuffer = Stream.of(a).map(ByteBuffer::wrap).toList(); - List bBuffer = Stream.of(b).map(ByteBuffer::wrap).toList(); - - assertTrue(aBuffer.containsAll(bBuffer)); + private static StorageId unknownKey(int val) { + return StorageId.forType(byteArray(val), UNKNOWN_TYPE); } private static class TestGenerator implements StorageSyncHelper.KeyGenerator { diff --git a/app/src/test/java/org/thoughtcrime/securesms/testutil/TestHelpers.java b/app/src/test/java/org/thoughtcrime/securesms/testutil/TestHelpers.java new file mode 100644 index 0000000000..c5e599823f --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/testutil/TestHelpers.java @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.testutil; + +import com.annimon.stream.Stream; +import com.google.common.collect.Sets; + +import org.thoughtcrime.securesms.util.Conversions; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; + +import static junit.framework.TestCase.assertTrue; +import static org.junit.Assert.assertEquals; + +public final class TestHelpers { + + private TestHelpers() {} + + + public static byte[] byteArray(int a) { + return Conversions.intToByteArray(a); + } + + public static List byteListOf(int... vals) { + List list = new ArrayList<>(vals.length); + + for (int i = 0; i < vals.length; i++) { + list.add(Conversions.intToByteArray(vals[i])); + + } + return list; + } + + @SafeVarargs + public static Set setOf(E... values) { + return Sets.newHashSet(values); + } + + public static void assertByteListEquals(List a, List b) { + assertEquals(a.size(), b.size()); + + List aBuffer = Stream.of(a).map(ByteBuffer::wrap).toList(); + List bBuffer = Stream.of(b).map(ByteBuffer::wrap).toList(); + + assertTrue(aBuffer.containsAll(bBuffer)); + } + + public static void assertContentsEqual(Collection a, Collection b) { + assertEquals(a.size(), b.size()); + assertTrue(a.containsAll(b)); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java index fce5221b66..9d68eeb093 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java @@ -17,13 +17,13 @@ import org.whispersystems.libsignal.ecc.ECPublicKey; import org.whispersystems.libsignal.logging.Log; import org.whispersystems.libsignal.state.PreKeyRecord; import org.whispersystems.libsignal.state.SignedPreKeyRecord; -import org.whispersystems.libsignal.util.Pair; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.FeatureFlags; import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException; import org.whispersystems.signalservice.api.crypto.ProfileCipher; import org.whispersystems.signalservice.api.crypto.ProfileCipherOutputStream; import org.whispersystems.signalservice.api.push.exceptions.NoContentException; +import org.whispersystems.signalservice.api.storage.StorageId; import org.whispersystems.signalservice.api.storage.StorageKey; import org.whispersystems.signalservice.api.messages.calls.TurnServerInfo; import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo; @@ -424,40 +424,40 @@ public class SignalServiceAccountManager { return Optional.absent(); } - byte[] rawRecord = SignalStorageCipher.decrypt(storageKey.deriveManifestKey(storageManifest.getVersion()), storageManifest.getValue().toByteArray()); - ManifestRecord manifestRecord = ManifestRecord.parseFrom(rawRecord); - List keys = new ArrayList<>(manifestRecord.getKeysCount()); - - for (ByteString key : manifestRecord.getKeysList()) { - keys.add(key.toByteArray()); - } - - return Optional.of(new SignalStorageManifest(manifestRecord.getVersion(), keys)); + return Optional.of(SignalStorageModels.remoteToLocalStorageManifest(storageManifest, storageKey)); } catch (NoContentException e) { return Optional.absent(); } } - public List readStorageRecords(StorageKey storageKey, List storageKeys) throws IOException, InvalidKeyException { - ReadOperation.Builder operation = ReadOperation.newBuilder(); + public List readStorageRecords(StorageKey storageKey, List storageKeys) throws IOException, InvalidKeyException { + List result = new ArrayList<>(); + ReadOperation.Builder operation = ReadOperation.newBuilder(); + Map typeMap = new HashMap<>(); - for (byte[] key : storageKeys) { - operation.addReadKey(ByteString.copyFrom(key)); + for (StorageId key : storageKeys) { + typeMap.put(ByteString.copyFrom(key.getRaw()), key.getType()); + + if (StorageId.isKnownType(key.getType())) { + operation.addReadKey(ByteString.copyFrom(key.getRaw())); + } else { + result.add(SignalStorageRecord.forUnknown(key)); + } } - String authToken = this.pushServiceSocket.getStorageAuth(); - StorageItems items = this.pushServiceSocket.readStorageItems(authToken, operation.build()); - List result = new ArrayList<>(items.getItemsCount()); + String authToken = this.pushServiceSocket.getStorageAuth(); + StorageItems items = this.pushServiceSocket.readStorageItems(authToken, operation.build()); if (items.getItemsCount() != storageKeys.size()) { Log.w(TAG, "Failed to find all remote keys! Requested: " + storageKeys.size() + ", Found: " + items.getItemsCount()); } for (StorageItem item : items.getItemsList()) { - if (item.hasKey()) { - result.add(SignalStorageModels.remoteToLocalStorageRecord(item, storageKey)); + Integer type = typeMap.get(item.getKey()); + if (type != null) { + result.add(SignalStorageModels.remoteToLocalStorageRecord(item, type, storageKey)); } else { - Log.w(TAG, "Encountered a StorageItem with no key! Skipping."); + Log.w(TAG, "No type found! Skipping."); } } @@ -498,8 +498,11 @@ public class SignalServiceAccountManager { { ManifestRecord.Builder manifestRecordBuilder = ManifestRecord.newBuilder().setVersion(manifest.getVersion()); - for (byte[] key : manifest.getStorageKeys()) { - manifestRecordBuilder.addKeys(ByteString.copyFrom(key)); + for (StorageId id : manifest.getStorageIds()) { + ManifestRecord.Identifier idProto = ManifestRecord.Identifier.newBuilder() + .setRaw(ByteString.copyFrom(id.getRaw())) + .setType(ManifestRecord.Identifier.Type.forNumber(id.getType())).build(); + manifestRecordBuilder.addIdentifiers(idProto); } String authToken = this.pushServiceSocket.getStorageAuth(); @@ -529,13 +532,13 @@ public class SignalServiceAccountManager { StorageManifestKey conflictKey = storageKey.deriveManifestKey(conflict.get().getVersion()); byte[] rawManifestRecord = SignalStorageCipher.decrypt(conflictKey, conflict.get().getValue().toByteArray()); ManifestRecord record = ManifestRecord.parseFrom(rawManifestRecord); - List keys = new ArrayList<>(record.getKeysCount()); + List ids = new ArrayList<>(record.getIdentifiersCount()); - for (ByteString key : record.getKeysList()) { - keys.add(key.toByteArray()); + for (ManifestRecord.Identifier id : record.getIdentifiersList()) { + ids.add(StorageId.forType(id.getRaw().toByteArray(), id.getType().getNumber())); } - SignalStorageManifest conflictManifest = new SignalStorageManifest(record.getVersion(), keys); + SignalStorageManifest conflictManifest = new SignalStorageManifest(record.getVersion(), ids); return Optional.of(conflictManifest); } else { 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 aa1820ff22..62d5bd56de 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 @@ -1,57 +1,43 @@ package org.whispersystems.signalservice.api.storage; +import com.google.protobuf.ByteString; + import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.util.OptionalUtil; +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.Arrays; import java.util.Objects; public final class SignalContactRecord implements SignalRecord { - private final byte[] key; + private final StorageId id; + private final ContactRecord proto; + private final SignalServiceAddress address; private final Optional givenName; private final Optional familyName; private final Optional profileKey; private final Optional username; private final Optional identityKey; - private final IdentityState identityState; - private final boolean blocked; - private final boolean profileSharingEnabled; - private final Optional nickname; - private final int protoVersion; - private SignalContactRecord(byte[] key, - SignalServiceAddress address, - String givenName, - String familyName, - byte[] profileKey, - String username, - byte[] identityKey, - IdentityState identityState, - boolean blocked, - boolean profileSharingEnabled, - String nickname, - int protoVersion) - { - this.key = key; - this.address = address; - this.givenName = Optional.fromNullable(givenName); - this.familyName = Optional.fromNullable(familyName); - this.profileKey = Optional.fromNullable(profileKey); - this.username = Optional.fromNullable(username); - this.identityKey = Optional.fromNullable(identityKey); - this.identityState = identityState != null ? identityState : IdentityState.DEFAULT; - this.blocked = blocked; - this.profileSharingEnabled = profileSharingEnabled; - this.nickname = Optional.fromNullable(nickname); - this.protoVersion = protoVersion; + private SignalContactRecord(StorageId id, ContactRecord proto) { + this.id = id; + this.proto = proto; + + this.address = new SignalServiceAddress(UuidUtil.parseOrNull(proto.getServiceUuid()), proto.getServiceE164()); + this.givenName = OptionalUtil.absentIfEmpty(proto.getGivenName()); + this.familyName = OptionalUtil.absentIfEmpty(proto.getFamilyName()); + this.profileKey = OptionalUtil.absentIfEmpty(proto.getProfileKey()); + this.username = OptionalUtil.absentIfEmpty(proto.getUsername()); + this.identityKey = OptionalUtil.absentIfEmpty(proto.getIdentityKey()); } @Override - public byte[] getKey() { - return key; + public StorageId getId() { + return id; } public SignalServiceAddress getAddress() { @@ -79,139 +65,98 @@ public final class SignalContactRecord implements SignalRecord { } public IdentityState getIdentityState() { - return identityState; + return proto.getIdentityState(); } public boolean isBlocked() { - return blocked; + return proto.getBlocked(); } public boolean isProfileSharingEnabled() { - return profileSharingEnabled; + return proto.getWhitelisted(); } - public Optional getNickname() { - return nickname; + public boolean isArchived() { + return proto.getArchived(); } - public int getProtoVersion() { - return protoVersion; + ContactRecord toProto() { + return proto; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; - SignalContactRecord contact = (SignalContactRecord) o; - return blocked == contact.blocked && - profileSharingEnabled == contact.profileSharingEnabled && - Arrays.equals(key, contact.key) && - Objects.equals(address, contact.address) && - givenName.equals(contact.givenName) && - familyName.equals(contact.familyName) && - OptionalUtil.byteArrayEquals(profileKey, contact.profileKey) && - username.equals(contact.username) && - OptionalUtil.byteArrayEquals(identityKey, contact.identityKey) && - identityState == contact.identityState && - Objects.equals(nickname, contact.nickname); + SignalContactRecord that = (SignalContactRecord) o; + return id.equals(that.id) && + proto.equals(that.proto); } @Override public int hashCode() { - int result = Objects.hash(address, givenName, familyName, username, identityState, blocked, profileSharingEnabled, nickname); - result = 31 * result + Arrays.hashCode(key); - result = 31 * result + OptionalUtil.byteArrayHashCode(profileKey); - result = 31 * result + OptionalUtil.byteArrayHashCode(identityKey); - return result; + return Objects.hash(id, proto); } public static final class Builder { - private final byte[] key; - private final SignalServiceAddress address; + private final StorageId id; + private final ContactRecord.Builder builder; - private String givenName; - private String familyName; - private byte[] profileKey; - private String username; - private byte[] identityKey; - private IdentityState identityState; - private boolean blocked; - private boolean profileSharingEnabled; - private String nickname; - private int version; + public Builder(byte[] rawId, SignalServiceAddress address) { + this.id = StorageId.forContact(rawId); + this.builder = ContactRecord.newBuilder(); - public Builder(byte[] key, SignalServiceAddress address) { - this.key = key; - this.address = address; + builder.setServiceUuid(address.getUuid().isPresent() ? address.getUuid().get().toString() : ""); + builder.setServiceE164(address.getNumber().or("")); } public Builder setGivenName(String givenName) { - this.givenName = givenName; + builder.setGivenName(givenName == null ? "" : givenName); return this; } public Builder setFamilyName(String familyName) { - this.familyName = familyName; + builder.setFamilyName(familyName == null ? "" : familyName); return this; } public Builder setProfileKey(byte[] profileKey) { - this.profileKey = profileKey; + builder.setProfileKey(profileKey == null ? ByteString.EMPTY : ByteString.copyFrom(profileKey)); return this; } public Builder setUsername(String username) { - this.username = username; + builder.setUsername(username == null ? "" : username); return this; } public Builder setIdentityKey(byte[] identityKey) { - this.identityKey = identityKey; + builder.setIdentityKey(identityKey == null ? ByteString.EMPTY : ByteString.copyFrom(identityKey)); return this; } public Builder setIdentityState(IdentityState identityState) { - this.identityState = identityState; + builder.setIdentityState(identityState == null ? IdentityState.DEFAULT : identityState); return this; } public Builder setBlocked(boolean blocked) { - this.blocked = blocked; + builder.setBlocked(blocked); return this; } public Builder setProfileSharingEnabled(boolean profileSharingEnabled) { - this.profileSharingEnabled = profileSharingEnabled; + builder.setWhitelisted(profileSharingEnabled); return this; } - public Builder setNickname(String nickname) { - this.nickname = nickname; - return this; - } - - Builder setProtoVersion(int version) { - this.version = version; + public Builder setArchived(boolean archived) { + builder.setArchived(archived); return this; } public SignalContactRecord build() { - return new SignalContactRecord(key, - address, - givenName, - familyName, - profileKey, - username, - identityKey, - identityState, - blocked, - profileSharingEnabled, - nickname, - version); + return new SignalContactRecord(id, builder.build()); } } - - public enum IdentityState { - DEFAULT, VERIFIED, UNVERIFIED - } } 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 a86b023119..e0556848ce 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 @@ -1,29 +1,26 @@ package org.whispersystems.signalservice.api.storage; -import org.whispersystems.libsignal.util.guava.Optional; -import org.whispersystems.signalservice.api.push.SignalServiceAddress; -import org.whispersystems.signalservice.api.util.OptionalUtil; +import com.google.protobuf.ByteString; + +import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record; -import java.util.Arrays; import java.util.Objects; public final class SignalGroupV1Record implements SignalRecord { - private final byte[] key; - private final byte[] groupId; - private final boolean blocked; - private final boolean profileSharingEnabled; + private final StorageId id; + private final GroupV1Record proto; + private final byte[] groupId; - private SignalGroupV1Record(byte[] key, byte[] groupId, boolean blocked, boolean profileSharingEnabled) { - this.key = key; - this.groupId = groupId; - this.blocked = blocked; - this.profileSharingEnabled = profileSharingEnabled; + private SignalGroupV1Record(StorageId id, GroupV1Record proto) { + this.id = id; + this.proto = proto; + this.groupId = proto.getId().toByteArray(); } @Override - public byte[] getKey() { - return key; + public StorageId getId() { + return id; } public byte[] getGroupId() { @@ -31,11 +28,19 @@ public final class SignalGroupV1Record implements SignalRecord { } public boolean isBlocked() { - return blocked; + return proto.getBlocked(); } public boolean isProfileSharingEnabled() { - return profileSharingEnabled; + return proto.getWhitelisted(); + } + + public boolean isArchived() { + return proto.getArchived(); + } + + GroupV1Record toProto() { + return proto; } @Override @@ -43,43 +48,43 @@ public final class SignalGroupV1Record implements SignalRecord { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; SignalGroupV1Record that = (SignalGroupV1Record) o; - return blocked == that.blocked && - profileSharingEnabled == that.profileSharingEnabled && - Arrays.equals(key, that.key) && - Arrays.equals(groupId, that.groupId); + return id.equals(that.id) && + proto.equals(that.proto); } @Override public int hashCode() { - int result = Objects.hash(blocked, profileSharingEnabled); - result = 31 * result + Arrays.hashCode(key); - result = 31 * result + Arrays.hashCode(groupId); - return result; + return Objects.hash(id, proto); } public static final class Builder { - private final byte[] key; - private final byte[] groupId; - private boolean blocked; - private boolean profileSharingEnabled; + private final StorageId id; + private final GroupV1Record.Builder builder; - public Builder(byte[] key, byte[] groupId) { - this.key = key; - this.groupId = groupId; + public Builder(byte[] rawId, byte[] groupId) { + this.id = StorageId.forGroupV1(rawId); + this.builder = GroupV1Record.newBuilder(); + + builder.setId(ByteString.copyFrom(groupId)); } public Builder setBlocked(boolean blocked) { - this.blocked = blocked; + builder.setBlocked(blocked); return this; } public Builder setProfileSharingEnabled(boolean profileSharingEnabled) { - this.profileSharingEnabled = profileSharingEnabled; + builder.setWhitelisted(profileSharingEnabled); + return this; + } + + public Builder setArchived(boolean archived) { + builder.setArchived(archived); return this; } public SignalGroupV1Record build() { - return new SignalGroupV1Record(key, groupId, blocked, profileSharingEnabled); + return new SignalGroupV1Record(id, builder.build()); } } } 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 ac9e887ec2..a857805eae 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 @@ -1,5 +1,5 @@ package org.whispersystems.signalservice.api.storage; public interface SignalRecord { - byte[] getKey(); + StorageId getId(); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageManifest.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageManifest.java index b997ef3b2a..ee18a7b0e7 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageManifest.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageManifest.java @@ -3,19 +3,19 @@ package org.whispersystems.signalservice.api.storage; import java.util.List; public class SignalStorageManifest { - private final long version; - private final List storageKeys; + private final long version; + private final List storageIds; - public SignalStorageManifest(long version, List storageKeys) { + public SignalStorageManifest(long version, List storageIds) { this.version = version; - this.storageKeys = storageKeys; + this.storageIds = storageIds; } public long getVersion() { return version; } - public List getStorageKeys() { - return storageKeys; + public List getStorageIds() { + return storageIds; } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageModels.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageModels.java index 6552a1dd48..116059dfce 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageModels.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageModels.java @@ -7,25 +7,40 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.internal.storage.protos.ContactRecord; import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record; +import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord; import org.whispersystems.signalservice.internal.storage.protos.StorageItem; +import org.whispersystems.signalservice.internal.storage.protos.StorageManifest; import org.whispersystems.signalservice.internal.storage.protos.StorageRecord; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; public final class SignalStorageModels { - public static SignalStorageRecord remoteToLocalStorageRecord(StorageItem item, StorageKey storageKey) throws IOException, InvalidKeyException { + public static SignalStorageManifest remoteToLocalStorageManifest(StorageManifest manifest, StorageKey storageKey) throws IOException, InvalidKeyException { + byte[] rawRecord = SignalStorageCipher.decrypt(storageKey.deriveManifestKey(manifest.getVersion()), manifest.getValue().toByteArray()); + ManifestRecord manifestRecord = ManifestRecord.parseFrom(rawRecord); + List ids = new ArrayList<>(manifestRecord.getIdentifiersCount()); + + for (ManifestRecord.Identifier id : manifestRecord.getIdentifiersList()) { + ids.add(StorageId.forType(id.getRaw().toByteArray(), id.getType().getNumber())); + } + + return new SignalStorageManifest(manifestRecord.getVersion(), ids); + } + + public static SignalStorageRecord remoteToLocalStorageRecord(StorageItem item, int type, StorageKey storageKey) throws IOException, InvalidKeyException { byte[] key = item.getKey().toByteArray(); byte[] rawRecord = SignalStorageCipher.decrypt(storageKey.deriveItemKey(key), item.getValue().toByteArray()); StorageRecord record = StorageRecord.parseFrom(rawRecord); - switch (record.getType()) { - case StorageRecord.Type.CONTACT_VALUE: - return SignalStorageRecord.forContact(key, remoteToLocalContactRecord(key, record.getContact())); - case StorageRecord.Type.GROUPV1_VALUE: - return SignalStorageRecord.forGroupV1(key, remoteToLocalGroupV1Record(key, record.getGroupV1())); - default: - return SignalStorageRecord.forUnknown(key, record.getType()); + if (record.hasContact() && type == ManifestRecord.Identifier.Type.CONTACT_VALUE) { + return SignalStorageRecord.forContact(StorageId.forContact(key), remoteToLocalContactRecord(key, record.getContact())); + } else if (record.hasGroupV1() && type == ManifestRecord.Identifier.Type.GROUPV1_VALUE) { + return SignalStorageRecord.forGroupV1(StorageId.forGroupV1(key), remoteToLocalGroupV1Record(key, record.getGroupV1())); + } else { + return SignalStorageRecord.forUnknown(StorageId.forType(key, type)); } } @@ -33,149 +48,43 @@ public final class SignalStorageModels { StorageRecord.Builder builder = StorageRecord.newBuilder(); if (record.getContact().isPresent()) { - builder.setContact(localToRemoteContactRecord(record.getContact().get())); + builder.setContact(record.getContact().get().toProto()); } else if (record.getGroupV1().isPresent()) { - builder.setGroupV1(localToRemoteGroupV1Record(record.getGroupV1().get())); + builder.setGroupV1(record.getGroupV1().get().toProto()); } else { throw new InvalidStorageWriteError(); } - builder.setType(record.getType()); - StorageRecord remoteRecord = builder.build(); - StorageItemKey itemKey = storageKey.deriveItemKey(record.getKey()); + StorageItemKey itemKey = storageKey.deriveItemKey(record.getId().getRaw()); byte[] encryptedRecord = SignalStorageCipher.encrypt(itemKey, remoteRecord.toByteArray()); return StorageItem.newBuilder() - .setKey(ByteString.copyFrom(record.getKey())) + .setKey(ByteString.copyFrom(record.getId().getRaw())) .setValue(ByteString.copyFrom(encryptedRecord)) .build(); } private static SignalContactRecord remoteToLocalContactRecord(byte[] key, ContactRecord contact) { - SignalServiceAddress address = new SignalServiceAddress(UuidUtil.parseOrNull(contact.getServiceUuid()), contact.getServiceE164()); - SignalContactRecord.Builder builder = new SignalContactRecord.Builder(key, address); + SignalServiceAddress address = new SignalServiceAddress(UuidUtil.parseOrNull(contact.getServiceUuid()), contact.getServiceE164()); - if (contact.hasBlocked()) { - builder.setBlocked(contact.getBlocked()); - } - - if (contact.hasWhitelisted()) { - builder.setProfileSharingEnabled(contact.getWhitelisted()); - } - - if (contact.hasNickname()) { - builder.setNickname(contact.getNickname()); - } - - if (contact.hasProfile()) { - if (contact.getProfile().hasKey()) { - builder.setProfileKey(contact.getProfile().getKey().toByteArray()); - } - - if (contact.getProfile().hasGivenName()) { - builder.setGivenName(contact.getProfile().getGivenName()); - } - - if (contact.getProfile().hasFamilyName()) { - builder.setFamilyName(contact.getProfile().getFamilyName()); - } - - if (contact.getProfile().hasUsername()) { - builder.setUsername(contact.getProfile().getUsername()); - } - } - - if (contact.hasIdentity()) { - if (contact.getIdentity().hasKey()) { - builder.setIdentityKey(contact.getIdentity().getKey().toByteArray()); - } - - if (contact.getIdentity().hasState()) { - switch (contact.getIdentity().getState()) { - case VERIFIED: builder.setIdentityState(SignalContactRecord.IdentityState.VERIFIED); - case UNVERIFIED: builder.setIdentityState(SignalContactRecord.IdentityState.UNVERIFIED); - default: builder.setIdentityState(SignalContactRecord.IdentityState.DEFAULT); - } - } - } - - return builder.build(); + return new SignalContactRecord.Builder(key, address) + .setBlocked(contact.getBlocked()) + .setProfileSharingEnabled(contact.getWhitelisted()) + .setProfileKey(contact.getProfileKey().toByteArray()) + .setGivenName(contact.getGivenName()) + .setFamilyName(contact.getFamilyName()) + .setUsername(contact.getUsername()) + .setIdentityKey(contact.getIdentityKey().toByteArray()) + .setIdentityState(contact.getIdentityState()) + .build(); } private static SignalGroupV1Record remoteToLocalGroupV1Record(byte[] key, GroupV1Record groupV1) { - SignalGroupV1Record.Builder builder = new SignalGroupV1Record.Builder(key, groupV1.getId().toByteArray()); - - if (groupV1.hasBlocked()) { - builder.setBlocked(groupV1.getBlocked()); - } - - if (groupV1.hasWhitelisted()) { - builder.setProfileSharingEnabled(groupV1.getWhitelisted()); - } - - return builder.build(); - } - - private static ContactRecord localToRemoteContactRecord(SignalContactRecord contact) { - ContactRecord.Builder contactRecordBuilder = ContactRecord.newBuilder() - .setBlocked(contact.isBlocked()) - .setWhitelisted(contact.isProfileSharingEnabled()); - if (contact.getAddress().getNumber().isPresent()) { - contactRecordBuilder.setServiceE164(contact.getAddress().getNumber().get()); - } - - if (contact.getAddress().getUuid().isPresent()) { - contactRecordBuilder.setServiceUuid(contact.getAddress().getUuid().get().toString()); - } - - if (contact.getNickname().isPresent()) { - contactRecordBuilder.setNickname(contact.getNickname().get()); - } - - ContactRecord.Identity.Builder identityBuilder = ContactRecord.Identity.newBuilder(); - - switch (contact.getIdentityState()) { - case VERIFIED: identityBuilder.setState(ContactRecord.Identity.State.VERIFIED); - case UNVERIFIED: identityBuilder.setState(ContactRecord.Identity.State.UNVERIFIED); - case DEFAULT: identityBuilder.setState(ContactRecord.Identity.State.DEFAULT); - } - - if (contact.getIdentityKey().isPresent()) { - identityBuilder.setKey(ByteString.copyFrom(contact.getIdentityKey().get())); - } - - contactRecordBuilder.setIdentity(identityBuilder.build()); - - ContactRecord.Profile.Builder profileBuilder = ContactRecord.Profile.newBuilder(); - - if (contact.getProfileKey().isPresent()) { - profileBuilder.setKey(ByteString.copyFrom(contact.getProfileKey().get())); - } - - if (contact.getGivenName().isPresent()) { - profileBuilder.setGivenName(contact.getGivenName().get()); - } - - if (contact.getFamilyName().isPresent()) { - profileBuilder.setFamilyName(contact.getFamilyName().get()); - } - - if (contact.getUsername().isPresent()) { - profileBuilder.setUsername(contact.getUsername().get()); - } - - contactRecordBuilder.setProfile(profileBuilder.build()); - - return contactRecordBuilder.build(); - } - - private static GroupV1Record localToRemoteGroupV1Record(SignalGroupV1Record groupV1) { - return GroupV1Record.newBuilder() - .setId(ByteString.copyFrom(groupV1.getGroupId())) - .setBlocked(groupV1.isBlocked()) - .setWhitelisted(groupV1.isProfileSharingEnabled()) - .build(); + return new SignalGroupV1Record.Builder(key, groupV1.getId().toByteArray()) + .setBlocked(groupV1.getBlocked()) + .setProfileSharingEnabled(groupV1.getWhitelisted()) + .build(); } private static class InvalidStorageWriteError extends Error { 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 d368b88477..0d4b8ec8c7 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 @@ -1,56 +1,51 @@ package org.whispersystems.signalservice.api.storage; import org.whispersystems.libsignal.util.guava.Optional; -import org.whispersystems.signalservice.internal.storage.protos.StorageRecord; -import java.util.Arrays; import java.util.Objects; public class SignalStorageRecord implements SignalRecord { - private final byte[] key; - private final int type; + private final StorageId id; private final Optional contact; private final Optional groupV1; public static SignalStorageRecord forContact(SignalContactRecord contact) { - return forContact(contact.getKey(), contact); + return forContact(contact.getId(), contact); } - public static SignalStorageRecord forContact(byte[] key, SignalContactRecord contact) { - return new SignalStorageRecord(key, StorageRecord.Type.CONTACT_VALUE, Optional.of(contact), Optional.absent()); + public static SignalStorageRecord forContact(StorageId key, SignalContactRecord contact) { + return new SignalStorageRecord(key, Optional.of(contact), Optional.absent()); } public static SignalStorageRecord forGroupV1(SignalGroupV1Record groupV1) { - return forGroupV1(groupV1.getKey(), groupV1); + return forGroupV1(groupV1.getId(), groupV1); } - public static SignalStorageRecord forGroupV1(byte[] key, SignalGroupV1Record groupV1) { - return new SignalStorageRecord(key, StorageRecord.Type.GROUPV1_VALUE, Optional.absent(), Optional.of(groupV1)); + public static SignalStorageRecord forGroupV1(StorageId key, SignalGroupV1Record groupV1) { + return new SignalStorageRecord(key, Optional.absent(), Optional.of(groupV1)); } - public static SignalStorageRecord forUnknown(byte[] key, int type) { - return new SignalStorageRecord(key, type, Optional.absent(), Optional.absent()); + public static SignalStorageRecord forUnknown(StorageId key) { + return new SignalStorageRecord(key,Optional.absent(), Optional.absent()); } - private SignalStorageRecord(byte[] key, - int type, + private SignalStorageRecord(StorageId id, Optional contact, Optional groupV1) { - this.key = key; - this.type = type; + this.id = id; this.contact = contact; this.groupV1 = groupV1; } @Override - public byte[] getKey() { - return key; + public StorageId getId() { + return id; } public int getType() { - return type; + return id.getType(); } public Optional getContact() { @@ -69,17 +64,14 @@ public class SignalStorageRecord implements SignalRecord { public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; - SignalStorageRecord record = (SignalStorageRecord) o; - return type == record.type && - Arrays.equals(key, record.key) && - contact.equals(record.contact) && - groupV1.equals(record.groupV1); + SignalStorageRecord that = (SignalStorageRecord) o; + return Objects.equals(id, that.id) && + Objects.equals(contact, that.contact) && + Objects.equals(groupV1, that.groupV1); } @Override public int hashCode() { - int result = Objects.hash(type, contact, groupV1); - result = 31 * result + Arrays.hashCode(key); - return result; + return Objects.hash(id, contact, groupV1); } } 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 new file mode 100644 index 0000000000..3a42655040 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/StorageId.java @@ -0,0 +1,65 @@ +package org.whispersystems.signalservice.api.storage; + +import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord; + +import java.util.Arrays; +import java.util.Objects; + +public class StorageId { + private final int type; + private final byte[] raw; + + public static StorageId forContact(byte[] raw) { + return new StorageId(ManifestRecord.Identifier.Type.CONTACT_VALUE, raw); + } + + public static StorageId forGroupV1(byte[] raw) { + return new StorageId(ManifestRecord.Identifier.Type.GROUPV1_VALUE, raw); + } + + public static StorageId forType(byte[] raw, int type) { + return new StorageId(type, raw); + } + + private StorageId(int type, byte[] raw) { + this.type = type; + this.raw = raw; + } + + public int getType() { + return type; + } + + public byte[] getRaw() { + return raw; + } + + public StorageId withNewBytes(byte[] key) { + return new StorageId(type, key); + } + + public static boolean isKnownType(int val) { + for (ManifestRecord.Identifier.Type type : ManifestRecord.Identifier.Type.values()) { + if (type.getNumber() == val) { + return true; + } + } + return false; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + StorageId storageId = (StorageId) o; + return type == storageId.type && + Arrays.equals(raw, storageId.raw); + } + + @Override + public int hashCode() { + int result = Objects.hash(type); + result = 31 * result + Arrays.hashCode(raw); + return result; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/OptionalUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/OptionalUtil.java index 3c959d222c..db976d4086 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/OptionalUtil.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/OptionalUtil.java @@ -1,5 +1,7 @@ package org.whispersystems.signalservice.api.util; +import com.google.protobuf.ByteString; + import org.whispersystems.libsignal.util.guava.Optional; import java.util.Arrays; @@ -24,4 +26,20 @@ public final class OptionalUtil { public static int byteArrayHashCode(Optional bytes) { return bytes.isPresent() ? Arrays.hashCode(bytes.get()) : 0; } + + public static Optional absentIfEmpty(String value) { + if (value == null || value.length() == 0) { + return Optional.absent(); + } else { + return Optional.of(value); + } + } + + public static Optional absentIfEmpty(ByteString value) { + if (value == null || value.isEmpty()) { + return Optional.absent(); + } else { + return Optional.of(value.toByteArray()); + } + } } diff --git a/libsignal/service/src/main/proto/SignalStorage.proto b/libsignal/service/src/main/proto/SignalStorage.proto index 8ce6ebe642..de0a175373 100644 --- a/libsignal/service/src/main/proto/SignalStorage.proto +++ b/libsignal/service/src/main/proto/SignalStorage.proto @@ -3,86 +3,107 @@ * * Licensed according to the LICENSE file in this repository. */ -syntax = "proto2"; +syntax = "proto3"; package signalservice; option java_package = "org.whispersystems.signalservice.internal.storage.protos"; option java_multiple_files = true; +message StorageManifest { + uint64 version = 1; + bytes value = 2; +} message StorageItem { - optional bytes key = 1; - optional bytes value = 2; + bytes key = 1; + bytes value = 2; } message StorageItems { repeated StorageItem items = 1; } -message StorageManifest { - optional uint64 version = 1; - optional bytes value = 2; -} - message ReadOperation { repeated bytes readKey = 1; } message WriteOperation { - optional StorageManifest manifest = 1; + StorageManifest manifest = 1; repeated StorageItem insertItem = 2; repeated bytes deleteKey = 3; - optional bool clearAll = 4; -} - -message StorageRecord { - enum Type { - UNKNOWN = 0; - CONTACT = 1; - GROUPV1 = 2; - } - - optional uint32 type = 1; - optional ContactRecord contact = 2; - optional GroupV1Record groupV1 = 3; -} - -message ContactRecord { - message Identity { - enum State { - DEFAULT = 0; - VERIFIED = 1; - UNVERIFIED = 2; - } - - optional bytes key = 1; - optional State state = 2; - } - - message Profile { - optional string givenName = 1; - optional string familyName = 4; - optional bytes key = 2; - optional string username = 3; - } - - optional string serviceUuid = 1; - optional string serviceE164 = 2; - optional Profile profile = 3; - optional Identity identity = 4; - optional bool blocked = 5; - optional bool whitelisted = 6; - optional string nickname = 7; -} - -message GroupV1Record { - optional bytes id = 1; - optional bool blocked = 2; - optional bool whitelisted = 3; + bool clearAll = 4; } message ManifestRecord { - optional uint64 version = 1; - repeated bytes keys = 2; + message Identifier { + enum Type { + UNKNOWN = 0; + CONTACT = 1; + GROUPV1 = 2; + GROUPV2 = 3; + ACCOUNT = 4; + } + + bytes raw = 1; + Type type = 2; + } + + uint64 version = 1; + repeated Identifier identifiers = 2; +} + +message StorageRecord { + oneof record { + ContactRecord contact = 1; + GroupV1Record groupV1 = 2; + GroupV2Record groupV2 = 3; + AccountRecord account = 4; + } +} + +message ContactRecord { + enum IdentityState { + DEFAULT = 0; + VERIFIED = 1; + UNVERIFIED = 2; + } + + string serviceUuid = 1; + string serviceE164 = 2; + bytes profileKey = 3; + bytes identityKey = 4; + IdentityState identityState = 5; + string givenName = 6; + string familyName = 7; + string username = 8; + bool blocked = 9; + bool whitelisted = 10; + bool archived = 11; +} + +message GroupV1Record { + bytes id = 1; + bool blocked = 2; + bool whitelisted = 3; + bool archived = 4; +} + +message GroupV2Record { + bytes masterKey = 1; + bool blocked = 2; + bool whitelisted = 3; + bool archived = 4; +} + +message AccountRecord { + message Config { + bool readReceipts = 1; + bool sealedSenderIndicators = 2; + bool typingIndicators = 3; + bool linkPreviews = 4; + } + + ContactRecord contact = 1; + Config config = 2; }