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

import Foundation
import LibSignalClient

class BackupArchiveMessageAttachmentArchiver: BackupArchiveProtoStreamWriter {
    private typealias ArchiveFrameError = BackupArchive.ArchiveFrameError<BackupArchive.InteractionUniqueId>

    private let attachmentManager: AttachmentManager
    private let attachmentStore: AttachmentStore
    private let backupAttachmentDownloadScheduler: BackupAttachmentDownloadScheduler

    init(
        attachmentManager: AttachmentManager,
        attachmentStore: AttachmentStore,
        backupAttachmentDownloadScheduler: BackupAttachmentDownloadScheduler,
    ) {
        self.attachmentManager = attachmentManager
        self.attachmentStore = attachmentStore
        self.backupAttachmentDownloadScheduler = backupAttachmentDownloadScheduler
    }

    // MARK: - Archiving

    func archiveBodyAttachments(
        referencedAttachments: [ReferencedAttachment],
        context: BackupArchive.ArchivingContext,
    ) -> [BackupProto_MessageAttachment] {
        var pointers = [BackupProto_MessageAttachment]()

        for referencedAttachment in referencedAttachments {
            let pointerProto = referencedAttachment.asBackupFilePointer(
                context: context,
            )

            var attachmentProto = BackupProto_MessageAttachment()
            attachmentProto.pointer = pointerProto
            attachmentProto.flag = referencedAttachment.reference.renderingFlag.asBackupProtoFlag
            attachmentProto.wasDownloaded = referencedAttachment.attachment.asStream() != nil

            switch referencedAttachment.reference.owner {
            case .message(.bodyAttachment(let metadata)):
                metadata.idInOwner.map { attachmentProto.clientUuid = $0.data }
            default:
                // Technically this is an error, but ignoring right now doesn't hurt.
                continue
            }

            pointers.append(attachmentProto)
        }

        return pointers
    }

    func archiveOversizeTextAttachment(
        referencedAttachment: ReferencedAttachment,
        context: BackupArchive.ArchivingContext,
    ) -> BackupProto_FilePointer {
        return referencedAttachment.asBackupFilePointer(context: context)
    }

    func archiveLinkPreviewAttachment(
        referencedAttachment: ReferencedAttachment,
        context: BackupArchive.ArchivingContext,
    ) -> BackupProto_FilePointer {
        return referencedAttachment.asBackupFilePointer(context: context)
    }

    func archiveQuotedReplyThumbnailAttachment(
        referencedAttachment: ReferencedAttachment,
        context: BackupArchive.ArchivingContext,
    ) -> BackupProto_MessageAttachment {
        let pointerProto = referencedAttachment.asBackupFilePointer(context: context)

        var attachmentProto = BackupProto_MessageAttachment()
        attachmentProto.pointer = pointerProto
        attachmentProto.flag = referencedAttachment.reference.renderingFlag.asBackupProtoFlag
        attachmentProto.wasDownloaded = referencedAttachment.attachment.asStream() != nil
        // NOTE: clientUuid is unecessary for quoted reply attachments.

        return attachmentProto
    }

    func archiveContactAvatar(
        referencedAttachment: ReferencedAttachment,
        context: BackupArchive.ArchivingContext,
    ) -> BackupProto_FilePointer {
        return referencedAttachment.asBackupFilePointer(context: context)
    }

    func archiveStickerAttachment(
        referencedAttachment: ReferencedAttachment,
        context: BackupArchive.ArchivingContext,
    ) -> BackupProto_FilePointer {
        return referencedAttachment.asBackupFilePointer(context: context)
    }

    // MARK: Restoring -

