Add internal pre-alpha support for storage service.
parent
52447f5e97
commit
cc0ced9a81
|
@ -227,6 +227,7 @@ android {
|
|||
|
||||
buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L"
|
||||
buildConfigField "String", "SIGNAL_URL", "\"https://textsecure-service.whispersystems.org\""
|
||||
buildConfigField "String", "STORAGE_URL", "\"https://storage.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api.directory.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\""
|
||||
|
@ -302,6 +303,7 @@ android {
|
|||
initWith debug
|
||||
|
||||
buildConfigField "String", "SIGNAL_URL", "\"https://textsecure-service-staging.whispersystems.org\""
|
||||
buildConfigField "String", "STORAGE_URL", "\"https://storage-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api-staging.directory.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
|
||||
|
|
|
@ -289,6 +289,7 @@ message SyncMessage {
|
|||
GROUPS = 2;
|
||||
BLOCKED = 3;
|
||||
CONFIGURATION = 4;
|
||||
KEYS = 5;
|
||||
}
|
||||
|
||||
optional Type type = 1;
|
||||
|
@ -325,7 +326,6 @@ message SyncMessage {
|
|||
optional uint64 timestamp = 2;
|
||||
}
|
||||
|
||||
|
||||
message FetchLatest {
|
||||
enum Type {
|
||||
UNKNOWN = 0;
|
||||
|
@ -336,6 +336,9 @@ message SyncMessage {
|
|||
optional Type type = 1;
|
||||
}
|
||||
|
||||
message Keys {
|
||||
optional bytes storageService = 1;
|
||||
}
|
||||
|
||||
optional Sent sent = 1;
|
||||
optional Contacts contacts = 2;
|
||||
|
@ -349,6 +352,7 @@ message SyncMessage {
|
|||
repeated StickerPackOperation stickerPackOperation = 10;
|
||||
optional ViewOnceOpen viewOnceOpen = 11;
|
||||
optional FetchLatest fetchLatest = 12;
|
||||
optional Keys keys = 13;
|
||||
}
|
||||
|
||||
message AttachmentPointer {
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Copyright (C) 2019 Open Whisper Systems
|
||||
*
|
||||
* Licensed according to the LICENSE file in this repository.
|
||||
*/
|
||||
syntax = "proto2";
|
||||
|
||||
package signalservice;
|
||||
|
||||
option java_package = "org.whispersystems.signalservice.internal.storage.protos";
|
||||
option java_multiple_files = true;
|
||||
|
||||
|
||||
message StorageItem {
|
||||
optional bytes key = 1;
|
||||
optional bytes value = 2;
|
||||
}
|
||||
|
||||
message StorageItems {
|
||||
repeated StorageItem items = 1;
|
||||
}
|
||||
|
||||
message StorageManifest {
|
||||
optional uint64 version = 1;
|
||||
optional bytes value = 2;
|
||||
}
|
||||
|
||||
message ReadOperation {
|
||||
repeated bytes readKey = 1;
|
||||
}
|
||||
|
||||
message WriteOperation {
|
||||
optional StorageManifest manifest = 1;
|
||||
repeated StorageItem insertItem = 2;
|
||||
repeated bytes deleteKey = 3;
|
||||
}
|
||||
|
||||
message StorageRecord {
|
||||
enum Type {
|
||||
UNKNOWN = 0;
|
||||
CONTACT = 1;
|
||||
}
|
||||
|
||||
optional uint32 type = 1;
|
||||
optional ContactRecord contact = 2;
|
||||
}
|
||||
|
||||
message ContactRecord {
|
||||
message Identity {
|
||||
enum State {
|
||||
DEFAULT = 0;
|
||||
VERIFIED = 1;
|
||||
UNVERIFIED = 2;
|
||||
}
|
||||
|
||||
optional bytes key = 1;
|
||||
optional State state = 2;
|
||||
}
|
||||
|
||||
message Profile {
|
||||
optional string name = 1;
|
||||
optional bytes key = 2;
|
||||
optional string username = 3;
|
||||
}
|
||||
|
||||
optional string serviceUuid = 1;
|
||||
optional string serviceE164 = 2;
|
||||
optional Profile profile = 3;
|
||||
optional Identity identity = 4;
|
||||
optional bool blocked = 5;
|
||||
optional bool whitelisted = 6;
|
||||
optional string nickname = 7;
|
||||
}
|
||||
|
||||
message ManifestRecord {
|
||||
optional uint64 version = 1;
|
||||
repeated bytes keys = 2;
|
||||
}
|
|
@ -24,6 +24,12 @@ import org.whispersystems.signalservice.api.messages.calls.TurnServerInfo;
|
|||
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo;
|
||||
import org.whispersystems.signalservice.api.push.ContactTokenDetails;
|
||||
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageCipher;
|
||||
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageModels;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageManifest;
|
||||
import org.whispersystems.signalservice.api.util.CredentialsProvider;
|
||||
import org.whispersystems.signalservice.api.util.StreamDetails;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
|
@ -39,6 +45,12 @@ import org.whispersystems.signalservice.internal.push.ProfileAvatarData;
|
|||
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
|
||||
import org.whispersystems.signalservice.internal.push.RemoteAttestationUtil;
|
||||
import org.whispersystems.signalservice.internal.push.http.ProfileCipherOutputStreamFactory;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ReadOperation;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageItem;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageItems;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageManifest;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.WriteOperation;
|
||||
import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
import org.whispersystems.util.Base64;
|
||||
|
@ -48,6 +60,7 @@ import java.security.KeyStore;
|
|||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SignatureException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
|
@ -386,6 +399,113 @@ public class SignalServiceAccountManager {
|
|||
}
|
||||
}
|
||||
|
||||
public long getStorageManifestVersion() throws IOException {
|
||||
try {
|
||||
String authToken = this.pushServiceSocket.getStorageAuth();
|
||||
StorageManifest storageManifest = this.pushServiceSocket.getStorageManifest(authToken);
|
||||
|
||||
return storageManifest.getVersion();
|
||||
} catch (NotFoundException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<SignalStorageManifest> getStorageManifest(byte[] storageServiceKey) 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<byte[]> 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) {
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
public List<SignalStorageRecord> readStorageRecords(byte[] storageServiceKey, List<byte[]> 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());
|
||||
|
||||
SignalStorageCipher storageCipher = new SignalStorageCipher(storageServiceKey);
|
||||
List<SignalStorageRecord> result = new ArrayList<>(items.getItemsCount());
|
||||
|
||||
for (StorageItem item : items.getItemsList()) {
|
||||
if (item.hasKey()) {
|
||||
result.add(SignalStorageModels.remoteToLocalStorageRecord(item, storageCipher));
|
||||
} else {
|
||||
Log.w(TAG, "Encountered a StorageItem with no key! Skipping.");
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return If there was a conflict, the latest {@link SignalStorageManifest}. Otherwise absent.
|
||||
*/
|
||||
public Optional<SignalStorageManifest> writeStorageRecords(byte[] storageServiceKey,
|
||||
SignalStorageManifest manifest,
|
||||
List<SignalStorageRecord> inserts,
|
||||
List<byte[]> deletes)
|
||||
throws IOException, InvalidKeyException
|
||||
{
|
||||
ManifestRecord.Builder manifestRecordBuilder = ManifestRecord.newBuilder().setVersion(manifest.getVersion());
|
||||
|
||||
for (byte[] key : manifest.getStorageKeys()) {
|
||||
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()
|
||||
.setVersion(manifest.getVersion())
|
||||
.setValue(ByteString.copyFrom(encryptedRecord))
|
||||
.build();
|
||||
WriteOperation.Builder writeBuilder = WriteOperation.newBuilder().setManifest(storageManifest);
|
||||
|
||||
for (SignalStorageRecord insert : inserts) {
|
||||
writeBuilder.addInsertItem(SignalStorageModels.localToRemoteStorageRecord(insert, cipher));
|
||||
}
|
||||
|
||||
for (byte[] delete : deletes) {
|
||||
writeBuilder.addDeleteKey(ByteString.copyFrom(delete));
|
||||
}
|
||||
|
||||
Optional<StorageManifest> conflict = this.pushServiceSocket.writeStorageContacts(authToken, writeBuilder.build());
|
||||
|
||||
if (conflict.isPresent()) {
|
||||
byte[] rawManifestRecord = cipher.decrypt(conflict.get().getValue().toByteArray());
|
||||
ManifestRecord record = ManifestRecord.parseFrom(rawManifestRecord);
|
||||
List<byte[]> keys = new ArrayList<>(record.getKeysCount());
|
||||
|
||||
for (ByteString key : record.getKeysList()) {
|
||||
keys.add(key.toByteArray());
|
||||
}
|
||||
|
||||
SignalStorageManifest conflictManifest = new SignalStorageManifest(record.getVersion(), keys);
|
||||
|
||||
return Optional.of(conflictManifest);
|
||||
} else {
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public String getNewDeviceVerificationCode() throws IOException {
|
||||
return this.pushServiceSocket.getNewDeviceVerificationCode();
|
||||
}
|
||||
|
@ -495,4 +615,5 @@ public class SignalServiceAccountManager {
|
|||
return tokenMap;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
public final class CryptoUtil {
|
||||
|
||||
private CryptoUtil () { }
|
||||
|
||||
public static byte[] computeHmacSha256(byte[] key, byte[] data) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(key, "HmacSHA256"));
|
||||
return mac.doFinal(data);
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package org.whispersystems.signalservice.api.messages.multidevice;
|
||||
|
||||
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
public class KeysMessage {
|
||||
|
||||
private final Optional<byte[]> storageService;
|
||||
|
||||
public KeysMessage(Optional<byte[]> storageService) {
|
||||
this.storageService = storageService;
|
||||
}
|
||||
|
||||
public Optional<byte[]> getStorageService() {
|
||||
return storageService;
|
||||
}
|
||||
}
|
|
@ -31,4 +31,8 @@ public class RequestMessage {
|
|||
public boolean isConfigurationRequest() {
|
||||
return request.getType() == Request.Type.CONFIGURATION;
|
||||
}
|
||||
|
||||
public boolean isKeysRequest() {
|
||||
return request.getType() == Request.Type.KEYS;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ public class SignalServiceSyncMessage {
|
|||
private final Optional<ConfigurationMessage> configuration;
|
||||
private final Optional<List<StickerPackOperationMessage>> stickerPackOperations;
|
||||
private final Optional<FetchType> fetchType;
|
||||
private final Optional<KeysMessage> keys;
|
||||
|
||||
private SignalServiceSyncMessage(Optional<SentTranscriptMessage> sent,
|
||||
Optional<ContactsMessage> contacts,
|
||||
|
@ -36,7 +37,8 @@ public class SignalServiceSyncMessage {
|
|||
Optional<VerifiedMessage> verified,
|
||||
Optional<ConfigurationMessage> configuration,
|
||||
Optional<List<StickerPackOperationMessage>> stickerPackOperations,
|
||||
Optional<FetchType> fetchType)
|
||||
Optional<FetchType> fetchType,
|
||||
Optional<KeysMessage> keys)
|
||||
{
|
||||
this.sent = sent;
|
||||
this.contacts = contacts;
|
||||
|
@ -49,6 +51,7 @@ public class SignalServiceSyncMessage {
|
|||
this.configuration = configuration;
|
||||
this.stickerPackOperations = stickerPackOperations;
|
||||
this.fetchType = fetchType;
|
||||
this.keys = keys;
|
||||
}
|
||||
|
||||
public static SignalServiceSyncMessage forSentTranscript(SentTranscriptMessage sent) {
|
||||
|
@ -62,7 +65,8 @@ public class SignalServiceSyncMessage {
|
|||
Optional.<VerifiedMessage>absent(),
|
||||
Optional.<ConfigurationMessage>absent(),
|
||||
Optional.<List<StickerPackOperationMessage>>absent(),
|
||||
Optional.<FetchType>absent());
|
||||
Optional.<FetchType>absent(),
|
||||
Optional.<KeysMessage>absent());
|
||||
}
|
||||
|
||||
public static SignalServiceSyncMessage forContacts(ContactsMessage contacts) {
|
||||
|
@ -76,7 +80,8 @@ public class SignalServiceSyncMessage {
|
|||
Optional.<VerifiedMessage>absent(),
|
||||
Optional.<ConfigurationMessage>absent(),
|
||||
Optional.<List<StickerPackOperationMessage>>absent(),
|
||||
Optional.<FetchType>absent());
|
||||
Optional.<FetchType>absent(),
|
||||
Optional.<KeysMessage>absent());
|
||||
}
|
||||
|
||||
public static SignalServiceSyncMessage forGroups(SignalServiceAttachment groups) {
|
||||
|
@ -90,7 +95,8 @@ public class SignalServiceSyncMessage {
|
|||
Optional.<VerifiedMessage>absent(),
|
||||
Optional.<ConfigurationMessage>absent(),
|
||||
Optional.<List<StickerPackOperationMessage>>absent(),
|
||||
Optional.<FetchType>absent());
|
||||
Optional.<FetchType>absent(),
|
||||
Optional.<KeysMessage>absent());
|
||||
}
|
||||
|
||||
public static SignalServiceSyncMessage forRequest(RequestMessage request) {
|
||||
|
@ -104,7 +110,8 @@ public class SignalServiceSyncMessage {
|
|||
Optional.<VerifiedMessage>absent(),
|
||||
Optional.<ConfigurationMessage>absent(),
|
||||
Optional.<List<StickerPackOperationMessage>>absent(),
|
||||
Optional.<FetchType>absent());
|
||||
Optional.<FetchType>absent(),
|
||||
Optional.<KeysMessage>absent());
|
||||
}
|
||||
|
||||
public static SignalServiceSyncMessage forRead(List<ReadMessage> reads) {
|
||||
|
@ -118,7 +125,8 @@ public class SignalServiceSyncMessage {
|
|||
Optional.<VerifiedMessage>absent(),
|
||||
Optional.<ConfigurationMessage>absent(),
|
||||
Optional.<List<StickerPackOperationMessage>>absent(),
|
||||
Optional.<FetchType>absent());
|
||||
Optional.<FetchType>absent(),
|
||||
Optional.<KeysMessage>absent());
|
||||
}
|
||||
|
||||
public static SignalServiceSyncMessage forViewOnceOpen(ViewOnceOpenMessage timerRead) {
|
||||
|
@ -132,7 +140,8 @@ public class SignalServiceSyncMessage {
|
|||
Optional.<VerifiedMessage>absent(),
|
||||
Optional.<ConfigurationMessage>absent(),
|
||||
Optional.<List<StickerPackOperationMessage>>absent(),
|
||||
Optional.<FetchType>absent());
|
||||
Optional.<FetchType>absent(),
|
||||
Optional.<KeysMessage>absent());
|
||||
}
|
||||
|
||||
public static SignalServiceSyncMessage forRead(ReadMessage read) {
|
||||
|
@ -149,7 +158,8 @@ public class SignalServiceSyncMessage {
|
|||
Optional.<VerifiedMessage>absent(),
|
||||
Optional.<ConfigurationMessage>absent(),
|
||||
Optional.<List<StickerPackOperationMessage>>absent(),
|
||||
Optional.<FetchType>absent());
|
||||
Optional.<FetchType>absent(),
|
||||
Optional.<KeysMessage>absent());
|
||||
}
|
||||
|
||||
public static SignalServiceSyncMessage forVerified(VerifiedMessage verifiedMessage) {
|
||||
|
@ -163,7 +173,8 @@ public class SignalServiceSyncMessage {
|
|||
Optional.of(verifiedMessage),
|
||||
Optional.<ConfigurationMessage>absent(),
|
||||
Optional.<List<StickerPackOperationMessage>>absent(),
|
||||
Optional.<FetchType>absent());
|
||||
Optional.<FetchType>absent(),
|
||||
Optional.<KeysMessage>absent());
|
||||
}
|
||||
|
||||
public static SignalServiceSyncMessage forBlocked(BlockedListMessage blocked) {
|
||||
|
@ -177,7 +188,8 @@ public class SignalServiceSyncMessage {
|
|||
Optional.<VerifiedMessage>absent(),
|
||||
Optional.<ConfigurationMessage>absent(),
|
||||
Optional.<List<StickerPackOperationMessage>>absent(),
|
||||
Optional.<FetchType>absent());
|
||||
Optional.<FetchType>absent(),
|
||||
Optional.<KeysMessage>absent());
|
||||
}
|
||||
|
||||
public static SignalServiceSyncMessage forConfiguration(ConfigurationMessage configuration) {
|
||||
|
@ -191,7 +203,8 @@ public class SignalServiceSyncMessage {
|
|||
Optional.<VerifiedMessage>absent(),
|
||||
Optional.of(configuration),
|
||||
Optional.<List<StickerPackOperationMessage>>absent(),
|
||||
Optional.<FetchType>absent());
|
||||
Optional.<FetchType>absent(),
|
||||
Optional.<KeysMessage>absent());
|
||||
}
|
||||
|
||||
public static SignalServiceSyncMessage forStickerPackOperations(List<StickerPackOperationMessage> stickerPackOperations) {
|
||||
|
@ -205,7 +218,8 @@ public class SignalServiceSyncMessage {
|
|||
Optional.<VerifiedMessage>absent(),
|
||||
Optional.<ConfigurationMessage>absent(),
|
||||
Optional.of(stickerPackOperations),
|
||||
Optional.<FetchType>absent());
|
||||
Optional.<FetchType>absent(),
|
||||
Optional.<KeysMessage>absent());
|
||||
}
|
||||
|
||||
public static SignalServiceSyncMessage forFetchLatest(FetchType fetchType) {
|
||||
|
@ -219,13 +233,14 @@ public class SignalServiceSyncMessage {
|
|||
Optional.<VerifiedMessage>absent(),
|
||||
Optional.<ConfigurationMessage>absent(),
|
||||
Optional.<List<StickerPackOperationMessage>>absent(),
|
||||
Optional.of(fetchType));
|
||||
Optional.of(fetchType),
|
||||
Optional.<KeysMessage>absent());
|
||||
}
|
||||
|
||||
public static SignalServiceSyncMessage empty() {
|
||||
public static SignalServiceSyncMessage forKeys(KeysMessage keys) {
|
||||
return new SignalServiceSyncMessage(Optional.<SentTranscriptMessage>absent(),
|
||||
Optional.<ContactsMessage>absent(),
|
||||
Optional.<SignalServiceAttachment>absent(),
|
||||
Optional.<ContactsMessage>absent(),
|
||||
Optional.<SignalServiceAttachment>absent(),
|
||||
Optional.<BlockedListMessage>absent(),
|
||||
Optional.<RequestMessage>absent(),
|
||||
Optional.<List<ReadMessage>>absent(),
|
||||
|
@ -233,7 +248,23 @@ public class SignalServiceSyncMessage {
|
|||
Optional.<VerifiedMessage>absent(),
|
||||
Optional.<ConfigurationMessage>absent(),
|
||||
Optional.<List<StickerPackOperationMessage>>absent(),
|
||||
Optional.<FetchType>absent());
|
||||
Optional.<FetchType>absent(),
|
||||
Optional.of(keys));
|
||||
}
|
||||
|
||||
public static SignalServiceSyncMessage empty() {
|
||||
return new SignalServiceSyncMessage(Optional.<SentTranscriptMessage>absent(),
|
||||
Optional.<ContactsMessage>absent(),
|
||||
Optional.<SignalServiceAttachment>absent(),
|
||||
Optional.<BlockedListMessage>absent(),
|
||||
Optional.<RequestMessage>absent(),
|
||||
Optional.<List<ReadMessage>>absent(),
|
||||
Optional.<ViewOnceOpenMessage>absent(),
|
||||
Optional.<VerifiedMessage>absent(),
|
||||
Optional.<ConfigurationMessage>absent(),
|
||||
Optional.<List<StickerPackOperationMessage>>absent(),
|
||||
Optional.<FetchType>absent(),
|
||||
Optional.<KeysMessage>absent());
|
||||
}
|
||||
|
||||
public Optional<SentTranscriptMessage> getSent() {
|
||||
|
@ -280,6 +311,10 @@ public class SignalServiceSyncMessage {
|
|||
return fetchType;
|
||||
}
|
||||
|
||||
public Optional<KeysMessage> getKeys() {
|
||||
return keys;
|
||||
}
|
||||
|
||||
public enum FetchType {
|
||||
LOCAL_PROFILE,
|
||||
STORAGE_MANIFEST
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
package org.whispersystems.signalservice.api.push.exceptions;
|
||||
|
||||
public class ContactManifestMismatchException extends NonSuccessfulResponseCodeException {
|
||||
|
||||
private final byte[] responseBody;
|
||||
|
||||
public ContactManifestMismatchException(byte[] responseBody) {
|
||||
this.responseBody = responseBody;
|
||||
}
|
||||
|
||||
public byte[] getResponseBody() {
|
||||
return responseBody;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,198 @@
|
|||
package org.whispersystems.signalservice.api.storage;
|
||||
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
public class SignalContactRecord {
|
||||
|
||||
private final byte[] key;
|
||||
private final SignalServiceAddress address;
|
||||
private final Optional<String> profileName;
|
||||
private final Optional<byte[]> profileKey;
|
||||
private final Optional<String> username;
|
||||
private final Optional<byte[]> identityKey;
|
||||
private final IdentityState identityState;
|
||||
private final boolean blocked;
|
||||
private final boolean profileSharingEnabled;
|
||||
private final Optional<String> nickname;
|
||||
private final int protoVersion;
|
||||
|
||||
private SignalContactRecord(byte[] key,
|
||||
SignalServiceAddress address,
|
||||
String profileName,
|
||||
byte[] profileKey,
|
||||
String username,
|
||||
byte[] identityKey,
|
||||
IdentityState identityState,
|
||||
boolean blocked,
|
||||
boolean profileSharingEnabled,
|
||||
String nickname,
|
||||
int protoVersion)
|
||||
{
|
||||
this.key = key;
|
||||
this.address = address;
|
||||
this.profileName = Optional.fromNullable(profileName);
|
||||
this.profileKey = Optional.fromNullable(profileKey);
|
||||
this.username = Optional.fromNullable(username);
|
||||
this.identityKey = Optional.fromNullable(identityKey);
|
||||
this.identityState = identityState != null ? identityState : IdentityState.DEFAULT;
|
||||
this.blocked = blocked;
|
||||
this.profileSharingEnabled = profileSharingEnabled;
|
||||
this.nickname = Optional.fromNullable(nickname);
|
||||
this.protoVersion = protoVersion;
|
||||
}
|
||||
|
||||
public byte[] getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
public SignalServiceAddress getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public Optional<String> getProfileName() {
|
||||
return profileName;
|
||||
}
|
||||
|
||||
public Optional<byte[]> getProfileKey() {
|
||||
return profileKey;
|
||||
}
|
||||
|
||||
public Optional<String> getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public Optional<byte[]> getIdentityKey() {
|
||||
return identityKey;
|
||||
}
|
||||
|
||||
public IdentityState getIdentityState() {
|
||||
return identityState;
|
||||
}
|
||||
|
||||
public boolean isBlocked() {
|
||||
return blocked;
|
||||
}
|
||||
|
||||
public boolean isProfileSharingEnabled() {
|
||||
return profileSharingEnabled;
|
||||
}
|
||||
|
||||
public Optional<String> getNickname() {
|
||||
return nickname;
|
||||
}
|
||||
|
||||
public int getProtoVersion() {
|
||||
return protoVersion;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
SignalContactRecord contact = (SignalContactRecord) o;
|
||||
return blocked == contact.blocked &&
|
||||
profileSharingEnabled == contact.profileSharingEnabled &&
|
||||
Arrays.equals(key, contact.key) &&
|
||||
Objects.equals(address, contact.address) &&
|
||||
Objects.equals(profileName, contact.profileName) &&
|
||||
Objects.equals(profileKey, contact.profileKey) &&
|
||||
Objects.equals(username, contact.username) &&
|
||||
Objects.equals(identityKey, contact.identityKey) &&
|
||||
identityState == contact.identityState &&
|
||||
Objects.equals(nickname, contact.nickname);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = Objects.hash(address, profileName, profileKey, username, identityKey, identityState, blocked, profileSharingEnabled, nickname);
|
||||
result = 31 * result + Arrays.hashCode(key);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
private final byte[] key;
|
||||
private final SignalServiceAddress address;
|
||||
|
||||
private String profileName;
|
||||
private byte[] profileKey;
|
||||
private String username;
|
||||
private byte[] identityKey;
|
||||
private IdentityState identityState;
|
||||
private boolean blocked;
|
||||
private boolean profileSharingEnabled;
|
||||
private String nickname;
|
||||
private int version;
|
||||
|
||||
public Builder(byte[] key, SignalServiceAddress address) {
|
||||
this.key = key;
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
public Builder setProfileName(String profileName) {
|
||||
this.profileName = profileName;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setProfileKey(byte[] profileKey) {
|
||||
this.profileKey= profileKey;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setUsername(String username) {
|
||||
this.username = username;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setIdentityKey(byte[] identityKey) {
|
||||
this.identityKey = identityKey;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setIdentityState(IdentityState identityState) {
|
||||
this.identityState = identityState;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setBlocked(boolean blocked) {
|
||||
this.blocked = blocked;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setProfileSharingEnabled(boolean profileSharingEnabled) {
|
||||
this.profileSharingEnabled = profileSharingEnabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setNickname(String nickname) {
|
||||
this.nickname = nickname;
|
||||
return this;
|
||||
}
|
||||
|
||||
Builder setProtoVersion(int version) {
|
||||
this.version = version;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SignalContactRecord build() {
|
||||
return new SignalContactRecord(key,
|
||||
address,
|
||||
profileName,
|
||||
profileKey,
|
||||
username,
|
||||
identityKey,
|
||||
identityState,
|
||||
blocked,
|
||||
profileSharingEnabled,
|
||||
nickname,
|
||||
version);
|
||||
}
|
||||
}
|
||||
|
||||
public enum IdentityState {
|
||||
DEFAULT, VERIFIED, UNVERIFIED
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
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;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* Encrypts and decrypts data from the storage service.
|
||||
*/
|
||||
public class SignalStorageCipher {
|
||||
|
||||
private final byte[] key;
|
||||
|
||||
public SignalStorageCipher(byte[] storageServiceKey) {
|
||||
this.key = storageServiceKey;
|
||||
}
|
||||
|
||||
public byte[] encrypt(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));
|
||||
byte[] ciphertext = cipher.doFinal(data);
|
||||
|
||||
return ByteUtil.combine(iv, ciphertext);
|
||||
} catch (NoSuchAlgorithmException | java.security.InvalidKeyException | InvalidAlgorithmParameterException | NoSuchPaddingException | BadPaddingException | IllegalBlockSizeException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] decrypt(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));
|
||||
return cipher.doFinal(cipherText);
|
||||
} catch (java.security.InvalidKeyException | BadPaddingException | IllegalBlockSizeException e) {
|
||||
throw new InvalidKeyException(e);
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package org.whispersystems.signalservice.api.storage;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class SignalStorageManifest {
|
||||
private final long version;
|
||||
private final List<byte[]> storageKeys;
|
||||
|
||||
public SignalStorageManifest(long version, List<byte[]> storageKeys) {
|
||||
this.version = version;
|
||||
this.storageKeys = storageKeys;
|
||||
}
|
||||
|
||||
public long getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public List<byte[]> getStorageKeys() {
|
||||
return storageKeys;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,146 @@
|
|||
package org.whispersystems.signalservice.api.storage;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
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.StorageItem;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageRecord;
|
||||
|
||||
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();
|
||||
|
||||
if (record.getType() == StorageRecord.Type.CONTACT_VALUE && record.hasContact()) {
|
||||
return SignalStorageRecord.forContact(storageKey, remoteToLocalContactRecord(storageKey, record.getContact()));
|
||||
} else {
|
||||
return SignalStorageRecord.forUnknown(storageKey, record.getType());
|
||||
}
|
||||
}
|
||||
|
||||
public static StorageItem localToRemoteStorageRecord(SignalStorageRecord record, SignalStorageCipher cipher) throws IOException {
|
||||
StorageRecord.Builder builder = StorageRecord.newBuilder();
|
||||
|
||||
if (record.getContact().isPresent()) {
|
||||
builder.setContact(localToRemoteContactRecord(record.getContact().get()));
|
||||
} else {
|
||||
throw new InvalidStorageWriteError();
|
||||
}
|
||||
|
||||
builder.setType(record.getType());
|
||||
|
||||
StorageRecord remoteRecord = builder.build();
|
||||
byte[] encryptedRecord = cipher.encrypt(remoteRecord.toByteArray());
|
||||
|
||||
return StorageItem.newBuilder()
|
||||
.setKey(ByteString.copyFrom(record.getKey()))
|
||||
.setValue(ByteString.copyFrom(encryptedRecord))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static SignalContactRecord remoteToLocalContactRecord(byte[] storageKey, ContactRecord contact) throws IOException {
|
||||
SignalServiceAddress address = new SignalServiceAddress(UuidUtil.parseOrNull(contact.getServiceUuid()), contact.getServiceE164());
|
||||
SignalContactRecord.Builder builder = new SignalContactRecord.Builder(storageKey, address);
|
||||
|
||||
if (contact.hasBlocked()) {
|
||||
builder.setBlocked(contact.getBlocked());
|
||||
}
|
||||
|
||||
if (contact.hasWhitelisted()) {
|
||||
builder.setProfileSharingEnabled(contact.getWhitelisted());
|
||||
}
|
||||
|
||||
if (contact.hasNickname()) {
|
||||
builder.setNickname(contact.getNickname());
|
||||
}
|
||||
|
||||
if (contact.hasProfile()) {
|
||||
if (contact.getProfile().hasKey()) {
|
||||
builder.setProfileKey(contact.getProfile().getKey().toByteArray());
|
||||
}
|
||||
|
||||
if (contact.getProfile().hasName()) {
|
||||
builder.setProfileName(contact.getProfile().getName());
|
||||
}
|
||||
|
||||
if (contact.getProfile().hasUsername()) {
|
||||
builder.setUsername(contact.getProfile().getUsername());
|
||||
}
|
||||
}
|
||||
|
||||
if (contact.hasIdentity()) {
|
||||
if (contact.getIdentity().hasKey()) {
|
||||
builder.setIdentityKey(contact.getIdentity().getKey().toByteArray());
|
||||
}
|
||||
|
||||
if (contact.getIdentity().hasState()) {
|
||||
switch (contact.getIdentity().getState()) {
|
||||
case VERIFIED: builder.setIdentityState(SignalContactRecord.IdentityState.VERIFIED);
|
||||
case UNVERIFIED: builder.setIdentityState(SignalContactRecord.IdentityState.UNVERIFIED);
|
||||
default: builder.setIdentityState(SignalContactRecord.IdentityState.VERIFIED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public static ContactRecord localToRemoteContactRecord(SignalContactRecord contact) {
|
||||
ContactRecord.Builder contactRecordBuilder = ContactRecord.newBuilder()
|
||||
.setBlocked(contact.isBlocked())
|
||||
.setWhitelisted(contact.isProfileSharingEnabled());
|
||||
if (contact.getAddress().getNumber().isPresent()) {
|
||||
contactRecordBuilder.setServiceE164(contact.getAddress().getNumber().get());
|
||||
}
|
||||
|
||||
if (contact.getAddress().getUuid().isPresent()) {
|
||||
contactRecordBuilder.setServiceUuid(contact.getAddress().getUuid().get().toString());
|
||||
}
|
||||
|
||||
if (contact.getNickname().isPresent()) {
|
||||
contactRecordBuilder.setNickname(contact.getNickname().get());
|
||||
}
|
||||
|
||||
ContactRecord.Identity.Builder identityBuilder = ContactRecord.Identity.newBuilder();
|
||||
|
||||
switch (contact.getIdentityState()) {
|
||||
case VERIFIED: identityBuilder.setState(ContactRecord.Identity.State.VERIFIED);
|
||||
case UNVERIFIED: identityBuilder.setState(ContactRecord.Identity.State.UNVERIFIED);
|
||||
case DEFAULT: identityBuilder.setState(ContactRecord.Identity.State.DEFAULT);
|
||||
}
|
||||
|
||||
if (contact.getIdentityKey().isPresent()) {
|
||||
identityBuilder.setKey(ByteString.copyFrom(contact.getIdentityKey().get()));
|
||||
}
|
||||
|
||||
contactRecordBuilder.setIdentity(identityBuilder.build());
|
||||
|
||||
ContactRecord.Profile.Builder profileBuilder = ContactRecord.Profile.newBuilder();
|
||||
|
||||
if (contact.getProfileKey().isPresent()) {
|
||||
profileBuilder.setKey(ByteString.copyFrom(contact.getProfileKey().get()));
|
||||
}
|
||||
|
||||
if (contact.getProfileName().isPresent()) {
|
||||
profileBuilder.setName(contact.getProfileName().get());
|
||||
}
|
||||
|
||||
if (contact.getUsername().isPresent()) {
|
||||
profileBuilder.setUsername(contact.getUsername().get());
|
||||
}
|
||||
|
||||
contactRecordBuilder.setProfile(profileBuilder.build());
|
||||
|
||||
return contactRecordBuilder.build();
|
||||
}
|
||||
|
||||
private static class InvalidStorageWriteError extends Error {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
package org.whispersystems.signalservice.api.storage;
|
||||
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageRecord;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
public class SignalStorageRecord {
|
||||
|
||||
private final byte[] key;
|
||||
private final int type;
|
||||
private final Optional<SignalContactRecord> contact;
|
||||
|
||||
public static SignalStorageRecord forContact(byte[] key, SignalContactRecord contact) {
|
||||
return new SignalStorageRecord(key, StorageRecord.Type.CONTACT_VALUE, Optional.of(contact));
|
||||
}
|
||||
|
||||
public static SignalStorageRecord forUnknown(byte[] key, int type) {
|
||||
return new SignalStorageRecord(key, type, Optional.<SignalContactRecord>absent());
|
||||
}
|
||||
|
||||
private SignalStorageRecord(byte key[], int type, Optional<SignalContactRecord> contact) {
|
||||
this.key = key;
|
||||
this.type = type;
|
||||
this.contact = contact;
|
||||
}
|
||||
|
||||
public byte[] getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
public int getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public Optional<SignalContactRecord> getContact() {
|
||||
return contact;
|
||||
}
|
||||
|
||||
public boolean isUnknown() {
|
||||
return !contact.isPresent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
SignalStorageRecord record = (SignalStorageRecord) o;
|
||||
return type == record.type &&
|
||||
Arrays.equals(key, record.key) &&
|
||||
contact.equals(record.contact);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = Objects.hash(type, contact);
|
||||
result = 31 * result + Arrays.hashCode(key);
|
||||
return result;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package org.whispersystems.signalservice.api.storage;
|
||||
|
||||
import org.whispersystems.signalservice.api.crypto.CryptoUtil;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public final class SignalStorageUtil {
|
||||
|
||||
public static byte[] computeStorageServiceKey(byte[] kbsMasterKey) {
|
||||
return CryptoUtil.computeHmacSha256(kbsMasterKey, "Storage Service Encryption".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
package org.whispersystems.signalservice.api.storage;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
public class StorageAuthResponse {
|
||||
|
||||
@JsonProperty
|
||||
private String username;
|
||||
|
||||
@JsonProperty
|
||||
private String password;
|
||||
|
||||
public StorageAuthResponse() { }
|
||||
|
||||
public StorageAuthResponse(String username, String password) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
}
|
|
@ -7,15 +7,19 @@ public class SignalServiceConfiguration {
|
|||
private final SignalCdnUrl[] signalCdnUrls;
|
||||
private final SignalContactDiscoveryUrl[] signalContactDiscoveryUrls;
|
||||
private final SignalKeyBackupServiceUrl[] signalKeyBackupServiceUrls;
|
||||
private final SignalStorageUrl[] signalStorageUrls;
|
||||
|
||||
public SignalServiceConfiguration(SignalServiceUrl[] signalServiceUrls,
|
||||
SignalCdnUrl[] signalCdnUrls,
|
||||
SignalContactDiscoveryUrl[] signalContactDiscoveryUrls,
|
||||
SignalKeyBackupServiceUrl[] signalKeyBackupServiceUrls) {
|
||||
SignalKeyBackupServiceUrl[] signalKeyBackupServiceUrls,
|
||||
SignalStorageUrl[] signalStorageUrls)
|
||||
{
|
||||
this.signalServiceUrls = signalServiceUrls;
|
||||
this.signalCdnUrls = signalCdnUrls;
|
||||
this.signalContactDiscoveryUrls = signalContactDiscoveryUrls;
|
||||
this.signalKeyBackupServiceUrls = signalKeyBackupServiceUrls;
|
||||
this.signalStorageUrls = signalStorageUrls;
|
||||
}
|
||||
|
||||
public SignalServiceUrl[] getSignalServiceUrls() {
|
||||
|
@ -33,4 +37,8 @@ public class SignalServiceConfiguration {
|
|||
public SignalKeyBackupServiceUrl[] getSignalKeyBackupServiceUrls() {
|
||||
return signalKeyBackupServiceUrls;
|
||||
}
|
||||
|
||||
public SignalStorageUrl[] getSignalStorageUrls() {
|
||||
return signalStorageUrls;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
package org.whispersystems.signalservice.internal.configuration;
|
||||
|
||||
|
||||
import org.whispersystems.signalservice.api.push.TrustStore;
|
||||
|
||||
import okhttp3.ConnectionSpec;
|
||||
|
||||
public class SignalStorageUrl extends SignalUrl {
|
||||
|
||||
public SignalStorageUrl(String url, TrustStore trustStore) {
|
||||
super(url, trustStore);
|
||||
}
|
||||
|
||||
public SignalStorageUrl(String url, String hostHeader, TrustStore trustStore, ConnectionSpec connectionSpec) {
|
||||
super(url, hostHeader, trustStore, connectionSpec);
|
||||
}
|
||||
}
|
|
@ -27,6 +27,7 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
|||
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
|
||||
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.NonSuccessfulResponseCodeException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
||||
|
@ -36,6 +37,7 @@ import org.whispersystems.signalservice.api.push.exceptions.RemoteAttestationRes
|
|||
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException;
|
||||
import org.whispersystems.signalservice.api.storage.StorageAuthResponse;
|
||||
import org.whispersystems.signalservice.api.util.CredentialsProvider;
|
||||
import org.whispersystems.signalservice.api.util.Tls12SocketFactory;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
@ -50,6 +52,10 @@ import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevic
|
|||
import org.whispersystems.signalservice.internal.push.exceptions.StaleDevicesException;
|
||||
import org.whispersystems.signalservice.internal.push.http.DigestingRequestBody;
|
||||
import org.whispersystems.signalservice.internal.push.http.OutputStreamFactory;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ReadOperation;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageItems;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageManifest;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.WriteOperation;
|
||||
import org.whispersystems.signalservice.internal.util.BlacklistingTrustManager;
|
||||
import org.whispersystems.signalservice.internal.util.Hex;
|
||||
import org.whispersystems.signalservice.internal.util.JsonUtil;
|
||||
|
@ -79,15 +85,14 @@ import java.util.Set;
|
|||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import javax.net.ssl.HostnameVerifier;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLSession;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
import okhttp3.Call;
|
||||
import okhttp3.ConnectionSpec;
|
||||
import okhttp3.Credentials;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.MultipartBody;
|
||||
import okhttp3.OkHttpClient;
|
||||
|
@ -158,6 +163,7 @@ public class PushServiceSocket {
|
|||
private final ConnectionHolder[] cdnClients;
|
||||
private final ConnectionHolder[] contactDiscoveryClients;
|
||||
private final ConnectionHolder[] keyBackupServiceClients;
|
||||
private final ConnectionHolder[] storageClients;
|
||||
private final OkHttpClient attachmentClient;
|
||||
|
||||
private final CredentialsProvider credentialsProvider;
|
||||
|
@ -171,6 +177,7 @@ public class PushServiceSocket {
|
|||
this.cdnClients = createConnectionHolders(signalServiceConfiguration.getSignalCdnUrls());
|
||||
this.contactDiscoveryClients = createConnectionHolders(signalServiceConfiguration.getSignalContactDiscoveryUrls());
|
||||
this.keyBackupServiceClients = createConnectionHolders(signalServiceConfiguration.getSignalKeyBackupServiceUrls());
|
||||
this.storageClients = createConnectionHolders(signalServiceConfiguration.getSignalStorageUrls());
|
||||
this.attachmentClient = createAttachmentClient();
|
||||
this.random = new SecureRandom();
|
||||
}
|
||||
|
@ -203,7 +210,7 @@ public class PushServiceSocket {
|
|||
} else if (challenge.isPresent()) {
|
||||
path += "?challenge=" + challenge.get();
|
||||
}
|
||||
|
||||
|
||||
makeServiceRequest(path, "GET", null, headers, new ResponseCodeHandler() {
|
||||
@Override
|
||||
public void handle(int responseCode) throws NonSuccessfulResponseCodeException {
|
||||
|
@ -426,8 +433,7 @@ public class PushServiceSocket {
|
|||
|
||||
public PreKeyBundle getPreKey(SignalServiceAddress destination, int deviceId) throws IOException {
|
||||
try {
|
||||
String path = String.format(PREKEY_DEVICE_PATH, destination.getIdentifier(),
|
||||
String.valueOf(deviceId));
|
||||
String path = String.format(PREKEY_DEVICE_PATH, destination.getIdentifier(), String.valueOf(deviceId));
|
||||
|
||||
if (destination.getRelay().isPresent()) {
|
||||
path = path + "?relay=" + destination.getRelay().get();
|
||||
|
@ -543,7 +549,7 @@ public class PushServiceSocket {
|
|||
}
|
||||
|
||||
public void retrieveProfileAvatar(String path, File destination, int maxSizeBytes)
|
||||
throws NonSuccessfulResponseCodeException, PushNetworkException
|
||||
throws NonSuccessfulResponseCodeException, PushNetworkException
|
||||
{
|
||||
downloadFromCdn(destination, path, maxSizeBytes, null);
|
||||
}
|
||||
|
@ -688,6 +694,42 @@ public class PushServiceSocket {
|
|||
return JsonUtil.fromJson(response, TurnServerInfo.class);
|
||||
}
|
||||
|
||||
public String getStorageAuth() throws IOException {
|
||||
String response = makeServiceRequest("/v1/storage/auth", "GET", null);
|
||||
StorageAuthResponse authResponse = JsonUtil.fromJson(response, StorageAuthResponse.class);
|
||||
|
||||
return Credentials.basic(authResponse.getUsername(), authResponse.getPassword());
|
||||
}
|
||||
|
||||
public StorageManifest getStorageManifest(String authToken) throws IOException {
|
||||
Response response = makeStorageRequest(authToken, "/v1/storage/manifest", "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());
|
||||
|
||||
if (response.body() == null) {
|
||||
throw new IOException("Missing body!");
|
||||
}
|
||||
|
||||
return StorageItems.parseFrom(response.body().bytes());
|
||||
}
|
||||
|
||||
public Optional<StorageManifest> writeStorageContacts(String authToken, WriteOperation writeOperation) throws IOException {
|
||||
try {
|
||||
makeStorageRequest(authToken, "/v1/storage", "PUT", writeOperation.toByteArray());
|
||||
return Optional.absent();
|
||||
} catch (ContactManifestMismatchException e) {
|
||||
return Optional.of(StorageManifest.parseFrom(e.getResponseBody()));
|
||||
}
|
||||
}
|
||||
|
||||
public void setSoTimeoutMillis(long soTimeoutMillis) {
|
||||
this.soTimeoutMillis = soTimeoutMillis;
|
||||
}
|
||||
|
@ -812,17 +854,17 @@ public class PushServiceSocket {
|
|||
DigestingRequestBody file = new DigestingRequestBody(data, outputStreamFactory, contentType, length, progressListener);
|
||||
|
||||
RequestBody requestBody = new MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("acl", acl)
|
||||
.addFormDataPart("key", key)
|
||||
.addFormDataPart("policy", policy)
|
||||
.addFormDataPart("Content-Type", contentType)
|
||||
.addFormDataPart("x-amz-algorithm", algorithm)
|
||||
.addFormDataPart("x-amz-credential", credential)
|
||||
.addFormDataPart("x-amz-date", date)
|
||||
.addFormDataPart("x-amz-signature", signature)
|
||||
.addFormDataPart("file", "file", file)
|
||||
.build();
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("acl", acl)
|
||||
.addFormDataPart("key", key)
|
||||
.addFormDataPart("policy", policy)
|
||||
.addFormDataPart("Content-Type", contentType)
|
||||
.addFormDataPart("x-amz-algorithm", algorithm)
|
||||
.addFormDataPart("x-amz-credential", credential)
|
||||
.addFormDataPart("x-amz-date", date)
|
||||
.addFormDataPart("x-amz-signature", signature)
|
||||
.addFormDataPart("file", "file", file)
|
||||
.build();
|
||||
|
||||
Request.Builder request = new Request.Builder()
|
||||
.url(connectionHolder.getUrl() + "/" + path)
|
||||
|
@ -967,8 +1009,7 @@ public class PushServiceSocket {
|
|||
}
|
||||
|
||||
if (responseCode != 200 && responseCode != 204) {
|
||||
throw new NonSuccessfulResponseCodeException("Bad response: " + responseCode + " " +
|
||||
responseMessage);
|
||||
throw new NonSuccessfulResponseCodeException("Bad response: " + responseCode + " " + responseMessage);
|
||||
}
|
||||
|
||||
return responseBody;
|
||||
|
@ -1118,6 +1159,75 @@ public class PushServiceSocket {
|
|||
throw new NonSuccessfulResponseCodeException("Response: " + response);
|
||||
}
|
||||
|
||||
private Response makeStorageRequest(String authorization, String path, String method, byte[] body)
|
||||
throws PushNetworkException, NonSuccessfulResponseCodeException
|
||||
{
|
||||
ConnectionHolder connectionHolder = getRandom(storageClients, random);
|
||||
OkHttpClient okHttpClient = connectionHolder.getClient()
|
||||
.newBuilder()
|
||||
.connectTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS)
|
||||
.build();
|
||||
|
||||
Request.Builder request = new Request.Builder().url(connectionHolder.getUrl() + path);
|
||||
|
||||
if (body != null) {
|
||||
request.method(method, RequestBody.create(MediaType.parse("application/x-protobuf"), body));
|
||||
} else {
|
||||
request.method(method, null);
|
||||
}
|
||||
|
||||
if (connectionHolder.getHostHeader().isPresent()) {
|
||||
request.addHeader("Host", connectionHolder.getHostHeader().get());
|
||||
}
|
||||
|
||||
if (authorization != null) {
|
||||
request.addHeader("Authorization", authorization);
|
||||
}
|
||||
|
||||
Call call = okHttpClient.newCall(request.build());
|
||||
|
||||
synchronized (connections) {
|
||||
connections.add(call);
|
||||
}
|
||||
|
||||
Response response;
|
||||
|
||||
try {
|
||||
response = call.execute();
|
||||
|
||||
if (response.isSuccessful()) {
|
||||
return response;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new PushNetworkException(e);
|
||||
} finally {
|
||||
synchronized (connections) {
|
||||
connections.remove(call);
|
||||
}
|
||||
}
|
||||
|
||||
switch (response.code()) {
|
||||
case 401:
|
||||
case 403:
|
||||
throw new AuthorizationFailedException("Authorization failed!");
|
||||
case 404:
|
||||
throw new NotFoundException("Not found");
|
||||
case 409:
|
||||
if (response.body() != null) {
|
||||
try {
|
||||
throw new ContactManifestMismatchException(response.body().bytes());
|
||||
} catch (IOException e) {
|
||||
throw new PushNetworkException(e);
|
||||
}
|
||||
}
|
||||
case 429:
|
||||
throw new RateLimitException("Rate limit exceeded: " + response.code());
|
||||
}
|
||||
|
||||
throw new NonSuccessfulResponseCodeException("Response: " + response);
|
||||
}
|
||||
|
||||
private ServiceConnectionHolder[] createServiceConnectionHolders(SignalUrl[] urls) {
|
||||
List<ServiceConnectionHolder> serviceConnectionHolders = new LinkedList<>();
|
||||
|
||||
|
|
|
@ -82,7 +82,7 @@ public class Util {
|
|||
return result;
|
||||
}
|
||||
|
||||
public static String readFully(InputStream in) throws IOException {
|
||||
public static byte[] readFullyAsBytes(InputStream in) throws IOException {
|
||||
ByteArrayOutputStream bout = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[4096];
|
||||
int read;
|
||||
|
@ -93,7 +93,11 @@ public class Util {
|
|||
|
||||
in.close();
|
||||
|
||||
return new String(bout.toByteArray());
|
||||
return bout.toByteArray();
|
||||
}
|
||||
|
||||
public static String readFully(InputStream in) throws IOException {
|
||||
return new String(readFullyAsBytes(in));
|
||||
}
|
||||
|
||||
public static void readFully(InputStream in, byte[] buffer) throws IOException {
|
||||
|
|
|
@ -45,6 +45,7 @@ import org.thoughtcrime.securesms.insights.InsightsOptOut;
|
|||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||
import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob;
|
||||
import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.StorageSyncJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
|
||||
import org.thoughtcrime.securesms.logging.AndroidLogger;
|
||||
|
|
|
@ -6,6 +6,8 @@ import androidx.annotation.NonNull;
|
|||
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.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
|
||||
|
@ -21,15 +23,28 @@ public class DirectoryHelper {
|
|||
} else {
|
||||
DirectoryHelperV1.refreshDirectory(context, notifyOfNewUsers);
|
||||
}
|
||||
|
||||
if (FeatureFlags.STORAGE_SERVICE) {
|
||||
ApplicationDependencies.getJobManager().add(new StorageSyncJob());
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static RegisteredState refreshDirectoryFor(@NonNull Context context, @NonNull Recipient recipient, boolean notifyOfNewUsers) throws IOException {
|
||||
RegisteredState originalRegisteredState = recipient.resolve().getRegistered();
|
||||
RegisteredState newRegisteredState = null;
|
||||
|
||||
if (FeatureFlags.UUIDS) {
|
||||
// TODO [greyson] Create a DirectoryHelperV2 when appropriate.
|
||||
return DirectoryHelperV1.refreshDirectoryFor(context, recipient, notifyOfNewUsers);
|
||||
newRegisteredState = DirectoryHelperV1.refreshDirectoryFor(context, recipient, notifyOfNewUsers);
|
||||
} else {
|
||||
return DirectoryHelperV1.refreshDirectoryFor(context, recipient, notifyOfNewUsers);
|
||||
newRegisteredState = DirectoryHelperV1.refreshDirectoryFor(context, recipient, notifyOfNewUsers);
|
||||
}
|
||||
|
||||
if (FeatureFlags.STORAGE_SERVICE && newRegisteredState != originalRegisteredState) {
|
||||
ApplicationDependencies.getJobManager().add(new StorageSyncJob());
|
||||
}
|
||||
|
||||
return newRegisteredState;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,7 +20,6 @@ import androidx.annotation.WorkerThread;
|
|||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.contacts.ContactAccessor;
|
||||
import org.thoughtcrime.securesms.crypto.SessionUtil;
|
||||
|
@ -155,7 +154,7 @@ class DirectoryHelperV1 {
|
|||
DatabaseFactory.getContactsDatabase(context).setRegisteredUsers(account.get().getAccount(), activeAddresses, removeMissing);
|
||||
|
||||
Cursor cursor = ContactAccessor.getInstance().getAllSystemContacts(context);
|
||||
RecipientDatabase.BulkOperationsHandle handle = DatabaseFactory.getRecipientDatabase(context).resetAllSystemContactInfo();
|
||||
RecipientDatabase.BulkOperationsHandle handle = DatabaseFactory.getRecipientDatabase(context).beginBulkSystemContactUpdate();
|
||||
|
||||
try {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
|
|
|
@ -0,0 +1,537 @@
|
|||
package org.thoughtcrime.securesms.contacts.sync;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings;
|
||||
import org.thoughtcrime.securesms.database.StorageKeyDatabase;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.SetUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
|
||||
import org.whispersystems.signalservice.api.storage.SignalContactRecord.IdentityState;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageManifest;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class StorageSyncHelper {
|
||||
|
||||
private static final String TAG = Log.tag(StorageSyncHelper.class);
|
||||
|
||||
private static final KeyGenerator KEY_GENERATOR = () -> Util.getSecretBytes(16);
|
||||
|
||||
private static KeyGenerator testKeyGenerator = null;
|
||||
|
||||
/**
|
||||
* Given the local state of pending storage mutatations, this will generate a result that will
|
||||
* include that data that needs to be written to the storage service, as well as any changes you
|
||||
* need to write back to local storage (like storage keys that might have changed for updated
|
||||
* contacts).
|
||||
*
|
||||
* @param currentManifestVersion What you think the version is locally.
|
||||
* @param currentLocalKeys All local keys you have. This assumes that 'inserts' were given keys
|
||||
* already, and that deletes still have keys.
|
||||
* @param updates Contacts that have been altered.
|
||||
* @param inserts Contacts that have been inserted (or newly marked as registered).
|
||||
* @param deletes Contacts that are no longer registered.
|
||||
*
|
||||
* @return If changes need to be written, then it will return those changes. If no changes need
|
||||
* to be written, this will return {@link Optional#absent()}.
|
||||
*/
|
||||
public static @NonNull Optional<LocalWriteResult> buildStorageUpdatesForLocal(long currentManifestVersion,
|
||||
@NonNull List<byte[]> currentLocalKeys,
|
||||
@NonNull List<RecipientSettings> updates,
|
||||
@NonNull List<RecipientSettings> inserts,
|
||||
@NonNull List<RecipientSettings> deletes)
|
||||
{
|
||||
Set<ByteBuffer> completeKeys = new LinkedHashSet<>(Stream.of(currentLocalKeys).map(ByteBuffer::wrap).toList());
|
||||
Set<SignalContactRecord> contactInserts = new LinkedHashSet<>();
|
||||
Set<ByteBuffer> contactDeletes = new LinkedHashSet<>();
|
||||
Map<RecipientId, byte[]> storageKeyUpdates = new HashMap<>();
|
||||
|
||||
for (RecipientSettings insert : inserts) {
|
||||
contactInserts.add(localToRemoteContact(insert));
|
||||
}
|
||||
|
||||
for (RecipientSettings delete : deletes) {
|
||||
byte[] key = Objects.requireNonNull(delete.getStorageKey());
|
||||
contactDeletes.add(ByteBuffer.wrap(key));
|
||||
completeKeys.remove(ByteBuffer.wrap(key));
|
||||
}
|
||||
|
||||
for (RecipientSettings update : updates) {
|
||||
byte[] oldKey = Objects.requireNonNull(update.getStorageKey());
|
||||
byte[] newKey = generateKey();
|
||||
|
||||
contactInserts.add(localToRemoteContact(update, newKey));
|
||||
contactDeletes.add(ByteBuffer.wrap(oldKey));
|
||||
completeKeys.remove(ByteBuffer.wrap(oldKey));
|
||||
completeKeys.add(ByteBuffer.wrap(newKey));
|
||||
storageKeyUpdates.put(update.getId(), newKey);
|
||||
}
|
||||
|
||||
if (contactInserts.isEmpty() && contactDeletes.isEmpty()) {
|
||||
return Optional.absent();
|
||||
} else {
|
||||
List<SignalStorageRecord> storageInserts = Stream.of(contactInserts).map(c -> SignalStorageRecord.forContact(c.getKey(), c)).toList();
|
||||
List<byte[]> contactDeleteBytes = Stream.of(contactDeletes).map(ByteBuffer::array).toList();
|
||||
List<byte[]> completeKeysBytes = Stream.of(completeKeys).map(ByteBuffer::array).toList();
|
||||
SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, completeKeysBytes);
|
||||
WriteOperationResult writeOperationResult = new WriteOperationResult(manifest, storageInserts, contactDeleteBytes);
|
||||
|
||||
return Optional.of(new LocalWriteResult(writeOperationResult, storageKeyUpdates));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of all the local and remote keys you know about, this will return a result telling
|
||||
* you which keys are exclusively remote and which are exclusively local.
|
||||
*
|
||||
* @param remoteKeys All remote keys available.
|
||||
* @param localKeys All local keys available.
|
||||
*
|
||||
* @return An object describing which keys are exclusive to the remote data set and which keys are
|
||||
* exclusive to the local data set.
|
||||
*/
|
||||
public static @NonNull KeyDifferenceResult findKeyDifference(@NonNull List<byte[]> remoteKeys,
|
||||
@NonNull List<byte[]> localKeys)
|
||||
{
|
||||
Set<ByteBuffer> allRemoteKeys = Stream.of(remoteKeys).map(ByteBuffer::wrap).collect(LinkedHashSet::new, HashSet::add);
|
||||
Set<ByteBuffer> allLocalKeys = Stream.of(localKeys).map(ByteBuffer::wrap).collect(LinkedHashSet::new, HashSet::add);
|
||||
|
||||
Set<ByteBuffer> remoteOnlyKeys = SetUtil.difference(allRemoteKeys, allLocalKeys);
|
||||
Set<ByteBuffer> localOnlyKeys = SetUtil.difference(allLocalKeys, allRemoteKeys);
|
||||
|
||||
return new KeyDifferenceResult(Stream.of(remoteOnlyKeys).map(ByteBuffer::array).toList(),
|
||||
Stream.of(localOnlyKeys).map(ByteBuffer::array).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Given two sets of storage records, this will resolve the data into a set of actions that need
|
||||
* to be applied to resolve the differences. This will handle discovering which records between
|
||||
* the two collections refer to the same contacts and are actually updates, which are brand new,
|
||||
* etc.
|
||||
*
|
||||
* @param remoteOnlyRecords Records that are only present remotely.
|
||||
* @param localOnlyRecords Records that are only present locally.
|
||||
*
|
||||
* @return A set of actions that should be applied to resolve the conflict.
|
||||
*/
|
||||
public static @NonNull MergeResult resolveConflict(@NonNull Collection<SignalStorageRecord> remoteOnlyRecords,
|
||||
@NonNull Collection<SignalStorageRecord> localOnlyRecords)
|
||||
{
|
||||
List<SignalContactRecord> remoteOnlyContacts = Stream.of(remoteOnlyRecords).filter(r -> r.getContact().isPresent()).map(r -> r.getContact().get()).toList();
|
||||
List<SignalContactRecord> localOnlyContacts = Stream.of(localOnlyRecords).filter(r -> r.getContact().isPresent()).map(r -> r.getContact().get()).toList();
|
||||
|
||||
List<SignalStorageRecord> remoteOnlyUnknowns = Stream.of(remoteOnlyRecords).filter(SignalStorageRecord::isUnknown).toList();
|
||||
List<SignalStorageRecord> localOnlyUnknowns = Stream.of(localOnlyRecords).filter(SignalStorageRecord::isUnknown).toList();
|
||||
|
||||
ContactRecordMergeResult contactMergeResult = resolveContactConflict(remoteOnlyContacts, localOnlyContacts);
|
||||
|
||||
return new MergeResult(contactMergeResult.localInserts,
|
||||
contactMergeResult.localUpdates,
|
||||
contactMergeResult.remoteInserts,
|
||||
contactMergeResult.remoteUpdates,
|
||||
new LinkedHashSet<>(remoteOnlyUnknowns),
|
||||
new LinkedHashSet<>(localOnlyUnknowns));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assumes that the merge result has *not* yet been applied to the local data. That means that
|
||||
* this method will handle generating the correct final key set based on the merge result.
|
||||
*/
|
||||
public static @NonNull WriteOperationResult createWriteOperation(long currentManifestVersion,
|
||||
@NonNull List<byte[]> currentLocalStorageKeys,
|
||||
@NonNull MergeResult mergeResult)
|
||||
{
|
||||
Set<ByteBuffer> completeKeys = new LinkedHashSet<>(Stream.of(currentLocalStorageKeys).map(ByteBuffer::wrap).toList());
|
||||
|
||||
for (SignalContactRecord insert : mergeResult.getLocalContactInserts()) {
|
||||
completeKeys.add(ByteBuffer.wrap(insert.getKey()));
|
||||
}
|
||||
|
||||
for (SignalContactRecord insert : mergeResult.getRemoteContactInserts()) {
|
||||
completeKeys.add(ByteBuffer.wrap(insert.getKey()));
|
||||
}
|
||||
|
||||
for (SignalStorageRecord insert : mergeResult.getLocalUnknownInserts()) {
|
||||
completeKeys.add(ByteBuffer.wrap(insert.getKey()));
|
||||
}
|
||||
|
||||
for (ContactUpdate update : mergeResult.getLocalContactUpdates()) {
|
||||
completeKeys.remove(ByteBuffer.wrap(update.getOldContact().getKey()));
|
||||
completeKeys.add(ByteBuffer.wrap(update.getNewContact().getKey()));
|
||||
}
|
||||
|
||||
for (ContactUpdate update : mergeResult.getRemoteContactUpdates()) {
|
||||
completeKeys.remove(ByteBuffer.wrap(update.getOldContact().getKey()));
|
||||
completeKeys.add(ByteBuffer.wrap(update.getNewContact().getKey()));
|
||||
}
|
||||
|
||||
SignalStorageManifest manifest = new SignalStorageManifest(currentManifestVersion + 1, Stream.of(completeKeys).map(ByteBuffer::array).toList());
|
||||
|
||||
List<SignalContactRecord> contactInserts = new ArrayList<>();
|
||||
contactInserts.addAll(mergeResult.getRemoteContactInserts());
|
||||
contactInserts.addAll(Stream.of(mergeResult.getRemoteContactUpdates()).map(ContactUpdate::getNewContact).toList());
|
||||
|
||||
List<SignalStorageRecord> inserts = Stream.of(contactInserts).map(c -> SignalStorageRecord.forContact(c.getKey(), c)).toList();
|
||||
|
||||
List<byte[]> deletes = Stream.of(mergeResult.getRemoteContactUpdates()).map(ContactUpdate::getOldContact).map(SignalContactRecord::getKey).toList();
|
||||
|
||||
return new WriteOperationResult(manifest, inserts, deletes);
|
||||
}
|
||||
|
||||
public static @NonNull SignalContactRecord localToRemoteContact(@NonNull RecipientSettings recipient) {
|
||||
if (recipient.getStorageKey() == null) {
|
||||
throw new AssertionError("Must have a storage key!");
|
||||
}
|
||||
|
||||
return localToRemoteContact(recipient, recipient.getStorageKey());
|
||||
}
|
||||
|
||||
private static @NonNull SignalContactRecord localToRemoteContact(@NonNull RecipientSettings recipient, byte[] storageKey) {
|
||||
if (recipient.getUuid() == null && recipient.getE164() == null) {
|
||||
throw new AssertionError("Must have either a UUID or a phone number!");
|
||||
}
|
||||
|
||||
return new SignalContactRecord.Builder(storageKey, new SignalServiceAddress(recipient.getUuid(), recipient.getE164()))
|
||||
.setProfileKey(recipient.getProfileKey())
|
||||
.setProfileName(recipient.getProfileName())
|
||||
.setBlocked(recipient.isBlocked())
|
||||
.setProfileSharingEnabled(recipient.isProfileSharing())
|
||||
.setIdentityKey(recipient.getIdentityKey())
|
||||
.setIdentityState(localToRemoteIdentityState(recipient.getIdentityStatus()))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static @NonNull IdentityDatabase.VerifiedStatus remoteToLocalIdentityStatus(@NonNull IdentityState identityState) {
|
||||
switch (identityState) {
|
||||
case VERIFIED: return IdentityDatabase.VerifiedStatus.VERIFIED;
|
||||
case UNVERIFIED: return IdentityDatabase.VerifiedStatus.UNVERIFIED;
|
||||
default: return IdentityDatabase.VerifiedStatus.DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
public static @NonNull byte[] generateKey() {
|
||||
if (testKeyGenerator != null) {
|
||||
return testKeyGenerator.generate();
|
||||
} else {
|
||||
return KEY_GENERATOR.generate();
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static @NonNull SignalContactRecord mergeContacts(@NonNull SignalContactRecord remote,
|
||||
@NonNull SignalContactRecord local)
|
||||
{
|
||||
UUID uuid = remote.getAddress().getUuid().or(local.getAddress().getUuid()).orNull();
|
||||
String e164 = remote.getAddress().getNumber().or(local.getAddress().getNumber()).orNull();
|
||||
SignalServiceAddress address = new SignalServiceAddress(uuid, e164);
|
||||
String profileName = remote.getProfileName().or(local.getProfileName()).orNull();
|
||||
byte[] profileKey = remote.getProfileKey().or(local.getProfileKey()).orNull();
|
||||
String username = remote.getUsername().or(local.getUsername()).orNull();
|
||||
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
|
||||
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);
|
||||
|
||||
if (remote.getProtoVersion() > 0) {
|
||||
Log.w(TAG, "Inbound model has version " + remote.getProtoVersion() + ", but our version is 0.");
|
||||
}
|
||||
|
||||
if (matchesRemote) {
|
||||
return remote;
|
||||
} else if (matchesLocal) {
|
||||
return local;
|
||||
} else {
|
||||
return new SignalContactRecord.Builder(generateKey(), address)
|
||||
.setProfileName(profileName)
|
||||
.setProfileKey(profileKey)
|
||||
.setUsername(username)
|
||||
.setIdentityState(identityState)
|
||||
.setIdentityKey(identityKey)
|
||||
.setBlocked(blocked)
|
||||
.setProfileSharingEnabled(profileSharing)
|
||||
.setNickname(nickname)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static void setTestKeyGenerator(@Nullable KeyGenerator keyGenerator) {
|
||||
testKeyGenerator = keyGenerator;
|
||||
}
|
||||
|
||||
private static IdentityState localToRemoteIdentityState(@NonNull IdentityDatabase.VerifiedStatus local) {
|
||||
switch (local) {
|
||||
case VERIFIED: return IdentityState.VERIFIED;
|
||||
case UNVERIFIED: return IdentityState.UNVERIFIED;
|
||||
default: return IdentityState.DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean doParamsMatchContact(@NonNull SignalContactRecord contact,
|
||||
@NonNull SignalServiceAddress address,
|
||||
@Nullable String profileName,
|
||||
@Nullable byte[] profileKey,
|
||||
@Nullable String username,
|
||||
@Nullable IdentityState identityState,
|
||||
@Nullable byte[] identityKey,
|
||||
boolean blocked,
|
||||
boolean profileSharing,
|
||||
@Nullable String nickname)
|
||||
{
|
||||
return Objects.equals(contact.getAddress(), address) &&
|
||||
Objects.equals(contact.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);
|
||||
}
|
||||
|
||||
private static @NonNull ContactRecordMergeResult resolveContactConflict(@NonNull Collection<SignalContactRecord> remoteOnlyRecords,
|
||||
@NonNull Collection<SignalContactRecord> localOnlyRecords)
|
||||
{
|
||||
Map<UUID, SignalContactRecord> localByUuid = new HashMap<>();
|
||||
Map<String, SignalContactRecord> localByE164 = new HashMap<>();
|
||||
|
||||
for (SignalContactRecord contact : localOnlyRecords) {
|
||||
if (contact.getAddress().getUuid().isPresent()) {
|
||||
localByUuid.put(contact.getAddress().getUuid().get(), contact);
|
||||
}
|
||||
if (contact.getAddress().getNumber().isPresent()) {
|
||||
localByE164.put(contact.getAddress().getNumber().get(), contact);
|
||||
}
|
||||
}
|
||||
|
||||
Set<SignalContactRecord> localInserts = new LinkedHashSet<>(remoteOnlyRecords);
|
||||
Set<SignalContactRecord> remoteInserts = new LinkedHashSet<>(localOnlyRecords);
|
||||
Set<ContactUpdate> localUpdates = new LinkedHashSet<>();
|
||||
Set<ContactUpdate> remoteUpdates = new LinkedHashSet<>();
|
||||
|
||||
for (SignalContactRecord remote : remoteOnlyRecords) {
|
||||
SignalContactRecord localUuid = remote.getAddress().getUuid().isPresent() ? localByUuid.get(remote.getAddress().getUuid().get()) : null;
|
||||
SignalContactRecord localE164 = remote.getAddress().getNumber().isPresent() ? localByE164.get(remote.getAddress().getNumber().get()) : null;
|
||||
|
||||
Optional<SignalContactRecord> local = Optional.fromNullable(localUuid).or(Optional.fromNullable(localE164));
|
||||
|
||||
if (local.isPresent()) {
|
||||
SignalContactRecord merged = mergeContacts(remote, local.get());
|
||||
|
||||
if (!merged.equals(remote)) {
|
||||
remoteUpdates.add(new ContactUpdate(remote, merged));
|
||||
}
|
||||
|
||||
if (!merged.equals(local.get())) {
|
||||
localUpdates.add(new ContactUpdate(local.get(), merged));
|
||||
}
|
||||
|
||||
localInserts.remove(remote);
|
||||
remoteInserts.remove(local.get());
|
||||
}
|
||||
}
|
||||
|
||||
return new ContactRecordMergeResult(localInserts, localUpdates, remoteInserts, remoteUpdates);
|
||||
}
|
||||
|
||||
public static final class ContactUpdate {
|
||||
private final SignalContactRecord oldContact;
|
||||
private final SignalContactRecord newContact;
|
||||
|
||||
public ContactUpdate(@NonNull SignalContactRecord oldContact, @NonNull SignalContactRecord newContact) {
|
||||
this.oldContact = oldContact;
|
||||
this.newContact = newContact;
|
||||
}
|
||||
|
||||
public @NonNull
|
||||
SignalContactRecord getOldContact() {
|
||||
return oldContact;
|
||||
}
|
||||
|
||||
public @NonNull
|
||||
SignalContactRecord getNewContact() {
|
||||
return newContact;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
ContactUpdate that = (ContactUpdate) o;
|
||||
return oldContact.equals(that.oldContact) &&
|
||||
newContact.equals(that.newContact);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(oldContact, newContact);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class KeyDifferenceResult {
|
||||
private final List<byte[]> remoteOnlyKeys;
|
||||
private final List<byte[]> localOnlyKeys;
|
||||
|
||||
private KeyDifferenceResult(@NonNull List<byte[]> remoteOnlyKeys, @NonNull List<byte[]> localOnlyKeys) {
|
||||
this.remoteOnlyKeys = remoteOnlyKeys;
|
||||
this.localOnlyKeys = localOnlyKeys;
|
||||
}
|
||||
|
||||
public @NonNull List<byte[]> getRemoteOnlyKeys() {
|
||||
return remoteOnlyKeys;
|
||||
}
|
||||
|
||||
public @NonNull List<byte[]> getLocalOnlyKeys() {
|
||||
return localOnlyKeys;
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return remoteOnlyKeys.isEmpty() && localOnlyKeys.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public static final class MergeResult {
|
||||
private final Set<SignalContactRecord> localContactInserts;
|
||||
private final Set<ContactUpdate> localContactUpdates;
|
||||
private final Set<SignalContactRecord> remoteContactInserts;
|
||||
private final Set<ContactUpdate> remoteContactUpdates;
|
||||
private final Set<SignalStorageRecord> localUnknownInserts;
|
||||
private final Set<SignalStorageRecord> localUnknownDeletes;
|
||||
|
||||
@VisibleForTesting
|
||||
MergeResult(@NonNull Set<SignalContactRecord> localContactInserts,
|
||||
@NonNull Set<ContactUpdate> localContactUpdates,
|
||||
@NonNull Set<SignalContactRecord> remoteContactInserts,
|
||||
@NonNull Set<ContactUpdate> remoteContactUpdates,
|
||||
@NonNull Set<SignalStorageRecord> localUnknownInserts,
|
||||
@NonNull Set<SignalStorageRecord> localUnknownDeletes)
|
||||
{
|
||||
this.localContactInserts = localContactInserts;
|
||||
this.localContactUpdates = localContactUpdates;
|
||||
this.remoteContactInserts = remoteContactInserts;
|
||||
this.remoteContactUpdates = remoteContactUpdates;
|
||||
this.localUnknownInserts = localUnknownInserts;
|
||||
this.localUnknownDeletes = localUnknownDeletes;
|
||||
}
|
||||
|
||||
public @NonNull Set<SignalContactRecord> getLocalContactInserts() {
|
||||
return localContactInserts;
|
||||
}
|
||||
|
||||
public @NonNull Set<ContactUpdate> getLocalContactUpdates() {
|
||||
return localContactUpdates;
|
||||
}
|
||||
|
||||
public @NonNull Set<SignalContactRecord> getRemoteContactInserts() {
|
||||
return remoteContactInserts;
|
||||
}
|
||||
|
||||
public @NonNull Set<ContactUpdate> getRemoteContactUpdates() {
|
||||
return remoteContactUpdates;
|
||||
}
|
||||
|
||||
public @NonNull Set<SignalStorageRecord> getLocalUnknownInserts() {
|
||||
return localUnknownInserts;
|
||||
}
|
||||
|
||||
public @NonNull Set<SignalStorageRecord> getLocalUnknownDeletes() {
|
||||
return localUnknownDeletes;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class WriteOperationResult {
|
||||
private final SignalStorageManifest manifest;
|
||||
private final List<SignalStorageRecord> inserts;
|
||||
private final List<byte[]> deletes;
|
||||
|
||||
private WriteOperationResult(@NonNull SignalStorageManifest manifest,
|
||||
@NonNull List<SignalStorageRecord> inserts,
|
||||
@NonNull List<byte[]> deletes)
|
||||
{
|
||||
this.manifest = manifest;
|
||||
this.inserts = inserts;
|
||||
this.deletes = deletes;
|
||||
}
|
||||
|
||||
public @NonNull SignalStorageManifest getManifest() {
|
||||
return manifest;
|
||||
}
|
||||
|
||||
public @NonNull List<SignalStorageRecord> getInserts() {
|
||||
return inserts;
|
||||
}
|
||||
|
||||
public @NonNull List<byte[]> getDeletes() {
|
||||
return deletes;
|
||||
}
|
||||
}
|
||||
|
||||
public static class LocalWriteResult {
|
||||
private final WriteOperationResult writeResult;
|
||||
private final Map<RecipientId, byte[]> storageKeyUpdates;
|
||||
|
||||
public LocalWriteResult(WriteOperationResult writeResult, Map<RecipientId, byte[]> storageKeyUpdates) {
|
||||
this.writeResult = writeResult;
|
||||
this.storageKeyUpdates = storageKeyUpdates;
|
||||
}
|
||||
|
||||
public @NonNull WriteOperationResult getWriteResult() {
|
||||
return writeResult;
|
||||
}
|
||||
|
||||
public @NonNull Map<RecipientId, byte[]> getStorageKeyUpdates() {
|
||||
return storageKeyUpdates;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ContactRecordMergeResult {
|
||||
final Set<SignalContactRecord> localInserts;
|
||||
final Set<ContactUpdate> localUpdates;
|
||||
final Set<SignalContactRecord> remoteInserts;
|
||||
final Set<ContactUpdate> remoteUpdates;
|
||||
|
||||
ContactRecordMergeResult(@NonNull Set<SignalContactRecord> localInserts,
|
||||
@NonNull Set<ContactUpdate> localUpdates,
|
||||
@NonNull Set<SignalContactRecord> remoteInserts,
|
||||
@NonNull Set<ContactUpdate> remoteUpdates)
|
||||
{
|
||||
this.localInserts = localInserts;
|
||||
this.localUpdates = localUpdates;
|
||||
this.remoteInserts = remoteInserts;
|
||||
this.remoteUpdates = remoteUpdates;
|
||||
}
|
||||
}
|
||||
|
||||
interface KeyGenerator {
|
||||
@NonNull byte[] generate();
|
||||
}
|
||||
}
|
|
@ -59,6 +59,7 @@ public class DatabaseFactory {
|
|||
private final SearchDatabase searchDatabase;
|
||||
private final JobDatabase jobDatabase;
|
||||
private final StickerDatabase stickerDatabase;
|
||||
private final StorageKeyDatabase storageKeyDatabase;
|
||||
|
||||
public static DatabaseFactory getInstance(Context context) {
|
||||
synchronized (lock) {
|
||||
|
@ -145,6 +146,10 @@ public class DatabaseFactory {
|
|||
return getInstance(context).stickerDatabase;
|
||||
}
|
||||
|
||||
public static StorageKeyDatabase getStorageKeyDatabase(Context context) {
|
||||
return getInstance(context).storageKeyDatabase;
|
||||
}
|
||||
|
||||
public static SQLiteDatabase getBackupDatabase(Context context) {
|
||||
return getInstance(context).databaseHelper.getReadableDatabase();
|
||||
}
|
||||
|
@ -181,6 +186,7 @@ public class DatabaseFactory {
|
|||
this.searchDatabase = new SearchDatabase(context, databaseHelper);
|
||||
this.jobDatabase = new JobDatabase(context, databaseHelper);
|
||||
this.stickerDatabase = new StickerDatabase(context, databaseHelper, attachmentSecret);
|
||||
this.storageKeyDatabase = new StorageKeyDatabase(context, databaseHelper);
|
||||
}
|
||||
|
||||
public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret,
|
||||
|
|
|
@ -26,6 +26,7 @@ import net.sqlcipher.database.SQLiteDatabase;
|
|||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
|
@ -39,14 +40,14 @@ public class IdentityDatabase extends Database {
|
|||
@SuppressWarnings("unused")
|
||||
private static final String TAG = IdentityDatabase.class.getSimpleName();
|
||||
|
||||
private static final String TABLE_NAME = "identities";
|
||||
static final String TABLE_NAME = "identities";
|
||||
private static final String ID = "_id";
|
||||
private static final String RECIPIENT_ID = "address";
|
||||
private static final String IDENTITY_KEY = "key";
|
||||
static final String RECIPIENT_ID = "address";
|
||||
static final String IDENTITY_KEY = "key";
|
||||
private static final String TIMESTAMP = "timestamp";
|
||||
private static final String FIRST_USE = "first_use";
|
||||
private static final String NONBLOCKING_APPROVAL = "nonblocking_approval";
|
||||
private static final String VERIFIED = "verified";
|
||||
static final String VERIFIED = "verified";
|
||||
|
||||
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME +
|
||||
" (" + ID + " INTEGER PRIMARY KEY, " +
|
||||
|
@ -112,21 +113,8 @@ public class IdentityDatabase extends Database {
|
|||
public void saveIdentity(@NonNull RecipientId recipientId, IdentityKey identityKey, VerifiedStatus verifiedStatus,
|
||||
boolean firstUse, long timestamp, boolean nonBlockingApproval)
|
||||
{
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
String identityKeyString = Base64.encodeBytes(identityKey.serialize());
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(RECIPIENT_ID, recipientId.serialize());
|
||||
contentValues.put(IDENTITY_KEY, identityKeyString);
|
||||
contentValues.put(TIMESTAMP, timestamp);
|
||||
contentValues.put(VERIFIED, verifiedStatus.toInt());
|
||||
contentValues.put(NONBLOCKING_APPROVAL, nonBlockingApproval ? 1 : 0);
|
||||
contentValues.put(FIRST_USE, firstUse ? 1 : 0);
|
||||
|
||||
database.replace(TABLE_NAME, null, contentValues);
|
||||
|
||||
EventBus.getDefault().post(new IdentityRecord(recipientId, identityKey, verifiedStatus,
|
||||
firstUse, timestamp, nonBlockingApproval));
|
||||
saveIdentityInternal(recipientId, identityKey, verifiedStatus, firstUse, timestamp, nonBlockingApproval);
|
||||
DatabaseFactory.getRecipientDatabase(context).markDirty(recipientId, RecipientDatabase.DirtyState.UPDATE);
|
||||
}
|
||||
|
||||
public void setApproval(@NonNull RecipientId recipientId, boolean nonBlockingApproval) {
|
||||
|
@ -136,6 +124,8 @@ public class IdentityDatabase extends Database {
|
|||
contentValues.put(NONBLOCKING_APPROVAL, nonBlockingApproval);
|
||||
|
||||
database.update(TABLE_NAME, contentValues, RECIPIENT_ID + " = ?", new String[] {recipientId.serialize()});
|
||||
|
||||
DatabaseFactory.getRecipientDatabase(context).markDirty(recipientId, RecipientDatabase.DirtyState.UPDATE);
|
||||
}
|
||||
|
||||
public void setVerified(@NonNull RecipientId recipientId, IdentityKey identityKey, VerifiedStatus verifiedStatus) {
|
||||
|
@ -150,6 +140,25 @@ public class IdentityDatabase extends Database {
|
|||
if (updated > 0) {
|
||||
Optional<IdentityRecord> record = getIdentity(recipientId);
|
||||
if (record.isPresent()) EventBus.getDefault().post(record.get());
|
||||
DatabaseFactory.getRecipientDatabase(context).markDirty(recipientId, RecipientDatabase.DirtyState.UPDATE);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateIdentityAfterSync(@NonNull RecipientId id, IdentityKey identityKey, VerifiedStatus verifiedStatus) {
|
||||
if (!hasMatchingKey(id, identityKey, verifiedStatus)) {
|
||||
saveIdentityInternal(id, identityKey, verifiedStatus, false, System.currentTimeMillis(), true);
|
||||
Optional<IdentityRecord> record = getIdentity(id);
|
||||
if (record.isPresent()) EventBus.getDefault().post(record.get());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasMatchingKey(@NonNull RecipientId id, IdentityKey identityKey, VerifiedStatus verifiedStatus) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String query = RECIPIENT_ID + " = ? AND " + IDENTITY_KEY + " = ? AND " + VERIFIED + " = ?";
|
||||
String[] args = new String[]{id.serialize(), Base64.encodeBytes(identityKey.serialize()), String.valueOf(verifiedStatus.toInt())};
|
||||
|
||||
try (Cursor cursor = db.query(TABLE_NAME, null, query, args, null, null, null)) {
|
||||
return cursor != null && cursor.moveToFirst();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -165,6 +174,26 @@ public class IdentityDatabase extends Database {
|
|||
return new IdentityRecord(RecipientId.from(recipientId), identity, VerifiedStatus.forState(verifiedStatus), firstUse, timestamp, nonblockingApproval);
|
||||
}
|
||||
|
||||
private void saveIdentityInternal(@NonNull RecipientId recipientId, IdentityKey identityKey, VerifiedStatus verifiedStatus,
|
||||
boolean firstUse, long timestamp, boolean nonBlockingApproval)
|
||||
{
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
String identityKeyString = Base64.encodeBytes(identityKey.serialize());
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(RECIPIENT_ID, recipientId.serialize());
|
||||
contentValues.put(IDENTITY_KEY, identityKeyString);
|
||||
contentValues.put(TIMESTAMP, timestamp);
|
||||
contentValues.put(VERIFIED, verifiedStatus.toInt());
|
||||
contentValues.put(NONBLOCKING_APPROVAL, nonBlockingApproval ? 1 : 0);
|
||||
contentValues.put(FIRST_USE, firstUse ? 1 : 0);
|
||||
|
||||
database.replace(TABLE_NAME, null, contentValues);
|
||||
|
||||
EventBus.getDefault().post(new IdentityRecord(recipientId, identityKey, verifiedStatus,
|
||||
firstUse, timestamp, nonBlockingApproval));
|
||||
}
|
||||
|
||||
public static class IdentityRecord {
|
||||
|
||||
private final RecipientId recipientId;
|
||||
|
|
|
@ -10,24 +10,34 @@ import androidx.annotation.NonNull;
|
|||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.gms.common.util.ArrayUtils;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.color.MaterialColor;
|
||||
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.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.GroupUtil;
|
||||
import org.thoughtcrime.securesms.util.IdentityUtil;
|
||||
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.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Array;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
|
@ -66,6 +76,7 @@ public class RecipientDatabase extends Database {
|
|||
public static final String SYSTEM_PHONE_TYPE = "system_phone_type";
|
||||
public static final String SYSTEM_PHONE_LABEL = "system_phone_label";
|
||||
private static final String SYSTEM_CONTACT_URI = "system_contact_uri";
|
||||
private static final String SYSTEM_INFO_PENDING = "system_info_pending";
|
||||
private static final String PROFILE_KEY = "profile_key";
|
||||
public static final String SIGNAL_PROFILE_NAME = "signal_profile_name";
|
||||
private static final String SIGNAL_PROFILE_AVATAR = "signal_profile_avatar";
|
||||
|
@ -73,8 +84,13 @@ public class RecipientDatabase extends Database {
|
|||
private static final String UNIDENTIFIED_ACCESS_MODE = "unidentified_access_mode";
|
||||
private static final String FORCE_SMS_SELECTION = "force_sms_selection";
|
||||
private static final String UUID_SUPPORTED = "uuid_supported";
|
||||
private static final String STORAGE_SERVICE_KEY = "storage_service_key";
|
||||
private static final String DIRTY = "dirty";
|
||||
|
||||
private static final String SORT_NAME = "sort_name";
|
||||
private static final String IDENTITY_STATUS = "identity_status";
|
||||
private static final String IDENTITY_KEY = "identity_key";
|
||||
|
||||
|
||||
private static final String[] RECIPIENT_PROJECTION = new String[] {
|
||||
UUID, USERNAME, PHONE, EMAIL, GROUP_ID,
|
||||
|
@ -82,7 +98,20 @@ public class RecipientDatabase extends Database {
|
|||
PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, SYSTEM_CONTACT_URI,
|
||||
SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
|
||||
UNIDENTIFIED_ACCESS_MODE,
|
||||
FORCE_SMS_SELECTION, UUID_SUPPORTED
|
||||
FORCE_SMS_SELECTION, UUID_SUPPORTED, STORAGE_SERVICE_KEY, DIRTY
|
||||
};
|
||||
|
||||
private static final String[] RECIPIENT_FULL_PROJECTION = ArrayUtils.concat(
|
||||
new String[] { TABLE_NAME + "." + ID },
|
||||
RECIPIENT_PROJECTION,
|
||||
new String[] {
|
||||
IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.VERIFIED + " AS " + IDENTITY_STATUS,
|
||||
IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.IDENTITY_KEY + " AS " + IDENTITY_KEY
|
||||
});
|
||||
|
||||
|
||||
public static final String[] CREATE_INDEXS = new String[] {
|
||||
"CREATE INDEX IF NOT EXISTS recipient_dirty_index ON " + TABLE_NAME + " (" + DIRTY + ");",
|
||||
};
|
||||
|
||||
private static final String[] ID_PROJECTION = new String[]{ID};
|
||||
|
@ -167,6 +196,20 @@ public class RecipientDatabase extends Database {
|
|||
}
|
||||
}
|
||||
|
||||
enum DirtyState {
|
||||
CLEAN(0), UPDATE(1), INSERT(2), DELETE(3);
|
||||
|
||||
private final int id;
|
||||
|
||||
DirtyState(int id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
int getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
public static final String CREATE_TABLE =
|
||||
"CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
UUID + " TEXT UNIQUE DEFAULT NULL, " +
|
||||
|
@ -191,13 +234,16 @@ public class RecipientDatabase extends Database {
|
|||
SYSTEM_PHONE_LABEL + " TEXT DEFAULT NULL, " +
|
||||
SYSTEM_PHONE_TYPE + " INTEGER DEFAULT -1, " +
|
||||
SYSTEM_CONTACT_URI + " TEXT DEFAULT NULL, " +
|
||||
SYSTEM_INFO_PENDING + " INTEGER DEFAULT 0, " +
|
||||
PROFILE_KEY + " TEXT DEFAULT NULL, " +
|
||||
SIGNAL_PROFILE_NAME + " TEXT DEFAULT NULL, " +
|
||||
SIGNAL_PROFILE_AVATAR + " TEXT DEFAULT NULL, " +
|
||||
PROFILE_SHARING + " INTEGER DEFAULT 0, " +
|
||||
UNIDENTIFIED_ACCESS_MODE + " INTEGER DEFAULT 0, " +
|
||||
FORCE_SMS_SELECTION + " INTEGER DEFAULT 0, " +
|
||||
UUID_SUPPORTED + " INTEGER DEFAULT 0);";
|
||||
UUID_SUPPORTED + " INTEGER DEFAULT 0, " +
|
||||
STORAGE_SERVICE_KEY + " TEXT UNIQUE DEFAULT NULL, " +
|
||||
DIRTY + " INTEGER DEFAULT 0);";
|
||||
|
||||
private static final String INSIGHTS_INVITEE_LIST = "SELECT " + TABLE_NAME + "." + ID +
|
||||
" FROM " + TABLE_NAME +
|
||||
|
@ -270,7 +316,7 @@ public class RecipientDatabase extends Database {
|
|||
}
|
||||
|
||||
public RecipientReader readerForBlocked(Cursor cursor) {
|
||||
return new RecipientReader(context, cursor);
|
||||
return new RecipientReader(cursor);
|
||||
}
|
||||
|
||||
public RecipientReader getRecipientsWithNotificationChannels() {
|
||||
|
@ -278,15 +324,16 @@ public class RecipientDatabase extends Database {
|
|||
Cursor cursor = database.query(TABLE_NAME, ID_PROJECTION, NOTIFICATION_CHANNEL + " NOT NULL",
|
||||
null, null, null, null, null);
|
||||
|
||||
return new RecipientReader(context, cursor);
|
||||
return new RecipientReader(cursor);
|
||||
}
|
||||
|
||||
public @NonNull RecipientSettings getRecipientSettings(@NonNull RecipientId id) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
String query = ID + " = ?";
|
||||
String table = TABLE_NAME + " LEFT OUTER JOIN " + IdentityDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + ID + " = " + IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.RECIPIENT_ID;
|
||||
String query = TABLE_NAME + "." + ID + " = ?";
|
||||
String[] args = new String[] { id.serialize() };
|
||||
|
||||
try (Cursor cursor = database.query(TABLE_NAME, null, query, args, null, null, null)) {
|
||||
try (Cursor cursor = database.query(table, RECIPIENT_FULL_PROJECTION, query, args, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToNext()) {
|
||||
return getRecipientSettings(cursor);
|
||||
} else {
|
||||
|
@ -295,6 +342,191 @@ public class RecipientDatabase extends Database {
|
|||
}
|
||||
}
|
||||
|
||||
public @NonNull List<RecipientSettings> getPendingRecipientSyncUpdates() {
|
||||
return getRecipientSettings(DIRTY + " = ?", new String[] { String.valueOf(DirtyState.UPDATE.getId()) });
|
||||
}
|
||||
|
||||
public @NonNull List<RecipientSettings> getPendingRecipientSyncInsertions() {
|
||||
return getRecipientSettings(DIRTY + " = ?", new String[] { String.valueOf(DirtyState.INSERT.getId()) });
|
||||
}
|
||||
|
||||
public @NonNull List<RecipientSettings> getPendingRecipientSyncDeletions() {
|
||||
return getRecipientSettings(DIRTY + " = ?", new String[] { String.valueOf(DirtyState.DELETE.getId()) });
|
||||
}
|
||||
|
||||
public @Nullable RecipientSettings getByStorageSyncKey(@NonNull byte[] key) {
|
||||
List<RecipientSettings> result = getRecipientSettings(STORAGE_SERVICE_KEY + " = ?", new String[] { Base64.encodeBytes(key) });
|
||||
|
||||
if (result.size() > 0) {
|
||||
return result.get(0);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void applyStorageSyncKeyUpdates(@NonNull Map<RecipientId, byte[]> keys) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
|
||||
db.beginTransaction();
|
||||
try {
|
||||
String query = ID + " = ?";
|
||||
|
||||
for (Map.Entry<RecipientId, byte[]> entry : keys.entrySet()) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(entry.getValue()));
|
||||
values.put(DIRTY, DirtyState.CLEAN.getId());
|
||||
|
||||
db.update(TABLE_NAME, values, query, new String[] { entry.getKey().serialize() });
|
||||
}
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
public void applyStorageSyncUpdates(@NonNull Collection<SignalContactRecord> inserts,
|
||||
@NonNull Collection<StorageSyncHelper.ContactUpdate> updates)
|
||||
{
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context);
|
||||
|
||||
db.beginTransaction();
|
||||
|
||||
try {
|
||||
for (SignalContactRecord insert : inserts) {
|
||||
ContentValues values = getValuesForStorageContact(insert);
|
||||
long id = db.insertOrThrow(TABLE_NAME, null, values);
|
||||
RecipientId recipientId = RecipientId.from(id);
|
||||
|
||||
if (insert.getIdentityKey().isPresent()) {
|
||||
try {
|
||||
IdentityKey identityKey = new IdentityKey(insert.getIdentityKey().get(), 0);
|
||||
|
||||
DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncHelper.remoteToLocalIdentityStatus(insert.getIdentityState()));
|
||||
IdentityUtil.markIdentityVerified(context, Recipient.resolved(recipientId), true, true);
|
||||
} catch (InvalidKeyException e) {
|
||||
Log.w(TAG, "Failed to process identity key during insert! Skipping.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())});
|
||||
|
||||
if (updateCount < 1) {
|
||||
throw new AssertionError("Had an update, but it didn't match any rows!");
|
||||
}
|
||||
|
||||
RecipientId recipientId = getByStorageKeyOrThrow(update.getNewContact().getKey());
|
||||
|
||||
try {
|
||||
Optional<IdentityRecord> 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()));
|
||||
|
||||
Optional<IdentityRecord> newIdentityRecord = identityDatabase.getIdentity(recipientId);
|
||||
|
||||
if ((newIdentityRecord.isPresent() && newIdentityRecord.get().getVerifiedStatus() == IdentityDatabase.VerifiedStatus.VERIFIED) &&
|
||||
(!oldIdentityRecord.isPresent() || oldIdentityRecord.get().getVerifiedStatus() != IdentityDatabase.VerifiedStatus.VERIFIED))
|
||||
{
|
||||
IdentityUtil.markIdentityVerified(context, Recipient.resolved(recipientId), true, true);
|
||||
} else if ((newIdentityRecord.isPresent() && newIdentityRecord.get().getVerifiedStatus() != IdentityDatabase.VerifiedStatus.VERIFIED) &&
|
||||
(oldIdentityRecord.isPresent() && oldIdentityRecord.get().getVerifiedStatus() == IdentityDatabase.VerifiedStatus.VERIFIED))
|
||||
{
|
||||
IdentityUtil.markIdentityVerified(context, Recipient.resolved(recipientId), false, true);
|
||||
}
|
||||
} catch (InvalidKeyException e) {
|
||||
Log.w(TAG, "Failed to process identity key during update! Skipping.", e);
|
||||
}
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull RecipientId getByStorageKeyOrThrow(byte[] storageKey) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String query = STORAGE_SERVICE_KEY + " = ?";
|
||||
String[] args = new String[]{Base64.encodeBytes(storageKey)};
|
||||
|
||||
try (Cursor cursor = db.query(TABLE_NAME, ID_PROJECTION, query, args, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID));
|
||||
return RecipientId.from(id);
|
||||
} else {
|
||||
throw new AssertionError("No recipient with that storage key!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull ContentValues getValuesForStorageContact(@NonNull SignalContactRecord contact) {
|
||||
ContentValues values = new ContentValues();
|
||||
|
||||
if (contact.getAddress().getUuid().isPresent()) {
|
||||
values.put(UUID, contact.getAddress().getUuid().get().toString());
|
||||
}
|
||||
|
||||
values.put(PHONE, contact.getAddress().getNumber().orNull());
|
||||
values.put(SIGNAL_PROFILE_NAME, contact.getProfileName().orNull());
|
||||
values.put(PROFILE_KEY, contact.getProfileKey().orNull());
|
||||
// TODO [greyson] Username
|
||||
values.put(PROFILE_SHARING, contact.isProfileSharingEnabled() ? "1" : "0");
|
||||
values.put(BLOCKED, contact.isBlocked() ? "1" : "0");
|
||||
values.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(contact.getKey()));
|
||||
values.put(DIRTY, DirtyState.CLEAN.getId());
|
||||
return values;
|
||||
}
|
||||
|
||||
private List<RecipientSettings> 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;
|
||||
List<RecipientSettings> out = new ArrayList<>();
|
||||
|
||||
try (Cursor cursor = db.query(table, RECIPIENT_FULL_PROJECTION, query, args, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
out.add(getRecipientSettings(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return All storage keys, excluding the ones that need to be deleted.
|
||||
*/
|
||||
public List<byte[]> getAllStorageSyncKeys() {
|
||||
return new ArrayList<>(getAllStorageSyncKeysMap().values());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return All storage keys, excluding the ones that need to be deleted.
|
||||
*/
|
||||
public Map<RecipientId, byte[]> getAllStorageSyncKeysMap() {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String query = STORAGE_SERVICE_KEY + " NOT NULL AND " + DIRTY + " != ?";
|
||||
String[] args = new String[]{String.valueOf(DirtyState.DELETE)};
|
||||
Map<RecipientId, byte[]> out = new HashMap<>();
|
||||
|
||||
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)));
|
||||
String encodedKey = cursor.getString(cursor.getColumnIndexOrThrow(STORAGE_SERVICE_KEY));
|
||||
|
||||
try {
|
||||
out.put(id, Base64.decode(encodedKey));
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@NonNull RecipientSettings getRecipientSettings(@NonNull Cursor cursor) {
|
||||
long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID));
|
||||
UUID uuid = UuidUtil.parseOrNull(cursor.getString(cursor.getColumnIndexOrThrow(UUID)));
|
||||
|
@ -325,6 +557,9 @@ public class RecipientDatabase extends Database {
|
|||
int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE));
|
||||
boolean forceSmsSelection = cursor.getInt(cursor.getColumnIndexOrThrow(FORCE_SMS_SELECTION)) == 1;
|
||||
boolean uuidSupported = cursor.getInt(cursor.getColumnIndexOrThrow(UUID_SUPPORTED)) == 1;
|
||||
String storageKeyRaw = cursor.getString(cursor.getColumnIndexOrThrow(STORAGE_SERVICE_KEY));
|
||||
String identityKeyRaw = cursor.getString(cursor.getColumnIndexOrThrow(IDENTITY_KEY));
|
||||
int identityStatusRaw = cursor.getInt(cursor.getColumnIndexOrThrow(IDENTITY_STATUS));
|
||||
|
||||
MaterialColor color;
|
||||
byte[] profileKey = null;
|
||||
|
@ -345,6 +580,22 @@ 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);
|
||||
}
|
||||
|
||||
IdentityDatabase.VerifiedStatus identityStatus = IdentityDatabase.VerifiedStatus.forState(identityStatusRaw);
|
||||
|
||||
return new RecipientSettings(RecipientId.from(id), uuid, username, e164, email, groupId, blocked, muteUntil,
|
||||
VibrateState.fromId(messageVibrateState),
|
||||
VibrateState.fromId(callVibrateState),
|
||||
|
@ -355,20 +606,18 @@ public class RecipientDatabase extends Database {
|
|||
systemPhoneLabel, systemContactUri,
|
||||
signalProfileName, signalProfileAvatar, profileSharing,
|
||||
notificationChannel, UnidentifiedAccessMode.fromMode(unidentifiedAccessMode),
|
||||
forceSmsSelection, uuidSupported, InsightsBannerTier.fromId(insightsBannerTier));
|
||||
forceSmsSelection, uuidSupported, InsightsBannerTier.fromId(insightsBannerTier),
|
||||
storageKey, identityKey, identityStatus);
|
||||
}
|
||||
|
||||
public BulkOperationsHandle resetAllSystemContactInfo() {
|
||||
public BulkOperationsHandle beginBulkSystemContactUpdate() {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
database.beginTransaction();
|
||||
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(SYSTEM_DISPLAY_NAME, (String)null);
|
||||
contentValues.put(SYSTEM_PHOTO_URI, (String)null);
|
||||
contentValues.put(SYSTEM_PHONE_LABEL, (String)null);
|
||||
contentValues.put(SYSTEM_CONTACT_URI, (String)null);
|
||||
contentValues.put(SYSTEM_INFO_PENDING, 1);
|
||||
|
||||
database.update(TABLE_NAME, contentValues, null, null);
|
||||
database.update(TABLE_NAME, contentValues, SYSTEM_CONTACT_URI + " NOT NULL", null);
|
||||
|
||||
return new BulkOperationsHandle(database);
|
||||
}
|
||||
|
@ -376,29 +625,34 @@ public class RecipientDatabase extends Database {
|
|||
public void setColor(@NonNull RecipientId id, @NonNull MaterialColor color) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(COLOR, color.serialize());
|
||||
update(id, values);
|
||||
Recipient.live(id).refresh();
|
||||
if (update(id, values)) {
|
||||
Recipient.live(id).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public void setDefaultSubscriptionId(@NonNull RecipientId id, int defaultSubscriptionId) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(DEFAULT_SUBSCRIPTION_ID, defaultSubscriptionId);
|
||||
update(id, values);
|
||||
Recipient.live(id).refresh();
|
||||
if (update(id, values)) {
|
||||
Recipient.live(id).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public void setForceSmsSelection(@NonNull RecipientId id, boolean forceSmsSelection) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(FORCE_SMS_SELECTION, forceSmsSelection ? 1 : 0);
|
||||
update(id, contentValues);
|
||||
Recipient.live(id).refresh();
|
||||
if (update(id, contentValues)) {
|
||||
Recipient.live(id).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public void setBlocked(@NonNull RecipientId id, boolean blocked) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(BLOCKED, blocked ? 1 : 0);
|
||||
update(id, values);
|
||||
Recipient.live(id).refresh();
|
||||
if (update(id, values)) {
|
||||
markDirty(id, DirtyState.UPDATE);
|
||||
Recipient.live(id).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public void setMessageRingtone(@NonNull RecipientId id, @Nullable Uri notification) {
|
||||
|
@ -469,7 +723,9 @@ public class RecipientDatabase extends Database {
|
|||
public void setUnidentifiedAccessMode(@NonNull RecipientId id, @NonNull UnidentifiedAccessMode unidentifiedAccessMode) {
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(UNIDENTIFIED_ACCESS_MODE, unidentifiedAccessMode.getMode());
|
||||
update(id, values);
|
||||
if (update(id, values)) {
|
||||
markDirty(id, DirtyState.UPDATE);
|
||||
}
|
||||
Recipient.live(id).refresh();
|
||||
}
|
||||
|
||||
|
@ -483,43 +739,53 @@ public class RecipientDatabase extends Database {
|
|||
public void setProfileKey(@NonNull RecipientId id, @Nullable byte[] profileKey) {
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(PROFILE_KEY, profileKey == null ? null : Base64.encodeBytes(profileKey));
|
||||
update(id, values);
|
||||
Recipient.live(id).refresh();
|
||||
if (update(id, values)) {
|
||||
markDirty(id, DirtyState.UPDATE);
|
||||
Recipient.live(id).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public void setProfileName(@NonNull RecipientId id, @Nullable String profileName) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(SIGNAL_PROFILE_NAME, profileName);
|
||||
update(id, contentValues);
|
||||
Recipient.live(id).refresh();
|
||||
if (update(id, contentValues)) {
|
||||
markDirty(id, DirtyState.UPDATE);
|
||||
Recipient.live(id).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public void setProfileAvatar(@NonNull RecipientId id, @Nullable String profileAvatar) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(SIGNAL_PROFILE_AVATAR, profileAvatar);
|
||||
update(id, contentValues);
|
||||
Recipient.live(id).refresh();
|
||||
if (update(id, contentValues)) {
|
||||
Recipient.live(id).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public void setProfileSharing(@NonNull RecipientId id, @SuppressWarnings("SameParameterValue") boolean enabled) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(PROFILE_SHARING, enabled ? 1 : 0);
|
||||
update(id, contentValues);
|
||||
Recipient.live(id).refresh();
|
||||
if (update(id, contentValues)) {
|
||||
markDirty(id, DirtyState.UPDATE);
|
||||
Recipient.live(id).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public void setNotificationChannel(@NonNull RecipientId id, @Nullable String notificationChannel) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(NOTIFICATION_CHANNEL, notificationChannel);
|
||||
update(id, contentValues);
|
||||
Recipient.live(id).refresh();
|
||||
if (update(id, contentValues)) {
|
||||
Recipient.live(id).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public void setPhoneNumber(@NonNull RecipientId id, @NonNull String e164) {
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(PHONE, e164);
|
||||
update(id, contentValues);
|
||||
Recipient.live(id).refresh();
|
||||
if (update(id, contentValues)) {
|
||||
markDirty(id, DirtyState.UPDATE);
|
||||
Recipient.live(id).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public void setUsername(@NonNull RecipientId id, @Nullable String username) {
|
||||
|
@ -564,11 +830,14 @@ public class RecipientDatabase extends Database {
|
|||
}
|
||||
|
||||
public void markRegistered(@NonNull RecipientId id, @NonNull UUID uuid) {
|
||||
ContentValues contentValues = new ContentValues(2);
|
||||
ContentValues contentValues = new ContentValues(3);
|
||||
contentValues.put(REGISTERED, RegisteredState.REGISTERED.getId());
|
||||
contentValues.put(UUID, uuid.toString().toLowerCase());
|
||||
update(id, contentValues);
|
||||
Recipient.live(id).refresh();
|
||||
contentValues.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(StorageSyncHelper.generateKey()));
|
||||
if (update(id, contentValues)) {
|
||||
markDirty(id, DirtyState.INSERT);
|
||||
Recipient.live(id).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -579,16 +848,21 @@ public class RecipientDatabase extends Database {
|
|||
public void markRegistered(@NonNull RecipientId id) {
|
||||
ContentValues contentValues = new ContentValues(2);
|
||||
contentValues.put(REGISTERED, RegisteredState.REGISTERED.getId());
|
||||
update(id, contentValues);
|
||||
Recipient.live(id).refresh();
|
||||
contentValues.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(StorageSyncHelper.generateKey()));
|
||||
if (update(id, contentValues)) {
|
||||
markDirty(id, DirtyState.INSERT);
|
||||
Recipient.live(id).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public void markUnregistered(@NonNull RecipientId id) {
|
||||
ContentValues contentValues = new ContentValues(2);
|
||||
contentValues.put(REGISTERED, RegisteredState.NOT_REGISTERED.getId());
|
||||
contentValues.put(UUID, (String) null);
|
||||
update(id, contentValues);
|
||||
Recipient.live(id).refresh();
|
||||
if (update(id, contentValues)) {
|
||||
markDirty(id, DirtyState.DELETE);
|
||||
Recipient.live(id).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public void bulkUpdatedRegisteredStatus(@NonNull Map<RecipientId, String> registered, Collection<RecipientId> unregistered) {
|
||||
|
@ -600,14 +874,18 @@ public class RecipientDatabase extends Database {
|
|||
ContentValues values = new ContentValues(2);
|
||||
values.put(REGISTERED, RegisteredState.REGISTERED.getId());
|
||||
values.put(UUID, entry.getValue().toLowerCase());
|
||||
db.update(TABLE_NAME, values, ID_WHERE, new String[] { entry.getKey().serialize() });
|
||||
if (update(entry.getKey(), values)) {
|
||||
markDirty(entry.getKey(), DirtyState.INSERT);
|
||||
}
|
||||
}
|
||||
|
||||
for (RecipientId id : unregistered) {
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(REGISTERED, RegisteredState.NOT_REGISTERED.getId());
|
||||
values.put(UUID, (String) null);
|
||||
db.update(TABLE_NAME, values, ID_WHERE, new String[] { id.serialize() });
|
||||
if (update(id, values)) {
|
||||
markDirty(id, DirtyState.DELETE);
|
||||
}
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
|
@ -632,7 +910,7 @@ public class RecipientDatabase extends Database {
|
|||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(REGISTERED, RegisteredState.REGISTERED.getId());
|
||||
|
||||
if (update(activeId, contentValues) > 0) {
|
||||
if (update(activeId, contentValues)) {
|
||||
Recipient.live(activeId).refresh();
|
||||
}
|
||||
}
|
||||
|
@ -641,7 +919,7 @@ public class RecipientDatabase extends Database {
|
|||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(REGISTERED, RegisteredState.NOT_REGISTERED.getId());
|
||||
|
||||
if (update(inactiveId, contentValues) > 0) {
|
||||
if (update(inactiveId, contentValues)) {
|
||||
Recipient.live(inactiveId).refresh();
|
||||
}
|
||||
}
|
||||
|
@ -834,10 +1112,81 @@ public class RecipientDatabase extends Database {
|
|||
ApplicationDependencies.getRecipientCache().clear();
|
||||
}
|
||||
|
||||
public void updateStorageKeys(@NonNull Map<RecipientId, byte[]> keys) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.beginTransaction();
|
||||
|
||||
private int update(@NonNull RecipientId id, ContentValues contentValues) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
return database.update(TABLE_NAME, contentValues, ID + " = ?", new String[] { id.serialize() });
|
||||
try {
|
||||
for (Map.Entry<RecipientId, byte[]> entry : keys.entrySet()) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(STORAGE_SERVICE_KEY, Base64.encodeBytes(entry.getValue()));
|
||||
db.update(TABLE_NAME, values, ID_WHERE, new String[] { entry.getKey().serialize() });
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
public void clearDirtyState(@NonNull List<RecipientId> recipients) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
db.beginTransaction();
|
||||
|
||||
try {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(DIRTY, DirtyState.CLEAN.getId());
|
||||
|
||||
for (RecipientId id : recipients) {
|
||||
db.update(TABLE_NAME, values, ID_WHERE, new String[]{ id.serialize() });
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
void markDirty(@NonNull RecipientId recipientId, @NonNull DirtyState dirtyState) {
|
||||
if (!FeatureFlags.STORAGE_SERVICE) 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[] args = new String[] { recipientId.serialize(), String.valueOf(dirtyState.id) };
|
||||
|
||||
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, query, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Will update the database with the content values you specified. It will make an intelligent
|
||||
* query such that this will only return true if a row was *actually* updated.
|
||||
*/
|
||||
private boolean update(@NonNull RecipientId id, ContentValues contentValues) {
|
||||
SQLiteDatabase database = databaseHelper.getWritableDatabase();
|
||||
StringBuilder qualifier = new StringBuilder();
|
||||
Set<Map.Entry<String, Object>> valueSet = contentValues.valueSet();
|
||||
String[] args = new String[valueSet.size() + 1];
|
||||
|
||||
args[0] = id.serialize();
|
||||
|
||||
int i = 0;
|
||||
|
||||
for (Map.Entry<String, Object> entry : valueSet) {
|
||||
qualifier.append(entry.getKey()).append(" != ?");
|
||||
|
||||
if (i != valueSet.size() - 1) {
|
||||
qualifier.append(" OR ");
|
||||
}
|
||||
|
||||
args[i + 1] = String.valueOf(entry.getValue());
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
|
||||
return database.update(TABLE_NAME, contentValues, ID + " = ? AND (" + qualifier + ")", args) > 0;
|
||||
}
|
||||
|
||||
private @NonNull Optional<RecipientId> getByColumn(@NonNull String column, String value) {
|
||||
|
@ -900,23 +1249,62 @@ public class RecipientDatabase extends Database {
|
|||
int systemPhoneType,
|
||||
@Nullable String systemContactUri)
|
||||
{
|
||||
ContentValues contentValues = new ContentValues(1);
|
||||
contentValues.put(SYSTEM_DISPLAY_NAME, displayName);
|
||||
contentValues.put(SYSTEM_PHOTO_URI, photoUri);
|
||||
contentValues.put(SYSTEM_PHONE_LABEL, systemPhoneLabel);
|
||||
contentValues.put(SYSTEM_PHONE_TYPE, systemPhoneType);
|
||||
contentValues.put(SYSTEM_CONTACT_URI, systemContactUri);
|
||||
ContentValues dirtyQualifyingValues = new ContentValues();
|
||||
dirtyQualifyingValues.put(SYSTEM_DISPLAY_NAME, displayName);
|
||||
|
||||
update(id, contentValues);
|
||||
pendingContactInfoMap.put(id, new PendingContactInfo(displayName, photoUri, systemPhoneLabel, systemContactUri));
|
||||
if (update(id, dirtyQualifyingValues)) {
|
||||
markDirty(id, DirtyState.UPDATE);
|
||||
}
|
||||
|
||||
ContentValues refreshQualifyingValues = new ContentValues();
|
||||
refreshQualifyingValues.put(SYSTEM_PHOTO_URI, photoUri);
|
||||
refreshQualifyingValues.put(SYSTEM_PHONE_LABEL, systemPhoneLabel);
|
||||
refreshQualifyingValues.put(SYSTEM_PHONE_TYPE, systemPhoneType);
|
||||
refreshQualifyingValues.put(SYSTEM_CONTACT_URI, systemContactUri);
|
||||
|
||||
if (update(id, refreshQualifyingValues)) {
|
||||
pendingContactInfoMap.put(id, new PendingContactInfo(displayName, photoUri, systemPhoneLabel, systemContactUri));
|
||||
}
|
||||
|
||||
ContentValues otherValues = new ContentValues();
|
||||
otherValues.put(SYSTEM_INFO_PENDING, 0);
|
||||
update(id, otherValues);
|
||||
}
|
||||
|
||||
public void finish() {
|
||||
markAllRelevantEntriesDirty();
|
||||
clearSystemDataForPendingInfo();
|
||||
|
||||
database.setTransactionSuccessful();
|
||||
database.endTransaction();
|
||||
|
||||
Stream.of(pendingContactInfoMap.entrySet()).forEach(entry -> Recipient.live(entry.getKey()).refresh());
|
||||
}
|
||||
|
||||
private void markAllRelevantEntriesDirty() {
|
||||
String query = SYSTEM_INFO_PENDING + " = ? AND " + STORAGE_SERVICE_KEY + " NOT NULL AND " + DIRTY + " < ?";
|
||||
String[] args = new String[] { "1", String.valueOf(DirtyState.UPDATE.getId()) };
|
||||
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(DIRTY, DirtyState.UPDATE.getId());
|
||||
|
||||
database.update(TABLE_NAME, values, query, args);
|
||||
}
|
||||
|
||||
private void clearSystemDataForPendingInfo() {
|
||||
String query = SYSTEM_INFO_PENDING + " = ?";
|
||||
String[] args = new String[] { "1" };
|
||||
|
||||
ContentValues values = new ContentValues(5);
|
||||
|
||||
values.put(SYSTEM_INFO_PENDING, 0);
|
||||
values.put(SYSTEM_DISPLAY_NAME, (String) null);
|
||||
values.put(SYSTEM_PHOTO_URI, (String) null);
|
||||
values.put(SYSTEM_PHONE_LABEL, (String) null);
|
||||
values.put(SYSTEM_CONTACT_URI, (String) null);
|
||||
|
||||
database.update(TABLE_NAME, values, query, args);
|
||||
}
|
||||
}
|
||||
|
||||
public interface ColorUpdater {
|
||||
|
@ -924,35 +1312,38 @@ public class RecipientDatabase extends Database {
|
|||
}
|
||||
|
||||
public static class RecipientSettings {
|
||||
private final RecipientId id;
|
||||
private final UUID uuid;
|
||||
private final String username;
|
||||
private final String e164;
|
||||
private final String email;
|
||||
private final String groupId;
|
||||
private final boolean blocked;
|
||||
private final long muteUntil;
|
||||
private final VibrateState messageVibrateState;
|
||||
private final VibrateState callVibrateState;
|
||||
private final Uri messageRingtone;
|
||||
private final Uri callRingtone;
|
||||
private final MaterialColor color;
|
||||
private final int defaultSubscriptionId;
|
||||
private final int expireMessages;
|
||||
private final RegisteredState registered;
|
||||
private final byte[] profileKey;
|
||||
private final String systemDisplayName;
|
||||
private final String systemContactPhoto;
|
||||
private final String systemPhoneLabel;
|
||||
private final String systemContactUri;
|
||||
private final String signalProfileName;
|
||||
private final String signalProfileAvatar;
|
||||
private final boolean profileSharing;
|
||||
private final String notificationChannel;
|
||||
private final UnidentifiedAccessMode unidentifiedAccessMode;
|
||||
private final boolean forceSmsSelection;
|
||||
private final boolean uuidSupported;
|
||||
private final InsightsBannerTier insightsBannerTier;
|
||||
private final RecipientId id;
|
||||
private final UUID uuid;
|
||||
private final String username;
|
||||
private final String e164;
|
||||
private final String email;
|
||||
private final String groupId;
|
||||
private final boolean blocked;
|
||||
private final long muteUntil;
|
||||
private final VibrateState messageVibrateState;
|
||||
private final VibrateState callVibrateState;
|
||||
private final Uri messageRingtone;
|
||||
private final Uri callRingtone;
|
||||
private final MaterialColor color;
|
||||
private final int defaultSubscriptionId;
|
||||
private final int expireMessages;
|
||||
private final RegisteredState registered;
|
||||
private final byte[] profileKey;
|
||||
private final String systemDisplayName;
|
||||
private final String systemContactPhoto;
|
||||
private final String systemPhoneLabel;
|
||||
private final String systemContactUri;
|
||||
private final String signalProfileName;
|
||||
private final String signalProfileAvatar;
|
||||
private final boolean profileSharing;
|
||||
private final String notificationChannel;
|
||||
private final UnidentifiedAccessMode unidentifiedAccessMode;
|
||||
private final boolean forceSmsSelection;
|
||||
private final boolean uuidSupported;
|
||||
private final InsightsBannerTier insightsBannerTier;
|
||||
private final byte[] storageKey;
|
||||
private final byte[] identityKey;
|
||||
private final IdentityDatabase.VerifiedStatus identityStatus;
|
||||
|
||||
RecipientSettings(@NonNull RecipientId id,
|
||||
@Nullable UUID uuid,
|
||||
|
@ -981,7 +1372,10 @@ public class RecipientDatabase extends Database {
|
|||
@NonNull UnidentifiedAccessMode unidentifiedAccessMode,
|
||||
boolean forceSmsSelection,
|
||||
boolean uuidSupported,
|
||||
@NonNull InsightsBannerTier insightsBannerTier)
|
||||
@NonNull InsightsBannerTier insightsBannerTier,
|
||||
@Nullable byte[] storageKey,
|
||||
@Nullable byte[] identityKey,
|
||||
@NonNull IdentityDatabase.VerifiedStatus identityStatus)
|
||||
{
|
||||
this.id = id;
|
||||
this.uuid = uuid;
|
||||
|
@ -1011,7 +1405,10 @@ public class RecipientDatabase extends Database {
|
|||
this.unidentifiedAccessMode = unidentifiedAccessMode;
|
||||
this.forceSmsSelection = forceSmsSelection;
|
||||
this.uuidSupported = uuidSupported;
|
||||
this.insightsBannerTier = insightsBannerTier;
|
||||
this.insightsBannerTier = insightsBannerTier;
|
||||
this.storageKey = storageKey;
|
||||
this.identityKey = identityKey;
|
||||
this.identityStatus = identityStatus;
|
||||
}
|
||||
|
||||
public RecipientId getId() {
|
||||
|
@ -1129,15 +1526,25 @@ public class RecipientDatabase extends Database {
|
|||
public boolean isUuidSupported() {
|
||||
return uuidSupported;
|
||||
}
|
||||
|
||||
public @Nullable byte[] getStorageKey() {
|
||||
return storageKey;
|
||||
}
|
||||
|
||||
public @Nullable byte[] getIdentityKey() {
|
||||
return identityKey;
|
||||
}
|
||||
|
||||
public @NonNull IdentityDatabase.VerifiedStatus getIdentityStatus() {
|
||||
return identityStatus;
|
||||
}
|
||||
}
|
||||
|
||||
public static class RecipientReader implements Closeable {
|
||||
|
||||
private final Context context;
|
||||
private final Cursor cursor;
|
||||
|
||||
RecipientReader(Context context, Cursor cursor) {
|
||||
this.context = context;
|
||||
RecipientReader(Cursor cursor) {
|
||||
this.cursor = cursor;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,115 @@
|
|||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A list of storage keys whose types we do not currently have syncing logic for. We need to
|
||||
* remember that these keys exist so that we don't blast any data away.
|
||||
*/
|
||||
public class StorageKeyDatabase extends Database {
|
||||
|
||||
private static final String TABLE_NAME = "storage_key";
|
||||
private static final String ID = "_id";
|
||||
private static final String TYPE = "type";
|
||||
private static final String KEY = "key";
|
||||
|
||||
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
TYPE + " INTEGER, " +
|
||||
KEY + " TEXT UNIQUE)";
|
||||
|
||||
public static final String[] CREATE_INDEXES = new String[] {
|
||||
"CREATE INDEX IF NOT EXISTS storage_key_type_index ON " + TABLE_NAME + " (" + TYPE + ");"
|
||||
};
|
||||
|
||||
public StorageKeyDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public List<byte[]> getAllKeys() {
|
||||
List<byte[]> keys = new ArrayList<>();
|
||||
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, null, null, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
String keyEncoded = cursor.getString(cursor.getColumnIndexOrThrow(KEY));
|
||||
try {
|
||||
keys.add(Base64.decode(keyEncoded));
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
public @Nullable SignalStorageRecord getByKey(@NonNull byte[] key) {
|
||||
String query = KEY + " = ?";
|
||||
String[] args = new String[] { Base64.encodeBytes(key) };
|
||||
|
||||
try (Cursor cursor = databaseHelper.getReadableDatabase().query(TABLE_NAME, null, query, args, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
int type = cursor.getInt(cursor.getColumnIndexOrThrow(TYPE));
|
||||
return SignalStorageRecord.forUnknown(key, type);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void applyStorageSyncUpdates(@NonNull Collection<SignalStorageRecord> inserts,
|
||||
@NonNull Collection<SignalStorageRecord> deletes)
|
||||
{
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
|
||||
db.beginTransaction();
|
||||
try {
|
||||
for (SignalStorageRecord insert : inserts) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(TYPE, insert.getType());
|
||||
values.put(KEY, Base64.encodeBytes(insert.getKey()));
|
||||
|
||||
db.insert(TABLE_NAME, null, values);
|
||||
}
|
||||
|
||||
String deleteQuery = KEY + " = ?";
|
||||
|
||||
for (SignalStorageRecord delete : deletes) {
|
||||
String[] args = new String[] { Base64.encodeBytes(delete.getKey()) };
|
||||
db.delete(TABLE_NAME, deleteQuery, args);
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void deleteByType(int type) {
|
||||
String query = TYPE + " = ?";
|
||||
String[] args = new String[]{String.valueOf(type)};
|
||||
|
||||
databaseHelper.getWritableDatabase().delete(TABLE_NAME, query, args);
|
||||
}
|
||||
|
||||
public void deleteAll() {
|
||||
databaseHelper.getWritableDatabase().delete(TABLE_NAME, null, null);
|
||||
}
|
||||
}
|
|
@ -20,6 +20,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;
|
||||
|
@ -37,6 +38,7 @@ import org.thoughtcrime.securesms.database.SessionDatabase;
|
|||
import org.thoughtcrime.securesms.database.SignedPreKeyDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.StickerDatabase;
|
||||
import org.thoughtcrime.securesms.database.StorageKeyDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob;
|
||||
|
@ -44,6 +46,7 @@ import org.thoughtcrime.securesms.logging.Log;
|
|||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.thoughtcrime.securesms.util.GroupUtil;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.SqlUtil;
|
||||
|
@ -93,9 +96,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||
private static final int UUIDS = 35;
|
||||
private static final int USERNAMES = 36;
|
||||
private static final int REACTIONS = 37;
|
||||
private static final int STORAGE_SERVICE = 38;
|
||||
|
||||
private static final int DATABASE_VERSION = 37;
|
||||
|
||||
private static final int DATABASE_VERSION = 38;
|
||||
private static final String DATABASE_NAME = "signal.db";
|
||||
|
||||
private final Context context;
|
||||
|
@ -136,9 +139,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||
db.execSQL(SignedPreKeyDatabase.CREATE_TABLE);
|
||||
db.execSQL(SessionDatabase.CREATE_TABLE);
|
||||
db.execSQL(StickerDatabase.CREATE_TABLE);
|
||||
db.execSQL(StorageKeyDatabase.CREATE_TABLE);
|
||||
executeStatements(db, SearchDatabase.CREATE_TABLE);
|
||||
executeStatements(db, JobDatabase.CREATE_TABLE);
|
||||
|
||||
executeStatements(db, RecipientDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, SmsDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, MmsDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, AttachmentDatabase.CREATE_INDEXS);
|
||||
|
@ -147,6 +152,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||
executeStatements(db, GroupDatabase.CREATE_INDEXS);
|
||||
executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES);
|
||||
executeStatements(db, StickerDatabase.CREATE_INDEXES);
|
||||
executeStatements(db, StorageKeyDatabase.CREATE_INDEXES);
|
||||
|
||||
if (context.getDatabasePath(ClassicOpenHelper.NAME).exists()) {
|
||||
ClassicOpenHelper legacyHelper = new ClassicOpenHelper(context);
|
||||
|
@ -171,6 +177,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||
@Override
|
||||
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
|
||||
Log.i(TAG, "Upgrading database: " + oldVersion + ", " + newVersion);
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
db.beginTransaction();
|
||||
|
||||
|
@ -638,6 +645,34 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||
db.execSQL("ALTER TABLE mms ADD COLUMN reactions_last_seen INTEGER DEFAULT -1");
|
||||
}
|
||||
|
||||
if (oldVersion < STORAGE_SERVICE) {
|
||||
db.execSQL("CREATE TABLE storage_key (_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
"type INTEGER, " +
|
||||
"key TEXT UNIQUE)");
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS storage_key_type_index ON storage_key (type)");
|
||||
|
||||
db.execSQL("ALTER TABLE recipient ADD COLUMN system_info_pending INTEGER DEFAULT 0");
|
||||
db.execSQL("ALTER TABLE recipient ADD COLUMN storage_service_key TEXT DEFAULT NULL");
|
||||
db.execSQL("ALTER TABLE recipient ADD COLUMN dirty INTEGER DEFAULT 0");
|
||||
|
||||
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 });
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
|
@ -646,6 +681,8 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
|
|||
if (oldVersion < MIGRATE_PREKEYS_VERSION) {
|
||||
PreKeyMigrationHelper.cleanUpPreKeys(context);
|
||||
}
|
||||
|
||||
Log.i(TAG, "Upgrade complete. Took " + (System.currentTimeMillis() - startTime) + " ms.");
|
||||
}
|
||||
|
||||
public SQLiteDatabase getReadableDatabase() {
|
||||
|
|
|
@ -53,11 +53,13 @@ public final class JobManagerFactories {
|
|||
put(MultiDeviceConfigurationUpdateJob.KEY, new MultiDeviceConfigurationUpdateJob.Factory());
|
||||
put(MultiDeviceContactUpdateJob.KEY, new MultiDeviceContactUpdateJob.Factory());
|
||||
put(MultiDeviceGroupUpdateJob.KEY, new MultiDeviceGroupUpdateJob.Factory());
|
||||
put(MultiDeviceKeysUpdateJob.KEY, new MultiDeviceKeysUpdateJob.Factory());
|
||||
put(MultiDeviceProfileContentUpdateJob.KEY, new MultiDeviceProfileContentUpdateJob.Factory());
|
||||
put(MultiDeviceProfileKeyUpdateJob.KEY, new MultiDeviceProfileKeyUpdateJob.Factory());
|
||||
put(MultiDeviceReadUpdateJob.KEY, new MultiDeviceReadUpdateJob.Factory());
|
||||
put(MultiDeviceStickerPackOperationJob.KEY, new MultiDeviceStickerPackOperationJob.Factory());
|
||||
put(MultiDeviceStickerPackSyncJob.KEY, new MultiDeviceStickerPackSyncJob.Factory());
|
||||
put(MultiDeviceStorageSyncRequestJob.KEY, new MultiDeviceStorageSyncRequestJob.Factory());
|
||||
put(MultiDeviceVerifiedUpdateJob.KEY, new MultiDeviceVerifiedUpdateJob.Factory());
|
||||
put(MultiDeviceViewOnceOpenJob.KEY, new MultiDeviceViewOnceOpenJob.Factory());
|
||||
put(PushDecryptJob.KEY, new PushDecryptJob.Factory());
|
||||
|
@ -84,6 +86,8 @@ public final class JobManagerFactories {
|
|||
put(SmsSentJob.KEY, new SmsSentJob.Factory());
|
||||
put(StickerDownloadJob.KEY, new StickerDownloadJob.Factory());
|
||||
put(StickerPackDownloadJob.KEY, new StickerPackDownloadJob.Factory());
|
||||
put(StorageForcePushJob.KEY, new StorageForcePushJob.Factory());
|
||||
put(StorageSyncJob.KEY, new StorageSyncJob.Factory());
|
||||
put(TrimThreadJob.KEY, new TrimThreadJob.Factory());
|
||||
put(TypingSendJob.KEY, new TypingSendJob.Factory());
|
||||
put(UpdateApkJob.KEY, new UpdateApkJob.Factory());
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
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.messages.multidevice.ConfigurationMessage;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.KeysMessage;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageUtil;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class MultiDeviceKeysUpdateJob extends BaseJob {
|
||||
|
||||
public static final String KEY = "MultiDeviceKeysUpdateJob";
|
||||
|
||||
private static final String TAG = MultiDeviceKeysUpdateJob.class.getSimpleName();
|
||||
|
||||
public MultiDeviceKeysUpdateJob() {
|
||||
this(new Parameters.Builder()
|
||||
.setQueue("MultiDeviceKeysUpdateJob")
|
||||
.setMaxInstances(2)
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setMaxAttempts(10)
|
||||
.build());
|
||||
|
||||
}
|
||||
|
||||
private MultiDeviceKeysUpdateJob(@NonNull Parameters parameters) {
|
||||
super(parameters);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Data serialize() {
|
||||
return Data.EMPTY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRun() throws IOException, UntrustedIdentityException {
|
||||
if (!TextSecurePreferences.isMultiDevice(context)) {
|
||||
Log.i(TAG, "Not multi device, aborting...");
|
||||
return;
|
||||
}
|
||||
|
||||
SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender();
|
||||
|
||||
byte[] masterKey = TextSecurePreferences.getMasterKey(context);
|
||||
byte[] storageServiceKey = masterKey != null ? SignalStorageUtil.computeStorageServiceKey(masterKey)
|
||||
: null;
|
||||
|
||||
if (storageServiceKey == null) {
|
||||
Log.w(TAG, "Syncing a null storage service key.");
|
||||
}
|
||||
|
||||
messageSender.sendMessage(SignalServiceSyncMessage.forKeys(new KeysMessage(Optional.fromNullable(storageServiceKey))),
|
||||
UnidentifiedAccessUtil.getAccessForSync(context));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onShouldRetry(@NonNull Exception e) {
|
||||
return e instanceof PushNetworkException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCanceled() {
|
||||
}
|
||||
|
||||
public static final class Factory implements Job.Factory<MultiDeviceKeysUpdateJob> {
|
||||
@Override
|
||||
public @NonNull MultiDeviceKeysUpdateJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
return new MultiDeviceKeysUpdateJob(parameters);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -22,6 +22,7 @@ public class MultiDeviceProfileContentUpdateJob extends BaseJob {
|
|||
public MultiDeviceProfileContentUpdateJob() {
|
||||
this(new Parameters.Builder()
|
||||
.setQueue("MultiDeviceProfileUpdateJob")
|
||||
.setMaxInstances(2)
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setMaxAttempts(10)
|
||||
.build());
|
||||
|
@ -59,7 +60,6 @@ public class MultiDeviceProfileContentUpdateJob extends BaseJob {
|
|||
return e instanceof PushNetworkException;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void onCanceled() {
|
||||
Log.w(TAG, "Did not succeed!");
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
|
||||
|
||||
public class MultiDeviceStorageSyncRequestJob extends BaseJob {
|
||||
|
||||
public static final String KEY = "MultiDeviceStorageSyncRequestJob";
|
||||
|
||||
private static final String TAG = Log.tag(MultiDeviceStorageSyncRequestJob.class);
|
||||
|
||||
public MultiDeviceStorageSyncRequestJob() {
|
||||
this(new Parameters.Builder()
|
||||
.setQueue("MultiDeviceStorageSyncRequestJob")
|
||||
.setMaxInstances(2)
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setMaxAttempts(10)
|
||||
.build());
|
||||
}
|
||||
|
||||
private MultiDeviceStorageSyncRequestJob(@NonNull Parameters parameters) {
|
||||
super(parameters);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Data serialize() {
|
||||
return Data.EMPTY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRun() throws Exception {
|
||||
if (!TextSecurePreferences.isMultiDevice(context)) {
|
||||
Log.i(TAG, "Not multi device, aborting...");
|
||||
return;
|
||||
}
|
||||
|
||||
SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender();
|
||||
|
||||
messageSender.sendMessage(SignalServiceSyncMessage.forFetchLatest(SignalServiceSyncMessage.FetchType.STORAGE_MANIFEST),
|
||||
UnidentifiedAccessUtil.getAccessForSync(context));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onShouldRetry(@NonNull Exception e) {
|
||||
return e instanceof PushNetworkException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCanceled() {
|
||||
Log.w(TAG, "Did not succeed!");
|
||||
}
|
||||
|
||||
public static final class Factory implements Job.Factory<MultiDeviceStorageSyncRequestJob> {
|
||||
@Override
|
||||
public @NonNull MultiDeviceStorageSyncRequestJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
return new MultiDeviceStorageSyncRequestJob(parameters);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -679,6 +679,10 @@ public class PushDecryptJob extends BaseJob {
|
|||
TextSecurePreferences.isLinkPreviewsEnabled(context)));
|
||||
ApplicationDependencies.getJobManager().add(new MultiDeviceStickerPackSyncJob());
|
||||
}
|
||||
|
||||
if (message.isKeysRequest()) {
|
||||
// ApplicationDependencies.getJobManager().add(new );
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSynchronizeReadMessage(@NonNull List<ReadMessage> readMessages, long envelopeTimestamp)
|
||||
|
|
|
@ -0,0 +1,142 @@
|
|||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.StorageKeyDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
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.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
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;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageUtil;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageRecord;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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.
|
||||
*/
|
||||
public class StorageForcePushJob extends BaseJob {
|
||||
|
||||
public static final String KEY = "StorageForcePushJob";
|
||||
|
||||
private static final String TAG = Log.tag(StorageForcePushJob.class);
|
||||
|
||||
public StorageForcePushJob() {
|
||||
this(new Parameters.Builder().addConstraint(NetworkConstraint.KEY)
|
||||
.setQueue(StorageSyncJob.QUEUE_KEY)
|
||||
.setMaxInstances(1)
|
||||
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
||||
.build());
|
||||
}
|
||||
|
||||
private StorageForcePushJob(@NonNull Parameters parameters) {
|
||||
super(parameters);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Data serialize() {
|
||||
return Data.EMPTY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRun() throws IOException, RetryLaterException {
|
||||
if (!FeatureFlags.STORAGE_SERVICE) throw new AssertionError();
|
||||
|
||||
byte[] kbsMasterKey = TextSecurePreferences.getMasterKey(context);
|
||||
|
||||
if (kbsMasterKey == null) {
|
||||
Log.w(TAG, "No KBS master key is set! Must abort.");
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] storageServiceKey = SignalStorageUtil.computeStorageServiceKey(kbsMasterKey);
|
||||
SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager();
|
||||
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
|
||||
StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context);
|
||||
|
||||
long currentVersion = accountManager.getStorageManifestVersion();
|
||||
Map<RecipientId, byte[]> oldContactKeys = recipientDatabase.getAllStorageSyncKeysMap();
|
||||
List<byte[]> oldUnknownKeys = storageKeyDatabase.getAllKeys();
|
||||
|
||||
long newVersion = currentVersion + 1;
|
||||
Map<RecipientId, byte[]> newContactKeys = generateNewKeys(oldContactKeys);
|
||||
List<byte[]> keysToDelete = Util.concatenatedList(new ArrayList<>(oldContactKeys.values()), oldUnknownKeys);
|
||||
List<SignalStorageRecord> 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();
|
||||
}
|
||||
|
||||
TextSecurePreferences.setStorageManifestVersion(context, newVersion);
|
||||
recipientDatabase.applyStorageSyncKeyUpdates(newContactKeys);
|
||||
storageKeyDatabase.deleteAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onShouldRetry(@NonNull Exception e) {
|
||||
return e instanceof PushNetworkException || e instanceof RetryLaterException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCanceled() {
|
||||
}
|
||||
|
||||
private static @NonNull Map<RecipientId, byte[]> generateNewKeys(@NonNull Map<RecipientId, byte[]> oldKeys) {
|
||||
Map<RecipientId, byte[]> out = new HashMap<>();
|
||||
|
||||
for (Map.Entry<RecipientId, byte[]> entry : oldKeys.entrySet()) {
|
||||
out.put(entry.getKey(), StorageSyncHelper.generateKey());
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
public static final class Factory implements Job.Factory<StorageForcePushJob> {
|
||||
|
||||
@Override
|
||||
public @NonNull
|
||||
StorageForcePushJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
return new StorageForcePushJob(parameters);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,228 @@
|
|||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper.KeyDifferenceResult;
|
||||
import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper.LocalWriteResult;
|
||||
import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper.MergeResult;
|
||||
import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper.WriteOperationResult;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings;
|
||||
import org.thoughtcrime.securesms.database.StorageKeyDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.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.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
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;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Does a full sync of our local storage state with the remote storage state. Will write any pending
|
||||
* local changes and resolve any conflicts with remote storage.
|
||||
*
|
||||
* This should be performed whenever a change is made locally, or whenever we want to retrieve
|
||||
* changes that have been made remotely.
|
||||
*/
|
||||
public class StorageSyncJob extends BaseJob {
|
||||
|
||||
public static final String KEY = "StorageSyncJob";
|
||||
public static final String QUEUE_KEY = "StorageSyncingJobs";
|
||||
|
||||
private static final String TAG = Log.tag(StorageSyncJob.class);
|
||||
|
||||
public StorageSyncJob() {
|
||||
this(new Job.Parameters.Builder().addConstraint(NetworkConstraint.KEY)
|
||||
.setQueue(QUEUE_KEY)
|
||||
.setMaxInstances(1)
|
||||
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
||||
.build());
|
||||
}
|
||||
|
||||
private StorageSyncJob(@NonNull Parameters parameters) {
|
||||
super(parameters);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Data serialize() {
|
||||
return Data.EMPTY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRun() throws IOException, RetryLaterException {
|
||||
if (!FeatureFlags.STORAGE_SERVICE) throw new AssertionError();
|
||||
|
||||
try {
|
||||
boolean needsMultiDeviceSync = performSync();
|
||||
|
||||
if (TextSecurePreferences.isMultiDevice(context) && needsMultiDeviceSync) {
|
||||
ApplicationDependencies.getJobManager().add(new MultiDeviceStorageSyncRequestJob());
|
||||
}
|
||||
} catch (InvalidKeyException e) {
|
||||
Log.w(TAG, "Failed to decrypt remote storage! Force-pushing and syncing the storage key to linked devices.", e);
|
||||
|
||||
ApplicationDependencies.getJobManager().startChain(new MultiDeviceKeysUpdateJob())
|
||||
.then(new StorageForcePushJob())
|
||||
.then(new MultiDeviceStorageSyncRequestJob())
|
||||
.enqueue();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onShouldRetry(@NonNull Exception e) {
|
||||
return e instanceof PushNetworkException || e instanceof RetryLaterException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCanceled() {
|
||||
}
|
||||
|
||||
private boolean performSync() throws IOException, RetryLaterException, InvalidKeyException {
|
||||
SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager();
|
||||
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
|
||||
StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context);
|
||||
byte[] kbsMasterKey = TextSecurePreferences.getMasterKey(context);
|
||||
|
||||
if (kbsMasterKey == null) {
|
||||
Log.w(TAG, "No KBS master key is set! Must abort.");
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] storageServiceKey = SignalStorageUtil.computeStorageServiceKey(kbsMasterKey);
|
||||
boolean needsMultiDeviceSync = false;
|
||||
long localManifestVersion = TextSecurePreferences.getStorageManifestVersion(context);
|
||||
SignalStorageManifest remoteManifest = accountManager.getStorageManifest(storageServiceKey).or(new SignalStorageManifest(0, Collections.emptyList()));
|
||||
|
||||
if (remoteManifest.getVersion() > localManifestVersion) {
|
||||
Log.i(TAG, "Newer manifest version found! Our version: " + localManifestVersion + ", their version: " + remoteManifest.getVersion());
|
||||
|
||||
List<byte[]> allLocalStorageKeys = getAllLocalStorageKeys(context);
|
||||
KeyDifferenceResult keyDifference = StorageSyncHelper.findKeyDifference(remoteManifest.getStorageKeys(), allLocalStorageKeys);
|
||||
|
||||
if (!keyDifference.isEmpty()) {
|
||||
List<SignalStorageRecord> localOnly = buildLocalStorageRecords(context, keyDifference.getLocalOnlyKeys());
|
||||
List<SignalStorageRecord> remoteOnly = accountManager.readStorageRecords(storageServiceKey, keyDifference.getRemoteOnlyKeys());
|
||||
MergeResult mergeResult = StorageSyncHelper.resolveConflict(remoteOnly, localOnly);
|
||||
WriteOperationResult writeOperationResult = StorageSyncHelper.createWriteOperation(remoteManifest.getVersion(), allLocalStorageKeys, mergeResult);
|
||||
|
||||
Optional<SignalStorageManifest> conflict = accountManager.writeStorageRecords(storageServiceKey, writeOperationResult.getManifest(), writeOperationResult.getInserts(), writeOperationResult.getDeletes());
|
||||
|
||||
if (conflict.isPresent()) {
|
||||
Log.w(TAG, "Hit a conflict when trying to resolve the conflict! Retrying.");
|
||||
throw new RetryLaterException();
|
||||
}
|
||||
|
||||
recipientDatabase.applyStorageSyncUpdates(mergeResult.getLocalContactInserts(), mergeResult.getLocalContactUpdates());
|
||||
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());
|
||||
} 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());
|
||||
}
|
||||
}
|
||||
|
||||
localManifestVersion = TextSecurePreferences.getStorageManifestVersion(context);
|
||||
|
||||
List<byte[]> allLocalStorageKeys = recipientDatabase.getAllStorageSyncKeys();
|
||||
List<RecipientSettings> pendingUpdates = recipientDatabase.getPendingRecipientSyncUpdates();
|
||||
List<RecipientSettings> pendingInsertions = recipientDatabase.getPendingRecipientSyncInsertions();
|
||||
List<RecipientSettings> pendingDeletions = recipientDatabase.getPendingRecipientSyncDeletions();
|
||||
Optional<LocalWriteResult> localWriteResult = StorageSyncHelper.buildStorageUpdatesForLocal(localManifestVersion,
|
||||
allLocalStorageKeys,
|
||||
pendingUpdates,
|
||||
pendingInsertions,
|
||||
pendingDeletions);
|
||||
|
||||
if (localWriteResult.isPresent()) {
|
||||
WriteOperationResult localWrite = localWriteResult.get().getWriteResult();
|
||||
Optional<SignalStorageManifest> 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.");
|
||||
throw new RetryLaterException();
|
||||
}
|
||||
|
||||
List<RecipientId> clearIds = new ArrayList<>(pendingUpdates.size() + pendingInsertions.size() + pendingDeletions.size());
|
||||
|
||||
clearIds.addAll(Stream.of(pendingUpdates).map(RecipientSettings::getId).toList());
|
||||
clearIds.addAll(Stream.of(pendingInsertions).map(RecipientSettings::getId).toList());
|
||||
clearIds.addAll(Stream.of(pendingDeletions).map(RecipientSettings::getId).toList());
|
||||
|
||||
recipientDatabase.clearDirtyState(clearIds);
|
||||
recipientDatabase.updateStorageKeys(localWriteResult.get().getStorageKeyUpdates());
|
||||
|
||||
needsMultiDeviceSync = true;
|
||||
|
||||
Log.i(TAG, "[Post Write] 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.");
|
||||
}
|
||||
|
||||
return needsMultiDeviceSync;
|
||||
}
|
||||
|
||||
public static @NonNull List<byte[]> getAllLocalStorageKeys(@NonNull Context context) {
|
||||
return Util.concatenatedList(DatabaseFactory.getRecipientDatabase(context).getAllStorageSyncKeys(),
|
||||
DatabaseFactory.getStorageKeyDatabase(context).getAllKeys());
|
||||
}
|
||||
|
||||
public static @NonNull List<SignalStorageRecord> buildLocalStorageRecords(@NonNull Context context, @NonNull List<byte[]> keys) {
|
||||
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
|
||||
StorageKeyDatabase storageKeyDatabase = DatabaseFactory.getStorageKeyDatabase(context);
|
||||
|
||||
List<SignalStorageRecord> records = new ArrayList<>(keys.size());
|
||||
|
||||
for (byte[] key : keys) {
|
||||
SignalStorageRecord record = Optional.fromNullable(recipientDatabase.getByStorageSyncKey(key))
|
||||
.transform(recipient -> {
|
||||
SignalContactRecord contact = StorageSyncHelper.localToRemoteContact(recipient);
|
||||
return SignalStorageRecord.forContact(key, contact);
|
||||
})
|
||||
.or(() -> storageKeyDatabase.getByKey(key));
|
||||
records.add(record);
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
public static final class Factory implements Job.Factory<StorageSyncJob> {
|
||||
|
||||
@Override
|
||||
public @NonNull StorageSyncJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
return new StorageSyncJob(parameters);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,6 +13,7 @@ import org.whispersystems.signalservice.internal.configuration.SignalContactDisc
|
|||
import org.whispersystems.signalservice.internal.configuration.SignalKeyBackupServiceUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
@ -120,34 +121,49 @@ public class SignalServiceNetworkAccess {
|
|||
|
||||
final SignalKeyBackupServiceUrl signalContactDiscoveryUrl = new SignalKeyBackupServiceUrl(BuildConfig.SIGNAL_KEY_BACKUP_URL, SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC);
|
||||
|
||||
final SignalStorageUrl baseGoogleStorage = new SignalStorageUrl("https://www.google.com/directory", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC);
|
||||
final SignalStorageUrl baseAndroidStorage = new SignalStorageUrl("https://android.clients.google.com/directory", SERVICE_REFLECTOR_HOST, trustStore, PLAY_CONNECTION_SPEC);
|
||||
final SignalStorageUrl mapsOneAndroidStorage = new SignalStorageUrl("https://clients3.google.com/directory", SERVICE_REFLECTOR_HOST, trustStore, GMAPS_CONNECTION_SPEC);
|
||||
final SignalStorageUrl mapsTwoAndroidStorage = new SignalStorageUrl("https://clients4.google.com/directory", SERVICE_REFLECTOR_HOST, trustStore, GMAPS_CONNECTION_SPEC);
|
||||
final SignalStorageUrl mailAndroidStorage = new SignalStorageUrl("https://inbox.google.com/directory", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC);
|
||||
final SignalStorageUrl egyptGoogleStorage = new SignalStorageUrl("https://www.google.com.eg/directory", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC);
|
||||
final SignalStorageUrl uaeGoogleStorage = new SignalStorageUrl("https://www.google.ae/directory", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC);
|
||||
final SignalStorageUrl omanGoogleStorage = new SignalStorageUrl("https://www.google.com.om/directory", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC);
|
||||
final SignalStorageUrl qatarGoogleStorage = new SignalStorageUrl("https://www.google.com.qa/directory", SERVICE_REFLECTOR_HOST, trustStore, GMAIL_CONNECTION_SPEC);
|
||||
|
||||
|
||||
this.censorshipConfiguration = new HashMap<String, SignalServiceConfiguration>() {{
|
||||
put(COUNTRY_CODE_EGYPT, new SignalServiceConfiguration(new SignalServiceUrl[] {egyptGoogleService, baseGoogleService, baseAndroidService, mapsOneAndroidService, mapsTwoAndroidService, mailAndroidService},
|
||||
new SignalCdnUrl[] {egyptGoogleCdn, baseAndroidCdn, baseGoogleCdn, mapsOneAndroidCdn, mapsTwoAndroidCdn, mailAndroidCdn, mailAndroidCdn},
|
||||
new SignalContactDiscoveryUrl[] {egyptGoogleDiscovery, baseGoogleDiscovery, baseAndroidDiscovery, mapsOneAndroidDiscovery, mapsTwoAndroidDiscovery, mailAndroidDiscovery},
|
||||
new SignalKeyBackupServiceUrl[] { signalContactDiscoveryUrl }));
|
||||
new SignalKeyBackupServiceUrl[] { signalContactDiscoveryUrl },
|
||||
new SignalStorageUrl[] {egyptGoogleStorage, baseGoogleStorage, baseAndroidStorage, mapsOneAndroidStorage, mapsTwoAndroidStorage, mailAndroidStorage}));
|
||||
|
||||
put(COUNTRY_CODE_UAE, new SignalServiceConfiguration(new SignalServiceUrl[] {uaeGoogleService, baseAndroidService, baseGoogleService, mapsOneAndroidService, mapsTwoAndroidService, mailAndroidService},
|
||||
new SignalCdnUrl[] {uaeGoogleCdn, baseAndroidCdn, baseGoogleCdn, mapsOneAndroidCdn, mapsTwoAndroidCdn, mailAndroidCdn},
|
||||
new SignalContactDiscoveryUrl[] {uaeGoogleDiscovery, baseGoogleDiscovery, baseAndroidDiscovery, mapsOneAndroidDiscovery, mapsTwoAndroidDiscovery, mailAndroidDiscovery},
|
||||
new SignalKeyBackupServiceUrl[] { signalContactDiscoveryUrl }));
|
||||
new SignalKeyBackupServiceUrl[] { signalContactDiscoveryUrl },
|
||||
new SignalStorageUrl[] {uaeGoogleStorage, baseGoogleStorage, baseAndroidStorage, mapsOneAndroidStorage, mapsTwoAndroidStorage, mailAndroidStorage}));
|
||||
|
||||
put(COUNTRY_CODE_OMAN, new SignalServiceConfiguration(new SignalServiceUrl[] {omanGoogleService, baseAndroidService, baseGoogleService, mapsOneAndroidService, mapsTwoAndroidService, mailAndroidService},
|
||||
new SignalCdnUrl[] {omanGoogleCdn, baseAndroidCdn, baseGoogleCdn, mapsOneAndroidCdn, mapsTwoAndroidCdn, mailAndroidCdn},
|
||||
new SignalContactDiscoveryUrl[] {omanGoogleDiscovery, baseGoogleDiscovery, baseAndroidDiscovery, mapsOneAndroidDiscovery, mapsTwoAndroidDiscovery, mailAndroidDiscovery},
|
||||
new SignalKeyBackupServiceUrl[] { signalContactDiscoveryUrl }));
|
||||
new SignalKeyBackupServiceUrl[] { signalContactDiscoveryUrl },
|
||||
new SignalStorageUrl[] {omanGoogleStorage, baseGoogleStorage, baseAndroidStorage, mapsOneAndroidStorage, mapsTwoAndroidStorage, mailAndroidStorage}));
|
||||
|
||||
|
||||
put(COUNTRY_CODE_QATAR, new SignalServiceConfiguration(new SignalServiceUrl[] {qatarGoogleService, baseAndroidService, baseGoogleService, mapsOneAndroidService, mapsTwoAndroidService, mailAndroidService},
|
||||
new SignalCdnUrl[] {qatarGoogleCdn, baseAndroidCdn, baseGoogleCdn, mapsOneAndroidCdn, mapsTwoAndroidCdn, mailAndroidCdn},
|
||||
new SignalContactDiscoveryUrl[] {qatarGoogleDiscovery, baseGoogleDiscovery, baseAndroidDiscovery, mapsOneAndroidDiscovery, mapsTwoAndroidDiscovery, mailAndroidDiscovery},
|
||||
new SignalKeyBackupServiceUrl[] { signalContactDiscoveryUrl }));
|
||||
new SignalKeyBackupServiceUrl[] { signalContactDiscoveryUrl },
|
||||
new SignalStorageUrl[] {qatarGoogleStorage, baseGoogleStorage, baseAndroidStorage, mapsOneAndroidStorage, mapsTwoAndroidStorage, mailAndroidStorage}));
|
||||
}};
|
||||
|
||||
this.uncensoredConfiguration = new SignalServiceConfiguration(new SignalServiceUrl[] {new SignalServiceUrl(BuildConfig.SIGNAL_URL, new SignalServiceTrustStore(context))},
|
||||
new SignalCdnUrl[] {new SignalCdnUrl(BuildConfig.SIGNAL_CDN_URL, new SignalServiceTrustStore(context))},
|
||||
new SignalContactDiscoveryUrl[] {new SignalContactDiscoveryUrl(BuildConfig.SIGNAL_CONTACT_DISCOVERY_URL, new SignalServiceTrustStore(context))},
|
||||
new SignalKeyBackupServiceUrl[] { new SignalKeyBackupServiceUrl(BuildConfig.SIGNAL_KEY_BACKUP_URL, new SignalServiceTrustStore(context)) });
|
||||
new SignalKeyBackupServiceUrl[] { new SignalKeyBackupServiceUrl(BuildConfig.SIGNAL_KEY_BACKUP_URL, new SignalServiceTrustStore(context)) },
|
||||
new SignalStorageUrl[] {new SignalStorageUrl(BuildConfig.STORAGE_URL, new SignalServiceTrustStore(context))});
|
||||
|
||||
this.censoredCountries = this.censorshipConfiguration.keySet().toArray(new String[0]);
|
||||
}
|
||||
|
|
|
@ -22,6 +22,8 @@ import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
|
|||
import org.thoughtcrime.securesms.contacts.avatars.SystemContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.TransparentContactPhoto;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode;
|
||||
|
@ -89,6 +91,9 @@ public class Recipient {
|
|||
private final boolean forceSmsSelection;
|
||||
private final boolean uuidSupported;
|
||||
private final InsightsBannerTier insightsBannerTier;
|
||||
private final byte[] storageKey;
|
||||
private final byte[] identityKey;
|
||||
private final VerifiedStatus identityStatus;
|
||||
|
||||
|
||||
/**
|
||||
|
@ -293,6 +298,9 @@ public class Recipient {
|
|||
this.unidentifiedAccessMode = UnidentifiedAccessMode.DISABLED;
|
||||
this.forceSmsSelection = false;
|
||||
this.uuidSupported = false;
|
||||
this.storageKey = null;
|
||||
this.identityKey = null;
|
||||
this.identityStatus = VerifiedStatus.DEFAULT;
|
||||
}
|
||||
|
||||
Recipient(@NonNull RecipientId id, @NonNull RecipientDetails details) {
|
||||
|
@ -329,6 +337,9 @@ public class Recipient {
|
|||
this.unidentifiedAccessMode = details.unidentifiedAccessMode;
|
||||
this.forceSmsSelection = details.forceSmsSelection;
|
||||
this.uuidSupported = details.uuidSuported;
|
||||
this.storageKey = details.storageKey;
|
||||
this.identityKey = details.identityKey;
|
||||
this.identityStatus = details.identityStatus;
|
||||
}
|
||||
|
||||
public @NonNull RecipientId getId() {
|
||||
|
@ -645,6 +656,18 @@ public class Recipient {
|
|||
return profileKey;
|
||||
}
|
||||
|
||||
public @Nullable byte[] getStorageServiceKey() {
|
||||
return storageKey;
|
||||
}
|
||||
|
||||
public @NonNull VerifiedStatus getIdentityVerifiedStatus() {
|
||||
return identityStatus;
|
||||
}
|
||||
|
||||
public @Nullable byte[] getIdentityKey() {
|
||||
return identityKey;
|
||||
}
|
||||
|
||||
public @NonNull UnidentifiedAccessMode getUnidentifiedAccessMode() {
|
||||
return unidentifiedAccessMode;
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import androidx.annotation.Nullable;
|
|||
import org.thoughtcrime.securesms.color.MaterialColor;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.InsightsBannerTier;
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode;
|
||||
|
@ -55,6 +56,9 @@ public class RecipientDetails {
|
|||
final boolean forceSmsSelection;
|
||||
final boolean uuidSuported;
|
||||
final InsightsBannerTier insightsBannerTier;
|
||||
final byte[] storageKey;
|
||||
final byte[] identityKey;
|
||||
final VerifiedStatus identityStatus;
|
||||
|
||||
RecipientDetails(@NonNull Context context,
|
||||
@Nullable String name,
|
||||
|
@ -95,12 +99,18 @@ public class RecipientDetails {
|
|||
this.forceSmsSelection = settings.isForceSmsSelection();
|
||||
this.uuidSuported = settings.isUuidSupported();
|
||||
this.insightsBannerTier = settings.getInsightsBannerTier();
|
||||
this.storageKey = settings.getStorageKey();
|
||||
this.identityKey = settings.getIdentityKey();
|
||||
this.identityStatus = settings.getIdentityStatus();
|
||||
|
||||
if (name == null) this.name = settings.getSystemDisplayName();
|
||||
else this.name = name;
|
||||
}
|
||||
|
||||
public RecipientDetails() {
|
||||
/**
|
||||
* Only used for {@link Recipient#UNKNOWN}.
|
||||
*/
|
||||
RecipientDetails() {
|
||||
this.groupAvatarId = null;
|
||||
this.systemContactPhoto = null;
|
||||
this.customLabel = null;
|
||||
|
@ -133,5 +143,8 @@ public class RecipientDetails {
|
|||
this.forceSmsSelection = false;
|
||||
this.name = null;
|
||||
this.uuidSuported = false;
|
||||
this.storageKey = null;
|
||||
this.identityKey = null;
|
||||
this.identityStatus = VerifiedStatus.DEFAULT;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,9 @@ public class FeatureFlags {
|
|||
/** Set or migrate PIN to KBS */
|
||||
public static final boolean KBS = false;
|
||||
|
||||
/** Storage service. Requires {@link #KBS}. */
|
||||
public static final boolean STORAGE_SERVICE = false;
|
||||
|
||||
/** Send support for reactions. */
|
||||
public static final boolean REACTION_SENDING = false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public final class SetUtil {
|
||||
private SetUtil() {}
|
||||
|
||||
public static <E> Set<E> intersection(Set<E> a, Set<E> b) {
|
||||
Set<E> intersection = new LinkedHashSet<>(a);
|
||||
intersection.retainAll(b);
|
||||
return intersection;
|
||||
}
|
||||
|
||||
public static <E> Set<E> difference(Set<E> a, Set<E> b) {
|
||||
Set<E> difference = new LinkedHashSet<>(a);
|
||||
difference.removeAll(b);
|
||||
return difference;
|
||||
}
|
||||
|
||||
public static <E> Set<E> union(Set<E>... sets) {
|
||||
Set<E> result = new LinkedHashSet<>();
|
||||
|
||||
for (Set<E> set : sets) {
|
||||
result.addAll(set);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
|
@ -213,6 +213,8 @@ public class TextSecurePreferences {
|
|||
|
||||
private static final String HAS_SEEN_VIDEO_RECORDING_TOOLTIP = "camerax.fragment.has.dismissed.video.recording.tooltip";
|
||||
|
||||
private static final String STORAGE_MANIFEST_VERSION = "pref_storage_manifest_version";
|
||||
|
||||
public static boolean isScreenLockEnabled(@NonNull Context context) {
|
||||
return getBooleanPreference(context, SCREEN_LOCK, false);
|
||||
}
|
||||
|
@ -1328,6 +1330,14 @@ public class TextSecurePreferences {
|
|||
setBooleanPreference(context, HAS_SEEN_VIDEO_RECORDING_TOOLTIP, value);
|
||||
}
|
||||
|
||||
public static long getStorageManifestVersion(Context context) {
|
||||
return getLongPreference(context, STORAGE_MANIFEST_VERSION, 0);
|
||||
}
|
||||
|
||||
public static void setStorageManifestVersion(Context context, long version) {
|
||||
setLongPreference(context, STORAGE_MANIFEST_VERSION, version);
|
||||
}
|
||||
|
||||
public static void setBooleanPreference(Context context, String key, boolean value) {
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(key, value).apply();
|
||||
}
|
||||
|
|
|
@ -0,0 +1,323 @@
|
|||
package org.thoughtcrime.securesms.contacts.sync;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper.KeyDifferenceResult;
|
||||
import org.thoughtcrime.securesms.contacts.sync.StorageSyncHelper.MergeResult;
|
||||
import org.thoughtcrime.securesms.util.Conversions;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import edu.emory.mathcs.backport.java.util.Arrays;
|
||||
|
||||
import static junit.framework.TestCase.assertTrue;
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class StorageSyncHelperTest {
|
||||
|
||||
private static final UUID UUID_A = UuidUtil.parseOrThrow("ebef429e-695e-4f51-bcc4-526a60ac68c7");
|
||||
private static final UUID UUID_B = UuidUtil.parseOrThrow("32119989-77fb-4e18-af70-81d55185c6b1");
|
||||
private static final UUID UUID_C = UuidUtil.parseOrThrow("b5552203-2bca-44aa-b6f5-9f5d87a335b6");
|
||||
private static final UUID UUID_D = UuidUtil.parseOrThrow("94829a32-7199-4a7b-8fb4-7e978509ab84");
|
||||
|
||||
private static final String E164_A = "+16108675309";
|
||||
private static final String E164_B = "+16101234567";
|
||||
private static final String E164_C = "+16101112222";
|
||||
private static final String E164_D = "+16103334444";
|
||||
|
||||
private static final int UNKNOWN_TYPE = Integer.MAX_VALUE;
|
||||
|
||||
@Before
|
||||
public void setup() {
|
||||
StorageSyncHelper.setTestKeyGenerator(null);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void findKeyDifference_allOverlap() {
|
||||
KeyDifferenceResult result = StorageSyncHelper.findKeyDifference(byteListOf(1, 2, 3), byteListOf(1, 2, 3));
|
||||
assertTrue(result.getLocalOnlyKeys().isEmpty());
|
||||
assertTrue(result.getRemoteOnlyKeys().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void findKeyDifference_noOverlap() {
|
||||
KeyDifferenceResult result = StorageSyncHelper.findKeyDifference(byteListOf(1, 2, 3), byteListOf(4, 5, 6));
|
||||
assertByteListEquals(byteListOf(1, 2, 3), result.getRemoteOnlyKeys());
|
||||
assertByteListEquals(byteListOf(4, 5, 6), result.getLocalOnlyKeys());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void findKeyDifference_someOverlap() {
|
||||
KeyDifferenceResult result = StorageSyncHelper.findKeyDifference(byteListOf(1, 2, 3), byteListOf(2, 3, 4));
|
||||
assertByteListEquals(byteListOf(1), result.getRemoteOnlyKeys());
|
||||
assertByteListEquals(byteListOf(4), result.getLocalOnlyKeys());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveConflict_noOverlap() {
|
||||
SignalContactRecord remote1 = contact(1, UUID_A, E164_A, "a");
|
||||
SignalContactRecord local1 = contact(2, UUID_B, E164_B, "b");
|
||||
|
||||
MergeResult result = StorageSyncHelper.resolveConflict(recordSetOf(remote1), recordSetOf(local1));
|
||||
|
||||
assertEquals(setOf(remote1), result.getLocalContactInserts());
|
||||
assertTrue(result.getLocalContactUpdates().isEmpty());
|
||||
assertEquals(setOf(local1), result.getRemoteContactInserts());
|
||||
assertTrue(result.getRemoteContactUpdates().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveConflict_sameAsRemote() {
|
||||
SignalContactRecord remote1 = contact(1, UUID_A, E164_A, "a");
|
||||
SignalContactRecord local1 = contact(2, UUID_A, E164_A, "a");
|
||||
|
||||
MergeResult result = StorageSyncHelper.resolveConflict(recordSetOf(remote1), recordSetOf(local1));
|
||||
|
||||
SignalContactRecord expectedMerge = contact(1, UUID_A, E164_A, "a");
|
||||
|
||||
assertTrue(result.getLocalContactInserts().isEmpty());
|
||||
assertEquals(setOf(contactUpdate(local1, expectedMerge)), result.getLocalContactUpdates());
|
||||
assertTrue(result.getRemoteContactInserts().isEmpty());
|
||||
assertTrue(result.getRemoteContactUpdates().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveConflict_sameAsLocal() {
|
||||
SignalContactRecord remote1 = contact(1, UUID_A, E164_A, null);
|
||||
SignalContactRecord local1 = contact(2, UUID_A, E164_A, "a");
|
||||
|
||||
MergeResult result = StorageSyncHelper.resolveConflict(recordSetOf(remote1), recordSetOf(local1));
|
||||
|
||||
SignalContactRecord expectedMerge = contact(2, UUID_A, E164_A, "a");
|
||||
|
||||
assertTrue(result.getLocalContactInserts().isEmpty());
|
||||
assertTrue(result.getLocalContactUpdates().isEmpty());
|
||||
assertTrue(result.getRemoteContactInserts().isEmpty());
|
||||
assertEquals(setOf(contactUpdate(remote1, expectedMerge)), result.getRemoteContactUpdates());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveConflict_unknowns() {
|
||||
SignalStorageRecord remote1 = unknown(3);
|
||||
SignalStorageRecord remote2 = unknown(4);
|
||||
SignalStorageRecord local1 = unknown(1);
|
||||
SignalStorageRecord local2 = unknown(2);
|
||||
|
||||
MergeResult result = StorageSyncHelper.resolveConflict(setOf(remote1, remote2), setOf(local1, local2));
|
||||
|
||||
assertTrue(result.getLocalContactInserts().isEmpty());
|
||||
assertTrue(result.getLocalContactUpdates().isEmpty());
|
||||
assertEquals(setOf(remote1, remote2), result.getLocalUnknownInserts());
|
||||
assertEquals(setOf(local1, local2), result.getLocalUnknownDeletes());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveConflict_complex() {
|
||||
SignalContactRecord remote1 = contact(1, UUID_A, null, "a");
|
||||
SignalContactRecord local1 = contact(2, UUID_A, E164_A, "a");
|
||||
|
||||
SignalContactRecord remote2 = contact(3, UUID_B, E164_B, null);
|
||||
SignalContactRecord local2 = contact(4, UUID_B, null, "b");
|
||||
|
||||
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);
|
||||
|
||||
StorageSyncHelper.setTestKeyGenerator(new TestGenerator(999));
|
||||
|
||||
Set<SignalStorageRecord> remoteOnly = recordSetOf(remote1, remote2, remote3);
|
||||
Set<SignalStorageRecord> localOnly = recordSetOf(local1, local2, local3);
|
||||
|
||||
remoteOnly.add(unknownRemote);
|
||||
localOnly.add(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");
|
||||
|
||||
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(unknownRemote), result.getLocalUnknownInserts());
|
||||
assertEquals(setOf(unknownLocal), result.getLocalUnknownDeletes());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mergeContacts_alwaysPreferRemoteExceptNickname() {
|
||||
SignalContactRecord remote = new SignalContactRecord.Builder(byteArray(1), new SignalServiceAddress(UUID_A, E164_A))
|
||||
.setBlocked(true)
|
||||
.setIdentityKey(byteArray(2))
|
||||
.setIdentityState(SignalContactRecord.IdentityState.VERIFIED)
|
||||
.setProfileKey(byteArray(3))
|
||||
.setProfileName("profile name A")
|
||||
.setUsername("username A")
|
||||
.setNickname("nickname A")
|
||||
.setProfileSharingEnabled(true)
|
||||
.build();
|
||||
SignalContactRecord local = new SignalContactRecord.Builder(byteArray(2), new SignalServiceAddress(UUID_B, E164_B))
|
||||
.setBlocked(false)
|
||||
.setIdentityKey(byteArray(99))
|
||||
.setIdentityState(SignalContactRecord.IdentityState.DEFAULT)
|
||||
.setProfileKey(byteArray(999))
|
||||
.setProfileName("profile name B")
|
||||
.setUsername("username B")
|
||||
.setNickname("nickname B")
|
||||
.setProfileSharingEnabled(false)
|
||||
.build();
|
||||
SignalContactRecord merged = StorageSyncHelper.mergeContacts(remote, local);
|
||||
|
||||
assertEquals(UUID_A, merged.getAddress().getUuid().get());
|
||||
assertEquals(E164_A, merged.getAddress().getNumber().get());
|
||||
assertTrue(merged.isBlocked());
|
||||
assertArrayEquals(byteArray(2), merged.getIdentityKey().get());
|
||||
assertEquals(SignalContactRecord.IdentityState.VERIFIED, merged.getIdentityState());
|
||||
assertArrayEquals(byteArray(3), merged.getProfileKey().get());
|
||||
assertEquals("profile name A", merged.getProfileName().get());
|
||||
assertEquals("username A", merged.getUsername().get());
|
||||
assertEquals("nickname B", merged.getNickname().get());
|
||||
assertTrue(merged.isProfileSharingEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mergeContacts_fillInGaps() {
|
||||
SignalContactRecord remote = new SignalContactRecord.Builder(byteArray(1), new SignalServiceAddress(UUID_A, null))
|
||||
.setBlocked(true)
|
||||
.setProfileName("profile name A")
|
||||
.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")
|
||||
.setUsername("username B")
|
||||
.setProfileSharingEnabled(false)
|
||||
.build();
|
||||
SignalContactRecord merged = StorageSyncHelper.mergeContacts(remote, local);
|
||||
|
||||
assertEquals(UUID_A, merged.getAddress().getUuid().get());
|
||||
assertEquals(E164_B, merged.getAddress().getNumber().get());
|
||||
assertTrue(merged.isBlocked());
|
||||
assertArrayEquals(byteArray(2), merged.getIdentityKey().get());
|
||||
assertEquals(SignalContactRecord.IdentityState.DEFAULT, merged.getIdentityState());
|
||||
assertArrayEquals(byteArray(3), merged.getProfileKey().get());
|
||||
assertEquals("profile name A", merged.getProfileName().get());
|
||||
assertEquals("username B", merged.getUsername().get());
|
||||
assertTrue(merged.isProfileSharingEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createWriteOperation_generic() {
|
||||
List<byte[]> localKeys = byteListOf(1, 2, 3, 4);
|
||||
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);
|
||||
|
||||
StorageSyncHelper.WriteOperationResult result = StorageSyncHelper.createWriteOperation(1,
|
||||
localKeys,
|
||||
new MergeResult(setOf(insert2),
|
||||
setOf(contactUpdate(old2, new2)),
|
||||
setOf(insert1),
|
||||
setOf(contactUpdate(old1, new1)),
|
||||
setOf(unknownInsert),
|
||||
setOf(unknownDelete)));
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
private static <E> Set<E> setOf(E... vals) {
|
||||
return new LinkedHashSet<E>(Arrays.asList(vals));
|
||||
}
|
||||
|
||||
private static Set<SignalStorageRecord> recordSetOf(SignalContactRecord... contactRecords) {
|
||||
LinkedHashSet<SignalStorageRecord> storageRecords = new LinkedHashSet<>();
|
||||
|
||||
for (SignalContactRecord contactRecord : contactRecords) {
|
||||
storageRecords.add(SignalStorageRecord.forContact(contactRecord.getKey(), contactRecord));
|
||||
}
|
||||
|
||||
return storageRecords;
|
||||
}
|
||||
|
||||
private static SignalContactRecord contact(int key,
|
||||
UUID uuid,
|
||||
String e164,
|
||||
String profileName)
|
||||
{
|
||||
return new SignalContactRecord.Builder(byteArray(key), new SignalServiceAddress(uuid, e164))
|
||||
.setProfileName(profileName)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static StorageSyncHelper.ContactUpdate contactUpdate(SignalContactRecord oldContact, SignalContactRecord newContact) {
|
||||
return new StorageSyncHelper.ContactUpdate(oldContact, newContact);
|
||||
}
|
||||
|
||||
private static SignalStorageRecord unknown(int key) {
|
||||
return SignalStorageRecord.forUnknown(byteArray(key), UNKNOWN_TYPE);
|
||||
}
|
||||
|
||||
private static List<byte[]> byteListOf(int... vals) {
|
||||
List<byte[]> list = new ArrayList<>(vals.length);
|
||||
|
||||
for (int i = 0; i < vals.length; i++) {
|
||||
list.add(Conversions.intToByteArray(vals[i]));
|
||||
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static byte[] byteArray(int a) {
|
||||
return Conversions.intToByteArray(a);
|
||||
}
|
||||
|
||||
private static void assertByteListEquals(List<byte[]> a, List<byte[]> b) {
|
||||
assertEquals(a.size(), b.size());
|
||||
|
||||
List<ByteBuffer> aBuffer = Stream.of(a).map(ByteBuffer::wrap).toList();
|
||||
List<ByteBuffer> bBuffer = Stream.of(b).map(ByteBuffer::wrap).toList();
|
||||
|
||||
assertTrue(aBuffer.containsAll(bBuffer));
|
||||
}
|
||||
|
||||
private static class TestGenerator implements StorageSyncHelper.KeyGenerator {
|
||||
private final byte[] key;
|
||||
|
||||
private TestGenerator(int key) {
|
||||
this.key = byteArray(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull byte[] generate() {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue