Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Messages/Attachments/V2/Downloads/AttachmentDownloadManager.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation

public enum AttachmentDownloads {

    /// There's two sources of truth for calculating download progress,
    /// OWSProgress handles partial progress and AttachmentDownloadManager.downloadAttachment
    /// handles errors and success. To avoid double counting progress updates
    /// we keep a set of completed downloads and ignore progress
    /// updates if the download has already completed. However, we still send the
    /// attachmentDownloadProgressNotification in this case.
    public static let attachmentDownloadProgressNotification = Notification.Name("AttachmentDownloadProgressNotification")

    /// Key for a CGFloat progress value from 0 to 1
    public static var attachmentDownloadProgressKey: String { "attachmentDownloadProgressKey" }

    /// Label for ``AttachmentDownloadManager`` download progress source.
    public static var downloadProgressLabel: String { "download" }

    /// Key for a ``Attachment.IdType`` value.
    public static var attachmentDownloadAttachmentIDKey: String { "attachmentDownloadAttachmentIDKey" }

    public struct DownloadMetadata {
        public let mimeType: String
        public let cdnNumber: UInt32
        public let encryptionKey: Data
        public let source: Source

        public enum Source {
            case transitTier(cdnKey: String, integrityCheck: AttachmentIntegrityCheck, plaintextLength: UInt32?)
            case mediaTierFullsize(
                cdnReadCredential: MediaTierReadCredential,
                outerEncryptionMetadata: MediaTierEncryptionMetadata,
                integrityCheck: AttachmentIntegrityCheck,
                plaintextLength: UInt32?,
            )
            case mediaTierThumbnail(
                cdnReadCredential: MediaTierReadCredential,
                outerEncyptionMetadata: MediaTierEncryptionMetadata,
                innerEncryptionMetadata: MediaTierEncryptionMetadata,
            )
            case linkNSyncBackup(cdnKey: String)

            var asQueuedDownloadSource: QueuedAttachmentDownloadRecord.SourceType {
                switch self {
                case .transitTier:
                    return .transitTier
                case .mediaTierFullsize:
                    return .mediaTierFullsize
                case .mediaTierThumbnail:
                    return .mediaTierThumbnail
                case .linkNSyncBackup:
                    return .transitTier
                }
            }
        }

        public var integrityCheck: AttachmentIntegrityCheck? {
            switch source {
            case .transitTier(_, let integrityCheck, _):
                return integrityCheck
            case .mediaTierFullsize(_, _, let integrityCheck, _):
                return integrityCheck
            case .mediaTierThumbnail:
                // No integrityCheck for media tier thumbnails; they come from the local user.
                return nil
            case .linkNSyncBackup:
                // No integrityCheck for link'n'sync backups; they come from the local user.
                return nil
            }
        }

        public var plaintextLength: UInt32? {
            switch source {
            case .transitTier(_, _, let plaintextLength):
                return plaintextLength
            case .mediaTierFullsize(_, _, _, let plaintextLength):
                return plaintextLength
            case .mediaTierThumbnail:
                // Thumbnails don't include a length out of band.
                // They may be padded with 0s to hit bucket sizes, but
                // we take advantage of the fact that jpegs support
                // no-op trailing 0s (and all thumbnails are jpegs).
                return nil
            case .linkNSyncBackup:
                // Link'n'sync backups don't include a length out
                // of band because gzip ignores padding.
                return nil
            }
        }

        public init(
            mimeType: String,
            cdnNumber: UInt32,
            encryptionKey: Data,
            source: Source,
        ) {
            self.mimeType = mimeType
            self.cdnNumber = cdnNumber
            self.encryptionKey = encryptionKey
            self.source = source
        }
    }

    public enum Error: Swift.Error {
        case expiredCredentials
        case blockedByActiveCall
        case blockedByPendingMessageRequest
        case blockedByAutoDownloadSettings
        case blockedByNetworkState
    }

    public struct CdnInfo {
        public let contentLength: UInt
        public let lastModified: Date

        public init(contentLength: UInt, lastModified: Date) {
            self.contentLength = contentLength
            self.lastModified = lastModified
        }

        init(_ headers: HttpHeaders) throws {
            guard
                let contentLengthRaw = headers["Content-Length"],
                let contentLengthBytes = UInt(contentLengthRaw)
            else {
                throw OWSGenericError("Missing content length from cdn")
            }
            self.contentLength = contentLengthBytes

            guard
                let lastModifiedRaw = headers["Last-Modified"],
                let lastModifiedDate = Date.ows_parseFromHTTPDateString(lastModifiedRaw)
            else {
                throw OWSGenericError("Missing last modified from cdn")
            }
            self.lastModified = lastModifiedDate
        }
    }
}

public protocol AttachmentDownloadManager {

    func backupCdnInfo(
        metadata: BackupReadCredential,
    ) async throws -> BackupCdnInfo

    func downloadBackup(
        metadata: BackupReadCredential,
        progress: OWSProgressSink?,
    ) async throws -> URL

    func downloadTransientAttachment(
        metadata: AttachmentDownloads.DownloadMetadata,
        progress: OWSProgressSink?,
    ) async throws -> URL

    func enqueueDownloadOfAttachmentsForMessage(
        _ message: TSMessage,
        priority: AttachmentDownloadPriority,
        tx: DBWriteTransaction,
    )

    func enqueueDownloadOfAttachmentsForStoryMessage(
        _ message: StoryMessage,
        priority: AttachmentDownloadPriority,
        tx: DBWriteTransaction,
    )

    func enqueueDownloadOfAttachment(
        id: Attachment.IDType,
        priority: AttachmentDownloadPriority,
        source: QueuedAttachmentDownloadRecord.SourceType,
        tx: DBWriteTransaction,
    )

    /// There's two sources of truth for calculating download progress,
    /// OWSProgress handles partial progress and this method returning (or throwing)
    /// handles errors and success. To avoid double counting progress updates
    /// we keep a set of completed downloads and ignore progress
    /// updates if the download has already completed. However, we still send the
    /// attachmentDownloadProgressNotification in this case.
    func downloadAttachment(
        id: Attachment.IDType,
        priority: AttachmentDownloadPriority,
        source: QueuedAttachmentDownloadRecord.SourceType,
        progress: OWSProgressSink?,
    ) async throws

    /// Starts downloading off the persisted queue, if there's anything to download
    /// and if not already downloading the max number of parallel downloads at once.
    func beginDownloadingIfNecessary()

    func cancelDownload(for attachmentId: Attachment.IDType, tx: DBWriteTransaction)
}

extension AttachmentDownloadManager {

    public func enqueueDownloadOfAttachmentsForMessage(
        _ message: TSMessage,
        tx: DBWriteTransaction,
    ) {
        enqueueDownloadOfAttachmentsForMessage(message, priority: .default, tx: tx)
    }

    public func enqueueDownloadOfAttachmentsForStoryMessage(
        _ message: StoryMessage,
        tx: DBWriteTransaction,
    ) {
        enqueueDownloadOfAttachmentsForStoryMessage(message, priority: .default, tx: tx)
    }

}