diff --git a/app/src/main/java/org/thoughtcrime/securesms/AppInitialization.java b/app/src/main/java/org/thoughtcrime/securesms/AppInitialization.java index 0ddb10242e..d3c38c64b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/AppInitialization.java +++ b/app/src/main/java/org/thoughtcrime/securesms/AppInitialization.java @@ -30,7 +30,7 @@ public final class AppInitialization { TextSecurePreferences.setLastExperienceVersionCode(context, Util.getCanonicalVersionCode()); TextSecurePreferences.setHasSeenStickerIntroTooltip(context, true); ApplicationDependencies.getMegaphoneRepository().onFirstEverAppLaunch(); - SignalStore.registrationValues().onNewInstall(); + SignalStore.onFirstEverAppLaunch(); ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.ZOZO.getPackId(), BlessedPacks.ZOZO.getPackKey(), false)); ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.BANDIT.getPackId(), BlessedPacks.BANDIT.getPackKey(), false)); ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_HANDS.getPackId(), BlessedPacks.SWOON_HANDS.getPackKey())); @@ -39,7 +39,7 @@ public final class AppInitialization { public static void onPostBackupRestore(@NonNull Context context) { ApplicationDependencies.getMegaphoneRepository().onFirstEverAppLaunch(); - SignalStore.registrationValues().onNewInstall(); + SignalStore.onFirstEverAppLaunch(); ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.ZOZO.getPackId(), BlessedPacks.ZOZO.getPackKey(), false)); ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.BANDIT.getPackId(), BlessedPacks.BANDIT.getPackKey(), false)); ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forReference(BlessedPacks.SWOON_HANDS.getPackId(), BlessedPacks.SWOON_HANDS.getPackKey())); diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 0c63b6782e..29056202d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -47,6 +47,7 @@ import org.thoughtcrime.securesms.jobs.FcmRefreshJob; import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob; import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob; +import org.thoughtcrime.securesms.jobs.StorageSyncJob; import org.thoughtcrime.securesms.logging.AndroidLogger; import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger; import org.thoughtcrime.securesms.logging.Log; @@ -54,6 +55,7 @@ import org.thoughtcrime.securesms.logging.PersistentLogger; import org.thoughtcrime.securesms.logging.SignalUncaughtExceptionHandler; import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil; import org.thoughtcrime.securesms.migrations.ApplicationMigrations; +import org.thoughtcrime.securesms.migrations.StorageServiceMigrationJob; import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.providers.BlobProvider; @@ -134,6 +136,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi FeatureFlags.init(); NotificationChannels.create(this); RefreshPreKeysJob.scheduleIfNecessary(); + StorageSyncJob.scheduleIfNecessary(); ProcessLifecycleOwner.get().getLifecycle().addObserver(this); if (Build.VERSION.SDK_INT < 21) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java b/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java index e696506743..1f4acd054b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/VerifyIdentityActivity.java @@ -44,6 +44,7 @@ import android.text.TextUtils; import android.text.method.LinkMovementMethod; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.StorageSyncJob; import org.thoughtcrime.securesms.logging.Log; import android.view.ContextMenu; import android.view.ContextMenu.ContextMenuInfo; @@ -604,6 +605,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActionBarActivity remoteIdentity, isChecked ? VerifiedStatus.VERIFIED : VerifiedStatus.DEFAULT)); + ApplicationDependencies.getJobManager().add(new StorageSyncJob()); IdentityUtil.markIdentityVerified(getActivity(), recipient.get(), isChecked, false); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java index 3e564a9305..d79d088857 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/DirectoryHelper.java @@ -8,6 +8,8 @@ import androidx.annotation.WorkerThread; import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.StorageSyncJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.FeatureFlags; @@ -15,8 +17,15 @@ import java.io.IOException; public class DirectoryHelper { + private static final String TAG = Log.tag(DirectoryHelper.class); + @WorkerThread public static void refreshDirectory(@NonNull Context context, boolean notifyOfNewUsers) throws IOException { + if (!SignalStore.storageServiceValues().hasFirstStorageSyncCompleted()) { + Log.i(TAG, "First storage sync has not completed. Skipping."); + return; + } + if (FeatureFlags.uuids()) { // TODO [greyson] Create a DirectoryHelperV2 when appropriate. DirectoryHelperV1.refreshDirectory(context, notifyOfNewUsers); @@ -24,9 +33,7 @@ public class DirectoryHelper { DirectoryHelperV1.refreshDirectory(context, notifyOfNewUsers); } - if (FeatureFlags.storageService()) { - ApplicationDependencies.getJobManager().add(new StorageSyncJob()); - } + ApplicationDependencies.getJobManager().add(new StorageSyncJob()); } @WorkerThread @@ -41,7 +48,7 @@ public class DirectoryHelper { newRegisteredState = DirectoryHelperV1.refreshDirectoryFor(context, recipient, notifyOfNewUsers); } - if (FeatureFlags.storageService() && newRegisteredState != originalRegisteredState) { + if (newRegisteredState != originalRegisteredState) { ApplicationDependencies.getJobManager().add(new StorageSyncJob()); } 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 index 34beb68a73..eab25c0ebe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/StorageSyncHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/StorageSyncHelper.java @@ -4,18 +4,22 @@ 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; @@ -28,11 +32,14 @@ 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); @@ -42,7 +49,7 @@ public final class StorageSyncHelper { private static KeyGenerator testKeyGenerator = null; /** - * Given the local state of pending storage mutatations, this will generate a result that will + * 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). @@ -64,17 +71,17 @@ public final class StorageSyncHelper { @NonNull List deletes) { Set completeKeys = new LinkedHashSet<>(Stream.of(currentLocalKeys).map(ByteBuffer::wrap).toList()); - Set contactInserts = new LinkedHashSet<>(); - Set contactDeletes = new LinkedHashSet<>(); + Set storageInserts = new LinkedHashSet<>(); + Set storageDeletes = new LinkedHashSet<>(); Map storageKeyUpdates = new HashMap<>(); for (RecipientSettings insert : inserts) { - contactInserts.add(localToRemoteContact(insert)); + storageInserts.add(localToRemoteRecord(insert)); } for (RecipientSettings delete : deletes) { byte[] key = Objects.requireNonNull(delete.getStorageKey()); - contactDeletes.add(ByteBuffer.wrap(key)); + storageDeletes.add(ByteBuffer.wrap(key)); completeKeys.remove(ByteBuffer.wrap(key)); } @@ -82,21 +89,20 @@ public final class StorageSyncHelper { byte[] oldKey = Objects.requireNonNull(update.getStorageKey()); byte[] newKey = generateKey(); - contactInserts.add(localToRemoteContact(update, newKey)); - contactDeletes.add(ByteBuffer.wrap(oldKey)); + 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 (contactInserts.isEmpty() && contactDeletes.isEmpty()) { + if (storageInserts.isEmpty() && storageDeletes.isEmpty()) { return Optional.absent(); } else { - List storageInserts = Stream.of(contactInserts).map(c -> SignalStorageRecord.forContact(c.getKey(), c)).toList(); - List contactDeleteBytes = Stream.of(contactDeletes).map(ByteBuffer::array).toList(); + 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, storageInserts, contactDeleteBytes); + WriteOperationResult writeOperationResult = new WriteOperationResult(manifest, new ArrayList<>(storageInserts), contactDeleteBytes); return Optional.of(new LocalWriteResult(writeOperationResult, storageKeyUpdates)); } @@ -142,17 +148,35 @@ public final class StorageSyncHelper { 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, - contactMergeResult.remoteInserts, - contactMergeResult.remoteUpdates, + groupV1MergeResult.localInserts, + groupV1MergeResult.localUpdates, new LinkedHashSet<>(remoteOnlyUnknowns), - new LinkedHashSet<>(localOnlyUnknowns)); + new LinkedHashSet<>(localOnlyUnknowns), + remoteInserts, + remoteUpdates); } /** @@ -169,7 +193,11 @@ public final class StorageSyncHelper { completeKeys.add(ByteBuffer.wrap(insert.getKey())); } - for (SignalContactRecord insert : mergeResult.getRemoteContactInserts()) { + for (SignalGroupV1Record insert : mergeResult.getLocalGroupV1Inserts()) { + completeKeys.add(ByteBuffer.wrap(insert.getKey())); + } + + for (SignalStorageRecord insert : mergeResult.getRemoteInserts()) { completeKeys.add(ByteBuffer.wrap(insert.getKey())); } @@ -178,34 +206,47 @@ public final class StorageSyncHelper { } for (ContactUpdate update : mergeResult.getLocalContactUpdates()) { - completeKeys.remove(ByteBuffer.wrap(update.getOldContact().getKey())); - completeKeys.add(ByteBuffer.wrap(update.getNewContact().getKey())); + completeKeys.remove(ByteBuffer.wrap(update.getOld().getKey())); + completeKeys.add(ByteBuffer.wrap(update.getNew().getKey())); } - for (ContactUpdate update : mergeResult.getRemoteContactUpdates()) { - completeKeys.remove(ByteBuffer.wrap(update.getOldContact().getKey())); - completeKeys.add(ByteBuffer.wrap(update.getNewContact().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 contactInserts = new ArrayList<>(); - contactInserts.addAll(mergeResult.getRemoteContactInserts()); - contactInserts.addAll(Stream.of(mergeResult.getRemoteContactUpdates()).map(ContactUpdate::getNewContact).toList()); + List inserts = new ArrayList<>(); + inserts.addAll(mergeResult.getRemoteInserts()); + inserts.addAll(Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getNew).toList()); - List inserts = Stream.of(contactInserts).map(c -> SignalStorageRecord.forContact(c.getKey(), c)).toList(); - - List deletes = Stream.of(mergeResult.getRemoteContactUpdates()).map(ContactUpdate::getOldContact).map(SignalContactRecord::getKey).toList(); + List deletes = Stream.of(mergeResult.getRemoteUpdates()).map(RecordUpdate::getOld).map(SignalStorageRecord::getKey).toList(); return new WriteOperationResult(manifest, inserts, deletes); } - public static @NonNull SignalContactRecord localToRemoteContact(@NonNull RecipientSettings recipient) { - if (recipient.getStorageKey() == null) { + public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientSettings settings) { + if (settings.getStorageKey() == null) { throw new AssertionError("Must have a storage key!"); } - return localToRemoteContact(recipient, recipient.getStorageKey()); + 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) { @@ -215,7 +256,8 @@ public final class StorageSyncHelper { return new SignalContactRecord.Builder(storageKey, new SignalServiceAddress(recipient.getUuid(), recipient.getE164())) .setProfileKey(recipient.getProfileKey()) - .setProfileName(recipient.getProfileName().serialize()) + .setGivenName(recipient.getProfileName().getGivenName()) + .setFamilyName(recipient.getProfileName().getFamilyName()) .setBlocked(recipient.isBlocked()) .setProfileSharingEnabled(recipient.isProfileSharing()) .setIdentityKey(recipient.getIdentityKey()) @@ -223,6 +265,17 @@ public final class StorageSyncHelper { .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; @@ -246,16 +299,17 @@ public final class StorageSyncHelper { 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 profileName = remote.getProfileName().or(local.getProfileName()).orNull(); + 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()).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().orNull(); // TODO [greyson] Update this when we add real nickname support + 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, profileName, profileKey, username, identityState, identityKey, blocked, profileSharing, nickname); - boolean matchesLocal = doParamsMatchContact(local, address, profileName, profileKey, username, identityState, identityKey, blocked, profileSharing, nickname); + 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."); @@ -267,15 +321,38 @@ public final class StorageSyncHelper { return local; } else { return new SignalContactRecord.Builder(generateKey(), address) - .setProfileName(profileName) - .setProfileKey(profileKey) - .setUsername(username) - .setIdentityState(identityState) - .setIdentityKey(identityKey) - .setBlocked(blocked) - .setProfileSharingEnabled(profileSharing) - .setNickname(nickname) - .build(); + .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(); } } @@ -294,7 +371,8 @@ public final class StorageSyncHelper { private static boolean doParamsMatchContact(@NonNull SignalContactRecord contact, @NonNull SignalServiceAddress address, - @Nullable String profileName, + @Nullable String givenName, + @Nullable String familyName, @Nullable byte[] profileKey, @Nullable String username, @Nullable IdentityState identityState, @@ -303,15 +381,16 @@ public final class StorageSyncHelper { boolean profileSharing, @Nullable String nickname) { - return Objects.equals(contact.getAddress(), address) && - Objects.equals(contact.getProfileName().orNull(), profileName) && - Arrays.equals(contact.getProfileKey().orNull(), profileKey) && - Objects.equals(contact.getUsername().orNull(), username) && - Objects.equals(contact.getIdentityState(), identityState) && - Arrays.equals(contact.getIdentityKey().orNull(), identityKey) && - contact.isBlocked() == blocked && - contact.isProfileSharingEnabled() == profileSharing && - Objects.equals(contact.getNickname().orNull(), 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, @@ -359,6 +438,40 @@ public final class StorageSyncHelper { 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; @@ -368,13 +481,11 @@ public final class StorageSyncHelper { this.newContact = newContact; } - public @NonNull - SignalContactRecord getOldContact() { + public @NonNull SignalContactRecord getOld() { return oldContact; } - public @NonNull - SignalContactRecord getNewContact() { + public @NonNull SignalContactRecord getNew() { return newContact; } @@ -397,6 +508,72 @@ public final class StorageSyncHelper { } } + 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; @@ -422,25 +599,31 @@ public final class StorageSyncHelper { public static final class MergeResult { private final Set localContactInserts; private final Set localContactUpdates; - private final Set remoteContactInserts; - private final Set remoteContactUpdates; + 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 remoteContactInserts, - @NonNull Set remoteContactUpdates, + @NonNull Set localContactUpdates, + @NonNull Set localGroupV1Inserts, + @NonNull Set localGroupV1Updates, @NonNull Set localUnknownInserts, - @NonNull Set localUnknownDeletes) + @NonNull Set localUnknownDeletes, + @NonNull Set remoteInserts, + @NonNull Set remoteUpdates) { this.localContactInserts = localContactInserts; this.localContactUpdates = localContactUpdates; - this.remoteContactInserts = remoteContactInserts; - this.remoteContactUpdates = remoteContactUpdates; + this.localGroupV1Inserts = localGroupV1Inserts; + this.localGroupV1Updates = localGroupV1Updates; this.localUnknownInserts = localUnknownInserts; this.localUnknownDeletes = localUnknownDeletes; + this.remoteInserts = remoteInserts; + this.remoteUpdates = remoteUpdates; } public @NonNull Set getLocalContactInserts() { @@ -451,12 +634,12 @@ public final class StorageSyncHelper { return localContactUpdates; } - public @NonNull Set getRemoteContactInserts() { - return remoteContactInserts; + public @NonNull Set getLocalGroupV1Inserts() { + return localGroupV1Inserts; } - public @NonNull Set getRemoteContactUpdates() { - return remoteContactUpdates; + public @NonNull Set getLocalGroupV1Updates() { + return localGroupV1Updates; } public @NonNull Set getLocalUnknownInserts() { @@ -466,6 +649,21 @@ public final class StorageSyncHelper { 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 { @@ -493,6 +691,20 @@ public final class StorageSyncHelper { 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 { @@ -531,6 +743,24 @@ public final class StorageSyncHelper { } } + 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 de4558eece..812b2900e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -21,21 +21,24 @@ import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper; import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.StorageSyncJob; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.Base64; -import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.IdentityUtil; import org.thoughtcrime.securesms.util.SqlUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.IdentityKey; import org.whispersystems.libsignal.InvalidKeyException; 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.SignalGroupV1Record; +import org.whispersystems.signalservice.api.storage.StorageKey; import org.whispersystems.signalservice.api.util.UuidUtil; import java.io.Closeable; @@ -62,6 +65,7 @@ public class RecipientDatabase extends Database { public static final String PHONE = "phone"; public static final String EMAIL = "email"; static final String GROUP_ID = "group_id"; + private static final String GROUP_TYPE = "group_type"; private static final String BLOCKED = "blocked"; private static final String MESSAGE_RINGTONE = "message_ringtone"; private static final String MESSAGE_VIBRATE = "message_vibrate"; @@ -100,7 +104,7 @@ public class RecipientDatabase extends Database { private static final String[] RECIPIENT_PROJECTION = new String[] { - UUID, USERNAME, PHONE, EMAIL, GROUP_ID, + UUID, USERNAME, PHONE, EMAIL, GROUP_ID, GROUP_TYPE, BLOCKED, MESSAGE_RINGTONE, CALL_RINGTONE, MESSAGE_VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, MESSAGE_EXPIRATION_TIME, REGISTERED, PROFILE_KEY, PROFILE_KEY_CREDENTIAL, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, SYSTEM_CONTACT_URI, @@ -120,6 +124,7 @@ public class RecipientDatabase extends Database { public static final String[] CREATE_INDEXS = new String[] { "CREATE INDEX IF NOT EXISTS recipient_dirty_index ON " + TABLE_NAME + " (" + DIRTY + ");", + "CREATE INDEX IF NOT EXISTS recipient_group_type_index ON " + TABLE_NAME + " (" + GROUP_TYPE + ");", }; private static final String[] ID_PROJECTION = new String[]{ID}; @@ -219,6 +224,24 @@ public class RecipientDatabase extends Database { } } + public enum GroupType { + NONE(0), MMS(1), SIGNAL_V1(2); + + private final int id; + + GroupType(int id) { + this.id = id; + } + + int getId() { + return id; + } + + public static GroupType fromId(int id) { + return values()[id]; + } + } + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + UUID + " TEXT UNIQUE DEFAULT NULL, " + @@ -226,6 +249,7 @@ public class RecipientDatabase extends Database { PHONE + " TEXT UNIQUE DEFAULT NULL, " + EMAIL + " TEXT UNIQUE DEFAULT NULL, " + GROUP_ID + " TEXT UNIQUE DEFAULT NULL, " + + GROUP_TYPE + " INTEGER DEFAULT " + GroupType.NONE.getId() + ", " + BLOCKED + " INTEGER DEFAULT 0," + MESSAGE_RINGTONE + " TEXT DEFAULT NULL, " + MESSAGE_VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ", " + @@ -255,7 +279,7 @@ public class RecipientDatabase extends Database { FORCE_SMS_SELECTION + " INTEGER DEFAULT 0, " + UUID_SUPPORTED + " INTEGER DEFAULT 0, " + STORAGE_SERVICE_KEY + " TEXT UNIQUE DEFAULT NULL, " + - DIRTY + " INTEGER DEFAULT 0);"; + DIRTY + " INTEGER DEFAULT " + DirtyState.CLEAN.getId() + ");"; private static final String INSIGHTS_INVITEE_LIST = "SELECT " + TABLE_NAME + "." + ID + " FROM " + TABLE_NAME + @@ -305,19 +329,35 @@ public class RecipientDatabase extends Database { } public @NonNull RecipientId getOrInsertFromUuid(@NonNull UUID uuid) { - return getOrInsertByColumn(UUID, uuid.toString()); + return getOrInsertByColumn(UUID, uuid.toString()).recipientId; } public @NonNull RecipientId getOrInsertFromE164(@NonNull String e164) { - return getOrInsertByColumn(PHONE, e164); + return getOrInsertByColumn(PHONE, e164).recipientId; } - public RecipientId getOrInsertFromEmail(@NonNull String email) { - return getOrInsertByColumn(EMAIL, email); + public @NonNull RecipientId getOrInsertFromEmail(@NonNull String email) { + return getOrInsertByColumn(EMAIL, email).recipientId; } - public RecipientId getOrInsertFromGroupId(@NonNull String groupId) { - return getOrInsertByColumn(GROUP_ID, groupId); + public @NonNull RecipientId getOrInsertFromGroupId(@NonNull String groupId) { + GetOrInsertResult result = getOrInsertByColumn(GROUP_ID, groupId); + + if (result.neededInsert) { + ContentValues values = new ContentValues(); + + if (GroupUtil.isMmsGroup(groupId)) { + values.put(GROUP_TYPE, GroupType.MMS.getId()); + } else { + values.put(GROUP_TYPE, GroupType.SIGNAL_V1.getId()); + values.put(DIRTY, DirtyState.INSERT.getId()); + values.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(StorageSyncHelper.generateKey())); + } + + update(result.recipientId, values); + } + + return result.recipientId; } public Cursor getBlocked() { @@ -355,15 +395,24 @@ public class RecipientDatabase extends Database { } public @NonNull List getPendingRecipientSyncUpdates() { - return getRecipientSettings(DIRTY + " = ?", new String[] { String.valueOf(DirtyState.UPDATE.getId()) }); + String query = DIRTY + " = ? AND " + STORAGE_SERVICE_KEY + " NOT NULL"; + String[] args = new String[] { String.valueOf(DirtyState.UPDATE.getId()) }; + + return getRecipientSettings(query, args); } public @NonNull List getPendingRecipientSyncInsertions() { - return getRecipientSettings(DIRTY + " = ?", new String[] { String.valueOf(DirtyState.INSERT.getId()) }); + String query = DIRTY + " = ? AND " + STORAGE_SERVICE_KEY + " NOT NULL"; + String[] args = new String[] { String.valueOf(DirtyState.INSERT.getId()) }; + + return getRecipientSettings(query, args); } public @NonNull List getPendingRecipientSyncDeletions() { - return getRecipientSettings(DIRTY + " = ?", new String[] { String.valueOf(DirtyState.DELETE.getId()) }); + String query = DIRTY + " = ? AND " + STORAGE_SERVICE_KEY + " NOT NULL"; + String[] args = new String[] { String.valueOf(DirtyState.DELETE.getId()) }; + + return getRecipientSettings(query, args); } public @Nullable RecipientSettings getByStorageSyncKey(@NonNull byte[] key) { @@ -396,8 +445,10 @@ public class RecipientDatabase extends Database { } } - public void applyStorageSyncUpdates(@NonNull Collection inserts, - @NonNull Collection updates) + public void applyStorageSyncUpdates(@NonNull Collection contactInserts, + @NonNull Collection contactUpdates, + @NonNull Collection groupV1Inserts, + @NonNull Collection groupV1Updates) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context); @@ -405,7 +456,7 @@ public class RecipientDatabase extends Database { db.beginTransaction(); try { - for (SignalContactRecord insert : inserts) { + for (SignalContactRecord insert : contactInserts) { ContentValues values = getValuesForStorageContact(insert); long id = db.insertOrThrow(TABLE_NAME, null, values); RecipientId recipientId = RecipientId.from(id); @@ -420,17 +471,21 @@ public class RecipientDatabase extends Database { Log.w(TAG, "Failed to process identity key during insert! Skipping.", e); } } + + if (Recipient.self().getId().equals(recipientId)) { + TextSecurePreferences.setProfileName(context, ProfileName.fromParts(insert.getGivenName().orNull(), insert.getFamilyName().orNull())); + } } - for (StorageSyncHelper.ContactUpdate update : updates) { - ContentValues values = getValuesForStorageContact(update.getNewContact()); - int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_KEY + " = ?", new String[]{Base64.encodeBytes(update.getOldContact().getKey())}); + for (StorageSyncHelper.ContactUpdate update : contactUpdates) { + ContentValues values = getValuesForStorageContact(update.getNew()); + int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_KEY + " = ?", new String[]{Base64.encodeBytes(update.getOld().getKey())}); if (updateCount < 1) { throw new AssertionError("Had an update, but it didn't match any rows!"); } - RecipientId recipientId = getByStorageKeyOrThrow(update.getNewContact().getKey()); + RecipientId recipientId = getByStorageKeyOrThrow(update.getNew().getKey()); if (update.profileKeyChanged()) { clearProfileKeyCredential(recipientId); @@ -438,9 +493,11 @@ public class RecipientDatabase extends Database { try { Optional oldIdentityRecord = identityDatabase.getIdentity(recipientId); - IdentityKey identityKey = update.getNewContact().getIdentityKey().isPresent() ? new IdentityKey(update.getNewContact().getIdentityKey().get(), 0) : null; - DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncHelper.remoteToLocalIdentityStatus(update.getNewContact().getIdentityState())); + 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())); + } Optional newIdentityRecord = identityDatabase.getIdentity(recipientId); @@ -458,6 +515,19 @@ public class RecipientDatabase extends Database { } } + for (SignalGroupV1Record insert : groupV1Inserts) { + db.insertOrThrow(TABLE_NAME, null, getValuesForStorageGroupV1(insert)); + } + + for (StorageSyncHelper.GroupV1Update update : groupV1Updates) { + ContentValues values = getValuesForStorageGroupV1(update.getNew()); + int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_KEY + " = ?", new String[]{Base64.encodeBytes(update.getOld().getKey())}); + + if (updateCount < 1) { + throw new AssertionError("Had an update, but it didn't match any rows!"); + } + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); @@ -508,14 +578,14 @@ public class RecipientDatabase extends Database { values.put(UUID, contact.getAddress().getUuid().get().toString()); } - ProfileName profileName = ProfileName.fromSerialized(contact.getProfileName().orNull()); + ProfileName profileName = ProfileName.fromParts(contact.getGivenName().orNull(), contact.getFamilyName().orNull()); values.put(PHONE, contact.getAddress().getNumber().orNull()); values.put(PROFILE_GIVEN_NAME, profileName.getGivenName()); values.put(PROFILE_FAMILY_NAME, profileName.getFamilyName()); values.put(PROFILE_JOINED_NAME, profileName.toString()); - values.put(PROFILE_KEY, contact.getProfileKey().orNull()); - // TODO [greyson] Username + values.put(PROFILE_KEY, contact.getProfileKey().transform(Base64::encodeBytes).orNull()); + values.put(USERNAME, contact.getUsername().orNull()); values.put(PROFILE_SHARING, contact.isProfileSharingEnabled() ? "1" : "0"); values.put(BLOCKED, contact.isBlocked() ? "1" : "0"); values.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(contact.getKey())); @@ -523,6 +593,17 @@ public class RecipientDatabase extends Database { return values; } + private static @NonNull ContentValues getValuesForStorageGroupV1(@NonNull SignalGroupV1Record groupV1) { + ContentValues values = new ContentValues(); + values.put(GROUP_ID, GroupUtil.getEncodedId(groupV1.getGroupId(), false)); + 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(DIRTY, DirtyState.CLEAN.getId()); + return values; + } + private List getRecipientSettings(@Nullable String query, @Nullable String[] args) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); String table = TABLE_NAME + " LEFT OUTER JOIN " + IdentityDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + ID + " = " + IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.RECIPIENT_ID; @@ -555,27 +636,24 @@ public class RecipientDatabase extends Database { try (Cursor cursor = db.query(TABLE_NAME, new String[] { ID, STORAGE_SERVICE_KEY }, query, args, null, null, null)) { while (cursor != null && cursor.moveToNext()) { - RecipientId id = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID))); + RecipientId id = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID))); String encodedKey = cursor.getString(cursor.getColumnIndexOrThrow(STORAGE_SERVICE_KEY)); - try { - out.put(id, Base64.decode(encodedKey)); - } catch (IOException e) { - throw new AssertionError(e); - } + out.put(id, Base64.decodeOrThrow(encodedKey)); } } return out; } - @NonNull RecipientSettings getRecipientSettings(@NonNull Cursor cursor) { + private @NonNull RecipientSettings getRecipientSettings(@NonNull Cursor cursor) { long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); UUID uuid = UuidUtil.parseOrNull(cursor.getString(cursor.getColumnIndexOrThrow(UUID))); String username = cursor.getString(cursor.getColumnIndexOrThrow(USERNAME)); String e164 = cursor.getString(cursor.getColumnIndexOrThrow(PHONE)); String email = cursor.getString(cursor.getColumnIndexOrThrow(EMAIL)); String groupId = cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID)); + int groupType = cursor.getInt(cursor.getColumnIndexOrThrow(GROUP_TYPE)); boolean blocked = cursor.getInt(cursor.getColumnIndexOrThrow(BLOCKED)) == 1; String messageRingtone = cursor.getString(cursor.getColumnIndexOrThrow(MESSAGE_RINGTONE)); String callRingtone = cursor.getString(cursor.getColumnIndexOrThrow(CALL_RINGTONE)); @@ -634,23 +712,12 @@ public class RecipientDatabase extends Database { } } - byte[] storageKey = null; - try { - storageKey = storageKeyRaw != null ? Base64.decode(storageKeyRaw) : null; - } catch (IOException e) { - throw new AssertionError(e); - } - - byte[] identityKey = null; - try { - identityKey = identityKeyRaw != null ? Base64.decode(identityKeyRaw) : null; - } catch (IOException e) { - throw new AssertionError(e); - } + byte[] storageKey = storageKeyRaw != null ? Base64.decodeOrThrow(storageKeyRaw) : null; + byte[] identityKey = identityKeyRaw != null ? Base64.decodeOrThrow(identityKeyRaw) : null; IdentityDatabase.VerifiedStatus identityStatus = IdentityDatabase.VerifiedStatus.forState(identityStatusRaw); - return new RecipientSettings(RecipientId.from(id), uuid, username, e164, email, groupId, blocked, muteUntil, + return new RecipientSettings(RecipientId.from(id), uuid, username, e164, email, groupId, GroupType.fromId(groupType), blocked, muteUntil, VibrateState.fromId(messageVibrateState), VibrateState.fromId(callVibrateState), Util.uri(messageRingtone), Util.uri(callRingtone), @@ -820,6 +887,7 @@ public class RecipientDatabase extends Database { if (update(updateQuery, valuesToSet)) { markDirty(id, DirtyState.UPDATE); Recipient.live(id).refresh(); + ApplicationDependencies.getJobManager().add(new StorageSyncJob()); return true; } else { return false; @@ -865,6 +933,7 @@ public class RecipientDatabase extends Database { if (update(id, contentValues)) { markDirty(id, DirtyState.UPDATE); Recipient.live(id).refresh(); + ApplicationDependencies.getJobManager().add(new StorageSyncJob()); } } @@ -882,6 +951,7 @@ public class RecipientDatabase extends Database { if (update(id, contentValues)) { markDirty(id, DirtyState.UPDATE); Recipient.live(id).refresh(); + ApplicationDependencies.getJobManager().add(new StorageSyncJob()); } } @@ -947,7 +1017,6 @@ public class RecipientDatabase extends Database { ContentValues contentValues = new ContentValues(3); contentValues.put(REGISTERED, RegisteredState.REGISTERED.getId()); contentValues.put(UUID, uuid.toString().toLowerCase()); - contentValues.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(StorageSyncHelper.generateKey())); if (update(id, contentValues)) { markDirty(id, DirtyState.INSERT); Recipient.live(id).refresh(); @@ -962,7 +1031,6 @@ public class RecipientDatabase extends Database { public void markRegistered(@NonNull RecipientId id) { ContentValues contentValues = new ContentValues(2); contentValues.put(REGISTERED, RegisteredState.REGISTERED.getId()); - contentValues.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(StorageSyncHelper.generateKey())); if (update(id, contentValues)) { markDirty(id, DirtyState.INSERT); Recipient.live(id).refresh(); @@ -1010,10 +1078,22 @@ public class RecipientDatabase extends Database { @Deprecated public void setRegistered(@NonNull RecipientId id, RegisteredState registeredState) { - ContentValues contentValues = new ContentValues(1); + ContentValues contentValues = new ContentValues(2); contentValues.put(REGISTERED, registeredState.getId()); - update(id, contentValues); - Recipient.live(id).refresh(); + + if (registeredState == RegisteredState.REGISTERED) { + contentValues.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(StorageSyncHelper.generateKey())); + } + + if (update(id, contentValues)) { + if (registeredState == RegisteredState.REGISTERED) { + markDirty(id, DirtyState.INSERT); + } else if (registeredState == RegisteredState.NOT_REGISTERED) { + markDirty(id, DirtyState.DELETE); + } + + Recipient.live(id).refresh(); + } } @Deprecated @@ -1021,10 +1101,11 @@ public class RecipientDatabase extends Database { @NonNull Collection inactiveIds) { for (RecipientId activeId : activeIds) { - ContentValues contentValues = new ContentValues(1); - contentValues.put(REGISTERED, RegisteredState.REGISTERED.getId()); + ContentValues registeredValues = new ContentValues(1); + registeredValues.put(REGISTERED, RegisteredState.REGISTERED.getId()); - if (update(activeId, contentValues)) { + if (update(activeId, registeredValues)) { + markDirty(activeId, DirtyState.INSERT); Recipient.live(activeId).refresh(); } } @@ -1034,6 +1115,7 @@ public class RecipientDatabase extends Database { contentValues.put(REGISTERED, RegisteredState.NOT_REGISTERED.getId()); if (update(inactiveId, contentValues)) { + markDirty(inactiveId, DirtyState.DELETE); Recipient.live(inactiveId).refresh(); } } @@ -1282,14 +1364,27 @@ public class RecipientDatabase extends Database { } void markDirty(@NonNull RecipientId recipientId, @NonNull DirtyState dirtyState) { - if (!FeatureFlags.storageService()) return; - ContentValues contentValues = new ContentValues(1); contentValues.put(DIRTY, dirtyState.getId()); - String query = ID + " = ? AND (" + UUID + " NOT NULL OR " + PHONE + " NOT NULL) AND " + DIRTY + " < ?"; + String query = ID + " = ? AND (" + UUID + " NOT NULL OR " + PHONE + " NOT NULL) AND "; String[] args = new String[] { recipientId.serialize(), String.valueOf(dirtyState.id) }; + switch (dirtyState) { + case INSERT: + query += "(" + DIRTY + " < ? OR " + DIRTY + " = ?)"; + args = SqlUtil.appendArg(args, String.valueOf(DirtyState.DELETE.getId())); + + contentValues.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(StorageSyncHelper.generateKey())); + break; + case DELETE: + query += "(" + DIRTY + " < ? OR " + DIRTY + " = ?)"; + args = SqlUtil.appendArg(args, String.valueOf(DirtyState.INSERT.getId())); + break; + default: + query += DIRTY + " < ?"; + } + databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, query, args); } @@ -1330,7 +1425,7 @@ public class RecipientDatabase extends Database { } } - private @NonNull RecipientId getOrInsertByColumn(@NonNull String column, String value) { + private @NonNull GetOrInsertResult getOrInsertByColumn(@NonNull String column, String value) { if (TextUtils.isEmpty(value)) { throw new AssertionError(column + " cannot be empty."); } @@ -1338,7 +1433,7 @@ public class RecipientDatabase extends Database { Optional existing = getByColumn(column, value); if (existing.isPresent()) { - return existing.get(); + return new GetOrInsertResult(existing.get(), false); } else { ContentValues values = new ContentValues(); values.put(column, value); @@ -1349,12 +1444,12 @@ public class RecipientDatabase extends Database { existing = getByColumn(column, value); if (existing.isPresent()) { - return existing.get(); + return new GetOrInsertResult(existing.get(), false); } else { throw new AssertionError("Failed to insert recipient!"); } } else { - return RecipientId.from(id); + return new GetOrInsertResult(RecipientId.from(id), true); } } } @@ -1445,6 +1540,7 @@ public class RecipientDatabase extends Database { private final String e164; private final String email; private final String groupId; + private final GroupType groupType; private final boolean blocked; private final long muteUntil; private final VibrateState messageVibrateState; @@ -1479,7 +1575,9 @@ public class RecipientDatabase extends Database { @Nullable String e164, @Nullable String email, @Nullable String groupId, - boolean blocked, long muteUntil, + @NonNull GroupType groupType, + boolean blocked, + long muteUntil, @NonNull VibrateState messageVibrateState, @NonNull VibrateState callVibrateState, @Nullable Uri messageRingtone, @@ -1512,6 +1610,7 @@ public class RecipientDatabase extends Database { this.e164 = e164; this.email = email; this.groupId = groupId; + this.groupType = groupType; this.blocked = blocked; this.muteUntil = muteUntil; this.messageVibrateState = messageVibrateState; @@ -1565,6 +1664,10 @@ public class RecipientDatabase extends Database { return groupId; } + public @NonNull GroupType getGroupType() { + return groupType; + } + public @Nullable MaterialColor getColor() { return color; } @@ -1720,4 +1823,14 @@ public class RecipientDatabase extends Database { super("Failed to find recipient with ID: " + id); } } + + private static class GetOrInsertResult { + final RecipientId recipientId; + final boolean neededInsert; + + private GetOrInsertResult(@NonNull RecipientId recipientId, boolean neededInsert) { + this.recipientId = recipientId; + this.neededInsert = neededInsert; + } + } } 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 7fe80af4df..b377ca08db 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,6 +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.crypto.DatabaseSecret; import org.thoughtcrime.securesms.crypto.MasterSecret; import org.thoughtcrime.securesms.database.AttachmentDatabase; @@ -111,8 +112,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int PROFILE_KEY_TO_DB = 47; private static final int PROFILE_KEY_CREDENTIALS = 48; private static final int ATTACHMENT_FILE_INDEX = 49; + private static final int STORAGE_SERVICE_ACTIVE = 50; - private static final int DATABASE_VERSION = 49; + private static final int DATABASE_VERSION = 50; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -673,20 +675,6 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL("CREATE UNIQUE INDEX recipient_storage_service_key ON recipient (storage_service_key)"); db.execSQL("CREATE INDEX recipient_dirty_index ON recipient (dirty)"); - - // TODO [greyson] Do this in a future DB migration -// db.execSQL("UPDATE recipient SET dirty = 2 WHERE registered = 1"); -// -// try (Cursor cursor = db.rawQuery("SELECT _id FROM recipient WHERE registered = 1", null)) { -// while (cursor != null && cursor.moveToNext()) { -// String id = cursor.getString(cursor.getColumnIndexOrThrow("_id")); -// ContentValues values = new ContentValues(1); -// -// values.put("storage_service_key", Base64.encodeBytes(StorageSyncHelper.generateKey())); -// -// db.update("recipient", values, "_id = ?", new String[] { id }); -// } -// } } if (oldVersion < REACTIONS_UNREAD_INDEX) { @@ -753,6 +741,26 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL("CREATE INDEX IF NOT EXISTS part_data_index ON part (_data)"); } + if (oldVersion < STORAGE_SERVICE_ACTIVE) { + db.execSQL("ALTER TABLE recipient ADD COLUMN group_type INTEGER DEFAULT 0"); + db.execSQL("CREATE INDEX IF NOT EXISTS recipient_group_type_index ON recipient (group_type)"); + + db.execSQL("UPDATE recipient set group_type = 1 WHERE group_id NOT NULL AND group_id LIKE '__signal_mms_group__%'"); + db.execSQL("UPDATE recipient set group_type = 2 WHERE group_id NOT NULL AND group_id LIKE '__textsecure_group__%'"); + + try (Cursor cursor = db.rawQuery("SELECT _id FROM recipient WHERE registered = 1 or group_type = 2", null)) { + while (cursor != null && cursor.moveToNext()) { + String id = cursor.getString(cursor.getColumnIndexOrThrow("_id")); + ContentValues values = new ContentValues(1); + + values.put("dirty", 2); + values.put("storage_service_key", Base64.encodeBytes(StorageSyncHelper.generateKey())); + + db.update("recipient", values, "_id = ?", new String[] { id }); + } + } + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java index 8425e635fc..47ef724243 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java @@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.jobmanager.persistence.JobStorage; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.util.Debouncer; import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.whispersystems.libsignal.util.guava.Optional; import java.util.ArrayList; import java.util.Collection; @@ -23,9 +24,12 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; /** * Allows the scheduling of durable jobs that will be run as early as possible. @@ -159,6 +163,43 @@ public class JobManager implements ConstraintObserver.Notifier { executor.execute(() -> jobController.cancelJob(id)); } + /** + * Runs the specified job synchronously. Beware: All normal dependencies are respected, meaning + * you must take great care where you call this. It could take a very long time to complete! + * + * @return If the job completed, this will contain its completion state. If it timed out or + * otherwise didn't complete, this will be absent. + */ + @WorkerThread + public Optional runSynchronously(@NonNull Job job, long timeout) { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference resultState = new AtomicReference<>(); + + addListener(job.getId(), new JobTracker.JobListener() { + @Override + public void onStateChanged(@NonNull JobTracker.JobState jobState) { + if (jobState.isComplete()) { + removeListener(this); + resultState.set(jobState); + latch.countDown(); + } + } + }); + + add(job); + + try { + if (!latch.await(timeout, TimeUnit.MILLISECONDS)) { + return Optional.absent(); + } + } catch (InterruptedException e) { + Log.w(TAG, "Interrupted during runSynchronously()", e); + return Optional.absent(); + } + + return Optional.fromNullable(resultState.get()); + } + /** * Retrieves a string representing the state of the job queue. Intended for debugging. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index b4e30a6c35..b03c0a5693 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.migrations.RecipientSearchMigrationJob; import org.thoughtcrime.securesms.migrations.RegistrationPinV2MigrationJob; import org.thoughtcrime.securesms.migrations.StickerAdditionMigrationJob; import org.thoughtcrime.securesms.migrations.StickerLaunchMigrationJob; +import org.thoughtcrime.securesms.migrations.StorageServiceMigrationJob; import org.thoughtcrime.securesms.migrations.UuidMigrationJob; import java.util.Arrays; @@ -116,6 +117,7 @@ public final class JobManagerFactories { put(RegistrationPinV2MigrationJob.KEY, new RegistrationPinV2MigrationJob.Factory()); put(StickerLaunchMigrationJob.KEY, new StickerLaunchMigrationJob.Factory()); put(StickerAdditionMigrationJob.KEY, new StickerAdditionMigrationJob.Factory()); + put(StorageServiceMigrationJob.KEY, new StorageServiceMigrationJob.Factory()); put(UuidMigrationJob.KEY, new UuidMigrationJob.Factory()); // Dead jobs diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceKeysUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceKeysUpdateJob.java index e1b370d88b..0522d18919 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceKeysUpdateJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceKeysUpdateJob.java @@ -15,6 +15,7 @@ import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.SignalServiceMessageSender; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; import org.whispersystems.signalservice.api.kbs.MasterKey; +import org.whispersystems.signalservice.api.storage.StorageKey; import org.whispersystems.signalservice.api.messages.multidevice.KeysMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; @@ -58,15 +59,8 @@ public class MultiDeviceKeysUpdateJob extends BaseJob { return; } - SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); - - MasterKey masterKey = SignalStore.kbsValues().getPinBackedMasterKey(); - byte[] storageServiceKey = masterKey != null ? masterKey.deriveStorageServiceKey() - : null; - - if (storageServiceKey == null) { - Log.w(TAG, "Syncing a null storage service key."); - } + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + StorageKey storageServiceKey = SignalStore.storageServiceValues().getOrCreateStorageMasterKey().deriveStorageServiceKey(); messageSender.sendMessage(SignalServiceSyncMessage.forKeys(new KeysMessage(Optional.fromNullable(storageServiceKey))), UnidentifiedAccessUtil.getAccessForSync(context)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java index 9feb76b5a5..2a13171ac0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java @@ -654,10 +654,17 @@ public final class PushProcessMessageJob extends BaseJob { } private static void handleSynchronizeFetchMessage(@NonNull SignalServiceSyncMessage.FetchType fetchType) { - if (fetchType == SignalServiceSyncMessage.FetchType.LOCAL_PROFILE) { - ApplicationDependencies.getJobManager().add(new RefreshOwnProfileJob()); - } else { - Log.w(TAG, "Received a fetch message for an unknown type."); + Log.i(TAG, "Received fetch request with type: " + fetchType); + + switch (fetchType) { + case LOCAL_PROFILE: + ApplicationDependencies.getJobManager().add(new RefreshOwnProfileJob()); + break; + case STORAGE_MANIFEST: + ApplicationDependencies.getJobManager().add(new StorageSyncJob()); + break; + default: + Log.w(TAG, "Received a fetch message for an unknown type."); } } @@ -777,7 +784,7 @@ public final class PushProcessMessageJob extends BaseJob { } if (message.isKeysRequest()) { -// ApplicationDependencies.getJobManager().add(new ); + ApplicationDependencies.getJobManager().add(new MultiDeviceKeysUpdateJob()); } } 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 d89afa53bb..8e0f0f794b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.java @@ -16,12 +16,12 @@ 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.StorageKey; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; import org.whispersystems.signalservice.api.storage.SignalStorageManifest; import org.whispersystems.signalservice.api.storage.SignalStorageRecord; @@ -30,12 +30,14 @@ import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.concurrent.TimeUnit; /** - * Forces remote storage to match our local state. This should only be done after a key change or - * when we detect that the remote data is badly-encrypted. + * Forces remote storage to match our local state. This should only be done when we detect that the + * remote data is badly-encrypted (which should only happen after re-registering without a PIN). */ public class StorageForcePushJob extends BaseJob { @@ -45,10 +47,10 @@ public class StorageForcePushJob extends BaseJob { public StorageForcePushJob() { this(new Parameters.Builder().addConstraint(NetworkConstraint.KEY) - .setQueue(StorageSyncJob.QUEUE_KEY) - .setMaxInstances(1) - .setLifespan(TimeUnit.DAYS.toMillis(1)) - .build()); + .setQueue(StorageSyncJob.QUEUE_KEY) + .setMaxInstances(1) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .build()); } private StorageForcePushJob(@NonNull Parameters parameters) { @@ -67,45 +69,42 @@ public class StorageForcePushJob extends BaseJob { @Override protected void onRun() throws IOException, RetryLaterException { - if (!FeatureFlags.storageService()) throw new AssertionError(); - - MasterKey kbsMasterKey = SignalStore.kbsValues().getPinBackedMasterKey(); - - if (kbsMasterKey == null) { - Log.w(TAG, "No KBS master key is set! Must abort."); - return; - } - - byte[] storageServiceKey = kbsMasterKey.deriveStorageServiceKey(); + StorageKey storageServiceKey = SignalStore.storageServiceValues().getOrCreateStorageMasterKey().deriveStorageServiceKey(); SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context); long currentVersion = accountManager.getStorageManifestVersion(); - Map oldContactKeys = recipientDatabase.getAllStorageSyncKeysMap(); - List oldUnknownKeys = storageKeyDatabase.getAllKeys(); + Map oldStorageKeys = recipientDatabase.getAllStorageSyncKeysMap(); - long newVersion = currentVersion + 1; - Map newContactKeys = generateNewKeys(oldContactKeys); - List keysToDelete = Util.concatenatedList(new ArrayList<>(oldContactKeys.values()), oldUnknownKeys); - List inserts = Stream.of(oldContactKeys.keySet()) - .map(recipientDatabase::getRecipientSettings) - .withoutNulls() - .map(StorageSyncHelper::localToRemoteContact) - .map(r -> SignalStorageRecord.forContact(r.getKey(), r)) - .toList(); - - SignalStorageManifest manifest = new SignalStorageManifest(newVersion, new ArrayList<>(newContactKeys.values())); - - try { - accountManager.writeStorageRecords(storageServiceKey, manifest, inserts, keysToDelete); - } catch (InvalidKeyException e) { - Log.w(TAG, "Hit an invalid key exception, which likely indicates a conflict."); - throw new RetryLaterException(); + if (currentVersion < 1) { + throw new IllegalStateException("We should never be force-pushing a manifest as the first version!"); } + 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())))) + .toList(); + + SignalStorageManifest manifest = new SignalStorageManifest(newVersion, new ArrayList<>(newStorageKeys.values())); + + try { + Log.i(TAG, String.format(Locale.ENGLISH, "Force-pushing data. Inserting %d keys.", inserts.size())); + if (accountManager.resetStorageRecords(storageServiceKey, manifest, inserts).isPresent()) { + Log.w(TAG, "Hit a conflict. Trying again."); + throw new RetryLaterException(); + } + } catch (InvalidKeyException e) { + Log.w(TAG, "Hit an invalid key exception, which likely indicates a conflict."); + throw new RetryLaterException(e); + } + + Log.i(TAG, "Force push succeeded. Updating local manifest version to: " + newVersion); TextSecurePreferences.setStorageManifestVersion(context, newVersion); - recipientDatabase.applyStorageSyncKeyUpdates(newContactKeys); + recipientDatabase.applyStorageSyncKeyUpdates(newStorageKeys); storageKeyDatabase.deleteAll(); } @@ -129,10 +128,8 @@ public class StorageForcePushJob extends BaseJob { } public static final class Factory implements Job.Factory { - @Override - public @NonNull - StorageForcePushJob create(@NonNull Parameters parameters, @NonNull Data data) { + public @NonNull StorageForcePushJob create(@NonNull Parameters parameters, @NonNull Data data) { return new StorageForcePushJob(parameters); } } 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 f047ee6f7a..baeb03a354 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java @@ -29,9 +29,8 @@ 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.kbs.MasterKey; +import org.whispersystems.signalservice.api.storage.StorageKey; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; -import org.whispersystems.signalservice.api.storage.SignalContactRecord; import org.whispersystems.signalservice.api.storage.SignalStorageManifest; import org.whispersystems.signalservice.api.storage.SignalStorageRecord; @@ -39,6 +38,7 @@ 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; /** @@ -55,6 +55,8 @@ public class StorageSyncJob extends BaseJob { private static final String TAG = Log.tag(StorageSyncJob.class); + private static final long REFRESH_INTERVAL = TimeUnit.HOURS.toMillis(2); + public StorageSyncJob() { this(new Job.Parameters.Builder().addConstraint(NetworkConstraint.KEY) .setQueue(QUEUE_KEY) @@ -63,6 +65,17 @@ public class StorageSyncJob extends BaseJob { .build()); } + public static void scheduleIfNecessary() { + long timeSinceLastSync = System.currentTimeMillis() - SignalStore.storageServiceValues().getLastSyncTime(); + + if (timeSinceLastSync > REFRESH_INTERVAL) { + Log.d(TAG, "Scheduling a sync. Last sync was " + timeSinceLastSync + " ms ago."); + ApplicationDependencies.getJobManager().add(new StorageSyncJob()); + } else { + Log.d(TAG, "No need for sync. Last sync was " + timeSinceLastSync + " ms ago."); + } + } + private StorageSyncJob(@NonNull Parameters parameters) { super(parameters); } @@ -79,7 +92,7 @@ public class StorageSyncJob extends BaseJob { @Override protected void onRun() throws IOException, RetryLaterException { - if (!FeatureFlags.storageService()) throw new AssertionError(); + if (!FeatureFlags.storageService()) return; try { boolean needsMultiDeviceSync = performSync(); @@ -87,6 +100,8 @@ public class StorageSyncJob extends BaseJob { if (TextSecurePreferences.isMultiDevice(context) && needsMultiDeviceSync) { ApplicationDependencies.getJobManager().add(new MultiDeviceStorageSyncRequestJob()); } + + SignalStore.storageServiceValues().onSyncCompleted(); } catch (InvalidKeyException e) { Log.w(TAG, "Failed to decrypt remote storage! Force-pushing and syncing the storage key to linked devices.", e); @@ -94,6 +109,11 @@ public class StorageSyncJob extends BaseJob { .then(new StorageForcePushJob()) .then(new MultiDeviceStorageSyncRequestJob()) .enqueue(); + } finally { + if (!SignalStore.storageServiceValues().hasFirstStorageSyncCompleted()) { + SignalStore.storageServiceValues().setFirstStorageSyncCompleted(true); + ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(false)); + } } } @@ -110,47 +130,62 @@ public class StorageSyncJob extends BaseJob { SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager(); RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context); - MasterKey kbsMasterKey = SignalStore.kbsValues().getPinBackedMasterKey(); + StorageKey storageServiceKey = SignalStore.storageServiceValues().getOrCreateStorageMasterKey().deriveStorageServiceKey(); - if (kbsMasterKey == null) { - Log.w(TAG, "No KBS master key is set! Must abort."); - return false; - } + boolean needsMultiDeviceSync = false; + long localManifestVersion = TextSecurePreferences.getStorageManifestVersion(context); + Optional remoteManifest = accountManager.getStorageManifestIfDifferentVersion(storageServiceKey, localManifestVersion); + long remoteManifestVersion = remoteManifest.transform(SignalStorageManifest::getVersion).or(localManifestVersion); - byte[] storageServiceKey = kbsMasterKey.deriveStorageServiceKey(); - boolean needsMultiDeviceSync = false; - long localManifestVersion = TextSecurePreferences.getStorageManifestVersion(context); - SignalStorageManifest remoteManifest = accountManager.getStorageManifest(storageServiceKey).or(new SignalStorageManifest(0, Collections.emptyList())); + Log.i(TAG, "Our version: " + localManifestVersion + ", their version: " + remoteManifestVersion); - if (remoteManifest.getVersion() > localManifestVersion) { - Log.i(TAG, "Newer manifest version found! Our version: " + localManifestVersion + ", their version: " + remoteManifest.getVersion()); + if (remoteManifest.isPresent() && remoteManifestVersion > localManifestVersion) { + Log.i(TAG, "[Remote Newer] Newer manifest version found!"); List allLocalStorageKeys = getAllLocalStorageKeys(context); - KeyDifferenceResult keyDifference = StorageSyncHelper.findKeyDifference(remoteManifest.getStorageKeys(), allLocalStorageKeys); + KeyDifferenceResult keyDifference = StorageSyncHelper.findKeyDifference(remoteManifest.get().getStorageKeys(), 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()); + List localOnly = buildLocalStorageRecords(context, keyDifference.getLocalOnlyKeys()); List remoteOnly = accountManager.readStorageRecords(storageServiceKey, keyDifference.getRemoteOnlyKeys()); MergeResult mergeResult = StorageSyncHelper.resolveConflict(remoteOnly, localOnly); - WriteOperationResult writeOperationResult = StorageSyncHelper.createWriteOperation(remoteManifest.getVersion(), allLocalStorageKeys, mergeResult); + WriteOperationResult writeOperationResult = StorageSyncHelper.createWriteOperation(remoteManifest.get().getVersion(), allLocalStorageKeys, mergeResult); - Optional conflict = accountManager.writeStorageRecords(storageServiceKey, writeOperationResult.getManifest(), writeOperationResult.getInserts(), writeOperationResult.getDeletes()); + Log.i(TAG, "[Remote Newer] MergeResult :: " + mergeResult); - if (conflict.isPresent()) { - Log.w(TAG, "Hit a conflict when trying to resolve the conflict! Retrying."); - throw new RetryLaterException(); + if (!writeOperationResult.isEmpty()) { + 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()) { + 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())); + } + + Optional conflict = accountManager.writeStorageRecords(storageServiceKey, writeOperationResult.getManifest(), writeOperationResult.getInserts(), writeOperationResult.getDeletes()); + + if (conflict.isPresent()) { + Log.w(TAG, "[Remote Newer] Hit a conflict when trying to resolve the conflict! Retrying."); + throw new RetryLaterException(); + } + + remoteManifestVersion = writeOperationResult.getManifest().getVersion(); + } else { + Log.i(TAG, "[Remote Newer] After resolving the conflict, all changes are local. No remote writes needed."); } - recipientDatabase.applyStorageSyncUpdates(mergeResult.getLocalContactInserts(), mergeResult.getLocalContactUpdates()); + recipientDatabase.applyStorageSyncUpdates(mergeResult.getLocalContactInserts(), mergeResult.getLocalContactUpdates(), mergeResult.getLocalGroupV1Inserts(), mergeResult.getLocalGroupV1Updates()); storageKeyDatabase.applyStorageSyncUpdates(mergeResult.getLocalUnknownInserts(), mergeResult.getLocalUnknownDeletes()); needsMultiDeviceSync = true; - Log.i(TAG, "[Post-Conflict] Updating local manifest version to: " + writeOperationResult.getManifest().getVersion()); - TextSecurePreferences.setStorageManifestVersion(context, writeOperationResult.getManifest().getVersion()); + Log.i(TAG, "[Remote Newer] Updating local manifest version to: " + remoteManifestVersion); + TextSecurePreferences.setStorageManifestVersion(context, remoteManifestVersion); } else { - Log.i(TAG, "Remote version was newer, but our local data matched."); - Log.i(TAG, "[Post-Empty-Conflict] Updating local manifest version to: " + remoteManifest.getVersion()); - TextSecurePreferences.setStorageManifestVersion(context, remoteManifest.getVersion()); + Log.i(TAG, "[Remote Newer] Remote version was newer, but our local data matched."); + Log.i(TAG, "[Remote Newer] Updating local manifest version to: " + remoteManifest.get().getVersion()); + TextSecurePreferences.setStorageManifestVersion(context, remoteManifest.get().getVersion()); } } @@ -167,11 +202,20 @@ public class StorageSyncJob extends BaseJob { pendingDeletions); if (localWriteResult.isPresent()) { - WriteOperationResult localWrite = localWriteResult.get().getWriteResult(); + Log.i(TAG, String.format(Locale.ENGLISH, "[Local Changes] Local changes present. %d updates, %d inserts, %d deletes.", pendingUpdates.size(), pendingInsertions.size(), pendingDeletions.size())); + + WriteOperationResult localWrite = localWriteResult.get().getWriteResult(); + + Log.i(TAG, "[Local Changes] WriteOperationResult :: " + localWrite); + + if (localWrite.isEmpty()) { + throw new AssertionError("Decided there were local writes, but our write result was empty!"); + } + Optional conflict = accountManager.writeStorageRecords(storageServiceKey, localWrite.getManifest(), localWrite.getInserts(), localWrite.getDeletes()); if (conflict.isPresent()) { - Log.w(TAG, "Hit a conflict when trying to upload our local writes! Retrying."); + Log.w(TAG, "[Local Changes] Hit a conflict when trying to upload our local writes! Retrying."); throw new RetryLaterException(); } @@ -186,21 +230,21 @@ public class StorageSyncJob extends BaseJob { needsMultiDeviceSync = true; - Log.i(TAG, "[Post Write] Updating local manifest version to: " + localWriteResult.get().getWriteResult().getManifest().getVersion()); + Log.i(TAG, "[Local Changes] Updating local manifest version to: " + localWriteResult.get().getWriteResult().getManifest().getVersion()); TextSecurePreferences.setStorageManifestVersion(context, localWriteResult.get().getWriteResult().getManifest().getVersion()); } else { - Log.i(TAG, "Nothing locally to write."); + Log.i(TAG, "[Local Changes] No local changes."); } return needsMultiDeviceSync; } - public 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()); } - public static @NonNull List buildLocalStorageRecords(@NonNull Context context, @NonNull List keys) { + private static @NonNull List buildLocalStorageRecords(@NonNull Context context, @NonNull List keys) { RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context); @@ -208,10 +252,7 @@ public class StorageSyncJob extends BaseJob { for (byte[] key : keys) { SignalStorageRecord record = Optional.fromNullable(recipientDatabase.getByStorageSyncKey(key)) - .transform(recipient -> { - SignalContactRecord contact = StorageSyncHelper.localToRemoteContact(recipient); - return SignalStorageRecord.forContact(key, contact); - }) + .transform(StorageSyncHelper::localToRemoteRecord) .or(() -> storageKeyDatabase.getByKey(key)); records.add(record); } @@ -220,7 +261,6 @@ public class StorageSyncJob extends BaseJob { } public static final class Factory implements Job.Factory { - @Override public @NonNull StorageSyncJob create(@NonNull Parameters parameters, @NonNull Data data) { return new StorageSyncJob(parameters); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java index 045fdcf1b6..78656b7dfd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java @@ -14,7 +14,7 @@ public final class RegistrationValues { this.store = store; } - public synchronized void onNewInstall() { + public synchronized void onFirstEverAppLaunch() { store.beginWrite() .putBoolean(REGISTRATION_COMPLETE, false) // TODO [greyson] [pins] Maybe re-enable in the future @@ -23,7 +23,7 @@ public final class RegistrationValues { } public synchronized void clearRegistrationComplete() { - onNewInstall(); + onFirstEverAppLaunch(); } public synchronized void setRegistrationComplete() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java index cb526e3a9e..603f1dd7ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java @@ -4,6 +4,7 @@ import androidx.annotation.NonNull; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.logging.SignalUncaughtExceptionHandler; +import org.thoughtcrime.securesms.util.FeatureFlags; /** * Simple, encrypted key-value store. @@ -15,6 +16,11 @@ public final class SignalStore { private SignalStore() {} + public static void onFirstEverAppLaunch() { + registrationValues().onFirstEverAppLaunch(); + storageServiceValues().setFirstStorageSyncCompleted(false); + } + public static @NonNull KbsValues kbsValues() { return new KbsValues(getStore()); } @@ -31,6 +37,10 @@ public final class SignalStore { return new RemoteConfigValues(getStore()); } + public static @NonNull StorageServiceValues storageServiceValues() { + return new StorageServiceValues(getStore()); + } + public static long getLastPrekeyRefreshTime() { return getStore().getLong(LAST_PREKEY_REFRESH_TIME, 0); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.java new file mode 100644 index 0000000000..a7f083a34a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.whispersystems.signalservice.api.kbs.MasterKey; + +import java.security.SecureRandom; + +public class StorageServiceValues { + + private static final String STORAGE_MASTER_KEY = "storage.storage_master_key"; + private static final String FIRST_STORAGE_SYNC_COMPLETED = "storage.first_storage_sync_completed"; + private static final String LAST_SYNC_TIME = "storage.last_sync_time"; + + private final KeyValueStore store; + + StorageServiceValues(@NonNull KeyValueStore store) { + this.store = store; + } + + public synchronized MasterKey getOrCreateStorageMasterKey() { + byte[] blob = store.getBlob(STORAGE_MASTER_KEY, null); + + if (blob == null) { + store.beginWrite() + .putBlob(STORAGE_MASTER_KEY, MasterKey.createNew(new SecureRandom()).serialize()) + .commit(); + blob = store.getBlob(STORAGE_MASTER_KEY, null); + } + + return new MasterKey(blob); + } + + public boolean hasFirstStorageSyncCompleted() { + return !FeatureFlags.storageServiceRestore() || store.getBoolean(FIRST_STORAGE_SYNC_COMPLETED, true); + } + + public void setFirstStorageSyncCompleted(boolean completed) { + store.beginWrite().putBoolean(FIRST_STORAGE_SYNC_COMPLETED, completed).apply(); + } + + public long getLastSyncTime() { + return store.getLong(LAST_SYNC_TIME, 0); + } + + public void onSyncCompleted() { + store.beginWrite().putLong(LAST_SYNC_TIME, System.currentTimeMillis()).apply(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java index a037a5aef3..434ec595f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -40,7 +40,7 @@ public class ApplicationMigrations { private static final int LEGACY_CANONICAL_VERSION = 455; - public static final int CURRENT_VERSION = 10; + public static final int CURRENT_VERSION = 11; private static final class Version { static final int LEGACY = 1; @@ -53,6 +53,7 @@ public class ApplicationMigrations { static final int STICKERS_LAUNCH = 8; static final int TEST_ARGON2 = 9; static final int SWOON_STICKERS = 10; + static final int STORAGE_SERVICE = 11; } /** @@ -205,6 +206,10 @@ public class ApplicationMigrations { jobs.put(Version.SWOON_STICKERS, new StickerAdditionMigrationJob(BlessedPacks.SWOON_HANDS, BlessedPacks.SWOON_FACES)); } + if (lastSeenVersion < Version.STORAGE_SERVICE) { + jobs.put(Version.STORAGE_SERVICE, new StorageServiceMigrationJob()); + } + return jobs; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/StorageServiceMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/StorageServiceMigrationJob.java new file mode 100644 index 0000000000..27b42ba7ba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/StorageServiceMigrationJob.java @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.migrations; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.MultiDeviceKeysUpdateJob; +import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob; +import org.thoughtcrime.securesms.jobs.StorageSyncJob; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.stickers.BlessedPacks; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.util.Arrays; +import java.util.List; + +public class StorageServiceMigrationJob extends MigrationJob { + + private static final String TAG = Log.tag(StorageServiceMigrationJob.class); + + public static final String KEY = "StorageServiceMigrationJob"; + + StorageServiceMigrationJob() { + this(new Parameters.Builder().build()); + } + + private StorageServiceMigrationJob(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public boolean isUiBlocking() { + return false; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void performMigration() { + JobManager jobManager = ApplicationDependencies.getJobManager(); + + if (TextSecurePreferences.isMultiDevice(context)) { + Log.i(TAG, "Multi-device."); + jobManager.startChain(new MultiDeviceKeysUpdateJob()) + .then(new StorageSyncJob()) + .enqueue(); + } else { + Log.i(TAG, "Single-device."); + jobManager.add(new StorageSyncJob()); + } + } + + @Override + boolean shouldRetry(@NonNull Exception e) { + return false; + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull StorageServiceMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new StorageServiceMigrationJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileName.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileName.java index 280f1b0632..6d98106a46 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileName.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/ProfileName.java @@ -39,8 +39,7 @@ public final class ProfileName implements Parcelable { return givenName; } - public @NonNull - String getFamilyName() { + public @NonNull String getFamilyName() { return familyName; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java index d1f0a61aaf..18833b73de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob; import org.thoughtcrime.securesms.jobs.MultiDeviceMessageRequestResponseJob; import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.jobs.StorageSyncJob; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.mms.OutgoingGroupMediaMessage; @@ -86,6 +87,7 @@ public class RecipientUtil { } ApplicationDependencies.getJobManager().add(new MultiDeviceBlockedUpdateJob()); + ApplicationDependencies.getJobManager().add(new StorageSyncJob()); } @WorkerThread @@ -96,6 +98,7 @@ public class RecipientUtil { DatabaseFactory.getRecipientDatabase(context).setBlocked(recipient.getId(), false); ApplicationDependencies.getJobManager().add(new MultiDeviceBlockedUpdateJob()); + ApplicationDependencies.getJobManager().add(new StorageSyncJob()); if (FeatureFlags.messageRequests()) { ApplicationDependencies.getJobManager().add(MultiDeviceMessageRequestResponseJob.forAccept(recipient.getId())); diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java index 8fb1c43051..7089f5e00e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RegistrationLockFragment.java @@ -6,7 +6,6 @@ import android.text.InputType; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.view.WindowManager; import android.view.inputmethod.EditorInfo; import android.widget.EditText; import android.widget.TextView; @@ -21,13 +20,17 @@ import androidx.navigation.Navigation; import com.dd.CircularProgressButton; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.StorageSyncJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.lock.v2.PinKeyboardType; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.registration.service.CodeVerificationRequest; import org.thoughtcrime.securesms.registration.service.RegistrationService; import org.thoughtcrime.securesms.registration.viewmodel.RegistrationViewModel; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.ServiceUtil; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse; import java.util.concurrent.TimeUnit; @@ -172,10 +175,7 @@ public final class RegistrationLockFragment extends BaseRegistrationFragment { @Override public void onSuccessfulRegistration() { - cancelSpinning(pinButton); - SignalStore.kbsValues().setKeyboardType(getPinEntryKeyboardType()); - - Navigation.findNavController(requireView()).navigate(RegistrationLockFragmentDirections.actionSuccessfulRegistration()); + handleSuccessfulPinEntry(); } @Override @@ -301,4 +301,28 @@ public final class RegistrationLockFragment extends BaseRegistrationFragment { ServiceUtil.getInputMethodManager(pinEntry.getContext()).showSoftInput(pinEntry, 0); } } + + private void handleSuccessfulPinEntry() { + SignalStore.kbsValues().setKeyboardType(getPinEntryKeyboardType()); + + if (FeatureFlags.storageServiceRestore()) { + long startTime = System.currentTimeMillis(); + SimpleTask.run(() -> { + return ApplicationDependencies.getJobManager().runSynchronously(new StorageSyncJob(), TimeUnit.SECONDS.toMillis(10)); + }, result -> { + long elapsedTime = System.currentTimeMillis() - startTime; + + if (result.isPresent()) { + Log.i(TAG, "Storage Service restore completed: " + result.get().name() + ". (Took " + elapsedTime + " ms)"); + } else { + Log.i(TAG, "Storage Service restore failed to complete in the allotted time. (" + elapsedTime + " ms elapsed)"); + } + cancelSpinning(pinButton); + Navigation.findNavController(requireView()).navigate(RegistrationLockFragmentDirections.actionSuccessfulRegistration()); + }); + } else { + cancelSpinning(pinButton); + Navigation.findNavController(requireView()).navigate(RegistrationLockFragmentDirections.actionSuccessfulRegistration()); + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/service/CodeVerificationRequest.java b/app/src/main/java/org/thoughtcrime/securesms/registration/service/CodeVerificationRequest.java index 7bd5e85780..495a213162 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/service/CodeVerificationRequest.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/service/CodeVerificationRequest.java @@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.service.DirectoryRefreshListener; import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener; import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.whispersystems.libsignal.IdentityKeyPair; import org.whispersystems.libsignal.state.PreKeyRecord; import org.whispersystems.libsignal.state.SignedPreKeyRecord; @@ -166,7 +167,7 @@ public final class CodeVerificationRequest { break; } } - }.execute(); + }.executeOnExecutor(SignalExecutors.UNBOUNDED); } private static TokenResponse getToken(@Nullable String basicStorageCredentials) throws IOException { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index fbec7bdd83..201d3c02e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -49,11 +49,11 @@ public final class FeatureFlags { private static final String UUIDS = "android.uuids"; private static final String MESSAGE_REQUESTS = "android.messageRequests"; private static final String USERNAMES = "android.usernames"; - private static final String STORAGE_SERVICE = "android.storageService"; private static final String PINS_FOR_ALL = "android.pinsForAll"; private static final String PINS_MEGAPHONE_KILL_SWITCH = "android.pinsMegaphoneKillSwitch"; private static final String PROFILE_NAMES_MEGAPHONE = "android.profileNamesMegaphone"; private static final String VIDEO_TRIMMING = "android.videoTrimming"; + private static final String STORAGE_SERVICE = "android.storageService"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -65,7 +65,8 @@ public final class FeatureFlags { PINS_FOR_ALL, PINS_MEGAPHONE_KILL_SWITCH, PROFILE_NAMES_MEGAPHONE, - MESSAGE_REQUESTS + MESSAGE_REQUESTS, + STORAGE_SERVICE ); /** @@ -86,15 +87,16 @@ public final class FeatureFlags { * more burden on the reader to ensure that the app experience remains consistent. */ private static final Set HOT_SWAPPABLE = Sets.newHashSet( - VIDEO_TRIMMING, - PINS_MEGAPHONE_KILL_SWITCH + VIDEO_TRIMMING, + PINS_MEGAPHONE_KILL_SWITCH, + STORAGE_SERVICE ); /** * Flags in this set will stay true forever once they receive a true value from a remote config. */ private static final Set STICKY = Sets.newHashSet( - PINS_FOR_ALL + PINS_FOR_ALL ); /** @@ -179,11 +181,6 @@ public final class FeatureFlags { return value; } - /** Storage service. */ - public static boolean storageService() { - return getValue(STORAGE_SERVICE, false); - } - /** Enables new KBS UI and notices but does not require user to set a pin */ public static boolean pinsForAll() { return SignalStore.registrationValues().pinWasRequiredAtRegistration() || @@ -207,6 +204,16 @@ public final class FeatureFlags { return getValue(VIDEO_TRIMMING, false); } + /** Whether or not we can actually restore data on a new installation. NOT remote-configurable. */ + public static boolean storageServiceRestore() { + return false; + } + + /** Whether or not we sync to the storage service. */ + public static boolean storageService() { + return getValue(STORAGE_SERVICE, false); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java index 9a2d20e1b6..5d4490ba63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.java @@ -74,6 +74,15 @@ public final class SqlUtil { return new UpdateQuery("(" + selection + ") AND (" + qualifier + ")", fullArgs.toArray(new String[0])); } + public static String[] appendArg(@NonNull String[] args, String addition) { + String[] output = new String[args.length + 1]; + + System.arraycopy(args, 0, output, 0, args.length); + output[output.length - 1] = addition; + + return output; + } + public static class UpdateQuery { private final String where; private final String[] whereArgs; diff --git a/app/src/test/java/org/thoughtcrime/securesms/contacts/sync/StorageSyncHelperTest.java b/app/src/test/java/org/thoughtcrime/securesms/contacts/sync/StorageSyncHelperTest.java index 4c5bce3105..97027c8069 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/contacts/sync/StorageSyncHelperTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/contacts/sync/StorageSyncHelperTest.java @@ -12,6 +12,8 @@ import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper.MergeResult; import org.thoughtcrime.securesms.util.Conversions; 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.util.UuidUtil; @@ -78,12 +80,12 @@ public final class StorageSyncHelperTest { assertEquals(setOf(remote1), result.getLocalContactInserts()); assertTrue(result.getLocalContactUpdates().isEmpty()); - assertEquals(setOf(local1), result.getRemoteContactInserts()); - assertTrue(result.getRemoteContactUpdates().isEmpty()); + assertEquals(setOf(SignalStorageRecord.forContact(local1)), result.getRemoteInserts()); + assertTrue(result.getRemoteUpdates().isEmpty()); } @Test - public void resolveConflict_sameAsRemote() { + public void resolveConflict_contact_sameAsRemote() { SignalContactRecord remote1 = contact(1, UUID_A, E164_A, "a"); SignalContactRecord local1 = contact(2, UUID_A, E164_A, "a"); @@ -93,12 +95,27 @@ public final class StorageSyncHelperTest { assertTrue(result.getLocalContactInserts().isEmpty()); assertEquals(setOf(contactUpdate(local1, expectedMerge)), result.getLocalContactUpdates()); - assertTrue(result.getRemoteContactInserts().isEmpty()); - assertTrue(result.getRemoteContactUpdates().isEmpty()); + assertTrue(result.getRemoteInserts().isEmpty()); + assertTrue(result.getRemoteUpdates().isEmpty()); } @Test - public void resolveConflict_sameAsLocal() { + public void resolveConflict_group_sameAsRemote() { + SignalGroupV1Record remote1 = groupV1(1, 1, true, false); + SignalGroupV1Record local1 = groupV1(2, 1, true, false); + + MergeResult result = StorageSyncHelper.resolveConflict(recordSetOf(remote1), recordSetOf(local1)); + + SignalGroupV1Record expectedMerge = groupV1(1, 1, true, false); + + assertTrue(result.getLocalContactInserts().isEmpty()); + assertEquals(setOf(groupV1Update(local1, expectedMerge)), result.getLocalGroupV1Updates()); + assertTrue(result.getRemoteInserts().isEmpty()); + assertTrue(result.getRemoteUpdates().isEmpty()); + } + + @Test + public void resolveConflict_contact_sameAsLocal() { SignalContactRecord remote1 = contact(1, UUID_A, E164_A, null); SignalContactRecord local1 = contact(2, UUID_A, E164_A, "a"); @@ -108,8 +125,23 @@ public final class StorageSyncHelperTest { assertTrue(result.getLocalContactInserts().isEmpty()); assertTrue(result.getLocalContactUpdates().isEmpty()); - assertTrue(result.getRemoteContactInserts().isEmpty()); - assertEquals(setOf(contactUpdate(remote1, expectedMerge)), result.getRemoteContactUpdates()); + assertTrue(result.getRemoteInserts().isEmpty()); + assertEquals(setOf(recordUpdate(remote1, expectedMerge)), result.getRemoteUpdates()); + } + + @Test + public void resolveConflict_group_sameAsLocal() { + SignalGroupV1Record remote1 = groupV1(1, 1, true, false); + SignalGroupV1Record local1 = groupV1(2, 1, true, true); + + MergeResult result = StorageSyncHelper.resolveConflict(recordSetOf(remote1), recordSetOf(local1)); + + SignalGroupV1Record expectedMerge = groupV1(2, 1, true, true); + + assertTrue(result.getLocalContactInserts().isEmpty()); + assertTrue(result.getLocalGroupV1Updates().isEmpty()); + assertTrue(result.getRemoteInserts().isEmpty()); + assertEquals(setOf(recordUpdate(remote1, expectedMerge)), result.getRemoteUpdates()); } @Test @@ -138,26 +170,28 @@ public final class StorageSyncHelperTest { SignalContactRecord remote3 = contact(5, UUID_C, E164_C, "c"); SignalContactRecord local3 = contact(6, UUID_D, E164_D, "d"); - SignalStorageRecord unknownRemote = unknown(7); - SignalStorageRecord unknownLocal = unknown(8); + SignalGroupV1Record remote4 = groupV1(7, 1, true, false); + SignalGroupV1Record local4 = groupV1(8, 1, false, true); - StorageSyncHelper.setTestKeyGenerator(new TestGenerator(999)); + SignalStorageRecord unknownRemote = unknown(9); + SignalStorageRecord unknownLocal = unknown(10); - Set remoteOnly = recordSetOf(remote1, remote2, remote3); - Set localOnly = recordSetOf(local1, local2, local3); + StorageSyncHelper.setTestKeyGenerator(new TestGenerator(111, 222)); - remoteOnly.add(unknownRemote); - localOnly.add(unknownLocal); + Set remoteOnly = recordSetOf(remote1, remote2, remote3, remote4, unknownRemote); + Set localOnly = recordSetOf(local1, local2, local3, local4, unknownLocal); MergeResult result = StorageSyncHelper.resolveConflict(remoteOnly, localOnly); SignalContactRecord merge1 = contact(2, UUID_A, E164_A, "a"); - SignalContactRecord merge2 = contact(999, UUID_B, E164_B, "b"); + SignalContactRecord merge2 = contact(111, UUID_B, E164_B, "b"); + SignalGroupV1Record merge4 = groupV1(222, 1, true, true); assertEquals(setOf(remote3), result.getLocalContactInserts()); assertEquals(setOf(contactUpdate(local2, merge2)), result.getLocalContactUpdates()); - assertEquals(setOf(local3), result.getRemoteContactInserts()); - assertEquals(setOf(contactUpdate(remote1, merge1), contactUpdate(remote2, merge2)), result.getRemoteContactUpdates()); + 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(unknownRemote), result.getLocalUnknownInserts()); assertEquals(setOf(unknownLocal), result.getLocalUnknownDeletes()); } @@ -169,7 +203,8 @@ public final class StorageSyncHelperTest { .setIdentityKey(byteArray(2)) .setIdentityState(SignalContactRecord.IdentityState.VERIFIED) .setProfileKey(byteArray(3)) - .setProfileName("profile name A") + .setGivenName("AFirst") + .setFamilyName("ALast") .setUsername("username A") .setNickname("nickname A") .setProfileSharingEnabled(true) @@ -179,7 +214,8 @@ public final class StorageSyncHelperTest { .setIdentityKey(byteArray(99)) .setIdentityState(SignalContactRecord.IdentityState.DEFAULT) .setProfileKey(byteArray(999)) - .setProfileName("profile name B") + .setGivenName("BFirst") + .setFamilyName("BLast") .setUsername("username B") .setNickname("nickname B") .setProfileSharingEnabled(false) @@ -192,7 +228,8 @@ public final class StorageSyncHelperTest { assertArrayEquals(byteArray(2), merged.getIdentityKey().get()); assertEquals(SignalContactRecord.IdentityState.VERIFIED, merged.getIdentityState()); assertArrayEquals(byteArray(3), merged.getProfileKey().get()); - assertEquals("profile name A", merged.getProfileName().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()); @@ -202,14 +239,16 @@ public final class StorageSyncHelperTest { public void mergeContacts_fillInGaps() { SignalContactRecord remote = new SignalContactRecord.Builder(byteArray(1), new SignalServiceAddress(UUID_A, null)) .setBlocked(true) - .setProfileName("profile name A") + .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)) - .setProfileName("profile name B") + .setGivenName("BFirst") + .setFamilyName("BLast") .setUsername("username B") .setProfileSharingEnabled(false) .build(); @@ -221,41 +260,47 @@ public final class StorageSyncHelperTest { assertArrayEquals(byteArray(2), merged.getIdentityKey().get()); assertEquals(SignalContactRecord.IdentityState.DEFAULT, merged.getIdentityState()); assertArrayEquals(byteArray(3), merged.getProfileKey().get()); - assertEquals("profile name A", merged.getProfileName().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); + 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"); - SignalStorageRecord unknownInsert = unknown(9); - SignalStorageRecord unknownDelete = unknown(10); + 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, new MergeResult(setOf(insert2), setOf(contactUpdate(old2, new2)), - setOf(insert1), - setOf(contactUpdate(old1, new1)), + setOf(insert3), + setOf(groupV1Update(old3, new3)), setOf(unknownInsert), - setOf(unknownDelete))); + setOf(unknownDelete), + recordSetOf(insert1, insert3), + setOf(recordUpdate(old1, new1), recordUpdate(old3, new3)))); assertEquals(2, result.getManifest().getVersion()); - assertByteListEquals(byteListOf(3, 4, 5, 6, 7, 8, 9), result.getManifest().getStorageKeys()); - assertTrue(recordSetOf(insert1, new1).containsAll(result.getInserts())); - assertEquals(2, result.getInserts().size()); - assertByteListEquals(byteListOf(1), result.getDeletes()); + assertByteListEquals(byteListOf(3, 4, 5, 6, 7, 8, 9, 10, 11), result.getManifest().getStorageKeys()); + assertTrue(recordSetOf(insert1, new1, insert3, new3).containsAll(result.getInserts())); + assertEquals(4, result.getInserts().size()); + assertByteListEquals(byteListOf(1, 100), result.getDeletes()); } @Test - public void contacts_with_same_profile_key_contents_are_equal() { + public void ContactUpdate_equals_sameProfileKeys() { byte[] profileKey = new byte[32]; byte[] profileKeyCopy = profileKey.clone(); @@ -269,7 +314,7 @@ public final class StorageSyncHelperTest { } @Test - public void contacts_with_different_profile_key_contents_are_not_equal() { + public void ContactUpdate_equals_differentProfileKeys() { byte[] profileKey = new byte[32]; byte[] profileKeyCopy = profileKey.clone(); profileKeyCopy[0] = 1; @@ -283,41 +328,33 @@ public final class StorageSyncHelperTest { assertTrue(contactUpdate(a, b).profileKeyChanged()); } - @Test - public void contacts_with_same_identity_key_contents_are_equal() { - byte[] profileKey = new byte[32]; - byte[] profileKeyCopy = profileKey.clone(); - - SignalContactRecord a = contactBuilder(1, UUID_A, E164_A, "a").setIdentityKey(profileKey).build(); - SignalContactRecord b = contactBuilder(1, UUID_A, E164_A, "a").setIdentityKey(profileKeyCopy).build(); - - assertEquals(a, b); - assertEquals(a.hashCode(), b.hashCode()); - } - - @Test - public void contacts_with_different_identity_key_contents_are_not_equal() { - byte[] profileKey = new byte[32]; - byte[] profileKeyCopy = profileKey.clone(); - profileKeyCopy[0] = 1; - - SignalContactRecord a = contactBuilder(1, UUID_A, E164_A, "a").setIdentityKey(profileKey).build(); - SignalContactRecord b = contactBuilder(1, UUID_A, E164_A, "a").setIdentityKey(profileKeyCopy).build(); - - assertNotEquals(a, b); - assertNotEquals(a.hashCode(), b.hashCode()); - } @SafeVarargs private static Set setOf(E... values) { return Sets.newHashSet(values); } - private static Set recordSetOf(SignalContactRecord... contactRecords) { + private static Set recordSetOf(SignalRecord... records) { LinkedHashSet storageRecords = new LinkedHashSet<>(); - for (SignalContactRecord contactRecord : contactRecords) { - storageRecords.add(SignalStorageRecord.forContact(contactRecord.getKey(), contactRecord)); + for (SignalRecord record : records) { + if (record instanceof SignalContactRecord) { + storageRecords.add(SignalStorageRecord.forContact(record.getKey(), (SignalContactRecord) record)); + } else if (record instanceof SignalGroupV1Record) { + storageRecords.add(SignalStorageRecord.forGroupV1(record.getKey(), (SignalGroupV1Record) record)); + } else { + storageRecords.add(SignalStorageRecord.forUnknown(record.getKey(), UNKNOWN_TYPE)); + } + } + + return storageRecords; + } + + private static Set recordSetOf(SignalGroupV1Record... groupRecords) { + LinkedHashSet storageRecords = new LinkedHashSet<>(); + + for (SignalGroupV1Record contactRecord : groupRecords) { + storageRecords.add(SignalStorageRecord.forGroupV1(contactRecord.getKey(), contactRecord)); } return storageRecords; @@ -329,7 +366,7 @@ public final class StorageSyncHelperTest { String profileName) { return new SignalContactRecord.Builder(byteArray(key), new SignalServiceAddress(uuid, e164)) - .setProfileName(profileName); + .setGivenName(profileName); } private static SignalContactRecord contact(int key, @@ -340,10 +377,30 @@ public final class StorageSyncHelperTest { return contactBuilder(key, uuid, e164, profileName).build(); } + private static SignalGroupV1Record groupV1(int key, + int groupId, + boolean blocked, + boolean profileSharing) + { + 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.GroupV1Update groupV1Update(SignalGroupV1Record oldGroup, SignalGroupV1Record newGroup) { + return new StorageSyncHelper.GroupV1Update(oldGroup, newGroup); + } + + private static StorageSyncHelper.RecordUpdate recordUpdate(SignalContactRecord oldContact, SignalContactRecord 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)); + } + private static SignalStorageRecord unknown(int key) { return SignalStorageRecord.forUnknown(byteArray(key), UNKNOWN_TYPE); } @@ -372,15 +429,17 @@ public final class StorageSyncHelperTest { } private static class TestGenerator implements StorageSyncHelper.KeyGenerator { - private final byte[] key; + private final int[] keys; - private TestGenerator(int key) { - this.key = byteArray(key); + private int index = 0; + + private TestGenerator(int... keys) { + this.keys = keys; } @Override public @NonNull byte[] generate() { - return key; + return byteArray(keys[index++]); } } } 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 e38cabe4ba..c61906c3c0 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,11 +17,14 @@ 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.StorageKey; import org.whispersystems.signalservice.api.messages.calls.TurnServerInfo; import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo; import org.whispersystems.signalservice.api.profiles.SignalServiceProfileWrite; @@ -32,6 +35,7 @@ import org.whispersystems.signalservice.api.storage.SignalStorageCipher; import org.whispersystems.signalservice.api.storage.SignalStorageManifest; import org.whispersystems.signalservice.api.storage.SignalStorageModels; import org.whispersystems.signalservice.api.storage.SignalStorageRecord; +import org.whispersystems.signalservice.api.storage.StorageManifestKey; import org.whispersystems.signalservice.api.util.CredentialsProvider; import org.whispersystems.signalservice.api.util.StreamDetails; import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; @@ -66,6 +70,7 @@ import java.security.NoSuchAlgorithmException; import java.security.SignatureException; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedList; @@ -404,41 +409,48 @@ public class SignalServiceAccountManager { } } - public Optional getStorageManifest(byte[] storageServiceKey) throws IOException, InvalidKeyException { + public Optional getStorageManifestIfDifferentVersion(StorageKey storageKey, long manifestVersion) throws IOException, InvalidKeyException { try { - SignalStorageCipher cipher = new SignalStorageCipher(storageServiceKey); - String authToken = this.pushServiceSocket.getStorageAuth(); - StorageManifest storageManifest = this.pushServiceSocket.getStorageManifest(authToken); - byte[] rawRecord = cipher.decrypt(storageManifest.getValue().toByteArray()); - ManifestRecord manifestRecord = ManifestRecord.parseFrom(rawRecord); - List keys = new ArrayList<>(manifestRecord.getKeysCount()); + String authToken = this.pushServiceSocket.getStorageAuth(); + StorageManifest storageManifest = this.pushServiceSocket.getStorageManifestIfDifferentVersion(authToken, manifestVersion); + + if (storageManifest.getValue().isEmpty()) { + Log.w(TAG, "Got an empty storage manifest!"); + 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)); - } catch (NotFoundException e) { + } catch (NoContentException e) { return Optional.absent(); } } - public List readStorageRecords(byte[] storageServiceKey, List storageKeys) throws IOException, InvalidKeyException { + public List readStorageRecords(StorageKey storageKey, List storageKeys) throws IOException, InvalidKeyException { ReadOperation.Builder operation = ReadOperation.newBuilder(); for (byte[] key : storageKeys) { operation.addReadKey(ByteString.copyFrom(key)); } - String authToken = this.pushServiceSocket.getStorageAuth(); - StorageItems items = this.pushServiceSocket.readStorageItems(authToken, operation.build()); + String authToken = this.pushServiceSocket.getStorageAuth(); + StorageItems items = this.pushServiceSocket.readStorageItems(authToken, operation.build()); + List result = new ArrayList<>(items.getItemsCount()); - SignalStorageCipher storageCipher = new SignalStorageCipher(storageServiceKey); - List result = new ArrayList<>(items.getItemsCount()); + 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, storageCipher)); + result.add(SignalStorageModels.remoteToLocalStorageRecord(item, storageKey)); } else { Log.w(TAG, "Encountered a StorageItem with no key! Skipping."); } @@ -446,15 +458,38 @@ public class SignalServiceAccountManager { return result; } + /** + * @return If there was a conflict, the latest {@link SignalStorageManifest}. Otherwise absent. + */ + public Optional resetStorageRecords(StorageKey storageKey, + SignalStorageManifest manifest, + List allRecords) + throws IOException, InvalidKeyException + { + return writeStorageRecords(storageKey, manifest, allRecords, Collections.emptyList(), true); + } /** * @return If there was a conflict, the latest {@link SignalStorageManifest}. Otherwise absent. */ - public Optional writeStorageRecords(byte[] storageServiceKey, + public Optional writeStorageRecords(StorageKey storageKey, SignalStorageManifest manifest, List inserts, List deletes) throws IOException, InvalidKeyException + { + return writeStorageRecords(storageKey, manifest, inserts, deletes, false); + } + + /** + * @return If there was a conflict, the latest {@link SignalStorageManifest}. Otherwise absent. + */ + private Optional writeStorageRecords(StorageKey storageKey, + SignalStorageManifest manifest, + List inserts, + List deletes, + boolean clearAll) + throws IOException, InvalidKeyException { ManifestRecord.Builder manifestRecordBuilder = ManifestRecord.newBuilder().setVersion(manifest.getVersion()); @@ -462,29 +497,34 @@ public class SignalServiceAccountManager { manifestRecordBuilder.addKeys(ByteString.copyFrom(key)); } - String authToken = this.pushServiceSocket.getStorageAuth(); - SignalStorageCipher cipher = new SignalStorageCipher(storageServiceKey); - byte[] encryptedRecord = cipher.encrypt(manifestRecordBuilder.build().toByteArray()); - StorageManifest storageManifest = StorageManifest.newBuilder() + String authToken = this.pushServiceSocket.getStorageAuth(); + StorageManifestKey manifestKey = storageKey.deriveManifestKey(manifest.getVersion()); + byte[] encryptedRecord = SignalStorageCipher.encrypt(manifestKey, manifestRecordBuilder.build().toByteArray()); + StorageManifest storageManifest = StorageManifest.newBuilder() .setVersion(manifest.getVersion()) .setValue(ByteString.copyFrom(encryptedRecord)) .build(); - WriteOperation.Builder writeBuilder = WriteOperation.newBuilder().setManifest(storageManifest); + WriteOperation.Builder writeBuilder = WriteOperation.newBuilder().setManifest(storageManifest); for (SignalStorageRecord insert : inserts) { - writeBuilder.addInsertItem(SignalStorageModels.localToRemoteStorageRecord(insert, cipher)); + writeBuilder.addInsertItem(SignalStorageModels.localToRemoteStorageRecord(insert, storageKey)); } - for (byte[] delete : deletes) { - writeBuilder.addDeleteKey(ByteString.copyFrom(delete)); + if (clearAll) { + writeBuilder.setClearAll(true); + } else { + for (byte[] delete : deletes) { + writeBuilder.addDeleteKey(ByteString.copyFrom(delete)); + } } Optional conflict = this.pushServiceSocket.writeStorageContacts(authToken, writeBuilder.build()); if (conflict.isPresent()) { - byte[] rawManifestRecord = cipher.decrypt(conflict.get().getValue().toByteArray()); - ManifestRecord record = ManifestRecord.parseFrom(rawManifestRecord); - List keys = new ArrayList<>(record.getKeysCount()); + 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()); for (ByteString key : record.getKeysList()) { keys.add(key.toByteArray()); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index 5673192a25..9c28a3a952 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -36,6 +36,7 @@ import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMess import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage; import org.whispersystems.signalservice.api.messages.multidevice.ConfigurationMessage; import org.whispersystems.signalservice.api.messages.multidevice.MessageRequestResponseMessage; +import org.whispersystems.signalservice.api.messages.multidevice.KeysMessage; import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage; import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; @@ -305,6 +306,8 @@ public class SignalServiceMessageSender { content = createMultiDeviceFetchTypeContent(message.getFetchType().get()); } else if (message.getMessageRequestResponse().isPresent()) { content = createMultiDeviceMessageRequestResponseContent(message.getMessageRequestResponse().get()); + } else if (message.getKeys().isPresent()) { + content = createMultiDeviceSyncKeysContent(message.getKeys().get()); } else if (message.getVerified().isPresent()) { sendMessage(message.getVerified().get(), unidentifiedAccess); return; @@ -822,8 +825,8 @@ public class SignalServiceMessageSender { } private byte[] createMultiDeviceMessageRequestResponseContent(MessageRequestResponseMessage message) { - Content.Builder container = Content.newBuilder(); - SyncMessage.Builder syncMessage = createSyncMessageBuilder(); + Content.Builder container = Content.newBuilder(); + SyncMessage.Builder syncMessage = createSyncMessageBuilder(); SyncMessage.MessageRequestResponse.Builder responseMessage = SyncMessage.MessageRequestResponse.newBuilder(); if (message.getGroupId().isPresent()) { @@ -863,6 +866,20 @@ public class SignalServiceMessageSender { return container.setSyncMessage(syncMessage).build().toByteArray(); } + private byte[] createMultiDeviceSyncKeysContent(KeysMessage keysMessage) { + Content.Builder container = Content.newBuilder(); + SyncMessage.Builder syncMessage = createSyncMessageBuilder(); + SyncMessage.Keys.Builder builder = SyncMessage.Keys.newBuilder(); + + if (keysMessage.getStorageService().isPresent()) { + builder.setStorageService(ByteString.copyFrom(keysMessage.getStorageService().get().serialize())); + } else { + Log.w(TAG, "Invalid keys message!"); + } + + return container.setSyncMessage(syncMessage.setKeys(builder)).build().toByteArray(); + } + private byte[] createMultiDeviceVerifiedContent(VerifiedMessage verifiedMessage, byte[] nullMessage) { Content.Builder container = Content.newBuilder(); SyncMessage.Builder syncMessage = createSyncMessageBuilder(); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/kbs/MasterKey.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/kbs/MasterKey.java index 6542a6006c..47bd3a6041 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/kbs/MasterKey.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/kbs/MasterKey.java @@ -1,5 +1,6 @@ package org.whispersystems.signalservice.api.kbs; +import org.whispersystems.signalservice.api.storage.StorageKey; import org.whispersystems.signalservice.internal.util.Hex; import org.whispersystems.util.StringUtil; @@ -30,8 +31,8 @@ public final class MasterKey { return Hex.toStringCondensed(derive("Registration Lock")); } - public byte[] deriveStorageServiceKey() { - return derive("Storage Service Encryption"); + public StorageKey deriveStorageServiceKey() { + return new StorageKey(derive("Storage Service Encryption")); } private byte[] derive(String keyName) { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/KeysMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/KeysMessage.java index 04cb024939..a8d342353c 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/KeysMessage.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/KeysMessage.java @@ -2,16 +2,17 @@ package org.whispersystems.signalservice.api.messages.multidevice; import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.storage.StorageKey; public class KeysMessage { - private final Optional storageService; + private final Optional storageService; - public KeysMessage(Optional storageService) { + public KeysMessage(Optional storageService) { this.storageService = storageService; } - public Optional getStorageService() { + public Optional getStorageService() { return storageService; } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/NoContentException.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/NoContentException.java new file mode 100644 index 0000000000..4b46a48873 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/push/exceptions/NoContentException.java @@ -0,0 +1,13 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +package org.whispersystems.signalservice.api.push.exceptions; + +public class NoContentException extends NonSuccessfulResponseCodeException { + public NoContentException(String s) { + super(s); + } +} 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 bce42051ae..aa1820ff22 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 @@ -7,11 +7,12 @@ import org.whispersystems.signalservice.api.util.OptionalUtil; import java.util.Arrays; import java.util.Objects; -public final class SignalContactRecord { +public final class SignalContactRecord implements SignalRecord { private final byte[] key; private final SignalServiceAddress address; - private final Optional profileName; + private final Optional givenName; + private final Optional familyName; private final Optional profileKey; private final Optional username; private final Optional identityKey; @@ -23,7 +24,8 @@ public final class SignalContactRecord { private SignalContactRecord(byte[] key, SignalServiceAddress address, - String profileName, + String givenName, + String familyName, byte[] profileKey, String username, byte[] identityKey, @@ -35,7 +37,8 @@ public final class SignalContactRecord { { this.key = key; this.address = address; - this.profileName = Optional.fromNullable(profileName); + 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); @@ -46,6 +49,7 @@ public final class SignalContactRecord { this.protoVersion = protoVersion; } + @Override public byte[] getKey() { return key; } @@ -54,8 +58,12 @@ public final class SignalContactRecord { return address; } - public Optional getProfileName() { - return profileName; + public Optional getGivenName() { + return givenName; + } + + public Optional getFamilyName() { + return familyName; } public Optional getProfileKey() { @@ -99,7 +107,8 @@ public final class SignalContactRecord { profileSharingEnabled == contact.profileSharingEnabled && Arrays.equals(key, contact.key) && Objects.equals(address, contact.address) && - profileName.equals(contact.profileName) && + givenName.equals(contact.givenName) && + familyName.equals(contact.familyName) && OptionalUtil.byteArrayEquals(profileKey, contact.profileKey) && username.equals(contact.username) && OptionalUtil.byteArrayEquals(identityKey, contact.identityKey) && @@ -109,7 +118,7 @@ public final class SignalContactRecord { @Override public int hashCode() { - int result = Objects.hash(address, profileName, username, identityState, blocked, profileSharingEnabled, nickname); + 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); @@ -120,7 +129,8 @@ public final class SignalContactRecord { private final byte[] key; private final SignalServiceAddress address; - private String profileName; + private String givenName; + private String familyName; private byte[] profileKey; private String username; private byte[] identityKey; @@ -135,8 +145,13 @@ public final class SignalContactRecord { this.address = address; } - public Builder setProfileName(String profileName) { - this.profileName = profileName; + public Builder setGivenName(String givenName) { + this.givenName = givenName; + return this; + } + + public Builder setFamilyName(String familyName) { + this.familyName = familyName; return this; } @@ -183,7 +198,8 @@ public final class SignalContactRecord { public SignalContactRecord build() { return new SignalContactRecord(key, address, - profileName, + givenName, + familyName, profileKey, username, identityKey, 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 new file mode 100644 index 0000000000..a86b023119 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV1Record.java @@ -0,0 +1,85 @@ +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 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 SignalGroupV1Record(byte[] key, byte[] groupId, boolean blocked, boolean profileSharingEnabled) { + this.key = key; + this.groupId = groupId; + this.blocked = blocked; + this.profileSharingEnabled = profileSharingEnabled; + } + + @Override + public byte[] getKey() { + return key; + } + + public byte[] getGroupId() { + return groupId; + } + + public boolean isBlocked() { + return blocked; + } + + public boolean isProfileSharingEnabled() { + return profileSharingEnabled; + } + + @Override + public boolean equals(Object o) { + 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); + } + + @Override + public int hashCode() { + int result = Objects.hash(blocked, profileSharingEnabled); + result = 31 * result + Arrays.hashCode(key); + result = 31 * result + Arrays.hashCode(groupId); + return result; + } + + public static final class Builder { + private final byte[] key; + private final byte[] groupId; + private boolean blocked; + private boolean profileSharingEnabled; + + public Builder(byte[] key, byte[] groupId) { + this.key = key; + this.groupId = groupId; + } + + public Builder setBlocked(boolean blocked) { + this.blocked = blocked; + return this; + } + + public Builder setProfileSharingEnabled(boolean profileSharingEnabled) { + this.profileSharingEnabled = profileSharingEnabled; + return this; + } + + public SignalGroupV1Record build() { + return new SignalGroupV1Record(key, groupId, blocked, profileSharingEnabled); + } + } +} 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 new file mode 100644 index 0000000000..ac9e887ec2 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalRecord.java @@ -0,0 +1,5 @@ +package org.whispersystems.signalservice.api.storage; + +public interface SignalRecord { + byte[] getKey(); +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageCipher.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageCipher.java index 03ae3020e6..c9360e8886 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageCipher.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageCipher.java @@ -1,22 +1,13 @@ package org.whispersystems.signalservice.api.storage; import org.whispersystems.libsignal.InvalidKeyException; -import org.whispersystems.libsignal.util.ByteUtil; -import org.whispersystems.signalservice.api.crypto.CryptoUtil; import org.whispersystems.signalservice.internal.util.Util; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; import java.security.InvalidAlgorithmParameterException; import java.security.NoSuchAlgorithmException; import javax.crypto.BadPaddingException; import javax.crypto.Cipher; -import javax.crypto.CipherInputStream; -import javax.crypto.CipherOutputStream; import javax.crypto.IllegalBlockSizeException; import javax.crypto.NoSuchPaddingException; import javax.crypto.spec.GCMParameterSpec; @@ -27,34 +18,28 @@ import javax.crypto.spec.SecretKeySpec; */ public class SignalStorageCipher { - private final byte[] key; - - public SignalStorageCipher(byte[] storageServiceKey) { - this.key = storageServiceKey; - } - - public byte[] encrypt(byte[] data) { + public static byte[] encrypt(StorageCipherKey key, byte[] data) { try { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); byte[] iv = Util.getSecretBytes(16); - cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, iv)); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key.serialize(), "AES"), new GCMParameterSpec(128, iv)); byte[] ciphertext = cipher.doFinal(data); - return ByteUtil.combine(iv, ciphertext); + return Util.join(iv, ciphertext); } catch (NoSuchAlgorithmException | java.security.InvalidKeyException | InvalidAlgorithmParameterException | NoSuchPaddingException | BadPaddingException | IllegalBlockSizeException e) { throw new AssertionError(e); } } - public byte[] decrypt(byte[] data) throws InvalidKeyException { + public static byte[] decrypt(StorageCipherKey key, byte[] data) throws InvalidKeyException { try { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); byte[][] split = Util.split(data, 16, data.length - 16); byte[] iv = split[0]; byte[] cipherText = split[1]; - cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, iv)); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.serialize(), "AES"), new GCMParameterSpec(128, iv)); return cipher.doFinal(cipherText); } catch (java.security.InvalidKeyException | BadPaddingException | IllegalBlockSizeException e) { throw new InvalidKeyException(e); 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 bdb6517db3..6552a1dd48 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 @@ -6,6 +6,7 @@ import org.whispersystems.libsignal.InvalidKeyException; 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.StorageItem; import org.whispersystems.signalservice.internal.storage.protos.StorageRecord; @@ -13,31 +14,37 @@ import java.io.IOException; public final class SignalStorageModels { - public static SignalStorageRecord remoteToLocalStorageRecord(StorageItem item, SignalStorageCipher cipher) throws IOException, InvalidKeyException { - byte[] rawRecord = cipher.decrypt(item.getValue().toByteArray()); - StorageRecord record = StorageRecord.parseFrom(rawRecord); - byte[] storageKey = item.getKey().toByteArray(); + public static SignalStorageRecord remoteToLocalStorageRecord(StorageItem item, 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); - if (record.getType() == StorageRecord.Type.CONTACT_VALUE && record.hasContact()) { - return SignalStorageRecord.forContact(storageKey, remoteToLocalContactRecord(storageKey, record.getContact())); - } else { - return SignalStorageRecord.forUnknown(storageKey, record.getType()); + 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()); } } - public static StorageItem localToRemoteStorageRecord(SignalStorageRecord record, SignalStorageCipher cipher) throws IOException { + public static StorageItem localToRemoteStorageRecord(SignalStorageRecord record, StorageKey storageKey) { StorageRecord.Builder builder = StorageRecord.newBuilder(); if (record.getContact().isPresent()) { builder.setContact(localToRemoteContactRecord(record.getContact().get())); + } else if (record.getGroupV1().isPresent()) { + builder.setGroupV1(localToRemoteGroupV1Record(record.getGroupV1().get())); } else { throw new InvalidStorageWriteError(); } builder.setType(record.getType()); - StorageRecord remoteRecord = builder.build(); - byte[] encryptedRecord = cipher.encrypt(remoteRecord.toByteArray()); + StorageRecord remoteRecord = builder.build(); + StorageItemKey itemKey = storageKey.deriveItemKey(record.getKey()); + byte[] encryptedRecord = SignalStorageCipher.encrypt(itemKey, remoteRecord.toByteArray()); return StorageItem.newBuilder() .setKey(ByteString.copyFrom(record.getKey())) @@ -45,9 +52,9 @@ public final class SignalStorageModels { .build(); } - public static SignalContactRecord remoteToLocalContactRecord(byte[] storageKey, ContactRecord contact) throws IOException { + private static SignalContactRecord remoteToLocalContactRecord(byte[] key, ContactRecord contact) { SignalServiceAddress address = new SignalServiceAddress(UuidUtil.parseOrNull(contact.getServiceUuid()), contact.getServiceE164()); - SignalContactRecord.Builder builder = new SignalContactRecord.Builder(storageKey, address); + SignalContactRecord.Builder builder = new SignalContactRecord.Builder(key, address); if (contact.hasBlocked()) { builder.setBlocked(contact.getBlocked()); @@ -66,8 +73,12 @@ public final class SignalStorageModels { builder.setProfileKey(contact.getProfile().getKey().toByteArray()); } - if (contact.getProfile().hasName()) { - builder.setProfileName(contact.getProfile().getName()); + if (contact.getProfile().hasGivenName()) { + builder.setGivenName(contact.getProfile().getGivenName()); + } + + if (contact.getProfile().hasFamilyName()) { + builder.setFamilyName(contact.getProfile().getFamilyName()); } if (contact.getProfile().hasUsername()) { @@ -84,7 +95,7 @@ public final class SignalStorageModels { switch (contact.getIdentity().getState()) { case VERIFIED: builder.setIdentityState(SignalContactRecord.IdentityState.VERIFIED); case UNVERIFIED: builder.setIdentityState(SignalContactRecord.IdentityState.UNVERIFIED); - default: builder.setIdentityState(SignalContactRecord.IdentityState.VERIFIED); + default: builder.setIdentityState(SignalContactRecord.IdentityState.DEFAULT); } } } @@ -92,7 +103,21 @@ public final class SignalStorageModels { return builder.build(); } - public static ContactRecord localToRemoteContactRecord(SignalContactRecord contact) { + 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()); @@ -128,8 +153,12 @@ public final class SignalStorageModels { profileBuilder.setKey(ByteString.copyFrom(contact.getProfileKey().get())); } - if (contact.getProfileName().isPresent()) { - profileBuilder.setName(contact.getProfileName().get()); + if (contact.getGivenName().isPresent()) { + profileBuilder.setGivenName(contact.getGivenName().get()); + } + + if (contact.getFamilyName().isPresent()) { + profileBuilder.setFamilyName(contact.getFamilyName().get()); } if (contact.getUsername().isPresent()) { @@ -141,6 +170,14 @@ public final class SignalStorageModels { return contactRecordBuilder.build(); } + private static GroupV1Record localToRemoteGroupV1Record(SignalGroupV1Record groupV1) { + return GroupV1Record.newBuilder() + .setId(ByteString.copyFrom(groupV1.getGroupId())) + .setBlocked(groupV1.isBlocked()) + .setWhitelisted(groupV1.isProfileSharingEnabled()) + .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 c73b94bad1..d368b88477 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 @@ -6,26 +6,45 @@ import org.whispersystems.signalservice.internal.storage.protos.StorageRecord; import java.util.Arrays; import java.util.Objects; -public class SignalStorageRecord { +public class SignalStorageRecord implements SignalRecord { private final byte[] key; private final int type; private final Optional contact; + private final Optional groupV1; + + public static SignalStorageRecord forContact(SignalContactRecord contact) { + return forContact(contact.getKey(), contact); + } public static SignalStorageRecord forContact(byte[] key, SignalContactRecord contact) { - return new SignalStorageRecord(key, StorageRecord.Type.CONTACT_VALUE, Optional.of(contact)); + return new SignalStorageRecord(key, StorageRecord.Type.CONTACT_VALUE, Optional.of(contact), Optional.absent()); + } + + public static SignalStorageRecord forGroupV1(SignalGroupV1Record groupV1) { + return forGroupV1(groupV1.getKey(), 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 forUnknown(byte[] key, int type) { - return new SignalStorageRecord(key, type, Optional.absent()); + return new SignalStorageRecord(key, type, Optional.absent(), Optional.absent()); } - private SignalStorageRecord(byte key[], int type, Optional contact) { + private SignalStorageRecord(byte[] key, + int type, + Optional contact, + Optional groupV1) + { this.key = key; this.type = type; this.contact = contact; + this.groupV1 = groupV1; } + @Override public byte[] getKey() { return key; } @@ -38,8 +57,12 @@ public class SignalStorageRecord { return contact; } + public Optional getGroupV1() { + return groupV1; + } + public boolean isUnknown() { - return !contact.isPresent(); + return !contact.isPresent() && !groupV1.isPresent(); } @Override @@ -48,13 +71,14 @@ public class SignalStorageRecord { 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); + Arrays.equals(key, record.key) && + contact.equals(record.contact) && + groupV1.equals(record.groupV1); } @Override public int hashCode() { - int result = Objects.hash(type, contact); + int result = Objects.hash(type, contact, groupV1); result = 31 * result + Arrays.hashCode(key); return result; } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/StorageCipherKey.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/StorageCipherKey.java new file mode 100644 index 0000000000..474de1e6de --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/StorageCipherKey.java @@ -0,0 +1,5 @@ +package org.whispersystems.signalservice.api.storage; + +public interface StorageCipherKey { + byte[] serialize(); +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/StorageItemKey.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/StorageItemKey.java new file mode 100644 index 0000000000..81400b93d4 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/StorageItemKey.java @@ -0,0 +1,38 @@ +package org.whispersystems.signalservice.api.storage; + +import java.util.Arrays; + +/** + * Key used to encrypt individual storage items in the storage service. + * + * Created via {@link StorageKey#deriveItemKey(byte[]) }. + */ +public final class StorageItemKey implements StorageCipherKey { + + private static final int LENGTH = 32; + + private final byte[] key; + + StorageItemKey(byte[] key) { + if (key.length != LENGTH) throw new AssertionError(); + + this.key = key; + } + + @Override + public byte[] serialize() { + return key.clone(); + } + + @Override + public boolean equals(Object o) { + if (o == null || o.getClass() != getClass()) return false; + + return Arrays.equals(((StorageItemKey) o).key, key); + } + + @Override + public int hashCode() { + return Arrays.hashCode(key); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/StorageKey.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/StorageKey.java new file mode 100644 index 0000000000..2ef6b0c58c --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/StorageKey.java @@ -0,0 +1,57 @@ +package org.whispersystems.signalservice.api.storage; + +import org.whispersystems.signalservice.api.kbs.MasterKey; +import org.whispersystems.signalservice.internal.util.Hex; +import org.whispersystems.util.Base64; +import org.whispersystems.util.StringUtil; + +import java.util.Arrays; + +import static org.whispersystems.signalservice.api.crypto.CryptoUtil.hmacSha256; + +/** + * Key used to encrypt data on the storage service. Not used directly -- instead we used keys that + * are derived for each item we're storing. + * + * Created via {@link MasterKey#deriveStorageServiceKey()}. + */ +public final class StorageKey { + + private static final int LENGTH = 32; + + private final byte[] key; + + public StorageKey(byte[] key) { + if (key.length != LENGTH) throw new AssertionError(); + + this.key = key; + } + + public StorageManifestKey deriveManifestKey(long version) { + return new StorageManifestKey(derive("Manifest_" + version)); + } + + public StorageItemKey deriveItemKey(byte[] key) { + return new StorageItemKey(derive("Item_" + Base64.encodeBytes(key))); + } + + private byte[] derive(String keyName) { + return hmacSha256(key, StringUtil.utf8(keyName)); + } + + public byte[] serialize() { + return key.clone(); + } + + @Override + public boolean equals(Object o) { + if (o == null || o.getClass() != getClass()) return false; + + return Arrays.equals(((StorageKey) o).key, key); + } + + @Override + public int hashCode() { + return Arrays.hashCode(key); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/StorageManifestKey.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/StorageManifestKey.java new file mode 100644 index 0000000000..98977b66fe --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/storage/StorageManifestKey.java @@ -0,0 +1,38 @@ +package org.whispersystems.signalservice.api.storage; + +import java.util.Arrays; + +/** + * Key used to encrypt a manifest in the storage service. + * + * Created via {@link StorageKey#deriveManifestKey(long)}. + */ +public final class StorageManifestKey implements StorageCipherKey { + + private static final int LENGTH = 32; + + private final byte[] key; + + StorageManifestKey(byte[] key) { + if (key.length != LENGTH) throw new AssertionError(); + + this.key = key; + } + + @Override + public byte[] serialize() { + return key.clone(); + } + + @Override + public boolean equals(Object o) { + if (o == null || o.getClass() != getClass()) return false; + + return Arrays.equals(((StorageManifestKey) o).key, key); + } + + @Override + public int hashCode() { + return Arrays.hashCode(key); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 1abc55d12a..7538317cbf 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -39,6 +39,7 @@ import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedE import org.whispersystems.signalservice.api.push.exceptions.CaptchaRequiredException; import org.whispersystems.signalservice.api.push.exceptions.ContactManifestMismatchException; import org.whispersystems.signalservice.api.push.exceptions.ExpectationFailedException; +import org.whispersystems.signalservice.api.push.exceptions.NoContentException; import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; import org.whispersystems.signalservice.api.push.exceptions.NotFoundException; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; @@ -787,6 +788,16 @@ public class PushServiceSocket { return StorageManifest.parseFrom(response.body().bytes()); } + public StorageManifest getStorageManifestIfDifferentVersion(String authToken, long version) throws IOException { + Response response = makeStorageRequest(authToken, "/v1/storage/manifest/version/" + version, "GET", null); + + if (response.body() == null) { + throw new IOException("Missing body!"); + } + + return StorageManifest.parseFrom(response.body().bytes()); + } + public StorageItems readStorageItems(String authToken, ReadOperation operation) throws IOException { Response response = makeStorageRequest(authToken, "/v1/storage/read", "PUT", operation.toByteArray()); @@ -1119,8 +1130,8 @@ public class PushServiceSocket { .readTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS) .build(); - Log.w(TAG, "Push service URL: " + connectionHolder.getUrl()); - Log.w(TAG, "Opening URL: " + String.format("%s%s", connectionHolder.getUrl(), urlFragment)); + Log.d(TAG, "Push service URL: " + connectionHolder.getUrl()); + Log.d(TAG, "Opening URL: " + String.format("%s%s", connectionHolder.getUrl(), urlFragment)); Request.Builder request = new Request.Builder(); request.url(String.format("%s%s", connectionHolder.getUrl(), urlFragment)); @@ -1262,6 +1273,8 @@ public class PushServiceSocket { .readTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS) .build(); + Log.d(TAG, "Opening URL: " + String.format("%s%s", connectionHolder.getUrl(), path)); + Request.Builder request = new Request.Builder().url(connectionHolder.getUrl() + path); if (body != null) { @@ -1289,7 +1302,7 @@ public class PushServiceSocket { try { response = call.execute(); - if (response.isSuccessful()) { + if (response.isSuccessful() && response.code() != 204) { return response; } } catch (IOException e) { @@ -1301,6 +1314,8 @@ public class PushServiceSocket { } switch (response.code()) { + case 204: + throw new NoContentException("No content!"); case 401: case 403: throw new AuthorizationFailedException("Authorization failed!"); diff --git a/libsignal/service/src/main/proto/SignalStorage.proto b/libsignal/service/src/main/proto/SignalStorage.proto index 19fa066607..8ce6ebe642 100644 --- a/libsignal/service/src/main/proto/SignalStorage.proto +++ b/libsignal/service/src/main/proto/SignalStorage.proto @@ -33,16 +33,19 @@ message WriteOperation { optional 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 { @@ -58,9 +61,10 @@ message ContactRecord { } message Profile { - optional string name = 1; - optional bytes key = 2; - optional string username = 3; + optional string givenName = 1; + optional string familyName = 4; + optional bytes key = 2; + optional string username = 3; } optional string serviceUuid = 1; @@ -72,6 +76,12 @@ message ContactRecord { optional string nickname = 7; } +message GroupV1Record { + optional bytes id = 1; + optional bool blocked = 2; + optional bool whitelisted = 3; +} + message ManifestRecord { optional uint64 version = 1; repeated bytes keys = 2; diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/storage/SignalContactRecordTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/storage/SignalContactRecordTest.java new file mode 100644 index 0000000000..69e7ac3fc9 --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/storage/SignalContactRecordTest.java @@ -0,0 +1,59 @@ +package org.whispersystems.signalservice.api.storage; + +import org.junit.Test; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + +public class SignalContactRecordTest { + + private static final UUID UUID_A = UuidUtil.parseOrThrow("ebef429e-695e-4f51-bcc4-526a60ac68c7"); + private static final String E164_A = "+16108675309"; + + @Test + public void contacts_with_same_identity_key_contents_are_equal() { + byte[] profileKey = new byte[32]; + byte[] profileKeyCopy = profileKey.clone(); + + SignalContactRecord a = contactBuilder(1, UUID_A, E164_A, "a").setIdentityKey(profileKey).build(); + SignalContactRecord b = contactBuilder(1, UUID_A, E164_A, "a").setIdentityKey(profileKeyCopy).build(); + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + public void contacts_with_different_identity_key_contents_are_not_equal() { + byte[] profileKey = new byte[32]; + byte[] profileKeyCopy = profileKey.clone(); + profileKeyCopy[0] = 1; + + SignalContactRecord a = contactBuilder(1, UUID_A, E164_A, "a").setIdentityKey(profileKey).build(); + SignalContactRecord b = contactBuilder(1, UUID_A, E164_A, "a").setIdentityKey(profileKeyCopy).build(); + + assertNotEquals(a, b); + assertNotEquals(a.hashCode(), b.hashCode()); + } + + private static byte[] byteArray(int a) { + byte[] bytes = new byte[4]; + bytes[3] = (byte) a; + bytes[2] = (byte)(a >> 8); + bytes[1] = (byte)(a >> 16); + bytes[0] = (byte)(a >> 24); + return bytes; + } + + private static SignalContactRecord.Builder contactBuilder(int key, + UUID uuid, + String e164, + String givenName) + { + return new SignalContactRecord.Builder(byteArray(key), new SignalServiceAddress(uuid, e164)) + .setGivenName(givenName); + } +} diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/storage/SignalStorageCipherTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/storage/SignalStorageCipherTest.java new file mode 100644 index 0000000000..caaff88f00 --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/storage/SignalStorageCipherTest.java @@ -0,0 +1,33 @@ +package org.whispersystems.signalservice.api.storage; + +import org.junit.Test; +import org.whispersystems.libsignal.InvalidKeyException; +import org.whispersystems.signalservice.internal.util.Util; + +import static org.junit.Assert.assertArrayEquals; + +public class SignalStorageCipherTest { + + @Test + public void symmetry() throws InvalidKeyException { + StorageItemKey key = new StorageItemKey(Util.getSecretBytes(32)); + byte[] data = Util.getSecretBytes(1337); + + byte[] ciphertext = SignalStorageCipher.encrypt(key, data); + byte[] plaintext = SignalStorageCipher.decrypt(key, ciphertext); + + assertArrayEquals(data, plaintext); + } + + @Test(expected = InvalidKeyException.class) + public void badKeyOnDecrypt() throws InvalidKeyException { + StorageItemKey key = new StorageItemKey(Util.getSecretBytes(32)); + byte[] data = Util.getSecretBytes(1337); + + byte[] badKey = key.serialize().clone(); + badKey[0] += 1; + + byte[] ciphertext = SignalStorageCipher.encrypt(key, data); + byte[] plaintext = SignalStorageCipher.decrypt(new StorageItemKey(badKey), ciphertext); + } +}