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

import Foundation
public import SignalServiceKit
import UniformTypeIdentifiers

// MARK: - ItemProviderError

private enum ItemProviderError: Error {
    case unsupportedMedia
    case cannotLoadUIImageObject
    case loadUIImageObjectFailed
    case cannotLoadURLObject
    case loadURLObjectFailed
    case cannotLoadStringObject
    case loadStringObjectFailed
    case loadDataRepresentationFailed
    case loadInPlaceFileRepresentationFailed
    case fileUrlWasBplist
}

// MARK: - TypedItem

public enum TypedItem {
    case text(MessageText)
    case contact(Data)
    case other(PreviewableAttachment)

    public struct MessageText {
        public let filteredValue: FilteredString
        public init?(filteredValue: FilteredString) {
            guard filteredValue.rawValue.utf8.count <= OWSMediaUtils.kMaxOversizeTextMessageSendSizeBytes else {
                return nil
            }
            self.filteredValue = filteredValue
        }
    }

    public var isVisualMedia: Bool {
        switch self {
        case .text, .contact: false
        case .other(let attachment): attachment.isVisualMedia
        }
    }

    public var isStoriesCompatible: Bool {
        switch self {
        case .text: true
        case .contact: false
        case .other(let attachment): attachment.isVisualMedia
        }
    }
}

// MARK: - TypedItemProvider

public struct TypedItemProvider {

    // MARK: ItemType

    public enum ItemType {
        case movie
        case image
        case webUrl
        case fileUrl
        case contact
        // Apple docs and runtime checks seem to imply "public.plain-text"
        // should be able to be loaded from an NSItemProvider as
        // "public.text", but in practice it fails with:
        // "A string could not be instantiated because of an unknown error."
        case plainText
        case text
        case pdf
        case pkPass
        case json
        case data

        public var typeIdentifier: String {
            switch self {
            case .movie:
                return UTType.movie.identifier
            case .image:
                return UTType.image.identifier
            case .webUrl:
                return UTType.url.identifier
            case .fileUrl:
                return UTType.fileURL.identifier
            case .contact:
                return UTType.vCard.identifier
            case .plainText:
                return UTType.plainText.identifier
            case .text:
                return UTType.text.identifier
            case .pdf:
                return UTType.pdf.identifier
            case .pkPass:
                return "com.apple.pkpass"
            case .json:
                return UTType.json.identifier
            case .data:
                return UTType.data.identifier
            }
        }
    }

    // MARK: Properties

    public let itemProvider: NSItemProvider
    public let itemType: ItemType

    public init(itemProvider: NSItemProvider, itemType: ItemType) {
        self.itemProvider = itemProvider
        self.itemType = itemType
    }

    public var isWebUrl: Bool {
        itemType == .webUrl
    }

    public var isVisualMedia: Bool {
        itemType == .image || itemType == .movie
    }

    public var isStoriesCompatible: Bool {
        switch itemType {
        case .movie, .image, .webUrl, .plainText, .text:
            return true
        case .fileUrl, .contact, .pdf, .pkPass, .json, .data:
            return false
        }
    }

    // MARK: Creating typed item providers

    /// For some data types, the OS is just awful and apparently
    /// says they conform to something else but then returns
    /// useless versions of the information
    ///
    /// - `com.topografix.gpx`
    ///     conforms to `public.text`, but when asking the OS for text,
    ///     it returns a file URL instead
    private static let forcedDataTypeIdentifiers: [String] = ["com.topografix.gpx"]

    /// Due to UT conformance fallbacks, the order these
    /// are checked is important; more specific types need
    /// to come earlier in the list than their fallbacks.
    private static let itemTypeOrder: [TypedItemProvider.ItemType] = [.movie, .image, .contact, .json, .plainText, .text, .pdf, .pkPass, .fileUrl, .webUrl, .data]

    public static func buildVisualMediaAttachment(
        forItemProvider itemProvider: NSItemProvider,
        attachmentLimits: OutgoingAttachmentLimits,
    ) async throws -> PreviewableAttachment {
        let typedItem = try await make(for: itemProvider).buildAttachment(attachmentLimits: attachmentLimits)
        switch typedItem {
        case .other(let attachment) where attachment.isVisualMedia:
            return attachment
        case .text, .contact, .other:
            throw SignalAttachmentError.invalidFileFormat
        }
    }

