deep-linking-sample/Apps/Wikipedia/WMF Framework/ImageCacheController.swift
Javier Cicchelli 9bcdaa697b [Setup] Basic project structure (#1)
This PR contains all the work related to setting up this project as required to implement the [Assignment](https://repo.rock-n-code.com/rock-n-code/deep-linking-assignment/wiki/Assignment) on top, as intended.

To summarise this work:
- [x] created a new **Xcode** project;
- [x] cloned the `Wikipedia` app and inserted it into the **Xcode** project;
- [x] created the `Locations` app and also, its `Libraries` package;
- [x] created the `Shared` package to share dependencies between the apps;
- [x] added a `Makefile` file and implemented some **environment** and **help** commands.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#1
2023-04-08 18:37:13 +00:00

313 lines
12 KiB
Swift

import Foundation
enum ImageCacheControllerError: Error {
case failureGeneratingUniqueKey
}
public final class ImageCacheController: CacheController {
// MARK: Permanent Cache
// batch inserts to db and selectively decides which file variant to download. Used when inserting multiple image urls from media-list endpoint via ArticleCacheController.
func add(urls: [URL], groupKey: GroupKey, individualCompletion: @escaping IndividualCompletionBlock, groupCompletion: @escaping GroupCompletionBlock) {
// todo: could use DRY gatekeeper logic with superclass. this will likely get refactored out so holding off.
if gatekeeper.shouldQueueAddCompletion(groupKey: groupKey) {
gatekeeper.queueAddCompletion(groupKey: groupKey) {
self.add(urls: urls, groupKey: groupKey, individualCompletion: individualCompletion, groupCompletion: groupCompletion)
return
}
} else {
gatekeeper.addCurrentlyAddingGroupKey(groupKey)
}
if gatekeeper.numberOfQueuedGroupCompletions(for: groupKey) > 0 {
gatekeeper.queueGroupCompletion(groupKey: groupKey, groupCompletion: groupCompletion)
return
}
gatekeeper.queueGroupCompletion(groupKey: groupKey, groupCompletion: groupCompletion)
dbWriter.add(urls: urls, groupKey: groupKey) { [weak self] (result) in
self?.finishDBAdd(groupKey: groupKey, individualCompletion: individualCompletion, groupCompletion: groupCompletion, result: result)
}
}
// MARK: Logic taken from ImageController
private let imageFetcher: ImageFetcher
fileprivate let memoryCache: NSCache<NSString, Image>
init(moc: NSManagedObjectContext, session: Session, configuration: Configuration) {
self.imageFetcher = ImageFetcher(session: session, configuration: configuration)
memoryCache = NSCache<NSString, Image>()
memoryCache.totalCostLimit = 10000000 // pixel count
let fileWriter = CacheFileWriter(fetcher: imageFetcher)
let dbWriter = ImageCacheDBWriter(imageFetcher: imageFetcher, cacheBackgroundContext: moc)
super.init(dbWriter: dbWriter, fileWriter: fileWriter)
}
struct FetchResult {
let data: Data
let response: URLResponse
}
private var dataCompletionManager = ImageControllerCompletionManager<ImageControllerDataCompletion>()
// called when saving an image to persistent cache has completed. Hook here to allow additional saving into memoryCache.
override func finishFileSave(data: Data, mimeType: String?, uniqueKey: CacheController.UniqueKey, url: URL) {
guard let image = self.createImage(data: data, mimeType: mimeType) else {
return
}
addToMemoryCache(image, url: url)
}
// MARK: Errors
public enum FetchError: Error {
case dataNotFound
case invalidOrEmptyURL
case invalidImageCache
case invalidResponse
case duplicateRequest
case fileError
case dbError
case `deinit`
}
// MARK: Fetching
/// Fetches data from a given URL. Coalesces completion blocks so the same data isn't requested multiple times.
func fetchData(withURL url: URL, priority: Float, failure: @escaping (Error) -> Void, success: @escaping (Data, URLResponse) -> Void) -> String? {
guard let uniqueKey = imageFetcher.uniqueKeyForURL(url, type: .image) else {
failure(ImageCacheControllerError.failureGeneratingUniqueKey)
return nil
}
let token = UUID().uuidString
let completion = ImageControllerDataCompletion(success: success, failure: failure)
dataCompletionManager.add(completion, priority: priority, forIdentifier: uniqueKey, token: token) { [weak self] (isFirst) in
guard let self = self else {
return
}
guard isFirst else {
return
}
let schemedURL = (url as NSURL).wmf_urlByPrependingSchemeIfSchemeless() as URL
let acceptAnyContentType = ["Accept": "*/*"]
let task = self.imageFetcher.dataForURL(schemedURL, persistType: .image, headers: acceptAnyContentType) { [weak self] (result) in
guard let self = self else {
return
}
switch result {
case .failure(let error):
guard !self.isCancellationError(error) else {
return
}
default:
break
}
self.dataCompletionManager.complete(uniqueKey, enumerator: { (completion) in
// DDLogDebug("complete: \(url) \(token)")
switch result {
case .success(let result):
completion.success(result.data, result.response)
case .failure(let error):
completion.failure(error)
}
})
}
if let task = task {
task.priority = priority
self.dataCompletionManager.add(task, forIdentifier: uniqueKey)
task.resume()
}
}
return token
}
func fetchData(withURL url: URL, failure: @escaping (Error) -> Void, success: @escaping (Data, URLResponse) -> Void) {
_ = fetchData(withURL: url, priority: URLSessionTask.defaultPriority, failure: failure, success: success)
}
/// Fetches an image from a given URL. Coalesces completion blocks so the same data isn't requested multiple times.
public func fetchImage(withURL url: URL?, priority: Float, failure: @escaping (Error) -> Void, success: @escaping (ImageDownload) -> Void) -> String? {
assert(Thread.isMainThread)
guard let url = url else {
failure(FetchError.invalidOrEmptyURL)
return nil
}
if let memoryCachedImage = memoryCachedImage(withURL: url) {
success(ImageDownload(url: url, image: memoryCachedImage, origin: .memory))
return nil
}
return fetchData(withURL: url, priority: priority, failure: failure) { (data, response) in
guard let image = self.createImage(data: data, mimeType: response.mimeType) else {
DispatchQueue.main.async {
failure(FetchError.invalidResponse)
}
return
}
self.addToMemoryCache(image, url: url)
DispatchQueue.main.async {
success(ImageDownload(url: url, image: image, origin: .unknown))
}
}
}
public func fetchImage(withURL url: URL?, failure: @escaping (Error) -> Void, success: @escaping (ImageDownload) -> Void) {
_ = fetchImage(withURL: url, priority: URLSessionTask.defaultPriority, failure: failure, success: success)
}
public func cancelFetch(withURL url: URL?, token: String?) {
guard let url = url,
let uniqueKey = imageFetcher.uniqueKeyForURL(url, type: .image),
let token = token else {
return
}
dataCompletionManager.cancel(uniqueKey, token: token)
}
/// Populate the cache for a given URL
public func prefetch(withURL url: URL?) {
prefetch(withURL: url) { }
}
/// Populate the cache for a given URL
public func prefetch(withURL url: URL?, completion: @escaping () -> Void) {
_ = fetchImage(withURL: url, priority: URLSessionTask.lowPriority, failure: { (error) in }) { (download) in }
}
// MARK: Cache
/// Retrieves the image with the given url from the memory or file cache
public func cachedImage(withURL url: URL?) -> Image? {
guard let url = url else {
return nil
}
if let memoryCachedImage = memoryCachedImage(withURL: url) {
return memoryCachedImage
}
guard let typedImageData = data(withURL: url),
let data = typedImageData.data,
let image = createImage(data: data, mimeType: typedImageData.MIMEType) else {
return nil
}
addToMemoryCache(image, url: url)
return image
}
/// Retrieves the image data from the file cache
func data(withURL url: URL) -> TypedImageData? {
guard let response = imageFetcher.cachedResponseForURL(url, type: .image) else {
return TypedImageData(data: nil, MIMEType: nil)
}
return TypedImageData(data: response.data, MIMEType: response.response.mimeType)
}
// MARK: Memory Cache
/// Retrieves the image with the given url from the memory cache
public func memoryCachedImage(withURL url: URL) -> Image? {
guard let uniqueKey = imageFetcher.uniqueKeyForURL(url, type: .image) else {
return nil
}
return memoryCache.object(forKey: uniqueKey as NSString)
}
func addToMemoryCache(_ image: Image, url: URL) {
guard let uniqueKey = imageFetcher.uniqueKeyForURL(url, type: .image) else {
return
}
memoryCache.setObject(image, forKey: (uniqueKey as NSString), cost: Int(image.staticImage.size.width * image.staticImage.size.height))
}
// MARK: Utilities
private func createImage(data: Data, mimeType: String?) -> Image? {
if mimeType == "image/gif", let animatedImage = FLAnimatedImage.wmf_animatedImage(with: data), let staticImage = animatedImage.wmf_staticImage {
return Image(staticImage: staticImage, animatedImage: animatedImage)
}
guard let source = CGImageSourceCreateWithData(data as CFData, nil), CGImageSourceGetCount(source) > 0 else {
return nil
}
let options = [kCGImageSourceShouldCache as String: NSNumber(value: true)] as CFDictionary
guard let cgImage = CGImageSourceCreateImageAtIndex(source, 0, options) else {
return nil
}
let image: UIImage
if let orientation = getUIImageOrientation(from: source, options: options) {
image = UIImage(cgImage: cgImage, scale: 1, orientation: orientation)
} else {
image = UIImage(cgImage: cgImage)
}
return Image(staticImage: image, animatedImage: nil)
}
private func getUIImageOrientation(from imageSource: CGImageSource, options: CFDictionary) -> UIImage.Orientation? {
guard
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, options) as? [String: Any],
let orientationRawValue = properties[kCGImagePropertyOrientation as String] as? UInt32,
let cgOrientation = CGImagePropertyOrientation(rawValue: orientationRawValue)
else {
return nil
}
switch cgOrientation {
case .up: return .up
case .upMirrored: return .upMirrored
case .down: return .down
case .downMirrored: return .downMirrored
case .left: return .left
case .leftMirrored: return .leftMirrored
case .right: return .right
case .rightMirrored: return .rightMirrored
}
}
public override func cancelAllTasks() {
super.cancelAllTasks()
dataCompletionManager.cancelAll()
}
}
private extension ImageCacheController {
func isCancellationError(_ error: Error?) -> Bool {
return error?.isCancellationError ?? false
}
}
fileprivate extension Error {
var isCancellationError: Bool {
let potentialCancellationError = self as NSError
return potentialCancellationError.domain == NSURLErrorDomain && potentialCancellationError.code == NSURLErrorCancelled
}
}
@objc(WMFTypedImageData)
open class TypedImageData: NSObject {
@objc public let data:Data?
@objc public let MIMEType:String?
@objc public init(data data_: Data?, MIMEType type_: String?) {
data = data_
MIMEType = type_
}
}
let WMFExtendedFileAttributeNameMIMEType = "org.wikimedia.MIMEType"