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

import LibSignalClient

class BackupArchiveTSIncomingMessageArchiver {
    private typealias ArchiveFrameError = BackupArchive.ArchiveFrameError<BackupArchive.InteractionUniqueId>
    private typealias RestoreFrameError = BackupArchive.RestoreFrameError<BackupArchive.ChatItemId>

    private let contentsArchiver: BackupArchiveTSMessageContentsArchiver
    private let editHistoryArchiver: BackupArchiveTSMessageEditHistoryArchiver<TSIncomingMessage>
    private let editMessageStore: EditMessageStore
    private let interactionStore: BackupArchiveInteractionStore
    private let pinnedMessageManager: PinnedMessageManager

    init(
        contentsArchiver: BackupArchiveTSMessageContentsArchiver,
        editMessageStore: EditMessageStore,
        interactionStore: BackupArchiveInteractionStore,
        pinnedMessageManager: PinnedMessageManager,
    ) {
        self.contentsArchiver = contentsArchiver
        self.editHistoryArchiver = BackupArchiveTSMessageEditHistoryArchiver(
            editMessageStore: editMessageStore,
        )
        self.editMessageStore = editMessageStore
        self.interactionStore = interactionStore
        self.pinnedMessageManager = pinnedMessageManager
    }

    // MARK: - Archiving

    func archiveIncomingMessage(
        _ incomingMessage: TSIncomingMessage,
        threadInfo: BackupArchive.ChatArchivingContext.CachedThreadInfo,
        context: BackupArchive.ChatArchivingContext,
    ) -> BackupArchive.ArchiveInteractionResult<Details> {
        var partialErrors = [ArchiveFrameError]()

        let incomingMessageDetails: Details
        switch editHistoryArchiver.archiveMessageAndEditHistory(
            incomingMessage,
            threadInfo: threadInfo,
            context: context,
            builder: self,
        ).bubbleUp(Details.self, partialErrors: &partialErrors) {
        case .continue(let _incomingMessageDetails):
            incomingMessageDetails = _incomingMessageDetails
        case .bubbleUpError(let errorResult):
            return errorResult
        }

        if partialErrors.isEmpty {
            return .success(incomingMessageDetails)
        } else {
            return .partialFailure(incomingMessageDetails, partialErrors)
        }
    }

    // MARK: - Restoring

    func restoreIncomingChatItem(
        _ topLevelChatItem: BackupProto_ChatItem,
        chatThread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> BackupArchive.RestoreInteractionResult<Void> {
        var partialErrors = [RestoreFrameError]()

        switch editHistoryArchiver
            .restoreMessageAndEditHistory(
                topLevelChatItem,
                chatThread: chatThread,
                context: context,
                builder: self,
            )
            .bubbleUp(Void.self, partialErrors: &partialErrors)
        {
        case .continue:
            break
        case .bubbleUpError(let error):
            return error
        }

        if partialErrors.isEmpty {
            return .success(())
        } else {
            return .partialRestore((), partialErrors)
        }
    }
}

// MARK: - BackupArchive.TSMessageEditHistory.Builder

extension BackupArchiveTSIncomingMessageArchiver: BackupArchive.TSMessageEditHistory.Builder {
    typealias MessageType = TSIncomingMessage

    // MARK: - Archiving

