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
313 lines
12 KiB
Swift
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"
|