import Foundation enum ImageCacheControllerError: Error { case failureGeneratingUniqueKey } public final class ImageCacheController: CacheController { // MARK: Permanent Cache // batch inserts to db and selectively decides which file variant to download. Used when inserting multiple image urls from media-list endpoint via ArticleCacheController. func add(urls: [URL], groupKey: GroupKey, individualCompletion: @escaping IndividualCompletionBlock, groupCompletion: @escaping GroupCompletionBlock) { // todo: could use DRY gatekeeper logic with superclass. this will likely get refactored out so holding off. if gatekeeper.shouldQueueAddCompletion(groupKey: groupKey) { gatekeeper.queueAddCompletion(groupKey: groupKey) { self.add(urls: urls, groupKey: groupKey, individualCompletion: individualCompletion, groupCompletion: groupCompletion) return } } else { gatekeeper.addCurrentlyAddingGroupKey(groupKey) } if gatekeeper.numberOfQueuedGroupCompletions(for: groupKey) > 0 { gatekeeper.queueGroupCompletion(groupKey: groupKey, groupCompletion: groupCompletion) return } gatekeeper.queueGroupCompletion(groupKey: groupKey, groupCompletion: groupCompletion) dbWriter.add(urls: urls, groupKey: groupKey) { [weak self] (result) in self?.finishDBAdd(groupKey: groupKey, individualCompletion: individualCompletion, groupCompletion: groupCompletion, result: result) } } // MARK: Logic taken from ImageController private let imageFetcher: ImageFetcher fileprivate let memoryCache: NSCache init(moc: NSManagedObjectContext, session: Session, configuration: Configuration) { self.imageFetcher = ImageFetcher(session: session, configuration: configuration) memoryCache = NSCache() memoryCache.totalCostLimit = 10000000 // pixel count let fileWriter = CacheFileWriter(fetcher: imageFetcher) let dbWriter = ImageCacheDBWriter(imageFetcher: imageFetcher, cacheBackgroundContext: moc) super.init(dbWriter: dbWriter, fileWriter: fileWriter) } struct FetchResult { let data: Data let response: URLResponse } private var dataCompletionManager = ImageControllerCompletionManager() // called when saving an image to persistent cache has completed. Hook here to allow additional saving into memoryCache. override func finishFileSave(data: Data, mimeType: String?, uniqueKey: CacheController.UniqueKey, url: URL) { guard let image = self.createImage(data: data, mimeType: mimeType) else { return } addToMemoryCache(image, url: url) } // MARK: Errors public enum FetchError: Error { case dataNotFound case invalidOrEmptyURL case invalidImageCache case invalidResponse case duplicateRequest case fileError case dbError case `deinit` } // MARK: Fetching /// Fetches data from a given URL. Coalesces completion blocks so the same data isn't requested multiple times. func fetchData(withURL url: URL, priority: Float, failure: @escaping (Error) -> Void, success: @escaping (Data, URLResponse) -> Void) -> String? { guard let uniqueKey = imageFetcher.uniqueKeyForURL(url, type: .image) else { failure(ImageCacheControllerError.failureGeneratingUniqueKey) return nil } let token = UUID().uuidString let completion = ImageControllerDataCompletion(success: success, failure: failure) dataCompletionManager.add(completion, priority: priority, forIdentifier: uniqueKey, token: token) { [weak self] (isFirst) in guard let self = self else { return } guard isFirst else { return } let schemedURL = (url as NSURL).wmf_urlByPrependingSchemeIfSchemeless() as URL let acceptAnyContentType = ["Accept": "*/*"] let task = self.imageFetcher.dataForURL(schemedURL, persistType: .image, headers: acceptAnyContentType) { [weak self] (result) in guard let self = self else { return } switch result { case .failure(let error): guard !self.isCancellationError(error) else { return } default: break } self.dataCompletionManager.complete(uniqueKey, enumerator: { (completion) in // DDLogDebug("complete: \(url) \(token)") switch result { case .success(let result): completion.success(result.data, result.response) case .failure(let error): completion.failure(error) } }) } if let task = task { task.priority = priority self.dataCompletionManager.add(task, forIdentifier: uniqueKey) task.resume() } } return token } func fetchData(withURL url: URL, failure: @escaping (Error) -> Void, success: @escaping (Data, URLResponse) -> Void) { _ = fetchData(withURL: url, priority: URLSessionTask.defaultPriority, failure: failure, success: success) } /// Fetches an image from a given URL. Coalesces completion blocks so the same data isn't requested multiple times. public func fetchImage(withURL url: URL?, priority: Float, failure: @escaping (Error) -> Void, success: @escaping (ImageDownload) -> Void) -> String? { assert(Thread.isMainThread) guard let url = url else { failure(FetchError.invalidOrEmptyURL) return nil } if let memoryCachedImage = memoryCachedImage(withURL: url) { success(ImageDownload(url: url, image: memoryCachedImage, origin: .memory)) return nil } return fetchData(withURL: url, priority: priority, failure: failure) { (data, response) in guard let image = self.createImage(data: data, mimeType: response.mimeType) else { DispatchQueue.main.async { failure(FetchError.invalidResponse) } return } self.addToMemoryCache(image, url: url) DispatchQueue.main.async { success(ImageDownload(url: url, image: image, origin: .unknown)) } } } public func fetchImage(withURL url: URL?, failure: @escaping (Error) -> Void, success: @escaping (ImageDownload) -> Void) { _ = fetchImage(withURL: url, priority: URLSessionTask.defaultPriority, failure: failure, success: success) } public func cancelFetch(withURL url: URL?, token: String?) { guard let url = url, let uniqueKey = imageFetcher.uniqueKeyForURL(url, type: .image), let token = token else { return } dataCompletionManager.cancel(uniqueKey, token: token) } /// Populate the cache for a given URL public func prefetch(withURL url: URL?) { prefetch(withURL: url) { } } /// Populate the cache for a given URL public func prefetch(withURL url: URL?, completion: @escaping () -> Void) { _ = fetchImage(withURL: url, priority: URLSessionTask.lowPriority, failure: { (error) in }) { (download) in } } // MARK: Cache /// Retrieves the image with the given url from the memory or file cache public func cachedImage(withURL url: URL?) -> Image? { guard let url = url else { return nil } if let memoryCachedImage = memoryCachedImage(withURL: url) { return memoryCachedImage } guard let typedImageData = data(withURL: url), let data = typedImageData.data, let image = createImage(data: data, mimeType: typedImageData.MIMEType) else { return nil } addToMemoryCache(image, url: url) return image } /// Retrieves the image data from the file cache func data(withURL url: URL) -> TypedImageData? { guard let response = imageFetcher.cachedResponseForURL(url, type: .image) else { return TypedImageData(data: nil, MIMEType: nil) } return TypedImageData(data: response.data, MIMEType: response.response.mimeType) } // MARK: Memory Cache /// Retrieves the image with the given url from the memory cache public func memoryCachedImage(withURL url: URL) -> Image? { guard let uniqueKey = imageFetcher.uniqueKeyForURL(url, type: .image) else { return nil } return memoryCache.object(forKey: uniqueKey as NSString) } func addToMemoryCache(_ image: Image, url: URL) { guard let uniqueKey = imageFetcher.uniqueKeyForURL(url, type: .image) else { return } memoryCache.setObject(image, forKey: (uniqueKey as NSString), cost: Int(image.staticImage.size.width * image.staticImage.size.height)) } // MARK: Utilities private func createImage(data: Data, mimeType: String?) -> Image? { if mimeType == "image/gif", let animatedImage = FLAnimatedImage.wmf_animatedImage(with: data), let staticImage = animatedImage.wmf_staticImage { return Image(staticImage: staticImage, animatedImage: animatedImage) } guard let source = CGImageSourceCreateWithData(data as CFData, nil), CGImageSourceGetCount(source) > 0 else { return nil } let options = [kCGImageSourceShouldCache as String: NSNumber(value: true)] as CFDictionary guard let cgImage = CGImageSourceCreateImageAtIndex(source, 0, options) else { return nil } let image: UIImage if let orientation = getUIImageOrientation(from: source, options: options) { image = UIImage(cgImage: cgImage, scale: 1, orientation: orientation) } else { image = UIImage(cgImage: cgImage) } return Image(staticImage: image, animatedImage: nil) } private func getUIImageOrientation(from imageSource: CGImageSource, options: CFDictionary) -> UIImage.Orientation? { guard let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, options) as? [String: Any], let orientationRawValue = properties[kCGImagePropertyOrientation as String] as? UInt32, let cgOrientation = CGImagePropertyOrientation(rawValue: orientationRawValue) else { return nil } switch cgOrientation { case .up: return .up case .upMirrored: return .upMirrored case .down: return .down case .downMirrored: return .downMirrored case .left: return .left case .leftMirrored: return .leftMirrored case .right: return .right case .rightMirrored: return .rightMirrored } } public override func cancelAllTasks() { super.cancelAllTasks() dataCompletionManager.cancelAll() } } private extension ImageCacheController { func isCancellationError(_ error: Error?) -> Bool { return error?.isCancellationError ?? false } } fileprivate extension Error { var isCancellationError: Bool { let potentialCancellationError = self as NSError return potentialCancellationError.domain == NSURLErrorDomain && potentialCancellationError.code == NSURLErrorCancelled } } @objc(WMFTypedImageData) open class TypedImageData: NSObject { @objc public let data:Data? @objc public let MIMEType:String? @objc public init(data data_: Data?, MIMEType type_: String?) { data = data_ MIMEType = type_ } } let WMFExtendedFileAttributeNameMIMEType = "org.wikimedia.MIMEType"