Sources/FilestackSDK/Internal/Uploaders/MultipartUpload.swift
//
// MultipartUpload.swift
// FilestackSDK
//
// Created by Ruben Nine on 7/18/17.
// Copyright © 2017 Filestack. All rights reserved.
//
import Foundation
/// This class allows uploading a single `Uploadable` item to a given storage location.
class MultipartUpload: Uploader, DeferredAdd {
typealias Result = Swift.Result<[JSONResponse], Error>
// MARK: - Internal Properties
let uuid = UUID()
// Closures
var uploadProgress: ((Progress) -> Void)?
var completionHandler: (([JSONResponse]) -> Void)?
// Public-facing progress object.
let progress: Progress = {
let progress = Progress()
progress.kind = .file
progress.fileOperationKind = .copying
progress.totalUnitCount = 0 // indeterminate
return progress
}()
// MARK: - Private Properties
private var uploadables: [Uploadable]?
private let uploadQueue: DispatchQueue = DispatchQueue(label: "com.filestack.upload-queue")
private let masterProgress = Progress()
private var progressObservers: [NSKeyValueObservation] = []
private let queue: DispatchQueue
private let config: Config
private let options: UploadOptions
private lazy var operationQueue: OperationQueue = {
let operationQueue = OperationQueue()
operationQueue.maxConcurrentOperationCount = 1
return operationQueue
}()
// MARK: - Lifecycle
init(using uploadables: [Uploadable]? = nil, options: UploadOptions, config: Config, queue: DispatchQueue = .main) {
self.uploadables = uploadables
self.options = options
self.config = config
self.queue = queue
}
// MARK: - Uploadable Protocol Implementation
private(set) var state: UploadState = .notStarted {
didSet {
if state == .cancelled {
progress.cancel()
}
}
}
@discardableResult func cancel() -> Bool {
guard state != .cancelled else { return false }
uploadQueue.sync {
state = .cancelled
if uploadables == nil || uploadables?.count == 0 {
finish(with: [])
} else {
operationQueue.cancelAllOperations()
}
}
return true
}
@discardableResult func start() -> Bool {
guard state == .notStarted else { return false }
uploadQueue.sync { upload() }
return true
}
@discardableResult func add(uploadables: [Uploadable]) -> Bool {
uploadQueue.sync {
if self.uploadables != nil {
self.uploadables?.append(contentsOf: uploadables)
} else {
self.uploadables = uploadables
}
}
return true
}
}
// MARK: - Deprecated
extension MultipartUpload {
@available(*, deprecated, message: "Marked for removal in version 3.0. Use start() instead.")
func uploadFiles() {
start()
}
}
// MARK: - Private Functions
private extension MultipartUpload {
/// Starts the upload process.
func upload() {
guard let uploadables = uploadables else { return }
config.currentUploaders.append(self)
state = .inProgress
var results: [JSONResponse] = []
// Observe changes in `progress.totalUnitCount`.
progressObservers.append(masterProgress.observe(\.totalUnitCount) { progress, _ in
self.progress.totalUnitCount = progress.totalUnitCount
})
// Observe changes in `progress.fractionCompleted`.
progressObservers.append(masterProgress.observe(\.fractionCompleted) { progress, _ in
self.progress.completedUnitCount = Int64(progress.fractionCompleted * Double(progress.totalUnitCount))
self.queue.async {
self.uploadProgress?(self.progress)
}
})
for uploadable in uploadables {
let operation = UploadOperation(uploadable: uploadable, options: options, config: config)
// Block to run when operation completes.
operation.completionBlock = {
if let fileCompletedCount = self.progress.fileCompletedCount {
self.progress.fileCompletedCount = fileCompletedCount + 1
}
switch operation.result {
case let .success(response):
// Append success JSONResponse to `results`.
results.append(JSONResponse(using: response, context: uploadable))
case let .failure(error):
// Append error JSONResponse to `results`.
results.append(JSONResponse(error: error, context: uploadable))
// Substract failed operation's `totalUnitCount` from globalProgress's `totalUnitCount`.
self.masterProgress.totalUnitCount -= operation.progress.totalUnitCount
}
if results.count == uploadables.count {
// Finish.
self.uploadQueue.async {
self.finish(with: results)
}
}
}
// Add operations.
operationQueue.addOperation(operation)
// Update master progress.
masterProgress.addChild(operation.progress, withPendingUnitCount: operation.progress.totalUnitCount)
masterProgress.totalUnitCount += operation.progress.totalUnitCount
}
progress.fileCompletedCount = 0
progress.fileTotalCount = uploadables.count
}
/// Marks upload process as finished by updating `state` and calling `completionHandler` with return `results`.
func finish(with results: [JSONResponse]) {
progressObservers.removeAll()
// Update state to `completed` unless it is already in `cancelled` state.
if state != .cancelled {
state = .completed
}
config.currentUploaders.removeAll { $0 == self }
queue.async {
self.completionHandler?(results)
self.completionHandler = nil
self.uploadProgress = nil
// Delete any deletable files if `deleteTemporaryFilesAfterUpload` option is enabled.
if self.options.deleteTemporaryFilesAfterUpload, let deletables = (self.uploadables?.compactMap { $0 as? Deletable }) {
for deletable in deletables {
deletable.delete()
}
}
}
}
}
// MARK: - CustomStringConvertible Conformance
extension MultipartUpload {
/// :nodoc:
public var description: String {
return Tools.describe(subject: self, only: ["state", "progress"])
}
}