// // MastodonAttachmentService.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-17. // import os.log import UIKit import Combine import PhotosUI import Kingfisher import GameplayKit import MobileCoreServices import MastodonSDK protocol MastodonAttachmentServiceDelegate: AnyObject { func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) } final class MastodonAttachmentService { var disposeBag = Set() weak var delegate: MastodonAttachmentServiceDelegate? let identifier = UUID() // input let context: AppContext var authenticationBox: AuthenticationService.MastodonAuthenticationBox? let file = CurrentValueSubject(nil) let description = CurrentValueSubject(nil) // output let thumbnailImage = CurrentValueSubject(nil) let attachment = CurrentValueSubject(nil) let error = CurrentValueSubject(nil) private(set) lazy var uploadStateMachine: GKStateMachine = { // exclude timeline middle fetcher state let stateMachine = GKStateMachine(states: [ UploadState.Initial(service: self), UploadState.Uploading(service: self), UploadState.Fail(service: self), UploadState.Finish(service: self), ]) stateMachine.enter(UploadState.Initial.self) return stateMachine }() lazy var uploadStateMachineSubject = CurrentValueSubject(nil) init( context: AppContext, pickerResult: PHPickerResult, initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox? ) { self.context = context self.authenticationBox = initalAuthenticationBox // end init setupServiceObserver() Just(pickerResult) .flatMap { result -> AnyPublisher in if result.itemProvider.hasRepresentationConforming(toTypeIdentifier: UTType.image.identifier, fileOptions: []) { return PHPickerResultLoader.loadImageData(from: result).eraseToAnyPublisher() } if result.itemProvider.hasRepresentationConforming(toTypeIdentifier: UTType.movie.identifier, fileOptions: []) { return PHPickerResultLoader.loadVideoData(from: result).eraseToAnyPublisher() } return Fail(error: AttachmentError.invalidAttachmentType).eraseToAnyPublisher() } .sink { [weak self] completion in guard let self = self else { return } switch completion { case .failure(let error): self.error.value = error self.uploadStateMachine.enter(UploadState.Fail.self) case .finished: break } } receiveValue: { [weak self] file in guard let self = self else { return } self.file.value = file self.uploadStateMachine.enter(UploadState.Initial.self) } .store(in: &disposeBag) file .map { file -> UIImage? in guard let file = file else { return nil } switch file { case .jpeg(let data), .png(let data): return data.flatMap { UIImage(data: $0) } case .gif: // TODO: return nil case .other(let url, _, _): guard let url = url, 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 { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: thumbnail generate fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) return nil } } } .assign(to: \.value, on: thumbnailImage) .store(in: &disposeBag) } init( context: AppContext, image: UIImage, initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox? ) { self.context = context self.authenticationBox = initalAuthenticationBox // end init setupServiceObserver() file.value = .jpeg(image.jpegData(compressionQuality: 0.75)) uploadStateMachine.enter(UploadState.Initial.self) } init( context: AppContext, imageData: Data, initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox? ) { self.context = context self.authenticationBox = initalAuthenticationBox // end init setupServiceObserver() self.file.value = .jpeg(imageData) uploadStateMachine.enter(UploadState.Initial.self) } private func setupServiceObserver() { uploadStateMachineSubject .sink { [weak self] state in guard let self = self else { return } self.delegate?.mastodonAttachmentService(self, uploadStateDidChange: state) } .store(in: &disposeBag) } deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } } extension MastodonAttachmentService { enum AttachmentError: Error { case invalidAttachmentType case attachmentTooLarge } } extension MastodonAttachmentService { // FIXME: needs reset state for multiple account posting support func uploading(mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) -> Bool { authenticationBox = mastodonAuthenticationBox return uploadStateMachine.enter(UploadState.self) } } extension MastodonAttachmentService: Equatable, Hashable { static func == (lhs: MastodonAttachmentService, rhs: MastodonAttachmentService) -> Bool { return lhs.identifier == rhs.identifier } func hash(into hasher: inout Hasher) { hasher.combine(identifier) } }