// // ImageDownloader.swift // // Copyright (c) 2015 Alamofire Software Foundation (http://alamofire.org/) // // 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. // import Alamofire import Foundation #if os(iOS) || os(tvOS) || os(watchOS) import UIKit #elseif os(macOS) import Cocoa #endif /// Alias for `DataResponse`. public typealias AFIDataResponse = DataResponse /// Alias for `Result`. public typealias AFIResult = Result /// The `RequestReceipt` is an object vended by the `ImageDownloader` when starting a download request. It can be used /// to cancel active requests running on the `ImageDownloader` session. As a general rule, image download requests /// should be cancelled using the `RequestReceipt` instead of calling `cancel` directly on the `request` itself. The /// `ImageDownloader` is optimized to handle duplicate request scenarios as well as pending versus active downloads. open class RequestReceipt { /// The download request created by the `ImageDownloader`. public let request: DataRequest /// The unique identifier for the image filters and completion handlers when duplicate requests are made. public let receiptID: String init(request: DataRequest, receiptID: String) { self.request = request self.receiptID = receiptID } } // MARK: - /// The `ImageDownloader` class is responsible for downloading images in parallel on a prioritized queue. Incoming /// downloads are added to the front or back of the queue depending on the download prioritization. Each downloaded /// image is cached in the underlying `NSURLCache` as well as the in-memory image cache that supports image filters. /// By default, any download request with a cached image equivalent in the image cache will automatically be served the /// cached image representation. Additional advanced features include supporting multiple image filters and completion /// handlers for a single request. open class ImageDownloader { /// The completion handler closure used when an image download completes. public typealias CompletionHandler = (AFIDataResponse) -> Void /// The progress handler closure called periodically during an image download. public typealias ProgressHandler = DataRequest.ProgressHandler // MARK: Helper Types /// Defines the order prioritization of incoming download requests being inserted into the queue. /// /// - fifo: All incoming downloads are added to the back of the queue. /// - lifo: All incoming downloads are added to the front of the queue. public enum DownloadPrioritization { case fifo, lifo } class ResponseHandler { let urlID: String let handlerID: String let request: DataRequest var operations: [(receiptID: String, filter: ImageFilter?, completion: CompletionHandler?)] init(request: DataRequest, handlerID: String, receiptID: String, filter: ImageFilter?, completion: CompletionHandler?) { self.request = request urlID = ImageDownloader.urlIdentifier(for: request.convertible) self.handlerID = handlerID operations = [(receiptID: receiptID, filter: filter, completion: completion)] } } // MARK: Properties /// The image cache used to store all downloaded images in. public let imageCache: ImageRequestCache? /// The credential used for authenticating each download request. open private(set) var credential: URLCredential? /// Response serializer used to convert the image data to UIImage. public var imageResponseSerializer = ImageResponseSerializer() /// The underlying Alamofire `Session` instance used to handle all download requests. public let session: Session let downloadPrioritization: DownloadPrioritization let maximumActiveDownloads: Int var activeRequestCount = 0 var queuedRequests: [Request] = [] var responseHandlers: [String: ResponseHandler] = [:] private let synchronizationQueue: DispatchQueue = { let name = String(format: "org.alamofire.imagedownloader.synchronizationqueue-%08x%08x", arc4random(), arc4random()) return DispatchQueue(label: name) }() private let responseQueue: DispatchQueue = { let name = String(format: "org.alamofire.imagedownloader.responsequeue-%08x%08x", arc4random(), arc4random()) return DispatchQueue(label: name, attributes: .concurrent) }() // MARK: Initialization /// The default instance of `ImageDownloader` initialized with default values. public static let `default` = ImageDownloader() /// Creates a default `URLSessionConfiguration` with common usage parameter values. /// /// - returns: The default `URLSessionConfiguration` instance. open class func defaultURLSessionConfiguration() -> URLSessionConfiguration { let configuration = URLSessionConfiguration.default configuration.headers = .default configuration.httpShouldSetCookies = true configuration.httpShouldUsePipelining = false configuration.requestCachePolicy = .useProtocolCachePolicy configuration.allowsCellularAccess = true configuration.timeoutIntervalForRequest = 60 configuration.urlCache = ImageDownloader.defaultURLCache() return configuration } /// Creates a default `URLCache` with common usage parameter values. /// /// - returns: The default `URLCache` instance. open class func defaultURLCache() -> URLCache { let memoryCapacity = 20 * 1024 * 1024 let diskCapacity = 150 * 1024 * 1024 let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first let imageDownloaderPath = "org.alamofire.imagedownloader" #if targetEnvironment(macCatalyst) return URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, directory: cacheDirectory?.appendingPathComponent(imageDownloaderPath)) #else #if os(macOS) return URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, diskPath: cacheDirectory?.appendingPathComponent(imageDownloaderPath).absoluteString) #else return URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, diskPath: imageDownloaderPath) #endif #endif } /// Initializes the `ImageDownloader` instance with the given configuration, download prioritization, maximum active /// download count and image cache. /// /// - parameter configuration: The `URLSessionConfiguration` to use to create the underlying Alamofire /// `SessionManager` instance. /// - parameter downloadPrioritization: The download prioritization of the download queue. `.fifo` by default. /// - parameter maximumActiveDownloads: The maximum number of active downloads allowed at any given time. /// - parameter imageCache: The image cache used to store all downloaded images in. /// /// - returns: The new `ImageDownloader` instance. public init(configuration: URLSessionConfiguration = ImageDownloader.defaultURLSessionConfiguration(), downloadPrioritization: DownloadPrioritization = .fifo, maximumActiveDownloads: Int = 4, imageCache: ImageRequestCache? = AutoPurgingImageCache()) { session = Session(configuration: configuration, startRequestsImmediately: false) self.downloadPrioritization = downloadPrioritization self.maximumActiveDownloads = maximumActiveDownloads self.imageCache = imageCache } /// Initializes the `ImageDownloader` instance with the given session manager, download prioritization, maximum /// active download count and image cache. /// /// - parameter session: The Alamofire `Session` instance to handle all download requests. /// - parameter downloadPrioritization: The download prioritization of the download queue. `.fifo` by default. /// - parameter maximumActiveDownloads: The maximum number of active downloads allowed at any given time. /// - parameter imageCache: The image cache used to store all downloaded images in. /// /// - returns: The new `ImageDownloader` instance. public init(session: Session, downloadPrioritization: DownloadPrioritization = .fifo, maximumActiveDownloads: Int = 4, imageCache: ImageRequestCache? = AutoPurgingImageCache()) { precondition(!session.startRequestsImmediately, "Session must set `startRequestsImmediately` to `false`.") self.session = session self.downloadPrioritization = downloadPrioritization self.maximumActiveDownloads = maximumActiveDownloads self.imageCache = imageCache } // MARK: Authentication /// Associates an HTTP Basic Auth credential with all future download requests. /// /// - parameter user: The user. /// - parameter password: The password. /// - parameter persistence: The URL credential persistence. `.forSession` by default. open func addAuthentication(user: String, password: String, persistence: URLCredential.Persistence = .forSession) { let credential = URLCredential(user: user, password: password, persistence: persistence) addAuthentication(usingCredential: credential) } /// Associates the specified credential with all future download requests. /// /// - parameter credential: The credential. open func addAuthentication(usingCredential credential: URLCredential) { synchronizationQueue.sync { self.credential = credential } } // MARK: Download /// Creates a download request using the internal Alamofire `SessionManager` instance for the specified URL request. /// /// If the same download request is already in the queue or currently being downloaded, the filter and completion /// handler are appended to the already existing request. Once the request completes, all filters and completion /// handlers attached to the request are executed in the order they were added. Additionally, any filters attached /// to the request with the same identifiers are only executed once. The resulting image is then passed into each /// completion handler paired with the filter. /// /// You should not attempt to directly cancel the `request` inside the request receipt since other callers may be /// relying on the completion of that request. Instead, you should call `cancelRequestForRequestReceipt` with the /// returned request receipt to allow the `ImageDownloader` to optimize the cancellation on behalf of all active /// callers. /// /// - parameter urlRequest: The URL request. /// - parameter cacheKey: An optional key used to identify the image in the cache. Defaults to `nil`. /// - parameter receiptID: The `identifier` for the `RequestReceipt` returned. Defaults to a new, randomly /// generated UUID. /// - parameter serializer: Image response serializer used to convert the image data to `UIImage`. Defaults /// to `nil` which will fall back to the instance `imageResponseSerializer`. /// - parameter filter: The image filter to apply to the image after the download is complete. Defaults /// to `nil`. /// - parameter progress: The closure to be executed periodically during the lifecycle of the request. /// Defaults to `nil`. /// - parameter progressQueue: The dispatch queue to call the progress closure on. Defaults to the main queue. /// - parameter completion: The closure called when the download request is complete. Defaults to `nil`. /// /// - returns: The request receipt for the download request if available. `nil` if the image is stored in the image /// cache and the URL request cache policy allows the cache to be used. @discardableResult open func download(_ urlRequest: URLRequestConvertible, cacheKey: String? = nil, receiptID: String = UUID().uuidString, serializer: ImageResponseSerializer? = nil, filter: ImageFilter? = nil, progress: ProgressHandler? = nil, progressQueue: DispatchQueue = DispatchQueue.main, completion: CompletionHandler? = nil) -> RequestReceipt? { var queuedRequest: DataRequest? synchronizationQueue.sync { // 1) Append the filter and completion handler to a pre-existing request if it already exists let urlID = ImageDownloader.urlIdentifier(for: urlRequest) if let responseHandler = self.responseHandlers[urlID] { responseHandler.operations.append((receiptID: receiptID, filter: filter, completion: completion)) queuedRequest = responseHandler.request return } // 2) Attempt to load the image from the image cache if the cache policy allows it if let nonNilURLRequest = urlRequest.urlRequest { switch nonNilURLRequest.cachePolicy { case .useProtocolCachePolicy, .returnCacheDataElseLoad, .returnCacheDataDontLoad: let cachedImage: Image? if let cacheKey = cacheKey { cachedImage = self.imageCache?.image(withIdentifier: cacheKey) } else { cachedImage = self.imageCache?.image(for: nonNilURLRequest, withIdentifier: filter?.identifier) } if let image = cachedImage { DispatchQueue.main.async { let response = AFIDataResponse(request: urlRequest.urlRequest, response: nil, data: nil, metrics: nil, serializationDuration: 0.0, result: .success(image)) completion?(response) } return } default: break } } // 3) Create the request and set up authentication, validation and response serialization let request = self.session.request(urlRequest) queuedRequest = request if let credential = self.credential { request.authenticate(with: credential) } request.validate() if let progress = progress { request.downloadProgress(queue: progressQueue, closure: progress) } // Generate a unique handler id to check whether the active request has changed while downloading let handlerID = UUID().uuidString request.response(queue: self.responseQueue, responseSerializer: serializer ?? imageResponseSerializer, completionHandler: { response in defer { self.safelyDecrementActiveRequestCount() self.safelyStartNextRequestIfNecessary() } // Early out if the request has changed out from under us guard let handler = self.safelyFetchResponseHandler(withURLIdentifier: urlID), handler.handlerID == handlerID, let responseHandler = self.safelyRemoveResponseHandler(withURLIdentifier: urlID) else { return } switch response.result { case let .success(image): var filteredImages: [String: Image] = [:] for (_, filter, completion) in responseHandler.operations { var filteredImage: Image if let filter = filter { if let alreadyFilteredImage = filteredImages[filter.identifier] { filteredImage = alreadyFilteredImage } else { filteredImage = filter.filter(image) filteredImages[filter.identifier] = filteredImage } } else { filteredImage = image } if let cacheKey = cacheKey { self.imageCache?.add(filteredImage, withIdentifier: cacheKey) } else if let request = response.request { self.imageCache?.add(filteredImage, for: request, withIdentifier: filter?.identifier) } DispatchQueue.main.async { let response = AFIDataResponse(request: response.request, response: response.response, data: response.data, metrics: response.metrics, serializationDuration: response.serializationDuration, result: .success(filteredImage)) completion?(response) } } case .failure: for (_, _, completion) in responseHandler.operations { DispatchQueue.main.async { completion?(response.mapError { AFIError.alamofireError($0) }) } } } }) // 4) Store the response handler for use when the request completes let responseHandler = ResponseHandler(request: request, handlerID: handlerID, receiptID: receiptID, filter: filter, completion: completion) self.responseHandlers[urlID] = responseHandler // 5) Either start the request or enqueue it depending on the current active request count if self.isActiveRequestCountBelowMaximumLimit() { self.start(request) } else { self.enqueue(request) } } if let request = queuedRequest { return RequestReceipt(request: request, receiptID: receiptID) } return nil } /// Creates a download request using the internal Alamofire `SessionManager` instance for each specified URL request. /// /// For each request, if the same download request is already in the queue or currently being downloaded, the /// filter and completion handler are appended to the already existing request. Once the request completes, all /// filters and completion handlers attached to the request are executed in the order they were added. /// Additionally, any filters attached to the request with the same identifiers are only executed once. The /// resulting image is then passed into each completion handler paired with the filter. /// /// You should not attempt to directly cancel any of the `request`s inside the request receipts array since other /// callers may be relying on the completion of that request. Instead, you should call /// `cancelRequestForRequestReceipt` with the returned request receipt to allow the `ImageDownloader` to optimize /// the cancellation on behalf of all active callers. /// /// - parameter urlRequests: The URL requests. /// - parameter filter The image filter to apply to the image after each download is complete. /// - parameter progress: The closure to be executed periodically during the lifecycle of the request. Defaults /// to `nil`. /// - parameter progressQueue: The dispatch queue to call the progress closure on. Defaults to the main queue. /// - parameter completion: The closure called when each download request is complete. /// /// - returns: The request receipts for the download requests if available. If an image is stored in the image /// cache and the URL request cache policy allows the cache to be used, a receipt will not be returned /// for that request. @discardableResult open func download(_ urlRequests: [URLRequestConvertible], filter: ImageFilter? = nil, progress: ProgressHandler? = nil, progressQueue: DispatchQueue = DispatchQueue.main, completion: CompletionHandler? = nil) -> [RequestReceipt] { urlRequests.compactMap { download($0, filter: filter, progress: progress, progressQueue: progressQueue, completion: completion) } } /// Cancels the request contained inside the receipt calls the completion handler with a request cancelled error. /// /// - Parameter requestReceipt: The request receipt to cancel. open func cancelRequest(with requestReceipt: RequestReceipt) { synchronizationQueue.sync { let urlID = ImageDownloader.urlIdentifier(for: requestReceipt.request.convertible) guard let responseHandler = self.responseHandlers[urlID] else { return } let index = responseHandler.operations.firstIndex { $0.receiptID == requestReceipt.receiptID } if let index = index { let operation = responseHandler.operations.remove(at: index) let response: AFIDataResponse = { let urlRequest = requestReceipt.request.request let error = AFIError.requestCancelled return DataResponse(request: urlRequest, response: nil, data: nil, metrics: nil, serializationDuration: 0.0, result: .failure(error)) }() DispatchQueue.main.async { operation.completion?(response) } } if responseHandler.operations.isEmpty { requestReceipt.request.cancel() self.responseHandlers.removeValue(forKey: urlID) } } } // MARK: Internal - Thread-Safe Request Methods func safelyFetchResponseHandler(withURLIdentifier urlIdentifier: String) -> ResponseHandler? { var responseHandler: ResponseHandler? synchronizationQueue.sync { responseHandler = self.responseHandlers[urlIdentifier] } return responseHandler } func safelyRemoveResponseHandler(withURLIdentifier identifier: String) -> ResponseHandler? { var responseHandler: ResponseHandler? synchronizationQueue.sync { responseHandler = self.responseHandlers.removeValue(forKey: identifier) } return responseHandler } func safelyStartNextRequestIfNecessary() { synchronizationQueue.sync { guard self.isActiveRequestCountBelowMaximumLimit() else { return } guard let request = self.dequeue() else { return } self.start(request) } } func safelyDecrementActiveRequestCount() { synchronizationQueue.sync { self.activeRequestCount -= 1 } } // MARK: Internal - Non Thread-Safe Request Methods func start(_ request: Request) { request.resume() activeRequestCount += 1 } func enqueue(_ request: Request) { switch downloadPrioritization { case .fifo: queuedRequests.append(request) case .lifo: queuedRequests.insert(request, at: 0) } } @discardableResult func dequeue() -> Request? { var request: Request? if !queuedRequests.isEmpty { request = queuedRequests.removeFirst() } return request } func isActiveRequestCountBelowMaximumLimit() -> Bool { activeRequestCount < maximumActiveDownloads } static func urlIdentifier(for urlRequest: URLRequestConvertible) -> String { var urlID: String? do { urlID = try urlRequest.asURLRequest().url?.absoluteString } catch { // No-op } return urlID ?? "" } }