    public static func make(for itemProvider: NSItemProvider) throws -> TypedItemProvider {
        for typeIdentifier in forcedDataTypeIdentifiers {
            if itemProvider.hasItemConformingToTypeIdentifier(typeIdentifier) {
                return TypedItemProvider(itemProvider: itemProvider, itemType: .data)
            }
        }

        for itemType in itemTypeOrder {
            if itemProvider.hasItemConformingToTypeIdentifier(itemType.typeIdentifier) {
                return TypedItemProvider(itemProvider: itemProvider, itemType: itemType)
            }
        }

        owsFailDebug("unexpected share item: \(itemProvider)")
        throw ItemProviderError.unsupportedMedia
    }

    // MARK: Methods

    public nonisolated func buildAttachment(
        attachmentLimits: OutgoingAttachmentLimits,
        progress: Progress? = nil,
    ) async throws -> TypedItem {
        // Whenever this finishes, mark its progress as fully complete. This
        // handles item providers that can't provide partial progress updates.
        defer {
            if let progress {
                progress.completedUnitCount = progress.totalUnitCount
            }
        }

        let attachment: PreviewableAttachment
        switch itemType {
        case .image:
            // some apps send a usable file to us and some throw a UIImage at us, the UIImage can come in either directly
            // or as a bplist containing the NSKeyedArchiver output of a UIImage. the code below executes the following
            // order of attempts to load the input in the right way:
            //   1) try attaching the image from a file so we don't have to load the image into RAM in the common case
            //   2) try to load a UIImage directly in the case that is what was sent over
            //   3) try to NSKeyedUnarchive NSData directly into a UIImage
            do {
                attachment = try await buildFileAttachment(mustBeVisualMedia: true, attachmentLimits: attachmentLimits, progress: progress)
            } catch SignalAttachmentError.couldNotParseImage, ItemProviderError.fileUrlWasBplist {
                Logger.warn("failed to parse image directly from file; checking for loading UIImage directly")
                let image: UIImage = try await loadObjectWithKeyedUnarchiverFallback(
                    cannotLoadError: .cannotLoadUIImageObject,
                    failedLoadError: .loadUIImageObjectFailed,
                )
                attachment = try Self.createAttachment(withImage: image)
            }
        case .movie:
            attachment = try await self.buildFileAttachment(mustBeVisualMedia: true, attachmentLimits: attachmentLimits, progress: progress)
        case .pdf, .data:
            attachment = try await self.buildFileAttachment(mustBeVisualMedia: false, attachmentLimits: attachmentLimits, progress: progress)
        case .fileUrl, .json:
            let url: NSURL = try await loadObjectWithKeyedUnarchiverFallback(
                overrideTypeIdentifier: TypedItemProvider.ItemType.fileUrl.typeIdentifier,
                cannotLoadError: .cannotLoadURLObject,
                failedLoadError: .loadURLObjectFailed,
            )
            let (dataSource, dataUTI) = try Self.copyFileUrl(
                fileUrl: url as URL,
                defaultTypeIdentifier: UTType.data.identifier,
            )
            attachment = try await _buildFileAttachment(
                dataSource: dataSource,
                dataUTI: dataUTI,
                mustBeVisualMedia: false,
                attachmentLimits: attachmentLimits,
                progress: progress,
            )
        case .webUrl:
            let url: NSURL = try await loadObjectWithKeyedUnarchiverFallback(
                cannotLoadError: .cannotLoadURLObject,
                failedLoadError: .loadURLObjectFailed,
            )
            return try Self.createAttachment(withText: (url as URL).absoluteString, attachmentLimits: attachmentLimits)
        case .contact:
            let contactData = try await loadDataRepresentation()
            return .contact(contactData)
        case .plainText, .text:
            let text: NSString = try await loadObjectWithKeyedUnarchiverFallback(
                cannotLoadError: .cannotLoadStringObject,
                failedLoadError: .loadStringObjectFailed,
            )
            return try Self.createAttachment(withText: text as String, attachmentLimits: attachmentLimits)
        case .pkPass:
            let pkPass = try await loadDataRepresentation()
            let fileExtension = MimeTypeUtil.fileExtensionForUtiType(itemType.typeIdentifier)
            guard let fileExtension else {
                throw SignalAttachmentError.missingData
            }
            let dataSource = try DataSourcePath(writingTempFileData: pkPass, fileExtension: fileExtension)
            attachment = try PreviewableAttachment.genericAttachment(dataSource: dataSource, dataUTI: itemType.typeIdentifier, attachmentLimits: attachmentLimits)
        }
        return .other(attachment)
    }

