From 0f8a25c803bf92c84597fba650f270b88c5ce8be Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:45:29 -0700 Subject: [PATCH] Add video support to AsyncImageKit --- ...nimagedImage.swift => AnimatedImage.swift} | 0 .../AsyncImageKit/ImageDownloader.swift | 84 +++++++++- .../AsyncImageKit/ImagePrefetcher.swift | 2 +- .../Sources/AsyncImageKit/ImageRequest.swift | 51 +++++- .../Views/CachedAsyncImage.swift | 158 ++++++++++++++++-- .../WordPressUI/Views/SiteIconView.swift | 4 +- WordPress/Classes/Networking/MediaHost.swift | 5 + .../MediaRequestAuthenticator.swift | 19 +++ .../Networking/WordPressDotComClient.swift | 61 ++++++- .../List/NotificationsList/AvatarView.swift | 5 +- 10 files changed, 358 insertions(+), 31 deletions(-) rename Modules/Sources/AsyncImageKit/Helpers/{AnimagedImage.swift => AnimatedImage.swift} (100%) diff --git a/Modules/Sources/AsyncImageKit/Helpers/AnimagedImage.swift b/Modules/Sources/AsyncImageKit/Helpers/AnimatedImage.swift similarity index 100% rename from Modules/Sources/AsyncImageKit/Helpers/AnimagedImage.swift rename to Modules/Sources/AsyncImageKit/Helpers/AnimatedImage.swift diff --git a/Modules/Sources/AsyncImageKit/ImageDownloader.swift b/Modules/Sources/AsyncImageKit/ImageDownloader.swift index 70e1f25f3bf1..ea08a2cab203 100644 --- a/Modules/Sources/AsyncImageKit/ImageDownloader.swift +++ b/Modules/Sources/AsyncImageKit/ImageDownloader.swift @@ -1,4 +1,6 @@ import UIKit +import CryptoKit +import AVFoundation /// The system that downloads and caches images, and prepares them for display. @ImageDownloaderActor @@ -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 } @@ -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") } } @@ -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 @@ -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 } diff --git a/Modules/Sources/AsyncImageKit/ImagePrefetcher.swift b/Modules/Sources/AsyncImageKit/ImagePrefetcher.swift index 7b807ec0dd99..5f6fcf093987 100644 --- a/Modules/Sources/AsyncImageKit/ImagePrefetcher.swift +++ b/Modules/Sources/AsyncImageKit/ImagePrefetcher.swift @@ -1,4 +1,4 @@ -import UIKit +import Foundation import Collections @ImageDownloaderActor diff --git a/Modules/Sources/AsyncImageKit/ImageRequest.swift b/Modules/Sources/AsyncImageKit/ImageRequest.swift index 9573ab0edbd2..04cd96ae504d 100644 --- a/Modules/Sources/AsyncImageKit/ImageRequest.swift +++ b/Modules/Sources/AsyncImageKit/ImageRequest.swift @@ -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 } } } @@ -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 { @@ -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 } } diff --git a/Modules/Sources/AsyncImageKit/Views/CachedAsyncImage.swift b/Modules/Sources/AsyncImageKit/Views/CachedAsyncImage.swift index d6ef77690540..50989b9e4aff 100644 --- a/Modules/Sources/AsyncImageKit/Views/CachedAsyncImage.swift +++ b/Modules/Sources/AsyncImageKit/Views/CachedAsyncImage.swift @@ -4,14 +4,14 @@ import SwiftUI /// It uses `ImageDownloader` to fetch and cache the images. public struct CachedAsyncImage: View where Content: View { @State private var phase: AsyncImagePhase = .empty - private let url: URL? + + private let request: ImageRequest? private let content: (AsyncImagePhase) -> Content private let imageDownloader: ImageDownloader - private let host: MediaHostProtocol? public var body: some View { content(phase) - .task(id: url) { await fetchImage() } + .task(id: request?.source.url) { await fetchImage() } } // MARK: - Initializers @@ -19,7 +19,9 @@ public struct CachedAsyncImage: View where Content: View { /// Initializes an image without any customization. /// Provides a plain color as placeholder public init(url: URL?) where Content == _ConditionalContent { - self.init(url: url) { phase in + let request = url == nil ? nil : ImageRequest(url: url!) + + self.init(request: request) { phase in if let image = phase.image { image } else { @@ -28,15 +30,83 @@ public struct CachedAsyncImage: View where Content: View { } } + public init( + url: URL?, + host: MediaHostProtocol? = nil, + imageDownloader: ImageDownloader = .shared, + mutability: ResourceMutability = .mutable, + @ViewBuilder content: @escaping (AsyncImagePhase) -> Content + ) { + if let url { + let request = ImageRequest(url: url, host: host, options: ImageRequestOptions( + mutability: mutability + )) + + self.init( + request: request, + imageDownloader: imageDownloader, + content: content + ) + } else { + self.init(request: nil, imageDownloader: imageDownloader, content: content) + } + } + /// Allows content customization and providing a placeholder that will be shown /// until the image download is finalized. public init( url: URL?, host: MediaHostProtocol? = nil, + imageDownloader: ImageDownloader = .shared, + mutability: ResourceMutability = .mutable, + @ViewBuilder content: @escaping (Image) -> I, + @ViewBuilder placeholder: @escaping () -> P + ) where Content == _ConditionalContent, I: View, P: View { + if let url { + let request = ImageRequest(url: url, host: host, options: ImageRequestOptions( + mutability: mutability + )) + + self.init( + request: request, + imageDownloader: imageDownloader, + content: content, + placeholder: placeholder + ) + } else { + self.init(request: nil, content: content, placeholder: placeholder) + } + } + + /// Allows content customization and providing a placeholder that will be shown + /// until the image download is finalized. + public init( + videoUrl: URL, + host: MediaHostProtocol? = nil, + imageDownloader: ImageDownloader = .shared, + mutability: ResourceMutability = .mutable, @ViewBuilder content: @escaping (Image) -> I, @ViewBuilder placeholder: @escaping () -> P ) where Content == _ConditionalContent, I: View, P: View { - self.init(url: url, host: host) { phase in + self.init( + request: ImageRequest(videoUrl: videoUrl, host: host, options: ImageRequestOptions( + mutability: mutability + )), + imageDownloader: imageDownloader, + content: content, + placeholder: placeholder + ) + } + + /// Allows content customization and providing a placeholder that will be shown + /// until the image download is finalized. + private init( + request: ImageRequest?, + imageDownloader: ImageDownloader = .shared, + @ViewBuilder content: @escaping (Image) -> I, + @ViewBuilder placeholder: @escaping () -> P + ) where Content == _ConditionalContent, I: View, P: View { + self.init(request: request, imageDownloader: imageDownloader) { phase in if let image = phase.image { content(image) } else { @@ -45,14 +115,12 @@ public struct CachedAsyncImage: View where Content: View { } } - public init( - url: URL?, - host: MediaHostProtocol? = nil, + private init( + request: ImageRequest?, imageDownloader: ImageDownloader = .shared, @ViewBuilder content: @escaping (AsyncImagePhase) -> Content ) { - self.url = url - self.host = host + self.request = request self.imageDownloader = imageDownloader self.content = content } @@ -61,19 +129,15 @@ public struct CachedAsyncImage: View where Content: View { private func fetchImage() async { do { - guard let url else { + guard let request, let url = request.source.url else { phase = .empty return } + if let image = imageDownloader.cachedImage(for: url) { phase = .success(Image(uiImage: image)) } else { - let image: UIImage - if let host { - image = try await imageDownloader.image(from: url, host: host) - } else { - image = try await imageDownloader.image(from: url) - } + let image = try await imageDownloader.image(for: request) phase = .success(Image(uiImage: image)) } } catch { @@ -81,3 +145,63 @@ public struct CachedAsyncImage: View where Content: View { } } } + +fileprivate let testURL = URL(string: "https://i0.wp.com/themes.svn.wordpress.org/twentytwentyfive/1.3/screenshot.png")! + +// This video is the preview because it's not just black for the first frame, and right now video previews only fetch the first frame +fileprivate let videoURL = URL(string: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4")! + +#Preview("Basic Image") { + CachedAsyncImage(url: testURL) +} + +#Preview("Image Sized to fit") { + CachedAsyncImage(url: testURL) { image in + image.resizable().scaledToFit() + } placeholder: { + Text("Loading") + } +} + +#Preview("Image that never loads") { + CachedAsyncImage(url: nil) { image in + Text("This shouldn't be visible") + } placeholder: { + ProgressView("Forever loading...") + } +} + +@available(iOS 18.0, *) +#Preview("Manual State Handling") { + + @Previewable let cases: [String: URL?] = [ + "Success": testURL, + "Failure": URL(string: "example://foo/bar"), + "Never": nil + ] + + TabView { + ForEach(cases.keys.sorted().reversed(), id: \.self) { key in + Tab(key, systemImage: "placeholdertext.fill") { + CachedAsyncImage(url: cases[key]!, host: nil) { phase in + switch phase { + case .success(let image): + image.resizable().aspectRatio(contentMode: .fit) + case .failure: + Color.red + default: + Color.gray + } + } + } + } + } +} + +#Preview("Video") { + CachedAsyncImage(videoUrl: videoURL) { image in + image.resizable().aspectRatio(contentMode: .fit) + } placeholder: { + Text("Loading") + } +} diff --git a/Modules/Sources/WordPressUI/Views/SiteIconView.swift b/Modules/Sources/WordPressUI/Views/SiteIconView.swift index 3d7cec0444bf..595c47cce09b 100644 --- a/Modules/Sources/WordPressUI/Views/SiteIconView.swift +++ b/Modules/Sources/WordPressUI/Views/SiteIconView.swift @@ -23,7 +23,7 @@ public struct SiteIconView: View { @ViewBuilder private var contents: some View { if let imageURL = viewModel.imageURL { - CachedAsyncImage(url: imageURL, host: viewModel.host) { phase in + CachedAsyncImage(url: imageURL, host: viewModel.host, content: { phase in switch phase { case .success(let image): image.resizable().aspectRatio(contentMode: .fit) @@ -32,7 +32,7 @@ public struct SiteIconView: View { default: backgroundColor } - } + }) } else { noIconView } diff --git a/WordPress/Classes/Networking/MediaHost.swift b/WordPress/Classes/Networking/MediaHost.swift index 3aad15939825..d21335900dbb 100644 --- a/WordPress/Classes/Networking/MediaHost.swift +++ b/WordPress/Classes/Networking/MediaHost.swift @@ -1,4 +1,5 @@ import Foundation +import AVFoundation import AsyncImageKit /// Defines a media host for request authentication purposes. @@ -97,4 +98,8 @@ public enum MediaHost: Equatable, Sendable, MediaHostProtocol { public func authenticatedRequest(for url: URL) async throws -> URLRequest { try await MediaRequestAuthenticator().authenticatedRequest(for: url, host: self) } + + public func authenticatedAsset(for url: URL) async throws -> AVURLAsset { + try await MediaRequestAuthenticator().authenticatedAsset(for: url, host: self) + } } diff --git a/WordPress/Classes/Networking/MediaRequestAuthenticator.swift b/WordPress/Classes/Networking/MediaRequestAuthenticator.swift index 9911aefa0308..852e8e5f68ce 100644 --- a/WordPress/Classes/Networking/MediaRequestAuthenticator.swift +++ b/WordPress/Classes/Networking/MediaRequestAuthenticator.swift @@ -1,5 +1,6 @@ import Foundation import AsyncImageKit +import AVFoundation import WordPressData fileprivate let photonHost = "i0.wp.com" @@ -105,6 +106,24 @@ struct MediaRequestAuthenticator { } } + func authenticatedAsset(for url: URL, host: MediaHost) async throws -> AVURLAsset { + switch host { + case .publicSite: AVURLAsset(url: url) + case .publicWPComSite: AVURLAsset(url: url) + case .privateSelfHostedSite: AVURLAsset(url: url) + case .privateWPComSite(let authToken): authenticatedAsset(for: url, authToken: authToken) + case .privateAtomicWPComSite(_, _, let authToken): authenticatedAsset(for: url, authToken: authToken) + } + } + + private func authenticatedAsset(for url: URL, authToken: String) -> AVURLAsset { + let headers: [String: String] = ["Authorization": "Bearer \(authToken)"] + + return AVURLAsset(url: url, options: [ + "AVURLAssetHTTPHeaderFieldsKey": headers + ]) + } + // MARK: - Request Authentication: Specific Scenarios /// Authentication for a WPCom private request. diff --git a/WordPress/Classes/Networking/WordPressDotComClient.swift b/WordPress/Classes/Networking/WordPressDotComClient.swift index 107b8981659f..8b5763f0d191 100644 --- a/WordPress/Classes/Networking/WordPressDotComClient.swift +++ b/WordPress/Classes/Networking/WordPressDotComClient.swift @@ -1,18 +1,22 @@ import Foundation +import AsyncImageKit +import AVFoundation import WordPressAPI import WordPressAPIInternal import Combine -actor WordPressDotComClient { +actor WordPressDotComClient: MediaHostProtocol { + private let authProvider: AutoUpdatingWPComAuthenticationProvider + private let delegate: WpApiClientDelegate let api: WPComApiClient init() { let session = URLSession(configuration: .ephemeral) - let provider = AutoUpdatingWPComAuthenticationProvider(coreDataStack: ContextManager.shared) - let delegate = WpApiClientDelegate( - authProvider: .dynamic(dynamicAuthenticationProvider: provider), + self.authProvider = AutoUpdatingWPComAuthenticationProvider(coreDataStack: ContextManager.shared) + self.delegate = WpApiClientDelegate( + authProvider: .dynamic(dynamicAuthenticationProvider: self.authProvider), requestExecutor: WpRequestExecutor(urlSession: session), middlewarePipeline: WpApiMiddlewarePipeline(middlewares: []), appNotifier: WpComNotifier() @@ -20,6 +24,14 @@ actor WordPressDotComClient { self.api = WPComApiClient(delegate: delegate) } + + func authenticatedRequest(for url: URL) async throws -> URLRequest { + self.authProvider.authorize(URLRequest(url: url)) + } + + func authenticatedAsset(for url: URL) async throws -> AVURLAsset { + self.authProvider.authorize(AVURLAsset(url: url)) + } } final class AutoUpdatingWPComAuthenticationProvider: @unchecked Sendable, WpDynamicAuthenticationProvider { @@ -57,6 +69,47 @@ final class AutoUpdatingWPComAuthenticationProvider: @unchecked Sendable, WpDyna return authentication } + private var authorizationHeaderValue: String? { + switch self.authentication { + case .authorizationHeader(let headerValue): + headerValue + case .bearer(let token): + "Bearer \(token)" + default: nil + } + } + + func authorize(_ request: URLRequest) -> URLRequest { + var mutableRequest = request + + // Don't authorize requests for other domains + guard request.url?.host() == "public-api.wordpress.com" else { + return request + } + + mutableRequest.setValue(self.authorizationHeaderValue, forHTTPHeaderField: "Authorization") + + return mutableRequest + } + + func authorize(_ asset: AVURLAsset) -> AVURLAsset { + + // Don't authorize requests for other domains + guard asset.url.host() == "public-api.wordpress.com" else { + return asset + } + + guard let headerValue = self.authorizationHeaderValue else { + return asset + } + + let headers: [String: String] = ["Authorization": headerValue] + + return AVURLAsset(url: asset.url, options: [ + "AVURLAssetHTTPHeaderFieldsKey": headers + ]) + } + private static func readAuthentication(on stack: CoreDataStack) -> WpAuthentication { do { guard let authToken = try stack.performQuery({ diff --git a/WordPress/Classes/ViewRelated/Views/List/NotificationsList/AvatarView.swift b/WordPress/Classes/ViewRelated/Views/List/NotificationsList/AvatarView.swift index e4523ddf89cb..ac51092fa6ee 100644 --- a/WordPress/Classes/ViewRelated/Views/List/NotificationsList/AvatarView.swift +++ b/WordPress/Classes/ViewRelated/Views/List/NotificationsList/AvatarView.swift @@ -41,6 +41,9 @@ struct AvatarView: View { private let diameter: CGFloat @ScaledMetric private var scale = 1 + @Environment(\.displayScale) + private var screenScale: CGFloat + init( avatarShape: S = Circle(), style: Style, @@ -79,7 +82,7 @@ struct AvatarView: View { private func avatar(url: URL?) -> some View { let processedURL: URL? - let size = Int(ceil(diameter * UIScreen.main.scale)) + let size = Int(ceil(diameter * screenScale)) if let url, let gravatar = AvatarURL(url: url, options: .init(preferredSize: .pixels(size))) { processedURL = gravatar.url } else {