// // ImageDownloader.swift // Kingfisher // // Created by Wei Wang on 15/4/6. // // Copyright (c) 2019 Wei Wang // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. #if os(macOS) import AppKit #else import UIKit #endif typealias DownloadResult = Result /// Represents a success result of an image downloading progress. public struct ImageLoadingResult { /// The downloaded image. public let image: KFCrossPlatformImage /// Original URL of the image request. public let url: URL? /// The raw data received from downloader. public let originalData: Data /// Creates an `ImageDownloadResult` /// /// - parameter image: Image of the download result /// - parameter url: URL from where the image was downloaded from /// - parameter originalData: The image's binary data public init(image: KFCrossPlatformImage, url: URL? = nil, originalData: Data) { self.image = image self.url = url self.originalData = originalData } } /// Represents a task of an image downloading process. public struct DownloadTask { /// The `SessionDataTask` object bounded to this download task. Multiple `DownloadTask`s could refer /// to a same `sessionTask`. This is an optimization in Kingfisher to prevent multiple downloading task /// for the same URL resource at the same time. /// /// When you `cancel` a `DownloadTask`, this `SessionDataTask` and its cancel token will be pass through. /// You can use them to identify the cancelled task. public let sessionTask: SessionDataTask /// The cancel token which is used to cancel the task. This is only for identify the task when it is cancelled. /// To cancel a `DownloadTask`, use `cancel` instead. public let cancelToken: SessionDataTask.CancelToken /// Cancel this task if it is running. It will do nothing if this task is not running. /// /// - Note: /// In Kingfisher, there is an optimization to prevent starting another download task if the target URL is being /// downloading. However, even when internally no new session task created, a `DownloadTask` will be still created /// and returned when you call related methods, but it will share the session downloading task with a previous task. /// In this case, if multiple `DownloadTask`s share a single session download task, cancelling a `DownloadTask` /// does not affect other `DownloadTask`s. /// /// If you need to cancel all `DownloadTask`s of a url, use `ImageDownloader.cancel(url:)`. If you need to cancel /// all downloading tasks of an `ImageDownloader`, use `ImageDownloader.cancelAll()`. public func cancel() { sessionTask.cancel(token: cancelToken) } } extension DownloadTask { enum WrappedTask { case download(DownloadTask) case dataProviding func cancel() { switch self { case .download(let task): task.cancel() case .dataProviding: break } } var value: DownloadTask? { switch self { case .download(let task): return task case .dataProviding: return nil } } } } /// Represents a downloading manager for requesting the image with a URL from server. open class ImageDownloader { // MARK: Singleton /// The default downloader. public static let `default` = ImageDownloader(name: "default") // MARK: Public Properties /// The duration before the downloading is timeout. Default is 15 seconds. open var downloadTimeout: TimeInterval = 15.0 /// A set of trusted hosts when receiving server trust challenges. A challenge with host name contained in this /// set will be ignored. You can use this set to specify the self-signed site. It only will be used if you don't /// specify the `authenticationChallengeResponder`. /// /// If `authenticationChallengeResponder` is set, this property will be ignored and the implementation of /// `authenticationChallengeResponder` will be used instead. open var trustedHosts: Set? /// Use this to set supply a configuration for the downloader. By default, /// NSURLSessionConfiguration.ephemeralSessionConfiguration() will be used. /// /// You could change the configuration before a downloading task starts. /// A configuration without persistent storage for caches is requested for downloader working correctly. open var sessionConfiguration = URLSessionConfiguration.ephemeral { didSet { session.invalidateAndCancel() session = URLSession(configuration: sessionConfiguration, delegate: sessionDelegate, delegateQueue: nil) } } open var sessionDelegate: SessionDelegate { didSet { session.invalidateAndCancel() session = URLSession(configuration: sessionConfiguration, delegate: sessionDelegate, delegateQueue: nil) setupSessionHandler() } } /// Whether the download requests should use pipeline or not. Default is false. open var requestsUsePipelining = false /// Delegate of this `ImageDownloader` object. See `ImageDownloaderDelegate` protocol for more. open weak var delegate: ImageDownloaderDelegate? /// A responder for authentication challenge. /// Downloader will forward the received authentication challenge for the downloading session to this responder. open weak var authenticationChallengeResponder: AuthenticationChallengeResponsible? private let name: String private var session: URLSession // MARK: Initializers /// Creates a downloader with name. /// /// - Parameter name: The name for the downloader. It should not be empty. public init(name: String) { if name.isEmpty { fatalError("[Kingfisher] You should specify a name for the downloader. " + "A downloader with empty name is not permitted.") } self.name = name sessionDelegate = SessionDelegate() session = URLSession( configuration: sessionConfiguration, delegate: sessionDelegate, delegateQueue: nil) authenticationChallengeResponder = self setupSessionHandler() } deinit { session.invalidateAndCancel() } private func setupSessionHandler() { sessionDelegate.onReceiveSessionChallenge.delegate(on: self) { (self, invoke) in self.authenticationChallengeResponder?.downloader(self, didReceive: invoke.1, completionHandler: invoke.2) } sessionDelegate.onReceiveSessionTaskChallenge.delegate(on: self) { (self, invoke) in self.authenticationChallengeResponder?.downloader( self, task: invoke.1, didReceive: invoke.2, completionHandler: invoke.3) } sessionDelegate.onValidStatusCode.delegate(on: self) { (self, code) in return (self.delegate ?? self).isValidStatusCode(code, for: self) } sessionDelegate.onResponseReceived.delegate(on: self) { (self, invoke) in (self.delegate ?? self).imageDownloader(self, didReceive: invoke.0, completionHandler: invoke.1) } sessionDelegate.onDownloadingFinished.delegate(on: self) { (self, value) in let (url, result) = value do { let value = try result.get() self.delegate?.imageDownloader(self, didFinishDownloadingImageForURL: url, with: value, error: nil) } catch { self.delegate?.imageDownloader(self, didFinishDownloadingImageForURL: url, with: nil, error: error) } } sessionDelegate.onDidDownloadData.delegate(on: self) { (self, task) in return (self.delegate ?? self).imageDownloader(self, didDownload: task.mutableData, with: task) } } // Wraps `completionHandler` to `onCompleted` respectively. private func createCompletionCallBack(_ completionHandler: ((DownloadResult) -> Void)?) -> Delegate? { return completionHandler.map { block -> Delegate in let delegate = Delegate, Void>() delegate.delegate(on: self) { (self, callback) in block(callback) } return delegate } } private func createTaskCallback( _ completionHandler: ((DownloadResult) -> Void)?, options: KingfisherParsedOptionsInfo ) -> SessionDataTask.TaskCallback { return SessionDataTask.TaskCallback( onCompleted: createCompletionCallBack(completionHandler), options: options ) } private func createDownloadContext( with url: URL, options: KingfisherParsedOptionsInfo, done: @escaping ((Result) -> Void) ) { func checkRequestAndDone(r: URLRequest) { // There is a possibility that request modifier changed the url to `nil` or empty. // In this case, throw an error. guard let url = r.url, !url.absoluteString.isEmpty else { done(.failure(KingfisherError.requestError(reason: .invalidURL(request: r)))) return } done(.success(DownloadingContext(url: url, request: r, options: options))) } // Creates default request. var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: downloadTimeout) request.httpShouldUsePipelining = requestsUsePipelining if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) , options.lowDataModeSource != nil { request.allowsConstrainedNetworkAccess = false } if let requestModifier = options.requestModifier { // Modifies request before sending. requestModifier.modified(for: request) { result in guard let finalRequest = result else { done(.failure(KingfisherError.requestError(reason: .emptyRequest))) return } checkRequestAndDone(r: finalRequest) } } else { checkRequestAndDone(r: request) } } private func addDownloadTask( context: DownloadingContext, callback: SessionDataTask.TaskCallback ) -> DownloadTask { // Ready to start download. Add it to session task manager (`sessionHandler`) let downloadTask: DownloadTask if let existingTask = sessionDelegate.task(for: context.url) { downloadTask = sessionDelegate.append(existingTask, callback: callback) } else { let sessionDataTask = session.dataTask(with: context.request) sessionDataTask.priority = context.options.downloadPriority downloadTask = sessionDelegate.add(sessionDataTask, url: context.url, callback: callback) } return downloadTask } private func reportWillDownloadImage(url: URL, request: URLRequest) { delegate?.imageDownloader(self, willDownloadImageForURL: url, with: request) } private func reportDidDownloadImageData(result: Result<(Data, URLResponse?), KingfisherError>, url: URL) { var response: URLResponse? var err: Error? do { response = try result.get().1 } catch { err = error } self.delegate?.imageDownloader( self, didFinishDownloadingImageForURL: url, with: response, error: err ) } private func reportDidProcessImage( result: Result, url: URL, response: URLResponse? ) { if let image = try? result.get() { self.delegate?.imageDownloader(self, didDownload: image, for: url, with: response) } } private func startDownloadTask( context: DownloadingContext, callback: SessionDataTask.TaskCallback ) -> DownloadTask { let downloadTask = addDownloadTask(context: context, callback: callback) let sessionTask = downloadTask.sessionTask guard !sessionTask.started else { return downloadTask } sessionTask.onTaskDone.delegate(on: self) { (self, done) in // Underlying downloading finishes. // result: Result<(Data, URLResponse?)>, callbacks: [TaskCallback] let (result, callbacks) = done // Before processing the downloaded data. self.reportDidDownloadImageData(result: result, url: context.url) switch result { // Download finished. Now process the data to an image. case .success(let (data, response)): let processor = ImageDataProcessor( data: data, callbacks: callbacks, processingQueue: context.options.processingQueue ) processor.onImageProcessed.delegate(on: self) { (self, done) in // `onImageProcessed` will be called for `callbacks.count` times, with each // `SessionDataTask.TaskCallback` as the input parameter. // result: Result, callback: SessionDataTask.TaskCallback let (result, callback) = done self.reportDidProcessImage(result: result, url: context.url, response: response) let imageResult = result.map { ImageLoadingResult(image: $0, url: context.url, originalData: data) } let queue = callback.options.callbackQueue queue.execute { callback.onCompleted?.call(imageResult) } } processor.process() case .failure(let error): callbacks.forEach { callback in let queue = callback.options.callbackQueue queue.execute { callback.onCompleted?.call(.failure(error)) } } } } reportWillDownloadImage(url: context.url, request: context.request) sessionTask.resume() return downloadTask } // MARK: Downloading Task /// Downloads an image with a URL and option. Invoked internally by Kingfisher. Subclasses must invoke super. /// /// - Parameters: /// - url: Target URL. /// - options: The options could control download behavior. See `KingfisherOptionsInfo`. /// - completionHandler: Called when the download progress finishes. This block will be called in the queue /// defined in `.callbackQueue` in `options` parameter. /// - Returns: A downloading task. You could call `cancel` on it to stop the download task. @discardableResult open func downloadImage( with url: URL, options: KingfisherParsedOptionsInfo, completionHandler: ((Result) -> Void)? = nil) -> DownloadTask? { var downloadTask: DownloadTask? createDownloadContext(with: url, options: options) { result in switch result { case .success(let context): // `downloadTask` will be set if the downloading started immediately. This is the case when no request // modifier or a sync modifier (`ImageDownloadRequestModifier`) is used. Otherwise, when an // `AsyncImageDownloadRequestModifier` is used the returned `downloadTask` of this method will be `nil` // and the actual "delayed" task is given in `AsyncImageDownloadRequestModifier.onDownloadTaskStarted` // callback. downloadTask = self.startDownloadTask( context: context, callback: self.createTaskCallback(completionHandler, options: options) ) if let modifier = options.requestModifier { modifier.onDownloadTaskStarted?(downloadTask) } case .failure(let error): options.callbackQueue.execute { completionHandler?(.failure(error)) } } } return downloadTask } /// Downloads an image with a URL and option. /// /// - Parameters: /// - url: Target URL. /// - options: The options could control download behavior. See `KingfisherOptionsInfo`. /// - progressBlock: Called when the download progress updated. This block will be always be called in main queue. /// - completionHandler: Called when the download progress finishes. This block will be called in the queue /// defined in `.callbackQueue` in `options` parameter. /// - Returns: A downloading task. You could call `cancel` on it to stop the download task. @discardableResult open func downloadImage( with url: URL, options: KingfisherOptionsInfo? = nil, progressBlock: DownloadProgressBlock? = nil, completionHandler: ((Result) -> Void)? = nil) -> DownloadTask? { var info = KingfisherParsedOptionsInfo(options) if let block = progressBlock { info.onDataReceived = (info.onDataReceived ?? []) + [ImageLoadingProgressSideEffect(block)] } return downloadImage( with: url, options: info, completionHandler: completionHandler) } /// Downloads an image with a URL and option. /// /// - Parameters: /// - url: Target URL. /// - options: The options could control download behavior. See `KingfisherOptionsInfo`. /// - completionHandler: Called when the download progress finishes. This block will be called in the queue /// defined in `.callbackQueue` in `options` parameter. /// - Returns: A downloading task. You could call `cancel` on it to stop the download task. @discardableResult open func downloadImage( with url: URL, options: KingfisherOptionsInfo? = nil, completionHandler: ((Result) -> Void)? = nil) -> DownloadTask? { downloadImage( with: url, options: KingfisherParsedOptionsInfo(options), completionHandler: completionHandler ) } } // MARK: Cancelling Task extension ImageDownloader { /// Cancel all downloading tasks for this `ImageDownloader`. It will trigger the completion handlers /// for all not-yet-finished downloading tasks. /// /// If you need to only cancel a certain task, call `cancel()` on the `DownloadTask` /// returned by the downloading methods. If you need to cancel all `DownloadTask`s of a certain url, /// use `ImageDownloader.cancel(url:)`. public func cancelAll() { sessionDelegate.cancelAll() } /// Cancel all downloading tasks for a given URL. It will trigger the completion handlers for /// all not-yet-finished downloading tasks for the URL. /// /// - Parameter url: The URL which you want to cancel downloading. public func cancel(url: URL) { sessionDelegate.cancel(url: url) } } // Use the default implementation from extension of `AuthenticationChallengeResponsible`. extension ImageDownloader: AuthenticationChallengeResponsible {} // Use the default implementation from extension of `ImageDownloaderDelegate`. extension ImageDownloader: ImageDownloaderDelegate {} extension ImageDownloader { struct DownloadingContext { let url: URL let request: URLRequest let options: KingfisherParsedOptionsInfo } }