Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 79 additions & 5 deletions Modules/Sources/AsyncImageKit/ImageDownloader.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import UIKit
import CryptoKit
import AVFoundation

/// The system that downloads and caches images, and prepares them for display.
@ImageDownloaderActor
Expand Down Expand Up @@ -27,21 +29,43 @@ public final class ImageDownloader {
self.cache = cache
}

public func image(from url: URL, host: MediaHostProtocol? = nil, options: ImageRequestOptions = .init()) async throws -> UIImage {
public func image(
from url: URL,
host: MediaHostProtocol? = nil,
options: ImageRequestOptions = ImageRequestOptions()
) async throws -> UIImage {
try await image(for: ImageRequest(url: url, host: host, options: options))
}

public func image(for request: ImageRequest) async throws -> UIImage {
let options = request.options
let key = makeKey(for: request.source.url, size: options.size)
if options.isMemoryCacheEnabled, let image = cache[key] {

if let cachedImage = try self.fetch(key, options: request.options) {
return cachedImage
}

if case .video(let url, let mediaHost) = request.source {
let asset: AVAsset = try await mediaHost?.authenticatedAsset(for: url) ?? AVURLAsset(url: url)
let generator = AVAssetImageGenerator(asset: asset)

if let size = options.size {
generator.maximumSize = CGSize(width: size.width, height: size.height)
}

let result = try await generator.image(at: .zero)
let image = UIImage(cgImage: result.image)

try store(image, for: key, options: options)

return image
}

let data = try await data(for: request)
let image = try await ImageDecoder.makeImage(from: data, size: options.size.map(CGSize.init))
if options.isMemoryCacheEnabled {
cache[key] = image
}

try store(image, for: key, options: options)

return image
}

Expand All @@ -63,6 +87,8 @@ public final class ImageDownloader {
return request
case .urlRequest(let urlRequest):
return urlRequest
case .video:
preconditionFailure("Cannot make URLRequest for video – use AVFoundation APIs instead")
}
}

Expand Down Expand Up @@ -151,6 +177,53 @@ public final class ImageDownloader {
throw ImageDownloaderError.unacceptableStatusCode(response.statusCode)
}
}

// MARK: Manual caching
private func fetch(_ key: String, options: ImageRequestOptions) throws -> UIImage? {

if options.isMemoryCacheEnabled, let image = cache[key] {
return image
}

guard options.isDiskCacheEnabled else {
return nil
}

let path = path(for: key)

guard FileManager.default.fileExists(atPath: path.path()) else {
return nil
}

let pngData = try Data(contentsOf: path)
return UIImage(data: pngData)
}

private func store(_ image: UIImage, for key: String, options: ImageRequestOptions) throws {

if options.isMemoryCacheEnabled {
cache[key] = image
}

// If the image is immutable, store it in the disk cache "forever"
guard options.isDiskCacheEnabled, options.mutability == .immutable else {
return
}

guard let data = image.pngData() else {
return
}

let path = self.path(for: key)

try FileManager.default.createDirectory(at: path.deletingLastPathComponent(), withIntermediateDirectories: true)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

store it in the disk cache "forever"

I suggest to store these images in the existing URLCache (see urlSessionWithCache). I recently used URLCache store storing thumbnails for Gutenberg patterns, and it worked pretty well – it doesn't have to be a network response. You can always re-create a preview from AVURLAsset if needed. If these are stored on disk with no cleanup mechanism, it will leave lingering files on disk increasing the space the app takes on disk.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If these are stored on disk with no cleanup mechanism, it will leave lingering files on disk increasing the space the app takes on disk

The cleanup mechanism is that the system will purge anything in the Caches directory if it needs to, and #24972 adds a cache cleaning mechanism that can do it on user request. There's no such great way to do this with URLCache because under the hood it's a sqlite DB in ~/Library/Caches/com.automattic.jetpack/org.automattic.ImageDownloader for the in-memory DB and stores its files in the same directory. Because that's all managed by urlsessiond I'm not sure we can just erase it?

I'm not sure what we lose by storing the files in ~/Library/Caches/image-cache aside from the automatic cache eviction based on size? But again, if the system needs the space it'll just take it back?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just realized we could call ImageDownloader.clearURLSessionCache() which is presumably thread-safe so never mind that part. 🤦

It'd be weird/kinda annoying to work it into the cache clearing stuff but it's certainly doable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The longer I look at this the less I feel like URLCache should be used as a general-purpose disk cache?

If I want to store something in it, I need to use a URLResponse alongside my data, but do I also need to manually add fake HTTP headers so that URLCache will store it indefinitely? If I just use an empty URLResponse will it store the results at all? Storing bytes on-disk seems much more straightforward in comparison tbh

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be happy to have a custom disk cache with LRU cleanup as an alternative. If you'd like to address it separately, that's OK.

But again, if the system needs the space it'll just take it back?

It's only the last resort. It's best to be a good citizen and not waste space by setting a limit and cleanup up automatically.

try data.write(to: path)
}

