402 lines
13 KiB
Swift
402 lines
13 KiB
Swift
//
|
|
// AttachmentViewModel.swift
|
|
//
|
|
//
|
|
// Created by MainasuK on 2021/11/19.
|
|
//
|
|
|
|
import os.log
|
|
import UIKit
|
|
import Combine
|
|
import PhotosUI
|
|
import Kingfisher
|
|
import MastodonCore
|
|
|
|
final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable {
|
|
|
|
static let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel")
|
|
|
|
public let id = UUID()
|
|
|
|
var disposeBag = Set<AnyCancellable>()
|
|
var observations = Set<NSKeyValueObservation>()
|
|
|
|
// input
|
|
public let input: Input
|
|
@Published var caption = ""
|
|
@Published var sizeLimit = SizeLimit()
|
|
@Published public var isPreviewPresented = false
|
|
|
|
// output
|
|
@Published public private(set) var output: Output?
|
|
@Published public private(set) var thumbnail: UIImage? // original size image thumbnail
|
|
@Published var error: Error?
|
|
let progress = Progress() // upload progress
|
|
|
|
public init(input: Input) {
|
|
self.input = input
|
|
super.init()
|
|
// end init
|
|
|
|
defer {
|
|
load(input: input)
|
|
}
|
|
|
|
$output
|
|
.map { output -> UIImage? in
|
|
switch output {
|
|
case .image(let data, _):
|
|
return UIImage(data: data)
|
|
case .video(let url, _):
|
|
return AttachmentViewModel.createThumbnailForVideo(url: url)
|
|
case .none:
|
|
return nil
|
|
}
|
|
}
|
|
.assign(to: &$thumbnail)
|
|
}
|
|
|
|
deinit {
|
|
switch output {
|
|
case .image:
|
|
// FIXME:
|
|
break
|
|
case .video(let url, _):
|
|
try? FileManager.default.removeItem(at: url)
|
|
case nil :
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
extension AttachmentViewModel {
|
|
public enum Input: Hashable {
|
|
case image(UIImage)
|
|
case url(URL)
|
|
case pickerResult(PHPickerResult)
|
|
case itemProvider(NSItemProvider)
|
|
}
|
|
|
|
public enum Output {
|
|
case image(Data, imageKind: ImageKind)
|
|
// case gif(Data)
|
|
case video(URL, mimeType: String) // assert use file for video only
|
|
|
|
public enum ImageKind {
|
|
case png
|
|
case jpg
|
|
}
|
|
|
|
public var twitterMediaCategory: TwitterMediaCategory {
|
|
switch self {
|
|
case .image: return .image
|
|
case .video: return .amplifyVideo
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct SizeLimit {
|
|
public let image: Int
|
|
public let gif: Int
|
|
public let video: Int
|
|
|
|
public init(
|
|
image: Int = 5 * 1024 * 1024, // 5 MiB,
|
|
gif: Int = 15 * 1024 * 1024, // 15 MiB,
|
|
video: Int = 512 * 1024 * 1024 // 512 MiB
|
|
) {
|
|
self.image = image
|
|
self.gif = gif
|
|
self.video = video
|
|
}
|
|
}
|
|
|
|
public enum AttachmentError: Error {
|
|
case invalidAttachmentType
|
|
case attachmentTooLarge
|
|
}
|
|
|
|
public enum TwitterMediaCategory: String {
|
|
case image = "TWEET_IMAGE"
|
|
case GIF = "TWEET_GIF"
|
|
case video = "TWEET_VIDEO"
|
|
case amplifyVideo = "AMPLIFY_VIDEO"
|
|
}
|
|
}
|
|
|
|
extension AttachmentViewModel {
|
|
|
|
private func load(input: Input) {
|
|
switch input {
|
|
case .image(let image):
|
|
guard let data = image.pngData() else {
|
|
error = AttachmentError.invalidAttachmentType
|
|
return
|
|
}
|
|
output = .image(data, imageKind: .png)
|
|
case .url(let url):
|
|
Task { @MainActor in
|
|
do {
|
|
let output = try await AttachmentViewModel.load(url: url)
|
|
self.output = output
|
|
} catch {
|
|
self.error = error
|
|
}
|
|
} // end Task
|
|
case .pickerResult(let pickerResult):
|
|
Task { @MainActor in
|
|
do {
|
|
let output = try await AttachmentViewModel.load(itemProvider: pickerResult.itemProvider)
|
|
self.output = output
|
|
} catch {
|
|
self.error = error
|
|
}
|
|
} // end Task
|
|
case .itemProvider(let itemProvider):
|
|
Task { @MainActor in
|
|
do {
|
|
let output = try await AttachmentViewModel.load(itemProvider: itemProvider)
|
|
self.output = output
|
|
} catch {
|
|
self.error = error
|
|
}
|
|
} // end Task
|
|
}
|
|
}
|
|
|
|
private static func load(url: URL) async throws -> Output {
|
|
guard let uti = UTType(filenameExtension: url.pathExtension) else {
|
|
throw AttachmentError.invalidAttachmentType
|
|
}
|
|
|
|
if uti.conforms(to: .image) {
|
|
guard url.startAccessingSecurityScopedResource() else {
|
|
throw AttachmentError.invalidAttachmentType
|
|
}
|
|
defer { url.stopAccessingSecurityScopedResource() }
|
|
let imageData = try Data(contentsOf: url)
|
|
return .image(imageData, imageKind: imageData.kf.imageFormat == .PNG ? .png : .jpg)
|
|
} else if uti.conforms(to: .movie) {
|
|
guard url.startAccessingSecurityScopedResource() else {
|
|
throw AttachmentError.invalidAttachmentType
|
|
}
|
|
defer { url.stopAccessingSecurityScopedResource() }
|
|
|
|
let fileName = UUID().uuidString
|
|
let tempDirectoryURL = FileManager.default.temporaryDirectory
|
|
let fileURL = tempDirectoryURL.appendingPathComponent(fileName).appendingPathExtension(url.pathExtension)
|
|
try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
|
try FileManager.default.copyItem(at: url, to: fileURL)
|
|
return .video(fileURL, mimeType: UTType.movie.preferredMIMEType ?? "video/mp4")
|
|
} else {
|
|
throw AttachmentError.invalidAttachmentType
|
|
}
|
|
}
|
|
|
|
private static func load(itemProvider: NSItemProvider) async throws -> Output {
|
|
if itemProvider.isImage() {
|
|
guard let result = try await itemProvider.loadImageData() else {
|
|
throw AttachmentError.invalidAttachmentType
|
|
}
|
|
let imageKind: Output.ImageKind = {
|
|
if let type = result.type {
|
|
if type == UTType.png {
|
|
return .png
|
|
}
|
|
if type == UTType.jpeg {
|
|
return .jpg
|
|
}
|
|
}
|
|
|
|
let imageData = result.data
|
|
|
|
if imageData.kf.imageFormat == .PNG {
|
|
return .png
|
|
}
|
|
if imageData.kf.imageFormat == .JPEG {
|
|
return .jpg
|
|
}
|
|
|
|
assertionFailure("unknown image kind")
|
|
return .jpg
|
|
}()
|
|
return .image(result.data, imageKind: imageKind)
|
|
} else if itemProvider.isMovie() {
|
|
guard let result = try await itemProvider.loadVideoData() else {
|
|
throw AttachmentError.invalidAttachmentType
|
|
}
|
|
return .video(result.url, mimeType: "video/mp4")
|
|
} else {
|
|
assertionFailure()
|
|
throw AttachmentError.invalidAttachmentType
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
extension AttachmentViewModel {
|
|
static func createThumbnailForVideo(url: URL) -> UIImage? {
|
|
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
|
let asset = AVURLAsset(url: url)
|
|
let assetImageGenerator = AVAssetImageGenerator(asset: asset)
|
|
assetImageGenerator.appliesPreferredTrackTransform = true // fix orientation
|
|
do {
|
|
let cgImage = try assetImageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil)
|
|
let image = UIImage(cgImage: cgImage)
|
|
return image
|
|
} catch {
|
|
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): thumbnail generate fail: \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - TypeIdentifiedItemProvider
|
|
extension AttachmentViewModel: TypeIdentifiedItemProvider {
|
|
public static var typeIdentifier: String {
|
|
// must in UTI format
|
|
// https://developer.apple.com/library/archive/qa/qa1796/_index.html
|
|
return "com.twidere.AttachmentViewModel"
|
|
}
|
|
}
|
|
|
|
// MARK: - NSItemProviderWriting
|
|
extension AttachmentViewModel: NSItemProviderWriting {
|
|
|
|
|
|
/// Attachment uniform type idendifiers
|
|
///
|
|
/// The latest one for in-app drag and drop.
|
|
/// And use generic `image` and `movie` type to
|
|
/// allows transformable media in different formats
|
|
public static var writableTypeIdentifiersForItemProvider: [String] {
|
|
return [
|
|
UTType.image.identifier,
|
|
UTType.movie.identifier,
|
|
AttachmentViewModel.typeIdentifier,
|
|
]
|
|
}
|
|
|
|
public var writableTypeIdentifiersForItemProvider: [String] {
|
|
// should append elements in priority order from high to low
|
|
var typeIdentifiers: [String] = []
|
|
|
|
// FIXME: check jpg or png
|
|
switch input {
|
|
case .image:
|
|
typeIdentifiers.append(UTType.png.identifier)
|
|
case .url(let url):
|
|
let _uti = UTType(filenameExtension: url.pathExtension)
|
|
if let uti = _uti {
|
|
if uti.conforms(to: .image) {
|
|
typeIdentifiers.append(UTType.png.identifier)
|
|
} else if uti.conforms(to: .movie) {
|
|
typeIdentifiers.append(UTType.mpeg4Movie.identifier)
|
|
}
|
|
}
|
|
case .pickerResult(let item):
|
|
if item.itemProvider.isImage() {
|
|
typeIdentifiers.append(UTType.png.identifier)
|
|
} else if item.itemProvider.isMovie() {
|
|
typeIdentifiers.append(UTType.mpeg4Movie.identifier)
|
|
}
|
|
case .itemProvider(let itemProvider):
|
|
if itemProvider.isImage() {
|
|
typeIdentifiers.append(UTType.png.identifier)
|
|
} else if itemProvider.isMovie() {
|
|
typeIdentifiers.append(UTType.mpeg4Movie.identifier)
|
|
}
|
|
}
|
|
|
|
typeIdentifiers.append(AttachmentViewModel.typeIdentifier)
|
|
|
|
return typeIdentifiers
|
|
}
|
|
|
|
public func loadData(
|
|
withTypeIdentifier typeIdentifier: String,
|
|
forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void
|
|
) -> Progress? {
|
|
switch typeIdentifier {
|
|
case AttachmentViewModel.typeIdentifier:
|
|
do {
|
|
let archiver = NSKeyedArchiver(requiringSecureCoding: false)
|
|
try archiver.encodeEncodable(id, forKey: NSKeyedArchiveRootObjectKey)
|
|
archiver.finishEncoding()
|
|
let data = archiver.encodedData
|
|
completionHandler(data, nil)
|
|
} catch {
|
|
assertionFailure()
|
|
completionHandler(nil, nil)
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
|
|
let loadingProgress = Progress(totalUnitCount: 100)
|
|
|
|
Publishers.CombineLatest(
|
|
$output,
|
|
$error
|
|
)
|
|
.sink { [weak self] output, error in
|
|
guard let self = self else { return }
|
|
|
|
// continue when load completed
|
|
guard output != nil || error != nil else { return }
|
|
|
|
switch output {
|
|
case .image(let data, _):
|
|
switch typeIdentifier {
|
|
case UTType.png.identifier:
|
|
loadingProgress.completedUnitCount = 100
|
|
completionHandler(data, nil)
|
|
default:
|
|
completionHandler(nil, nil)
|
|
}
|
|
case .video(let url, _):
|
|
switch typeIdentifier {
|
|
case UTType.png.identifier:
|
|
let _image = AttachmentViewModel.createThumbnailForVideo(url: url)
|
|
let _data = _image?.pngData()
|
|
loadingProgress.completedUnitCount = 100
|
|
completionHandler(_data, nil)
|
|
case UTType.mpeg4Movie.identifier:
|
|
let task = URLSession.shared.dataTask(with: url) { data, response, error in
|
|
completionHandler(data, error)
|
|
}
|
|
task.progress.observe(\.fractionCompleted) { progress, change in
|
|
loadingProgress.completedUnitCount = Int64(100 * progress.fractionCompleted)
|
|
}
|
|
.store(in: &self.observations)
|
|
task.resume()
|
|
default:
|
|
completionHandler(nil, nil)
|
|
}
|
|
case nil:
|
|
completionHandler(nil, error)
|
|
}
|
|
}
|
|
.store(in: &disposeBag)
|
|
|
|
return loadingProgress
|
|
}
|
|
|
|
}
|
|
|
|
extension NSItemProvider {
|
|
fileprivate func isImage() -> Bool {
|
|
return hasRepresentationConforming(
|
|
toTypeIdentifier: UTType.image.identifier,
|
|
fileOptions: []
|
|
)
|
|
}
|
|
|
|
fileprivate func isMovie() -> Bool {
|
|
return hasRepresentationConforming(
|
|
toTypeIdentifier: UTType.movie.identifier,
|
|
fileOptions: []
|
|
)
|
|
}
|
|
}
|