// // ImageCache.swift // Tusker // // Created by Shadowfacts on 8/21/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import UIKit #if os(visionOS) private let imageScale: CGFloat = 2 #else @MainActor private let imageScale = UIScreen.main.scale #endif final class ImageCache: @unchecked Sendable { @MainActor static let avatars = ImageCache(name: "Avatars", memoryExpiry: .seconds(60 * 60), diskExpiry: .seconds(60 * 60 * 24 * 7), desiredSize: CGSize(width: 50, height: 50)) @MainActor static let headers = ImageCache(name: "Headers", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60 * 24 * 7)) @MainActor static let attachments = ImageCache(name: "Attachments", memoryExpiry: .seconds(60 * 2)) @MainActor static let emojis = ImageCache(name: "Emojis", memoryExpiry: .seconds(60 * 5), diskExpiry: .seconds(60 * 60 * 24 * 7)) #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 let desiredPixelSize: CGSize? @MainActor 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: imageScale, y: imageScale)) self.desiredPixelSize = pixelSize self.cache = ImageDataCache(name: name, memoryExpiry: memoryExpiry, diskExpiry: diskExpiry, storeOriginalDataInMemory: diskExpiry == nil, desiredPixelSize: pixelSize) } func get(_ url: URL, loadOriginal: Bool = false, completion: (@Sendable (Data?, UIImage?) -> Void)?) -> Request? { if !ImageCache.disableCaching, let entry = try? cache.get(url.absoluteString, loadOriginal: loadOriginal) { completion?(entry.data, entry.image) return nil } else { return getFromSource(url, completion: completion) } } func getFromSource(_ url: URL, completion: (@Sendable (Data?, UIImage?) -> Void)?) -> Request? { return Task.detached(priority: .userInitiated) { let result = await self.fetch(url: url) switch result { case .data(let data): completion?(data, nil) case .dataAndImage(let data, let image): completion?(data, image) case .none: completion?(nil, nil) } } } func get(_ url: URL, loadOriginal: Bool = false) async -> (Data?, UIImage?) { if !ImageCache.disableCaching, let entry = try? cache.get(url.absoluteString, loadOriginal: loadOriginal) { return (entry.data, entry.image) } else { let result = await self.fetch(url: url) switch result { case .data(let data): return (data, nil) case .dataAndImage(let data, let image): return (data, image) case .none: return (nil, nil) } } } 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) { Task.detached(priority: .medium) { _ = await self.fetch(url: url) } } } private func fetch(url: URL) async -> FetchResult { guard let (data, _) = try? await URLSession.shared.data(from: url) else { return .none } guard let image = UIImage(data: data) else { try? cache.set(url.absoluteString, data: data, image: nil) return .data(data) } let preparedImage: UIImage? if let desiredPixelSize { preparedImage = await image.byPreparingThumbnail(ofSize: desiredPixelSize) } else { preparedImage = await image.byPreparingForDisplay() } try? cache.set(url.absoluteString, data: data, image: preparedImage ?? image) return .dataAndImage(data, preparedImage ?? image) } func getData(_ url: URL) -> Data? { return try? cache.getData(url.absoluteString) } func get(_ url: URL, loadOriginal: Bool = false) -> ImageDataCache.Entry? { return try? cache.get(url.absoluteString, loadOriginal: loadOriginal) } func reset() throws { try cache.removeAll() } func getDiskSizeInBytes() -> Int64? { return cache.disk?.getSizeInBytes() } typealias Request = Task enum FetchResult { case data(Data) case dataAndImage(Data, UIImage) case none } }