Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Backups/Archiving/Archivers/ChatItem/BackupArchiveChatItemArchiver.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import LibSignalClient

public extension BackupArchive {

    /// An identifier for a ``BackupProto_ChatItem`` backup frame.
    struct ChatItemId: BackupArchive.LoggableId, Hashable {
        let value: UInt64

        public init(backupProtoChatItem: BackupProto_ChatItem) {
            self.value = backupProtoChatItem.dateSent
        }

        public init(interaction: TSInteraction) {
            self.value = interaction.timestamp
        }

        // MARK: BackupArchive.LoggableId

        public var typeLogString: String { "BackupProto_ChatItem" }
        public var idLogString: String { "timestamp: \(value)" }
    }
}

// MARK: -

public class BackupArchiveChatItemArchiver: BackupArchiveProtoStreamWriter {
    typealias ChatItemId = BackupArchive.ChatItemId
    typealias ArchiveMultiFrameResult = BackupArchive.ArchiveMultiFrameResult<BackupArchive.InteractionUniqueId>
    typealias RestoreFrameResult = BackupArchive.RestoreFrameResult<ChatItemId>

    private typealias ArchiveFrameError = BackupArchive.ArchiveFrameError<BackupArchive.InteractionUniqueId>

    private let archivedPaymentStore: ArchivedPaymentStore
    private let attachmentsArchiver: BackupArchiveMessageAttachmentArchiver
    private let attachmentStore: AttachmentStore
    private let callRecordStore: CallRecordStore
    private let contactManager: BackupArchive.Shims.ContactManager
    private let editMessageStore: EditMessageStore
    private let groupCallRecordManager: GroupCallRecordManager
    private let groupUpdateItemBuilder: GroupUpdateItemBuilder
    private let individualCallRecordManager: IndividualCallRecordManager
    private let interactionStore: BackupArchiveInteractionStore
    private let oversizeTextArchiver: BackupArchiveInlinedOversizeTextArchiver
    private let pollArchiver: BackupArchivePollArchiver
    private let reactionStore: ReactionStore
    private let threadStore: BackupArchiveThreadStore
    private let reactionArchiver: BackupArchiveReactionArchiver
    private let pinnedMessageManager: PinnedMessageManager
    private let adminDeleteManager: AdminDeleteManager

    private lazy var contentsArchiver = BackupArchiveTSMessageContentsArchiver(
        interactionStore: interactionStore,
        archivedPaymentStore: archivedPaymentStore,
        attachmentsArchiver: attachmentsArchiver,
        attachmentStore: attachmentStore,
        oversizeTextArchiver: oversizeTextArchiver,
        reactionArchiver: reactionArchiver,
        pollArchiver: pollArchiver,
        pinnedMessageManager: pinnedMessageManager,
        adminDeleteManager: adminDeleteManager,
    )
    private lazy var incomingMessageArchiver = BackupArchiveTSIncomingMessageArchiver(
        contentsArchiver: contentsArchiver,
        editMessageStore: editMessageStore,
        interactionStore: interactionStore,
        pinnedMessageManager: pinnedMessageManager,
    )
    private lazy var outgoingMessageArchiver = BackupArchiveTSOutgoingMessageArchiver(
        contentsArchiver: contentsArchiver,
        editMessageStore: editMessageStore,
        interactionStore: interactionStore,
        pinnedMessageManager: pinnedMessageManager,
    )
    private lazy var chatUpdateMessageArchiver = BackupArchiveChatUpdateMessageArchiver(
        callRecordStore: callRecordStore,
        contactManager: contactManager,
        groupCallRecordManager: groupCallRecordManager,
        groupUpdateItemBuilder: groupUpdateItemBuilder,
        individualCallRecordManager: individualCallRecordManager,
        interactionStore: interactionStore,
    )

