ReadKeep/readeck/Data/Utils/KingfisherImagePrefetcher.swift

193 lines
6.6 KiB
Swift

//
// KingfisherImagePrefetcher.swift
// readeck
//
// Created by Ilyas Hallak on 30.11.25.
//
import Foundation
import Kingfisher
/// Wrapper around Kingfisher for prefetching and caching images for offline use
class KingfisherImagePrefetcher {
// MARK: - Public Methods
/// Prefetches images and stores them in Kingfisher cache for offline access
/// - Parameter urls: Array of image URLs to prefetch
func prefetchImages(urls: [URL]) async {
guard !urls.isEmpty else { return }
Logger.sync.info("🔄 Starting Kingfisher prefetch for \(urls.count) images")
logPrefetchURLs(urls)
let options = buildOfflineCachingOptions()
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
let prefetcher = ImagePrefetcher(
urls: urls,
options: options,
progressBlock: { [weak self] skippedResources, failedResources, completedResources in
self?.logPrefetchProgress(
total: urls.count,
completed: completedResources.count,
failed: failedResources.count,
skipped: skippedResources.count
)
},
completionHandler: { [weak self] skippedResources, failedResources, completedResources in
self?.logPrefetchCompletion(
total: urls.count,
completed: completedResources.count,
failed: failedResources.count,
skipped: skippedResources.count
)
// Verify cache after prefetch
Task {
await self?.verifyPrefetchedImages(urls)
continuation.resume()
}
}
)
prefetcher.start()
}
}
/// Caches an image with a custom key for offline retrieval
/// - Parameters:
/// - url: The image URL to download
/// - key: Custom cache key
func cacheImageWithCustomKey(url: URL, key: String) async {
Logger.sync.debug("Caching image with custom key: \(key)")
// Check if already cached
if await isImageCached(forKey: key) {
Logger.sync.debug("Image already cached with key: \(key)")
return
}
// Download and cache with custom key
let image = await downloadImage(from: url)
if let image = image {
try? await ImageCache.default.store(image, forKey: key)
Logger.sync.info("✅ Cached image with custom key: \(key)")
} else {
Logger.sync.warning("❌ Failed to cache image with key: \(key)")
}
}
/// Clears cached images from Kingfisher cache
/// - Parameter urls: Array of image URLs to clear
func clearCachedImages(urls: [URL]) async {
guard !urls.isEmpty else { return }
Logger.sync.info("Clearing Kingfisher cache for \(urls.count) images")
await withTaskGroup(of: Void.self) { group in
for url in urls {
group.addTask {
try? await KingfisherManager.shared.cache.removeImage(forKey: url.cacheKey)
}
}
}
Logger.sync.info("✅ Kingfisher cache cleared for \(urls.count) images")
}
/// Verifies that images are present in cache
/// - Parameter urls: Array of URLs to verify
func verifyPrefetchedImages(_ urls: [URL]) async {
Logger.sync.info("🔍 Verifying prefetched images in cache...")
var cachedCount = 0
var missingCount = 0
for url in urls {
let isCached = await isImageCached(forKey: url.cacheKey)
if isCached {
cachedCount += 1
Logger.sync.debug("✅ Verified in cache: \(url.absoluteString)")
} else {
missingCount += 1
Logger.sync.warning("❌ NOT in cache after prefetch: \(url.absoluteString)")
}
}
Logger.sync.info("📊 Cache verification: \(cachedCount) cached, \(missingCount) missing out of \(urls.count) total")
}
// MARK: - Private Helper Methods
private func buildOfflineCachingOptions() -> KingfisherOptionsInfo {
[
.cacheOriginalImage,
.diskCacheExpiration(.never), // Keep images as long as article is cached
.backgroundDecode,
]
}
private func logPrefetchURLs(_ urls: [URL]) {
for (index, url) in urls.enumerated() {
Logger.sync.debug("[\(index + 1)/\(urls.count)] Prefetching: \(url.absoluteString)")
Logger.sync.debug(" Cache key: \(url.cacheKey)")
}
}
private func logPrefetchProgress(
total: Int,
completed: Int,
failed: Int,
skipped: Int
) {
let progress = completed + failed + skipped
Logger.sync.debug("Prefetch progress: \(progress)/\(total) - completed: \(completed), failed: \(failed), skipped: \(skipped)")
}
private func logPrefetchCompletion(
total: Int,
completed: Int,
failed: Int,
skipped: Int
) {
Logger.sync.info("✅ Prefetch completed: \(completed)/\(total) images cached")
if failed > 0 {
Logger.sync.warning("❌ Failed to cache \(failed) images")
}
if skipped > 0 {
Logger.sync.info("⏭️ Skipped \(skipped) images (already cached)")
}
}
private func isImageCached(forKey key: String) async -> Bool {
await withCheckedContinuation { continuation in
ImageCache.default.retrieveImage(forKey: key) { result in
switch result {
case .success(let cacheResult):
continuation.resume(returning: cacheResult.image != nil)
case .failure:
continuation.resume(returning: false)
}
}
}
}
private func downloadImage(from url: URL) async -> KFCrossPlatformImage? {
await withCheckedContinuation { continuation in
KingfisherManager.shared.retrieveImage(with: url) { result in
switch result {
case .success(let imageResult):
continuation.resume(returning: imageResult.image)
case .failure(let error):
Logger.sync.error("Failed to download image: \(error.localizedDescription)")
continuation.resume(returning: nil)
}
}
}
}
}