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

import CoreImage
import Foundation
import SDWebImageWebPCoder
public import UIKit

public class AttachmentThumbnailServiceImpl: AttachmentThumbnailService {

    private let remoteConfigProvider: RemoteConfigProvider

    public init(remoteConfigProvider: RemoteConfigProvider) {
        self.remoteConfigProvider = remoteConfigProvider
    }

    private let taskQueue = ConcurrentTaskQueue(concurrentLimit: 1)

    public func thumbnailImage(
        for attachmentStream: AttachmentStream,
        quality: AttachmentThumbnailQuality,
    ) async -> UIImage? {
        // Check if we even need to generate anything before enqueing.
        switch thumbnailSpec(for: attachmentStream, quality: quality) {
        case .cannotGenerate:
            return nil
        case .originalFits(let image):
            return image
        case .requiresGeneration:
            return try? await taskQueue.run { [weak self] in
                return self?.thumbnailImageSync(for: attachmentStream, quality: quality)
            }
        }
    }

    public func thumbnailImageSync(
        for attachmentStream: AttachmentStream,
        quality: AttachmentThumbnailQuality,
    ) -> UIImage? {
        switch thumbnailSpec(for: attachmentStream, quality: quality) {
        case .cannotGenerate:
            return nil
        case .originalFits(let image):
            return image
        case .requiresGeneration:
            break
        }

        if let cached = cachedThumbnail(for: attachmentStream, quality: quality) {
            return cached
        }

        let thumbnailImage = try? UIImage
            .fromEncryptedFile(
                at: AttachmentStream.absoluteAttachmentFileURL(
                    relativeFilePath: attachmentStream.localRelativeFilePath,
                ),
                attachmentKey: AttachmentKey(combinedKey: attachmentStream.attachment.encryptionKey),
                plaintextLength: attachmentStream.unencryptedByteCount,
                mimeType: attachmentStream.mimeType,
            )
            .resized(maxDimensionPoints: quality.thumbnailDimensionPoints())

        guard let thumbnailImage else {
            owsFailDebug("Unable to generate thumbnail")
            return nil
        }
        cacheThumbnail(thumbnailImage, for: attachmentStream, quality: quality)
        return thumbnailImage
    }

    public func backupThumbnailData(image: UIImage) throws -> Data {
        let maxFileSize: UInt32 = remoteConfigProvider.currentConfig().backupMaxThumbnailFileSize

        // libwebp uses `maxFileSize` as a best effort guide, so start with 80% this value to
        // allow for some overruns in size during encoding
        let initialMaxFileSize = UInt32(CGFloat(maxFileSize) * 0.8)

        return try backupThumbnailData(
            image: image,
            targetMaxFileSize: initialMaxFileSize,
            targetMaxPixelSize: AttachmentThumbnailQuality.backupThumbnailDimensionPixels,
        )
    }

    private func backupThumbnailData(
        image: UIImage,
        targetMaxFileSize: UInt32,
        targetMaxPixelSize: CGFloat,
    ) throws -> Data {
        let targetSize: CGSize
        let imagePixelSize = image.pixelSize
        if imagePixelSize.largerAxis > targetMaxPixelSize {
            let scaleRatio = targetMaxPixelSize / imagePixelSize.largerAxis
            targetSize = CGSize(
                width: imagePixelSize.width * scaleRatio,
                height: imagePixelSize.height * scaleRatio,
            )
        } else {
            targetSize = imagePixelSize
        }

        guard
            let data = SDImageWebPCoder.shared.encodedData(
                with: image,
                format: .webP,
                options: [
                    .encodeMaxFileSize: targetMaxFileSize,
                    .encodeMaxPixelSize: targetSize,
                ],
            )
        else {
            throw OWSAssertionError("Unable to generate webp")
        }
        if data.count > AttachmentThumbnailQuality.backupThumbnailMaxSizeBytes {
            let nextTargetMaxPixelSize = targetMaxPixelSize * 0.75
            let nextTargetMaxFileSize = UInt32(Double(targetMaxFileSize) * 0.75)
            if
                nextTargetMaxFileSize < AttachmentThumbnailQuality.backupThumbnailMinSizeBytes,
                nextTargetMaxPixelSize < AttachmentThumbnailQuality.backupThumbnailMinPixelSize
            {
                throw OWSAssertionError("Generated thumbnail too large")
            } else if nextTargetMaxFileSize < AttachmentThumbnailQuality.backupThumbnailMinSizeBytes {
                // If the next decrement of the file size is below the min size,
                // start to scale down the pixel size of the image
                return try backupThumbnailData(
                    image: image,
                    targetMaxFileSize: targetMaxFileSize,
                    targetMaxPixelSize: nextTargetMaxPixelSize,
                )
            } else {
                return try backupThumbnailData(
                    image: image,
                    targetMaxFileSize: nextTargetMaxFileSize,
                    targetMaxPixelSize: targetMaxPixelSize,
                )
            }
        }
        return data
    }