    init(
        archivedPaymentStore: ArchivedPaymentStore,
        attachmentsArchiver: BackupArchiveMessageAttachmentArchiver,
        attachmentStore: AttachmentStore,
        callRecordStore: CallRecordStore,
        contactManager: BackupArchive.Shims.ContactManager,
        editMessageStore: EditMessageStore,
        groupCallRecordManager: GroupCallRecordManager,
        groupUpdateItemBuilder: GroupUpdateItemBuilder,
        individualCallRecordManager: IndividualCallRecordManager,
        interactionStore: BackupArchiveInteractionStore,
        oversizeTextArchiver: BackupArchiveInlinedOversizeTextArchiver,
        pollArchiver: BackupArchivePollArchiver,
        reactionStore: ReactionStore,
        threadStore: BackupArchiveThreadStore,
        reactionArchiver: BackupArchiveReactionArchiver,
        pinnedMessageManager: PinnedMessageManager,
        adminDeleteManager: AdminDeleteManager,
    ) {
        self.archivedPaymentStore = archivedPaymentStore
        self.attachmentsArchiver = attachmentsArchiver
        self.attachmentStore = attachmentStore
        self.callRecordStore = callRecordStore
        self.contactManager = contactManager
        self.editMessageStore = editMessageStore
        self.groupCallRecordManager = groupCallRecordManager
        self.groupUpdateItemBuilder = groupUpdateItemBuilder
        self.individualCallRecordManager = individualCallRecordManager
        self.interactionStore = interactionStore
        self.oversizeTextArchiver = oversizeTextArchiver
        self.pollArchiver = pollArchiver
        self.reactionStore = reactionStore
        self.threadStore = threadStore
        self.reactionArchiver = reactionArchiver
        self.pinnedMessageManager = pinnedMessageManager
        self.adminDeleteManager = adminDeleteManager
    }

    // MARK: -

    /// Archive all ``TSInteraction``s (they map to ``BackupProto_ChatItem`` and ``BackupProto_Call``).
    ///
    /// - Returns: ``ArchiveMultiFrameResult.success`` if all frames were written without error, or either
    /// partial or complete failure otherwise.
    /// How to handle ``ArchiveMultiFrameResult.partialSuccess`` is up to the caller,
    /// but typically an error will be shown to the user, but the backup will be allowed to proceed.
    /// ``ArchiveMultiFrameResult.completeFailure``, on the other hand, will stop the entire backup,
    /// and should be used if some critical or category-wide failure occurs.
    func archiveInteractions(
        stream: BackupArchiveProtoOutputStream,
        context: BackupArchive.ChatArchivingContext,
    ) throws(CancellationError) -> ArchiveMultiFrameResult {
        var completeFailureError: BackupArchive.FatalArchivingError?
        var partialFailures = [ArchiveFrameError]()

        func archiveInteraction(
            _ interactionRecord: InteractionRecord,
            _ frameBencher: BackupArchive.Bencher.FrameBencher,
        ) -> Bool {
            return autoreleasepool { () -> Bool in
                let interaction: TSInteraction
                do {
                    interaction = try TSInteraction.fromRecord(interactionRecord)
                } catch let error {
                    partialFailures.append(.archiveFrameError(
                        .invalidInteractionDatabaseRow(error),
                        BackupArchive.InteractionUniqueId(invalidInteractionRecord: interactionRecord),
                    ))
                    return true
                }

                let result = self.archiveInteraction(
                    interaction,
                    stream: stream,
                    frameBencher: frameBencher,
                    context: context,
                )
                switch result {
                case .success:
                    return true
                case .partialSuccess(let errors):
                    partialFailures.append(contentsOf: errors)
                    return true
                case .completeFailure(let error):
                    completeFailureError = error
                    return false
                }
            }
        }

        do {
            try context.bencher.wrapEnumeration(
                { tx, block in
                    let cursor = try InteractionRecord
                        .fetchCursor(tx.database)

                    while
                        let interactionRecord = try cursor.next(),
                        try block(interactionRecord)
                    {}
                },
                tx: context.tx,
            ) { interactionRecord, frameBencher in
                try Task.checkCancellation()
                return archiveInteraction(interactionRecord, frameBencher)
            }
        } catch let error as CancellationError {
            throw error
        } catch let error {
            // Errors thrown here are from the iterator's SQL query,
            // not the individual interaction handler.
            return .completeFailure(.fatalArchiveError(.interactionIteratorError(error)))
        }

        if let completeFailureError {
            return .completeFailure(completeFailureError)
        } else if partialFailures.isEmpty {
            return .success
        } else {
            return .partialSuccess(partialFailures)
        }
    }