    private nonisolated func buildFileAttachment(
        mustBeVisualMedia: Bool,
        attachmentLimits: OutgoingAttachmentLimits,
        progress: Progress?,
    ) async throws -> PreviewableAttachment {
        let (dataSource, dataUTI): (DataSourcePath, String) = try await withCheckedThrowingContinuation { continuation in
            let typeIdentifier = itemType.typeIdentifier
            _ = itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { fileUrl, error in
                if let error {
                    continuation.resume(throwing: error)
                } else if let fileUrl {
                    if Self.isBplist(url: fileUrl) {
                        continuation.resume(throwing: ItemProviderError.fileUrlWasBplist)
                    } else {
                        do {
                            continuation.resume(returning: try Self.copyFileUrl(fileUrl: fileUrl, defaultTypeIdentifier: typeIdentifier))
                        } catch {
                            continuation.resume(throwing: error)
                        }
                    }
                } else {
                    continuation.resume(throwing: ItemProviderError.loadInPlaceFileRepresentationFailed)
                }
            }
        }

        return try await _buildFileAttachment(
            dataSource: dataSource,
            dataUTI: dataUTI,
            mustBeVisualMedia: mustBeVisualMedia,
            attachmentLimits: attachmentLimits,
            progress: progress,
        )
    }

    private nonisolated func loadDataRepresentation(
        overrideTypeIdentifier: String? = nil,
    ) async throws -> Data {
        try await withCheckedThrowingContinuation { continuation in
            _ = itemProvider.loadDataRepresentation(
                forTypeIdentifier: overrideTypeIdentifier ?? itemType.typeIdentifier,
            ) { data, error in
                if let error {
                    continuation.resume(throwing: error)
                } else if let data {
                    continuation.resume(returning: data)
                } else {
                    continuation.resume(throwing: ItemProviderError.loadDataRepresentationFailed)
                }
            }
        }
    }

    private nonisolated func loadObjectWithKeyedUnarchiverFallback<T>(
        overrideTypeIdentifier: String? = nil,
        cannotLoadError: ItemProviderError,
        failedLoadError: ItemProviderError,
    ) async throws -> T where T: NSItemProviderReading, T: NSSecureCoding, T: NSObject {
        do {
            guard itemProvider.canLoadObject(ofClass: T.self) else {
                throw cannotLoadError
            }
            return try await withCheckedThrowingContinuation { continuation in
                _ = itemProvider.loadObject(ofClass: T.self) { object, error in
                    if let error {
                        continuation.resume(throwing: error)
                    } else if let typedObject = object as? T {
                        continuation.resume(returning: typedObject)
                    } else {
                        continuation.resume(throwing: failedLoadError)
                    }
                }
            }
        } catch {
            let data = try await loadDataRepresentation(overrideTypeIdentifier: overrideTypeIdentifier)
            if let result = try? NSKeyedUnarchiver.unarchivedObject(ofClass: T.self, from: data) {
                return result
            } else {
                throw error
            }
        }
    }

    // MARK: Static helpers

    private nonisolated static func isBplist(url: URL) -> Bool {
        if let handle = try? FileHandle(forReadingFrom: url) {
            let data = handle.readData(ofLength: 6)
            return data == Data("bplist".utf8)
        } else {
            return false
        }
    }