    private enum ThumbnailSpec {
        case cannotGenerate
        case originalFits(UIImage)
        case requiresGeneration
    }

    private func thumbnailSpec(
        for attachmentStream: AttachmentStream,
        quality: AttachmentThumbnailQuality,
    ) -> ThumbnailSpec {
        switch attachmentStream.contentType {
        case .invalid, .file, .audio:
            return .cannotGenerate

        case .video:
            // We always provide the still frame regardless of size,
            // since video is already size-limited.
            if let image = try? attachmentStream.decryptedImage() {
                return .originalFits(image)
            } else {
                return .cannotGenerate
            }

        case .image(let pixelSize), .animatedImage(let pixelSize):
            let pointSize = AttachmentThumbnailQuality.pointSize(pixelSize: pixelSize)
            let targetSize = quality.thumbnailDimensionPoints()

            if pointSize.width < targetSize, pointSize.height < targetSize {
                if let image = try? attachmentStream.decryptedImage() {
                    return .originalFits(image)
                } else {
                    // If we can't read the original image, we can't generate.
                    return .cannotGenerate
                }
            } else {
                return .requiresGeneration
            }
        }
    }

    private func cachedThumbnail(
        for attachmentStream: AttachmentStream,
        quality: AttachmentThumbnailQuality,
    ) -> UIImage? {
        let cacheUrl = AttachmentThumbnailQuality.thumbnailCacheFileUrl(
            for: attachmentStream,
            at: quality,
        )
        if OWSFileSystem.fileOrFolderExists(url: cacheUrl) {
            do {
                return try UIImage.fromEncryptedFile(
                    at: cacheUrl,
                    attachmentKey: AttachmentKey(combinedKey: attachmentStream.attachment.encryptionKey),
                    // thumbnails have no special padding;
                    // therefore no plaintext length needed.
                    plaintextLength: nil,
                    mimeType: MimeTypeUtil.thumbnailMimetype(
                        fullsizeMimeType: attachmentStream.mimeType,
                        quality: quality,
                    ),
                )
            } catch {
                Logger.error("Failed to read cached attachment.")
                // Delete the cached file, and just recompute.
                try? OWSFileSystem.deleteFile(url: cacheUrl)
                return nil
            }
        }
        return nil
    }

    private func cacheThumbnail(
        _ thumbnail: UIImage,
        for attachmentStream: AttachmentStream,
        quality: AttachmentThumbnailQuality,
    ) {
        let cacheUrl = AttachmentThumbnailQuality.thumbnailCacheFileUrl(
            for: attachmentStream,
            at: quality,
        )
        do {
            try OWSFileSystem.deleteFileIfExists(url: cacheUrl)
            let thumbnailMimeType = MimeTypeUtil.thumbnailMimetype(
                fullsizeMimeType: attachmentStream.mimeType,
                quality: quality,
            )

            let imageData: Data?
            switch thumbnailMimeType {
            case MimeType.imagePng.rawValue:
                imageData = thumbnail.pngData()
            case MimeType.imageJpeg.rawValue:
                imageData = thumbnail.jpegData(compressionQuality: 0.85)
            case MimeType.imageWebp.rawValue where quality == .backupThumbnail:
                imageData = try? backupThumbnailData(image: thumbnail)
            default:
                owsFailDebug("Unknown thumbnail mime type!")
                return
            }

            guard let imageData else {
                owsFailDebug("Unable to generate thumbnail data")
                return
            }

            // Encrypt _without_ custom padding; we never send these files
            // and just use them locally, so no need for custom padding
            // that later requires out-of-band plaintext length tracking
            // so we can trim the custom padding at read time.
            let (encryptedImageData, _) = try Cryptography.encrypt(
                imageData,
                attachmentKey: AttachmentKey(combinedKey: attachmentStream.attachment.encryptionKey),
            )

            try encryptedImageData.write(to: cacheUrl, options: .atomic)
        } catch {
            owsFailDebug("Failed to cache thumbnail image")
        }
    }
}