    private func archiveInteraction(
        _ interaction: TSInteraction,
        stream: BackupArchiveProtoOutputStream,
        frameBencher: BackupArchive.Bencher.FrameBencher,
        context: BackupArchive.ChatArchivingContext,
    ) -> ArchiveMultiFrameResult {
        var partialErrors = [ArchiveFrameError]()

        let chatId = context[interaction.uniqueThreadIdentifier]
        let threadInfo = chatId.map { context[$0] } ?? nil

        if context.gv1ThreadIds.contains(interaction.uniqueThreadIdentifier) {
            /// We are knowingly dropping GV1 data from backups, so we'll skip
            /// archiving any interactions for GV1 threads without errors.
            return .success
        }

        guard let chatId, let threadInfo else {
            partialErrors.append(.archiveFrameError(
                .referencedThreadIdMissing(interaction.uniqueThreadIdentifier),
                interaction.uniqueInteractionId,
            ))
            return .partialSuccess(partialErrors)
        }

        let archiveInteractionResult: BackupArchive.ArchiveInteractionResult<BackupArchive.InteractionArchiveDetails>
        if
            let message = interaction as? TSMessage,
            message.isGroupStoryReply
        {
            // We skip group story reply messages, as stories
            // aren't backed up so neither should their replies.
            return .success
        } else if let incomingMessage = interaction as? TSIncomingMessage {
            archiveInteractionResult = incomingMessageArchiver.archiveIncomingMessage(
                incomingMessage,
                threadInfo: threadInfo,
                context: context,
            )
        } else if let outgoingMessage = interaction as? TSOutgoingMessage {
            archiveInteractionResult = outgoingMessageArchiver.archiveOutgoingMessage(
                outgoingMessage,
                threadInfo: threadInfo,
                context: context,
            )
        } else if let individualCallInteraction = interaction as? TSCall {
            archiveInteractionResult = chatUpdateMessageArchiver.archiveIndividualCall(
                individualCallInteraction,
                threadInfo: threadInfo,
                context: context,
            )
        } else if let groupCallInteraction = interaction as? OWSGroupCallMessage {
            archiveInteractionResult = chatUpdateMessageArchiver.archiveGroupCall(
                groupCallInteraction,
                threadInfo: threadInfo,
                context: context,
            )
        } else if let errorMessage = interaction as? TSErrorMessage {
            archiveInteractionResult = chatUpdateMessageArchiver.archiveErrorMessage(
                errorMessage,
                threadInfo: threadInfo,
                context: context,
            )
        } else if let infoMessage = interaction as? TSInfoMessage {
            archiveInteractionResult = chatUpdateMessageArchiver.archiveInfoMessage(
                infoMessage,
                threadInfo: threadInfo,
                context: context,
            )
        } else {
            /// Any interactions that landed us here will be legacy messages we
            /// no longer support and which have no corresponding type in the
            /// Backup, so we'll skip them and report it as a success.
            return .success
        }

        var details: BackupArchive.InteractionArchiveDetails
        switch archiveInteractionResult {
        case .success(let deets):
            details = deets
        case .partialFailure(let deets, let errors):
            details = deets
            partialErrors.append(contentsOf: errors)
        case .skippableInteraction:
            // Skip! Say it succeeded so we ignore it.
            return .success
        case .messageFailure(let errors):
            partialErrors.append(contentsOf: errors)
            return .partialSuccess(partialErrors)
        case .completeFailure(let error):
            return .completeFailure(error)
        }

        // We may skip archiving messages based on their expiration
        // (disappearing message) details.
        if
            context.includedContentFilter.shouldSkipMessageBasedOnExpiration(
                expireStartDate: details.expireStartDate,
                expiresInMs: details.expiresInMs,
                currentTimestamp: context.startDate.ows_millisecondsSince1970,
            )
        {
            // Skip, but treat as a success.
            return .success
        }

        // A bug on iOS allowed us to create edits of voice notes that contained
        // text as well, which are not allowed in a Backup. Sanitize before
        // writing that disallowed content to the stream.
        sanitizeVoiceNotesWithText(details: &details)

        let error = Self.writeFrameToStream(
            stream,
            objectId: interaction.uniqueInteractionId,
            frameBencher: frameBencher,
        ) {
            let chatItem = buildChatItem(
                fromDetails: details,
                chatId: chatId,
            )

            var frame = BackupProto_Frame()
            frame.item = .chatItem(chatItem)
            return frame
        }

        if let error {
            partialErrors.append(error)
            return .partialSuccess(partialErrors)
        } else if partialErrors.isEmpty {
            return .success
        } else {
            return .partialSuccess(partialErrors)
        }
    }

