From 058c523329e31b0ea343fc5a032e35b308b707be Mon Sep 17 00:00:00 2001 From: Clark Date: Tue, 25 Jun 2024 12:46:34 -0400 Subject: [PATCH] Add support for import/export of shared contact messages. --- .../v2/database/ChatItemExportIterator.kt | 136 +++++++++++++++++- .../v2/database/ChatItemImportInserter.kt | 92 ++++++++++++ .../database/MessageTableBackupExtensions.kt | 1 + .../securesms/contactshare/Contact.java | 32 ++--- .../securesms/database/MessageTable.kt | 2 +- 5 files changed, 243 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt index abe7a19bfa..48f161c08b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt @@ -25,6 +25,8 @@ import org.thoughtcrime.securesms.attachments.DatabaseAttachment import org.thoughtcrime.securesms.backup.v2.BackupRepository.getMediaName import org.thoughtcrime.securesms.backup.v2.proto.ChatItem import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage +import org.thoughtcrime.securesms.backup.v2.proto.ContactAttachment +import org.thoughtcrime.securesms.backup.v2.proto.ContactMessage import org.thoughtcrime.securesms.backup.v2.proto.ExpirationTimerChatUpdate import org.thoughtcrime.securesms.backup.v2.proto.FilePointer import org.thoughtcrime.securesms.backup.v2.proto.GroupCall @@ -44,6 +46,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.Sticker import org.thoughtcrime.securesms.backup.v2.proto.StickerMessage import org.thoughtcrime.securesms.backup.v2.proto.Text import org.thoughtcrime.securesms.backup.v2.proto.ThreadMergeChatUpdate +import org.thoughtcrime.securesms.contactshare.Contact import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.database.CallTable import org.thoughtcrime.securesms.database.GroupReceiptTable @@ -198,6 +201,9 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: MessageTypes.isGiftBadge(record.type) -> { builder.giftBadge = record.toGiftBadgeUpdate() } + !record.sharedContacts.isNullOrEmpty() -> { + builder.contactMessage = record.toContactMessage(reactionsById[id], attachmentsById[id]) + } else -> { if (record.body == null && !attachmentsById.containsKey(record.id)) { Log.w(TAG, "Record with ID ${record.id} missing a body and doesn't have attachments. Skipping.") @@ -490,6 +496,45 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: } } + private fun BackupMessageRecord.parseSharedContacts(attachments: List?): List { + if (this.sharedContacts.isNullOrEmpty()) { + return emptyList() + } + + val attachmentIdMap: Map = attachments?.associateBy { it.attachmentId } ?: emptyMap() + + try { + val contacts: MutableList = LinkedList() + val jsonContacts = JSONArray(sharedContacts) + + for (i in 0 until jsonContacts.length()) { + val contact: Contact = Contact.deserialize(jsonContacts.getJSONObject(i).toString()) + + if (contact.avatar != null && contact.avatar!!.attachmentId != null) { + val attachment = attachmentIdMap[contact.avatar!!.attachmentId] + + val updatedAvatar = Contact.Avatar( + contact.avatar!!.attachmentId, + attachment, + contact.avatar!!.isProfile + ) + + contacts += Contact(contact, updatedAvatar) + } else { + contacts += contact + } + } + + return contacts + } catch (e: JSONException) { + Log.w(TAG, "Failed to parse shared contacts.", e) + } catch (e: IOException) { + Log.w(TAG, "Failed to parse shared contacts.", e) + } + + return emptyList() + } + private fun BackupMessageRecord.parseLinkPreviews(attachments: List?): List { if (linkPreview.isNullOrEmpty()) { return emptyList() @@ -536,6 +581,86 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: ) } + private fun BackupMessageRecord.toContactMessage(reactionRecords: List?, attachments: List?): ContactMessage { + val sharedContacts = parseSharedContacts(attachments) + + val contacts = sharedContacts.map { + ContactAttachment( + name = it.name.toBackup(), + avatar = (it.avatar?.attachment as? DatabaseAttachment)?.toBackupAttachment()?.pointer, + organization = it.organization, + number = it.phoneNumbers.map { phone -> + ContactAttachment.Phone( + value_ = phone.number, + type = phone.type.toBackup(), + label = phone.label + ) + }, + email = it.emails.map { email -> + ContactAttachment.Email( + value_ = email.email, + label = email.label, + type = email.type.toBackup() + ) + }, + address = it.postalAddresses.map { address -> + ContactAttachment.PostalAddress( + type = address.type.toBackup(), + label = address.label, + street = address.street, + pobox = address.poBox, + neighborhood = address.neighborhood, + city = address.city, + region = address.region, + postcode = address.postalCode, + country = address.country + ) + } + ) + } + return ContactMessage( + contact = contacts, + reactions = reactionRecords.toBackupReactions() + ) + } + + private fun Contact.Name.toBackup(): ContactAttachment.Name { + return ContactAttachment.Name( + givenName = givenName, + familyName = familyName, + prefix = prefix, + suffix = suffix, + middleName = middleName, + displayName = displayName + ) + } + + private fun Contact.Phone.Type.toBackup(): ContactAttachment.Phone.Type { + return when (this) { + Contact.Phone.Type.HOME -> ContactAttachment.Phone.Type.HOME + Contact.Phone.Type.MOBILE -> ContactAttachment.Phone.Type.MOBILE + Contact.Phone.Type.WORK -> ContactAttachment.Phone.Type.WORK + Contact.Phone.Type.CUSTOM -> ContactAttachment.Phone.Type.CUSTOM + } + } + + private fun Contact.Email.Type.toBackup(): ContactAttachment.Email.Type { + return when (this) { + Contact.Email.Type.HOME -> ContactAttachment.Email.Type.HOME + Contact.Email.Type.MOBILE -> ContactAttachment.Email.Type.MOBILE + Contact.Email.Type.WORK -> ContactAttachment.Email.Type.WORK + Contact.Email.Type.CUSTOM -> ContactAttachment.Email.Type.CUSTOM + } + } + + private fun Contact.PostalAddress.Type.toBackup(): ContactAttachment.PostalAddress.Type { + return when (this) { + Contact.PostalAddress.Type.HOME -> ContactAttachment.PostalAddress.Type.HOME + Contact.PostalAddress.Type.WORK -> ContactAttachment.PostalAddress.Type.WORK + Contact.PostalAddress.Type.CUSTOM -> ContactAttachment.PostalAddress.Type.CUSTOM + } + } + private fun BackupMessageRecord.toStandardMessage(reactionRecords: List?, mentions: List?, attachments: List?): StandardMessage { val text = if (body == null) { null @@ -545,10 +670,13 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: bodyRanges = (this.bodyRanges?.toBackupBodyRanges() ?: emptyList()) + (mentions?.toBackupBodyRanges() ?: emptyList()) ) } - val linkPreviews = this.parseLinkPreviews(attachments) + val linkPreviews = parseLinkPreviews(attachments) val linkPreviewAttachments = linkPreviews.mapNotNull { it.thumbnail.orElse(null) }.toSet() - val quotedAttachments = attachments?.filter { it.quote && !linkPreviewAttachments.contains(it) } ?: emptyList() - val messageAttachments = attachments?.filter { !it.quote && !linkPreviewAttachments.contains(it) } ?: emptyList() + val quotedAttachments = attachments?.filter { it.quote } ?: emptyList() + val messageAttachments = attachments + ?.filterNot { it.quote } + ?.filterNot { linkPreviewAttachments.contains(it) } + ?: emptyList() return StandardMessage( quote = this.toQuote(quotedAttachments), text = text, @@ -898,6 +1026,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: remoteDeleted = this.requireBoolean(MessageTable.REMOTE_DELETED), sealedSender = this.requireBoolean(MessageTable.UNIDENTIFIED), linkPreview = this.requireString(MessageTable.LINK_PREVIEWS), + sharedContacts = this.requireString(MessageTable.SHARED_CONTACTS), quoteTargetSentTimestamp = this.requireLong(MessageTable.QUOTE_ID), quoteAuthor = this.requireLong(MessageTable.QUOTE_AUTHOR), quoteBody = this.requireString(MessageTable.QUOTE_BODY), @@ -934,6 +1063,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: val remoteDeleted: Boolean, val sealedSender: Boolean, val linkPreview: String?, + val sharedContacts: String?, val quoteTargetSentTimestamp: Long, val quoteAuthor: Long, val quoteBody: String?, diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt index 8901bfa929..e1b3f8c314 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt @@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.backup.v2.BackupState import org.thoughtcrime.securesms.backup.v2.proto.BodyRange import org.thoughtcrime.securesms.backup.v2.proto.ChatItem import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage +import org.thoughtcrime.securesms.backup.v2.proto.ContactAttachment import org.thoughtcrime.securesms.backup.v2.proto.FilePointer import org.thoughtcrime.securesms.backup.v2.proto.GroupCall import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall @@ -36,6 +37,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.SendStatus import org.thoughtcrime.securesms.backup.v2.proto.SimpleChatUpdate import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage import org.thoughtcrime.securesms.backup.v2.proto.Sticker +import org.thoughtcrime.securesms.contactshare.Contact import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.database.CallTable import org.thoughtcrime.securesms.database.GroupReceiptTable @@ -316,6 +318,60 @@ class ChatItemImportInserter( } } } + if (this.contactMessage != null) { + val contacts = this.contactMessage.contact.map { backupContact -> + Contact( + backupContact.name.toLocal(), + backupContact.organization, + backupContact.number.map { phone -> + Contact.Phone( + phone.value_ ?: "", + phone.type.toLocal(), + phone.label + ) + }, + backupContact.email.map { email -> + Contact.Email( + email.value_ ?: "", + email.type.toLocal(), + email.label + ) + }, + backupContact.address.map { address -> + Contact.PostalAddress( + address.type.toLocal(), + address.label, + address.street, + address.pobox, + address.neighborhood, + address.city, + address.region, + address.postcode, + address.country + ) + }, + Contact.Avatar(null, backupContact.avatar.toLocalAttachment(voiceNote = false, borderless = false, gif = false, wasDownloaded = true), true) + ) + } + val contactAttachments = contacts.mapNotNull { it.avatarAttachment } + if (contacts.isNotEmpty()) { + followUp = { messageRowId -> + val attachmentMap = if (contactAttachments.isNotEmpty()) { + SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, contactAttachments, emptyList()) + } else { + emptyMap() + } + db.update( + MessageTable.TABLE_NAME, + contentValuesOf( + MessageTable.SHARED_CONTACTS to SignalDatabase.messages.getSerializedSharedContacts(attachmentMap, contacts) + ), + "${MessageTable.ID} = ?", + SqlUtil.buildArgs(messageRowId) + ) + } + } + } if (this.standardMessage != null) { val bodyRanges = this.standardMessage.text?.bodyRanges if (!bodyRanges.isNullOrEmpty()) { @@ -963,6 +1019,42 @@ class ChatItemImportInserter( ) } + private fun ContactAttachment.Name?.toLocal(): Contact.Name { + return Contact.Name(this?.displayName, this?.givenName, this?.familyName, this?.prefix, this?.suffix, this?.middleName) + } + + private fun ContactAttachment.Phone.Type?.toLocal(): Contact.Phone.Type { + return when (this) { + ContactAttachment.Phone.Type.HOME -> Contact.Phone.Type.HOME + ContactAttachment.Phone.Type.MOBILE -> Contact.Phone.Type.MOBILE + ContactAttachment.Phone.Type.WORK -> Contact.Phone.Type.WORK + ContactAttachment.Phone.Type.CUSTOM, + ContactAttachment.Phone.Type.UNKNOWN, + null -> Contact.Phone.Type.CUSTOM + } + } + + private fun ContactAttachment.Email.Type?.toLocal(): Contact.Email.Type { + return when (this) { + ContactAttachment.Email.Type.HOME -> Contact.Email.Type.HOME + ContactAttachment.Email.Type.MOBILE -> Contact.Email.Type.MOBILE + ContactAttachment.Email.Type.WORK -> Contact.Email.Type.WORK + ContactAttachment.Email.Type.CUSTOM, + ContactAttachment.Email.Type.UNKNOWN, + null -> Contact.Email.Type.CUSTOM + } + } + + private fun ContactAttachment.PostalAddress.Type?.toLocal(): Contact.PostalAddress.Type { + return when (this) { + ContactAttachment.PostalAddress.Type.HOME -> Contact.PostalAddress.Type.HOME + ContactAttachment.PostalAddress.Type.WORK -> Contact.PostalAddress.Type.WORK + ContactAttachment.PostalAddress.Type.CUSTOM, + ContactAttachment.PostalAddress.Type.UNKNOWN, + null -> Contact.PostalAddress.Type.CUSTOM + } + } + private fun MessageAttachment.toLocalAttachment(contentType: String?, fileName: String?): Attachment? { return pointer?.toLocalAttachment( voiceNote = flag == MessageAttachment.Flag.VOICE_MESSAGE, diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt index 5325135eaa..122727b78c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt @@ -34,6 +34,7 @@ fun MessageTable.getMessagesForBackup(backupTime: Long, archiveMedia: Boolean): MessageTable.REMOTE_DELETED, MessageTable.UNIDENTIFIED, MessageTable.LINK_PREVIEWS, + MessageTable.SHARED_CONTACTS, MessageTable.QUOTE_ID, MessageTable.QUOTE_AUTHOR, MessageTable.QUOTE_BODY, diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/Contact.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/Contact.java index 2d61ec3b68..36f50ab5fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/Contact.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/Contact.java @@ -160,7 +160,7 @@ public class Contact implements Parcelable { @JsonProperty private final String middleName; - Name(@JsonProperty("displayName") @Nullable String displayName, + public Name(@JsonProperty("displayName") @Nullable String displayName, @JsonProperty("givenName") @Nullable String givenName, @JsonProperty("familyName") @Nullable String familyName, @JsonProperty("prefix") @Nullable String prefix, @@ -254,9 +254,9 @@ public class Contact implements Parcelable { @JsonIgnore private boolean selected; - Phone(@JsonProperty("number") @NonNull String number, - @JsonProperty("type") @NonNull Type type, - @JsonProperty("label") @Nullable String label) + public Phone(@JsonProperty("number") @NonNull String number, + @JsonProperty("type") @NonNull Type type, + @JsonProperty("label") @Nullable String label) { this.number = number; this.type = type; @@ -333,9 +333,9 @@ public class Contact implements Parcelable { @JsonIgnore private boolean selected; - Email(@JsonProperty("email") @NonNull String email, - @JsonProperty("type") @NonNull Type type, - @JsonProperty("label") @Nullable String label) + public Email(@JsonProperty("email") @NonNull String email, + @JsonProperty("type") @NonNull Type type, + @JsonProperty("label") @Nullable String label) { this.email = email; this.type = type; @@ -430,15 +430,15 @@ public class Contact implements Parcelable { @JsonIgnore private boolean selected; - PostalAddress(@JsonProperty("type") @NonNull Type type, - @JsonProperty("label") @Nullable String label, - @JsonProperty("street") @Nullable String street, - @JsonProperty("poBox") @Nullable String poBox, - @JsonProperty("neighborhood") @Nullable String neighborhood, - @JsonProperty("city") @Nullable String city, - @JsonProperty("region") @Nullable String region, - @JsonProperty("postalCode") @Nullable String postalCode, - @JsonProperty("country") @Nullable String country) + public PostalAddress(@JsonProperty("type") @NonNull Type type, + @JsonProperty("label") @Nullable String label, + @JsonProperty("street") @Nullable String street, + @JsonProperty("poBox") @Nullable String poBox, + @JsonProperty("neighborhood") @Nullable String neighborhood, + @JsonProperty("city") @Nullable String city, + @JsonProperty("region") @Nullable String region, + @JsonProperty("postalCode") @Nullable String postalCode, + @JsonProperty("country") @Nullable String country) { this.type = type; this.label = label; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index e0286f0928..9367e0b69e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -3339,7 +3339,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } } - private fun getSerializedSharedContacts(insertedAttachmentIds: Map, contacts: List): String? { + fun getSerializedSharedContacts(insertedAttachmentIds: Map, contacts: List): String? { if (contacts.isEmpty()) { return null }