    func buildMessageArchiveDetails(
        message incomingMessage: MessageType,
        editRecord: EditRecord?,
        threadInfo: BackupArchive.ChatArchivingContext.CachedThreadInfo,
        context: BackupArchive.ChatArchivingContext,
    ) -> BackupArchive.ArchiveInteractionResult<Details> {
        var partialErrors = [ArchiveFrameError]()

        guard
            let authorAddress = BackupArchive.ContactAddress(
                // Incoming message authors are always ACIs, not PNIs
                aci: Aci.parseFrom(aciString: incomingMessage.authorUUID),
                e164: E164(incomingMessage.authorPhoneNumber),
            )
        else {
            // This is an invalid message.
            return .messageFailure([.archiveFrameError(.invalidIncomingMessageAuthor, incomingMessage.uniqueInteractionId)])
        }
        guard let author = context.recipientContext[authorAddress.asArchivingAddress()] else {
            return .messageFailure([.archiveFrameError(
                .referencedRecipientIdMissing(authorAddress.asArchivingAddress()),
                incomingMessage.uniqueInteractionId,
            )])
        }

        let chatItemType: BackupArchiveTSMessageContentsArchiver.ChatItemType
        switch contentsArchiver.archiveMessageContents(
            incomingMessage,
            context: context,
        ).bubbleUp(Details.self, partialErrors: &partialErrors) {
        case .continue(let _chatItemType):
            chatItemType = _chatItemType
        case .bubbleUpError(let errorResult):
            return errorResult
        }

        func buildSwizzledOutgoingNoteToSelfMessage() -> (BackupProto_ChatItem.OneOf_DirectionalDetails, Details.AuthorAddress) {
            var outgoingDetails = BackupProto_ChatItem.OutgoingMessageDetails()
            outgoingDetails.dateReceived = incomingMessage.receivedAtTimestamp
            var sendStatus = BackupProto_SendStatus()
            sendStatus.recipientID = context.recipientContext.localRecipientId.value
            sendStatus.timestamp = incomingMessage.receivedAtTimestamp
            var viewedStatus = BackupProto_SendStatus.Viewed()
            viewedStatus.sealedSender = incomingMessage.wasReceivedByUD
            sendStatus.deliveryStatus = .viewed(viewedStatus)
            outgoingDetails.sendStatus = [sendStatus]
            return (.outgoing(outgoingDetails), .localUser)
        }

        let detailsAuthor: Details.AuthorAddress
        let directionalDetails: BackupProto_ChatItem.OneOf_DirectionalDetails

        if author == context.recipientContext.localRecipientId {
            // Incoming messages from self are not allowed in backups
            // but have been observed in real-world databases.
            // If we encounter these, fudge them a bit and pretend
            // they were outgoing messages.
            partialErrors.append(.archiveFrameError(
                .incomingMessageFromSelf,
                incomingMessage.uniqueInteractionId,
            ))
            let pair = buildSwizzledOutgoingNoteToSelfMessage()
            directionalDetails = pair.0
            detailsAuthor = pair.1
        } else if case .noteToSelfThread = threadInfo {
            // Incoming messages in the note to self are not allowed in backups because:
            // 1. Messages not from self are not allowed in note to self
            // 2. Incoming messages from self are not allowed in general
            // So we swizzle this into an outgoing message from self.
            partialErrors.append(.archiveFrameError(
                .nonSelfAuthorInNoteToSelf,
                incomingMessage.uniqueInteractionId,
            ))
            let pair = buildSwizzledOutgoingNoteToSelfMessage()
            directionalDetails = pair.0
            detailsAuthor = pair.1
        } else {
            let incomingMessageDetails: BackupProto_ChatItem.IncomingMessageDetails = buildIncomingMessageDetails(
                incomingMessage,
                editRecord: editRecord,
            )
            directionalDetails = .incoming(incomingMessageDetails)
            detailsAuthor = .contact(authorAddress)
        }

        let expireStartDate: UInt64?
        if incomingMessage.expireStartedAt > 0 {
            expireStartDate = incomingMessage.expireStartedAt
        } else {
            expireStartDate = nil
        }

        guard let interactionRowId = incomingMessage.sqliteRowId else {
            return .completeFailure(.fatalArchiveError(
                .fetchedInteractionMissingRowId,
            ))
        }

        let pinMessageDetails = pinnedMessageManager.pinMessageDetails(
            interactionId: interactionRowId,
            tx: context.tx,
        )

        let detailsResult = Details.validateAndBuild(
            interactionUniqueId: incomingMessage.uniqueInteractionId,
            author: detailsAuthor,
            directionalDetails: directionalDetails,
            dateCreated: incomingMessage.timestamp,
            expireStartDate: expireStartDate,
            expiresInMs: UInt64(incomingMessage.expiresInSeconds) * 1000,
            isSealedSender: incomingMessage.wasReceivedByUD.negated,
            chatItemType: chatItemType,
            isSmsPreviouslyRestoredFromBackup: incomingMessage.isSmsMessageRestoredFromBackup,
            threadInfo: threadInfo,
            pinMessageDetails: pinMessageDetails,
            context: context.recipientContext,
        )

        let details: Details
        switch detailsResult.bubbleUp(Details.self, partialErrors: &partialErrors) {
        case .continue(let _details):
            details = _details
        case .bubbleUpError(let error):
            return error
        }

        if partialErrors.isEmpty {
            return .success(details)
        } else {
            return .partialFailure(details, partialErrors)
        }
    }

    private func buildIncomingMessageDetails(
        _ incomingMessage: TSIncomingMessage,
        editRecord: EditRecord?,
    ) -> BackupProto_ChatItem.IncomingMessageDetails {
        var incomingDetails = BackupProto_ChatItem.IncomingMessageDetails()
        incomingDetails.dateReceived = incomingMessage.receivedAtTimestamp
        if let dateServerSent = incomingMessage.serverTimestamp?.uint64Value {
            incomingDetails.dateServerSent = dateServerSent
        }
        incomingDetails.sealedSender = incomingMessage.wasReceivedByUD
        incomingDetails.read = {
            switch incomingMessage.editState {
            case .none, .latestRevisionRead, .latestRevisionUnread:
                // If the latest revision, we can trust the interaction's read
                // state.
                return incomingMessage.wasRead
            case .pastRevision:
                // All past-revision interactions are read; defer to the
                // editRecord instead.
                guard let editRecord else {
                    owsFailDebug("Unexpectedly missing editRecord for past revision!")
                    return true
                }

                return editRecord.read
            }
        }()

        return incomingDetails
    }