    private nonisolated static func createAttachment(
        withText text: String,
        attachmentLimits: OutgoingAttachmentLimits,
    ) throws -> TypedItem {
        let filteredText = FilteredString(rawValue: text)
        if let messageText = TypedItem.MessageText(filteredValue: filteredText) {
            return .text(messageText)
        } else {
            // If this is too large to send as a message, fall back to treating it as a
            // generic attachment that happens to contain text.
            let dataSource = try DataSourcePath(
                writingTempFileData: Data(filteredText.rawValue.utf8),
                fileExtension: MimeTypeUtil.oversizeTextAttachmentFileExtension,
            )
            return .other(try PreviewableAttachment.genericAttachment(
                dataSource: dataSource,
                dataUTI: UTType.plainText.identifier,
                attachmentLimits: attachmentLimits,
            ))
        }
    }

    private nonisolated static func createAttachment(withImage image: UIImage) throws -> PreviewableAttachment {
        let normalizedImage = try NormalizedImage.forImage(image)
        return PreviewableAttachment.imageAttachmentForNormalizedImage(normalizedImage)
    }

    private nonisolated static func copyFileUrl(
        fileUrl: URL,
        defaultTypeIdentifier: String,
    ) throws -> (DataSourcePath, dataUTI: String) {
        guard fileUrl.isFileURL else {
            throw OWSAssertionError("Unexpectedly not a file URL: \(fileUrl)")
        }

        let copiedUrl = OWSFileSystem.temporaryFileUrl(
            fileExtension: fileUrl.pathExtension,
            isAvailableWhileDeviceLocked: false,
        )
        try FileManager.default.copyItem(at: fileUrl, to: copiedUrl)

        let dataSource = DataSourcePath(fileUrl: copiedUrl, ownership: .owned)
        dataSource.sourceFilename = fileUrl.lastPathComponent

        let dataUTI = MimeTypeUtil.utiTypeForFileExtension(fileUrl.pathExtension) ?? defaultTypeIdentifier

        return (dataSource, dataUTI)
    }

    private nonisolated func _buildFileAttachment(
        dataSource: DataSourcePath,
        dataUTI: String,
        mustBeVisualMedia: Bool,
        attachmentLimits: OutgoingAttachmentLimits,
        progress: Progress?,
    ) async throws -> PreviewableAttachment {
        if SignalAttachment.videoUTISet.contains(dataUTI) {
            // TODO: Move waiting for this export to the end of the share flow rather than up front
            var progressPoller: ProgressPoller?
            defer {
                progressPoller?.stopPolling()
            }
            return try await PreviewableAttachment.compressVideoAsMp4(
                dataSource: dataSource,
                attachmentLimits: attachmentLimits,
                sessionCallback: { exportSession in
                    guard let progress else { return }
                    progressPoller = ProgressPoller(progress: progress, pollInterval: 0.1, fractionCompleted: { return exportSession.progress })
                    progressPoller?.startPolling()
                },
            )
        } else if mustBeVisualMedia {
            // If it's not a video but must be visual media, then we must parse it as
            // an image or throw an error.
            return try PreviewableAttachment.imageAttachment(dataSource: dataSource, dataUTI: dataUTI)
        } else {
            return try PreviewableAttachment.buildAttachment(dataSource: dataSource, dataUTI: dataUTI, attachmentLimits: attachmentLimits)
        }
    }
}

// MARK: - ProgressPoller

/// Exposes a Progress object, whose progress is updated by polling the return of a given block
private class ProgressPoller: NSObject {
    private let progress: Progress
    private let pollInterval: TimeInterval
    private let fractionCompleted: () -> Float

    init(progress: Progress, pollInterval: TimeInterval, fractionCompleted: @escaping () -> Float) {
        self.progress = progress
        self.pollInterval = pollInterval
        self.fractionCompleted = fractionCompleted
    }

    private var timer: Timer?

    func stopPolling() {
        timer?.invalidate()
    }

    func startPolling() {
        guard self.timer == nil else {
            owsFailDebug("already started timer")
            return
        }

        self.timer = WeakTimer.scheduledTimer(timeInterval: pollInterval, target: self, userInfo: nil, repeats: true) { [weak self] timer in
            guard let self else {
                timer.invalidate()
                return
            }

            let fractionCompleted = self.fractionCompleted()
            self.progress.completedUnitCount = Int64(fractionCompleted * Float(self.progress.totalUnitCount))
        }
    }
}