forked from shadowfacts/Tusker
330 lines
13 KiB
Swift
330 lines
13 KiB
Swift
//
|
|
// 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 {
|
|
|
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<DraftAttachment> {
|
|
return NSFetchRequest<DraftAttachment>(entityName: "DraftAttachment")
|
|
}
|
|
|
|
@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 {
|
|
return .none
|
|
}
|
|
}
|
|
|
|
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)
|
|
case none
|
|
}
|
|
|
|
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 imageType = UTType.image.identifier
|
|
private let heifType = UTType.heif.identifier
|
|
private let heicType = UTType.heic.identifier
|
|
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, heifType, heicType, jpegType, pngType, imageType, mp4Type, quickTimeType]
|
|
}
|
|
|
|
public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment {
|
|
var data = data
|
|
var type = UTType(typeIdentifier)!
|
|
|
|
// the type is .image in certain circumstances:
|
|
// - macOS: image copied from macOS Safari -> only UIImage(data: data) works
|
|
// - iOS: sharing screenshot from markup -> only NSKeyedUnarchiver works
|
|
if type == .image,
|
|
let image = UIImage(data: data) ?? (try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIImage.self, from: data)),
|
|
let pngData = image.pngData() {
|
|
data = pngData
|
|
type = .png
|
|
}
|
|
|
|
let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil)
|
|
attachment.id = UUID()
|
|
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: type)
|
|
attachment.fileType = type.identifier
|
|
attachment.attachmentDescription = ""
|
|
return attachment
|
|
}
|
|
|
|
static var attachmentsDirectory: URL {
|
|
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
|
|
return containerURL.appendingPathComponent("Documents").appendingPathComponent("attachments")
|
|
}
|
|
|
|
static func writeDataToFile(_ data: Data, id: UUID, type: UTType) throws -> URL {
|
|
let directoryURL = attachmentsDirectory
|
|
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, features: features, 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, features: features, 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)))
|
|
}
|
|
}
|
|
} else {
|
|
completion(.failure(.noData))
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
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 || type == .heif {
|
|
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, features: InstanceFeatures, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
|
|
session.outputFileType = .mp4
|
|
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
|
|
if let configuration = features.mediaAttachmentsConfiguration {
|
|
session.fileLengthLimit = Int64(configuration.videoSizeLimit)
|
|
}
|
|
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
|
|
case noData
|
|
}
|
|
}
|