You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

677 lines
30 KiB

//
// ImageDownloader.swift
// Kingfisher
//
// Created by Wei Wang on 15/4/6.
//
// Copyright (c) 2018 Wei Wang <onevcat@gmail.com>
//
// 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
/// Progress update block of downloader.
public typealias ImageDownloaderProgressBlock = DownloadProgressBlock
/// Completion block of downloader.
public typealias ImageDownloaderCompletionHandler = ((_ image: Image?, _ error: NSError?, _ url: URL?, _ originalData: Data?) -> Void)
/// Download task.
public struct RetrieveImageDownloadTask {
let internalTask: URLSessionDataTask
/// Downloader by which this task is initialized.
public private(set) weak var ownerDownloader: ImageDownloader?
/// Cancel this download task. It will trigger the completion handler with an NSURLErrorCancelled error.
/// If you want to cancel all downloading tasks, call `cancelAll()` of `ImageDownloader` instance.
public func cancel() {
ownerDownloader?.cancel(self)
}
/// The original request URL of this download task.
public var url: URL? {
return internalTask.originalRequest?.url
}
/// The relative priority of this download task.
/// It represents the `priority` property of the internal `NSURLSessionTask` of this download task.
/// The value for it is between 0.0~1.0. Default priority is value of 0.5.
/// See documentation on `priority` of `NSURLSessionTask` for more about it.
public var priority: Float {
get {
return internalTask.priority
}
set {
internalTask.priority = newValue
}
}
}
///The code of errors which `ImageDownloader` might encountered.
public enum KingfisherError: Int {
/// badData: The downloaded data is not an image or the data is corrupted.
case badData = 10000
/// notModified: The remote server responded a 304 code. No image data downloaded.
case notModified = 10001
/// The HTTP status code in response is not valid. If an invalid
/// code error received, you could check the value under `KingfisherErrorStatusCodeKey`
/// in `userInfo` to see the code.
case invalidStatusCode = 10002
/// notCached: The image requested is not in cache but .onlyFromCache is activated.
case notCached = 10003
/// The URL is invalid.
case invalidURL = 20000
/// The downloading task is cancelled before started.
case downloadCancelledBeforeStarting = 30000
}
/// Key will be used in the `userInfo` of `.invalidStatusCode`
public let KingfisherErrorStatusCodeKey = "statusCode"
/// Protocol of `ImageDownloader`.
public protocol ImageDownloaderDelegate: AnyObject {
/**
Called when the `ImageDownloader` object will start downloading an image from specified URL.
- parameter downloader: The `ImageDownloader` object finishes the downloading.
- parameter url: URL of the original request URL.
- parameter response: The request object for the download process.
*/
func imageDownloader(_ downloader: ImageDownloader, willDownloadImageForURL url: URL, with request: URLRequest?)
/**
Called when the `ImageDownloader` completes a downloading request with success or failure.
- parameter downloader: The `ImageDownloader` object finishes the downloading.
- parameter url: URL of the original request URL.
- parameter response: The response object of the downloading process.
- parameter error: The error in case of failure.
*/
func imageDownloader(_ downloader: ImageDownloader, didFinishDownloadingImageForURL url: URL, with response: URLResponse?, error: Error?)
/**
Called when the `ImageDownloader` object successfully downloaded an image from specified URL.
- parameter downloader: The `ImageDownloader` object finishes the downloading.
- parameter image: Downloaded image.
- parameter url: URL of the original request URL.
- parameter response: The response object of the downloading process.
*/
func imageDownloader(_ downloader: ImageDownloader, didDownload image: Image, for url: URL, with response: URLResponse?)
/**
Check if a received HTTP status code is valid or not.
By default, a status code between 200 to 400 (excluded) is considered as valid.
If an invalid code is received, the downloader will raise an .invalidStatusCode error.
It has a `userInfo` which includes this statusCode and localizedString error message.
- parameter code: The received HTTP status code.
- parameter downloader: The `ImageDownloader` object asking for validate status code.
- returns: Whether this HTTP status code is valid or not.
- Note: If the default 200 to 400 valid code does not suit your need,
you can implement this method to change that behavior.
*/
func isValidStatusCode(_ code: Int, for downloader: ImageDownloader) -> Bool
/**
Called when the `ImageDownloader` object successfully downloaded image data from specified URL.
- parameter downloader: The `ImageDownloader` object finishes data downloading.
- parameter data: Downloaded data.
- parameter url: URL of the original request URL.
- returns: The data from which Kingfisher should use to create an image.
- Note: This callback can be used to preprocess raw image data
before creation of UIImage instance (i.e. decrypting or verification).
*/
func imageDownloader(_ downloader: ImageDownloader, didDownload data: Data, for url: URL) -> Data?
}
extension ImageDownloaderDelegate {
public func imageDownloader(_ downloader: ImageDownloader, willDownloadImageForURL url: URL, with request: URLRequest?) {}
public func imageDownloader(_ downloader: ImageDownloader, didFinishDownloadingImageForURL url: URL, with response: URLResponse?, error: Error?) {}
public func imageDownloader(_ downloader: ImageDownloader, didDownload image: Image, for url: URL, with response: URLResponse?) {}
public func isValidStatusCode(_ code: Int, for downloader: ImageDownloader) -> Bool {
return (200..<400).contains(code)
}
public func imageDownloader(_ downloader: ImageDownloader, didDownload data: Data, for url: URL) -> Data? {
return data
}
}
/// Protocol indicates that an authentication challenge could be handled.
public protocol AuthenticationChallengeResponsable: AnyObject {
/**
Called when an session level authentication challenge is received.
This method provide a chance to handle and response to the authentication challenge before downloading could start.
- parameter downloader: The downloader which receives this challenge.
- parameter challenge: An object that contains the request for authentication.
- parameter completionHandler: A handler that your delegate method must call.
- Note: This method is a forward from `URLSessionDelegate.urlSession(:didReceiveChallenge:completionHandler:)`. Please refer to the document of it in `URLSessionDelegate`.
*/
func downloader(_ downloader: ImageDownloader, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
/**
Called when an session level authentication challenge is received.
This method provide a chance to handle and response to the authentication challenge before downloading could start.
- parameter downloader: The downloader which receives this challenge.
- parameter task: The task whose request requires authentication.
- parameter challenge: An object that contains the request for authentication.
- parameter completionHandler: A handler that your delegate method must call.
- Note: This method is a forward from `URLSessionTaskDelegate.urlSession(:task:didReceiveChallenge:completionHandler:)`. Please refer to the document of it in `URLSessionTaskDelegate`.
*/
func downloader(_ downloader: ImageDownloader, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
}
extension AuthenticationChallengeResponsable {
func downloader(_ downloader: ImageDownloader, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust {
if let trustedHosts = downloader.trustedHosts, trustedHosts.contains(challenge.protectionSpace.host) {
let credential = URLCredential(trust: challenge.protectionSpace.serverTrust!)
completionHandler(.useCredential, credential)
return
}
}
completionHandler(.performDefaultHandling, nil)
}
func downloader(_ downloader: ImageDownloader, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
completionHandler(.performDefaultHandling, nil)
}
}
/// `ImageDownloader` represents a downloading manager for requesting the image with a URL from server.
open class ImageDownloader {
class ImageFetchLoad {
var contents = [(callback: CallbackPair, options: KingfisherOptionsInfo)]()
var responseData = NSMutableData()
var downloadTaskCount = 0
var downloadTask: RetrieveImageDownloadTask?
var cancelSemaphore: DispatchSemaphore?
}
// MARK: - Public property
/// The duration before the download 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<String>?
/// 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: sessionHandler, delegateQueue: nil)
}
}
/// Whether the download requests should use pipline or not. Default is false.
open var requestsUsePipelining = false
fileprivate let sessionHandler: ImageDownloaderSessionHandler
fileprivate var session: URLSession?
/// 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: AuthenticationChallengeResponsable?
// MARK: - Internal property
let barrierQueue: DispatchQueue
let processQueue: DispatchQueue
let cancelQueue: DispatchQueue
typealias CallbackPair = (progressBlock: ImageDownloaderProgressBlock?, completionHandler: ImageDownloaderCompletionHandler?)
var fetchLoads = [URL: ImageFetchLoad]()
// MARK: - Public method
/// The default downloader.
public static let `default` = ImageDownloader(name: "default")
/**
Init 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.")
}
barrierQueue = DispatchQueue(label: "com.onevcat.Kingfisher.ImageDownloader.Barrier.\(name)", attributes: .concurrent)
processQueue = DispatchQueue(label: "com.onevcat.Kingfisher.ImageDownloader.Process.\(name)", attributes: .concurrent)
cancelQueue = DispatchQueue(label: "com.onevcat.Kingfisher.ImageDownloader.Cancel.\(name)")
sessionHandler = ImageDownloaderSessionHandler(name: name)
// Provide a default implement for challenge responder.
authenticationChallengeResponder = sessionHandler
session = URLSession(configuration: sessionConfiguration, delegate: sessionHandler, delegateQueue: .main)
}
deinit {
session?.invalidateAndCancel()
}
func fetchLoad(for url: URL) -> ImageFetchLoad? {
var fetchLoad: ImageFetchLoad?
barrierQueue.sync(flags: .barrier) { fetchLoad = fetchLoads[url] }
return fetchLoad
}
/**
Download an image with a URL and option.
- parameter url: Target URL.
- parameter retrieveImageTask: The task to cooperate with cache. Pass `nil` if you are not trying to use downloader and cache.
- parameter options: The options could control download behavior. See `KingfisherOptionsInfo`.
- parameter progressBlock: Called when the download progress updated.
- parameter completionHandler: Called when the download progress finishes.
- returns: A downloading task. You could call `cancel` on it to stop the downloading process.
*/
@discardableResult
open func downloadImage(with url: URL,
retrieveImageTask: RetrieveImageTask? = nil,
options: KingfisherOptionsInfo? = nil,
progressBlock: ImageDownloaderProgressBlock? = nil,
completionHandler: ImageDownloaderCompletionHandler? = nil) -> RetrieveImageDownloadTask?
{
if let retrieveImageTask = retrieveImageTask, retrieveImageTask.cancelledBeforeDownloadStarting {
completionHandler?(nil, NSError(domain: KingfisherErrorDomain, code: KingfisherError.downloadCancelledBeforeStarting.rawValue, userInfo: nil), nil, nil)
return nil
}
let timeout = self.downloadTimeout == 0.0 ? 15.0 : self.downloadTimeout
// We need to set the URL as the load key. So before setup progress, we need to ask the `requestModifier` for a final URL.
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout)
request.httpShouldUsePipelining = requestsUsePipelining
if let modifier = options?.modifier {
guard let r = modifier.modified(for: request) else {
completionHandler?(nil, NSError(domain: KingfisherErrorDomain, code: KingfisherError.downloadCancelledBeforeStarting.rawValue, userInfo: nil), nil, nil)
return nil
}
request = r
}
// There is a possibility that request modifier changed the url to `nil` or empty.
guard let url = request.url, !url.absoluteString.isEmpty else {
completionHandler?(nil, NSError(domain: KingfisherErrorDomain, code: KingfisherError.invalidURL.rawValue, userInfo: nil), nil, nil)
return nil
}
var downloadTask: RetrieveImageDownloadTask?
setup(progressBlock: progressBlock, with: completionHandler, for: url, options: options) {(session, fetchLoad) -> Void in
if fetchLoad.downloadTask == nil {
let dataTask = session.dataTask(with: request)
fetchLoad.downloadTask = RetrieveImageDownloadTask(internalTask: dataTask, ownerDownloader: self)
dataTask.priority = options?.downloadPriority ?? URLSessionTask.defaultPriority
self.delegate?.imageDownloader(self, willDownloadImageForURL: url, with: request)
dataTask.resume()
// Hold self while the task is executing.
self.sessionHandler.downloadHolder = self
}
fetchLoad.downloadTaskCount += 1
downloadTask = fetchLoad.downloadTask
retrieveImageTask?.downloadTask = downloadTask
}
return downloadTask
}
}
// MARK: - Download method
extension ImageDownloader {
// A single key may have multiple callbacks. Only download once.
func setup(progressBlock: ImageDownloaderProgressBlock?, with completionHandler: ImageDownloaderCompletionHandler?, for url: URL, options: KingfisherOptionsInfo?, started: @escaping ((URLSession, ImageFetchLoad) -> Void)) {
func prepareFetchLoad() {
barrierQueue.sync(flags: .barrier) {
let loadObjectForURL = fetchLoads[url] ?? ImageFetchLoad()
let callbackPair = (progressBlock: progressBlock, completionHandler: completionHandler)
loadObjectForURL.contents.append((callbackPair, options ?? KingfisherEmptyOptionsInfo))
fetchLoads[url] = loadObjectForURL
if let session = session {
started(session, loadObjectForURL)
}
}
}
if let fetchLoad = fetchLoad(for: url), fetchLoad.downloadTaskCount == 0 {
if fetchLoad.cancelSemaphore == nil {
fetchLoad.cancelSemaphore = DispatchSemaphore(value: 0)
}
cancelQueue.async {
_ = fetchLoad.cancelSemaphore?.wait(timeout: .distantFuture)
fetchLoad.cancelSemaphore = nil
prepareFetchLoad()
}
} else {
prepareFetchLoad()
}
}
private func cancelTaskImpl(_ task: RetrieveImageDownloadTask, fetchLoad: ImageFetchLoad? = nil, ignoreTaskCount: Bool = false) {
func getFetchLoad(from task: RetrieveImageDownloadTask) -> ImageFetchLoad? {
guard let URL = task.internalTask.originalRequest?.url,
let imageFetchLoad = self.fetchLoads[URL] else
{
return nil
}
return imageFetchLoad
}
guard let imageFetchLoad = fetchLoad ?? getFetchLoad(from: task) else {
return
}
imageFetchLoad.downloadTaskCount -= 1
if ignoreTaskCount || imageFetchLoad.downloadTaskCount == 0 {
task.internalTask.cancel()
}
}
func cancel(_ task: RetrieveImageDownloadTask) {
barrierQueue.sync(flags: .barrier) { cancelTaskImpl(task) }
}
/// Cancel all downloading tasks. It will trigger the completion handlers for all not-yet-finished
/// downloading tasks with an NSURLErrorCancelled error.
///
/// If you need to only cancel a certain task, call `cancel()` on the `RetrieveImageDownloadTask`
/// returned by the downloading methods.
public func cancelAll() {
barrierQueue.sync(flags: .barrier) {
fetchLoads.forEach { v in
let fetchLoad = v.value
guard let task = fetchLoad.downloadTask else { return }
cancelTaskImpl(task, fetchLoad: fetchLoad, ignoreTaskCount: true)
}
}
}
}
// MARK: - NSURLSessionDataDelegate
/// Delegate class for `NSURLSessionTaskDelegate`.
/// The session object will hold its delegate until it gets invalidated.
/// If we use `ImageDownloader` as the session delegate, it will not be released.
/// So we need an additional handler to break the retain cycle.
// See https://github.com/onevcat/Kingfisher/issues/235
final class ImageDownloaderSessionHandler: NSObject, URLSessionDataDelegate, AuthenticationChallengeResponsable {
private let downloaderQueue: DispatchQueue
// The holder will keep downloader not released while a data task is being executed.
// It will be set when the task started, and reset when the task finished.
private var _downloadHolder: ImageDownloader?
var downloadHolder: ImageDownloader? {
get {
return downloaderQueue.sync { _downloadHolder }
}
set {
downloaderQueue.sync { _downloadHolder = newValue }
}
}
init(name: String) {
downloaderQueue = DispatchQueue(label: "com.onevcat.Kingfisher.ImageDownloader.SessionHandler.\(name)")
super.init()
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
guard let downloader = downloadHolder else {
completionHandler(.cancel)
return
}
var disposition = URLSession.ResponseDisposition.allow
if let statusCode = (response as? HTTPURLResponse)?.statusCode,
let url = dataTask.originalRequest?.url,
!(downloader.delegate ?? downloader).isValidStatusCode(statusCode, for: downloader)
{
let error = NSError(domain: KingfisherErrorDomain,
code: KingfisherError.invalidStatusCode.rawValue,
userInfo: [KingfisherErrorStatusCodeKey: statusCode, NSLocalizedDescriptionKey: HTTPURLResponse.localizedString(forStatusCode: statusCode)])
// Needs to be called before callCompletionHandlerFailure() because it removes downloadHolder
if let downloader = downloadHolder {
downloader.delegate?.imageDownloader(downloader, didFinishDownloadingImageForURL: url, with: response, error: error)
}
callCompletionHandlerFailure(error: error, url: url)
disposition = .cancel
}
completionHandler(disposition)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
guard let downloader = downloadHolder else {
return
}
if let url = dataTask.originalRequest?.url, let fetchLoad = downloader.fetchLoad(for: url) {
fetchLoad.responseData.append(data)
if let expectedLength = dataTask.response?.expectedContentLength {
for content in fetchLoad.contents {
DispatchQueue.main.async {
content.callback.progressBlock?(Int64(fetchLoad.responseData.length), expectedLength)
}
}
}
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let url = task.originalRequest?.url else {
return
}
if let downloader = downloadHolder {
downloader.delegate?.imageDownloader(downloader, didFinishDownloadingImageForURL: url, with: task.response, error: error)
}
guard error == nil else {
callCompletionHandlerFailure(error: error!, url: url)
return
}
processImage(for: task, url: url)
}
/**
This method is exposed since the compiler requests. Do not call it.
*/
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard let downloader = downloadHolder else {
return
}
downloader.authenticationChallengeResponder?.downloader(downloader, didReceive: challenge, completionHandler: completionHandler)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard let downloader = downloadHolder else {
return
}
downloader.authenticationChallengeResponder?.downloader(downloader, task: task, didReceive: challenge, completionHandler: completionHandler)
}
private func cleanFetchLoad(for url: URL) {
guard let downloader = downloadHolder else {
return
}
downloader.barrierQueue.sync(flags: .barrier) {
downloader.fetchLoads.removeValue(forKey: url)
if downloader.fetchLoads.isEmpty {
downloadHolder = nil
}
}
}
private func callCompletionHandlerFailure(error: Error, url: URL) {
guard let downloader = downloadHolder, let fetchLoad = downloader.fetchLoad(for: url) else {
return
}
// We need to clean the fetch load first, before actually calling completion handler.
cleanFetchLoad(for: url)
var leftSignal: Int
repeat {
leftSignal = fetchLoad.cancelSemaphore?.signal() ?? 0
} while leftSignal != 0
for content in fetchLoad.contents {
content.options.callbackDispatchQueue.safeAsync {
content.callback.completionHandler?(nil, error as NSError, url, nil)
}
}
}
private func processImage(for task: URLSessionTask, url: URL) {
guard let downloader = downloadHolder else {
return
}
// We are on main queue when receiving this.
downloader.processQueue.async {
guard let fetchLoad = downloader.fetchLoad(for: url) else {
return
}
self.cleanFetchLoad(for: url)
let data: Data?
let fetchedData = fetchLoad.responseData as Data
if let delegate = downloader.delegate {
data = delegate.imageDownloader(downloader, didDownload: fetchedData, for: url)
} else {
data = fetchedData
}
// Cache the processed images. So we do not need to re-process the image if using the same processor.
// Key is the identifier of processor.
var imageCache: [String: Image] = [:]
for content in fetchLoad.contents {
let options = content.options
let completionHandler = content.callback.completionHandler
let callbackQueue = options.callbackDispatchQueue
let processor = options.processor
var image = imageCache[processor.identifier]
if let data = data, image == nil {
image = processor.process(item: .data(data), options: options)
// Add the processed image to cache.
// If `image` is nil, nothing will happen (since the key is not existing before).
imageCache[processor.identifier] = image
}
if let image = image {
downloader.delegate?.imageDownloader(downloader, didDownload: image, for: url, with: task.response)
let imageModifier = options.imageModifier
let finalImage = imageModifier.modify(image)
if options.backgroundDecode {
let decodedImage = finalImage.kf.decoded
callbackQueue.safeAsync { completionHandler?(decodedImage, nil, url, data) }
} else {
callbackQueue.safeAsync { completionHandler?(finalImage, nil, url, data) }
}
} else {
if let res = task.response as? HTTPURLResponse , res.statusCode == 304 {
let notModified = NSError(domain: KingfisherErrorDomain, code: KingfisherError.notModified.rawValue, userInfo: nil)
completionHandler?(nil, notModified, url, nil)
continue
}
let badData = NSError(domain: KingfisherErrorDomain, code: KingfisherError.badData.rawValue, userInfo: nil)
callbackQueue.safeAsync { completionHandler?(nil, badData, url, nil) }
}
}
}
}
}
// Placeholder. For retrieving extension methods of ImageDownloaderDelegate
extension ImageDownloader: ImageDownloaderDelegate {}