// // ImageCache.swift // Tusker // // Created by Shadowfacts on 8/21/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import UIKit import Cache class ImageCache { static let avatars = ImageCache(name: "Avatars", memoryExpiry: .seconds(60 * 60), diskExpiry: .seconds(60 * 60 * 24), desiredSize: CGSize(width: 50, height: 50)) static let headers = ImageCache(name: "Headers", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60)) static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2)) static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60)) #if DEBUG private static let disableCaching = ProcessInfo.processInfo.environment.keys.contains("DISABLE_IMAGE_CACHE") #else private static let disableCaching = false #endif private let cache: ImageDataCache private var groups = MultiThreadDictionary(name: "ImageCache request groups") private var backgroundQueue = DispatchQueue(label: "ImageCache completion queue", qos: .default) init(name: String, memoryExpiry: CacheExpiry, diskExpiry: CacheExpiry? = nil, desiredSize: CGSize? = nil) { // todo: might not always want to use UIScreen.main for this, e.g. Catalyst? let pixelSize = desiredSize?.applying(.init(scaleX: UIScreen.main.scale, y: UIScreen.main.scale)) self.cache = ImageDataCache(name: name, memoryExpiry: memoryExpiry, diskExpiry: diskExpiry, storeOriginalDataInMemory: diskExpiry == nil, desiredPixelSize: pixelSize) } func get(_ url: URL, completion: ((Data?, UIImage?) -> Void)?) -> Request? { let key = url.absoluteString if !ImageCache.disableCaching, let entry = try? cache.get(key) { if let completion = completion { backgroundQueue.async { completion(entry.data, entry.image) } } return nil } else { if let group = groups[url] { if let completion = completion { return group.addCallback(completion) } return nil } else { let group = createGroup(url: url) let request = group.addCallback(completion) group.run() return request } } } func fetchIfNotCached(_ url: URL) { // if caching is disabled, don't bother fetching since nothing will be done with the result guard !ImageCache.disableCaching else { return } if !((try? cache.has(url.absoluteString)) ?? false), !groups.contains(key: url) { let group = createGroup(url: url) group.run() } } private func createGroup(url: URL) -> RequestGroup { let group = RequestGroup(url: url) { (data, image) in if let data = data { try? self.cache.set(url.absoluteString, data: data) } self.groups.removeValueWithoutReturning(forKey: url) } groups[url] = group return group } func getData(_ url: URL) -> Data? { return try? cache.getData(url.absoluteString) } func get(_ url: URL) -> ImageDataCache.Entry? { return try? cache.get(url.absoluteString) } func cancelWithoutCallback(_ url: URL) { groups[url]?.cancelWithoutCallback() } func reset() throws { try cache.removeAll() } private class RequestGroup { let url: URL private let onFinished: (Data?, UIImage?) -> Void private var task: URLSessionDataTask? private var requests = [Request]() init(url: URL, onFinished: @escaping (Data?, UIImage?) -> Void) { self.url = url self.onFinished = onFinished } deinit { task?.cancel() } func run() { task = URLSession.shared.dataTask(with: url, completionHandler: { (data, response, error) in guard error == nil, let data = data else { self.complete(with: nil) return } self.complete(with: data) }) task!.resume() } private func updatePriority() { task?.priority = max(1.0, URLSessionTask.defaultPriority + 0.1 * Float(requests.filter { !$0.cancelled }.count)) } func addCallback(_ completion: ((Data?, UIImage?) -> Void)?) -> Request { let request = Request(callback: completion) requests.append(request) updatePriority() return request } func cancelWithoutCallback() { if let request = requests.first(where: { $0.callback == nil && !$0.cancelled }) { request.cancel() updatePriority() } } fileprivate func requestCancelled() { let remaining = requests.filter { !$0.cancelled }.count if remaining <= 0 { task?.cancel() complete(with: nil) } else { updatePriority() } } func complete(with data: Data?) { let image = data != nil ? UIImage(data: data!) : nil requests.filter { !$0.cancelled }.forEach { if let callback = $0.callback { callback(data, image) } } self.onFinished(data, image) } } class Request { private weak var group: RequestGroup? private(set) var callback: ((Data?, UIImage?) -> Void)? private(set) var cancelled: Bool = false init(callback: ((Data?, UIImage?) -> Void)?) { self.callback = callback } func cancel() { cancelled = true callback = nil group?.requestCancelled() } } }