feat: bind the thumbnail and trigger media upload task

This commit is contained in:
CMK 2022-11-08 19:40:58 +08:00
parent fc3750c377
commit bdedd54318
18 changed files with 735 additions and 625 deletions

View File

@ -374,7 +374,11 @@
"video": "video",
"attachment_broken": "This %s is broken and cant be\nuploaded to Mastodon.",
"description_photo": "Describe the photo for the visually-impaired...",
"description_video": "Describe the video for the visually-impaired..."
"description_video": "Describe the video for the visually-impaired...",
"load_failed": "Load Failed",
"upload_failed": "Upload Failed",
"can_not_recognize_this_media_attachment": "Can not regonize this media attachment",
"attachment_too_large": "Attachment too large"
},
"poll": {
"duration_time": "Duration: %s",

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.400",
"green" : "0.275",
"red" : "0.275"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.400",
"green" : "0.275",
"red" : "0.275"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,91 @@
%PDF-1.7
1 0 obj
<< >>
endobj
2 0 obj
<< /Length 3 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
1.000000 0.000000 -0.000000 1.000000 2.750000 2.750000 cm
0.000000 0.000000 0.000000 scn
9.250000 16.500000 m
5.245935 16.500000 2.000000 13.254065 2.000000 9.250000 c
2.000000 5.245935 5.245935 2.000000 9.250000 2.000000 c
13.254065 2.000000 16.500000 5.245935 16.500000 9.250000 c
16.500000 9.535608 16.483484 9.817360 16.451357 10.094351 c
16.383255 10.681498 16.809317 11.250000 17.400400 11.250000 c
17.916018 11.250000 18.369314 10.891933 18.431660 10.380100 c
18.476776 10.009713 18.500000 9.632568 18.500000 9.250000 c
18.500000 4.141366 14.358634 0.000000 9.250000 0.000000 c
4.141366 0.000000 0.000000 4.141366 0.000000 9.250000 c
0.000000 14.358634 4.141366 18.500000 9.250000 18.500000 c
11.423139 18.500000 13.421247 17.750608 15.000000 16.496151 c
15.000000 17.000000 l
15.000000 17.552284 15.447716 18.000000 16.000000 18.000000 c
16.552284 18.000000 17.000000 17.552284 17.000000 17.000000 c
17.000000 14.301708 l
17.011232 14.284512 17.022409 14.267276 17.033529 14.250000 c
17.000000 14.250000 l
17.000000 14.000000 l
17.000000 13.447716 16.552284 13.000000 16.000000 13.000000 c
13.000000 13.000000 l
12.447715 13.000000 12.000000 13.447716 12.000000 14.000000 c
12.000000 14.552284 12.447715 15.000000 13.000000 15.000000 c
13.666476 15.000000 l
12.443584 15.940684 10.912110 16.500000 9.250000 16.500000 c
h
f
n
Q
endstream
endobj
3 0 obj
1365
endobj
4 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 24.000000 24.000000 ]
/Resources 1 0 R
/Contents 2 0 R
/Parent 5 0 R
>>
endobj
5 0 obj
<< /Kids [ 4 0 R ]
/Count 1
/Type /Pages
>>
endobj
6 0 obj
<< /Pages 5 0 R
/Type /Catalog
>>
endobj
xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000034 00000 n
0000001455 00000 n
0000001478 00000 n
0000001651 00000 n
0000001725 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 6 0 R
/Size 7
>>
startxref
1784
%%EOF

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "Arrow Clockwise.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "Dismiss.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -0,0 +1,89 @@
%PDF-1.7
1 0 obj
<< >>
endobj
2 0 obj
<< /Length 3 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
1.000000 0.000000 -0.000000 1.000000 4.000000 3.804749 cm
0.000000 0.000000 0.000000 scn
0.209704 15.808150 m
0.292893 15.902358 l
0.653377 16.262842 1.220608 16.290571 1.612899 15.985547 c
1.707107 15.902358 l
8.000000 9.610251 l
14.292892 15.902358 l
14.683416 16.292883 15.316584 16.292883 15.707108 15.902358 c
16.097631 15.511834 16.097631 14.878669 15.707108 14.488145 c
9.415000 8.195251 l
15.707108 1.902359 l
16.067591 1.541875 16.095320 0.974643 15.790295 0.582352 c
15.707108 0.488144 l
15.346623 0.127661 14.779391 0.099932 14.387100 0.404957 c
14.292892 0.488144 l
8.000000 6.780252 l
1.707107 0.488144 l
1.316582 0.097620 0.683418 0.097620 0.292893 0.488144 c
-0.097631 0.878668 -0.097631 1.511835 0.292893 1.902359 c
6.585000 8.195251 l
0.292893 14.488145 l
-0.067591 14.848629 -0.095320 15.415859 0.209704 15.808150 c
0.292893 15.902358 l
0.209704 15.808150 l
h
f
n
Q
endstream
endobj
3 0 obj
914
endobj
4 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 24.000000 24.000000 ]
/Resources 1 0 R
/Contents 2 0 R
/Parent 5 0 R
>>
endobj
5 0 obj
<< /Kids [ 4 0 R ]
/Count 1
/Type /Pages
>>
endobj
6 0 obj
<< /Pages 5 0 R
/Type /Catalog
>>
endobj
xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000034 00000 n
0000001004 00000 n
0000001026 00000 n
0000001199 00000 n
0000001273 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 6 0 R
/Size 7
>>
startxref
1332
%%EOF

