// // AttachmentViewModel.swift // // // Created by MainasuK on 2021/11/19. // import os.log import UIKit import Combine import PhotosUI import Kingfisher import MastodonCore import func QuartzCore.CACurrentMediaTime public protocol AttachmentViewModelDelegate: AnyObject { func attachmentViewModel(_ viewModel: AttachmentViewModel, uploadStateValueDidChange state: AttachmentViewModel.UploadState) func attachmentViewModel(_ viewModel: AttachmentViewModel, actionButtonDidPressed action: AttachmentViewModel.Action) } 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() var disposeBag = Set() var observations = Set() weak var delegate: AttachmentViewModelDelegate? let byteCountFormatter: ByteCountFormatter = { let formatter = ByteCountFormatter() formatter.allowsNonnumericFormatting = true formatter.countStyle = .memory return formatter }() let percentageFormatter: NumberFormatter = { let formatter = NumberFormatter() formatter.numberStyle = .percent return formatter }() // input public let api: APIService public let authContext: AuthContext public let input: Input @Published var caption = "" @Published var sizeLimit = SizeLimit() // var compressVideoTask: Task? // 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: Int64 = 0 @MainActor @Published public private(set) var uploadState: UploadState = .none @Published public private(set) var uploadResult: UploadResult? @Published var error: Error? var uploadTask: Task<(), Never>? @Published var videoCompressProgress: Double = 0 let progress = Progress() // upload progress @Published var fractionCompleted: Double = 0 private var lastTimestamp: TimeInterval? private var lastUploadSizeInByte: Int64 = 0 private var averageUploadSpeedInByte: Int64 = 0 private var remainTimeInterval: Double? @Published var remainTimeLocalizedString: String? public init( api: APIService, authContext: AuthContext, input: Input, delegate: AttachmentViewModelDelegate ) { self.api = api self.authContext = authContext self.input = input self.delegate = delegate super.init() // end init Timer.publish(every: 1.0 / 60.0, on: .main, in: .common) // 60 FPS .autoconnect() .share() .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } self.step() } .store(in: &disposeBag) 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.fractionCompleted = progress.fractionCompleted } } .store(in: &observations) // Note: this observation is redundant if .fractionCompleted listener always emit event when reach 1.0 progress // 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 switch output { case .image(let data, _): return UIImage(data: data) case .video(let url, _): return AttachmentViewModel.createThumbnailForVideo(url: url) case .none: return nil } } .receive(on: DispatchQueue.main) .assign(to: &$thumbnail) defer { let uploadTask = Task { @MainActor in do { var output = try await load(input: input) switch output { case .video(let fileURL, let mimeType): self.output = output self.update(uploadState: .compressing) let compressedFileURL = try await comporessVideo(url: fileURL) output = .video(compressedFileURL, mimeType: mimeType) try? FileManager.default.removeItem(at: fileURL) // remove old file default: break } self.outputSizeInByte = output.asAttachment.sizeInByte.flatMap { Int64($0) } ?? 0 self.output = output self.update(uploadState: .ready) self.delegate?.attachmentViewModel(self, uploadStateValueDidChange: self.uploadState) } catch { self.error = error } } // end Task self.uploadTask = uploadTask Task { await uploadTask.value } } } deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) uploadTask?.cancel() switch output { case .image: // FIXME: break case .video(let url, _): try? FileManager.default.removeItem(at: url) case nil: break } } } // calculate the upload speed // ref: https://stackoverflow.com/a/3841706/3797903 extension AttachmentViewModel { static var SpeedSmoothingFactor = 0.4 static let remainsTimeFormatter: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .full return formatter }() @objc private func step() { let uploadProgress = min(progress.fractionCompleted + 0.1, 1) // the progress split into 9:1 blocks (download : waiting) guard let lastTimestamp = self.lastTimestamp else { self.lastTimestamp = CACurrentMediaTime() self.lastUploadSizeInByte = Int64(Double(outputSizeInByte) * uploadProgress) return } let duration = CACurrentMediaTime() - lastTimestamp guard duration >= 1.0 else { return } // update every 1 sec let old = self.lastUploadSizeInByte self.lastUploadSizeInByte = Int64(Double(outputSizeInByte) * uploadProgress) let newSpeed = self.lastUploadSizeInByte - old let lastAverageSpeed = self.averageUploadSpeedInByte let newAverageSpeed = Int64(AttachmentViewModel.SpeedSmoothingFactor * Double(newSpeed) + (1 - AttachmentViewModel.SpeedSmoothingFactor) * Double(lastAverageSpeed)) let remainSizeInByte = Double(outputSizeInByte) * (1 - uploadProgress) let speed = Double(newAverageSpeed) if speed != .zero { // estimate by speed let uploadRemainTimeInSecond = remainSizeInByte / speed // estimate by progress 1s for 10% let remainPercentage = 1 - uploadProgress let estimateRemainTimeByProgress = remainPercentage / 0.1 // max estimate var remainTimeInSecond = max(estimateRemainTimeByProgress, uploadRemainTimeInSecond) // do not increate timer when < 5 sec if let remainTimeInterval = self.remainTimeInterval, remainTimeInSecond < 5 { remainTimeInSecond = min(remainTimeInterval, remainTimeInSecond) self.remainTimeInterval = remainTimeInSecond } else { self.remainTimeInterval = remainTimeInSecond } let string = AttachmentViewModel.remainsTimeFormatter.localizedString(fromTimeInterval: remainTimeInSecond) remainTimeLocalizedString = string // print("remains: \(remainSizeInByte), speed: \(newAverageSpeed), \(string)") } else { remainTimeLocalizedString = nil } self.lastTimestamp = CACurrentMediaTime() self.averageUploadSpeedInByte = newAverageSpeed } } 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 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, LocalizedError { case invalidAttachmentType case attachmentTooLarge public var errorDescription: String? { switch self { case .invalidAttachmentType: return "Can not regonize this media attachment" // TODO: i18n case .attachmentTooLarge: return "Attachment too large" } } } } extension AttachmentViewModel { public enum Action: Hashable { case remove case retry } } extension AttachmentViewModel { @MainActor func update(uploadState: UploadState) { self.uploadState = uploadState self.delegate?.attachmentViewModel(self, uploadStateValueDidChange: self.uploadState) } @MainActor func update(uploadResult: UploadResult) { self.uploadResult = uploadResult } }