// // DraftAttachment.swift // CoreData // // Created by Shadowfacts on 4/22/23. // import CoreData import PencilKit import UniformTypeIdentifiers import Photos import InstanceFeatures import Pachyderm private let decoder = PropertyListDecoder() private let encoder = PropertyListEncoder() @objc public final class DraftAttachment: NSManagedObject, Identifiable { @NSManaged internal var assetID: String? @NSManaged public var attachmentDescription: String @NSManaged internal private(set) var drawingData: Data? @NSManaged public var editedAttachmentID: String? @NSManaged private var editedAttachmentKindString: String? @NSManaged public var editedAttachmentURL: URL? @NSManaged public var fileURL: URL? @NSManaged internal var fileType: String? @NSManaged public var id: UUID @NSManaged internal var draft: Draft public var drawing: PKDrawing? { get { if let drawingData, let drawing = try? decoder.decode(PKDrawing.self, from: drawingData) { return drawing } else { return nil } } set { drawingData = try! encoder.encode(newValue) } } public var data: AttachmentData { if let editedAttachmentID { return .editing(editedAttachmentID, editedAttachmentKind!, editedAttachmentURL!) } else if let assetID { return .asset(assetID) } else if let drawing { return .drawing(drawing) } else if let fileURL, let fileType { return .file(fileURL, UTType(fileType)!) } else { fatalError() } } public var editedAttachmentKind: Attachment.Kind? { get { editedAttachmentKindString.flatMap(Attachment.Kind.init(rawValue:)) } set { editedAttachmentKindString = newValue?.rawValue } } public enum AttachmentData { case asset(String) case drawing(PKDrawing) case file(URL, UTType) case editing(String, Attachment.Kind, URL) } public override func prepareForDeletion() { super.prepareForDeletion() if let fileURL { try? FileManager.default.removeItem(at: fileURL) } } } extension DraftAttachment { var type: AttachmentType { if let editedAttachmentKind { switch editedAttachmentKind { case .image: return .image case .video: return .video case .gifv: return .video case .audio, .unknown: return .unknown } } else if let assetID { guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else { return .unknown } switch asset.mediaType { case .image: return .image case .video: return .video default: return .unknown } } else if drawingData != nil { return .image } else if let fileType, let type = UTType(fileType) { if type.conforms(to: .image) { return .image } else if type.conforms(to: .movie) { return .video } else { return .unknown } } else { return .unknown } } enum AttachmentType { case image, video, unknown } } //private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment" private let jpegType = UTType.jpeg.identifier private let pngType = UTType.png.identifier private let mp4Type = UTType.mpeg4Movie.identifier private let quickTimeType = UTType.quickTimeMovie.identifier private let gifType = UTType.gif.identifier extension DraftAttachment: NSItemProviderReading { public static var readableTypeIdentifiersForItemProvider: [String] { // todo: is there a better way of handling movies than manually adding all possible UTI types? // just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension // without the file extension, getting the thumbnail and exporting the video for attachment upload fails [/*typeIdentifier, */gifType, jpegType, pngType, mp4Type, quickTimeType] } public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment { let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil) attachment.id = UUID() attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: UTType(typeIdentifier)!) attachment.fileType = typeIdentifier attachment.attachmentDescription = "" return attachment } static func writeDataToFile(_ data: Data, id: UUID, type: UTType) throws -> URL { let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")! let directoryURL = containerURL.appendingPathComponent("Documents").appendingPathComponent("attachments") try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) let attachmentURL = directoryURL.appendingPathComponent(id.uuidString, conformingTo: type) try data.write(to: attachmentURL) return attachmentURL } } // MARK: Exporting extension DraftAttachment { func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) { if let assetID { guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else { completion(.failure(.noAsset)) return } if asset.mediaType == .image { let options = PHImageRequestOptions() options.version = .current options.deliveryMode = .highQualityFormat options.resizeMode = .none options.isNetworkAccessAllowed = true PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { data, dataUTI, orientation, info in guard let data, let dataUTI else { completion(.failure(.missingAssetData)) return } let processed = Self.processImageData(data, type: UTType(dataUTI)!, features: features, skipAllConversion: skipAllConversion) completion(.success(processed)) } } else if asset.mediaType == .video { let options = PHVideoRequestOptions() options.version = .current options.deliveryMode = .automatic options.isNetworkAccessAllowed = true PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { exportSession, info in if let exportSession { Self.exportVideoData(session: exportSession, completion: completion) } else if let error = info?[PHImageErrorKey] as? Error { completion(.failure(.videoExport(error))) } else { completion(.failure(.noVideoExportSession)) } } } else { completion(.failure(.unknownAssetType)) } } else if let drawingData { guard let drawing = try? decoder.decode(PKDrawing.self, from: drawingData) else { completion(.failure(.loadingDrawing)) return } let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1) completion(.success((image.pngData()!, .png))) } else if let fileURL, let fileType { let type = UTType(fileType)! if type.conforms(to: .movie) { let asset = AVURLAsset(url: fileURL) guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else { completion(.failure(.noVideoExportSession)) return } Self.exportVideoData(session: session, completion: completion) } else { let fileData: Data do { fileData = try Data(contentsOf: fileURL) } catch { completion(.failure(.loadingData)) return } if type != .gif, type.conforms(to: .image) { let result = Self.processImageData(fileData, type: type, features: features, skipAllConversion: skipAllConversion) completion(.success(result)) } else { completion(.success((fileData, type))) } } } } private static func processImageData(_ data: Data, type: UTType, features: InstanceFeatures, skipAllConversion: Bool) -> (Data, UTType) { guard !skipAllConversion else { return (data, type) } var data = data var type = type if type != .png && type != .jpeg, let image = UIImage(data: data) { // The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future. data = image.jpegData(compressionQuality: 0.8)! type = .jpeg } let image = CIImage(data: data)! let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB // neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG // they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB) // if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion if needsColorSpaceConversion || type == .heic { let context = CIContext() let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace! if type == .png { data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)! } else { data = context.jpegRepresentation(of: image, colorSpace: colorSpace)! type = .jpeg } } return (data, type) } private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) { session.outputFileType = .mp4 session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4") session.exportAsynchronously { guard session.status == .completed else { completion(.failure(.videoExport(session.error!))) return } do { let data = try Data(contentsOf: session.outputURL!) completion(.success((data, .mpeg4Movie))) } catch { completion(.failure(.videoExport(error))) } } } enum ExportError: Error { case noAsset case unknownAssetType case missingAssetData case videoExport(Error) case noVideoExportSession case loadingDrawing case loadingData } }