View File

@ -130,6 +130,11 @@ public enum Asset {
}
public enum Scene {
public enum Compose {
public enum Attachment {
public static let indicatorButtonBackground = ColorAsset(name: "Scene/Compose/Attachment/indicator.button.background")
public static let retry = ImageAsset(name: "Scene/Compose/Attachment/retry")
public static let stop = ImageAsset(name: "Scene/Compose/Attachment/stop")
}
public static let earth = ImageAsset(name: "Scene/Compose/Earth")
public static let mention = ImageAsset(name: "Scene/Compose/Mention")
public static let more = ImageAsset(name: "Scene/Compose/More")

View File

@ -43,6 +43,20 @@ extension Mastodon.API.V2.Media {
request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment
let serialStream = query.serialStream
request.httpBodyStream = serialStream.boundStreams.input
// total unit count in bytes count
// will small than actally count due to multipart protocol meta
serialStream.progress.totalUnitCount = {
var size = 0
size += query.file?.sizeInByte ?? 0
size += query.thumbnail?.sizeInByte ?? 0
return Int64(size)
}()
query.progress.addChild(
serialStream.progress,
withPendingUnitCount: query.progress.totalUnitCount
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response)

View File

@ -54,7 +54,7 @@ extension Mastodon.Query.MediaAttachment {
return data.map { "data:" + mimeType + ";base64," + $0.base64EncodedString() }
}
var sizeInByte: Int? {
public var sizeInByte: Int? {
switch self {
case .jpeg(let data), .gif(let data), .png(let data):
return data?.count

View File

@ -10,12 +10,17 @@ import UIKit
import SwiftUI
import Introspect
import AVKit
import MastodonAsset
public struct AttachmentView: View {
@ObservedObject var viewModel: AttachmentViewModel
let action: (Action) -> Void
var blurEffect: UIBlurEffect {
UIBlurEffect(style: .systemUltraThinMaterialDark)
}
public var body: some View {
ZStack {
@ -23,223 +28,81 @@ public struct AttachmentView: View {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
// loading
if viewModel.output == nil, viewModel.error == nil {
ProgressView()
.progressViewStyle(.circular)
}
// load failed
// cannot re-entry
if viewModel.output == nil, let error = viewModel.error {
VisualEffectView(effect: blurEffect)
VStack {
Text("Load Failed") // TODO: i18n
.font(.system(size: 13, weight: .semibold))
Text(error.localizedDescription)
.font(.system(size: 12, weight: .regular))
}
}
// loaded
// uploading or upload failed
// could retry upload when error emit
if viewModel.output != nil {
VisualEffectView(effect: blurEffect)
VStack {
let image: UIImage = {
if let _ = viewModel.error {
return Asset.Scene.Compose.Attachment.retry.image.withRenderingMode(.alwaysTemplate)
} else {
return Asset.Scene.Compose.Attachment.stop.image.withRenderingMode(.alwaysTemplate)
}
}()
Image(uiImage: image)
.foregroundColor(.white)
.padding()
.background(Color(Asset.Scene.Compose.Attachment.indicatorButtonBackground.color))
.clipShape(Circle())
.padding()
let title: String = {
if let _ = viewModel.error {
return "Upload Failed" // TODO: i18n
} else {
let total = ByteCountFormatter.string(fromByteCount: Int64(viewModel.outputSizeInByte), countStyle: .memory)
return "…/\(total)"
}
}()
let subtitle: String = {
if let error = viewModel.error {
return error.localizedDescription
} else {
return "… remaining"
}
}()
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal)
Text(subtitle)
.font(.system(size: 12, weight: .regular))
.foregroundColor(.white)
.padding(.horizontal)
}
}
} // end ZStack
.onChange(of: viewModel.progress) { progress in
// not works
print(progress.completedUnitCount)
}
// Menu {
// menu
// } label: {
// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3)
// Image(uiImage: image)
// .resizable()
// .aspectRatio(contentMode: .fill)
// .frame(width: AttachmentView.size.width, height: AttachmentView.size.height)
// .overlay {
// ZStack {
// // spinner
// if viewModel.output == nil {
// Color.clear
// .background(.ultraThinMaterial)
// ProgressView()
// .progressViewStyle(CircularProgressViewStyle())
// .foregroundStyle(.regularMaterial)
// }
// // border
// RoundedRectangle(cornerRadius: AttachmentView.cornerRadius)
// .stroke(Color.black.opacity(0.05))
// }
// .transition(.opacity)
// }
// .overlay(alignment: .bottom) {
// HStack(alignment: .bottom) {
// // alt
// VStack(spacing: 2) {
// switch viewModel.output {
// case .video:
// Image(uiImage: Asset.Media.playerRectangle.image)
// .resizable()
// .frame(width: 16, height: 12)
// default:
// EmptyView()
// }
// if !viewModel.caption.isEmpty {
// Image(uiImage: Asset.Media.altRectangle.image)
// .resizable()
// .frame(width: 16, height: 12)
// }
// }
// Spacer()
// // option
// Image(systemName: "ellipsis")
// .resizable()
// .frame(width: 12, height: 12)
// .symbolVariant(.circle)
// .symbolVariant(.fill)
// .symbolRenderingMode(.palette)
// .foregroundStyle(.white, .black)
// }
// .padding(6)
// }
// .cornerRadius(AttachmentView.cornerRadius)
// } // end Menu
// .sheet(isPresented: $isCaptionEditorPresented) {
// captionSheet
// } // end caption sheet
// .sheet(isPresented: $viewModel.isPreviewPresented) {
// previewSheet
// } // end preview sheet
} // end body
// var menu: some View {
// Group {
// Button(
// action: {
// action(.preview)
// },
// label: {
// Label(L10n.Scene.Compose.Media.preview, systemImage: "photo")
// }
// )
// // caption
// let canAddCaption: Bool = {
// switch viewModel.output {
// case .image: return true
// case .video: return false
// case .none: return false
// }
// }()
// if canAddCaption {
// Button(
// action: {
// action(.caption)
// caption = viewModel.caption
// isCaptionEditorPresented.toggle()
// },
// label: {
// let title = viewModel.caption.isEmpty ? L10n.Scene.Compose.Media.Caption.add : L10n.Scene.Compose.Media.Caption.update
// Label(title, systemImage: "text.bubble")
// // FIXME: https://stackoverflow.com/questions/72318730/how-to-customize-swiftui-menu
// // add caption subtitle
// }
// )
// }
// Divider()
// // remove
// Button(
// role: .destructive,
// action: {
// action(.remove)
// },
// label: {
// Label(L10n.Scene.Compose.Media.remove, systemImage: "minus.circle")
// }
// )
// }
// }
// var captionSheet: some View {
// NavigationView {
// ScrollView(.vertical) {
// VStack {
// // preview
// switch viewModel.output {
// case .image:
// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3)
// Image(uiImage: image)
// .resizable()
// .aspectRatio(contentMode: .fill)
// case .video(let url, _):
// let player = AVPlayer(url: url)
// VideoPlayer(player: player)
// .frame(height: 300)
// case .none:
// EmptyView()
// }
// // caption textField
// TextField(
// text: $caption,
// prompt: Text(L10n.Scene.Compose.Media.Caption.addADescriptionForThisImage)
// ) {
// Text(L10n.Scene.Compose.Media.Caption.update)
// }
// .padding()
// .introspectTextField { textField in
// textField.becomeFirstResponder()
// }
// }
// }
// .navigationTitle(L10n.Scene.Compose.Media.Caption.update)
// .navigationBarTitleDisplayMode(.inline)
// .toolbar {
// ToolbarItem(placement: .navigationBarLeading) {
// Button {
// isCaptionEditorPresented.toggle()
// } label: {
// Image(systemName: "xmark.circle.fill")
// .resizable()
// .frame(width: 30, height: 30, alignment: .center)
// .symbolRenderingMode(.hierarchical)
// .foregroundStyle(Color(uiColor: .secondaryLabel), Color(uiColor: .tertiaryLabel))
// }
// }
// ToolbarItem(placement: .navigationBarTrailing) {
// Button {
// viewModel.caption = caption.trimmingCharacters(in: .whitespacesAndNewlines)
// isCaptionEditorPresented.toggle()
// } label: {
// Text(L10n.Common.Controls.Actions.save)
// }
// }
// }
// } // end NavigationView
// }
// design for share extension
// preferred UIKit preview in app
// var previewSheet: some View {
// NavigationView {
// ScrollView(.vertical) {
// VStack {
// // preview
// switch viewModel.output {
// case .image:
// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3)
// Image(uiImage: image)
// .resizable()
// .aspectRatio(contentMode: .fill)
// case .video(let url, _):
// let player = AVPlayer(url: url)
// VideoPlayer(player: player)
// .frame(height: 300)
// case .none:
// EmptyView()
// }
// Spacer()
// }
// }
// .navigationTitle(L10n.Scene.Compose.Media.preview)
// .navigationBarTitleDisplayMode(.inline)
// .toolbar {
// ToolbarItem(placement: .navigationBarLeading) {
// Button {
// viewModel.isPreviewPresented.toggle()
// } label: {
// Image(systemName: "xmark.circle.fill")
// .resizable()
// .frame(width: 30, height: 30, alignment: .center)
// .symbolRenderingMode(.hierarchical)
// .foregroundStyle(Color(uiColor: .secondaryLabel), Color(uiColor: .tertiaryLabel))
// }
// }
// }
// } // end NavigationView
// }
}
extension AttachmentView {
public enum Action: Hashable {
case preview
case caption
case remove
case retry
}
}

View File

@ -0,0 +1,144 @@
//
// AttachmentViewModel+DragAndDrop.swift
//
//
// Created by MainasuK on 2022/11/8.
//
import os.log
import UIKit
import Combine
import UniformTypeIdentifiers
// 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 "org.joinmastodon.app.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
}
}

View File

@ -0,0 +1,148 @@
//
// AttachmentViewModel+Load.swift
//
//
// Created by MainasuK on 2022/11/8.
//
import os.log
import UIKit
import AVKit
import UniformTypeIdentifiers
extension AttachmentViewModel {
@MainActor
func load(input: Input) async throws -> Output {
switch input {
case .image(let image):
guard let data = image.pngData() else {
throw AttachmentError.invalidAttachmentType
}
return .image(data, imageKind: .png)
case .url(let url):
do {
let output = try await AttachmentViewModel.load(url: url)
return output
} catch {
throw error
}
case .pickerResult(let pickerResult):
do {
let output = try await AttachmentViewModel.load(itemProvider: pickerResult.itemProvider)
return output
} catch {
throw error
}
case .itemProvider(let itemProvider):
do {
let output = try await AttachmentViewModel.load(itemProvider: itemProvider)
return output
} catch {
throw error
}
}
}
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
}
}
}
extension NSItemProvider {
func isImage() -> Bool {
return hasRepresentationConforming(
toTypeIdentifier: UTType.image.identifier,
fileOptions: []
)
}
func isMovie() -> Bool {
return hasRepresentationConforming(
toTypeIdentifier: UTType.movie.identifier,
fileOptions: []
)
}
}

View File

@ -52,153 +52,25 @@ extension Data {
}
}
// Twitter Only
//extension AttachmentViewModel {
// class SliceResult {
//
// let fileURL: URL
// let chunks: Chunked<FileHandle.AsyncBytes>
// let chunkCount: Int
// let type: UTType
// let sizeInBytes: UInt64
//
// public init?(
// url: URL,
// type: UTType
// ) {
// guard let chunks = try? FileHandle(forReadingFrom: url).bytes.chunked else { return nil }
// let _sizeInBytes: UInt64? = {
// let attribute = try? FileManager.default.attributesOfItem(atPath: url.path)
// return attribute?[.size] as? UInt64
// }()
// guard let sizeInBytes = _sizeInBytes else { return nil }
//
// self.fileURL = url
// self.chunks = chunks
// self.chunkCount = SliceResult.chunkCount(chunkSize: UInt64(chunks.chunkSize), sizeInBytes: sizeInBytes)
// self.type = type
// self.sizeInBytes = sizeInBytes
// }
//
// public init?(
// imageData: Data,
// type: UTType
// ) {
// let _fileURL = try? FileManager.default.createTemporaryFileURL(
// filename: UUID().uuidString,
// pathExtension: imageData.kf.imageFormat == .PNG ? "png" : "jpeg"
// )
// guard let fileURL = _fileURL else { return nil }
//
// do {
// try imageData.write(to: fileURL)
// } catch {
// return nil
// }
//
// guard let chunks = try? FileHandle(forReadingFrom: fileURL).bytes.chunked else {
// return nil
// }
// let sizeInBytes = UInt64(imageData.count)
//
// self.fileURL = fileURL
// self.chunks = chunks
// self.chunkCount = SliceResult.chunkCount(chunkSize: UInt64(chunks.chunkSize), sizeInBytes: sizeInBytes)
// self.type = type
// self.sizeInBytes = sizeInBytes
// }
//
// static func chunkCount(chunkSize: UInt64, sizeInBytes: UInt64) -> Int {
// guard sizeInBytes > 0 else { return 0 }
// let count = sizeInBytes / chunkSize
// let remains = sizeInBytes % chunkSize
// let result = remains > 0 ? count + 1 : count
// return Int(result)
// }
//
// }
//
// static func slice(output: Output, sizeLimit: SizeLimit) -> SliceResult? {
// // needs execute in background
// assert(!Thread.isMainThread)
//
// // try png then use JPEG compress with Q=0.8
// // then slice into 1MiB chunks
// switch output {
// case .image(let data, _):
// let maxPayloadSizeInBytes = sizeLimit.image
//
// // use processed imageData to remove EXIF
// guard let image = UIImage(data: data),
// var imageData = image.pngData()
// else { return nil }
//
// var didRemoveEXIF = false
// repeat {
// guard let image = KFCrossPlatformImage(data: imageData) else { return nil }
// if imageData.kf.imageFormat == .PNG {
// // A. png image
// guard let pngData = image.pngData() else { return nil }
// didRemoveEXIF = true
// if pngData.count > maxPayloadSizeInBytes {
// guard let compressedJpegData = image.jpegData(compressionQuality: 0.8) else { return nil }
// os_log("%{public}s[%{public}ld], %{public}s: compress png %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(compressedJpegData.count) / 1024 / 1024)
// imageData = compressedJpegData
// } else {
// os_log("%{public}s[%{public}ld], %{public}s: png %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(pngData.count) / 1024 / 1024)
// imageData = pngData
// }
// } else {
// // B. other image
// if !didRemoveEXIF {
// guard let jpegData = image.jpegData(compressionQuality: 0.8) else { return nil }
// os_log("%{public}s[%{public}ld], %{public}s: compress jpeg %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(jpegData.count) / 1024 / 1024)
// imageData = jpegData
// didRemoveEXIF = true
// } else {
// let targetSize = CGSize(width: image.size.width * 0.8, height: image.size.height * 0.8)
// let scaledImage = image.af.imageScaled(to: targetSize)
// guard let compressedJpegData = scaledImage.jpegData(compressionQuality: 0.8) else { return nil }
// os_log("%{public}s[%{public}ld], %{public}s: compress jpeg %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(compressedJpegData.count) / 1024 / 1024)
// imageData = compressedJpegData
// }
// }
// } while (imageData.count > maxPayloadSizeInBytes)
//
// return SliceResult(
// imageData: imageData,
// type: imageData.kf.imageFormat == .PNG ? UTType.png : UTType.jpeg
// )
//
//// case .gif(let url):
//// fatalError()
// case .video(let url, _):
// return SliceResult(
// url: url,
// type: .movie
// )
// }
// }
//}
extension AttachmentViewModel {
struct UploadContext {
let apiService: APIService
let authContext: AuthContext
}
enum UploadResult {
case mastodon(Mastodon.Response.Content<Mastodon.Entity.Attachment>)
}
public typealias UploadResult = Mastodon.Entity.Attachment
}
extension AttachmentViewModel {
func upload(context: UploadContext) async throws -> UploadResult {
func upload(context: UploadContext) async throws -> UploadResult {
return try await uploadMastodonMedia(
context: context
)
}
// MainActor is required here to trigger stream upload task
@MainActor
private func uploadMastodonMedia(
context: UploadContext
) async throws -> UploadResult {
@ -283,7 +155,7 @@ extension AttachmentViewModel {
// escape here
progress.completedUnitCount = progress.totalUnitCount
return .mastodon(attachmentStatusResponse)
return attachmentStatusResponse.value
} else {
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): attachment processing. Retry \(waitProcessRetryCount)/\(waitProcessRetryLimit)")
@ -296,7 +168,7 @@ extension AttachmentViewModel {
} else {
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment success: \(attachmentUploadResponse.value.url ?? "<nil>")")
return .mastodon(attachmentUploadResponse)
return attachmentUploadResponse.value
}
}
}

View File

@ -15,6 +15,7 @@ import MastodonCore
final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable {
static let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel")
let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel")
public let id = UUID()
@ -22,32 +23,52 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
var observations = Set<NSKeyValueObservation>()
// input
public let api: APIService
public let authContext: AuthContext
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 public private(set) var outputSizeInByte: Int = 0
@Published public var uploadResult: UploadResult?
@Published var error: Error?
let progress = Progress() // upload progress
public init(
api: APIService,
authContext: AuthContext,
input: Input
) {
self.api = api
self.authContext = authContext
self.input = input
super.init()
// end init
defer {
Task {
await load(input: input)
progress
.observe(\.fractionCompleted, options: [.initial, .new]) { [weak self] progress, _ in
guard let self = self else { return }
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)")
DispatchQueue.main.async {
self.objectWillChange.send()
}
}
}
.store(in: &observations)
progress
.observe(\.isFinished, options: [.initial, .new]) { [weak self] progress, _ in
guard let self = self else { return }
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)")
DispatchQueue.main.async {
self.objectWillChange.send()
}
}
.store(in: &observations)
$output
.map { output -> UIImage? in
@ -62,6 +83,23 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable
}
.receive(on: DispatchQueue.main)
.assign(to: &$thumbnail)
defer {
Task { @MainActor in
do {
let output = try await load(input: input)
self.output = output
self.outputSizeInByte = output.asAttachment.sizeInByte ?? 0
let uploadResult = try await self.upload(context: .init(
apiService: self.api,
authContext: self.authContext
))
self.uploadResult = uploadResult
} catch {
self.error = error
}
} // end Task
}
}
deinit {
@ -112,280 +150,23 @@ extension AttachmentViewModel {
}
}
public enum AttachmentError: Error {
public enum AttachmentError: Error, LocalizedError {
case invalidAttachmentType
case attachmentTooLarge
}
}
extension AttachmentViewModel {
@MainActor
private func load(input: Input) async {
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):
do {
let output = try await AttachmentViewModel.load(url: url)
self.output = output
} catch {
self.error = error
}
case .pickerResult(let pickerResult):
do {
let output = try await AttachmentViewModel.load(itemProvider: pickerResult.itemProvider)
self.output = output
} catch {
self.error = error
}
case .itemProvider(let itemProvider):
do {
let output = try await AttachmentViewModel.load(itemProvider: itemProvider)
self.output = output
} catch {
self.error = error
}
}
}
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
public var errorDescription: String? {
switch self {
case .invalidAttachmentType:
return "Can not regonize this media attachment" // TODO: i18n
case .attachmentTooLarge:
return "Attachment too large"
}
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: []
)
}
}