    /// Strips the "voice message" flag from the attachments of all revisions
    /// of the given message, if any of those revisions include both a voice
    /// message and text.
    ///
    /// This works around an issue in which iOS allowed editing of voice
    /// messages such that they could get body text added, by converting those
    /// messages to "text messages with a non-voice-message audio attachment".
    private func sanitizeVoiceNotesWithText(
        details: inout BackupArchive.InteractionArchiveDetails,
    ) {
        let anyRevisionContainsVoiceNoteAndText = details.anyRevisionContainsChatItemType { chatItemType -> Bool in
            switch chatItemType {
            case .standardMessage(let standardMessageProto):
                let hasText = standardMessageProto.hasText
                let hasVoiceNote = standardMessageProto.attachments.contains {
                    $0.flag == .voiceMessage
                }

                return hasText && hasVoiceNote
            default:
                return false
            }
        }

        guard anyRevisionContainsVoiceNoteAndText else { return }

        details.mutateChatItemTypes { _chatItemType -> BackupArchive.InteractionArchiveDetails.ChatItemType in
            switch _chatItemType {
            case .standardMessage(var standardMessageProto):
                standardMessageProto.attachments = standardMessageProto.attachments.map { attachment in
                    if attachment.flag == .voiceMessage {
                        var _attachment = attachment
                        _attachment.flag = .none
                        return _attachment
                    }

                    return attachment
                }

                return .standardMessage(standardMessageProto)
            default:
                return _chatItemType
            }
        }
    }

    private func buildChatItem(
        fromDetails details: BackupArchive.InteractionArchiveDetails,
        chatId: BackupArchive.ChatId,
    ) -> BackupProto_ChatItem {
        var chatItem = BackupProto_ChatItem()
        chatItem.chatID = chatId.value
        chatItem.authorID = details.author.value
        chatItem.dateSent = details.dateCreated
        if let expiresInMs = details.expiresInMs, expiresInMs > 0 {
            if let expireStartDate = details.expireStartDate {
                chatItem.expireStartDate = expireStartDate
            }
            chatItem.expiresInMs = expiresInMs
        }
        chatItem.sms = details.isSmsPreviouslyRestoredFromBackup
        chatItem.item = details.chatItemType
        chatItem.directionalDetails = details.directionalDetails
        chatItem.revisions = details.pastRevisions.map { pastRevisionDetails in
            /// Recursively map our past revision details to `ChatItem`s of
            /// their own. (Their `pastRevisions` will all be empty.)
            return buildChatItem(
                fromDetails: pastRevisionDetails,
                chatId: chatId,
            )
        }

        if let pinMessageDetails = details.pinMessageDetails {
            var pinDetails = BackupProto_ChatItem.PinDetails()
            pinDetails.pinnedAtTimestamp = pinMessageDetails.pinnedAtTimestamp
            let expiryDetails: BackupProto_ChatItem.PinDetails.OneOf_PinExpiry
            if let expiry = pinMessageDetails.expiresAtTimestamp {
                expiryDetails = .pinExpiresAtTimestamp(expiry)
            } else {
                expiryDetails = .pinNeverExpires(true)
            }
            pinDetails.pinExpiry = expiryDetails
            chatItem.pinDetails = pinDetails
        }

        return chatItem
    }

