diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ArchiveTypeAliases.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ArchiveTypeAliases.kt new file mode 100644 index 0000000000..9655d3339e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ArchiveTypeAliases.kt @@ -0,0 +1,9 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2 + +typealias ArchiveRecipient = org.thoughtcrime.securesms.backup.v2.proto.Recipient +typealias ArchiveGroup = org.thoughtcrime.securesms.backup.v2.proto.Group diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index a52a3aa410..edb5b3fb85 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -29,7 +29,7 @@ import org.thoughtcrime.securesms.attachments.Cdn import org.thoughtcrime.securesms.attachments.DatabaseAttachment import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore -import org.thoughtcrime.securesms.backup.v2.processor.AccountDataProcessor +import org.thoughtcrime.securesms.backup.v2.processor.AccountDataBackupProcessor import org.thoughtcrime.securesms.backup.v2.processor.AdHocCallBackupProcessor import org.thoughtcrime.securesms.backup.v2.processor.ChatBackupProcessor import org.thoughtcrime.securesms.backup.v2.processor.ChatItemBackupProcessor @@ -285,7 +285,7 @@ object BackupRepository { // We're using a snapshot, so the transaction is more for perf than correctness dbSnapshot.rawWritableDatabase.withinTransaction { progressEmitter?.onAccount() - AccountDataProcessor.export(dbSnapshot, signalStoreSnapshot) { + AccountDataBackupProcessor.export(dbSnapshot, signalStoreSnapshot) { writer.write(it) eventTimer.emit("account") } @@ -412,7 +412,7 @@ object BackupRepository { for (frame in frameReader) { when { frame.account != null -> { - AccountDataProcessor.import(frame.account, selfId, importState) + AccountDataBackupProcessor.import(frame.account, selfId, importState) eventTimer.emit("account") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallLinkTableBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallLinkTableBackupExtensions.kt index 62c9c8fe61..e063e9bb82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallLinkTableBackupExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallLinkTableBackupExtensions.kt @@ -5,30 +5,25 @@ package org.thoughtcrime.securesms.backup.v2.database -import android.database.Cursor -import okio.ByteString -import okio.ByteString.Companion.toByteString import org.signal.core.util.select import org.signal.ringrtc.CallLinkRootKey import org.signal.ringrtc.CallLinkState import org.thoughtcrime.securesms.backup.v2.proto.CallLink import org.thoughtcrime.securesms.database.CallLinkTable -import org.thoughtcrime.securesms.database.RecipientTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState -import java.io.Closeable import java.time.Instant -fun CallLinkTable.getCallLinksForBackup(): BackupCallLinkIterator { +fun CallLinkTable.getCallLinksForBackup(): CallLinkArchiveExportIterator { val cursor = readableDatabase .select() .from(CallLinkTable.TABLE_NAME) .run() - return BackupCallLinkIterator(cursor) + return CallLinkArchiveExportIterator(cursor) } fun CallLinkTable.restoreFromBackup(callLink: CallLink): RecipientId? { @@ -53,50 +48,6 @@ fun CallLinkTable.restoreFromBackup(callLink: CallLink): RecipientId? { ) } -/** - * Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s. - * Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources. - */ -class BackupCallLinkIterator(private val cursor: Cursor) : Iterator, Closeable { - override fun hasNext(): Boolean { - return cursor.count > 0 && !cursor.isLast - } - - override fun next(): BackupRecipient { - if (!cursor.moveToNext()) { - throw NoSuchElementException() - } - - val callLink = CallLinkTable.CallLinkDeserializer.deserialize(cursor) - return BackupRecipient( - id = callLink.recipientId.toLong(), - callLink = CallLink( - rootKey = callLink.credentials?.linkKeyBytes?.toByteString() ?: ByteString.EMPTY, - adminKey = callLink.credentials?.adminPassBytes?.toByteString(), - name = callLink.state.name, - expirationMs = try { - callLink.state.expiration.toEpochMilli() - } catch (e: ArithmeticException) { - Long.MAX_VALUE - }, - restrictions = callLink.state.restrictions.toRemote() - ) - ) - } - - override fun close() { - cursor.close() - } -} - -private fun CallLinkState.Restrictions.toRemote(): CallLink.Restrictions { - return when (this) { - CallLinkState.Restrictions.ADMIN_APPROVAL -> CallLink.Restrictions.ADMIN_APPROVAL - CallLinkState.Restrictions.NONE -> CallLink.Restrictions.NONE - CallLinkState.Restrictions.UNKNOWN -> CallLink.Restrictions.UNKNOWN - } -} - private fun CallLink.Restrictions.toLocal(): CallLinkState.Restrictions { return when (this) { CallLink.Restrictions.ADMIN_APPROVAL -> CallLinkState.Restrictions.ADMIN_APPROVAL diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallTableBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallTableBackupExtensions.kt index 3c04e9b516..737f8b2fd1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallTableBackupExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallTableBackupExtensions.kt @@ -5,18 +5,14 @@ package org.thoughtcrime.securesms.backup.v2.database -import android.database.Cursor import org.signal.core.util.insertInto -import org.signal.core.util.requireLong import org.signal.core.util.select import org.thoughtcrime.securesms.backup.v2.ImportState import org.thoughtcrime.securesms.backup.v2.proto.AdHocCall import org.thoughtcrime.securesms.database.CallTable -import org.thoughtcrime.securesms.database.RecipientTable -import java.io.Closeable -fun CallTable.getAdhocCallsForBackup(): CallLogIterator { - return CallLogIterator( +fun CallTable.getAdhocCallsForBackup(): AdHocCallArchiveExportIterator { + return AdHocCallArchiveExportIterator( readableDatabase .select() .from(CallTable.TABLE_NAME) @@ -31,7 +27,7 @@ fun CallTable.restoreCallLogFromBackup(call: AdHocCall, importState: ImportState AdHocCall.State.UNKNOWN_STATE -> CallTable.Event.GENERIC_GROUP_CALL } - val result = writableDatabase + writableDatabase .insertInto(CallTable.TABLE_NAME) .values( CallTable.CALL_ID to call.callId, @@ -42,34 +38,4 @@ fun CallTable.restoreCallLogFromBackup(call: AdHocCall, importState: ImportState CallTable.TIMESTAMP to call.callTimestamp ) .run() - return Unit -} - -/** - * Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s. - * Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources. - */ -class CallLogIterator(private val cursor: Cursor) : Iterator, Closeable { - override fun hasNext(): Boolean { - return cursor.count > 0 && !cursor.isLast - } - - override fun next(): AdHocCall? { - if (!cursor.moveToNext()) { - throw NoSuchElementException() - } - - val callId = cursor.requireLong(CallTable.CALL_ID) - - return AdHocCall( - callId = callId, - recipientId = cursor.requireLong(CallTable.PEER), - state = AdHocCall.State.GENERIC, - callTimestamp = cursor.requireLong(CallTable.TIMESTAMP) - ) - } - - override fun close() { - cursor.close() - } } 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 deleted file mode 100644 index abdbecf801..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt +++ /dev/null @@ -1,1161 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.backup.v2.database - -import android.database.Cursor -import okio.ByteString.Companion.toByteString -import org.json.JSONArray -import org.json.JSONException -import org.signal.core.util.Base64 -import org.signal.core.util.Hex -import org.signal.core.util.logging.Log -import org.signal.core.util.nullIfEmpty -import org.signal.core.util.orNull -import org.signal.core.util.requireBlob -import org.signal.core.util.requireBoolean -import org.signal.core.util.requireInt -import org.signal.core.util.requireLong -import org.signal.core.util.requireLongOrNull -import org.signal.core.util.requireString -import org.thoughtcrime.securesms.attachments.AttachmentId -import org.thoughtcrime.securesms.attachments.DatabaseAttachment -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.GroupCall -import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall -import org.thoughtcrime.securesms.backup.v2.proto.LearnedProfileChatUpdate -import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment -import org.thoughtcrime.securesms.backup.v2.proto.PaymentNotification -import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate -import org.thoughtcrime.securesms.backup.v2.proto.Quote -import org.thoughtcrime.securesms.backup.v2.proto.Reaction -import org.thoughtcrime.securesms.backup.v2.proto.RemoteDeletedMessage -import org.thoughtcrime.securesms.backup.v2.proto.SendStatus -import org.thoughtcrime.securesms.backup.v2.proto.SessionSwitchoverChatUpdate -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.backup.v2.proto.StickerMessage -import org.thoughtcrime.securesms.backup.v2.proto.Text -import org.thoughtcrime.securesms.backup.v2.proto.ThreadMergeChatUpdate -import org.thoughtcrime.securesms.backup.v2.util.toRemoteFilePointer -import org.thoughtcrime.securesms.contactshare.Contact -import org.thoughtcrime.securesms.database.AttachmentTable -import org.thoughtcrime.securesms.database.CallTable -import org.thoughtcrime.securesms.database.GroupReceiptTable -import org.thoughtcrime.securesms.database.MessageTable -import org.thoughtcrime.securesms.database.MessageTypes -import org.thoughtcrime.securesms.database.PaymentTable -import org.thoughtcrime.securesms.database.SignalDatabase -import org.thoughtcrime.securesms.database.SignalDatabase.Companion.calls -import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients -import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet -import org.thoughtcrime.securesms.database.documents.NetworkFailureSet -import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil -import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter -import org.thoughtcrime.securesms.database.model.Mention -import org.thoughtcrime.securesms.database.model.ReactionRecord -import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList -import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context -import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge -import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras -import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails -import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent -import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent -import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.linkpreview.LinkPreview -import org.thoughtcrime.securesms.mms.QuoteModel -import org.thoughtcrime.securesms.payments.FailureReason -import org.thoughtcrime.securesms.payments.State -import org.thoughtcrime.securesms.payments.proto.PaymentMetaData -import org.thoughtcrime.securesms.util.JsonUtils -import org.whispersystems.signalservice.api.push.ServiceId.ACI -import org.whispersystems.signalservice.api.util.UuidUtil -import org.whispersystems.signalservice.api.util.toByteArray -import java.io.Closeable -import java.io.IOException -import java.util.HashMap -import java.util.LinkedList -import java.util.Queue -import kotlin.jvm.optionals.getOrNull -import org.thoughtcrime.securesms.backup.v2.proto.BodyRange as BackupBodyRange -import org.thoughtcrime.securesms.backup.v2.proto.GiftBadge as BackupGiftBadge - -/** - * An iterator for chat items with a clever performance twist: rather than do the extra queries one at a time (for reactions, - * attachments, etc), this will populate items in batches, doing bulk lookups to improve throughput. We keep these in a buffer - * and only do more queries when the buffer is empty. - * - * All of this complexity is hidden from the user -- they just get a normal iterator interface. - */ -class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: Int, private val mediaArchiveEnabled: Boolean) : Iterator, Closeable { - - companion object { - private val TAG = Log.tag(ChatItemExportIterator::class.java) - - const val COLUMN_BASE_TYPE = "base_type" - } - - /** - * A queue of already-parsed ChatItems. Processing in batches means that we read ahead in the cursor and put - * the pending items here. - */ - private val buffer: Queue = LinkedList() - - private val revisionMap: HashMap> = HashMap() - - override fun hasNext(): Boolean { - return buffer.isNotEmpty() || (cursor.count > 0 && !cursor.isLast && !cursor.isAfterLast) - } - - override fun next(): ChatItem? { - if (buffer.isNotEmpty()) { - return buffer.remove() - } - - val records: LinkedHashMap = linkedMapOf() - - for (i in 0 until batchSize) { - if (cursor.moveToNext()) { - val record = cursor.toBackupMessageRecord() - records[record.id] = record - } else { - break - } - } - - val reactionsById: Map> = SignalDatabase.reactions.getReactionsForMessages(records.keys).map { entry -> entry.key to entry.value.sortedBy { it.dateReceived } }.toMap() - val mentionsById: Map> = SignalDatabase.mentions.getMentionsForMessages(records.keys) - val attachmentsById: Map> = SignalDatabase.attachments.getAttachmentsForMessages(records.keys) - val groupReceiptsById: Map> = SignalDatabase.groupReceipts.getGroupReceiptInfoForMessages(records.keys) - - for ((id, record) in records) { - val builder = record.toBasicChatItemBuilder(groupReceiptsById[id]) - - when { - record.remoteDeleted -> { - builder.remoteDeletedMessage = RemoteDeletedMessage() - } - MessageTypes.isJoinedType(record.type) -> { - builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.JOINED_SIGNAL) - } - MessageTypes.isIdentityUpdate(record.type) -> { - builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.IDENTITY_UPDATE) - } - MessageTypes.isIdentityVerified(record.type) -> { - builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.IDENTITY_VERIFIED) - } - MessageTypes.isIdentityDefault(record.type) -> { - builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.IDENTITY_DEFAULT) - } - MessageTypes.isChangeNumber(record.type) -> { - builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.CHANGE_NUMBER) - } - MessageTypes.isReleaseChannelDonationRequest(record.type) -> { - builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.RELEASE_CHANNEL_DONATION_REQUEST) - } - MessageTypes.isEndSessionType(record.type) -> { - builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.END_SESSION) - } - MessageTypes.isChatSessionRefresh(record.type) -> { - builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.CHAT_SESSION_REFRESH) - } - MessageTypes.isBadDecryptType(record.type) -> { - builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.BAD_DECRYPT) - } - MessageTypes.isPaymentsActivated(record.type) -> { - builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.PAYMENTS_ACTIVATED) - } - MessageTypes.isPaymentsRequestToActivate(record.type) -> { - builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST) - } - MessageTypes.isUnsupportedMessageType(record.type) -> { - builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.UNSUPPORTED_PROTOCOL_MESSAGE) - } - MessageTypes.isReportedSpam(record.type) -> { - builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.REPORTED_SPAM) - } - MessageTypes.isMessageRequestAccepted(record.type) -> { - builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.MESSAGE_REQUEST_ACCEPTED) - } - MessageTypes.isBlocked(record.type) -> { - builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.BLOCKED) - } - MessageTypes.isUnblocked(record.type) -> { - builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.UNBLOCKED) - } - MessageTypes.isExpirationTimerUpdate(record.type) -> { - builder.updateMessage = ChatUpdateMessage(expirationTimerChange = ExpirationTimerChatUpdate(record.expiresIn)) - builder.expiresInMs = 0 - } - MessageTypes.isProfileChange(record.type) -> { - builder.updateMessage = record.toProfileChangeUpdate() - } - MessageTypes.isSessionSwitchoverType(record.type) -> { - builder.updateMessage = record.toSessionSwitchoverUpdate() - } - MessageTypes.isThreadMergeType(record.type) -> { - builder.updateMessage = record.toThreadMergeUpdate() - } - MessageTypes.isGroupV2(record.type) && MessageTypes.isGroupUpdate(record.type) -> { - builder.updateMessage = record.toGroupUpdate() - } - MessageTypes.isCallLog(record.type) -> { - builder.updateMessage = record.toCallUpdate() - } - MessageTypes.isPaymentsNotification(record.type) -> { - builder.paymentNotification = record.toPaymentNotificationUpdate() - } - 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.") - continue - } - - val attachments = attachmentsById[record.id] - val sticker = attachments?.firstOrNull { dbAttachment -> - dbAttachment.isSticker - } - - if (sticker?.stickerLocator != null) { - builder.stickerMessage = sticker.toStickerMessage(reactionsById[id]) - } else { - builder.standardMessage = record.toStandardMessage(reactionsById[id], mentions = mentionsById[id], attachments = attachmentsById[record.id]) - } - } - } - - if (record.latestRevisionId == null) { - val previousEdits = revisionMap.remove(record.id) - if (previousEdits != null) { - builder.revisions = previousEdits - } - buffer += builder.build() - } else { - var previousEdits = revisionMap[record.latestRevisionId] - if (previousEdits == null) { - previousEdits = ArrayList() - revisionMap[record.latestRevisionId] = previousEdits - } - previousEdits += builder.build() - } - } - - return if (buffer.isNotEmpty()) { - buffer.remove() - } else { - null - } - } - - override fun close() { - cursor.close() - } - - private fun simpleUpdate(type: SimpleChatUpdate.Type): ChatUpdateMessage { - return ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = type)) - } - - private fun BackupMessageRecord.toBasicChatItemBuilder(groupReceipts: List?): ChatItem.Builder { - val record = this - - return ChatItem.Builder().apply { - chatId = record.threadId - authorId = record.fromRecipientId - dateSent = record.dateSent - expireStartDate = if (record.expireStarted > 0) record.expireStarted else 0 - expiresInMs = if (record.expiresIn > 0) record.expiresIn else 0 - revisions = emptyList() - sms = record.type.isSmsType() - if (record.type.isDirectionlessType()) { - directionless = ChatItem.DirectionlessMessageDetails() - } else if (MessageTypes.isOutgoingMessageType(record.type)) { - outgoing = ChatItem.OutgoingMessageDetails( - sendStatus = record.toRemoteSendStatus(groupReceipts) - ) - } else { - incoming = ChatItem.IncomingMessageDetails( - dateServerSent = record.dateServer, - dateReceived = record.dateReceived, - read = record.read, - sealedSender = record.sealedSender - ) - } - } - } - - private fun BackupMessageRecord.toProfileChangeUpdate(): ChatUpdateMessage? { - val profileChangeDetails = if (this.messageExtras != null) { - this.messageExtras.profileChangeDetails - } else { - Base64.decodeOrNull(this.body)?.let { ProfileChangeDetails.ADAPTER.decode(it) } - } - - return if (profileChangeDetails?.profileNameChange != null) { - ChatUpdateMessage(profileChange = ProfileChangeChatUpdate(previousName = profileChangeDetails.profileNameChange.previous, newName = profileChangeDetails.profileNameChange.newValue)) - } else if (profileChangeDetails?.learnedProfileName != null) { - ChatUpdateMessage(learnedProfileChange = LearnedProfileChatUpdate(e164 = profileChangeDetails.learnedProfileName.e164?.e164ToLong(), username = profileChangeDetails.learnedProfileName.username)) - } else { - null - } - } - - private fun BackupMessageRecord.toSessionSwitchoverUpdate(): ChatUpdateMessage { - if (this.body == null) { - return ChatUpdateMessage(sessionSwitchover = SessionSwitchoverChatUpdate()) - } - - return ChatUpdateMessage( - sessionSwitchover = try { - val event = SessionSwitchoverEvent.ADAPTER.decode(Base64.decodeOrThrow(this.body)) - SessionSwitchoverChatUpdate(event.e164.e164ToLong()!!) - } catch (e: IOException) { - SessionSwitchoverChatUpdate() - } - ) - } - - private fun BackupMessageRecord.toThreadMergeUpdate(): ChatUpdateMessage { - if (this.body == null) { - return ChatUpdateMessage(threadMerge = ThreadMergeChatUpdate()) - } - - return ChatUpdateMessage( - threadMerge = try { - val event = ThreadMergeEvent.ADAPTER.decode(Base64.decodeOrThrow(this.body)) - ThreadMergeChatUpdate(event.previousE164.e164ToLong()!!) - } catch (e: IOException) { - ThreadMergeChatUpdate() - } - ) - } - - private fun BackupMessageRecord.toGroupUpdate(): ChatUpdateMessage? { - val groupChange = this.messageExtras?.gv2UpdateDescription?.groupChangeUpdate - return if (groupChange != null) { - ChatUpdateMessage( - groupChange = groupChange - ) - } else if (this.body != null) { - try { - val decoded: ByteArray = Base64.decode(this.body) - val context = DecryptedGroupV2Context.ADAPTER.decode(decoded) - ChatUpdateMessage( - groupChange = GroupsV2UpdateMessageConverter.translateDecryptedChange(selfIds = SignalStore.account.getServiceIds(), context) - ) - } catch (e: IOException) { - null - } - } else { - null - } - } - - private fun BackupMessageRecord.toCallUpdate(): ChatUpdateMessage? { - val call = calls.getCallByMessageId(this.id) - - if (call != null) { - return call.toCallUpdate(this) - } - - return when { - MessageTypes.isMissedAudioCall(this.type) -> { - ChatUpdateMessage( - individualCall = IndividualCall( - type = IndividualCall.Type.AUDIO_CALL, - state = IndividualCall.State.MISSED, - direction = IndividualCall.Direction.INCOMING - ) - ) - } - MessageTypes.isMissedVideoCall(this.type) -> { - ChatUpdateMessage( - individualCall = IndividualCall( - type = IndividualCall.Type.VIDEO_CALL, - state = IndividualCall.State.MISSED, - direction = IndividualCall.Direction.INCOMING - ) - ) - } - MessageTypes.isIncomingAudioCall(this.type) -> { - ChatUpdateMessage( - individualCall = IndividualCall( - type = IndividualCall.Type.AUDIO_CALL, - state = IndividualCall.State.ACCEPTED, - direction = IndividualCall.Direction.INCOMING - ) - ) - } - MessageTypes.isIncomingVideoCall(this.type) -> { - ChatUpdateMessage( - individualCall = IndividualCall( - type = IndividualCall.Type.VIDEO_CALL, - state = IndividualCall.State.ACCEPTED, - direction = IndividualCall.Direction.INCOMING - ) - ) - } - MessageTypes.isOutgoingAudioCall(this.type) -> { - ChatUpdateMessage( - individualCall = IndividualCall( - type = IndividualCall.Type.AUDIO_CALL, - state = IndividualCall.State.ACCEPTED, - direction = IndividualCall.Direction.OUTGOING - ) - ) - } - MessageTypes.isOutgoingVideoCall(this.type) -> { - ChatUpdateMessage( - individualCall = IndividualCall( - type = IndividualCall.Type.VIDEO_CALL, - state = IndividualCall.State.ACCEPTED, - direction = IndividualCall.Direction.OUTGOING - ) - ) - } - else -> { - null - } - } - } - - private fun CallTable.Call.toCallUpdate(messageRecord: BackupMessageRecord): ChatUpdateMessage? { - return when (this.type) { - CallTable.Type.GROUP_CALL -> { - val groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(messageRecord.body) - - ChatUpdateMessage( - groupCall = GroupCall( - callId = this.callId, - state = when (this.event) { - CallTable.Event.MISSED -> GroupCall.State.MISSED - CallTable.Event.ONGOING -> GroupCall.State.GENERIC - CallTable.Event.ACCEPTED -> GroupCall.State.ACCEPTED - CallTable.Event.NOT_ACCEPTED -> GroupCall.State.GENERIC - CallTable.Event.MISSED_NOTIFICATION_PROFILE -> GroupCall.State.MISSED_NOTIFICATION_PROFILE - CallTable.Event.GENERIC_GROUP_CALL -> GroupCall.State.GENERIC - CallTable.Event.JOINED -> GroupCall.State.JOINED - CallTable.Event.RINGING -> GroupCall.State.RINGING - CallTable.Event.DECLINED -> GroupCall.State.DECLINED - CallTable.Event.OUTGOING_RING -> GroupCall.State.OUTGOING_RING - CallTable.Event.DELETE -> return null - }, - ringerRecipientId = this.ringerRecipient?.toLong(), - startedCallRecipientId = ACI.parseOrNull(groupCallUpdateDetails.startedCallUuid)?.let { recipients.getByAci(it).getOrNull()?.toLong() }, - startedCallTimestamp = this.timestamp, - endedCallTimestamp = groupCallUpdateDetails.endedCallTimestamp, - read = messageRecord.read - ) - ) - } - - CallTable.Type.AUDIO_CALL, - CallTable.Type.VIDEO_CALL -> { - ChatUpdateMessage( - individualCall = IndividualCall( - callId = this.callId, - type = if (this.type == CallTable.Type.VIDEO_CALL) IndividualCall.Type.VIDEO_CALL else IndividualCall.Type.AUDIO_CALL, - direction = if (this.direction == CallTable.Direction.INCOMING) IndividualCall.Direction.INCOMING else IndividualCall.Direction.OUTGOING, - state = when (this.event) { - CallTable.Event.MISSED -> IndividualCall.State.MISSED - CallTable.Event.MISSED_NOTIFICATION_PROFILE -> IndividualCall.State.MISSED_NOTIFICATION_PROFILE - CallTable.Event.ACCEPTED -> IndividualCall.State.ACCEPTED - CallTable.Event.NOT_ACCEPTED -> IndividualCall.State.NOT_ACCEPTED - else -> IndividualCall.State.UNKNOWN_STATE - }, - startedCallTimestamp = this.timestamp, - read = messageRecord.read - ) - ) - } - - CallTable.Type.AD_HOC_CALL -> throw IllegalArgumentException("AdHoc calls are not update messages!") - } - } - - private fun BackupMessageRecord.toPaymentNotificationUpdate(): PaymentNotification { - val paymentUuid = UuidUtil.parseOrNull(this.body) - val payment = if (paymentUuid != null) { - SignalDatabase.payments.getPayment(paymentUuid) - } else { - null - } - - return if (payment == null) { - PaymentNotification() - } else { - PaymentNotification( - amountMob = payment.amount.serializeAmountString(), - feeMob = payment.fee.serializeAmountString(), - note = payment.note.takeUnless { it.isEmpty() }, - transactionDetails = payment.getTransactionDetails() - ) - } - } - - 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() - } - val attachmentIdMap: Map = attachments?.associateBy { it.attachmentId } ?: emptyMap() - - try { - val previews: MutableList = LinkedList() - val jsonPreviews = JSONArray(linkPreview) - - for (i in 0 until jsonPreviews.length()) { - val preview = LinkPreview.deserialize(jsonPreviews.getJSONObject(i).toString()) - - if (preview.attachmentId != null) { - val attachment = attachmentIdMap[preview.attachmentId] - - if (attachment != null) { - previews += LinkPreview(preview.url, preview.title, preview.description, preview.date, attachment) - } else { - previews += preview - } - } else { - previews += preview - } - } - - return previews - } catch (e: JSONException) { - Log.w(TAG, "Failed to parse link preview", e) - } catch (e: IOException) { - Log.w(TAG, "Failed to parse shared contacts.", e) - } - - return emptyList() - } - - private fun LinkPreview.toRemoteLinkPreview(): org.thoughtcrime.securesms.backup.v2.proto.LinkPreview { - return org.thoughtcrime.securesms.backup.v2.proto.LinkPreview( - url = url, - title = title.nullIfEmpty(), - image = (thumbnail.orNull() as? DatabaseAttachment)?.toRemoteMessageAttachment()?.pointer, - description = description.nullIfEmpty(), - date = date - ) - } - - private fun BackupMessageRecord.toContactMessage(reactionRecords: List?, attachments: List?): ContactMessage { - val sharedContacts = parseSharedContacts(attachments) - - val contacts = sharedContacts.map { - ContactAttachment( - name = it.name.toRemote(), - avatar = (it.avatar?.attachment as? DatabaseAttachment)?.toRemoteMessageAttachment()?.pointer, - organization = it.organization, - number = it.phoneNumbers.map { phone -> - ContactAttachment.Phone( - value_ = phone.number, - type = phone.type.toRemote(), - label = phone.label - ) - }, - email = it.emails.map { email -> - ContactAttachment.Email( - value_ = email.email, - label = email.label, - type = email.type.toRemote() - ) - }, - address = it.postalAddresses.map { address -> - ContactAttachment.PostalAddress( - type = address.type.toRemote(), - 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.toRemoteReactions() - ) - } - - private fun Contact.Name.toRemote(): ContactAttachment.Name { - return ContactAttachment.Name( - givenName = givenName, - familyName = familyName, - prefix = prefix, - suffix = suffix, - middleName = middleName, - nickname = nickname - ) - } - - private fun Contact.Phone.Type.toRemote(): 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.toRemote(): 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.toRemote(): 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 - } else { - Text( - body = this.body, - bodyRanges = (this.bodyRanges?.toRemoteBodyRanges() ?: emptyList()) + (mentions?.toRemoteBodyRanges() ?: emptyList()) - ) - } - val linkPreviews = parseLinkPreviews(attachments) - val linkPreviewAttachments = linkPreviews.mapNotNull { it.thumbnail.orElse(null) }.toSet() - val quotedAttachments = attachments?.filter { it.quote } ?: emptyList() - val longTextAttachment = attachments?.firstOrNull { it.contentType == "text/x-signal-plain" } - val messageAttachments = attachments - ?.filterNot { it.quote } - ?.filterNot { linkPreviewAttachments.contains(it) } - ?.filterNot { it == longTextAttachment } - ?: emptyList() - return StandardMessage( - quote = this.toQuote(quotedAttachments), - text = text, - attachments = messageAttachments.toBackupAttachments(), - linkPreview = linkPreviews.map { it.toRemoteLinkPreview() }, - longText = longTextAttachment?.toRemoteFilePointer(mediaArchiveEnabled), - reactions = reactionRecords.toRemoteReactions() - ) - } - - private fun BackupMessageRecord.toQuote(attachments: List? = null): Quote? { - if (this.quoteTargetSentTimestamp == MessageTable.QUOTE_NOT_PRESENT_ID || this.quoteAuthor <= 0) { - return null - } - - val type = QuoteModel.Type.fromCode(this.quoteType) - return Quote( - targetSentTimestamp = this.quoteTargetSentTimestamp.takeIf { !this.quoteMissing && it != MessageTable.QUOTE_TARGET_MISSING_ID }, - authorId = this.quoteAuthor, - text = this.quoteBody?.let { body -> - Text( - body = body, - bodyRanges = this.quoteBodyRanges?.toRemoteBodyRanges() ?: emptyList() - ) - }, - attachments = attachments?.toRemoteQuoteAttachments() ?: emptyList(), - type = when (type) { - QuoteModel.Type.NORMAL -> Quote.Type.NORMAL - QuoteModel.Type.GIFT_BADGE -> Quote.Type.GIFTBADGE - } - ) - } - - private fun BackupMessageRecord.toGiftBadgeUpdate(): BackupGiftBadge { - val giftBadge = try { - GiftBadge.ADAPTER.decode(Base64.decode(this.body ?: "")) - } catch (e: IOException) { - Log.w(TAG, "Failed to decode GiftBadge!") - return BackupGiftBadge() - } - - return BackupGiftBadge( - receiptCredentialPresentation = giftBadge.redemptionToken, - state = when (giftBadge.redemptionState) { - GiftBadge.RedemptionState.REDEEMED -> BackupGiftBadge.State.REDEEMED - GiftBadge.RedemptionState.FAILED -> BackupGiftBadge.State.FAILED - GiftBadge.RedemptionState.PENDING -> BackupGiftBadge.State.UNOPENED - GiftBadge.RedemptionState.STARTED -> BackupGiftBadge.State.OPENED - } - ) - } - - private fun DatabaseAttachment.toStickerMessage(reactions: List?): StickerMessage { - val stickerLocator = this.stickerLocator!! - return StickerMessage( - sticker = Sticker( - packId = Hex.fromStringCondensed(stickerLocator.packId).toByteString(), - packKey = Hex.fromStringCondensed(stickerLocator.packKey).toByteString(), - stickerId = stickerLocator.stickerId, - emoji = stickerLocator.emoji, - data_ = this.toRemoteMessageAttachment().pointer - ), - reactions = reactions.toRemoteReactions() - ) - } - - private fun List.toRemoteQuoteAttachments(): List { - return this.map { attachment -> - Quote.QuotedAttachment( - contentType = attachment.contentType, - fileName = attachment.fileName, - thumbnail = attachment.toRemoteMessageAttachment(contentTypeOverride = "image/jpeg").takeUnless { it.pointer?.invalidAttachmentLocator != null } - ) - } - } - - private fun DatabaseAttachment.toRemoteMessageAttachment(contentTypeOverride: String? = null): MessageAttachment { - return MessageAttachment( - pointer = this.toRemoteFilePointer(mediaArchiveEnabled, contentTypeOverride), - wasDownloaded = this.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE || this.transferState == AttachmentTable.TRANSFER_NEEDS_RESTORE, - flag = if (this.voiceNote) { - MessageAttachment.Flag.VOICE_MESSAGE - } else if (this.videoGif) { - MessageAttachment.Flag.GIF - } else if (this.borderless) { - MessageAttachment.Flag.BORDERLESS - } else { - MessageAttachment.Flag.NONE - }, - clientUuid = this.uuid?.let { UuidUtil.toByteString(uuid) } - ) - } - - private fun List.toBackupAttachments(): List { - return this.map { attachment -> - attachment.toRemoteMessageAttachment() - } - } - - private fun PaymentTable.PaymentTransaction.getTransactionDetails(): PaymentNotification.TransactionDetails? { - if (this.failureReason != null || this.state == State.FAILED) { - return PaymentNotification.TransactionDetails(failedTransaction = PaymentNotification.TransactionDetails.FailedTransaction(reason = this.failureReason.toBackupFailureReason())) - } - return PaymentNotification.TransactionDetails( - transaction = PaymentNotification.TransactionDetails.Transaction( - status = this.state.toBackupState(), - timestamp = this.timestamp, - blockIndex = this.blockIndex, - blockTimestamp = this.blockTimestamp, - mobileCoinIdentification = this.paymentMetaData.mobileCoinTxoIdentification?.toRemote(), - transaction = this.transaction?.toByteString(), - receipt = this.receipt?.toByteString() - ) - ) - } - - private fun PaymentMetaData.MobileCoinTxoIdentification.toRemote(): PaymentNotification.TransactionDetails.MobileCoinTxoIdentification { - return PaymentNotification.TransactionDetails.MobileCoinTxoIdentification( - publicKey = this.publicKey, - keyImages = this.keyImages - ) - } - - private fun State.toBackupState(): PaymentNotification.TransactionDetails.Transaction.Status { - return when (this) { - State.INITIAL -> PaymentNotification.TransactionDetails.Transaction.Status.INITIAL - State.SUBMITTED -> PaymentNotification.TransactionDetails.Transaction.Status.SUBMITTED - State.SUCCESSFUL -> PaymentNotification.TransactionDetails.Transaction.Status.SUCCESSFUL - State.FAILED -> throw IllegalArgumentException("state cannot be failed") - } - } - - private fun FailureReason?.toBackupFailureReason(): PaymentNotification.TransactionDetails.FailedTransaction.FailureReason { - return when (this) { - FailureReason.UNKNOWN -> PaymentNotification.TransactionDetails.FailedTransaction.FailureReason.GENERIC - FailureReason.INSUFFICIENT_FUNDS -> PaymentNotification.TransactionDetails.FailedTransaction.FailureReason.INSUFFICIENT_FUNDS - FailureReason.NETWORK -> PaymentNotification.TransactionDetails.FailedTransaction.FailureReason.NETWORK - else -> PaymentNotification.TransactionDetails.FailedTransaction.FailureReason.GENERIC - } - } - - private fun List.toRemoteBodyRanges(): List { - return this.map { - BackupBodyRange( - start = it.start, - length = it.length, - mentionAci = SignalDatabase.recipients.getRecord(it.recipientId).aci?.toByteString() - ) - } - } - - private fun ByteArray.toRemoteBodyRanges(): List { - val decoded: BodyRangeList = try { - BodyRangeList.ADAPTER.decode(this) - } catch (e: IOException) { - Log.w(TAG, "Failed to decode BodyRangeList!") - return emptyList() - } - - return decoded.ranges.map { - val mention = it.mentionUuid?.let { uuid -> UuidUtil.parseOrThrow(uuid) }?.toByteArray()?.toByteString() - val style = if (mention == null) { - it.style?.toBackupBodyRangeStyle() ?: BackupBodyRange.Style.NONE - } else { - null - } - - BackupBodyRange( - start = it.start, - length = it.length, - mentionAci = mention, - style = style - ) - } - } - - private fun BodyRangeList.BodyRange.Style.toBackupBodyRangeStyle(): BackupBodyRange.Style { - return when (this) { - BodyRangeList.BodyRange.Style.BOLD -> BackupBodyRange.Style.BOLD - BodyRangeList.BodyRange.Style.ITALIC -> BackupBodyRange.Style.ITALIC - BodyRangeList.BodyRange.Style.STRIKETHROUGH -> BackupBodyRange.Style.STRIKETHROUGH - BodyRangeList.BodyRange.Style.MONOSPACE -> BackupBodyRange.Style.MONOSPACE - BodyRangeList.BodyRange.Style.SPOILER -> BackupBodyRange.Style.SPOILER - } - } - - private fun List?.toRemoteReactions(): List { - return this - ?.map { - Reaction( - emoji = it.emoji, - authorId = it.author.toLong(), - sentTimestamp = it.dateSent, - sortOrder = it.dateReceived - ) - } ?: emptyList() - } - - private fun BackupMessageRecord.toRemoteSendStatus(groupReceipts: List?): List { - if (!MessageTypes.isOutgoingMessageType(this.type)) { - return emptyList() - } - - if (!groupReceipts.isNullOrEmpty()) { - return groupReceipts.toRemoteSendStatus(this, this.networkFailureRecipientIds, this.identityMismatchRecipientIds) - } - - val statusBuilder = SendStatus.Builder() - .recipientId(this.toRecipientId) - .timestamp(this.receiptTimestamp) - - when { - this.identityMismatchRecipientIds.contains(this.toRecipientId) -> { - statusBuilder.failed = SendStatus.Failed( - reason = SendStatus.Failed.FailureReason.IDENTITY_KEY_MISMATCH - ) - } - this.networkFailureRecipientIds.contains(this.toRecipientId) -> { - statusBuilder.failed = SendStatus.Failed( - reason = SendStatus.Failed.FailureReason.NETWORK - ) - } - this.viewed -> { - statusBuilder.viewed = SendStatus.Viewed( - sealedSender = this.sealedSender - ) - } - this.hasReadReceipt -> { - statusBuilder.read = SendStatus.Read( - sealedSender = this.sealedSender - ) - } - this.hasDeliveryReceipt -> { - statusBuilder.delivered = SendStatus.Delivered( - sealedSender = this.sealedSender - ) - } - this.baseType == MessageTypes.BASE_SENT_FAILED_TYPE -> { - statusBuilder.failed = SendStatus.Failed( - reason = SendStatus.Failed.FailureReason.UNKNOWN - ) - } - this.baseType == MessageTypes.BASE_SENDING_SKIPPED_TYPE -> { - statusBuilder.skipped = SendStatus.Skipped() - } - this.baseType == MessageTypes.BASE_SENT_TYPE -> { - statusBuilder.sent = SendStatus.Sent( - sealedSender = this.sealedSender - ) - } - else -> { - statusBuilder.pending = SendStatus.Pending() - } - } - - return listOf(statusBuilder.build()) - } - - private fun List.toRemoteSendStatus(messageRecord: BackupMessageRecord, networkFailureRecipientIds: Set, identityMismatchRecipientIds: Set): List { - return this.map { - val statusBuilder = SendStatus.Builder() - .recipientId(it.recipientId.toLong()) - .timestamp(it.timestamp) - - when { - identityMismatchRecipientIds.contains(it.recipientId.toLong()) -> { - statusBuilder.failed = SendStatus.Failed( - reason = SendStatus.Failed.FailureReason.IDENTITY_KEY_MISMATCH - ) - } - networkFailureRecipientIds.contains(it.recipientId.toLong()) -> { - statusBuilder.failed = SendStatus.Failed( - reason = SendStatus.Failed.FailureReason.NETWORK - ) - } - messageRecord.baseType == MessageTypes.BASE_SENT_FAILED_TYPE -> { - statusBuilder.failed = SendStatus.Failed( - reason = SendStatus.Failed.FailureReason.UNKNOWN - ) - } - it.status == GroupReceiptTable.STATUS_UNKNOWN -> { - statusBuilder.pending = SendStatus.Pending() - } - it.status == GroupReceiptTable.STATUS_UNDELIVERED -> { - statusBuilder.sent = SendStatus.Sent( - sealedSender = it.isUnidentified - ) - } - it.status == GroupReceiptTable.STATUS_DELIVERED -> { - statusBuilder.delivered = SendStatus.Delivered( - sealedSender = it.isUnidentified - ) - } - it.status == GroupReceiptTable.STATUS_READ -> { - statusBuilder.read = SendStatus.Read( - sealedSender = it.isUnidentified - ) - } - it.status == GroupReceiptTable.STATUS_VIEWED -> { - statusBuilder.viewed = SendStatus.Viewed( - sealedSender = it.isUnidentified - ) - } - it.status == GroupReceiptTable.STATUS_SKIPPED -> { - statusBuilder.skipped = SendStatus.Skipped() - } - else -> { - statusBuilder.pending = SendStatus.Pending() - } - } - - statusBuilder.build() - } - } - - private fun String?.parseNetworkFailures(): Set { - if (this.isNullOrBlank()) { - return emptySet() - } - - return try { - JsonUtils.fromJson(this, NetworkFailureSet::class.java).items.map { it.recipientId.toLong() }.toSet() - } catch (e: IOException) { - emptySet() - } - } - - private fun String?.parseIdentityMismatches(): Set { - if (this.isNullOrBlank()) { - return emptySet() - } - - return try { - JsonUtils.fromJson(this, IdentityKeyMismatchSet::class.java).items.map { it.recipientId.toLong() }.toSet() - } catch (e: IOException) { - emptySet() - } - } - - private fun ByteArray?.parseMessageExtras(): MessageExtras? { - if (this == null) { - return null - } - return try { - MessageExtras.ADAPTER.decode(this) - } catch (e: java.lang.Exception) { - null - } - } - - private fun Long.isSmsType(): Boolean { - if (MessageTypes.isSecureType(this)) { - return false - } - - if (MessageTypes.isCallLog(this)) { - return false - } - - return MessageTypes.isOutgoingMessageType(this) || MessageTypes.isInboxType(this) - } - - private fun Long.isDirectionlessType(): Boolean { - return MessageTypes.isCallLog(this) || - MessageTypes.isExpirationTimerUpdate(this) || - MessageTypes.isThreadMergeType(this) || - MessageTypes.isSessionSwitchoverType(this) || - MessageTypes.isProfileChange(this) || - MessageTypes.isJoinedType(this) || - MessageTypes.isIdentityUpdate(this) || - MessageTypes.isIdentityVerified(this) || - MessageTypes.isIdentityDefault(this) || - MessageTypes.isReleaseChannelDonationRequest(this) || - MessageTypes.isChangeNumber(this) || - MessageTypes.isEndSessionType(this) || - MessageTypes.isChatSessionRefresh(this) || - MessageTypes.isBadDecryptType(this) || - MessageTypes.isPaymentsActivated(this) || - MessageTypes.isPaymentsRequestToActivate(this) || - MessageTypes.isUnsupportedMessageType(this) || - MessageTypes.isReportedSpam(this) || - MessageTypes.isMessageRequestAccepted(this) || - MessageTypes.isBlocked(this) || - MessageTypes.isUnblocked(this) || - MessageTypes.isGroupCall(this) - } - - private fun String.e164ToLong(): Long? { - val fixed = if (this.startsWith("+")) { - this.substring(1) - } else { - this - } - - return fixed.toLongOrNull() - } - - private fun Cursor.toBackupMessageRecord(): BackupMessageRecord { - return BackupMessageRecord( - id = this.requireLong(MessageTable.ID), - dateSent = this.requireLong(MessageTable.DATE_SENT), - dateReceived = this.requireLong(MessageTable.DATE_RECEIVED), - dateServer = this.requireLong(MessageTable.DATE_SERVER), - type = this.requireLong(MessageTable.TYPE), - threadId = this.requireLong(MessageTable.THREAD_ID), - body = this.requireString(MessageTable.BODY), - bodyRanges = this.requireBlob(MessageTable.MESSAGE_RANGES), - fromRecipientId = this.requireLong(MessageTable.FROM_RECIPIENT_ID), - toRecipientId = this.requireLong(MessageTable.TO_RECIPIENT_ID), - expiresIn = this.requireLong(MessageTable.EXPIRES_IN), - expireStarted = this.requireLong(MessageTable.EXPIRE_STARTED), - 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), - quoteMissing = this.requireBoolean(MessageTable.QUOTE_MISSING), - quoteBodyRanges = this.requireBlob(MessageTable.QUOTE_BODY_RANGES), - quoteType = this.requireInt(MessageTable.QUOTE_TYPE), - originalMessageId = this.requireLongOrNull(MessageTable.ORIGINAL_MESSAGE_ID), - latestRevisionId = this.requireLongOrNull(MessageTable.LATEST_REVISION_ID), - hasDeliveryReceipt = this.requireBoolean(MessageTable.HAS_DELIVERY_RECEIPT), - viewed = this.requireBoolean(MessageTable.VIEWED_COLUMN), - hasReadReceipt = this.requireBoolean(MessageTable.HAS_READ_RECEIPT), - read = this.requireBoolean(MessageTable.READ), - receiptTimestamp = this.requireLong(MessageTable.RECEIPT_TIMESTAMP), - networkFailureRecipientIds = this.requireString(MessageTable.NETWORK_FAILURES).parseNetworkFailures(), - identityMismatchRecipientIds = this.requireString(MessageTable.MISMATCHED_IDENTITIES).parseIdentityMismatches(), - baseType = this.requireLong(COLUMN_BASE_TYPE), - messageExtras = this.requireBlob(MessageTable.MESSAGE_EXTRAS).parseMessageExtras() - ) - } - - private class BackupMessageRecord( - val id: Long, - val dateSent: Long, - val dateReceived: Long, - val dateServer: Long, - val type: Long, - val threadId: Long, - val body: String?, - val bodyRanges: ByteArray?, - val fromRecipientId: Long, - val toRecipientId: Long, - val expiresIn: Long, - val expireStarted: Long, - val remoteDeleted: Boolean, - val sealedSender: Boolean, - val linkPreview: String?, - val sharedContacts: String?, - val quoteTargetSentTimestamp: Long, - val quoteAuthor: Long, - val quoteBody: String?, - val quoteMissing: Boolean, - val quoteBodyRanges: ByteArray?, - val quoteType: Int, - val originalMessageId: Long?, - val latestRevisionId: Long?, - val hasDeliveryReceipt: Boolean, - val hasReadReceipt: Boolean, - val viewed: Boolean, - val receiptTimestamp: Long, - val read: Boolean, - val networkFailureRecipientIds: Set, - val identityMismatchRecipientIds: Set, - val baseType: Long, - val messageExtras: MessageExtras? - ) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/DistributionListTablesBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/DistributionListTablesBackupExtensions.kt index 76aea11833..24045a63ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/DistributionListTablesBackupExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/DistributionListTablesBackupExtensions.kt @@ -5,81 +5,31 @@ package org.thoughtcrime.securesms.backup.v2.database -import okio.ByteString.Companion.toByteString import org.signal.core.util.deleteAll import org.signal.core.util.logging.Log -import org.signal.core.util.readToList -import org.signal.core.util.requireBoolean -import org.signal.core.util.requireLong -import org.signal.core.util.requireNonNullString -import org.signal.core.util.requireObject import org.signal.core.util.select import org.signal.core.util.withinTransaction import org.thoughtcrime.securesms.backup.v2.ImportState -import org.thoughtcrime.securesms.backup.v2.proto.DistributionList +import org.thoughtcrime.securesms.backup.v2.exporters.DistributionListArchiveExportIterator import org.thoughtcrime.securesms.backup.v2.proto.DistributionListItem import org.thoughtcrime.securesms.database.DistributionListTables import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.DistributionListId import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode -import org.thoughtcrime.securesms.database.model.DistributionListRecord import org.thoughtcrime.securesms.recipients.RecipientId import org.whispersystems.signalservice.api.push.DistributionId import org.whispersystems.signalservice.api.util.UuidUtil -import org.whispersystems.signalservice.api.util.toByteArray import org.thoughtcrime.securesms.backup.v2.proto.DistributionList as BackupDistributionList private val TAG = Log.tag(DistributionListTables::class.java) -data class DistributionRecipient(val id: RecipientId, val record: DistributionListRecord) - -fun DistributionListTables.getAllForBackup(): List { - val records = readableDatabase +fun DistributionListTables.getAllForBackup(): DistributionListArchiveExportIterator { + val cursor = readableDatabase .select() .from(DistributionListTables.ListTable.TABLE_NAME) .run() - .readToList { cursor -> - val id: DistributionListId = DistributionListId.from(cursor.requireLong(DistributionListTables.ListTable.ID)) - val privacyMode: DistributionListPrivacyMode = cursor.requireObject(DistributionListTables.ListTable.PRIVACY_MODE, DistributionListPrivacyMode.Serializer) - val recipientId: RecipientId = RecipientId.from(cursor.requireLong(DistributionListTables.ListTable.RECIPIENT_ID)) - DistributionRecipient( - id = recipientId, - record = DistributionListRecord( - id = id, - name = cursor.requireNonNullString(DistributionListTables.ListTable.NAME), - distributionId = DistributionId.from(cursor.requireNonNullString(DistributionListTables.ListTable.DISTRIBUTION_ID)), - allowsReplies = cursor.requireBoolean(DistributionListTables.ListTable.ALLOWS_REPLIES), - rawMembers = getRawMembers(id, privacyMode), - members = getMembersForBackup(id), - deletedAtTimestamp = cursor.requireLong(DistributionListTables.ListTable.DELETION_TIMESTAMP), - isUnknown = cursor.requireBoolean(DistributionListTables.ListTable.IS_UNKNOWN), - privacyMode = privacyMode - ) - ) - } - return records - .map { recipient -> - BackupRecipient( - id = recipient.id.toLong(), - distributionList = if (recipient.record.deletedAtTimestamp != 0L) { - DistributionListItem( - distributionId = recipient.record.distributionId.asUuid().toByteArray().toByteString(), - deletionTimestamp = recipient.record.deletedAtTimestamp - ) - } else { - DistributionListItem( - distributionId = recipient.record.distributionId.asUuid().toByteArray().toByteString(), - distributionList = DistributionList( - name = recipient.record.name, - allowReplies = recipient.record.allowsReplies, - privacyMode = recipient.record.privacyMode.toBackupPrivacyMode(), - memberRecipientIds = recipient.record.members.map { it.toLong() } - ) - ) - } - ) - } + return DistributionListArchiveExportIterator(cursor, this) } fun DistributionListTables.getMembersForBackup(id: DistributionListId): List { @@ -142,14 +92,6 @@ fun DistributionListTables.clearAllDataForBackupRestore() { writableDatabase.deleteAll(DistributionListTables.MembershipTable.TABLE_NAME) } -private fun DistributionListPrivacyMode.toBackupPrivacyMode(): BackupDistributionList.PrivacyMode { - return when (this) { - DistributionListPrivacyMode.ONLY_WITH -> BackupDistributionList.PrivacyMode.ONLY_WITH - DistributionListPrivacyMode.ALL -> BackupDistributionList.PrivacyMode.ALL - DistributionListPrivacyMode.ALL_EXCEPT -> BackupDistributionList.PrivacyMode.ALL_EXCEPT - } -} - private fun BackupDistributionList.PrivacyMode.toLocalPrivacyMode(): DistributionListPrivacyMode { return when (this) { BackupDistributionList.PrivacyMode.UNKNOWN -> DistributionListPrivacyMode.ALL 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 a97bf94211..f5e4656ab6 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 @@ -6,17 +6,17 @@ package org.thoughtcrime.securesms.backup.v2.database import org.signal.core.util.SqlUtil -import org.signal.core.util.logging.Log import org.signal.core.util.select import org.thoughtcrime.securesms.backup.v2.ImportState +import org.thoughtcrime.securesms.backup.v2.exporters.ChatItemArchiveExportIterator import org.thoughtcrime.securesms.database.MessageTable import org.thoughtcrime.securesms.database.MessageTypes +import org.thoughtcrime.securesms.database.SignalDatabase import java.util.concurrent.TimeUnit -private val TAG = Log.tag(MessageTable::class.java) -private const val BASE_TYPE = "base_type" +private const val COLUMN_BASE_TYPE = "base_type" -fun MessageTable.getMessagesForBackup(backupTime: Long, mediaBackupEnabled: Boolean): ChatItemExportIterator { +fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, mediaBackupEnabled: Boolean): ChatItemArchiveExportIterator { val cursor = readableDatabase .select( MessageTable.ID, @@ -50,7 +50,7 @@ fun MessageTable.getMessagesForBackup(backupTime: Long, mediaBackupEnabled: Bool MessageTable.READ, MessageTable.NETWORK_FAILURES, MessageTable.MISMATCHED_IDENTITIES, - "${MessageTable.TYPE} & ${MessageTypes.BASE_TYPE_MASK} AS ${ChatItemExportIterator.COLUMN_BASE_TYPE}", + "${MessageTable.TYPE} & ${MessageTypes.BASE_TYPE_MASK} AS $COLUMN_BASE_TYPE", MessageTable.MESSAGE_EXTRAS ) .from(MessageTable.TABLE_NAME) @@ -66,7 +66,7 @@ fun MessageTable.getMessagesForBackup(backupTime: Long, mediaBackupEnabled: Bool .orderBy("${MessageTable.DATE_RECEIVED} ASC") .run() - return ChatItemExportIterator(cursor, 100, mediaBackupEnabled) + return ChatItemArchiveExportIterator(db, cursor, 100, mediaBackupEnabled) } fun MessageTable.createChatItemInserter(importState: ImportState): ChatItemImportInserter { diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableBackupExtensions.kt index 9ac5820866..b4a6bd7002 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableBackupExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableBackupExtensions.kt @@ -6,20 +6,12 @@ package org.thoughtcrime.securesms.backup.v2.database import android.content.ContentValues -import android.database.Cursor import androidx.core.content.contentValuesOf -import okio.ByteString.Companion.toByteString import org.signal.core.util.Base64 import org.signal.core.util.SqlUtil import org.signal.core.util.deleteAll import org.signal.core.util.logging.Log import org.signal.core.util.nullIfBlank -import org.signal.core.util.requireBlob -import org.signal.core.util.requireBoolean -import org.signal.core.util.requireInt -import org.signal.core.util.requireLong -import org.signal.core.util.requireNonNullBlob -import org.signal.core.util.requireString import org.signal.core.util.select import org.signal.core.util.toInt import org.signal.core.util.update @@ -35,14 +27,14 @@ import org.signal.storageservice.protos.groups.local.DecryptedPendingMember import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember import org.signal.storageservice.protos.groups.local.DecryptedTimer import org.signal.storageservice.protos.groups.local.EnabledState +import org.thoughtcrime.securesms.backup.v2.exporters.ContactArchiveExportIterator +import org.thoughtcrime.securesms.backup.v2.exporters.GroupArchiveExportIterator import org.thoughtcrime.securesms.backup.v2.proto.AccountData import org.thoughtcrime.securesms.backup.v2.proto.Contact import org.thoughtcrime.securesms.backup.v2.proto.Group -import org.thoughtcrime.securesms.backup.v2.proto.Self import org.thoughtcrime.securesms.conversation.colors.AvatarColorHash import org.thoughtcrime.securesms.database.GroupTable import org.thoughtcrime.securesms.database.RecipientTable -import org.thoughtcrime.securesms.database.RecipientTableCursorUtil import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.databaseprotos.RecipientExtras import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -58,18 +50,15 @@ import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations import org.whispersystems.signalservice.api.push.ServiceId import org.whispersystems.signalservice.api.push.ServiceId.ACI import org.whispersystems.signalservice.api.push.ServiceId.PNI -import org.whispersystems.signalservice.api.util.toByteArray -import java.io.Closeable -typealias BackupRecipient = org.thoughtcrime.securesms.backup.v2.proto.Recipient -typealias BackupGroup = Group +private typealias BackupRecipient = org.thoughtcrime.securesms.backup.v2.proto.Recipient /** * Fetches all individual contacts for backups and returns the result as an iterator. * It's important to note that the iterator still needs to be closed after it's used. * It's recommended to use `.use` or a try-with-resources pattern. */ -fun RecipientTable.getContactsForBackup(selfId: Long): BackupContactIterator { +fun RecipientTable.getContactsForBackup(selfId: Long): ContactArchiveExportIterator { val cursor = readableDatabase .select( RecipientTable.ID, @@ -104,10 +93,10 @@ fun RecipientTable.getContactsForBackup(selfId: Long): BackupContactIterator { ) .run() - return BackupContactIterator(cursor, selfId) + return ContactArchiveExportIterator(cursor, selfId) } -fun RecipientTable.getGroupsForBackup(): BackupGroupIterator { +fun RecipientTable.getGroupsForBackup(): GroupArchiveExportIterator { val cursor = readableDatabase .select( "${RecipientTable.TABLE_NAME}.${RecipientTable.ID}", @@ -129,7 +118,7 @@ fun RecipientTable.getGroupsForBackup(): BackupGroupIterator { .where("${GroupTable.TABLE_NAME}.${GroupTable.V2_MASTER_KEY} IS NOT NULL") .run() - return BackupGroupIterator(cursor) + return GroupArchiveExportIterator(cursor) } /** @@ -226,7 +215,7 @@ fun RecipientTable.restoreGroupFromBackup(group: Group): RecipientId { val decryptedState = if (group.snapshot == null) { DecryptedGroup(revision = GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) } else { - group.snapshot.toDecryptedGroup(operations) + group.snapshot.toLocal(operations) } val values = ContentValues().apply { @@ -244,12 +233,30 @@ fun RecipientTable.restoreGroupFromBackup(group: Group): RecipientId { val recipientId = writableDatabase.insert(RecipientTable.TABLE_NAME, null, values) val restoredId = SignalDatabase.groups.create(masterKey, decryptedState, groupSendEndorsements = null) if (restoredId != null) { - SignalDatabase.groups.setShowAsStoryState(restoredId, group.storySendMode.toGroupShowAsStoryState()) + SignalDatabase.groups.setShowAsStoryState(restoredId, group.storySendMode.toLocal()) } return RecipientId.from(recipientId) } +private fun Group.StorySendMode.toLocal(): GroupTable.ShowAsStoryState { + return when (this) { + Group.StorySendMode.ENABLED -> GroupTable.ShowAsStoryState.ALWAYS + Group.StorySendMode.DISABLED -> GroupTable.ShowAsStoryState.NEVER + Group.StorySendMode.DEFAULT -> GroupTable.ShowAsStoryState.IF_ACTIVE + } +} + +private fun Group.MemberPendingProfileKey.toLocal(operations: GroupsV2Operations.GroupOperations): DecryptedPendingMember { + return DecryptedPendingMember( + serviceIdBytes = member!!.userId, + role = member.role.toLocal(), + addedByAci = addedByUserId, + timestamp = timestamp, + serviceIdCipherText = operations.encryptServiceId(ServiceId.Companion.parseOrNull(member.userId)) + ) +} + private fun Contact.Visibility.toLocal(): Recipient.HiddenState { return when (this) { Contact.Visibility.VISIBLE -> Recipient.HiddenState.NOT_HIDDEN @@ -280,78 +287,10 @@ private fun Group.Member.Role.toLocal(): Member.Role { } } -private fun AccessControl.AccessRequired.toSnapshot(): Group.AccessControl.AccessRequired { - return when (this) { - AccessControl.AccessRequired.UNKNOWN -> Group.AccessControl.AccessRequired.UNKNOWN - AccessControl.AccessRequired.ANY -> Group.AccessControl.AccessRequired.ANY - AccessControl.AccessRequired.MEMBER -> Group.AccessControl.AccessRequired.MEMBER - AccessControl.AccessRequired.ADMINISTRATOR -> Group.AccessControl.AccessRequired.ADMINISTRATOR - AccessControl.AccessRequired.UNSATISFIABLE -> Group.AccessControl.AccessRequired.UNSATISFIABLE - } -} - -private fun AccessControl.toSnapshot(): Group.AccessControl { - return Group.AccessControl(members = members.toSnapshot(), attributes = attributes.toSnapshot(), addFromInviteLink = addFromInviteLink.toSnapshot()) -} - -private fun Member.Role.toSnapshot(): Group.Member.Role { - return when (this) { - Member.Role.UNKNOWN -> Group.Member.Role.UNKNOWN - Member.Role.DEFAULT -> Group.Member.Role.DEFAULT - Member.Role.ADMINISTRATOR -> Group.Member.Role.ADMINISTRATOR - } -} - -private fun DecryptedGroup.toSnapshot(): Group.GroupSnapshot? { - if (this.revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION || this.revision == GroupsV2StateProcessor.PLACEHOLDER_REVISION) { - return null - } - - return Group.GroupSnapshot( - title = Group.GroupAttributeBlob(title = this.title), - avatarUrl = this.avatar, - disappearingMessagesTimer = this.disappearingMessagesTimer?.takeIf { it.duration > 0 }?.let { Group.GroupAttributeBlob(disappearingMessagesDuration = it.duration) }, - accessControl = this.accessControl?.toSnapshot(), - version = this.revision, - members = this.members.map { it.toSnapshot() }, - membersPendingProfileKey = this.pendingMembers.map { it.toSnapshot() }, - membersPendingAdminApproval = this.requestingMembers.map { it.toSnapshot() }, - inviteLinkPassword = this.inviteLinkPassword, - description = this.description.takeUnless { it.isBlank() }?.let { Group.GroupAttributeBlob(descriptionText = it) }, - announcements_only = this.isAnnouncementGroup == EnabledState.ENABLED, - members_banned = this.bannedMembers.map { it.toSnapshot() } - ) -} - private fun Group.Member.toLocal(): DecryptedMember { return DecryptedMember(aciBytes = userId, role = role.toLocal(), joinedAtRevision = joinedAtVersion) } -private fun DecryptedMember.toSnapshot(): Group.Member { - return Group.Member(userId = aciBytes, role = role.toSnapshot(), joinedAtVersion = joinedAtRevision) -} - -private fun Group.MemberPendingProfileKey.toLocal(operations: GroupsV2Operations.GroupOperations): DecryptedPendingMember { - return DecryptedPendingMember( - serviceIdBytes = member!!.userId, - role = member.role.toLocal(), - addedByAci = addedByUserId, - timestamp = timestamp, - serviceIdCipherText = operations.encryptServiceId(ServiceId.Companion.parseOrNull(member.userId)) - ) -} - -private fun DecryptedPendingMember.toSnapshot(): Group.MemberPendingProfileKey { - return Group.MemberPendingProfileKey( - member = Group.Member( - userId = this.serviceIdBytes, - role = this.role.toSnapshot() - ), - addedByUserId = this.addedByAci, - timestamp = this.timestamp - ) -} - private fun Group.MemberPendingAdminApproval.toLocal(): DecryptedRequestingMember { return DecryptedRequestingMember( aciBytes = this.userId, @@ -359,13 +298,6 @@ private fun Group.MemberPendingAdminApproval.toLocal(): DecryptedRequestingMembe ) } -private fun DecryptedRequestingMember.toSnapshot(): Group.MemberPendingAdminApproval { - return Group.MemberPendingAdminApproval( - userId = this.aciBytes, - timestamp = this.timestamp - ) -} - private fun Group.MemberBanned.toLocal(): DecryptedBannedMember { return DecryptedBannedMember( serviceIdBytes = this.userId, @@ -373,14 +305,7 @@ private fun Group.MemberBanned.toLocal(): DecryptedBannedMember { ) } -private fun DecryptedBannedMember.toSnapshot(): Group.MemberBanned { - return Group.MemberBanned( - userId = this.serviceIdBytes, - timestamp = this.timestamp - ) -} - -private fun Group.GroupSnapshot.toDecryptedGroup(operations: GroupsV2Operations.GroupOperations): DecryptedGroup { +private fun Group.GroupSnapshot.toLocal(operations: GroupsV2Operations.GroupOperations): DecryptedGroup { return DecryptedGroup( title = this.title?.title ?: "", avatar = this.avatarUrl, @@ -403,139 +328,6 @@ private fun Contact.toLocalExtras(): RecipientExtras { ) } -/** - * Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s. - * Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources. - */ -class BackupContactIterator(private val cursor: Cursor, private val selfId: Long) : Iterator, Closeable { - override fun hasNext(): Boolean { - return cursor.count > 0 && !cursor.isLast - } - - override fun next(): BackupRecipient? { - if (!cursor.moveToNext()) { - throw NoSuchElementException() - } - - val id = cursor.requireLong(RecipientTable.ID) - if (id == selfId) { - return BackupRecipient( - id = id, - self = Self() - ) - } - - val aci = ACI.parseOrNull(cursor.requireString(RecipientTable.ACI_COLUMN)) - val pni = PNI.parseOrNull(cursor.requireString(RecipientTable.PNI_COLUMN)) - val e164 = cursor.requireString(RecipientTable.E164)?.e164ToLong() - val registeredState = RecipientTable.RegisteredState.fromId(cursor.requireInt(RecipientTable.REGISTERED)) - val profileKey = cursor.requireString(RecipientTable.PROFILE_KEY) - val extras = RecipientTableCursorUtil.getExtras(cursor) - - if (aci == null && pni == null && e164 == null) { - return null - } - - val contactBuilder = Contact.Builder() - .aci(aci?.rawUuid?.toByteArray()?.toByteString()) - .pni(pni?.rawUuid?.toByteArray()?.toByteString()) - .username(cursor.requireString(RecipientTable.USERNAME)) - .e164(cursor.requireString(RecipientTable.E164)?.e164ToLong()) - .blocked(cursor.requireBoolean(RecipientTable.BLOCKED)) - .visibility(Recipient.HiddenState.deserialize(cursor.requireInt(RecipientTable.HIDDEN)).toRemote()) - .profileKey(if (profileKey != null) Base64.decode(profileKey).toByteString() else null) - .profileSharing(cursor.requireBoolean(RecipientTable.PROFILE_SHARING)) - .profileGivenName(cursor.requireString(RecipientTable.PROFILE_GIVEN_NAME)) - .profileFamilyName(cursor.requireString(RecipientTable.PROFILE_FAMILY_NAME)) - .hideStory(extras?.hideStory() ?: false) - - if (registeredState == RecipientTable.RegisteredState.REGISTERED) { - contactBuilder.registered = Contact.Registered() - } else { - contactBuilder.notRegistered = Contact.NotRegistered(unregisteredTimestamp = cursor.requireLong(RecipientTable.UNREGISTERED_TIMESTAMP)) - } - - return BackupRecipient( - id = id, - contact = contactBuilder.build() - ) - } - - override fun close() { - cursor.close() - } -} - -private fun Recipient.HiddenState.toRemote(): Contact.Visibility { - return when (this) { - Recipient.HiddenState.NOT_HIDDEN -> return Contact.Visibility.VISIBLE - Recipient.HiddenState.HIDDEN -> return Contact.Visibility.HIDDEN - Recipient.HiddenState.HIDDEN_MESSAGE_REQUEST -> return Contact.Visibility.HIDDEN_MESSAGE_REQUEST - } -} - -/** - * Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s. - * Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources. - */ -class BackupGroupIterator(private val cursor: Cursor) : Iterator, Closeable { - override fun hasNext(): Boolean { - return cursor.count > 0 && !cursor.isLast - } - - override fun next(): BackupRecipient { - if (!cursor.moveToNext()) { - throw NoSuchElementException() - } - - val extras = RecipientTableCursorUtil.getExtras(cursor) - val showAsStoryState: GroupTable.ShowAsStoryState = GroupTable.ShowAsStoryState.deserialize(cursor.requireInt(GroupTable.SHOW_AS_STORY_STATE)) - - val decryptedGroup: DecryptedGroup = DecryptedGroup.ADAPTER.decode(cursor.requireBlob(GroupTable.V2_DECRYPTED_GROUP)!!) - - return BackupRecipient( - id = cursor.requireLong(RecipientTable.ID), - group = BackupGroup( - masterKey = cursor.requireNonNullBlob(GroupTable.V2_MASTER_KEY).toByteString(), - whitelisted = cursor.requireBoolean(RecipientTable.PROFILE_SHARING), - hideStory = extras?.hideStory() ?: false, - storySendMode = showAsStoryState.toGroupStorySendMode(), - snapshot = decryptedGroup.toSnapshot() - ) - ) - } - - override fun close() { - cursor.close() - } -} - -private fun String.e164ToLong(): Long? { - val fixed = if (this.startsWith("+")) { - this.substring(1) - } else { - this - } - - return fixed.toLongOrNull() -} - -private fun GroupTable.ShowAsStoryState.toGroupStorySendMode(): Group.StorySendMode { - return when (this) { - GroupTable.ShowAsStoryState.ALWAYS -> Group.StorySendMode.ENABLED - GroupTable.ShowAsStoryState.NEVER -> Group.StorySendMode.DISABLED - GroupTable.ShowAsStoryState.IF_ACTIVE -> Group.StorySendMode.DEFAULT - } -} - -private fun Group.StorySendMode.toGroupShowAsStoryState(): GroupTable.ShowAsStoryState { - return when (this) { - Group.StorySendMode.ENABLED -> GroupTable.ShowAsStoryState.ALWAYS - Group.StorySendMode.DISABLED -> GroupTable.ShowAsStoryState.NEVER - Group.StorySendMode.DEFAULT -> GroupTable.ShowAsStoryState.IF_ACTIVE - } -} - private val Contact.formattedE164: String? get() { return e164?.let { diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ThreadTableBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ThreadTableBackupExtensions.kt index a8f17d0e88..6745697556 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ThreadTableBackupExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ThreadTableBackupExtensions.kt @@ -5,21 +5,15 @@ package org.thoughtcrime.securesms.backup.v2.database -import android.database.Cursor import androidx.core.content.contentValuesOf import org.signal.core.util.SqlUtil -import org.signal.core.util.decodeOrNull import org.signal.core.util.insertInto import org.signal.core.util.logging.Log -import org.signal.core.util.requireBlob -import org.signal.core.util.requireBoolean -import org.signal.core.util.requireInt -import org.signal.core.util.requireLong import org.signal.core.util.toInt import org.thoughtcrime.securesms.attachments.AttachmentId import org.thoughtcrime.securesms.backup.v2.ImportState +import org.thoughtcrime.securesms.backup.v2.exporters.ChatArchiveExportIterator import org.thoughtcrime.securesms.backup.v2.proto.Chat -import org.thoughtcrime.securesms.backup.v2.util.ChatStyleConverter import org.thoughtcrime.securesms.backup.v2.util.parseChatWallpaper import org.thoughtcrime.securesms.backup.v2.util.toLocal import org.thoughtcrime.securesms.backup.v2.util.toLocalAttachment @@ -27,15 +21,12 @@ import org.thoughtcrime.securesms.conversation.colors.ChatColors import org.thoughtcrime.securesms.database.RecipientTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.ThreadTable -import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor -import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.wallpaper.UriChatWallpaper -import java.io.Closeable private val TAG = Log.tag(ThreadTable::class.java) -fun ThreadTable.getThreadsForBackup(db: SignalDatabase): ChatExportIterator { +fun ThreadTable.getThreadsForBackup(db: SignalDatabase): ChatArchiveExportIterator { //language=sql val query = """ SELECT @@ -57,7 +48,7 @@ fun ThreadTable.getThreadsForBackup(db: SignalDatabase): ChatExportIterator { """ val cursor = readableDatabase.query(query) - return ChatExportIterator(cursor, db) + return ChatArchiveExportIterator(cursor, db) } fun ThreadTable.clearAllDataForBackupRestore() { @@ -107,48 +98,3 @@ fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId, importSt return threadId } - -class ChatExportIterator(private val cursor: Cursor, private val db: SignalDatabase) : Iterator, Closeable { - override fun hasNext(): Boolean { - return cursor.count > 0 && !cursor.isLast - } - - override fun next(): Chat { - if (!cursor.moveToNext()) { - throw NoSuchElementException() - } - - val customChatColorsId = ChatColors.Id.forLongValue(cursor.requireLong(RecipientTable.CUSTOM_CHAT_COLORS_ID)) - - val chatColors: ChatColors? = cursor.requireBlob(RecipientTable.CHAT_COLORS)?.let { serializedChatColors -> - val chatColor = ChatColor.ADAPTER.decodeOrNull(serializedChatColors) - chatColor?.let { ChatColors.forChatColor(customChatColorsId, it) } - } - - val chatWallpaper: Wallpaper? = cursor.requireBlob(RecipientTable.WALLPAPER)?.let { serializedWallpaper -> - Wallpaper.ADAPTER.decodeOrNull(serializedWallpaper) - } - - return Chat( - id = cursor.requireLong(ThreadTable.ID), - recipientId = cursor.requireLong(ThreadTable.RECIPIENT_ID), - archived = cursor.requireBoolean(ThreadTable.ARCHIVED), - pinnedOrder = cursor.requireInt(ThreadTable.PINNED), - expirationTimerMs = cursor.requireLong(RecipientTable.MESSAGE_EXPIRATION_TIME), - expireTimerVersion = cursor.requireInt(RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION), - muteUntilMs = cursor.requireLong(RecipientTable.MUTE_UNTIL), - markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.FORCED_UNREAD, - dontNotifyForMentionsIfMuted = RecipientTable.MentionSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING), - style = ChatStyleConverter.constructRemoteChatStyle( - db = db, - chatColors = chatColors, - chatColorId = customChatColorsId, - chatWallpaper = chatWallpaper - ) - ) - } - - override fun close() { - cursor.close() - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/AdHocCallArchiveExportIterator.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/AdHocCallArchiveExportIterator.kt new file mode 100644 index 0000000000..ecd965514c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/AdHocCallArchiveExportIterator.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.database + +import android.database.Cursor +import org.signal.core.util.requireLong +import org.thoughtcrime.securesms.backup.v2.proto.AdHocCall +import org.thoughtcrime.securesms.database.CallTable +import java.io.Closeable + +/** + * Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s. + * Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources. + */ +class AdHocCallArchiveExportIterator(private val cursor: Cursor) : Iterator, Closeable { + override fun hasNext(): Boolean { + return cursor.count > 0 && !cursor.isLast + } + + override fun next(): AdHocCall { + if (!cursor.moveToNext()) { + throw NoSuchElementException() + } + + val callId = cursor.requireLong(CallTable.CALL_ID) + + return AdHocCall( + callId = callId, + recipientId = cursor.requireLong(CallTable.PEER), + state = AdHocCall.State.GENERIC, + callTimestamp = cursor.requireLong(CallTable.TIMESTAMP) + ) + } + + override fun close() { + cursor.close() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/CallLinkArchiveExportIterator.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/CallLinkArchiveExportIterator.kt new file mode 100644 index 0000000000..ee948cebd5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/CallLinkArchiveExportIterator.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.database + +import android.database.Cursor +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.signal.ringrtc.CallLinkState +import org.thoughtcrime.securesms.backup.v2.ArchiveRecipient +import org.thoughtcrime.securesms.backup.v2.proto.CallLink +import org.thoughtcrime.securesms.database.CallLinkTable +import java.io.Closeable + +/** + * Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s. + * Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources. + */ +class CallLinkArchiveExportIterator(private val cursor: Cursor) : Iterator, Closeable { + override fun hasNext(): Boolean { + return cursor.count > 0 && !cursor.isLast + } + + override fun next(): ArchiveRecipient { + if (!cursor.moveToNext()) { + throw NoSuchElementException() + } + + val callLink = CallLinkTable.CallLinkDeserializer.deserialize(cursor) + return ArchiveRecipient( + id = callLink.recipientId.toLong(), + callLink = CallLink( + rootKey = callLink.credentials?.linkKeyBytes?.toByteString() ?: ByteString.EMPTY, + adminKey = callLink.credentials?.adminPassBytes?.toByteString(), + name = callLink.state.name, + expirationMs = try { + callLink.state.expiration.toEpochMilli() + } catch (e: ArithmeticException) { + Long.MAX_VALUE + }, + restrictions = callLink.state.restrictions.toRemote() + ) + ) + } + + override fun close() { + cursor.close() + } +} + +private fun CallLinkState.Restrictions.toRemote(): CallLink.Restrictions { + return when (this) { + CallLinkState.Restrictions.ADMIN_APPROVAL -> CallLink.Restrictions.ADMIN_APPROVAL + CallLinkState.Restrictions.NONE -> CallLink.Restrictions.NONE + CallLinkState.Restrictions.UNKNOWN -> CallLink.Restrictions.UNKNOWN + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatArchiveExportIterator.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatArchiveExportIterator.kt new file mode 100644 index 0000000000..e4c13bb988 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatArchiveExportIterator.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.exporters + +import android.database.Cursor +import org.signal.core.util.decodeOrNull +import org.signal.core.util.requireBlob +import org.signal.core.util.requireBoolean +import org.signal.core.util.requireInt +import org.signal.core.util.requireLong +import org.thoughtcrime.securesms.backup.v2.proto.Chat +import org.thoughtcrime.securesms.backup.v2.util.ChatStyleConverter +import org.thoughtcrime.securesms.conversation.colors.ChatColors +import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.ThreadTable +import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor +import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper +import java.io.Closeable + +class ChatArchiveExportIterator(private val cursor: Cursor, private val db: SignalDatabase) : Iterator, Closeable { + override fun hasNext(): Boolean { + return cursor.count > 0 && !cursor.isLast + } + + override fun next(): Chat { + if (!cursor.moveToNext()) { + throw NoSuchElementException() + } + + val customChatColorsId = ChatColors.Id.forLongValue(cursor.requireLong(RecipientTable.CUSTOM_CHAT_COLORS_ID)) + + val chatColors: ChatColors? = cursor.requireBlob(RecipientTable.CHAT_COLORS)?.let { serializedChatColors -> + val chatColor = ChatColor.ADAPTER.decodeOrNull(serializedChatColors) + chatColor?.let { ChatColors.forChatColor(customChatColorsId, it) } + } + + val chatWallpaper: Wallpaper? = cursor.requireBlob(RecipientTable.WALLPAPER)?.let { serializedWallpaper -> + Wallpaper.ADAPTER.decodeOrNull(serializedWallpaper) + } + + return Chat( + id = cursor.requireLong(ThreadTable.ID), + recipientId = cursor.requireLong(ThreadTable.RECIPIENT_ID), + archived = cursor.requireBoolean(ThreadTable.ARCHIVED), + pinnedOrder = cursor.requireInt(ThreadTable.PINNED), + expirationTimerMs = cursor.requireLong(RecipientTable.MESSAGE_EXPIRATION_TIME), + expireTimerVersion = cursor.requireInt(RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION), + muteUntilMs = cursor.requireLong(RecipientTable.MUTE_UNTIL), + markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.FORCED_UNREAD, + dontNotifyForMentionsIfMuted = RecipientTable.MentionSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING), + style = ChatStyleConverter.constructRemoteChatStyle( + db = db, + chatColors = chatColors, + chatColorId = customChatColorsId, + chatWallpaper = chatWallpaper + ) + ) + } + + override fun close() { + cursor.close() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatItemArchiveExportIterator.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatItemArchiveExportIterator.kt new file mode 100644 index 0000000000..e2271d965f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ChatItemArchiveExportIterator.kt @@ -0,0 +1,1127 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.exporters + +import android.database.Cursor +import okio.ByteString.Companion.toByteString +import org.json.JSONArray +import org.json.JSONException +import org.signal.core.util.Base64 +import org.signal.core.util.Hex +import org.signal.core.util.logging.Log +import org.signal.core.util.nullIfEmpty +import org.signal.core.util.orNull +import org.signal.core.util.requireBlob +import org.signal.core.util.requireBoolean +import org.signal.core.util.requireInt +import org.signal.core.util.requireLong +import org.signal.core.util.requireLongOrNull +import org.signal.core.util.requireString +import org.thoughtcrime.securesms.attachments.AttachmentId +import org.thoughtcrime.securesms.attachments.DatabaseAttachment +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.GroupCall +import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall +import org.thoughtcrime.securesms.backup.v2.proto.LearnedProfileChatUpdate +import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment +import org.thoughtcrime.securesms.backup.v2.proto.PaymentNotification +import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate +import org.thoughtcrime.securesms.backup.v2.proto.Quote +import org.thoughtcrime.securesms.backup.v2.proto.Reaction +import org.thoughtcrime.securesms.backup.v2.proto.RemoteDeletedMessage +import org.thoughtcrime.securesms.backup.v2.proto.SendStatus +import org.thoughtcrime.securesms.backup.v2.proto.SessionSwitchoverChatUpdate +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.backup.v2.proto.StickerMessage +import org.thoughtcrime.securesms.backup.v2.proto.Text +import org.thoughtcrime.securesms.backup.v2.proto.ThreadMergeChatUpdate +import org.thoughtcrime.securesms.backup.v2.util.toRemoteFilePointer +import org.thoughtcrime.securesms.contactshare.Contact +import org.thoughtcrime.securesms.database.AttachmentTable +import org.thoughtcrime.securesms.database.CallTable +import org.thoughtcrime.securesms.database.GroupReceiptTable +import org.thoughtcrime.securesms.database.MessageTable +import org.thoughtcrime.securesms.database.MessageTypes +import org.thoughtcrime.securesms.database.PaymentTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet +import org.thoughtcrime.securesms.database.documents.NetworkFailureSet +import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil +import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter +import org.thoughtcrime.securesms.database.model.Mention +import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList +import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context +import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge +import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras +import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails +import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent +import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.linkpreview.LinkPreview +import org.thoughtcrime.securesms.mms.QuoteModel +import org.thoughtcrime.securesms.payments.FailureReason +import org.thoughtcrime.securesms.payments.State +import org.thoughtcrime.securesms.payments.proto.PaymentMetaData +import org.thoughtcrime.securesms.util.JsonUtils +import org.whispersystems.signalservice.api.push.ServiceId.ACI +import org.whispersystems.signalservice.api.util.UuidUtil +import org.whispersystems.signalservice.api.util.toByteArray +import java.io.Closeable +import java.io.IOException +import java.util.HashMap +import java.util.LinkedList +import java.util.Queue +import kotlin.jvm.optionals.getOrNull +import org.thoughtcrime.securesms.backup.v2.proto.BodyRange as BackupBodyRange +import org.thoughtcrime.securesms.backup.v2.proto.GiftBadge as BackupGiftBadge + +private val TAG = Log.tag(ChatItemArchiveExportIterator::class.java) +private const val COLUMN_BASE_TYPE = "base_type" + +/** + * An iterator for chat items with a clever performance twist: rather than do the extra queries one at a time (for reactions, + * attachments, etc), this will populate items in batches, doing bulk lookups to improve throughput. We keep these in a buffer + * and only do more queries when the buffer is empty. + * + * All of this complexity is hidden from the user -- they just get a normal iterator interface. + */ +class ChatItemArchiveExportIterator( + private val db: SignalDatabase, + private val cursor: Cursor, + private val batchSize: Int, + private val mediaArchiveEnabled: Boolean +) : Iterator, Closeable { + + /** + * A queue of already-parsed ChatItems. Processing in batches means that we read ahead in the cursor and put + * the pending items here. + */ + private val buffer: Queue = LinkedList() + + private val revisionMap: HashMap> = HashMap() + + override fun hasNext(): Boolean { + return buffer.isNotEmpty() || (cursor.count > 0 && !cursor.isLast && !cursor.isAfterLast) + } + + override fun next(): ChatItem? { + if (buffer.isNotEmpty()) { + return buffer.remove() + } + + val records: LinkedHashMap = linkedMapOf() + + for (i in 0 until batchSize) { + if (cursor.moveToNext()) { + val record = cursor.toBackupMessageRecord() + records[record.id] = record + } else { + break + } + } + + val reactionsById: Map> = db.reactionTable.getReactionsForMessages(records.keys).map { entry -> entry.key to entry.value.sortedBy { it.dateReceived } }.toMap() + val mentionsById: Map> = db.mentionTable.getMentionsForMessages(records.keys) + val attachmentsById: Map> = db.attachmentTable.getAttachmentsForMessages(records.keys) + val groupReceiptsById: Map> = db.groupReceiptTable.getGroupReceiptInfoForMessages(records.keys) + + for ((id, record) in records) { + val builder = record.toBasicChatItemBuilder(groupReceiptsById[id]) + + when { + record.remoteDeleted -> { + builder.remoteDeletedMessage = RemoteDeletedMessage() + } + + MessageTypes.isJoinedType(record.type) -> { + builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.JOINED_SIGNAL) + } + + MessageTypes.isIdentityUpdate(record.type) -> { + builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.IDENTITY_UPDATE) + } + + MessageTypes.isIdentityVerified(record.type) -> { + builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.IDENTITY_VERIFIED) + } + + MessageTypes.isIdentityDefault(record.type) -> { + builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.IDENTITY_DEFAULT) + } + + MessageTypes.isChangeNumber(record.type) -> { + builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.CHANGE_NUMBER) + } + + MessageTypes.isReleaseChannelDonationRequest(record.type) -> { + builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.RELEASE_CHANNEL_DONATION_REQUEST) + } + + MessageTypes.isEndSessionType(record.type) -> { + builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.END_SESSION) + } + + MessageTypes.isChatSessionRefresh(record.type) -> { + builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.CHAT_SESSION_REFRESH) + } + + MessageTypes.isBadDecryptType(record.type) -> { + builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.BAD_DECRYPT) + } + + MessageTypes.isPaymentsActivated(record.type) -> { + builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.PAYMENTS_ACTIVATED) + } + + MessageTypes.isPaymentsRequestToActivate(record.type) -> { + builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST) + } + + MessageTypes.isUnsupportedMessageType(record.type) -> { + builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.UNSUPPORTED_PROTOCOL_MESSAGE) + } + + MessageTypes.isReportedSpam(record.type) -> { + builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.REPORTED_SPAM) + } + + MessageTypes.isMessageRequestAccepted(record.type) -> { + builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.MESSAGE_REQUEST_ACCEPTED) + } + + MessageTypes.isBlocked(record.type) -> { + builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.BLOCKED) + } + + MessageTypes.isUnblocked(record.type) -> { + builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.UNBLOCKED) + } + + MessageTypes.isExpirationTimerUpdate(record.type) -> { + builder.updateMessage = ChatUpdateMessage(expirationTimerChange = ExpirationTimerChatUpdate(record.expiresIn)) + builder.expiresInMs = 0 + } + + MessageTypes.isProfileChange(record.type) -> { + builder.updateMessage = record.toRemoteProfileChangeUpdate() + } + + MessageTypes.isSessionSwitchoverType(record.type) -> { + builder.updateMessage = record.toRemoteSessionSwitchoverUpdate() + } + + MessageTypes.isThreadMergeType(record.type) -> { + builder.updateMessage = record.toRemoteThreadMergeUpdate() + } + + MessageTypes.isGroupV2(record.type) && MessageTypes.isGroupUpdate(record.type) -> { + builder.updateMessage = record.toRemoteGroupUpdate() + } + + MessageTypes.isCallLog(record.type) -> { + val call = db.callTable.getCallByMessageId(record.id) + builder.updateMessage = call?.toRemoteCallUpdate(db, record) + } + + MessageTypes.isPaymentsNotification(record.type) -> { + builder.paymentNotification = record.toRemotePaymentNotificationUpdate(db) + } + + MessageTypes.isGiftBadge(record.type) -> { + builder.giftBadge = record.toRemoteGiftBadgeUpdate() + } + + !record.sharedContacts.isNullOrEmpty() -> { + builder.contactMessage = record.toRemoteContactMessage(mediaArchiveEnabled = mediaArchiveEnabled, reactionRecords = reactionsById[id], attachments = 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.") + continue + } + + val attachments = attachmentsById[record.id] + val sticker = attachments?.firstOrNull { dbAttachment -> dbAttachment.isSticker } + + if (sticker?.stickerLocator != null) { + builder.stickerMessage = sticker.toRemoteStickerMessage(mediaArchiveEnabled = mediaArchiveEnabled, reactions = reactionsById[id]) + } else { + builder.standardMessage = record.toRemoteStandardMessage( + db = db, + mediaArchiveEnabled = mediaArchiveEnabled, + reactionRecords = reactionsById[id], + mentions = mentionsById[id], + attachments = attachmentsById[record.id] + ) + } + } + } + + if (record.latestRevisionId == null) { + val previousEdits = revisionMap.remove(record.id) + if (previousEdits != null) { + builder.revisions = previousEdits + } + buffer += builder.build() + } else { + var previousEdits = revisionMap[record.latestRevisionId] + if (previousEdits == null) { + previousEdits = ArrayList() + revisionMap[record.latestRevisionId] = previousEdits + } + previousEdits += builder.build() + } + } + + return if (buffer.isNotEmpty()) { + buffer.remove() + } else { + null + } + } + + override fun close() { + cursor.close() + } +} + +private fun simpleUpdate(type: SimpleChatUpdate.Type): ChatUpdateMessage { + return ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = type)) +} + +private fun BackupMessageRecord.toBasicChatItemBuilder(groupReceipts: List?): ChatItem.Builder { + val record = this + + return ChatItem.Builder().apply { + chatId = record.threadId + authorId = record.fromRecipientId + dateSent = record.dateSent + expireStartDate = if (record.expireStarted > 0) record.expireStarted else 0 + expiresInMs = if (record.expiresIn > 0) record.expiresIn else 0 + revisions = emptyList() + sms = record.type.isSmsType() + if (record.type.isDirectionlessType()) { + directionless = ChatItem.DirectionlessMessageDetails() + } else if (MessageTypes.isOutgoingMessageType(record.type)) { + outgoing = ChatItem.OutgoingMessageDetails( + sendStatus = record.toRemoteSendStatus(groupReceipts) + ) + } else { + incoming = ChatItem.IncomingMessageDetails( + dateServerSent = record.dateServer, + dateReceived = record.dateReceived, + read = record.read, + sealedSender = record.sealedSender + ) + } + } +} + +private fun BackupMessageRecord.toRemoteProfileChangeUpdate(): ChatUpdateMessage? { + val profileChangeDetails = if (this.messageExtras != null) { + this.messageExtras.profileChangeDetails + } else { + Base64.decodeOrNull(this.body)?.let { ProfileChangeDetails.ADAPTER.decode(it) } + } + + return if (profileChangeDetails?.profileNameChange != null) { + ChatUpdateMessage(profileChange = ProfileChangeChatUpdate(previousName = profileChangeDetails.profileNameChange.previous, newName = profileChangeDetails.profileNameChange.newValue)) + } else if (profileChangeDetails?.learnedProfileName != null) { + ChatUpdateMessage(learnedProfileChange = LearnedProfileChatUpdate(e164 = profileChangeDetails.learnedProfileName.e164?.e164ToLong(), username = profileChangeDetails.learnedProfileName.username)) + } else { + null + } +} + +private fun BackupMessageRecord.toRemoteSessionSwitchoverUpdate(): ChatUpdateMessage { + if (this.body == null) { + return ChatUpdateMessage(sessionSwitchover = SessionSwitchoverChatUpdate()) + } + + return ChatUpdateMessage( + sessionSwitchover = try { + val event = SessionSwitchoverEvent.ADAPTER.decode(Base64.decodeOrThrow(this.body)) + SessionSwitchoverChatUpdate(event.e164.e164ToLong()!!) + } catch (e: IOException) { + SessionSwitchoverChatUpdate() + } + ) +} + +private fun BackupMessageRecord.toRemoteThreadMergeUpdate(): ChatUpdateMessage { + if (this.body == null) { + return ChatUpdateMessage(threadMerge = ThreadMergeChatUpdate()) + } + + return ChatUpdateMessage( + threadMerge = try { + val event = ThreadMergeEvent.ADAPTER.decode(Base64.decodeOrThrow(this.body)) + ThreadMergeChatUpdate(event.previousE164.e164ToLong()!!) + } catch (e: IOException) { + ThreadMergeChatUpdate() + } + ) +} + +private fun BackupMessageRecord.toRemoteGroupUpdate(): ChatUpdateMessage? { + val groupChange = this.messageExtras?.gv2UpdateDescription?.groupChangeUpdate + if (groupChange != null) { + return ChatUpdateMessage( + groupChange = groupChange + ) + } + + if (this.body != null) { + return try { + val decoded: ByteArray = Base64.decode(this.body) + val context = DecryptedGroupV2Context.ADAPTER.decode(decoded) + ChatUpdateMessage( + groupChange = GroupsV2UpdateMessageConverter.translateDecryptedChange(selfIds = SignalStore.account.getServiceIds(), context) + ) + } catch (e: IOException) { + null + } + } + + return null +} + +private fun CallTable.Call.toRemoteCallUpdate(db: SignalDatabase, messageRecord: BackupMessageRecord): ChatUpdateMessage? { + return when (this.type) { + CallTable.Type.GROUP_CALL -> { + val groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(messageRecord.body) + + ChatUpdateMessage( + groupCall = GroupCall( + callId = this.callId, + state = when (this.event) { + CallTable.Event.MISSED -> GroupCall.State.MISSED + CallTable.Event.ONGOING -> GroupCall.State.GENERIC + CallTable.Event.ACCEPTED -> GroupCall.State.ACCEPTED + CallTable.Event.NOT_ACCEPTED -> GroupCall.State.GENERIC + CallTable.Event.MISSED_NOTIFICATION_PROFILE -> GroupCall.State.MISSED_NOTIFICATION_PROFILE + CallTable.Event.GENERIC_GROUP_CALL -> GroupCall.State.GENERIC + CallTable.Event.JOINED -> GroupCall.State.JOINED + CallTable.Event.RINGING -> GroupCall.State.RINGING + CallTable.Event.DECLINED -> GroupCall.State.DECLINED + CallTable.Event.OUTGOING_RING -> GroupCall.State.OUTGOING_RING + CallTable.Event.DELETE -> return null + }, + ringerRecipientId = this.ringerRecipient?.toLong(), + startedCallRecipientId = ACI.parseOrNull(groupCallUpdateDetails.startedCallUuid)?.let { db.recipientTable.getByAci(it).getOrNull()?.toLong() }, + startedCallTimestamp = this.timestamp, + endedCallTimestamp = groupCallUpdateDetails.endedCallTimestamp, + read = messageRecord.read + ) + ) + } + + CallTable.Type.AUDIO_CALL, + CallTable.Type.VIDEO_CALL -> { + ChatUpdateMessage( + individualCall = IndividualCall( + callId = this.callId, + type = if (this.type == CallTable.Type.VIDEO_CALL) IndividualCall.Type.VIDEO_CALL else IndividualCall.Type.AUDIO_CALL, + direction = if (this.direction == CallTable.Direction.INCOMING) IndividualCall.Direction.INCOMING else IndividualCall.Direction.OUTGOING, + state = when (this.event) { + CallTable.Event.MISSED -> IndividualCall.State.MISSED + CallTable.Event.MISSED_NOTIFICATION_PROFILE -> IndividualCall.State.MISSED_NOTIFICATION_PROFILE + CallTable.Event.ACCEPTED -> IndividualCall.State.ACCEPTED + CallTable.Event.NOT_ACCEPTED -> IndividualCall.State.NOT_ACCEPTED + else -> IndividualCall.State.UNKNOWN_STATE + }, + startedCallTimestamp = this.timestamp, + read = messageRecord.read + ) + ) + } + + CallTable.Type.AD_HOC_CALL -> throw IllegalArgumentException("AdHoc calls are not update messages!") + } +} + +private fun BackupMessageRecord.toRemotePaymentNotificationUpdate(db: SignalDatabase): PaymentNotification { + val paymentUuid = UuidUtil.parseOrNull(this.body) + val payment = if (paymentUuid != null) { + db.paymentTable.getPayment(paymentUuid) + } else { + null + } + + return if (payment == null) { + PaymentNotification() + } else { + PaymentNotification( + amountMob = payment.amount.serializeAmountString(), + feeMob = payment.fee.serializeAmountString(), + note = payment.note.takeUnless { it.isEmpty() }, + transactionDetails = payment.toRemoteTransactionDetails() + ) + } +} + +private fun BackupMessageRecord.toRemoteSharedContacts(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.toRemoteLinkPreviews(attachments: List?): List { + if (linkPreview.isNullOrEmpty()) { + return emptyList() + } + val attachmentIdMap: Map = attachments?.associateBy { it.attachmentId } ?: emptyMap() + + try { + val previews: MutableList = LinkedList() + val jsonPreviews = JSONArray(linkPreview) + + for (i in 0 until jsonPreviews.length()) { + val preview = LinkPreview.deserialize(jsonPreviews.getJSONObject(i).toString()) + + if (preview.attachmentId != null) { + val attachment = attachmentIdMap[preview.attachmentId] + + if (attachment != null) { + previews += LinkPreview(preview.url, preview.title, preview.description, preview.date, attachment) + } else { + previews += preview + } + } else { + previews += preview + } + } + + return previews + } catch (e: JSONException) { + Log.w(TAG, "Failed to parse link preview", e) + } catch (e: IOException) { + Log.w(TAG, "Failed to parse shared contacts.", e) + } + + return emptyList() +} + +private fun LinkPreview.toRemoteLinkPreview(mediaArchiveEnabled: Boolean): org.thoughtcrime.securesms.backup.v2.proto.LinkPreview { + return org.thoughtcrime.securesms.backup.v2.proto.LinkPreview( + url = url, + title = title.nullIfEmpty(), + image = (thumbnail.orNull() as? DatabaseAttachment)?.toRemoteMessageAttachment(mediaArchiveEnabled)?.pointer, + description = description.nullIfEmpty(), + date = date + ) +} + +private fun BackupMessageRecord.toRemoteContactMessage(mediaArchiveEnabled: Boolean, reactionRecords: List?, attachments: List?): ContactMessage { + val sharedContacts = toRemoteSharedContacts(attachments) + + val contacts = sharedContacts.map { + ContactAttachment( + name = it.name.toRemote(), + avatar = (it.avatar?.attachment as? DatabaseAttachment)?.toRemoteMessageAttachment(mediaArchiveEnabled)?.pointer, + organization = it.organization, + number = it.phoneNumbers.map { phone -> + ContactAttachment.Phone( + value_ = phone.number, + type = phone.type.toRemote(), + label = phone.label + ) + }, + email = it.emails.map { email -> + ContactAttachment.Email( + value_ = email.email, + label = email.label, + type = email.type.toRemote() + ) + }, + address = it.postalAddresses.map { address -> + ContactAttachment.PostalAddress( + type = address.type.toRemote(), + 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.toRemote() + ) +} + +private fun Contact.Name.toRemote(): ContactAttachment.Name { + return ContactAttachment.Name( + givenName = givenName, + familyName = familyName, + prefix = prefix, + suffix = suffix, + middleName = middleName, + nickname = nickname + ) +} + +private fun Contact.Phone.Type.toRemote(): 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.toRemote(): 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.toRemote(): 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.toRemoteStandardMessage(db: SignalDatabase, mediaArchiveEnabled: Boolean, reactionRecords: List?, mentions: List?, attachments: List?): StandardMessage { + val text = if (body == null) { + null + } else { + Text( + body = this.body, + bodyRanges = (this.bodyRanges?.toRemoteBodyRanges() ?: emptyList()) + (mentions?.toRemoteBodyRanges(db) ?: emptyList()) + ) + } + val linkPreviews = this.toRemoteLinkPreviews(attachments) + val linkPreviewAttachments = linkPreviews.mapNotNull { it.thumbnail.orElse(null) }.toSet() + val quotedAttachments = attachments?.filter { it.quote } ?: emptyList() + val longTextAttachment = attachments?.firstOrNull { it.contentType == "text/x-signal-plain" } + val messageAttachments = attachments + ?.filterNot { it.quote } + ?.filterNot { linkPreviewAttachments.contains(it) } + ?.filterNot { it == longTextAttachment } + ?: emptyList() + return StandardMessage( + quote = this.toRemoteQuote(mediaArchiveEnabled, quotedAttachments), + text = text, + attachments = messageAttachments.toRemoteAttachments(mediaArchiveEnabled), + linkPreview = linkPreviews.map { it.toRemoteLinkPreview(mediaArchiveEnabled) }, + longText = longTextAttachment?.toRemoteFilePointer(mediaArchiveEnabled), + reactions = reactionRecords.toRemote() + ) +} + +private fun BackupMessageRecord.toRemoteQuote(mediaArchiveEnabled: Boolean, attachments: List? = null): Quote? { + if (this.quoteTargetSentTimestamp == MessageTable.QUOTE_NOT_PRESENT_ID || this.quoteAuthor <= 0) { + return null + } + + val type = QuoteModel.Type.fromCode(this.quoteType) + return Quote( + targetSentTimestamp = this.quoteTargetSentTimestamp.takeIf { !this.quoteMissing && it != MessageTable.QUOTE_TARGET_MISSING_ID }, + authorId = this.quoteAuthor, + text = this.quoteBody?.let { body -> + Text( + body = body, + bodyRanges = this.quoteBodyRanges?.toRemoteBodyRanges() ?: emptyList() + ) + }, + attachments = attachments?.toRemoteQuoteAttachments(mediaArchiveEnabled) ?: emptyList(), + type = when (type) { + QuoteModel.Type.NORMAL -> Quote.Type.NORMAL + QuoteModel.Type.GIFT_BADGE -> Quote.Type.GIFTBADGE + } + ) +} + +private fun BackupMessageRecord.toRemoteGiftBadgeUpdate(): BackupGiftBadge { + val giftBadge = try { + GiftBadge.ADAPTER.decode(Base64.decode(this.body ?: "")) + } catch (e: IOException) { + Log.w(TAG, "Failed to decode GiftBadge!") + return BackupGiftBadge() + } + + return BackupGiftBadge( + receiptCredentialPresentation = giftBadge.redemptionToken, + state = when (giftBadge.redemptionState) { + GiftBadge.RedemptionState.REDEEMED -> BackupGiftBadge.State.REDEEMED + GiftBadge.RedemptionState.FAILED -> BackupGiftBadge.State.FAILED + GiftBadge.RedemptionState.PENDING -> BackupGiftBadge.State.UNOPENED + GiftBadge.RedemptionState.STARTED -> BackupGiftBadge.State.OPENED + } + ) +} + +private fun DatabaseAttachment.toRemoteStickerMessage(mediaArchiveEnabled: Boolean, reactions: List?): StickerMessage { + val stickerLocator = this.stickerLocator!! + return StickerMessage( + sticker = Sticker( + packId = Hex.fromStringCondensed(stickerLocator.packId).toByteString(), + packKey = Hex.fromStringCondensed(stickerLocator.packKey).toByteString(), + stickerId = stickerLocator.stickerId, + emoji = stickerLocator.emoji, + data_ = this.toRemoteMessageAttachment(mediaArchiveEnabled).pointer + ), + reactions = reactions.toRemote() + ) +} + +private fun List.toRemoteQuoteAttachments(mediaArchiveEnabled: Boolean): List { + return this.map { attachment -> + Quote.QuotedAttachment( + contentType = attachment.contentType, + fileName = attachment.fileName, + thumbnail = attachment.toRemoteMessageAttachment(mediaArchiveEnabled, contentTypeOverride = "image/jpeg").takeUnless { it.pointer?.invalidAttachmentLocator != null } + ) + } +} + +private fun DatabaseAttachment.toRemoteMessageAttachment(mediaArchiveEnabled: Boolean, contentTypeOverride: String? = null): MessageAttachment { + return MessageAttachment( + pointer = this.toRemoteFilePointer(mediaArchiveEnabled, contentTypeOverride), + wasDownloaded = this.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE || this.transferState == AttachmentTable.TRANSFER_NEEDS_RESTORE, + flag = if (this.voiceNote) { + MessageAttachment.Flag.VOICE_MESSAGE + } else if (this.videoGif) { + MessageAttachment.Flag.GIF + } else if (this.borderless) { + MessageAttachment.Flag.BORDERLESS + } else { + MessageAttachment.Flag.NONE + }, + clientUuid = this.uuid?.let { UuidUtil.toByteString(uuid) } + ) +} + +private fun List.toRemoteAttachments(mediaArchiveEnabled: Boolean): List { + return this.map { attachment -> + attachment.toRemoteMessageAttachment(mediaArchiveEnabled) + } +} + +private fun PaymentTable.PaymentTransaction.toRemoteTransactionDetails(): PaymentNotification.TransactionDetails { + if (this.failureReason != null || this.state == State.FAILED) { + return PaymentNotification.TransactionDetails(failedTransaction = PaymentNotification.TransactionDetails.FailedTransaction(reason = this.failureReason.toRemote())) + } + + return PaymentNotification.TransactionDetails( + transaction = PaymentNotification.TransactionDetails.Transaction( + status = this.state.toRemote(), + timestamp = this.timestamp, + blockIndex = this.blockIndex, + blockTimestamp = this.blockTimestamp, + mobileCoinIdentification = this.paymentMetaData.mobileCoinTxoIdentification?.toRemote(), + transaction = this.transaction?.toByteString(), + receipt = this.receipt?.toByteString() + ) + ) +} + +private fun PaymentMetaData.MobileCoinTxoIdentification.toRemote(): PaymentNotification.TransactionDetails.MobileCoinTxoIdentification { + return PaymentNotification.TransactionDetails.MobileCoinTxoIdentification( + publicKey = this.publicKey, + keyImages = this.keyImages + ) +} + +private fun State.toRemote(): PaymentNotification.TransactionDetails.Transaction.Status { + return when (this) { + State.INITIAL -> PaymentNotification.TransactionDetails.Transaction.Status.INITIAL + State.SUBMITTED -> PaymentNotification.TransactionDetails.Transaction.Status.SUBMITTED + State.SUCCESSFUL -> PaymentNotification.TransactionDetails.Transaction.Status.SUCCESSFUL + State.FAILED -> throw IllegalArgumentException("state cannot be failed") + } +} + +private fun FailureReason?.toRemote(): PaymentNotification.TransactionDetails.FailedTransaction.FailureReason { + return when (this) { + FailureReason.UNKNOWN -> PaymentNotification.TransactionDetails.FailedTransaction.FailureReason.GENERIC + FailureReason.INSUFFICIENT_FUNDS -> PaymentNotification.TransactionDetails.FailedTransaction.FailureReason.INSUFFICIENT_FUNDS + FailureReason.NETWORK -> PaymentNotification.TransactionDetails.FailedTransaction.FailureReason.NETWORK + else -> PaymentNotification.TransactionDetails.FailedTransaction.FailureReason.GENERIC + } +} + +private fun List.toRemoteBodyRanges(db: SignalDatabase): List { + return this.map { + BackupBodyRange( + start = it.start, + length = it.length, + mentionAci = db.recipientTable.getRecord(it.recipientId).aci?.toByteString() + ) + } +} + +private fun ByteArray.toRemoteBodyRanges(): List { + val decoded: BodyRangeList = try { + BodyRangeList.ADAPTER.decode(this) + } catch (e: IOException) { + Log.w(TAG, "Failed to decode BodyRangeList!") + return emptyList() + } + + return decoded.ranges.map { + val mention = it.mentionUuid?.let { uuid -> UuidUtil.parseOrThrow(uuid) }?.toByteArray()?.toByteString() + val style = if (mention == null) { + it.style?.toRemote() ?: BackupBodyRange.Style.NONE + } else { + null + } + + BackupBodyRange( + start = it.start, + length = it.length, + mentionAci = mention, + style = style + ) + } +} + +private fun BodyRangeList.BodyRange.Style.toRemote(): BackupBodyRange.Style { + return when (this) { + BodyRangeList.BodyRange.Style.BOLD -> BackupBodyRange.Style.BOLD + BodyRangeList.BodyRange.Style.ITALIC -> BackupBodyRange.Style.ITALIC + BodyRangeList.BodyRange.Style.STRIKETHROUGH -> BackupBodyRange.Style.STRIKETHROUGH + BodyRangeList.BodyRange.Style.MONOSPACE -> BackupBodyRange.Style.MONOSPACE + BodyRangeList.BodyRange.Style.SPOILER -> BackupBodyRange.Style.SPOILER + } +} + +private fun List?.toRemote(): List { + return this + ?.map { + Reaction( + emoji = it.emoji, + authorId = it.author.toLong(), + sentTimestamp = it.dateSent, + sortOrder = it.dateReceived + ) + } ?: emptyList() +} + +private fun BackupMessageRecord.toRemoteSendStatus(groupReceipts: List?): List { + if (!MessageTypes.isOutgoingMessageType(this.type)) { + return emptyList() + } + + if (!groupReceipts.isNullOrEmpty()) { + return groupReceipts.toRemoteSendStatus(this, this.networkFailureRecipientIds, this.identityMismatchRecipientIds) + } + + val statusBuilder = SendStatus.Builder() + .recipientId(this.toRecipientId) + .timestamp(this.receiptTimestamp) + + when { + this.identityMismatchRecipientIds.contains(this.toRecipientId) -> { + statusBuilder.failed = SendStatus.Failed( + reason = SendStatus.Failed.FailureReason.IDENTITY_KEY_MISMATCH + ) + } + this.networkFailureRecipientIds.contains(this.toRecipientId) -> { + statusBuilder.failed = SendStatus.Failed( + reason = SendStatus.Failed.FailureReason.NETWORK + ) + } + this.viewed -> { + statusBuilder.viewed = SendStatus.Viewed( + sealedSender = this.sealedSender + ) + } + this.hasReadReceipt -> { + statusBuilder.read = SendStatus.Read( + sealedSender = this.sealedSender + ) + } + this.hasDeliveryReceipt -> { + statusBuilder.delivered = SendStatus.Delivered( + sealedSender = this.sealedSender + ) + } + this.baseType == MessageTypes.BASE_SENT_FAILED_TYPE -> { + statusBuilder.failed = SendStatus.Failed( + reason = SendStatus.Failed.FailureReason.UNKNOWN + ) + } + this.baseType == MessageTypes.BASE_SENDING_SKIPPED_TYPE -> { + statusBuilder.skipped = SendStatus.Skipped() + } + this.baseType == MessageTypes.BASE_SENT_TYPE -> { + statusBuilder.sent = SendStatus.Sent( + sealedSender = this.sealedSender + ) + } + else -> { + statusBuilder.pending = SendStatus.Pending() + } + } + + return listOf(statusBuilder.build()) +} + +private fun List.toRemoteSendStatus(messageRecord: BackupMessageRecord, networkFailureRecipientIds: Set, identityMismatchRecipientIds: Set): List { + return this.map { + val statusBuilder = SendStatus.Builder() + .recipientId(it.recipientId.toLong()) + .timestamp(it.timestamp) + + when { + identityMismatchRecipientIds.contains(it.recipientId.toLong()) -> { + statusBuilder.failed = SendStatus.Failed( + reason = SendStatus.Failed.FailureReason.IDENTITY_KEY_MISMATCH + ) + } + networkFailureRecipientIds.contains(it.recipientId.toLong()) -> { + statusBuilder.failed = SendStatus.Failed( + reason = SendStatus.Failed.FailureReason.NETWORK + ) + } + messageRecord.baseType == MessageTypes.BASE_SENT_FAILED_TYPE -> { + statusBuilder.failed = SendStatus.Failed( + reason = SendStatus.Failed.FailureReason.UNKNOWN + ) + } + it.status == GroupReceiptTable.STATUS_UNKNOWN -> { + statusBuilder.pending = SendStatus.Pending() + } + it.status == GroupReceiptTable.STATUS_UNDELIVERED -> { + statusBuilder.sent = SendStatus.Sent( + sealedSender = it.isUnidentified + ) + } + it.status == GroupReceiptTable.STATUS_DELIVERED -> { + statusBuilder.delivered = SendStatus.Delivered( + sealedSender = it.isUnidentified + ) + } + it.status == GroupReceiptTable.STATUS_READ -> { + statusBuilder.read = SendStatus.Read( + sealedSender = it.isUnidentified + ) + } + it.status == GroupReceiptTable.STATUS_VIEWED -> { + statusBuilder.viewed = SendStatus.Viewed( + sealedSender = it.isUnidentified + ) + } + it.status == GroupReceiptTable.STATUS_SKIPPED -> { + statusBuilder.skipped = SendStatus.Skipped() + } + else -> { + statusBuilder.pending = SendStatus.Pending() + } + } + + statusBuilder.build() + } +} + +private fun String?.parseNetworkFailures(): Set { + if (this.isNullOrBlank()) { + return emptySet() + } + + return try { + JsonUtils.fromJson(this, NetworkFailureSet::class.java).items.map { it.recipientId.toLong() }.toSet() + } catch (e: IOException) { + emptySet() + } +} + +private fun String?.parseIdentityMismatches(): Set { + if (this.isNullOrBlank()) { + return emptySet() + } + + return try { + JsonUtils.fromJson(this, IdentityKeyMismatchSet::class.java).items.map { it.recipientId.toLong() }.toSet() + } catch (e: IOException) { + emptySet() + } +} + +private fun ByteArray?.parseMessageExtras(): MessageExtras? { + if (this == null) { + return null + } + return try { + MessageExtras.ADAPTER.decode(this) + } catch (e: java.lang.Exception) { + null + } +} + +private fun Long.isSmsType(): Boolean { + if (MessageTypes.isSecureType(this)) { + return false + } + + if (MessageTypes.isCallLog(this)) { + return false + } + + return MessageTypes.isOutgoingMessageType(this) || MessageTypes.isInboxType(this) +} + +private fun Long.isDirectionlessType(): Boolean { + return MessageTypes.isCallLog(this) || + MessageTypes.isExpirationTimerUpdate(this) || + MessageTypes.isThreadMergeType(this) || + MessageTypes.isSessionSwitchoverType(this) || + MessageTypes.isProfileChange(this) || + MessageTypes.isJoinedType(this) || + MessageTypes.isIdentityUpdate(this) || + MessageTypes.isIdentityVerified(this) || + MessageTypes.isIdentityDefault(this) || + MessageTypes.isReleaseChannelDonationRequest(this) || + MessageTypes.isChangeNumber(this) || + MessageTypes.isEndSessionType(this) || + MessageTypes.isChatSessionRefresh(this) || + MessageTypes.isBadDecryptType(this) || + MessageTypes.isPaymentsActivated(this) || + MessageTypes.isPaymentsRequestToActivate(this) || + MessageTypes.isUnsupportedMessageType(this) || + MessageTypes.isReportedSpam(this) || + MessageTypes.isMessageRequestAccepted(this) || + MessageTypes.isBlocked(this) || + MessageTypes.isUnblocked(this) || + MessageTypes.isGroupCall(this) +} + +private fun String.e164ToLong(): Long? { + val fixed = if (this.startsWith("+")) { + this.substring(1) + } else { + this + } + + return fixed.toLongOrNull() +} + +private fun Cursor.toBackupMessageRecord(): BackupMessageRecord { + return BackupMessageRecord( + id = this.requireLong(MessageTable.ID), + dateSent = this.requireLong(MessageTable.DATE_SENT), + dateReceived = this.requireLong(MessageTable.DATE_RECEIVED), + dateServer = this.requireLong(MessageTable.DATE_SERVER), + type = this.requireLong(MessageTable.TYPE), + threadId = this.requireLong(MessageTable.THREAD_ID), + body = this.requireString(MessageTable.BODY), + bodyRanges = this.requireBlob(MessageTable.MESSAGE_RANGES), + fromRecipientId = this.requireLong(MessageTable.FROM_RECIPIENT_ID), + toRecipientId = this.requireLong(MessageTable.TO_RECIPIENT_ID), + expiresIn = this.requireLong(MessageTable.EXPIRES_IN), + expireStarted = this.requireLong(MessageTable.EXPIRE_STARTED), + 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), + quoteMissing = this.requireBoolean(MessageTable.QUOTE_MISSING), + quoteBodyRanges = this.requireBlob(MessageTable.QUOTE_BODY_RANGES), + quoteType = this.requireInt(MessageTable.QUOTE_TYPE), + originalMessageId = this.requireLongOrNull(MessageTable.ORIGINAL_MESSAGE_ID), + latestRevisionId = this.requireLongOrNull(MessageTable.LATEST_REVISION_ID), + hasDeliveryReceipt = this.requireBoolean(MessageTable.HAS_DELIVERY_RECEIPT), + viewed = this.requireBoolean(MessageTable.VIEWED_COLUMN), + hasReadReceipt = this.requireBoolean(MessageTable.HAS_READ_RECEIPT), + read = this.requireBoolean(MessageTable.READ), + receiptTimestamp = this.requireLong(MessageTable.RECEIPT_TIMESTAMP), + networkFailureRecipientIds = this.requireString(MessageTable.NETWORK_FAILURES).parseNetworkFailures(), + identityMismatchRecipientIds = this.requireString(MessageTable.MISMATCHED_IDENTITIES).parseIdentityMismatches(), + baseType = this.requireLong(COLUMN_BASE_TYPE), + messageExtras = this.requireBlob(MessageTable.MESSAGE_EXTRAS).parseMessageExtras() + ) +} + +private class BackupMessageRecord( + val id: Long, + val dateSent: Long, + val dateReceived: Long, + val dateServer: Long, + val type: Long, + val threadId: Long, + val body: String?, + val bodyRanges: ByteArray?, + val fromRecipientId: Long, + val toRecipientId: Long, + val expiresIn: Long, + val expireStarted: Long, + val remoteDeleted: Boolean, + val sealedSender: Boolean, + val linkPreview: String?, + val sharedContacts: String?, + val quoteTargetSentTimestamp: Long, + val quoteAuthor: Long, + val quoteBody: String?, + val quoteMissing: Boolean, + val quoteBodyRanges: ByteArray?, + val quoteType: Int, + val originalMessageId: Long?, + val latestRevisionId: Long?, + val hasDeliveryReceipt: Boolean, + val hasReadReceipt: Boolean, + val viewed: Boolean, + val receiptTimestamp: Long, + val read: Boolean, + val networkFailureRecipientIds: Set, + val identityMismatchRecipientIds: Set, + val baseType: Long, + val messageExtras: MessageExtras? +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ContactArchiveExportIterator.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ContactArchiveExportIterator.kt new file mode 100644 index 0000000000..051bb19e88 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/ContactArchiveExportIterator.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.exporters + +import android.database.Cursor +import okio.ByteString.Companion.toByteString +import org.signal.core.util.Base64 +import org.signal.core.util.requireBoolean +import org.signal.core.util.requireInt +import org.signal.core.util.requireLong +import org.signal.core.util.requireString +import org.thoughtcrime.securesms.backup.v2.ArchiveRecipient +import org.thoughtcrime.securesms.backup.v2.proto.Contact +import org.thoughtcrime.securesms.backup.v2.proto.Self +import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.database.RecipientTableCursorUtil +import org.thoughtcrime.securesms.recipients.Recipient +import org.whispersystems.signalservice.api.push.ServiceId +import org.whispersystems.signalservice.api.util.toByteArray +import java.io.Closeable + +/** + * Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [ArchiveRecipient]s. + * Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources. + */ +class ContactArchiveExportIterator(private val cursor: Cursor, private val selfId: Long) : Iterator, Closeable { + override fun hasNext(): Boolean { + return cursor.count > 0 && !cursor.isLast + } + + override fun next(): ArchiveRecipient { + if (!cursor.moveToNext()) { + throw NoSuchElementException() + } + + val id = cursor.requireLong(RecipientTable.ID) + if (id == selfId) { + return ArchiveRecipient( + id = id, + self = Self() + ) + } + + val aci = ServiceId.ACI.Companion.parseOrNull(cursor.requireString(RecipientTable.ACI_COLUMN)) + val pni = ServiceId.PNI.Companion.parseOrNull(cursor.requireString(RecipientTable.PNI_COLUMN)) + val e164 = cursor.requireString(RecipientTable.E164)?.e164ToLong() + + if (aci == null && pni == null && e164 == null) { + throw IllegalStateException("Should not happen! Query guards against this.") + } + + val contactBuilder = Contact.Builder() + .aci(aci?.rawUuid?.toByteArray()?.toByteString()) + .pni(pni?.rawUuid?.toByteArray()?.toByteString()) + .username(cursor.requireString(RecipientTable.USERNAME)) + .e164(cursor.requireString(RecipientTable.E164)?.e164ToLong()) + .blocked(cursor.requireBoolean(RecipientTable.BLOCKED)) + .visibility(Recipient.HiddenState.deserialize(cursor.requireInt(RecipientTable.HIDDEN)).toRemote()) + .profileKey(cursor.requireString(RecipientTable.PROFILE_KEY)?.let { Base64.decode(it) }?.toByteString()) + .profileSharing(cursor.requireBoolean(RecipientTable.PROFILE_SHARING)) + .profileGivenName(cursor.requireString(RecipientTable.PROFILE_GIVEN_NAME)) + .profileFamilyName(cursor.requireString(RecipientTable.PROFILE_FAMILY_NAME)) + .hideStory(RecipientTableCursorUtil.getExtras(cursor)?.hideStory() ?: false) + + val registeredState = RecipientTable.RegisteredState.fromId(cursor.requireInt(RecipientTable.REGISTERED)) + if (registeredState == RecipientTable.RegisteredState.REGISTERED) { + contactBuilder.registered = Contact.Registered() + } else { + contactBuilder.notRegistered = Contact.NotRegistered(unregisteredTimestamp = cursor.requireLong(RecipientTable.UNREGISTERED_TIMESTAMP)) + } + + return ArchiveRecipient( + id = id, + contact = contactBuilder.build() + ) + } + + override fun close() { + cursor.close() + } +} + +private fun Recipient.HiddenState.toRemote(): Contact.Visibility { + return when (this) { + Recipient.HiddenState.NOT_HIDDEN -> return Contact.Visibility.VISIBLE + Recipient.HiddenState.HIDDEN -> return Contact.Visibility.HIDDEN + Recipient.HiddenState.HIDDEN_MESSAGE_REQUEST -> return Contact.Visibility.HIDDEN_MESSAGE_REQUEST + } +} + +private fun String.e164ToLong(): Long? { + val fixed = if (this.startsWith("+")) { + this.substring(1) + } else { + this + } + + return fixed.toLongOrNull() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/DistributionListArchiveExportIterator.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/DistributionListArchiveExportIterator.kt new file mode 100644 index 0000000000..c33308ccad --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/DistributionListArchiveExportIterator.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.exporters + +import android.database.Cursor +import okio.ByteString.Companion.toByteString +import org.signal.core.util.requireBoolean +import org.signal.core.util.requireLong +import org.signal.core.util.requireNonNullString +import org.signal.core.util.requireObject +import org.thoughtcrime.securesms.backup.v2.ArchiveRecipient +import org.thoughtcrime.securesms.backup.v2.database.getMembersForBackup +import org.thoughtcrime.securesms.backup.v2.proto.DistributionList +import org.thoughtcrime.securesms.backup.v2.proto.DistributionListItem +import org.thoughtcrime.securesms.database.DistributionListTables +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode +import org.thoughtcrime.securesms.database.model.DistributionListRecord +import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.api.push.DistributionId +import org.whispersystems.signalservice.api.util.toByteArray +import java.io.Closeable + +class DistributionListArchiveExportIterator( + private val cursor: Cursor, + private val distributionListTables: DistributionListTables +) : Iterator, Closeable { + + override fun hasNext(): Boolean { + return cursor.count > 0 && !cursor.isLast + } + + override fun next(): ArchiveRecipient { + if (!cursor.moveToNext()) { + throw NoSuchElementException() + } + + val id: DistributionListId = DistributionListId.from(cursor.requireLong(DistributionListTables.ListTable.ID)) + val privacyMode: DistributionListPrivacyMode = cursor.requireObject(DistributionListTables.ListTable.PRIVACY_MODE, DistributionListPrivacyMode.Serializer) + val recipientId: RecipientId = RecipientId.from(cursor.requireLong(DistributionListTables.ListTable.RECIPIENT_ID)) + + val record = DistributionListRecord( + id = id, + name = cursor.requireNonNullString(DistributionListTables.ListTable.NAME), + distributionId = DistributionId.from(cursor.requireNonNullString(DistributionListTables.ListTable.DISTRIBUTION_ID)), + allowsReplies = cursor.requireBoolean(DistributionListTables.ListTable.ALLOWS_REPLIES), + rawMembers = distributionListTables.getRawMembers(id, privacyMode), + members = distributionListTables.getMembersForBackup(id), + deletedAtTimestamp = cursor.requireLong(DistributionListTables.ListTable.DELETION_TIMESTAMP), + isUnknown = cursor.requireBoolean(DistributionListTables.ListTable.IS_UNKNOWN), + privacyMode = privacyMode + ) + + val distributionListItem = if (record.deletedAtTimestamp != 0L) { + DistributionListItem( + distributionId = record.distributionId.asUuid().toByteArray().toByteString(), + deletionTimestamp = record.deletedAtTimestamp + ) + } else { + DistributionListItem( + distributionId = record.distributionId.asUuid().toByteArray().toByteString(), + distributionList = DistributionList( + name = record.name, + allowReplies = record.allowsReplies, + privacyMode = record.privacyMode.toBackupPrivacyMode(), + memberRecipientIds = record.members.map { it.toLong() } + ) + ) + } + + return ArchiveRecipient( + id = recipientId.toLong(), + distributionList = distributionListItem + ) + } + + override fun close() { + cursor.close() + } +} + +private fun DistributionListPrivacyMode.toBackupPrivacyMode(): DistributionList.PrivacyMode { + return when (this) { + DistributionListPrivacyMode.ONLY_WITH -> DistributionList.PrivacyMode.ONLY_WITH + DistributionListPrivacyMode.ALL -> DistributionList.PrivacyMode.ALL + DistributionListPrivacyMode.ALL_EXCEPT -> DistributionList.PrivacyMode.ALL_EXCEPT + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/GroupArchiveExportIterator.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/GroupArchiveExportIterator.kt new file mode 100644 index 0000000000..30d930c9f2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/exporters/GroupArchiveExportIterator.kt @@ -0,0 +1,147 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.exporters + +import android.database.Cursor +import okio.ByteString.Companion.toByteString +import org.signal.core.util.requireBlob +import org.signal.core.util.requireBoolean +import org.signal.core.util.requireInt +import org.signal.core.util.requireLong +import org.signal.core.util.requireNonNullBlob +import org.signal.storageservice.protos.groups.AccessControl +import org.signal.storageservice.protos.groups.Member +import org.signal.storageservice.protos.groups.local.DecryptedBannedMember +import org.signal.storageservice.protos.groups.local.DecryptedGroup +import org.signal.storageservice.protos.groups.local.DecryptedMember +import org.signal.storageservice.protos.groups.local.DecryptedPendingMember +import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember +import org.signal.storageservice.protos.groups.local.EnabledState +import org.thoughtcrime.securesms.backup.v2.ArchiveGroup +import org.thoughtcrime.securesms.backup.v2.ArchiveRecipient +import org.thoughtcrime.securesms.backup.v2.proto.Group +import org.thoughtcrime.securesms.database.GroupTable +import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.database.RecipientTableCursorUtil +import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor +import java.io.Closeable + +/** + * Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [ArchiveRecipient]s. + * Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources. + */ +class GroupArchiveExportIterator(private val cursor: Cursor) : Iterator, Closeable { + + override fun hasNext(): Boolean { + return cursor.count > 0 && !cursor.isLast + } + + override fun next(): ArchiveRecipient { + if (!cursor.moveToNext()) { + throw NoSuchElementException() + } + + val extras = RecipientTableCursorUtil.getExtras(cursor) + val showAsStoryState: GroupTable.ShowAsStoryState = GroupTable.ShowAsStoryState.deserialize(cursor.requireInt(GroupTable.SHOW_AS_STORY_STATE)) + + val decryptedGroup: DecryptedGroup = DecryptedGroup.ADAPTER.decode(cursor.requireBlob(GroupTable.V2_DECRYPTED_GROUP)!!) + + return ArchiveRecipient( + id = cursor.requireLong(RecipientTable.ID), + group = ArchiveGroup( + masterKey = cursor.requireNonNullBlob(GroupTable.V2_MASTER_KEY).toByteString(), + whitelisted = cursor.requireBoolean(RecipientTable.PROFILE_SHARING), + hideStory = extras?.hideStory() ?: false, + storySendMode = showAsStoryState.toRemote(), + snapshot = decryptedGroup.toRemote() + ) + ) + } + + override fun close() { + cursor.close() + } +} + +private fun GroupTable.ShowAsStoryState.toRemote(): Group.StorySendMode { + return when (this) { + GroupTable.ShowAsStoryState.ALWAYS -> Group.StorySendMode.ENABLED + GroupTable.ShowAsStoryState.NEVER -> Group.StorySendMode.DISABLED + GroupTable.ShowAsStoryState.IF_ACTIVE -> Group.StorySendMode.DEFAULT + } +} + +private fun DecryptedGroup.toRemote(): Group.GroupSnapshot? { + if (this.revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION || this.revision == GroupsV2StateProcessor.PLACEHOLDER_REVISION) { + return null + } + + return Group.GroupSnapshot( + title = Group.GroupAttributeBlob(title = this.title), + avatarUrl = this.avatar, + disappearingMessagesTimer = this.disappearingMessagesTimer?.takeIf { it.duration > 0 }?.let { Group.GroupAttributeBlob(disappearingMessagesDuration = it.duration) }, + accessControl = this.accessControl?.toRemote(), + version = this.revision, + members = this.members.map { it.toRemote() }, + membersPendingProfileKey = this.pendingMembers.map { it.toRemote() }, + membersPendingAdminApproval = this.requestingMembers.map { it.toRemote() }, + inviteLinkPassword = this.inviteLinkPassword, + description = this.description.takeUnless { it.isBlank() }?.let { Group.GroupAttributeBlob(descriptionText = it) }, + announcements_only = this.isAnnouncementGroup == EnabledState.ENABLED, + members_banned = this.bannedMembers.map { it.toRemote() } + ) +} + +private fun AccessControl.AccessRequired.toRemote(): Group.AccessControl.AccessRequired { + return when (this) { + AccessControl.AccessRequired.UNKNOWN -> Group.AccessControl.AccessRequired.UNKNOWN + AccessControl.AccessRequired.ANY -> Group.AccessControl.AccessRequired.ANY + AccessControl.AccessRequired.MEMBER -> Group.AccessControl.AccessRequired.MEMBER + AccessControl.AccessRequired.ADMINISTRATOR -> Group.AccessControl.AccessRequired.ADMINISTRATOR + AccessControl.AccessRequired.UNSATISFIABLE -> Group.AccessControl.AccessRequired.UNSATISFIABLE + } +} + +private fun AccessControl.toRemote(): Group.AccessControl { + return Group.AccessControl(members = members.toRemote(), attributes = attributes.toRemote(), addFromInviteLink = addFromInviteLink.toRemote()) +} + +private fun Member.Role.toRemote(): Group.Member.Role { + return when (this) { + Member.Role.UNKNOWN -> Group.Member.Role.UNKNOWN + Member.Role.DEFAULT -> Group.Member.Role.DEFAULT + Member.Role.ADMINISTRATOR -> Group.Member.Role.ADMINISTRATOR + } +} + +private fun DecryptedMember.toRemote(): Group.Member { + return Group.Member(userId = aciBytes, role = role.toRemote(), joinedAtVersion = joinedAtRevision) +} + +private fun DecryptedPendingMember.toRemote(): Group.MemberPendingProfileKey { + return Group.MemberPendingProfileKey( + member = Group.Member( + userId = this.serviceIdBytes, + role = this.role.toRemote() + ), + addedByUserId = this.addedByAci, + timestamp = this.timestamp + ) +} + +private fun DecryptedBannedMember.toRemote(): Group.MemberBanned { + return Group.MemberBanned( + userId = this.serviceIdBytes, + timestamp = this.timestamp + ) +} + +private fun DecryptedRequestingMember.toRemote(): Group.MemberPendingAdminApproval { + return Group.MemberPendingAdminApproval( + userId = this.aciBytes, + timestamp = this.timestamp + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataBackupProcessor.kt similarity index 65% rename from app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataProcessor.kt rename to app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataBackupProcessor.kt index 27c8ebb8ab..7f38528e1c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataBackupProcessor.kt @@ -5,6 +5,7 @@ package org.thoughtcrime.securesms.backup.v2.processor +import android.content.Context import okio.ByteString.Companion.EMPTY import okio.ByteString.Companion.toByteString import org.signal.core.util.logging.Log @@ -40,9 +41,12 @@ import org.whispersystems.signalservice.api.util.UuidUtil import org.whispersystems.signalservice.api.util.toByteArray import java.util.Currency -object AccountDataProcessor { +/** + * Handles importing/exporting [AccountData] frames for an archive. + */ +object AccountDataBackupProcessor { - private val TAG = Log.tag(AccountDataProcessor::class) + private val TAG = Log.tag(AccountDataBackupProcessor::class) fun export(db: SignalDatabase, signalStore: SignalStore, emitter: BackupFrameEmitter) { val context = AppDependencies.application @@ -68,7 +72,7 @@ object AccountDataProcessor { AccountData.UsernameLink( entropy = signalStore.accountValues.usernameLink?.entropy?.toByteString() ?: EMPTY, serverId = signalStore.accountValues.usernameLink?.serverId?.toByteArray()?.toByteString() ?: EMPTY, - color = signalStore.miscValues.usernameQrCodeColorScheme.toBackupUsernameColor() + color = signalStore.miscValues.usernameQrCodeColorScheme.toRemoteUsernameColor() ) } else { null @@ -80,7 +84,7 @@ object AccountDataProcessor { sealedSenderIndicators = TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context), linkPreviews = signalStore.settingsValues.isLinkPreviewsEnabled, notDiscoverableByPhoneNumber = signalStore.phoneNumberPrivacyValues.phoneNumberDiscoverabilityMode == PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE, - phoneNumberSharingMode = signalStore.phoneNumberPrivacyValues.phoneNumberSharingMode.toBackupPhoneNumberSharingMode(), + phoneNumberSharingMode = signalStore.phoneNumberPrivacyValues.phoneNumberSharingMode.toRemotePhoneNumberSharingMode(), preferContactAvatars = signalStore.settingsValues.isPreferSystemContactPhotos, universalExpireTimerSeconds = signalStore.settingsValues.universalExpireTimer, preferredReactionEmoji = signalStore.emojiValues.rawReactions, @@ -114,107 +118,42 @@ object AccountDataProcessor { val settings = accountData.accountSettings if (settings != null) { - TextSecurePreferences.setReadReceiptsEnabled(context, settings.readReceipts) - TextSecurePreferences.setTypingIndicatorsEnabled(context, settings.typingIndicators) - TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, settings.sealedSenderIndicators) - SignalStore.settings.isLinkPreviewsEnabled = settings.linkPreviews - SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode = if (settings.notDiscoverableByPhoneNumber) PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE else PhoneNumberDiscoverabilityMode.DISCOVERABLE - SignalStore.phoneNumberPrivacy.phoneNumberSharingMode = settings.phoneNumberSharingMode.toLocalPhoneNumberMode() - SignalStore.settings.isPreferSystemContactPhotos = settings.preferContactAvatars - SignalStore.settings.universalExpireTimer = settings.universalExpireTimerSeconds - SignalStore.emoji.reactions = settings.preferredReactionEmoji - SignalStore.inAppPayments.setDisplayBadgesOnProfile(settings.displayBadgesOnProfile) - SignalStore.settings.setKeepMutedChatsArchived(settings.keepMutedChatsArchived) - SignalStore.story.userHasBeenNotifiedAboutStories = settings.hasSetMyStoriesPrivacy - SignalStore.story.userHasViewedOnboardingStory = settings.hasViewedOnboardingStory - SignalStore.story.isFeatureDisabled = settings.storiesDisabled - SignalStore.story.userHasSeenGroupStoryEducationSheet = settings.hasSeenGroupStoryEducationSheet - SignalStore.story.viewedReceiptsEnabled = settings.storyViewReceiptsEnabled ?: settings.readReceipts + importSettings(context, settings, importState) + } - settings.customChatColors - .mapNotNull { chatColor -> - val id = ChatColors.Id.forLongValue(chatColor.id) - when { - chatColor.solid != null -> { - ChatColors.forColor(id, chatColor.solid) - } - chatColor.gradient != null -> { - ChatColors.forGradient( - id, - ChatColors.LinearGradient( - degrees = chatColor.gradient.angle.toFloat(), - colors = chatColor.gradient.colors.toIntArray(), - positions = chatColor.gradient.positions.toFloatArray() - ) - ) - } - else -> null - } - } - .forEach { chatColor -> - // We need to use the "NotSet" chatId so that this operation is treated as an insert rather than an update - val saved = SignalDatabase.chatColors.saveChatColors(chatColor.withId(ChatColors.Id.NotSet)) - importState.remoteToLocalColorId[chatColor.id.longValue] = saved.id.longValue - } + if (accountData.donationSubscriberData != null) { + if (accountData.donationSubscriberData.subscriberId.size > 0) { + val remoteSubscriberId = SubscriberId.fromBytes(accountData.donationSubscriberData.subscriberId.toByteArray()) + val localSubscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION) - if (settings.defaultChatStyle != null) { - val chatColors = settings.defaultChatStyle.toLocal(importState) - SignalStore.chatColors.chatColors = chatColors - - val wallpaperAttachmentId: AttachmentId? = settings.defaultChatStyle.wallpaperPhoto?.let { filePointer -> - filePointer.toLocalAttachment(importState)?.let { - SignalDatabase.attachments.restoreWallpaperAttachment(it) - } - } - - SignalStore.wallpaper.wallpaper = settings.defaultChatStyle.parseChatWallpaper(wallpaperAttachmentId) - } else { - SignalStore.chatColors.chatColors = null - SignalStore.wallpaper.wallpaper = null - } - - if (accountData.donationSubscriberData != null) { - if (accountData.donationSubscriberData.subscriberId.size > 0) { - val remoteSubscriberId = SubscriberId.fromBytes(accountData.donationSubscriberData.subscriberId.toByteArray()) - val localSubscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION) - - val subscriber = InAppPaymentSubscriberRecord( - remoteSubscriberId, - Currency.getInstance(accountData.donationSubscriberData.currencyCode), - InAppPaymentSubscriberRecord.Type.DONATION, - localSubscriber?.requiresCancel ?: accountData.donationSubscriberData.manuallyCancelled, - InAppPaymentsRepository.getLatestPaymentMethodType(InAppPaymentSubscriberRecord.Type.DONATION) - ) - - InAppPaymentsRepository.setSubscriber(subscriber) - } - - if (accountData.donationSubscriberData.manuallyCancelled) { - SignalStore.inAppPayments.updateLocalStateForManualCancellation(InAppPaymentSubscriberRecord.Type.DONATION) - } - } - - if (accountData.avatarUrlPath.isNotEmpty()) { - AppDependencies.jobManager.add(RetrieveProfileAvatarJob(Recipient.self().fresh(), accountData.avatarUrlPath)) - } - - if (accountData.usernameLink != null) { - SignalStore.account.usernameLink = UsernameLinkComponents( - accountData.usernameLink.entropy.toByteArray(), - UuidUtil.parseOrThrow(accountData.usernameLink.serverId.toByteArray()) + val subscriber = InAppPaymentSubscriberRecord( + remoteSubscriberId, + Currency.getInstance(accountData.donationSubscriberData.currencyCode), + InAppPaymentSubscriberRecord.Type.DONATION, + localSubscriber?.requiresCancel ?: accountData.donationSubscriberData.manuallyCancelled, + InAppPaymentsRepository.getLatestPaymentMethodType(InAppPaymentSubscriberRecord.Type.DONATION) ) - SignalStore.misc.usernameQrCodeColorScheme = accountData.usernameLink.color.toLocalUsernameColor() - } else { - SignalStore.account.usernameLink = null + + InAppPaymentsRepository.setSubscriber(subscriber) } - if (settings.preferredReactionEmoji.isNotEmpty()) { - SignalStore.emoji.reactions = settings.preferredReactionEmoji + if (accountData.donationSubscriberData.manuallyCancelled) { + SignalStore.inAppPayments.updateLocalStateForManualCancellation(InAppPaymentSubscriberRecord.Type.DONATION) } + } - if (settings.hasCompletedUsernameOnboarding) { - SignalStore.uiHints.setHasCompletedUsernameOnboarding(true) - } + if (accountData.avatarUrlPath.isNotEmpty()) { + AppDependencies.jobManager.add(RetrieveProfileAvatarJob(Recipient.self().fresh(), accountData.avatarUrlPath)) + } + + if (accountData.usernameLink != null) { + SignalStore.account.usernameLink = UsernameLinkComponents( + accountData.usernameLink.entropy.toByteArray(), + UuidUtil.parseOrThrow(accountData.usernameLink.serverId.toByteArray()) + ) + SignalStore.misc.usernameQrCodeColorScheme = accountData.usernameLink.color.toLocalUsernameColor() + } else { + SignalStore.account.usernameLink = null } SignalDatabase.runPostSuccessfulTransaction { ProfileUtil.handleSelfProfileKeyChange() } @@ -222,7 +161,76 @@ object AccountDataProcessor { Recipient.self().live().refresh() } - private fun PhoneNumberPrivacyValues.PhoneNumberSharingMode.toBackupPhoneNumberSharingMode(): AccountData.PhoneNumberSharingMode { + private fun importSettings(context: Context, settings: AccountData.AccountSettings, importState: ImportState) { + TextSecurePreferences.setReadReceiptsEnabled(context, settings.readReceipts) + TextSecurePreferences.setTypingIndicatorsEnabled(context, settings.typingIndicators) + TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, settings.sealedSenderIndicators) + SignalStore.settings.isLinkPreviewsEnabled = settings.linkPreviews + SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode = if (settings.notDiscoverableByPhoneNumber) PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE else PhoneNumberDiscoverabilityMode.DISCOVERABLE + SignalStore.phoneNumberPrivacy.phoneNumberSharingMode = settings.phoneNumberSharingMode.toLocalPhoneNumberMode() + SignalStore.settings.isPreferSystemContactPhotos = settings.preferContactAvatars + SignalStore.settings.universalExpireTimer = settings.universalExpireTimerSeconds + SignalStore.emoji.reactions = settings.preferredReactionEmoji + SignalStore.inAppPayments.setDisplayBadgesOnProfile(settings.displayBadgesOnProfile) + SignalStore.settings.setKeepMutedChatsArchived(settings.keepMutedChatsArchived) + SignalStore.story.userHasBeenNotifiedAboutStories = settings.hasSetMyStoriesPrivacy + SignalStore.story.userHasViewedOnboardingStory = settings.hasViewedOnboardingStory + SignalStore.story.isFeatureDisabled = settings.storiesDisabled + SignalStore.story.userHasSeenGroupStoryEducationSheet = settings.hasSeenGroupStoryEducationSheet + SignalStore.story.viewedReceiptsEnabled = settings.storyViewReceiptsEnabled ?: settings.readReceipts + + settings.customChatColors + .mapNotNull { chatColor -> + val id = ChatColors.Id.forLongValue(chatColor.id) + when { + chatColor.solid != null -> { + ChatColors.forColor(id, chatColor.solid) + } + chatColor.gradient != null -> { + ChatColors.forGradient( + id, + ChatColors.LinearGradient( + degrees = chatColor.gradient.angle.toFloat(), + colors = chatColor.gradient.colors.toIntArray(), + positions = chatColor.gradient.positions.toFloatArray() + ) + ) + } + else -> null + } + } + .forEach { chatColor -> + // We need to use the "NotSet" chatId so that this operation is treated as an insert rather than an update + val saved = SignalDatabase.chatColors.saveChatColors(chatColor.withId(ChatColors.Id.NotSet)) + importState.remoteToLocalColorId[chatColor.id.longValue] = saved.id.longValue + } + + if (settings.defaultChatStyle != null) { + val chatColors = settings.defaultChatStyle.toLocal(importState) + SignalStore.chatColors.chatColors = chatColors + + val wallpaperAttachmentId: AttachmentId? = settings.defaultChatStyle.wallpaperPhoto?.let { filePointer -> + filePointer.toLocalAttachment(importState)?.let { + SignalDatabase.attachments.restoreWallpaperAttachment(it) + } + } + + SignalStore.wallpaper.wallpaper = settings.defaultChatStyle.parseChatWallpaper(wallpaperAttachmentId) + } else { + SignalStore.chatColors.chatColors = null + SignalStore.wallpaper.wallpaper = null + } + + if (settings.preferredReactionEmoji.isNotEmpty()) { + SignalStore.emoji.reactions = settings.preferredReactionEmoji + } + + if (settings.hasCompletedUsernameOnboarding) { + SignalStore.uiHints.setHasCompletedUsernameOnboarding(true) + } + } + + private fun PhoneNumberPrivacyValues.PhoneNumberSharingMode.toRemotePhoneNumberSharingMode(): AccountData.PhoneNumberSharingMode { return when (this) { PhoneNumberPrivacyValues.PhoneNumberSharingMode.DEFAULT -> AccountData.PhoneNumberSharingMode.EVERYBODY PhoneNumberPrivacyValues.PhoneNumberSharingMode.EVERYBODY -> AccountData.PhoneNumberSharingMode.EVERYBODY @@ -252,7 +260,7 @@ object AccountDataProcessor { } } - private fun UsernameQrCodeColorScheme.toBackupUsernameColor(): AccountData.UsernameLink.Color { + private fun UsernameQrCodeColorScheme.toRemoteUsernameColor(): AccountData.UsernameLink.Color { return when (this) { UsernameQrCodeColorScheme.Blue -> AccountData.UsernameLink.Color.BLUE UsernameQrCodeColorScheme.White -> AccountData.UsernameLink.Color.WHITE diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AdHocCallBackupProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AdHocCallBackupProcessor.kt index 4748362407..d5f341a7c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AdHocCallBackupProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AdHocCallBackupProcessor.kt @@ -14,6 +14,9 @@ import org.thoughtcrime.securesms.backup.v2.proto.Frame import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter import org.thoughtcrime.securesms.database.SignalDatabase +/** + * Handles importing/exporting [AdHocCall] frames for an archive. + */ object AdHocCallBackupProcessor { val TAG = Log.tag(AdHocCallBackupProcessor::class.java) @@ -21,9 +24,7 @@ object AdHocCallBackupProcessor { fun export(db: SignalDatabase, emitter: BackupFrameEmitter) { db.callTable.getAdhocCallsForBackup().use { reader -> for (callLog in reader) { - if (callLog != null) { - emitter.emit(Frame(adHocCall = callLog)) - } + emitter.emit(Frame(adHocCall = callLog)) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatBackupProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatBackupProcessor.kt index b4969c3ad2..a4d77b5154 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatBackupProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatBackupProcessor.kt @@ -16,6 +16,9 @@ import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.recipients.RecipientId +/** + * Handles importing/exporting [Chat] frames for an archive. + */ object ChatBackupProcessor { val TAG = Log.tag(ChatBackupProcessor::class.java) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatItemBackupProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatItemBackupProcessor.kt index 36664b818a..1bec7dda35 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatItemBackupProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatItemBackupProcessor.kt @@ -11,17 +11,21 @@ import org.thoughtcrime.securesms.backup.v2.ImportState import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter import org.thoughtcrime.securesms.backup.v2.database.createChatItemInserter import org.thoughtcrime.securesms.backup.v2.database.getMessagesForBackup +import org.thoughtcrime.securesms.backup.v2.proto.ChatItem import org.thoughtcrime.securesms.backup.v2.proto.Frame import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter import org.thoughtcrime.securesms.database.SignalDatabase +/** + * Handles importing/exporting [ChatItem] frames for an archive. + */ object ChatItemBackupProcessor { val TAG = Log.tag(ChatItemBackupProcessor::class.java) fun export(db: SignalDatabase, exportState: ExportState, emitter: BackupFrameEmitter) { - db.messageTable.getMessagesForBackup(exportState.backupTime, exportState.mediaBackupEnabled).use { chatItems -> + db.messageTable.getMessagesForBackup(db, exportState.backupTime, exportState.mediaBackupEnabled).use { chatItems -> while (chatItems.hasNext()) { - val chatItem = chatItems.next() + val chatItem: ChatItem? = chatItems.next() if (chatItem != null) { if (exportState.threadIds.contains(chatItem.chatId)) { emitter.emit(Frame(chatItem = chatItem)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/RecipientBackupProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/RecipientBackupProcessor.kt index e599e5b27d..16f5170077 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/RecipientBackupProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/RecipientBackupProcessor.kt @@ -6,9 +6,9 @@ package org.thoughtcrime.securesms.backup.v2.processor import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.backup.v2.ArchiveRecipient import org.thoughtcrime.securesms.backup.v2.ExportState import org.thoughtcrime.securesms.backup.v2.ImportState -import org.thoughtcrime.securesms.backup.v2.database.BackupRecipient import org.thoughtcrime.securesms.backup.v2.database.getAllForBackup import org.thoughtcrime.securesms.backup.v2.database.getCallLinksForBackup import org.thoughtcrime.securesms.backup.v2.database.getContactsForBackup @@ -24,6 +24,9 @@ import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient +/** + * Handles importing/exporting [ArchiveRecipient] frames for an archive. + */ object RecipientBackupProcessor { val TAG = Log.tag(RecipientBackupProcessor::class.java) @@ -35,7 +38,7 @@ object RecipientBackupProcessor { exportState.recipientIds.add(releaseChannelId.toLong()) emitter.emit( Frame( - recipient = BackupRecipient( + recipient = ArchiveRecipient( id = releaseChannelId.toLong(), releaseNotes = ReleaseNotes() ) @@ -46,33 +49,35 @@ object RecipientBackupProcessor { } db.recipientTable.getContactsForBackup(selfId).use { reader -> - for (backupRecipient in reader) { - if (backupRecipient != null) { - exportState.recipientIds.add(backupRecipient.id) - emitter.emit(Frame(recipient = backupRecipient)) - } + for (recipient in reader) { + exportState.recipientIds.add(recipient.id) + emitter.emit(Frame(recipient = recipient)) } } db.recipientTable.getGroupsForBackup().use { reader -> - for (backupRecipient in reader) { - exportState.recipientIds.add(backupRecipient.id) - emitter.emit(Frame(recipient = backupRecipient)) + for (recipient in reader) { + exportState.recipientIds.add(recipient.id) + emitter.emit(Frame(recipient = recipient)) } } - db.distributionListTables.getAllForBackup().forEach { - exportState.recipientIds.add(it.id) - emitter.emit(Frame(recipient = it)) + db.distributionListTables.getAllForBackup().use { reader -> + for (recipient in reader) { + exportState.recipientIds.add(recipient.id) + emitter.emit(Frame(recipient = recipient)) + } } - db.callLinkTable.getCallLinksForBackup().forEach { - exportState.recipientIds.add(it.id) - emitter.emit(Frame(recipient = it)) + db.callLinkTable.getCallLinksForBackup().use { reader -> + for (recipient in reader) { + exportState.recipientIds.add(recipient.id) + emitter.emit(Frame(recipient = recipient)) + } } } - fun import(recipient: BackupRecipient, importState: ImportState) { + fun import(recipient: ArchiveRecipient, importState: ImportState) { val newId = when { recipient.contact != null -> SignalDatabase.recipients.restoreContactFromBackup(recipient.contact) recipient.group != null -> SignalDatabase.recipients.restoreGroupFromBackup(recipient.group) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/StickerBackupProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/StickerBackupProcessor.kt index 8c82aad470..ff96b5d2cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/StickerBackupProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/StickerBackupProcessor.kt @@ -19,6 +19,9 @@ import org.thoughtcrime.securesms.database.model.StickerPackRecord import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob +/** + * Handles importing/exporting [StickerPack] frames for an archive. + */ object StickerBackupProcessor { fun export(db: SignalDatabase, emitter: BackupFrameEmitter) { StickerPackRecordReader(db.stickerTable.allStickerPacks).use { reader ->