View File

@ -326,7 +326,11 @@ extension ComposeContentViewController: PHPickerViewControllerDelegate {
picker.dismiss(animated: true, completion: nil)
let attachmentViewModels: [AttachmentViewModel] = results.map { result in
AttachmentViewModel(authContext: viewModel.authContext, input: .pickerResult(result))
AttachmentViewModel(
api: viewModel.context.apiService,
authContext: viewModel.authContext,
input: .pickerResult(result)
)
}
viewModel.attachmentViewModels += attachmentViewModels
}

View File

@ -119,13 +119,16 @@ extension MastodonStatusPublisher: StatusPublisher {
progress.addChild(attachmentViewModel.progress, withPendingUnitCount: publishAttachmentTaskWeight)
// upload media
do {
let result = try await attachmentViewModel.upload(context: uploadContext)
guard case let .mastodon(response) = result else {
assertionFailure()
continue
guard let attachment = attachmentViewModel.uploadResult else {
// precondition: all media uploaded
throw AppError.badRequest
}
let attachmentID = response.value.id
attachmentIDs.append(attachmentID)
attachmentIDs.append(attachment.id)
// TODO: allow background upload
// let attachment = try await attachmentViewModel.upload(context: uploadContext)
// let attachmentID = attachment.id
// attachmentIDs.append(attachmentID)
} catch {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment fail: \(error.localizedDescription)")
_state = .failure(error)

View File

@ -0,0 +1,15 @@
//
// VisualEffectView.swift
//
//
// Created by MainasuK on 2022/11/8.
//
import SwiftUI
// ref: https://stackoverflow.com/a/59111492/3797903
public struct VisualEffectView: UIViewRepresentable {
public var effect: UIVisualEffect?
public func makeUIView(context: UIViewRepresentableContext<Self>) -> UIVisualEffectView { UIVisualEffectView() }
public func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext<Self>) { uiView.effect = effect }
}