    // MARK: - Restoring

    func restoreMessage(
        _ chatItem: BackupProto_ChatItem,
        revisionType: BackupArchive.TSMessageEditHistory.RevisionType<MessageType>,
        chatThread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> BackupArchive.RestoreInteractionResult<MessageType> {
        guard let chatItemItem = chatItem.item else {
            return .unrecognizedEnum(BackupArchive.UnrecognizedEnumError(
                enumType: BackupProto_ChatItem.OneOf_Item.self,
            ))
        }

        let incomingDetails: BackupProto_ChatItem.IncomingMessageDetails
        switch chatItem.directionalDetails {
        case .incoming(let _incomingDetails):
            incomingDetails = _incomingDetails
        case .outgoing, .directionless:
            return .messageFailure([.restoreFrameError(
                .invalidProtoData(.revisionOfIncomingMessageMissingIncomingDetails),
                chatItem.id,
            )])
        case nil:
            return .unrecognizedEnum(BackupArchive.UnrecognizedEnumError(
                enumType: BackupProto_ChatItem.OneOf_DirectionalDetails.self,
            ))
        }

        let authorAci: Aci?
        let authorE164: E164?
        switch context.recipientContext[chatItem.authorRecipientId] {
        case .contact(let address):
            // See NormalizedDatabaseRecordAddress for more details.
            authorAci = address.aci
            authorE164 = authorAci == nil ? address.e164 : nil
            if authorAci == nil, authorE164 == nil {
                // Don't accept pni-only addresses. An incoming
                // message can only come from an aci, or if its
                // a legacy message, possibly from an e164.
                fallthrough
            }
        default:
            return .messageFailure([.restoreFrameError(
                .invalidProtoData(.incomingMessageNotFromAciOrE164),
                chatItem.id,
            )])
        }

        let expiresInSeconds: UInt32
        if chatItem.hasExpiresInMs {
            guard let _expiresInSeconds: UInt32 = .msToSecs(chatItem.expiresInMs) else {
                return .messageFailure([.restoreFrameError(
                    .invalidProtoData(.expirationTimerOverflowedLocalType),
                    chatItem.id,
                )])
            }
            expiresInSeconds = _expiresInSeconds
        } else {
            // 0 == no expiration
            expiresInSeconds = 0
        }
        let expireStartedAt: UInt64
        if chatItem.hasExpireStartDate {
            expireStartedAt = chatItem.expireStartDate
        } else if
            expiresInSeconds > 0,
            incomingDetails.read
        {
            // If marked as read but the chat timer hasn't started,
            // thats a bug on the export side but we can recover
            // from it now by starting the timer now.
            expireStartedAt = context.startDate.ows_millisecondsSince1970
        } else {
            // 0 = hasn't started expiring.
            expireStartedAt = 0
        }

        let editState: TSEditState
        let wasReadForInteraction: Bool
        switch revisionType {
        case .latestRevision(hasPastRevisions: false):
            editState = .none
            wasReadForInteraction = incomingDetails.read
        case .latestRevision(hasPastRevisions: true):
            editState = incomingDetails.read ? .latestRevisionRead : .latestRevisionUnread
            wasReadForInteraction = incomingDetails.read
        case .pastRevision:
            editState = .pastRevision
            // Past revisions always have their interaction "read", and track
            // read state instead via an EditRecord.
            wasReadForInteraction = true
        }

        var partialErrors = [RestoreFrameError]()

        let contents: BackupArchive.RestoredMessageContents
        switch contentsArchiver
            .restoreContents(
                chatItemItem,
                chatItemId: chatItem.id,
                chatThread: chatThread,
                context: context,
            )
            .bubbleUp(MessageType.self, partialErrors: &partialErrors)
        {
        case .continue(let component):
            contents = component
        case .bubbleUpError(let error):
            return error
        }

        let incomingMessage: TSIncomingMessage = {
            /// A "base" message builder, onto which we attach the data we
            /// unwrap from `contents`.
            let messageBuilder = TSIncomingMessageBuilder(
                thread: chatThread.tsThread,
                timestamp: chatItem.dateSent,
                receivedAtTimestamp: incomingDetails.dateReceived,
                authorAci: authorAci,
                authorE164: authorE164,
                messageBody: nil,
                editState: editState,
                expiresInSeconds: expiresInSeconds,
                // Backed up messages don't set the chat timer; version is irrelevant.
                expireTimerVersion: nil,
                expireStartedAt: expireStartedAt,
                read: wasReadForInteraction,
                serverTimestamp: incomingDetails.dateServerSent,
                serverDeliveryTimestamp: 0,
                serverGuid: nil,
                wasReceivedByUD: incomingDetails.sealedSender,
                isSmsMessageRestoredFromBackup: chatItem.sms,
                isViewOnceMessage: false,
                isViewOnceComplete: false,
                wasRemotelyDeleted: false,
                storyAuthorAci: nil,
                storyTimestamp: nil,
                storyReactionEmoji: nil,
                quotedMessage: nil,
                contactShare: nil,
                linkPreview: nil,
                messageSticker: nil,
                giftBadge: nil,
                paymentNotification: nil,
                isPoll: false,
            )

            switch contents {
            case .archivedPayment(let archivedPayment):
                return OWSIncomingArchivedPaymentMessage(
                    incomingMessageWith: messageBuilder,
                    amount: archivedPayment.amount,
                    fee: archivedPayment.fee,
                    note: archivedPayment.note,
                )
            case .remoteDeleteTombstone:
                messageBuilder.wasRemotelyDeleted = true
            case .text(let text):
                messageBuilder.setMessageBody(text.body)
                messageBuilder.quotedMessage = text.quotedMessage
                messageBuilder.linkPreview = text.linkPreview
            case .contactShare(let contactShare):
                messageBuilder.contactShare = contactShare.contact
            case .stickerMessage(let stickerMessage):
                messageBuilder.messageSticker = stickerMessage.sticker
            case .giftBadge(let giftBadge):
                messageBuilder.giftBadge = giftBadge.giftBadge
            case .viewOnceMessage(let viewOnceMessage):
                messageBuilder.isViewOnceMessage = true
                switch viewOnceMessage.state {
                case .unviewed:
                    messageBuilder.isViewOnceComplete = false
                case .complete:
                    messageBuilder.isViewOnceComplete = true
                }
            case .storyReply(let storyReply):
                switch storyReply.replyType {
                case .textReply(let textReply):
                    messageBuilder.setMessageBody(textReply.body)
                case .emoji(let emoji):
                    messageBuilder.storyReactionEmoji = emoji
                }
                // Peers can't reply to their own stories; if a 1:1 story reply is incoming
                // that means the author of the story being replied to was the local user.
                messageBuilder.storyAuthorAci = AciObjC(context.recipientContext.localIdentifiers.aci)
            case .poll(let poll):
                messageBuilder.isPoll = true
                messageBuilder.setMessageBody(poll.question)
            case .adminDeleteTombstone:
                messageBuilder.wasRemotelyDeleted = true
            }

            return messageBuilder.build()
        }()

        do {
            try interactionStore.insert(
                incomingMessage,
                in: chatThread,
                chatId: chatItem.typedChatId,
                senderAci: authorAci,
                wasRead: wasReadForInteraction,
                context: context,
            )
        } catch let error {
            return .messageFailure(partialErrors + [.restoreFrameError(.databaseInsertionFailed(error), chatItem.id)])
        }

        if authorAci == nil {
            context.recipientContext.setHasIncomingMessagesMissingAci(recipientId: chatItem.authorRecipientId)
        }

        switch contentsArchiver
            .restoreDownstreamObjects(
                message: incomingMessage,
                thread: chatThread,
                chatItemId: chatItem.id,
                pinDetails: chatItem.hasPinDetails ? chatItem.pinDetails : nil,
                restoredContents: contents,
                context: context,
            )
            .bubbleUp(TSIncomingMessage.self, partialErrors: &partialErrors)
        {
        case .continue:
            break
        case .bubbleUpError(let error):
            return error
        }

        do {
            let editRecord: EditRecord?
            switch revisionType {
            case .latestRevision:
                editRecord = nil
            case .pastRevision(let latestRevisionMessage):
                // Past-revision interactions always have `read: true`, so for
                // the EditRecord refer directly to the proto-being-restored.
                editRecord = EditRecord(
                    latestRevisionId: latestRevisionMessage.sqliteRowId!,
                    pastRevisionId: incomingMessage.sqliteRowId!,
                    read: incomingDetails.read,
                )
            }

            if let editRecord {
                try editMessageStore.insert(editRecord, tx: context.tx)
            }
        } catch {
            return .partialRestore(
                incomingMessage,
                [.restoreFrameError(
                    .databaseInsertionFailed(error),
                    chatItem.id,
                )] + partialErrors,
            )
        }

        if partialErrors.isEmpty {
            return .success(incomingMessage)
        } else {
            return .partialRestore(incomingMessage, partialErrors)
        }
    }
}