private func path(for key: String) -> URL {
let hash = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
return URL.cachesDirectory.appending(path: "image-cache").appending(path: hash)
}
}

@ImageDownloaderActor
Expand Down Expand Up @@ -196,4 +269,5 @@ private extension URLSession {

public protocol MediaHostProtocol: Sendable {
@MainActor func authenticatedRequest(for url: URL) async throws -> URLRequest
@MainActor func authenticatedAsset(for url: URL) async throws -> AVURLAsset
}
2 changes: 1 addition & 1 deletion Modules/Sources/AsyncImageKit/ImagePrefetcher.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import UIKit
import Foundation
import Collections

@ImageDownloaderActor
Expand Down
51 changes: 50 additions & 1 deletion Modules/Sources/AsyncImageKit/ImageRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,21 @@ public final class ImageRequest: Sendable {
public enum Source: Sendable {
case url(URL, MediaHostProtocol?)
case urlRequest(URLRequest)
case video(URL, MediaHostProtocol?)

var url: URL? {
switch self {
case .url(let url, _): url
case .urlRequest(let request): request.url
case .video(let url, _): url
}
}

var host: MediaHostProtocol? {
switch self {
case .url(_, let host): host
case .urlRequest: nil
case .video(_, let host): host
}
}
}
Expand All @@ -25,6 +35,34 @@ public final class ImageRequest: Sendable {
self.source = .urlRequest(urlRequest)
self.options = options
}

public init(videoUrl: URL, host: MediaHostProtocol? = nil, options: ImageRequestOptions = ImageRequestOptions()) {
self.source = .video(videoUrl, host)
self.options = options
}
}

/// Defines the mutability characteristics of a resource for caching purposes.
///
/// This affects how aggressively the resource is cached:
/// - `.mutable`: Resources are cached in memory and URLSession's disk cache (with eviction policies)
/// - `.immutable`: Resources are additionally cached persistently on disk (never evicted automatically)
public enum ResourceMutability: Sendable {
/// Items that might change over time while keeping the same URL.
///
/// These resources are cached in memory and URLSession's disk cache, but not persistently.
/// The cache may be evicted based on system policies.
///
/// **Example**: Site Icons, Gravatars - the same URL might return different images as users update their profiles
case mutable

/// Items that will never be modified after creation.
///
/// These resources are cached persistently on disk in addition to in-memory caching.
/// Once downloaded, they remain cached indefinitely since the content at the URL will never change.
///
/// **Example**: Support ticket attachments - these URLs point to immutable content
case immutable
}

public struct ImageRequestOptions: Hashable, Sendable {
Expand All @@ -38,14 +76,25 @@ public struct ImageRequestOptions: Hashable, Sendable {
/// with a relatively high disk capacity. By default, `true`.
public var isDiskCacheEnabled = true

/// Indicates how this asset should be cached based on whether the content can change.
///
/// Use `.mutable` (default) for resources that might change over time (like user avatars).
/// Use `.immutable` for resources that never change (like support attachments) to enable
/// persistent disk caching that survives app restarts and system cache evictions.
///
/// - Note: Only applies when `isDiskCacheEnabled` is `true`
public let mutability: ResourceMutability

public init(
size: ImageSize? = nil,
isMemoryCacheEnabled: Bool = true,
isDiskCacheEnabled: Bool = true
isDiskCacheEnabled: Bool = true,
mutability: ResourceMutability = .mutable
) {
self.size = size
self.isMemoryCacheEnabled = isMemoryCacheEnabled
self.isDiskCacheEnabled = isDiskCacheEnabled
self.mutability = mutability
}
}

Expand Down
Loading