    func restoreBodyAttachments(
        _ attachments: [BackupProto_MessageAttachment],
        chatItemId: BackupArchive.ChatItemId,
        messageRowId: Int64,
        message: TSMessage,
        thread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> BackupArchive.RestoreInteractionResult<Void> {
        var uuidErrors = [BackupArchive.RestoreFrameError<BackupArchive.ChatItemId>.ErrorType.InvalidProtoDataError]()
        let withUnwrappedUUIDs: [(BackupProto_MessageAttachment, UUID?)]
        withUnwrappedUUIDs = attachments.map { attachment in
            if attachment.hasClientUuid {
                guard let uuid = UUID(data: attachment.clientUuid) else {
                    uuidErrors.append(.invalidAttachmentClientUUID)
                    return (attachment, nil)
                }
                return (attachment, uuid)
            } else {
                return (attachment, nil)
            }
        }
        guard uuidErrors.isEmpty else {
            return .messageFailure(uuidErrors.map {
                .restoreFrameError(.invalidProtoData($0), chatItemId)
            })
        }

        let ownedAttachments = withUnwrappedUUIDs.enumerated().map { idx, withUnwrappedUUID in
            let (attachment, clientUUID) = withUnwrappedUUID
            return OwnedAttachmentBackupPointerProto(
                proto: attachment.pointer,
                renderingFlag: attachment.flag.asAttachmentFlag,
                clientUUID: clientUUID,
                owner: .messageBodyAttachment(.init(
                    messageRowId: messageRowId,
                    receivedAtTimestamp: message.receivedAtTimestamp,
                    threadRowId: thread.threadRowId,
                    isViewOnce: message.isViewOnceMessage,
                    isPastEditRevision: message.isPastEditRevision(),
                    orderInMessage: UInt32(idx),
                )),
            )
        }

        return restoreAttachments(
            ownedAttachments,
            chatItemId: chatItemId,
            context: context,
        )
    }

    func restoreOversizeTextAttachment(
        _ attachment: BackupProto_FilePointer,
        chatItemId: BackupArchive.ChatItemId,
        messageRowId: Int64,
        message: TSMessage,
        thread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> BackupArchive.RestoreInteractionResult<Void> {
        let ownedAttachment = OwnedAttachmentBackupPointerProto(
            proto: attachment,
            // Oversize text attachments have no flags
            renderingFlag: .default,
            // ClientUUID is only for body and quoted reply attachments.
            clientUUID: nil,
            owner: .messageOversizeText(.init(
                messageRowId: messageRowId,
                receivedAtTimestamp: message.receivedAtTimestamp,
                threadRowId: thread.threadRowId,
                isPastEditRevision: message.isPastEditRevision(),
            )),
        )

        return restoreAttachments(
            [ownedAttachment],
            chatItemId: chatItemId,
            context: context,
        )
    }

    func restoreQuotedReplyThumbnailAttachment(
        _ attachment: BackupProto_MessageAttachment,
        chatItemId: BackupArchive.ChatItemId,
        messageRowId: Int64,
        message: TSMessage,
        thread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> BackupArchive.RestoreInteractionResult<Void> {
        let clientUUID: UUID?
        if attachment.hasClientUuid {
            guard let uuid = UUID(data: attachment.clientUuid) else {
                return .messageFailure([.restoreFrameError(
                    .invalidProtoData(.invalidAttachmentClientUUID),
                    chatItemId,
                )])
            }
            clientUUID = uuid
        } else {
            clientUUID = nil
        }

        let ownedAttachment = OwnedAttachmentBackupPointerProto(
            proto: attachment.pointer,
            renderingFlag: attachment.flag.asAttachmentFlag,
            clientUUID: clientUUID,
            owner: .quotedReplyAttachment(.init(
                messageRowId: messageRowId,
                receivedAtTimestamp: message.receivedAtTimestamp,
                threadRowId: thread.threadRowId,
                isPastEditRevision: message.isPastEditRevision(),
            )),
        )

        return restoreAttachments(
            [ownedAttachment],
            chatItemId: chatItemId,
            context: context,
        )
    }

    func restoreLinkPreviewAttachment(
        _ attachment: BackupProto_FilePointer,
        chatItemId: BackupArchive.ChatItemId,
        messageRowId: Int64,
        message: TSMessage,
        thread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> BackupArchive.RestoreInteractionResult<Void> {
        let ownedAttachment = OwnedAttachmentBackupPointerProto(
            proto: attachment,
            // Link previews have no flags
            renderingFlag: .default,
            // ClientUUID is only for body and quoted reply attachments.
            clientUUID: nil,
            owner: .messageLinkPreview(.init(
                messageRowId: messageRowId,
                receivedAtTimestamp: message.receivedAtTimestamp,
                threadRowId: thread.threadRowId,
                isPastEditRevision: message.isPastEditRevision(),
            )),
        )

        return restoreAttachments(
            [ownedAttachment],
            chatItemId: chatItemId,
            context: context,
        )
    }

    func restoreContactAvatarAttachment(
        _ attachment: BackupProto_FilePointer,
        chatItemId: BackupArchive.ChatItemId,
        messageRowId: Int64,
        message: TSMessage,
        thread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> BackupArchive.RestoreInteractionResult<Void> {
        let ownedAttachment = OwnedAttachmentBackupPointerProto(
            proto: attachment,
            // Contact share avatars have no flags
            renderingFlag: .default,
            // ClientUUID is only for body and quoted reply attachments.
            clientUUID: nil,
            owner: .messageContactAvatar(.init(
                messageRowId: messageRowId,
                receivedAtTimestamp: message.receivedAtTimestamp,
                threadRowId: thread.threadRowId,
                isPastEditRevision: message.isPastEditRevision(),
            )),
        )

        return restoreAttachments(
            [ownedAttachment],
            chatItemId: chatItemId,
            context: context,
        )
    }

    func restoreStickerAttachment(
        _ attachment: BackupProto_FilePointer,
        stickerPackId: Data,
        stickerId: UInt32,
        chatItemId: BackupArchive.ChatItemId,
        messageRowId: Int64,
        message: TSMessage,
        thread: BackupArchive.ChatThread,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> BackupArchive.RestoreInteractionResult<Void> {
        let ownedAttachment = OwnedAttachmentBackupPointerProto(
            proto: attachment,
            // Sticker messages have no flags
            renderingFlag: .default,
            // ClientUUID is only for body and quoted reply attachments.
            clientUUID: nil,
            owner: .messageSticker(.init(
                messageRowId: messageRowId,
                receivedAtTimestamp: message.receivedAtTimestamp,
                threadRowId: thread.threadRowId,
                isPastEditRevision: message.isPastEditRevision(),
                stickerPackId: stickerPackId,
                stickerId: stickerId,
            )),
        )

        return restoreAttachments(
            [ownedAttachment],
            chatItemId: chatItemId,
            context: context,
        )
    }

    private func restoreAttachments(
        _ attachments: [OwnedAttachmentBackupPointerProto],
        chatItemId: BackupArchive.ChatItemId,
        context: BackupArchive.ChatItemRestoringContext,
    ) -> BackupArchive.RestoreInteractionResult<Void> {
        // Whether we're free or paid this should be set when we restored the account data frame.
        guard let uploadEra = context.chatContext.customChatColorContext.accountDataContext.uploadEra else {
            return .messageFailure([.restoreFrameError(.invalidProtoData(.accountDataNotFound), chatItemId)])
        }

        for attachment in attachments {
            attachmentManager.createAttachmentPointer(
                from: attachment,
                uploadEra: uploadEra,
                attachmentByteCounter: context.attachmentByteCounter,
                tx: context.tx,
            )
        }

        let results: [ReferencedAttachment]
        if
            attachments.count == 1,
            let attachment = attachments.first,
            case let .messageBodyAttachment(messageRowId) = attachment.owner.id,
            attachment.proto.contentType == MimeType.textXSignalPlain.rawValue
        {
            // A single body attachment thats of type text gets swizzled to a long
            // text attachment.
            results = attachmentStore.fetchReferencedAttachments(
                for: .messageOversizeText(messageRowId: messageRowId),
                tx: context.tx,
            )
        } else {
            results = attachmentStore.fetchReferencedAttachments(owners: attachments.map(\.owner.id), tx: context.tx)
        }
        if results.isEmpty, !attachments.isEmpty {
            return .messageFailure([.restoreFrameError(
                .failedToCreateAttachment,
                chatItemId,
            )])
        }

        guard let backupPlan = context.accountDataContext.backupPlan else {
            return .messageFailure([.restoreFrameError(
                .invalidProtoData(.accountDataNotFound),
                chatItemId,
            )])
        }

        for referencedAttachment in results {
            backupAttachmentDownloadScheduler.enqueueFromBackupIfNeeded(
                referencedAttachment,
                restoreStartTimestampMs: context.startDate.ows_millisecondsSince1970,
                backupPlan: backupPlan,
                remoteConfig: context.remoteConfig,
                isPrimaryDevice: context.isPrimaryDevice,
                tx: context.tx,
            )
        }

        return .success(())
    }
}

// MARK: -

extension BackupProto_MessageAttachment.Flag {

    var asAttachmentFlag: AttachmentReference.RenderingFlag {
        switch self {
        case .none, .UNRECOGNIZED:
            return .default
        case .voiceMessage:
            return .voiceMessage
        case .borderless:
            return .borderless
        case .gif:
            return .shouldLoop
        }
    }
}

extension AttachmentReference.RenderingFlag {

    fileprivate var asBackupProtoFlag: BackupProto_MessageAttachment.Flag {
        switch self {
        case .default:
            return .none
        case .voiceMessage:
            return .voiceMessage
        case .borderless:
            return .borderless
        case .shouldLoop:
            return .gif
        }
    }
}

extension ReferencedAttachment {

    func asBackupFilePointer(
        context: BackupArchive.ArchivingContext,
    ) -> BackupProto_FilePointer {
        var proto = BackupProto_FilePointer()
        proto.contentType = attachment.mimeType
        if let sourceFilename = reference.sourceFilename {
            proto.fileName = sourceFilename
        }
        if let caption = reference.legacyMessageCaption {
            proto.caption = caption
        }
        if let blurHash = attachment.blurHash {
            proto.blurHash = blurHash
        }

        switch attachment.streamInfo?.contentType {
        case
            .animatedImage(let pixelSize),
            .image(let pixelSize),
            .video(_, let pixelSize, _):
            proto.width = UInt32(pixelSize.width)
            proto.height = UInt32(pixelSize.height)
        case .audio, .file, .invalid:
            break
        case nil:
            if let mediaSize = reference.sourceMediaSizePixels {
                proto.width = UInt32(mediaSize.width)
                proto.height = UInt32(mediaSize.height)
            }
        }

        proto.locatorInfo = self.asBackupFilePointerLocatorInfo(context: context)

        if
            let mediaTierInfo = attachment.mediaTierInfo,
            mediaTierInfo.isUploaded(currentUploadEra: context.currentUploadEra)
        {
            let estimatedMediaTierSize = Cryptography.estimatedMediaTierCDNSize(
                unencryptedSize: UInt64(safeCast: mediaTierInfo.unencryptedByteCount),
            ) ?? UInt64(UInt32.max)

            context.attachmentByteCounter.addToByteCount(
                attachmentID: attachment.id,
                byteCount: estimatedMediaTierSize,
            )
        }

        // Notes:
        // * incrementalMac and incrementalMacChunkSize unsupported by iOS
        return proto
    }

    private func asBackupFilePointerLocatorInfo(
        context: BackupArchive.ArchivingContext,
    ) -> BackupProto_FilePointer.LocatorInfo {
        var locatorInfo = BackupProto_FilePointer.LocatorInfo()

        // Include the transit tier cdn info as a fallback, but only
        // if the encryption key matches.
        // When we need this: we create a backup and don't get to copy to
        // media tier before the device dies; on restore the restoring device
        // can't find the attachment on the media tier but its on the transit
        // tier if its been less than 30 days.
        // When encryption keys don't match: if we reupload (e.g. forward) an
        // attachment after 3+ days, we rotate to a new encryption key; transit
        // tier info uses this new random key and can't be the fallback here.
        var transitTierInfoToExport: Attachment.TransitTierInfo?
        if
            let latestTransitTierInfo = attachment.latestTransitTierInfo,
            latestTransitTierInfo.encryptionKey == attachment.encryptionKey
        {
            transitTierInfoToExport = latestTransitTierInfo
        } else if let originalTransitTierInfo = attachment.originalTransitTierInfo {
            transitTierInfoToExport = originalTransitTierInfo
        } else {
            transitTierInfoToExport = nil
        }

        if
            let transitTierUploadDate = transitTierInfoToExport.map({ Date(millisecondsSince1970: $0.uploadTimestamp) }),
            transitTierUploadDate.addingTimeInterval(context.remoteConfig.messageQueueTime) < context.startDate
        {
            // This transit tier info is expired, so there's no point in
            // exporting it.
            transitTierInfoToExport = nil
        }

        if let transitTierInfoToExport {
            locatorInfo.transitCdnKey = transitTierInfoToExport.cdnKey
            locatorInfo.transitCdnNumber = transitTierInfoToExport.cdnNumber
            locatorInfo.transitTierUploadTimestamp = transitTierInfoToExport.uploadTimestamp
        }

        if let mediaTierInfo = attachment.mediaTierInfo {
            locatorInfo.key = attachment.encryptionKey
            locatorInfo.size = mediaTierInfo.unencryptedByteCount
            locatorInfo.integrityCheck = .plaintextHash(mediaTierInfo.sha256ContentHash)

            if let cdnNumber = mediaTierInfo.cdnNumber {
                locatorInfo.mediaTierCdnNumber = cdnNumber
            }
        } else if let streamInfo = attachment.streamInfo {
            locatorInfo.key = attachment.encryptionKey
            locatorInfo.size = streamInfo.unencryptedByteCount
            locatorInfo.integrityCheck = .plaintextHash(streamInfo.sha256ContentHash)
        } else if let transitTierInfoToExport {
            locatorInfo.key = attachment.encryptionKey
            locatorInfo.size = transitTierInfoToExport.unencryptedByteCount ?? 0

            // At the time of writing, TransitTierInfo.integrityCheck prefers
            // the encrypted digest even if both are present. (See comment in
            // that type's init.) So, manually check for the plaintext hash
            // here, falling back to the encrypted digest otherwise.
            if let plaintextHash = attachment.sha256ContentHash {
                locatorInfo.integrityCheck = .plaintextHash(plaintextHash)
            } else {
                switch transitTierInfoToExport.integrityCheck {
                case .sha256ContentHash(let plaintextHash):
                    owsFailDebug("Missing Attachment plaintext hash, but had one on TransitTierInfo!")
                    locatorInfo.integrityCheck = .plaintextHash(plaintextHash)
                case .digestSHA256Ciphertext(let encryptedDigest):
                    locatorInfo.integrityCheck = .encryptedDigest(encryptedDigest)
                }
            }
        } else {
            // This attachment isn't uploaded anywhere and this device won't be
            // able to upload it in the future. So, we leave a bunch of fields
            // unset so any restoring device knows it's unavailable.
        }

        return locatorInfo
    }
}