diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java index 2b7bae4e55..ca490da328 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java @@ -207,7 +207,7 @@ public class FullBackupImporter extends FullBackupBase { private static void processAvatar(@NonNull Context context, @NonNull SQLiteDatabase db, @NonNull BackupProtos.Avatar avatar, @NonNull BackupRecordInputStream inputStream) throws IOException { if (avatar.hasRecipientId()) { RecipientId recipientId = RecipientId.from(avatar.getRecipientId()); - inputStream.readAttachmentTo(AvatarHelper.getOutputStream(context, recipientId), avatar.getLength()); + inputStream.readAttachmentTo(AvatarHelper.getOutputStream(context, recipientId, false), avatar.getLength()); } else { if (avatar.hasName() && SqlUtil.tableExists(db, "recipient_preferences")) { Log.w(TAG, "Avatar is missing a recipientId. Clearing signal_profile_avatar (legacy) so it can be fetched later."); diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ProfileContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ProfileContactPhoto.java index c14a762a8b..6dd7a080c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ProfileContactPhoto.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ProfileContactPhoto.java @@ -65,10 +65,6 @@ public class ProfileContactPhoto implements ContactPhoto { } private long getFileLastModified() { - if (!recipient.isSelf()) { - return 0; - } - return AvatarHelper.getLastModified(ApplicationDependencies.getApplication(), recipient.getId()); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt index 6d79418be6..8ba2bf22f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt @@ -1524,6 +1524,15 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : } } + fun setSystemContactName(id: RecipientId, systemContactName: String) { + val values = ContentValues().apply { + put(SYSTEM_JOINED_NAME, systemContactName) + } + if (update(id, values)) { + Recipient.live(id).refresh() + } + } + fun setProfileName(id: RecipientId, profileName: ProfileName) { val contentValues = ContentValues(1).apply { put(PROFILE_GIVEN_NAME, profileName.givenName) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 9b17c63329..67b414e080 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -104,6 +104,7 @@ public final class JobManagerFactories { put(MmsSendJob.KEY, new MmsSendJob.Factory()); put(MultiDeviceBlockedUpdateJob.KEY, new MultiDeviceBlockedUpdateJob.Factory()); put(MultiDeviceConfigurationUpdateJob.KEY, new MultiDeviceConfigurationUpdateJob.Factory()); + put(MultiDeviceContactSyncJob.KEY, new MultiDeviceContactSyncJob.Factory()); put(MultiDeviceContactUpdateJob.KEY, new MultiDeviceContactUpdateJob.Factory()); put(MultiDeviceGroupUpdateJob.KEY, new MultiDeviceGroupUpdateJob.Factory()); put(MultiDeviceKeysUpdateJob.KEY, new MultiDeviceKeysUpdateJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactSyncJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactSyncJob.kt new file mode 100644 index 0000000000..bd035fdb3b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactSyncJob.kt @@ -0,0 +1,154 @@ +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.jobmanager.Data +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.net.NotPushRegisteredException +import org.thoughtcrime.securesms.profiles.AvatarHelper +import org.thoughtcrime.securesms.providers.BlobProvider +import org.thoughtcrime.securesms.recipients.Recipient +import org.whispersystems.libsignal.InvalidMessageException +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer +import org.whispersystems.signalservice.api.messages.multidevice.DeviceContact +import org.whispersystems.signalservice.api.messages.multidevice.DeviceContactsInputStream +import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage.VerifiedState +import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException +import org.whispersystems.signalservice.api.util.AttachmentPointerUtil +import java.io.File +import java.io.IOException +import java.io.InputStream + +/** + * Sync contact data from primary device. + */ +class MultiDeviceContactSyncJob(parameters: Parameters, private val attachmentPointer: ByteArray) : BaseJob(parameters) { + + constructor(contactsAttachment: SignalServiceAttachmentPointer) : this( + Parameters.Builder() + .setQueue("MultiDeviceContactSyncJob") + .build(), + AttachmentPointerUtil.createAttachmentPointer(contactsAttachment).toByteArray() + ) + + override fun serialize(): Data { + return Data.Builder() + .putBlobAsString(KEY_ATTACHMENT_POINTER, attachmentPointer) + .build() + } + + override fun getFactoryKey(): String { + return KEY + } + + override fun onRun() { + if (!Recipient.self().isRegistered) { + throw NotPushRegisteredException() + } + + if (SignalStore.account().isPrimaryDevice) { + Log.i(TAG, "Sync jobs are for linked devices only") + return + } + + val contactAttachment: SignalServiceAttachmentPointer = AttachmentPointerUtil.createSignalAttachmentPointer(attachmentPointer) + + try { + val contactsFile: File = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(context) + ApplicationDependencies.getSignalServiceMessageReceiver() + .retrieveAttachment(contactAttachment, contactsFile, MAX_ATTACHMENT_SIZE) + .use(this::processContactFile) + } catch (e: MissingConfigurationException) { + throw IOException(e) + } catch (e: InvalidMessageException) { + throw IOException(e) + } + } + + private fun processContactFile(inputStream: InputStream) { + val deviceContacts = DeviceContactsInputStream(inputStream) + val recipients = SignalDatabase.recipients + val threads = SignalDatabase.threads + + var contact: DeviceContact? = deviceContacts.read() + while (contact != null) { + val recipient = Recipient.externalPush(context, contact.address.aci, contact.address.number.orNull(), true) + + if (recipient.isSelf) { + contact = deviceContacts.read() + continue + } + + if (contact.name.isPresent) { + recipients.setSystemContactName(recipient.id, contact.name.get()) + } + + if (contact.expirationTimer.isPresent) { + recipients.setExpireMessages(recipient.id, contact.expirationTimer.get()) + } + + if (contact.profileKey.isPresent) { + val profileKey = contact.profileKey.get() + recipients.setProfileKey(recipient.id, profileKey) + } + + if (contact.verified.isPresent) { + val verifiedStatus: VerifiedStatus = when (contact.verified.get().verified) { + VerifiedState.VERIFIED -> VerifiedStatus.VERIFIED + VerifiedState.UNVERIFIED -> VerifiedStatus.UNVERIFIED + else -> VerifiedStatus.DEFAULT + } + + ApplicationDependencies.getIdentityStore().saveIdentityWithoutSideEffects( + recipient.id, + contact.verified.get().identityKey, + verifiedStatus, + false, + contact.verified.get().timestamp, + true + ) + } + + recipients.setBlocked(recipient.id, contact.isBlocked) + + val threadRecord = threads.getThreadRecord(threads.getThreadIdFor(recipient.id)) + if (threadRecord != null && contact.isArchived != threadRecord.isArchived) { + if (contact.isArchived) { + threads.archiveConversation(threadRecord.threadId) + } else { + threads.unarchiveConversation(threadRecord.threadId) + } + } + + if (contact.avatar.isPresent) { + try { + AvatarHelper.setSyncAvatar(context, recipient.id, contact.avatar.get().inputStream) + } catch (e: IOException) { + Log.w(TAG, "Unable to set sync avatar for ${recipient.id}") + } + } + + contact = deviceContacts.read() + } + } + + override fun onShouldRetry(e: Exception): Boolean = false + + override fun onFailure() = Unit + + class Factory : Job.Factory { + override fun create(parameters: Parameters, data: Data): MultiDeviceContactSyncJob { + return MultiDeviceContactSyncJob(parameters, data.getStringAsBlob(KEY_ATTACHMENT_POINTER)) + } + } + + companion object { + const val KEY = "MultiDeviceContactSyncJob" + const val KEY_ATTACHMENT_POINTER = "attachment_pointer" + private const val MAX_ATTACHMENT_SIZE: Long = 100 * 1024 * 1024 + private val TAG = Log.tag(MultiDeviceContactSyncJob::class.java) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.java index 6a5984540a..3f69098cc2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.java @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.keyvalue; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.whispersystems.libsignal.util.guava.Preconditions; import org.whispersystems.signalservice.api.storage.SignalStorageManifest; import org.whispersystems.signalservice.api.storage.StorageKey; @@ -14,6 +15,7 @@ public class StorageServiceValues extends SignalStoreValues { private static final String LAST_SYNC_TIME = "storage.last_sync_time"; private static final String NEEDS_ACCOUNT_RESTORE = "storage.needs_account_restore"; private static final String MANIFEST = "storage.manifest"; + private static final String SYNC_STORAGE_KEY = "storage.syncStorageKey"; StorageServiceValues(@NonNull KeyValueStore store) { super(store); @@ -29,6 +31,9 @@ public class StorageServiceValues extends SignalStoreValues { } public synchronized StorageKey getOrCreateStorageKey() { + if (getStore().containsKey(SYNC_STORAGE_KEY)) { + return new StorageKey(getBlob(SYNC_STORAGE_KEY, null)); + } return SignalStore.kbsValues().getOrCreateMasterKey().deriveStorageServiceKey(); } @@ -61,4 +66,14 @@ public class StorageServiceValues extends SignalStoreValues { return SignalStorageManifest.EMPTY; } } + + public synchronized void setStorageKeyFromPrimary(@NonNull StorageKey storageKey) { + Preconditions.checkState(SignalStore.account().isLinkedDevice(), "Can only set storage key directly on linked devices"); + putBlob(SYNC_STORAGE_KEY, storageKey.serialize()); + } + + public void clearStorageKeyFromPrimary() { + Preconditions.checkState(SignalStore.account().isLinkedDevice(), "Can only clear storage key directly on linked devices"); + remove(SYNC_STORAGE_KEY); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java index 7a156d346b..c4eecb6bf8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -65,6 +65,7 @@ import org.thoughtcrime.securesms.jobs.GroupCallPeekJob; import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob; import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob; import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob; +import org.thoughtcrime.securesms.jobs.MultiDeviceContactSyncJob; import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; import org.thoughtcrime.securesms.jobs.MultiDeviceGroupUpdateJob; import org.thoughtcrime.securesms.jobs.MultiDeviceKeysUpdateJob; @@ -123,6 +124,7 @@ import org.whispersystems.libsignal.SignalProtocolAddress; import org.whispersystems.libsignal.protocol.DecryptionErrorMessage; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; import org.whispersystems.signalservice.api.messages.SignalServiceContent; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServiceGroup; @@ -139,6 +141,8 @@ import org.whispersystems.signalservice.api.messages.calls.OpaqueMessage; import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage; import org.whispersystems.signalservice.api.messages.multidevice.ConfigurationMessage; +import org.whispersystems.signalservice.api.messages.multidevice.ContactsMessage; +import org.whispersystems.signalservice.api.messages.multidevice.KeysMessage; import org.whispersystems.signalservice.api.messages.multidevice.MessageRequestResponseMessage; import org.whispersystems.signalservice.api.messages.multidevice.OutgoingPaymentMessage; import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage; @@ -309,6 +313,8 @@ public final class MessageContentProcessor { else if (syncMessage.getFetchType().isPresent()) handleSynchronizeFetchMessage(syncMessage.getFetchType().get(), content.getTimestamp()); else if (syncMessage.getMessageRequestResponse().isPresent()) handleSynchronizeMessageRequestResponse(syncMessage.getMessageRequestResponse().get(), content.getTimestamp()); else if (syncMessage.getOutgoingPaymentMessage().isPresent()) handleSynchronizeOutgoingPayment(content, syncMessage.getOutgoingPaymentMessage().get()); + else if (syncMessage.getKeys().isPresent()) handleSynchronizeKeys(syncMessage.getKeys().get(), content.getTimestamp()); + else if (syncMessage.getContacts().isPresent()) handleSynchronizeContacts(syncMessage.getContacts().get(), content.getTimestamp()); else warn(String.valueOf(content.getTimestamp()), "Contains no known sync types..."); } else if (content.getCallMessage().isPresent()) { log(String.valueOf(content.getTimestamp()), "Got call message..."); @@ -1081,6 +1087,35 @@ public final class MessageContentProcessor { log("Inserted synchronized payment " + uuid); } + private void handleSynchronizeKeys(@NonNull KeysMessage keysMessage, long envelopeTimestamp) { + if (SignalStore.account().isLinkedDevice()) { + log(envelopeTimestamp, "Synchronize keys."); + } else { + log(envelopeTimestamp, "Primary device ignores synchronize keys."); + return; + } + + SignalStore.storageService().setStorageKeyFromPrimary(keysMessage.getStorageService().get()); + } + + private void handleSynchronizeContacts(@NonNull ContactsMessage contactsMessage, long envelopeTimestamp) throws IOException { + if (SignalStore.account().isLinkedDevice()) { + log(envelopeTimestamp, "Synchronize contacts."); + } else { + log(envelopeTimestamp, "Primary device ignores synchronize contacts."); + return; + } + + if (!(contactsMessage.getContactsStream() instanceof SignalServiceAttachmentPointer)) { + warn(envelopeTimestamp, "No contact stream available."); + return; + } + + SignalServiceAttachmentPointer contactsAttachment = (SignalServiceAttachmentPointer) contactsMessage.getContactsStream(); + + ApplicationDependencies.getJobManager().add(new MultiDeviceContactSyncJob(contactsAttachment)); + } + private void handleSynchronizeSentMessage(@NonNull SignalServiceContent content, @NonNull SentTranscriptMessage message, @NonNull Recipient senderRecipient) @@ -1171,7 +1206,12 @@ public final class MessageContentProcessor { private void handleSynchronizeRequestMessage(@NonNull RequestMessage message, long envelopeTimestamp) { - log(envelopeTimestamp, "Synchronize request message."); + if (SignalStore.account().isPrimaryDevice()) { + log(envelopeTimestamp, "Synchronize request message."); + } else { + log(envelopeTimestamp, "Linked device ignoring synchronize request message."); + return; + } if (message.isContactsRequest()) { ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob(true)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/AvatarHelper.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/AvatarHelper.java index 3d8dc66e0c..93488ee59c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/AvatarHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/AvatarHelper.java @@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.crypto.AttachmentSecret; import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream; import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.ByteUnit; @@ -129,7 +130,24 @@ public class AvatarHelper { OutputStream outputStream = null; try { - outputStream = getOutputStream(context, recipientId); + outputStream = getOutputStream(context, recipientId, false); + StreamUtil.copy(inputStream, outputStream); + } finally { + StreamUtil.close(outputStream); + } + } + + public static void setSyncAvatar(@NonNull Context context, @NonNull RecipientId recipientId, @Nullable InputStream inputStream) + throws IOException + { + if (inputStream == null) { + delete(context, recipientId); + return; + } + + OutputStream outputStream = null; + try { + outputStream = getOutputStream(context, recipientId, true); StreamUtil.copy(inputStream, outputStream); } finally { StreamUtil.close(outputStream); @@ -140,9 +158,9 @@ public class AvatarHelper { * Retrieves an output stream you can write to that will be saved as the avatar for the specified * recipient. Only intended to be used for backup. Otherwise, use {@link #setAvatar(Context, RecipientId, InputStream)}. */ - public static @NonNull OutputStream getOutputStream(@NonNull Context context, @NonNull RecipientId recipientId) throws IOException { + public static @NonNull OutputStream getOutputStream(@NonNull Context context, @NonNull RecipientId recipientId, boolean isSyncAvatar) throws IOException { AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(); - File targetFile = getAvatarFile(context, recipientId); + File targetFile = getAvatarFile(context, recipientId, isSyncAvatar); return ModernEncryptingPartOutputStream.createFor(attachmentSecret, targetFile, true).second; } @@ -179,8 +197,25 @@ public class AvatarHelper { } private static @NonNull File getAvatarFile(@NonNull Context context, @NonNull RecipientId recipientId) { + File profileAvatar = getAvatarFile(context, recipientId, false); + boolean profileAvatarExists = profileAvatar.exists() && profileAvatar.length() > 0; + File syncAvatar = getAvatarFile(context, recipientId, true); + boolean syncAvatarExists = syncAvatar.exists() && syncAvatar.length() > 0; + + if (SignalStore.settings().isPreferSystemContactPhotos() && syncAvatarExists) { + return syncAvatar; + } else if (profileAvatarExists) { + return profileAvatar; + } else if (syncAvatarExists) { + return syncAvatar; + } + + return profileAvatar; + } + + private static @NonNull File getAvatarFile(@NonNull Context context, @NonNull RecipientId recipientId, boolean isSyncAvatar) { File directory = context.getDir(AVATAR_DIRECTORY, Context.MODE_PRIVATE); - return new File(directory, recipientId.serialize()); + return new File(directory, recipientId.serialize() + (isSyncAvatar ? "-sync" : "")); } public static class Avatar { diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java index 774c665036..e39991bf96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java @@ -413,6 +413,19 @@ public class BlobProvider { return context.getDir(directory, Context.MODE_PRIVATE); } + /** + * Returns a {@link File} within the appropriate directory to be cleaned up as part of + * normal operations. Unlike other blobs, this is just a file reference and no + * automatic encryption occurs when reading or writing and must be done by the caller. + * + * @return file located in the appropriate directory to be delete on app session restarts + */ + public File forNonAutoEncryptingSingleSessionOnDisk(@NonNull Context context) { + String directory = getDirectory(StorageType.SINGLE_SESSION_DISK); + String id = UUID.randomUUID().toString(); + return new File(getOrCreateDirectory(context, directory), buildFileName(id)); + } + public class BlobBuilder { private InputStream data; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConversationShortcutPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/util/ConversationShortcutPhoto.java index e9cb1e198d..0651de6c82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConversationShortcutPhoto.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConversationShortcutPhoto.java @@ -80,10 +80,6 @@ public final class ConversationShortcutPhoto implements Key { } private long getFileLastModified() { - if (!recipient.isSelf()) { - return 0; - } - return AvatarHelper.getLastModified(ApplicationDependencies.getApplication(), recipient.getId()); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index 9acc44d073..e9f2ee2778 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -55,7 +55,6 @@ import org.whispersystems.signalservice.api.messages.multidevice.KeysMessage; import org.whispersystems.signalservice.api.messages.multidevice.MessageRequestResponseMessage; import org.whispersystems.signalservice.api.messages.multidevice.OutgoingPaymentMessage; import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage; -import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage; import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage; @@ -75,6 +74,7 @@ import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedExcept import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; import org.whispersystems.signalservice.api.services.AttachmentService; import org.whispersystems.signalservice.api.services.MessagingService; +import org.whispersystems.signalservice.api.util.AttachmentPointerUtil; import org.whispersystems.signalservice.api.util.CredentialsProvider; import org.whispersystems.signalservice.api.util.Uint64RangeException; import org.whispersystems.signalservice.api.util.Uint64Util; @@ -506,7 +506,7 @@ public class SignalServiceMessageSender { } else if (message.getConfiguration().isPresent()) { content = createMultiDeviceConfigurationContent(message.getConfiguration().get()); } else if (message.getSent().isPresent()) { - content = createMultiDeviceSentTranscriptContent(message.getSent().get(), unidentifiedAccess); + content = createMultiDeviceSentTranscriptContent(message.getSent().get(), unidentifiedAccess.isPresent()); } else if (message.getStickerPackOperations().isPresent()) { content = createMultiDeviceStickerPackOperationContent(message.getStickerPackOperations().get()); } else if (message.getFetchType().isPresent()) { @@ -518,7 +518,7 @@ public class SignalServiceMessageSender { } else if (message.getKeys().isPresent()) { content = createMultiDeviceSyncKeysContent(message.getKeys().get()); } else if (message.getVerified().isPresent()) { - return sendVerifiedMessage(message.getVerified().get(), unidentifiedAccess); + return sendVerifiedSyncMessage(message.getVerified().get()); } else if (message.getRequest().isPresent()) { content = createRequestContent(message.getRequest().get().getRequest()); } else { @@ -640,7 +640,7 @@ public class SignalServiceMessageSender { attachment.getUploadTimestamp()); } - private SendMessageResult sendVerifiedMessage(VerifiedMessage message, Optional unidentifiedAccess) + private SendMessageResult sendVerifiedSyncMessage(VerifiedMessage message) throws IOException, UntrustedIdentityException { byte[] nullMessageBody = DataMessage.newBuilder() @@ -658,7 +658,7 @@ public class SignalServiceMessageSender { EnvelopeContent envelopeContent = EnvelopeContent.encrypted(content, ContentHint.IMPLICIT, Optional.absent()); - SendMessageResult result = sendMessage(message.getDestination(), getTargetUnidentifiedAccess(unidentifiedAccess), message.getTimestamp(), envelopeContent, false, null); + SendMessageResult result = sendMessage(message.getDestination(), Optional.absent(), message.getTimestamp(), envelopeContent, false, null); if (result.getSuccess().isNeedsSync()) { Content syncMessage = createMultiDeviceVerifiedContent(message, nullMessage.toByteArray()); @@ -1026,10 +1026,10 @@ public class SignalServiceMessageSender { return container.setSyncMessage(builder).build(); } - private Content createMultiDeviceSentTranscriptContent(SentTranscriptMessage transcript, Optional unidentifiedAccess) throws IOException { + private Content createMultiDeviceSentTranscriptContent(SentTranscriptMessage transcript, boolean unidentifiedAccess) throws IOException { SignalServiceAddress address = transcript.getDestination().get(); Content content = createMessageContent(transcript.getMessage()); - SendMessageResult result = SendMessageResult.success(address, Collections.emptyList(), unidentifiedAccess.isPresent(), true, -1, Optional.of(content)); + SendMessageResult result = SendMessageResult.success(address, Collections.emptyList(), unidentifiedAccess, true, -1, Optional.of(content)); return createMultiDeviceSentTranscriptContent(content, Optional.of(address), @@ -1910,63 +1910,7 @@ public class SignalServiceMessageSender { } private AttachmentPointer createAttachmentPointer(SignalServiceAttachmentPointer attachment) { - AttachmentPointer.Builder builder = AttachmentPointer.newBuilder() - .setCdnNumber(attachment.getCdnNumber()) - .setContentType(attachment.getContentType()) - .setKey(ByteString.copyFrom(attachment.getKey())) - .setDigest(ByteString.copyFrom(attachment.getDigest().get())) - .setSize(attachment.getSize().get()) - .setUploadTimestamp(attachment.getUploadTimestamp()); - - if (attachment.getRemoteId().getV2().isPresent()) { - builder.setCdnId(attachment.getRemoteId().getV2().get()); - } - - if (attachment.getRemoteId().getV3().isPresent()) { - builder.setCdnKey(attachment.getRemoteId().getV3().get()); - } - - if (attachment.getFileName().isPresent()) { - builder.setFileName(attachment.getFileName().get()); - } - - if (attachment.getPreview().isPresent()) { - builder.setThumbnail(ByteString.copyFrom(attachment.getPreview().get())); - } - - if (attachment.getWidth() > 0) { - builder.setWidth(attachment.getWidth()); - } - - if (attachment.getHeight() > 0) { - builder.setHeight(attachment.getHeight()); - } - - int flags = 0; - - if (attachment.getVoiceNote()) { - flags |= FlagUtil.toBinaryFlag(AttachmentPointer.Flags.VOICE_MESSAGE_VALUE); - } - - if (attachment.isBorderless()) { - flags |= FlagUtil.toBinaryFlag(AttachmentPointer.Flags.BORDERLESS_VALUE); - } - - if (attachment.isGif()) { - flags |= FlagUtil.toBinaryFlag(AttachmentPointer.Flags.GIF_VALUE); - } - - builder.setFlags(flags); - - if (attachment.getCaption().isPresent()) { - builder.setCaption(attachment.getCaption().get()); - } - - if (attachment.getBlurHash().isPresent()) { - builder.setBlurHash(attachment.getBlurHash().get()); - } - - return builder.build(); + return AttachmentPointerUtil.createAttachmentPointer(attachment); } private AttachmentPointer createAttachmentPointer(SignalServiceAttachmentStream attachment) @@ -1986,12 +1930,17 @@ public class SignalServiceMessageSender { { List messages = new LinkedList<>(); - boolean isLocalPrimaryDevice = recipient.matches(localAddress) && localDeviceId == SignalServiceAddress.DEFAULT_DEVICE_ID; - if (!isLocalPrimaryDevice || unidentifiedAccess.isPresent()) { - messages.add(getEncryptedMessage(socket, recipient, unidentifiedAccess, SignalServiceAddress.DEFAULT_DEVICE_ID, plaintext)); + List subDevices = store.getSubDeviceSessions(recipient.getIdentifier()); + + List deviceIds = new ArrayList<>(subDevices.size() + 1); + deviceIds.add(SignalServiceAddress.DEFAULT_DEVICE_ID); + deviceIds.addAll(subDevices); + + if (recipient.matches(localAddress)) { + deviceIds.remove(Integer.valueOf(localDeviceId)); } - for (int deviceId : store.getSubDeviceSessions(recipient.getIdentifier())) { + for (int deviceId : deviceIds) { if (store.containsSession(new SignalProtocolAddress(recipient.getIdentifier(), deviceId))) { messages.add(getEncryptedMessage(socket, recipient, unidentifiedAccess, deviceId, plaintext)); } @@ -2010,8 +1959,7 @@ public class SignalServiceMessageSender { SignalProtocolAddress signalProtocolAddress = new SignalProtocolAddress(recipient.getIdentifier(), deviceId); SignalServiceCipher cipher = new SignalServiceCipher(localAddress, localDeviceId, store, sessionLock, null); - boolean isLocalDevice = recipient.matches(localAddress) && deviceId == localDeviceId; - if (!store.containsSession(signalProtocolAddress) && !isLocalDevice) { + if (!store.containsSession(signalProtocolAddress)) { try { List preKeys = socket.getPreKeys(recipient, unidentifiedAccess, deviceId); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/AttachmentCipherInputStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/AttachmentCipherInputStream.java index 8ceb1226ea..3ab022f99b 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/AttachmentCipherInputStream.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/crypto/AttachmentCipherInputStream.java @@ -130,6 +130,17 @@ public class AttachmentCipherInputStream extends FilterInputStream { } } + @Override + public int read() throws IOException { + byte[] buffer = new byte[1]; + int read; + + //noinspection StatementWithEmptyBody + while ((read = read(buffer)) == 0); + + return (read == -1) ? -1 : ((int) buffer[0]) & 0xFF; + } + @Override public int read(byte[] buffer) throws IOException { return read(buffer, 0, buffer.length); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java index dea1989e4c..6b765d9f73 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java @@ -31,6 +31,8 @@ import org.whispersystems.signalservice.api.messages.calls.OpaqueMessage; import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; import org.whispersystems.signalservice.api.messages.multidevice.BlockedListMessage; import org.whispersystems.signalservice.api.messages.multidevice.ConfigurationMessage; +import org.whispersystems.signalservice.api.messages.multidevice.ContactsMessage; +import org.whispersystems.signalservice.api.messages.multidevice.KeysMessage; import org.whispersystems.signalservice.api.messages.multidevice.MessageRequestResponseMessage; import org.whispersystems.signalservice.api.messages.multidevice.OutgoingPaymentMessage; import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage; @@ -45,6 +47,8 @@ import org.whispersystems.signalservice.api.messages.shared.SharedContact; import org.whispersystems.signalservice.api.payments.Money; import org.whispersystems.signalservice.api.push.ACI; import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.api.storage.StorageKey; +import org.whispersystems.signalservice.api.util.AttachmentPointerUtil; import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.internal.push.SignalServiceProtos; import org.whispersystems.signalservice.internal.push.UnsupportedDataMessageException; @@ -813,6 +817,16 @@ public final class SignalServiceContent { } } + if (content.hasKeys() && content.getKeys().hasStorageService()) { + byte[] storageKey = content.getKeys().getStorageService().toByteArray(); + + return SignalServiceSyncMessage.forKeys(new KeysMessage(Optional.of(new StorageKey(storageKey)))); + } + + if (content.hasContacts()) { + return SignalServiceSyncMessage.forContacts(new ContactsMessage(createAttachmentPointer(content.getContacts().getBlob()), content.getContacts().getComplete())); + } + return SignalServiceSyncMessage.empty(); } @@ -1162,21 +1176,7 @@ public final class SignalServiceContent { } private static SignalServiceAttachmentPointer createAttachmentPointer(SignalServiceProtos.AttachmentPointer pointer) throws InvalidMessageStructureException { - return new SignalServiceAttachmentPointer(pointer.getCdnNumber(), - SignalServiceAttachmentRemoteId.from(pointer), - pointer.getContentType(), - pointer.getKey().toByteArray(), - pointer.hasSize() ? Optional.of(pointer.getSize()) : Optional.absent(), - pointer.hasThumbnail() ? Optional.of(pointer.getThumbnail().toByteArray()): Optional.absent(), - pointer.getWidth(), pointer.getHeight(), - pointer.hasDigest() ? Optional.of(pointer.getDigest().toByteArray()) : Optional.absent(), - pointer.hasFileName() ? Optional.of(pointer.getFileName()) : Optional.absent(), - (pointer.getFlags() & FlagUtil.toBinaryFlag(SignalServiceProtos.AttachmentPointer.Flags.VOICE_MESSAGE_VALUE)) != 0, - (pointer.getFlags() & FlagUtil.toBinaryFlag(SignalServiceProtos.AttachmentPointer.Flags.BORDERLESS_VALUE)) != 0, - (pointer.getFlags() & FlagUtil.toBinaryFlag(SignalServiceProtos.AttachmentPointer.Flags.GIF_VALUE)) != 0, - pointer.hasCaption() ? Optional.of(pointer.getCaption()) : Optional.absent(), - pointer.hasBlurHash() ? Optional.of(pointer.getBlurHash()) : Optional.absent(), - pointer.hasUploadTimestamp() ? pointer.getUploadTimestamp() : 0); + return AttachmentPointerUtil.createSignalAttachmentPointer(pointer); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ChunkedInputStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ChunkedInputStream.java index 75f29b5597..a99c69dc6c 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ChunkedInputStream.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/ChunkedInputStream.java @@ -12,63 +12,48 @@ public class ChunkedInputStream { this.in = in; } - protected int readRawVarint32() throws IOException { - byte tmp = (byte)in.read(); - if (tmp >= 0) { - return tmp; - } - int result = tmp & 0x7f; - if ((tmp = (byte)in.read()) >= 0) { - result |= tmp << 7; - } else { - result |= (tmp & 0x7f) << 7; - if ((tmp = (byte)in.read()) >= 0) { - result |= tmp << 14; - } else { - result |= (tmp & 0x7f) << 14; - if ((tmp = (byte)in.read()) >= 0) { - result |= tmp << 21; - } else { - result |= (tmp & 0x7f) << 21; - result |= (tmp = (byte)in.read()) << 28; - if (tmp < 0) { - // Discard upper 32 bits. - for (int i = 0; i < 5; i++) { - if ((byte)in.read() >= 0) { - return result; - } - } - - throw new IOException("Malformed varint!"); - } - } + long readRawVarint32() throws IOException { + long result = 0; + for (int shift = 0; shift < 32; shift += 7) { + int tmpInt = in.read(); + if (tmpInt < 0) { + return -1; + } + final byte b = (byte) tmpInt; + result |= (long) (b & 0x7F) << shift; + if ((b & 0x80) == 0) { + return result; } } - - return result; + throw new IOException("Malformed varint!"); } - protected static final class LimitedInputStream extends FilterInputStream { + protected static final class LimitedInputStream extends InputStream { + + private final InputStream in; private long left; private long mark = -1; LimitedInputStream(InputStream in, long limit) { - super(in); - left = limit; + this.in = in; + this.left = limit; } - @Override public int available() throws IOException { + @Override + public int available() throws IOException { return (int) Math.min(in.available(), left); } // it's okay to mark even if mark isn't supported, as reset won't work - @Override public synchronized void mark(int readLimit) { + @Override + public synchronized void mark(int readLimit) { in.mark(readLimit); mark = left; } - @Override public int read() throws IOException { + @Override + public int read() throws IOException { if (left == 0) { return -1; } @@ -80,7 +65,8 @@ public class ChunkedInputStream { return result; } - @Override public int read(byte[] b, int off, int len) throws IOException { + @Override + public int read(byte[] b, int off, int len) throws IOException { if (left == 0) { return -1; } @@ -93,7 +79,8 @@ public class ChunkedInputStream { return result; } - @Override public synchronized void reset() throws IOException { + @Override + public synchronized void reset() throws IOException { if (!in.markSupported()) { throw new IOException("Mark not supported"); } @@ -105,12 +92,18 @@ public class ChunkedInputStream { left = mark; } - @Override public long skip(long n) throws IOException { + @Override + public long skip(long n) throws IOException { n = Math.min(n, left); long skipped = in.skip(n); left -= skipped; return skipped; } + + @Override + public void close() throws IOException { + // do nothing + } } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStream.java index 62e4e314ce..5595ca8c34 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStream.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStream.java @@ -16,7 +16,6 @@ import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; import org.whispersystems.signalservice.api.push.ACI; import org.whispersystems.signalservice.api.push.SignalServiceAddress; -import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.internal.push.SignalServiceProtos; import org.whispersystems.signalservice.internal.util.Util; @@ -32,8 +31,12 @@ public class DeviceContactsInputStream extends ChunkedInputStream { } public DeviceContact read() throws IOException { - long detailsLength = readRawVarint32(); - byte[] detailsSerialized = new byte[(int)detailsLength]; + int detailsLength = (int) readRawVarint32(); + if (detailsLength == -1) { + return null; + } + + byte[] detailsSerialized = new byte[(int) detailsLength]; Util.readFully(in, detailsSerialized); SignalServiceProtos.ContactDetails details = SignalServiceProtos.ContactDetails.parseFrom(detailsSerialized); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/AttachmentPointerUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/AttachmentPointerUtil.java new file mode 100644 index 0000000000..e8bc32c8e2 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/util/AttachmentPointerUtil.java @@ -0,0 +1,96 @@ +package org.whispersystems.signalservice.api.util; + +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; + +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.InvalidMessageStructureException; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; +import org.whispersystems.util.FlagUtil; + +public final class AttachmentPointerUtil { + public static SignalServiceAttachmentPointer createSignalAttachmentPointer(byte[] pointer) throws InvalidMessageStructureException, InvalidProtocolBufferException { + return createSignalAttachmentPointer(SignalServiceProtos.AttachmentPointer.parseFrom(pointer)); + } + + public static SignalServiceAttachmentPointer createSignalAttachmentPointer(SignalServiceProtos.AttachmentPointer pointer) throws InvalidMessageStructureException { + return new SignalServiceAttachmentPointer(pointer.getCdnNumber(), + SignalServiceAttachmentRemoteId.from(pointer), + pointer.getContentType(), + pointer.getKey().toByteArray(), + pointer.hasSize() ? Optional.of(pointer.getSize()) : Optional.absent(), + pointer.hasThumbnail() ? Optional.of(pointer.getThumbnail().toByteArray()): Optional.absent(), + pointer.getWidth(), pointer.getHeight(), + pointer.hasDigest() ? Optional.of(pointer.getDigest().toByteArray()) : Optional.absent(), + pointer.hasFileName() ? Optional.of(pointer.getFileName()) : Optional.absent(), + (pointer.getFlags() & FlagUtil.toBinaryFlag(SignalServiceProtos.AttachmentPointer.Flags.VOICE_MESSAGE_VALUE)) != 0, + (pointer.getFlags() & FlagUtil.toBinaryFlag(SignalServiceProtos.AttachmentPointer.Flags.BORDERLESS_VALUE)) != 0, + (pointer.getFlags() & FlagUtil.toBinaryFlag(SignalServiceProtos.AttachmentPointer.Flags.GIF_VALUE)) != 0, + pointer.hasCaption() ? Optional.of(pointer.getCaption()) : Optional.absent(), + pointer.hasBlurHash() ? Optional.of(pointer.getBlurHash()) : Optional.absent(), + pointer.hasUploadTimestamp() ? pointer.getUploadTimestamp() : 0); + + } + + public static SignalServiceProtos.AttachmentPointer createAttachmentPointer(SignalServiceAttachmentPointer attachment) { + SignalServiceProtos.AttachmentPointer.Builder builder = SignalServiceProtos.AttachmentPointer.newBuilder() + .setCdnNumber(attachment.getCdnNumber()) + .setContentType(attachment.getContentType()) + .setKey(ByteString.copyFrom(attachment.getKey())) + .setDigest(ByteString.copyFrom(attachment.getDigest().get())) + .setSize(attachment.getSize().get()) + .setUploadTimestamp(attachment.getUploadTimestamp()); + + if (attachment.getRemoteId().getV2().isPresent()) { + builder.setCdnId(attachment.getRemoteId().getV2().get()); + } + + if (attachment.getRemoteId().getV3().isPresent()) { + builder.setCdnKey(attachment.getRemoteId().getV3().get()); + } + + if (attachment.getFileName().isPresent()) { + builder.setFileName(attachment.getFileName().get()); + } + + if (attachment.getPreview().isPresent()) { + builder.setThumbnail(ByteString.copyFrom(attachment.getPreview().get())); + } + + if (attachment.getWidth() > 0) { + builder.setWidth(attachment.getWidth()); + } + + if (attachment.getHeight() > 0) { + builder.setHeight(attachment.getHeight()); + } + + int flags = 0; + + if (attachment.getVoiceNote()) { + flags |= FlagUtil.toBinaryFlag(SignalServiceProtos.AttachmentPointer.Flags.VOICE_MESSAGE_VALUE); + } + + if (attachment.isBorderless()) { + flags |= FlagUtil.toBinaryFlag(SignalServiceProtos.AttachmentPointer.Flags.BORDERLESS_VALUE); + } + + if (attachment.isGif()) { + flags |= FlagUtil.toBinaryFlag(SignalServiceProtos.AttachmentPointer.Flags.GIF_VALUE); + } + + builder.setFlags(flags); + + if (attachment.getCaption().isPresent()) { + builder.setCaption(attachment.getCaption().get()); + } + + if (attachment.getBlurHash().isPresent()) { + builder.setBlurHash(attachment.getBlurHash().get()); + } + + return builder.build(); + } +} diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStreamTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStreamTest.java new file mode 100644 index 0000000000..7668c643cc --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStreamTest.java @@ -0,0 +1,89 @@ +package org.whispersystems.signalservice.api.messages.multidevice; + +import org.junit.Test; +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.profiles.ProfileKey; +import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.ecc.Curve; +import org.whispersystems.libsignal.ecc.ECKeyPair; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.push.ACI; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.internal.util.Util; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class DeviceContactsInputStreamTest { + + @Test + public void read() throws IOException, InvalidInputException { + ByteArrayOutputStream byteArrayOut = new ByteArrayOutputStream(); + DeviceContactsOutputStream output = new DeviceContactsOutputStream(byteArrayOut); + SignalServiceAddress addressFirst = new SignalServiceAddress(ACI.from(UUID.randomUUID()), "+1404555555"); + SignalServiceAddress addressSecond = new SignalServiceAddress(ACI.from(UUID.randomUUID()), "+1444555555"); + + DeviceContact first = new DeviceContact( + addressFirst, + Optional.of("Teal'c"), + Optional.absent(), + Optional.of("ultramarine"), + Optional.of(new VerifiedMessage(addressFirst, generateIdentityKey(), VerifiedMessage.VerifiedState.DEFAULT, System.currentTimeMillis())), + Optional.of(generateProfileKey()), + false, + Optional.of(0), + Optional.of(0), + false + ); + + DeviceContact second = new DeviceContact( + addressSecond, + Optional.of("Bra'tac"), + Optional.absent(), + Optional.of("ultramarine"), + Optional.of(new VerifiedMessage(addressSecond, generateIdentityKey(), VerifiedMessage.VerifiedState.DEFAULT, System.currentTimeMillis())), + Optional.of(generateProfileKey()), + false, + Optional.of(0), + Optional.of(0), + false + ); + + output.write(first); + output.write(second); + + output.close(); + + ByteArrayInputStream byteArrayIn = new ByteArrayInputStream(byteArrayOut.toByteArray()); + + DeviceContactsInputStream input = new DeviceContactsInputStream(byteArrayIn); + DeviceContact readFirst = input.read(); + DeviceContact readSecond = input.read(); + + assertEquals(first.getAddress(), readFirst.getAddress()); + assertEquals(first.getName(), readFirst.getName()); + assertEquals(first.getColor(), readFirst.getColor()); + assertEquals(first.getVerified().get().getIdentityKey(), readFirst.getVerified().get().getIdentityKey()); + assertEquals(first.isArchived(), readFirst.isArchived()); + + assertEquals(second.getAddress(), readSecond.getAddress()); + assertEquals(second.getName(), readSecond.getName()); + assertEquals(second.getColor(), readSecond.getColor()); + assertEquals(second.getVerified().get().getIdentityKey(), readSecond.getVerified().get().getIdentityKey()); + assertEquals(second.isArchived(), readSecond.isArchived()); + } + + private static IdentityKey generateIdentityKey() { + ECKeyPair djbKeyPair = Curve.generateKeyPair(); + return new IdentityKey(djbKeyPair.getPublicKey()); + } + + private static ProfileKey generateProfileKey() throws InvalidInputException { + return new ProfileKey(Util.getSecretBytes(32)); + } +} \ No newline at end of file