From b2137ad01d8024d2f0da8a7a9b062d57791c1e6a Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Wed, 20 Nov 2024 16:12:27 +0200 Subject: [PATCH] Handle NSItemProvider public.image data types. (#3541) --- .../Other/Extensions/NSItemProvider.swift | 121 +++++++++++++++++- .../PhotoLibraryPicker.swift | 2 +- .../Timeline/TimelineInteractionHandler.swift | 60 ++------- .../ShareExtensionViewController.swift | 46 +------ 4 files changed, 134 insertions(+), 95 deletions(-) diff --git a/ElementX/Sources/Other/Extensions/NSItemProvider.swift b/ElementX/Sources/Other/Extensions/NSItemProvider.swift index fef3478dbb..f567f7c280 100644 --- a/ElementX/Sources/Other/Extensions/NSItemProvider.swift +++ b/ElementX/Sources/Other/Extensions/NSItemProvider.swift @@ -6,23 +6,136 @@ // import Foundation +import UIKit import UniformTypeIdentifiers extension NSItemProvider { + struct PreferredContentType { + let type: UTType + let fileExtension: String + } + + func storeData() async -> URL? { + guard let contentType = preferredContentType else { + MXLog.error("Invalid NSItemProvider: \(self)") + return nil + } + + if contentType.type.identifier == UTType.image.identifier { + return await generateURLForUIImage(contentType) + } else { + return await generateURLForGenericData(contentType) + } + } + + private func generateURLForUIImage(_ contentType: PreferredContentType) async -> URL? { + guard let uiImage = try? await loadItem(forTypeIdentifier: contentType.type.identifier) as? UIImage else { + MXLog.error("Failed casting UIImage, invalid NSItemProvider: \(self)") + return nil + } + + guard let pngData = uiImage.pngData() else { + MXLog.error("Failed extracting PNG data out of the UIImage") + return nil + } + + do { + if let suggestedName = suggestedName as? NSString, + // Suggestions are nice but their extension is `jpeg` + let filename = (suggestedName.deletingPathExtension as NSString).appendingPathExtension(contentType.fileExtension) { + return try FileManager.default.writeDataToTemporaryDirectory(data: pngData, fileName: filename) + } else { + let filename = "\(UUID().uuidString).\(contentType.fileExtension)" + return try FileManager.default.writeDataToTemporaryDirectory(data: pngData, fileName: filename) + } + } catch { + MXLog.error("Failed storing NSItemProvider data \(self) with error: \(error)") + return nil + } + } + + private func generateURLForGenericData(_ contentType: PreferredContentType) async -> URL? { + let providerDescription = description + let shareData: Data? = await withCheckedContinuation { continuation in + _ = loadDataRepresentation(for: contentType.type) { data, error in + if let error { + MXLog.error("Failed processing NSItemProvider: \(providerDescription) with error: \(error)") + continuation.resume(returning: nil) + return + } + + guard let data else { + MXLog.error("Invalid NSItemProvider data: \(providerDescription)") + continuation.resume(returning: nil) + return + } + + continuation.resume(returning: data) + } + } + + guard let shareData else { + MXLog.error("Invalid share data") + return nil + } + + do { + if let filename = suggestedName { + let hasExtension = !(filename as NSString).pathExtension.isEmpty + let filename = hasExtension ? filename : "\(filename).\(contentType.fileExtension)" + return try FileManager.default.writeDataToTemporaryDirectory(data: shareData, fileName: filename) + } else { + let filename = "\(UUID().uuidString).\(contentType.fileExtension)" + return try FileManager.default.writeDataToTemporaryDirectory(data: shareData, fileName: filename) + } + } catch { + MXLog.error("Failed storing NSItemProvider data \(self) with error: \(error)") + return nil + } + } + var isSupportedForPasteOrDrop: Bool { preferredContentType != nil } - var preferredContentType: UTType? { + var preferredContentType: PreferredContentType? { let supportedContentTypes = registeredContentTypes - .filter { isMimeTypeSupported($0.preferredMIMEType) } + .filter { isMimeTypeSupported($0.preferredMIMEType) || isIdentifierSupported($0.identifier) } // Have .jpeg take priority over .heic if supportedContentTypes.contains(.jpeg) { - return .jpeg + guard let fileExtension = preferredFileExtension(for: .jpeg) else { + return nil + } + + return .init(type: .jpeg, fileExtension: fileExtension) + } + + guard let preferredContentType = supportedContentTypes.first, + let fileExtension = preferredFileExtension(for: preferredContentType) else { + return nil } - return supportedContentTypes.first + return .init(type: preferredContentType, fileExtension: fileExtension) + } + + private func preferredFileExtension(for contentType: UTType) -> String? { + if let fileExtension = contentType.preferredFilenameExtension { + return fileExtension + } + + switch contentType.identifier { + case UTType.image.identifier: + return "png" + default: + return nil + } + } + + private func isIdentifierSupported(_ identifier: String?) -> Bool { + // Don't filter out generic public.image content as screenshots are in this format + // and we can convert them to a PNG ourselves. + identifier == UTType.image.identifier } private func isMimeTypeSupported(_ mimeType: String?) -> Bool { diff --git a/ElementX/Sources/Screens/MediaPickerScreen/PhotoLibraryPicker.swift b/ElementX/Sources/Screens/MediaPickerScreen/PhotoLibraryPicker.swift index c1b1a4ad34..377d43a490 100644 --- a/ElementX/Sources/Screens/MediaPickerScreen/PhotoLibraryPicker.swift +++ b/ElementX/Sources/Screens/MediaPickerScreen/PhotoLibraryPicker.swift @@ -69,7 +69,7 @@ struct PhotoLibraryPicker: UIViewControllerRepresentable { photoLibraryPicker.userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier) } - provider.loadFileRepresentation(forTypeIdentifier: contentType.identifier) { [weak self] url, error in + provider.loadFileRepresentation(forTypeIdentifier: contentType.type.identifier) { [weak self] url, error in guard let url else { Task { @MainActor in self?.photoLibraryPicker.callback(.error(.failedLoadingFileRepresentation(error))) diff --git a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift index a70c982758..e21ee3b4a7 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift @@ -248,54 +248,20 @@ class TimelineInteractionHandler { // MARK: Pasting and dropping func handlePasteOrDrop(_ provider: NSItemProvider) { - guard let contentType = provider.preferredContentType, - let preferredExtension = contentType.preferredFilenameExtension else { - MXLog.error("Invalid NSItemProvider: \(provider)") - actionsSubject.send(.displayErrorToast(L10n.screenRoomErrorFailedProcessingMedia)) - return - } - - let providerSuggestedName = provider.suggestedName - let providerDescription = provider.description - - _ = provider.loadDataRepresentation(for: contentType) { data, error in - Task { @MainActor in - let loadingIndicatorIdentifier = UUID().uuidString - self.userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true)) - defer { - self.userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier) - } - - if let error { - self.actionsSubject.send(.displayErrorToast(L10n.screenRoomErrorFailedProcessingMedia)) - MXLog.error("Failed processing NSItemProvider: \(providerDescription) with error: \(error)") - return - } - - guard let data else { - self.actionsSubject.send(.displayErrorToast(L10n.screenRoomErrorFailedProcessingMedia)) - MXLog.error("Invalid NSItemProvider data: \(providerDescription)") - return - } - - do { - let url = try await Task.detached { - if let filename = providerSuggestedName { - let hasExtension = !(filename as NSString).pathExtension.isEmpty - let filename = hasExtension ? filename : "\(filename).\(preferredExtension)" - return try FileManager.default.writeDataToTemporaryDirectory(data: data, fileName: filename) - } else { - let filename = "\(UUID().uuidString).\(preferredExtension)" - return try FileManager.default.writeDataToTemporaryDirectory(data: data, fileName: filename) - } - }.value - - self.actionsSubject.send(.displayMediaUploadPreviewScreen(url: url)) - } catch { - self.actionsSubject.send(.displayErrorToast(L10n.screenRoomErrorFailedProcessingMedia)) - MXLog.error("Failed storing NSItemProvider data \(providerDescription) with error: \(error)") - } + Task { + let loadingIndicatorIdentifier = UUID().uuidString + self.userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true)) + defer { + self.userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier) } + + guard let fileURL = await provider.storeData() else { + MXLog.error("Failed storing NSItemProvider data \(provider)") + self.actionsSubject.send(.displayErrorToast(L10n.screenRoomErrorFailedProcessingMedia)) + return + } + + self.actionsSubject.send(.displayMediaUploadPreviewScreen(url: fileURL)) } } diff --git a/ShareExtension/Sources/ShareExtensionViewController.swift b/ShareExtension/Sources/ShareExtensionViewController.swift index e2060491cf..5ecafc5e3b 100644 --- a/ShareExtension/Sources/ShareExtensionViewController.swift +++ b/ShareExtension/Sources/ShareExtensionViewController.swift @@ -42,54 +42,14 @@ class ShareExtensionViewController: UIViewController { return nil } - guard let contentType = itemProvider.preferredContentType, - let preferredExtension = contentType.preferredFilenameExtension else { - MXLog.error("Invalid NSItemProvider: \(itemProvider)") + guard let fileURL = await itemProvider.storeData() else { + MXLog.error("Failed storing NSItemProvider data \(itemProvider)") return nil } let roomID = (extensionContext?.intent as? INSendMessageIntent)?.conversationIdentifier - let providerSuggestedName = itemProvider.suggestedName - let providerDescription = itemProvider.description - - let shareData: Data? = await withCheckedContinuation { continuation in - _ = itemProvider.loadDataRepresentation(for: contentType) { data, error in - if let error { - MXLog.error("Failed processing NSItemProvider: \(providerDescription) with error: \(error)") - continuation.resume(returning: nil) - return - } - - guard let data else { - MXLog.error("Invalid NSItemProvider data: \(providerDescription)") - continuation.resume(returning: nil) - return - } - continuation.resume(returning: data) - } - } - - guard let shareData else { - return nil - } - - do { - let url: URL - if let filename = providerSuggestedName { - let hasExtension = !(filename as NSString).pathExtension.isEmpty - let filename = hasExtension ? filename : "\(filename).\(preferredExtension)" - url = try FileManager.default.writeDataToTemporaryDirectory(data: shareData, fileName: filename) - } else { - let filename = "\(UUID().uuidString).\(preferredExtension)" - url = try FileManager.default.writeDataToTemporaryDirectory(data: shareData, fileName: filename) - } - - return .mediaFile(roomID: roomID, mediaFile: .init(url: url, suggestedName: providerSuggestedName)) - } catch { - MXLog.error("Failed storing NSItemProvider data \(providerDescription) with error: \(error)") - return nil - } + return .mediaFile(roomID: roomID, mediaFile: .init(url: fileURL, suggestedName: fileURL.lastPathComponent)) } private func openMainApp(payload: ShareExtensionPayload) async {