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
12 changes: 10 additions & 2 deletions Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ let package = Package(
.library(name: "WordPressShared", targets: ["WordPressShared"]),
.library(name: "WordPressUI", targets: ["WordPressUI"]),
.library(name: "WordPressReader", targets: ["WordPressReader"]),
.library(name: "WordPressCore", targets: ["WordPressCore"]),
.library(name: "WordPressCoreProtocols", targets: ["WordPressCoreProtocols"]),
],
dependencies: [
.package(url: "https://github.com/airbnb/lottie-ios", from: "4.4.0"),
Expand Down Expand Up @@ -137,7 +139,7 @@ let package = Package(
name: "Support",
dependencies: [
"AsyncImageKit",
"WordPressCore",
"WordPressCoreProtocols",
]
),
.target(name: "TextBundle"),
Expand All @@ -152,10 +154,15 @@ let package = Package(
], swiftSettings: [.swiftLanguageMode(.v5)]),
.target(name: "WordPressFlux", swiftSettings: [.swiftLanguageMode(.v5)]),
.target(name: "WordPressCore", dependencies: [
"WordPressCoreProtocols",
"WordPressShared",
.product(name: "WordPressAPI", package: "wordpress-rs")
.product(name: "WordPressAPI", package: "wordpress-rs"),
]
),
.target(name: "WordPressCoreProtocols", dependencies: [
// This package should never have dependencies – it exists to expose protocols implemented in WordPressCore
// to UI code, because `wordpress-rs` doesn't work nicely with previews.
]),
.target(name: "WordPressLegacy", dependencies: ["DesignSystem", "WordPressShared"]),
.target(name: "WordPressSharedObjC", resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)]),
.target(
Expand All @@ -177,6 +184,7 @@ let package = Package(
"DesignSystem",
"WordPressShared",
"WordPressLegacy",
.product(name: "ColorStudio", package: "color-studio"),
.product(name: "Reachability", package: "Reachability"),
],
resources: [.process("Resources")],
Expand Down
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)
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