    // MARK: -

    /// Restore a single ``BackupProto_ChatItem`` frame.
    ///
    /// - Returns: ``RestoreFrameResult.success`` if all frames were read without error.
    /// How to handle ``RestoreFrameResult.failure`` is up to the caller,
    /// but typically an error will be shown to the user, but the restore will be allowed to proceed.
    func restore(
        _ chatItem: BackupProto_ChatItem,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> RestoreFrameResult {
        func restoreFrameError(
            _ error: BackupArchive.RestoreFrameError<BackupArchive.ChatItemId>.ErrorType,
            line: UInt = #line,
        ) -> RestoreFrameResult {
            return .failure([.restoreFrameError(error, chatItem.id, line: line)])
        }

        switch context.recipientContext[chatItem.authorRecipientId] {
        case .releaseNotesChannel:
            // The release notes channel doesn't exist yet, so for the time
            // being we'll drop all chat items destined for it.
            //
            // TODO: [Backups] Implement restoring chat items into the release notes channel chat.
            return .success
        default:
            break
        }

        guard let thread = context.chatContext[chatItem.typedChatId] else {
            return restoreFrameError(.invalidProtoData(.chatIdNotFound(chatItem.typedChatId)))
        }

        let restoreInteractionResult: BackupArchive.RestoreInteractionResult<Void>
        switch chatItem.directionalDetails {
        case nil:
            return .unrecognizedEnum(BackupArchive.UnrecognizedEnumError(
                enumType: BackupProto_ChatItem.OneOf_DirectionalDetails.self,
            ))
        case .incoming:
            restoreInteractionResult = incomingMessageArchiver.restoreIncomingChatItem(
                chatItem,
                chatThread: thread,
                context: context,
            )
        case .outgoing:
            restoreInteractionResult = outgoingMessageArchiver.restoreChatItem(
                chatItem,
                chatThread: thread,
                context: context,
            )
        case .directionless:
            switch chatItem.item {
            case nil:
                return .unrecognizedEnum(BackupArchive.UnrecognizedEnumError(
                    enumType: BackupProto_ChatItem.OneOf_Item.self,
                ))
            case
                .standardMessage,
                .contactMessage,
                .giftBadge,
                .viewOnceMessage,
                .paymentNotification,
                .remoteDeletedMessage,
                .stickerMessage,
                .directStoryReplyMessage,
                .poll,
                .adminDeletedMessage:
                return restoreFrameError(.invalidProtoData(.directionlessChatItemNotUpdateMessage))
            case .updateMessage:
                restoreInteractionResult = chatUpdateMessageArchiver.restoreChatItem(
                    chatItem,
                    chatThread: thread,
                    context: context,
                )
            }
        }

        switch restoreInteractionResult {
        case .success:
            return .success
        case .unrecognizedEnum(let error):
            return .unrecognizedEnum(error)
        case .partialRestore(_, let errors):
            return .partialRestore(errors)
        case .messageFailure(let errors):
            return .failure(errors)
        }
    }
}