diff --git a/Demo/Demo/Kingfisher-Demo/Base.lproj/Main.storyboard b/Demo/Demo/Kingfisher-Demo/Base.lproj/Main.storyboard index 2af261af5..33de6a3be 100644 --- a/Demo/Demo/Kingfisher-Demo/Base.lproj/Main.storyboard +++ b/Demo/Demo/Kingfisher-Demo/Base.lproj/Main.storyboard @@ -1,9 +1,9 @@ - + - + @@ -491,9 +491,33 @@ - + + + + + + + + + + + + + + + + + + + + @@ -992,6 +1016,22 @@ + + + + + + + + + + + + + + + + diff --git a/Demo/Demo/Kingfisher-Demo/ViewControllers/LivePhotoViewController.swift b/Demo/Demo/Kingfisher-Demo/ViewControllers/LivePhotoViewController.swift new file mode 100644 index 000000000..77d7eb4a7 --- /dev/null +++ b/Demo/Demo/Kingfisher-Demo/ViewControllers/LivePhotoViewController.swift @@ -0,0 +1,65 @@ +// +// LivePhotoViewController.swift +// Kingfisher +// +// Created by onevcat on 2024/10/05. +// +// Copyright (c) 2024 Wei Wang +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import UIKit +import PhotosUI +import Kingfisher + +class LivePhotoViewController: UIViewController { + + private var livePhotoView: PHLivePhotoView! + + override func viewDidLoad() { + super.viewDidLoad() + title = "Live Photo" + setupOperationNavigationBar() + + livePhotoView = PHLivePhotoView() + livePhotoView.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(livePhotoView) + NSLayoutConstraint.activate([ + livePhotoView.heightAnchor.constraint(equalToConstant: 300), + livePhotoView.widthAnchor.constraint(equalToConstant: 300), + livePhotoView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + livePhotoView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -30) + ]) + + let urls = [ + "https://github.com/onevcat/Kingfisher-TestImages/raw/refs/heads/master/LivePhotos/live_photo_sample.HEIC", + "https://github.com/onevcat/Kingfisher-TestImages/raw/refs/heads/master/LivePhotos/live_photo_sample.MOV" + ].compactMap(URL.init) + livePhotoView.kf.setImage(with: urls, completionHandler: { result in + switch result { + case .success(let r): + print("Live Photo done. \(r.loadingInfo.cacheType)") + print("Info: \(String(describing: r.info))") + case .failure(let error): + print("Live Photo error: \(error)") + } + }) + } +} diff --git a/Demo/Kingfisher-Demo.xcodeproj/project.pbxproj b/Demo/Kingfisher-Demo.xcodeproj/project.pbxproj index 8cedd1200..0093041e1 100644 --- a/Demo/Kingfisher-Demo.xcodeproj/project.pbxproj +++ b/Demo/Kingfisher-Demo.xcodeproj/project.pbxproj @@ -54,6 +54,7 @@ D12E0CB61C47F9C100AC98AD /* NormalLoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12E0C941C47F91800AC98AD /* NormalLoadingViewController.swift */; }; D12EB83E24DD902300329EE1 /* TextAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12EB83D24DD902300329EE1 /* TextAttachmentViewController.swift */; }; D12EB84024DDB9E100329EE1 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D12EB83F24DDB9E000329EE1 /* LaunchScreen.storyboard */; }; + D12F67682CB10AE000AB63AB /* LivePhotoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12F67672CB10AD900AB63AB /* LivePhotoViewController.swift */; }; D1679A461C4E78B20020FD12 /* Kingfisher-watchOS-Demo Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D1679A451C4E78B20020FD12 /* Kingfisher-watchOS-Demo Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D16CC3D824E03FEA00F1A515 /* AVAssetImageGeneratorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16CC3D724E03FEA00F1A515 /* AVAssetImageGeneratorViewController.swift */; }; D198F41E25EDC11500C53E0D /* LazyVStackDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D198F41D25EDC11500C53E0D /* LazyVStackDemo.swift */; }; @@ -204,6 +205,7 @@ D12E0CA11C47F92200AC98AD /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D12EB83D24DD902300329EE1 /* TextAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextAttachmentViewController.swift; sourceTree = ""; }; D12EB83F24DDB9E000329EE1 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; + D12F67672CB10AD900AB63AB /* LivePhotoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivePhotoViewController.swift; sourceTree = ""; }; D13F49C21BEDA53F00CE335D /* Kingfisher-tvOS-Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Kingfisher-tvOS-Demo.app"; sourceTree = BUILT_PRODUCTS_DIR; }; D16218A4238EAA67004A1C6C /* Kingfisher-Demo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Kingfisher-Demo.entitlements"; sourceTree = ""; }; D1679A391C4E78B20020FD12 /* Kingfisher-watchOS-Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Kingfisher-watchOS-Demo.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -386,6 +388,7 @@ D1A1CCA921A1936300263AD8 /* ViewControllers */ = { isa = PBXGroup; children = ( + D12F67672CB10AD900AB63AB /* LivePhotoViewController.swift */, D10AC99721A300C9005F057C /* ProcessorCollectionViewController.swift */, 4B1C7A3C21A256E300CE9D31 /* InfinityCollectionViewController.swift */, D1CE1BCF21A1AFA300419000 /* TransitionViewController.swift */, @@ -731,6 +734,7 @@ 078DCB512BCFEFB40008114E /* PHPickerResultViewController.swift in Sources */, D1EDF7422C9F01270017FFA5 /* Issue2295View.swift in Sources */, D1A1CCA321A1879600263AD8 /* MainViewController.swift in Sources */, + D12F67682CB10AE000AB63AB /* LivePhotoViewController.swift in Sources */, 4BC0ED4A29A6EE78003E9CD1 /* Issue2035View.swift in Sources */, D1F06F3721AAEACF000B1C38 /* GIFViewController.swift in Sources */, 4B120CA726B91BB70060B092 /* TransitionViewDemo.swift in Sources */, diff --git a/Kingfisher.xcodeproj/project.pbxproj b/Kingfisher.xcodeproj/project.pbxproj index bf1bcd281..93bd0ab46 100644 --- a/Kingfisher.xcodeproj/project.pbxproj +++ b/Kingfisher.xcodeproj/project.pbxproj @@ -66,6 +66,10 @@ D12E0C571C47F23500AC98AD /* KingfisherTestHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12E0C4C1C47F23500AC98AD /* KingfisherTestHelper.swift */; }; D12E0C581C47F23500AC98AD /* UIButtonExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12E0C4E1C47F23500AC98AD /* UIButtonExtensionTests.swift */; }; D12EB83C24DD8EFC00329EE1 /* NSTextAttachment+Kingfisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12EB83B24DD8EFC00329EE1 /* NSTextAttachment+Kingfisher.swift */; }; + D12F67602CAC2DBF00AB63AB /* ImageDownloader+LivePhoto.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12F675F2CAC2DB700AB63AB /* ImageDownloader+LivePhoto.swift */; }; + D12F67622CAC32BF00AB63AB /* KingfisherManager+LivePhoto.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12F67612CAC32B800AB63AB /* KingfisherManager+LivePhoto.swift */; }; + D12F67642CAC330A00AB63AB /* LivePhotoSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12F67632CAC330600AB63AB /* LivePhotoSource.swift */; }; + D12F67662CB022FC00AB63AB /* PHLivePhotoView+Kingfisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12F67652CB022EB00AB63AB /* PHLivePhotoView+Kingfisher.swift */; }; D13646742165A1A100A33652 /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = D13646732165A1A100A33652 /* Result.swift */; }; D16CC3D624E02E9500F1A515 /* AVAssetImageDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D16CC3D524E02E9500F1A515 /* AVAssetImageDataProvider.swift */; }; D16FEA3A23078C63006E67D5 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = D16FE9F623078C63006E67D5 /* LICENSE */; }; @@ -117,6 +121,7 @@ D1E56445219B16330057AAE3 /* ImageDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E56444219B16330057AAE3 /* ImageDataProvider.swift */; }; D1ED2D401AD2D09F00CFC3EB /* Kingfisher.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D1ED2D351AD2D09F00CFC3EB /* Kingfisher.framework */; }; D1F1F6FF24625EC600910725 /* RetryStrategyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F1F6FE24625EC600910725 /* RetryStrategyTests.swift */; }; + D1F66CC12CB2CF2E004959F3 /* LivePhotoSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F66CC02CB2CF2D004959F3 /* LivePhotoSourceTests.swift */; }; D8FCF6A821C5A0E500F9ABC0 /* RedirectHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8FCF6A721C5A0E500F9ABC0 /* RedirectHandler.swift */; }; D9638BA61C7DC71F0046523D /* ImagePrefetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9638BA41C7DC71F0046523D /* ImagePrefetcherTests.swift */; }; E9E3ED8B2B1F66B200734CFF /* HasImageComponent+Kingfisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E3ED8A2B1F66B200734CFF /* HasImageComponent+Kingfisher.swift */; }; @@ -213,6 +218,10 @@ D12E0C4D1C47F23500AC98AD /* KingfisherTests-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "KingfisherTests-Bridging-Header.h"; sourceTree = ""; }; D12E0C4E1C47F23500AC98AD /* UIButtonExtensionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIButtonExtensionTests.swift; sourceTree = ""; }; D12EB83B24DD8EFC00329EE1 /* NSTextAttachment+Kingfisher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSTextAttachment+Kingfisher.swift"; sourceTree = ""; }; + D12F675F2CAC2DB700AB63AB /* ImageDownloader+LivePhoto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageDownloader+LivePhoto.swift"; sourceTree = ""; }; + D12F67612CAC32B800AB63AB /* KingfisherManager+LivePhoto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KingfisherManager+LivePhoto.swift"; sourceTree = ""; }; + D12F67632CAC330600AB63AB /* LivePhotoSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivePhotoSource.swift; sourceTree = ""; }; + D12F67652CB022EB00AB63AB /* PHLivePhotoView+Kingfisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PHLivePhotoView+Kingfisher.swift"; sourceTree = ""; }; D1356CEA2B273AEC009554C8 /* Documentation.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Documentation.docc; sourceTree = ""; }; D13646732165A1A100A33652 /* Result.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Result.swift; sourceTree = ""; }; D16CC3D524E02E9500F1A515 /* AVAssetImageDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVAssetImageDataProvider.swift; sourceTree = ""; }; @@ -293,6 +302,7 @@ D1ED2D351AD2D09F00CFC3EB /* Kingfisher.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Kingfisher.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D1ED2D3F1AD2D09F00CFC3EB /* KingfisherTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KingfisherTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D1F1F6FE24625EC600910725 /* RetryStrategyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryStrategyTests.swift; sourceTree = ""; }; + D1F66CC02CB2CF2D004959F3 /* LivePhotoSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivePhotoSourceTests.swift; sourceTree = ""; }; D1F7607523097532000C5269 /* ImageBinder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageBinder.swift; sourceTree = ""; }; D1F7607623097532000C5269 /* KFImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KFImage.swift; sourceTree = ""; }; D8FCF6A721C5A0E500F9ABC0 /* RedirectHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedirectHandler.swift; sourceTree = ""; }; @@ -361,6 +371,7 @@ D12AB69D215D2BB50013BA68 /* RequestModifier.swift */, D8FCF6A721C5A0E500F9ABC0 /* RedirectHandler.swift */, D12AB69F215D2BB50013BA68 /* ImageDownloader.swift */, + D12F675F2CAC2DB700AB63AB /* ImageDownloader+LivePhoto.swift */, 4BD821612189FC0C0084CC21 /* SessionDelegate.swift */, 4BD821662189FD330084CC21 /* SessionDataTask.swift */, 4B8E2916216F3F7F0095FAD1 /* ImageDownloaderDelegate.swift */, @@ -393,6 +404,7 @@ D12AB6AB215D2BB50013BA68 /* Extensions */ = { isa = PBXGroup; children = ( + D12F67652CB022EB00AB63AB /* PHLivePhotoView+Kingfisher.swift */, D12EB83B24DD8EFC00329EE1 /* NSTextAttachment+Kingfisher.swift */, D12AB6AC215D2BB50013BA68 /* ImageView+Kingfisher.swift */, D12AB6AD215D2BB50013BA68 /* NSButton+Kingfisher.swift */, @@ -412,6 +424,7 @@ D1132C9625919F69003E528D /* KFOptionsSetter.swift */, D12AB6B2215D2BB50013BA68 /* KingfisherError.swift */, D12AB6B3215D2BB50013BA68 /* KingfisherManager.swift */, + D12F67612CAC32B800AB63AB /* KingfisherManager+LivePhoto.swift */, D12AB6B4215D2BB50013BA68 /* KingfisherOptionsInfo.swift */, ); path = General; @@ -480,6 +493,7 @@ 4BCFF7A9219932390055AAC4 /* DiskStorageTests.swift */, D1E564402199C21E0057AAE3 /* StorageExpirationTests.swift */, D1A1CC9E21A0F98600263AD8 /* ImageDataProviderTests.swift */, + D1F66CC02CB2CF2D004959F3 /* LivePhotoSourceTests.swift */, D1BFED94222ACC6B009330C8 /* ImageProcessorTests.swift */, 4BA3BF1D228BCDD100909201 /* DataReceivingSideEffectTests.swift */, D1F1F6FE24625EC600910725 /* RetryStrategyTests.swift */, @@ -653,6 +667,7 @@ isa = PBXGroup; children = ( D1A1CC99219FAB4B00263AD8 /* Source.swift */, + D12F67632CAC330600AB63AB /* LivePhotoSource.swift */, D12AB69E215D2BB50013BA68 /* Resource.swift */, D1E56444219B16330057AAE3 /* ImageDataProvider.swift */, D16CC3D524E02E9500F1A515 /* AVAssetImageDataProvider.swift */, @@ -826,11 +841,13 @@ D16CC3D624E02E9500F1A515 /* AVAssetImageDataProvider.swift in Sources */, D1839845216E333E003927D3 /* Delegate.swift in Sources */, D12AB6D8215D2BB50013BA68 /* ImageTransition.swift in Sources */, + D12F67642CAC330A00AB63AB /* LivePhotoSource.swift in Sources */, D1A37BE8215D365A009B39B7 /* ExtensionHelpers.swift in Sources */, C9286407228584EB00257182 /* ImageProgressive.swift in Sources */, D12AB6DC215D2BB50013BA68 /* ImageProcessor.swift in Sources */, D12AB6D4215D2BB50013BA68 /* Image.swift in Sources */, D1AEB09425890DE7008556DF /* ImageBinder.swift in Sources */, + D12F67622CAC32BF00AB63AB /* KingfisherManager+LivePhoto.swift in Sources */, 4B8E2917216F3F7F0095FAD1 /* ImageDownloaderDelegate.swift in Sources */, E9E3ED8B2B1F66B200734CFF /* HasImageComponent+Kingfisher.swift in Sources */, D1132C9725919F69003E528D /* KFOptionsSetter.swift in Sources */, @@ -857,6 +874,7 @@ 388F37382B4D9CDB0089705C /* DisplayLink.swift in Sources */, D12AB6F4215D2BB50013BA68 /* ImageView+Kingfisher.swift in Sources */, D12AB6FC215D2BB50013BA68 /* UIButton+Kingfisher.swift in Sources */, + D12F67602CAC2DBF00AB63AB /* ImageDownloader+LivePhoto.swift in Sources */, D12AB6E8215D2BB50013BA68 /* GIFAnimatedImage.swift in Sources */, 22FDCE0E2700078B0044D11E /* CPListItem+Kingfisher.swift in Sources */, D13646742165A1A100A33652 /* Result.swift in Sources */, @@ -874,6 +892,7 @@ D12AB724215D2BB50013BA68 /* Box.swift in Sources */, 4B8E291C216F40AA0095FAD1 /* AuthenticationChallengeResponsable.swift in Sources */, 3ADE9AF92A73CD69009A86CA /* String+SHA256.swift in Sources */, + D12F67662CB022FC00AB63AB /* PHLivePhotoView+Kingfisher.swift in Sources */, D12AB710215D2BB50013BA68 /* KingfisherOptionsInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -905,6 +924,7 @@ D16FEA5023078C63006E67D5 /* NSString+Nocilla.m in Sources */, D16FEA4E23078C63006E67D5 /* LSRegexMatcher.m in Sources */, F72CE9CE1FCF17ED00CC522A /* ImageModifierTests.swift in Sources */, + D1F66CC12CB2CF2E004959F3 /* LivePhotoSourceTests.swift in Sources */, D12E0C531C47F23500AC98AD /* ImageViewExtensionTests.swift in Sources */, D16FEA4023078C63006E67D5 /* LSHTTPClientHook.m in Sources */, D16FEA3F23078C63006E67D5 /* LSHTTPRequestDiff.m in Sources */, diff --git a/Sources/Cache/DiskStorage.swift b/Sources/Cache/DiskStorage.swift index 3e018efcd..513c8a1e6 100644 --- a/Sources/Cache/DiskStorage.swift +++ b/Sources/Cache/DiskStorage.swift @@ -146,7 +146,9 @@ public enum DiskStorage { value: T, forKey key: String, expiration: StorageExpiration? = nil, - writeOptions: Data.WritingOptions = []) throws + writeOptions: Data.WritingOptions = [], + forcedExtension: String? = nil + ) throws { guard storageReady else { throw KingfisherError.cacheError(reason: .diskStorageIsNotReady(cacheURL: directoryURL)) @@ -163,7 +165,7 @@ public enum DiskStorage { throw KingfisherError.cacheError(reason: .cannotConvertToData(object: value, error: error)) } - let fileURL = cacheFileURL(forKey: key) + let fileURL = cacheFileURL(forKey: key, forcedExtension: forcedExtension) do { try data.write(to: fileURL, options: writeOptions) } catch { @@ -215,22 +217,34 @@ public enum DiskStorage { /// - extendingExpiration: The expiration policy used by this retrieval action. /// - Throws: An error during converting the data to a value or during the operation of disk files. /// - Returns: The value under `key` if it is valid and found in the storage; otherwise, `nil`. - public func value(forKey key: String, extendingExpiration: ExpirationExtending = .cacheTime) throws -> T? { - try value(forKey: key, referenceDate: Date(), actuallyLoad: true, extendingExpiration: extendingExpiration) + public func value( + forKey key: String, + forcedExtension: String? = nil, + extendingExpiration: ExpirationExtending = .cacheTime + ) throws -> T? { + try value( + forKey: key, + referenceDate: Date(), + actuallyLoad: true, + extendingExpiration: extendingExpiration, + forcedExtension: forcedExtension + ) } func value( forKey key: String, referenceDate: Date, actuallyLoad: Bool, - extendingExpiration: ExpirationExtending) throws -> T? + extendingExpiration: ExpirationExtending, + forcedExtension: String? + ) throws -> T? { guard storageReady else { throw KingfisherError.cacheError(reason: .diskStorageIsNotReady(cacheURL: directoryURL)) } let fileManager = config.fileManager - let fileURL = cacheFileURL(forKey: key) + let fileURL = cacheFileURL(forKey: key, forcedExtension: forcedExtension) let filePath = fileURL.path let fileMaybeCached = maybeCachedCheckingQueue.sync { @@ -276,8 +290,8 @@ public enum DiskStorage { /// /// > This method does not actually load the data from disk, so it is faster than directly loading the cached /// value by checking the nullability of the ``DiskStorage/Backend/value(forKey:extendingExpiration:)`` method. - public func isCached(forKey key: String) -> Bool { - return isCached(forKey: key, referenceDate: Date()) + public func isCached(forKey key: String, forcedExtension: String? = nil) -> Bool { + return isCached(forKey: key, referenceDate: Date(), forcedExtension: forcedExtension) } /// Determines whether there is valid cached data under a given key and a reference date. @@ -291,13 +305,14 @@ public enum DiskStorage { /// If you pass `Date()` as the `referenceDate`, this method is identical to /// ``DiskStorage/Backend/isCached(forKey:)``. Use the `referenceDate` to determine whether the cache is still /// valid for a future date. - public func isCached(forKey key: String, referenceDate: Date) -> Bool { + public func isCached(forKey key: String, referenceDate: Date, forcedExtension: String? = nil) -> Bool { do { let result = try value( forKey: key, referenceDate: referenceDate, actuallyLoad: false, - extendingExpiration: .none + extendingExpiration: .none, + forcedExtension: forcedExtension ) return result != nil } catch { @@ -308,8 +323,8 @@ public enum DiskStorage { /// Removes a value from a specified key. /// - Parameter key: The cache key of the value. /// - Throws: An error during the removal of the value. - public func remove(forKey key: String) throws { - let fileURL = cacheFileURL(forKey: key) + public func remove(forKey key: String, forcedExtension: String? = nil) throws { + let fileURL = cacheFileURL(forKey: key, forcedExtension: forcedExtension) try removeFile(at: fileURL) } @@ -338,23 +353,24 @@ public enum DiskStorage { /// /// This method does not guarantee that an image is already cached at the returned URL. It just provides the URL /// where the image should be if it exists in the disk storage, with the given key. - public func cacheFileURL(forKey key: String) -> URL { - let fileName = cacheFileName(forKey: key) + public func cacheFileURL(forKey key: String, forcedExtension: String? = nil) -> URL { + let fileName = cacheFileName(forKey: key, forcedExtension: forcedExtension) return directoryURL.appendingPathComponent(fileName, isDirectory: false) } - - func cacheFileName(forKey key: String) -> String { + + func cacheFileName(forKey key: String, forcedExtension: String? = nil) -> String { + // TODO: Bad code... Consider refactoring. if config.usesHashedFileName { let hashedKey = key.kf.sha256 - if let ext = config.pathExtension { + if let ext = forcedExtension ?? config.pathExtension { return "\(hashedKey).\(ext)" } else if config.autoExtAfterHashedFileName, - let ext = key.kf.ext { + let ext = forcedExtension ?? key.kf.ext { return "\(hashedKey).\(ext)" } return hashedKey } else { - if let ext = config.pathExtension { + if let ext = forcedExtension ?? config.pathExtension { return "\(key).\(ext)" } return key diff --git a/Sources/Cache/ImageCache.swift b/Sources/Cache/ImageCache.swift index cb636903d..54073b9ff 100644 --- a/Sources/Cache/ImageCache.swift +++ b/Sources/Cache/ImageCache.swift @@ -365,6 +365,7 @@ open class ImageCache: @unchecked Sendable { self.syncStoreToDisk( data, forKey: key, + forcedExtension: options.forcedExtension, processorIdentifier: identifier, callbackQueue: callbackQueue, expiration: options.diskCacheExpiration, @@ -395,6 +396,8 @@ open class ImageCache: @unchecked Sendable { /// - key: The key used for caching the image. /// - identifier: The identifier of the processor being used for caching. If you are using a processor for the /// image, pass the identifier of the processor to this parameter. + /// - forcedExtension: The expected extension of the file. If `nil`, the file extension will be determined by the + /// disk storage configuration instead. /// - serializer: The ``CacheSerializer`` used to convert the `image` and `original` to the data that will be /// stored to disk. By default, the ``DefaultCacheSerializer/default`` will be used. /// - toDisk: Whether this image should be cached to disk or not. If `false`, the image is only cached in memory. @@ -410,6 +413,7 @@ open class ImageCache: @unchecked Sendable { original: Data? = nil, forKey key: String, processorIdentifier identifier: String = "", + forcedExtension: String? = nil, cacheSerializer serializer: any CacheSerializer = DefaultCacheSerializer.default, toDisk: Bool = true, callbackQueue: CallbackQueue = .untouch, @@ -426,16 +430,40 @@ open class ImageCache: @unchecked Sendable { let options = KingfisherParsedOptionsInfo([ .processor(TempProcessor(identifier: identifier)), .cacheSerializer(serializer), - .callbackQueue(callbackQueue) + .callbackQueue(callbackQueue), + .forcedCacheFileExtension(forcedExtension) ]) - store(image, original: original, forKey: key, options: options, - toDisk: toDisk, completionHandler: completionHandler) + store( + image, + original: original, + forKey: key, + options: options, + toDisk: toDisk, + completionHandler: completionHandler + ) } + /// Store some data to the disk. + /// + /// - Parameters: + /// - data: The data to be stored. + /// - key: The key used for caching the data. + /// - identifier: The identifier of the processor being used for caching. If you are using a processor for the + /// image, pass the identifier of the processor to this parameter. + /// - forcedExtension: The expected extension of the file. If `nil`, the file extension will be determined by the + /// disk storage configuration instead. + /// - expiration: The expiration policy used by this storage action. + /// - callbackQueue: The callback queue on which the `completionHandler` is invoked. The default is + /// ``CallbackQueue/untouch``. Under this default ``CallbackQueue/untouch`` queue, if `toDisk` is `false`, it + /// means the `completionHandler` will be invoked from the caller queue of this method; if `toDisk` is `true`, + /// the `completionHandler` will be called from an internal file IO queue. To change this behavior, specify + /// another ``CallbackQueue`` value. + /// - completionHandler: A closure that is invoked when the cache operation finishes. open func storeToDisk( _ data: Data, forKey key: String, processorIdentifier identifier: String = "", + forcedExtension: String? = nil, expiration: StorageExpiration? = nil, callbackQueue: CallbackQueue = .untouch, completionHandler: (@Sendable (CacheStoreResult) -> Void)? = nil) @@ -444,16 +472,19 @@ open class ImageCache: @unchecked Sendable { self.syncStoreToDisk( data, forKey: key, + forcedExtension: forcedExtension, processorIdentifier: identifier, callbackQueue: callbackQueue, expiration: expiration, - completionHandler: completionHandler) + completionHandler: completionHandler + ) } } private func syncStoreToDisk( _ data: Data, forKey key: String, + forcedExtension: String?, processorIdentifier identifier: String = "", callbackQueue: CallbackQueue = .untouch, expiration: StorageExpiration? = nil, @@ -463,7 +494,13 @@ open class ImageCache: @unchecked Sendable { let computedKey = key.computedKey(with: identifier) let result: CacheStoreResult do { - try self.diskStorage.store(value: data, forKey: computedKey, expiration: expiration, writeOptions: writeOptions) + try self.diskStorage.store( + value: data, + forKey: computedKey, + expiration: expiration, + writeOptions: writeOptions, + forcedExtension: forcedExtension + ) result = CacheStoreResult(memoryCacheResult: .success(()), diskCacheResult: .success(())) } catch { let diskError: KingfisherError @@ -491,6 +528,8 @@ open class ImageCache: @unchecked Sendable { /// - key: The key used for caching the image. /// - identifier: The identifier of the processor being used for caching. If you are using a processor for the /// image, pass the identifier of the processor to this parameter. + /// - forcedExtension: The expected extension of the file. If `nil`, the file extension will be determined by the + /// disk storage configuration instead. /// - fromMemory: Whether this image should be removed from memory storage or not. If `false`, the image won't be /// removed from the memory storage. The default is `true`. /// - fromDisk: Whether this image should be removed from the disk storage or not. If `false`, the image won't be @@ -501,6 +540,7 @@ open class ImageCache: @unchecked Sendable { open func removeImage( forKey key: String, processorIdentifier identifier: String = "", + forcedExtension: String? = nil, fromMemory: Bool = true, fromDisk: Bool = true, callbackQueue: CallbackQueue = .untouch, @@ -510,6 +550,7 @@ open class ImageCache: @unchecked Sendable { removeImage( forKey: key, processorIdentifier: identifier, + forcedExtension: forcedExtension, fromMemory: fromMemory, fromDisk: fromDisk, callbackQueue: callbackQueue, @@ -517,12 +558,14 @@ open class ImageCache: @unchecked Sendable { ) } - func removeImage(forKey key: String, - processorIdentifier identifier: String = "", - fromMemory: Bool = true, - fromDisk: Bool = true, - callbackQueue: CallbackQueue = .untouch, - completionHandler: (@Sendable ((any Error)?) -> Void)? = nil) + func removeImage( + forKey key: String, + processorIdentifier identifier: String = "", + forcedExtension: String?, + fromMemory: Bool = true, + fromDisk: Bool = true, + callbackQueue: CallbackQueue = .untouch, + completionHandler: (@Sendable ((any Error)?) -> Void)? = nil) { let computedKey = key.computedKey(with: identifier) @@ -539,7 +582,7 @@ open class ImageCache: @unchecked Sendable { if fromDisk { ioQueue.async{ do { - try self.diskStorage.remove(forKey: computedKey) + try self.diskStorage.remove(forKey: computedKey, forcedExtension: forcedExtension) callHandler(nil) } catch { callHandler(error) @@ -687,7 +730,11 @@ open class ImageCache: @unchecked Sendable { loadingQueue.execute { do { var image: KFCrossPlatformImage? = nil - if let data = try self.diskStorage.value(forKey: computedKey, extendingExpiration: options.diskCacheAccessExtendingExpiration) { + if let data = try self.diskStorage.value( + forKey: computedKey, + forcedExtension: options.forcedExtension, + extendingExpiration: options.diskCacheAccessExtendingExpiration + ) { image = options.cacheSerializer.image(with: data, options: options) } if options.backgroundDecode { @@ -861,15 +908,20 @@ open class ImageCache: @unchecked Sendable { /// - key: The key used for caching the image. /// - identifier: The processor identifier used for this image. The default value is the /// ``DefaultImageProcessor/identifier`` of the ``DefaultImageProcessor/default`` image processor. + /// - forcedExtension: The expected extension of the file. If `nil`, the file extension will be determined by the + /// disk storage configuration instead. + /// /// - Returns: A ``CacheType`` instance that indicates the cache status. ``CacheType/none`` indicates that the /// image is not in the cache or that it has already expired. open func imageCachedType( forKey key: String, - processorIdentifier identifier: String = DefaultImageProcessor.default.identifier) -> CacheType + processorIdentifier identifier: String = DefaultImageProcessor.default.identifier, + forcedExtension: String? = nil + ) -> CacheType { let computedKey = key.computedKey(with: identifier) if memoryStorage.isCached(forKey: computedKey) { return .memory } - if diskStorage.isCached(forKey: computedKey) { return .disk } + if diskStorage.isCached(forKey: computedKey, forcedExtension: forcedExtension) { return .disk } return .none } @@ -879,6 +931,9 @@ open class ImageCache: @unchecked Sendable { /// - key: The key used for caching the image. /// - identifier: The processor identifier used for this image. The default value is the /// ``DefaultImageProcessor/identifier`` of the ``DefaultImageProcessor/default`` image processor. + /// - forcedExtension: The expected extension of the file. If `nil`, the file extension will be determined by the + /// disk storage configuration instead. + /// /// - Returns: A `Bool` value indicating whether a cache matches the given `key` and `identifier` combination. /// /// > The return value does not contain information about the kind of storage the cache matches from. @@ -886,9 +941,11 @@ open class ImageCache: @unchecked Sendable { /// ``ImageCache/imageCachedType(forKey:processorIdentifier:)`` instead. public func isCached( forKey key: String, - processorIdentifier identifier: String = DefaultImageProcessor.default.identifier) -> Bool + processorIdentifier identifier: String = DefaultImageProcessor.default.identifier, + forcedExtension: String? = nil + ) -> Bool { - return imageCachedType(forKey: key, processorIdentifier: identifier).cached + return imageCachedType(forKey: key, processorIdentifier: identifier, forcedExtension: forcedExtension).cached } /// Retrieves the hash used as the cache file name for the key. @@ -897,6 +954,9 @@ open class ImageCache: @unchecked Sendable { /// - key: The key used for caching the image. /// - identifier: The processor identifier used for this image. The default value is the /// ``DefaultImageProcessor/identifier`` of the ``DefaultImageProcessor/default`` image processor. + /// - forcedExtension: The expected extension of the file. If `nil`, the file extension will be determined by the + /// disk storage configuration instead. + /// /// - Returns: The hash used as the cache file name. /// /// > By default, for a given combination of `key` and `identifier`, the ``ImageCache`` instance uses the value @@ -904,10 +964,12 @@ open class ImageCache: @unchecked Sendable { /// needed. open func hash( forKey key: String, - processorIdentifier identifier: String = DefaultImageProcessor.default.identifier) -> String + processorIdentifier identifier: String = DefaultImageProcessor.default.identifier, + forcedExtension: String? = nil + ) -> String { let computedKey = key.computedKey(with: identifier) - return diskStorage.cacheFileName(forKey: computedKey) + return diskStorage.cacheFileName(forKey: computedKey, forcedExtension: forcedExtension) } /// Calculates the size taken by the disk storage. @@ -939,6 +1001,9 @@ open class ImageCache: @unchecked Sendable { /// - key: The key used for caching the image. /// - identifier: The processor identifier used for this image. The default value is the /// ``DefaultImageProcessor/identifier`` of the ``DefaultImageProcessor/default`` image processor. + /// - forcedExtension: The expected extension of the file. If `nil`, the file extension will be determined by the + /// disk storage configuration instead. + /// /// - Returns: The disk path of the cached image under the given `key` and `identifier`. /// /// > This method does not guarantee that there is an image already cached in the returned path. It simply provides @@ -948,10 +1013,33 @@ open class ImageCache: @unchecked Sendable { /// cached under that key on disk if necessary. open func cachePath( forKey key: String, - processorIdentifier identifier: String = DefaultImageProcessor.default.identifier) -> String + processorIdentifier identifier: String = DefaultImageProcessor.default.identifier, + forcedExtension: String? = nil + ) -> String { let computedKey = key.computedKey(with: identifier) - return diskStorage.cacheFileURL(forKey: computedKey).path + return diskStorage.cacheFileURL(forKey: computedKey, forcedExtension: forcedExtension).path + } + + /// Returns the file URL if a disk cache file is existing for the target key, identifier and forcedExtension + /// combination. Otherwise, if the requested cache value is not on the disk as a file, `nil`. + /// + /// - Parameters: + /// - key: The key used for caching the item. + /// - identifier: The processor identifier used for this image. It involves into calculating the final cache key. + /// - forcedExtension: The expected extension of the file. + /// - Returns: The file URL if a disk cache file is existing for the combination. Otherwise, `nil`. + open func cacheFileURLIfOnDisk( + forKey key: String, + processorIdentifier identifier: String = DefaultImageProcessor.default.identifier, + forcedExtension: String? = nil + ) -> URL? + { + let computedKey = key.computedKey(with: identifier) + return diskStorage.isCached( + forKey: computedKey, + forcedExtension: forcedExtension + ) ? diskStorage.cacheFileURL(forKey: computedKey, forcedExtension: forcedExtension) : nil } // MARK: - Concurrency @@ -1004,6 +1092,7 @@ open class ImageCache: @unchecked Sendable { original: Data? = nil, forKey key: String, processorIdentifier identifier: String = "", + forcedExtension: String? = nil, cacheSerializer serializer: any CacheSerializer = DefaultCacheSerializer.default, toDisk: Bool = true ) async throws { @@ -1013,6 +1102,7 @@ open class ImageCache: @unchecked Sendable { original: original, forKey: key, processorIdentifier: identifier, + forcedExtension: forcedExtension, cacheSerializer: serializer, toDisk: toDisk) { // Only `diskCacheResult` can fail @@ -1025,6 +1115,7 @@ open class ImageCache: @unchecked Sendable { _ data: Data, forKey key: String, processorIdentifier identifier: String = "", + forcedExtension: String? = nil, expiration: StorageExpiration? = nil ) async throws { @@ -1033,6 +1124,7 @@ open class ImageCache: @unchecked Sendable { data, forKey: key, processorIdentifier: identifier, + forcedExtension: forcedExtension, expiration: expiration) { // Only `diskCacheResult` can fail continuation.resume(with: $0.diskCacheResult) @@ -1053,6 +1145,7 @@ open class ImageCache: @unchecked Sendable { open func removeImage( forKey key: String, processorIdentifier identifier: String = "", + forcedExtension: String? = nil, fromMemory: Bool = true, fromDisk: Bool = true ) async throws { @@ -1060,6 +1153,7 @@ open class ImageCache: @unchecked Sendable { removeImage( forKey: key, processorIdentifier: identifier, + forcedExtension: forcedExtension, fromMemory: fromMemory, fromDisk: fromDisk, completionHandler: { error in diff --git a/Sources/Extensions/PHLivePhotoView+Kingfisher.swift b/Sources/Extensions/PHLivePhotoView+Kingfisher.swift new file mode 100644 index 000000000..10fe5999a --- /dev/null +++ b/Sources/Extensions/PHLivePhotoView+Kingfisher.swift @@ -0,0 +1,308 @@ +// +// PHLivePhotoView+Kingfisher.swift +// Kingfisher +// +// Created by onevcat on 2024/10/04. +// +// Copyright (c) 2024 Wei Wang +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +#if os(watchOS) +// Only a placeholder. +public struct RetrieveLivePhotoResult: @unchecked Sendable { +} +#else +@preconcurrency import PhotosUI + +/// A result type that contains the information of a retrieved live photo. +/// +/// This struct is used to encapsulate the result of a live photo retrieval operation, including the loading information, +/// the retrieved `PHLivePhoto` object, and any additional information provided by the result handler. +/// +/// - Note: The `info` dictionary is considered sendable based on the documentation for "Result Handler Info Dictionary Keys". +/// See: [Result Handler Info Dictionary Keys](https://developer.apple.com/documentation/photokit/phlivephoto/result_handler_info_dictionary_keys) +public struct RetrieveLivePhotoResult: @unchecked Sendable { + /// The loading information of the live photo. + public let loadingInfo: LivePhotoLoadingInfoResult + + /// The retrieved live photo object which is given by the + /// `PHLivePhoto.request(withResourceFileURLs:placeholderImage:targetSize:contentMode:resultHandler:)` method from + /// the result handler. + public let livePhoto: PHLivePhoto? + + + // According to "Result Handler Info Dictionary Keys", we can trust the `info` in handler is sendable. + // https://developer.apple.com/documentation/photokit/phlivephoto/result_handler_info_dictionary_keys + /// The additional information provided by the result handler when retrieving the live photo. + public let info: [AnyHashable : Any]? +} + +@MainActor private var taskIdentifierKey: Void? +@MainActor private var targetSizeKey: Void? +@MainActor private var contentModeKey: Void? + +@MainActor +extension KingfisherWrapper where Base: PHLivePhotoView { + /// Gets the task identifier associated with the image view for the live photo task. + public private(set) var taskIdentifier: Source.Identifier.Value? { + get { + let box: Box? = getAssociatedObject(base, &taskIdentifierKey) + return box?.value + } + set { + let box = newValue.map { Box($0) } + setRetainedAssociatedObject(base, &taskIdentifierKey, box) + } + } + + /// The target size of the live photo view. It is used in the + /// `PHLivePhoto.request(withResourceFileURLs:placeholderImage:targetSize:contentMode:resultHandler:)` method as + /// the `targetSize` argument when loading the live photo. + /// + /// If not set, `.zero` will be used. + public var targetSize: CGSize { + get { getAssociatedObject(base, &targetSizeKey) ?? .zero } + set { setRetainedAssociatedObject(base, &targetSizeKey, newValue) } + } + + /// The content mode of the live photo view. It is used in the + /// `PHLivePhoto.request(withResourceFileURLs:placeholderImage:targetSize:contentMode:resultHandler:)` method as + /// the `contentMode` argument when loading the live photo. + /// + /// If not set, `.default` will be used. + public var contentMode: PHImageContentMode { + get { getAssociatedObject(base, &contentModeKey) ?? .default } + set { setRetainedAssociatedObject(base, &contentModeKey, newValue) } + } + + /// Sets a live photo to the view with an array of `URL`. + /// + /// - Parameters: + /// - urls: The `URL`s defining the live photo resource. It should contains two URLs, one for the still image and + /// one for the video. + /// - options: An options set to define image setting behaviors. See ``KingfisherOptionsInfo`` for more. + /// - completionHandler: Called when the image setting process finishes. + /// - Returns: A task represents the image downloading. + /// The return value will be `nil` if the image is set with a empty source. + /// + /// - Note: Not all options in ``KingfisherOptionsInfo`` are supported in this method, for example, the live photo + /// does not support any custom processors. Different from the extension method for a normal image view on the + /// platform, the `placeholder` and `progressBlock` are not supported yet, and will be implemented in the future. + /// + /// - Note: To get refined control of the resources, use the ``setImage(with:options:completionHandler:)-1n4p2`` + /// method with a ``LivePhotoSource`` object. + /// + /// Example: + /// + /// ```swift + /// let urls = [ + /// URL(string: "https://example.com/image.heic")!, // imageURL + /// URL(string: "https://example.com/video.mov")! // videoURL + /// ] + /// let livePhotoView = PHLivePhotoView() + /// livePhotoView.kf.setImage(with: urls) { result in + /// switch result { + /// case .success(let retrieveResult): + /// print("Live photo loaded: \(retrieveResult.livePhoto).") + /// print("Cache type: \(retrieveResult.loadingInfo.cacheType).") + /// case .failure(let error): + /// print("Error: \(error)") + /// } + /// ``` + @discardableResult + public func setImage( + with urls: [URL], + // placeholder: KFCrossPlatformImage? = nil, // Not supported yet + options: KingfisherOptionsInfo? = nil, + // progressBlock: DownloadProgressBlock? = nil, // Not supported yet + completionHandler: (@MainActor @Sendable (Result) -> Void)? = nil + ) -> Task<(), Never>? { + setImage( + with: LivePhotoSource(urls: urls), + options: options, + completionHandler: completionHandler + ) + } + + /// Sets a live photo to the view with a ``LivePhotoSource``. + /// + /// - Parameters: + /// - source: The ``LivePhotoSource`` object defining the live photo resource. + /// - options: An options set to define image setting behaviors. See `KingfisherOptionsInfo` for more. + /// - completionHandler: Called when the image setting process finishes. + /// - Returns: A task represents the image downloading. + /// The return value will be `nil` if the image is set with a empty source. + /// + /// - Note: Not all options in ``KingfisherOptionsInfo`` are supported in this method, for example, the live photo + /// does not support any custom processors. Different from the extension method for a normal image view on the + /// platform, the `placeholder` and `progressBlock` are not supported yet, and will be implemented in the future. + /// + /// Sample: + /// ```swift + /// let source = LivePhotoSource(urls: [ + /// URL(string: "https://example.com/image.heic")!, // imageURL + /// URL(string: "https://example.com/video.mov")! // videoURL + /// ]) + /// let livePhotoView = PHLivePhotoView() + /// livePhotoView.kf.setImage(with: source) { result in + /// switch result { + /// case .success(let retrieveResult): + /// print("Live photo loaded: \(retrieveResult.livePhoto).") + /// print("Cache type: \(retrieveResult.loadingInfo.cacheType).") + /// case .failure(let error): + /// print("Error: \(error)") + /// } + /// ``` + @discardableResult + public func setImage( + with source: LivePhotoSource?, + // placeholder: KFCrossPlatformImage? = nil, // Not supported yet + options: KingfisherOptionsInfo? = nil, + // progressBlock: DownloadProgressBlock? = nil, // Not supported yet + completionHandler: (@MainActor @Sendable (Result) -> Void)? = nil + ) -> Task<(), Never>? { + var mutatingSelf = self + + // Empty source fails the loading early and clear the current task identifier. + guard let source = source else { + base.livePhoto = nil + mutatingSelf.taskIdentifier = nil + completionHandler?(.failure(KingfisherError.imageSettingError(reason: .emptySource))) + return nil + } + + let issuedIdentifier = Source.Identifier.next() + mutatingSelf.taskIdentifier = issuedIdentifier + + let taskIdentifierChecking = { issuedIdentifier == self.taskIdentifier } + + // Copy these associated values to prevent issues from reentrance. + let targetSize = targetSize + let contentMode = contentMode + + let task = Task { @MainActor in + do { + let loadingInfo = try await KingfisherManager.shared.retrieveLivePhoto( + with: source, + options: options, + progressBlock: nil, // progressBlock, // Not supported yet + referenceTaskIdentifierChecker: taskIdentifierChecking + ) + if let notCurrentTaskError = self.checkNotCurrentTask( + issuedIdentifier: issuedIdentifier, + result: .init(loadingInfo: loadingInfo, livePhoto: nil, info: nil), + error: nil, + source: source + ) { + completionHandler?(.failure(notCurrentTaskError)) + return + } + + PHLivePhoto.request( + withResourceFileURLs: loadingInfo.fileURLs, + placeholderImage: nil, + targetSize: targetSize, + contentMode: contentMode, + resultHandler: { livePhoto, info in + let result = RetrieveLivePhotoResult( + loadingInfo: loadingInfo, + livePhoto: livePhoto, + info: info + ) + + if let notCurrentTaskError = self.checkNotCurrentTask( + issuedIdentifier: issuedIdentifier, + result: result, + error: nil, + source: source + ) { + completionHandler?(.failure(notCurrentTaskError)) + return + } + + base.livePhoto = livePhoto + + if let error = info[PHLivePhotoInfoErrorKey] as? NSError { + let failingReason: KingfisherError.ImageSettingErrorReason = + .livePhotoResultError(result: result, error: error, source: source) + completionHandler?(.failure(.imageSettingError(reason: failingReason))) + return + } + + // Since we are not returning the request ID, seems no way for user to cancel it if the + // `request` method is called. However, we are sure the request method will always load the + // image from disk, it should not be a problem. In case we still report the error in the + // completion + if (info[PHLivePhotoInfoCancelledKey] as? NSNumber)?.boolValue ?? false { + completionHandler?(.failure( + .requestError(reason: .livePhotoTaskCancelled(source: source))) + ) + return + } + + // If the PHLivePhotoInfoIsDegradedKey value in your result handler’s info dictionary is true, + // Photos will call your result handler again. + if (info[PHLivePhotoInfoIsDegradedKey] as? NSNumber)?.boolValue == true { + // This ensures `completionHandler` be only called once. + return + } + + completionHandler?(.success(result)) + } + ) + } catch { + if let notCurrentTaskError = self.checkNotCurrentTask( + issuedIdentifier: issuedIdentifier, + result: nil, + error: error, + source: source + ) { + completionHandler?(.failure(notCurrentTaskError)) + return + } + + if let kfError = error as? KingfisherError { + completionHandler?(.failure(kfError)) + } else if error is CancellationError { + completionHandler?(.failure(.requestError(reason: .livePhotoTaskCancelled(source: source)))) + } else { + completionHandler?(.failure(.imageSettingError( + reason: .livePhotoResultError(result: nil, error: error, source: source))) + ) + } + } + } + + return task + } + + private func checkNotCurrentTask( + issuedIdentifier: Source.Identifier.Value, + result: RetrieveLivePhotoResult?, + error: (any Error)?, + source: LivePhotoSource + ) -> KingfisherError? { + if issuedIdentifier == self.taskIdentifier { + return nil + } + return .imageSettingError(reason: .notCurrentLivePhotoSourceTask(result: result, error: error, source: source)) + } +} +#endif diff --git a/Sources/General/ImageSource/ImageDataProvider.swift b/Sources/General/ImageSource/ImageDataProvider.swift index 8f9a5e0c8..8ca1e67cd 100644 --- a/Sources/General/ImageSource/ImageDataProvider.swift +++ b/Sources/General/ImageSource/ImageDataProvider.swift @@ -52,6 +52,14 @@ public protocol ImageDataProvider: Sendable { var contentURL: URL? { get } } +extension ImageDataProvider { + func data() async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + data(handler: { continuation.resume(with: $0) }) + } + } +} + public extension ImageDataProvider { var contentURL: URL? { return nil } func convertToSource() -> Source { diff --git a/Sources/General/ImageSource/LivePhotoSource.swift b/Sources/General/ImageSource/LivePhotoSource.swift new file mode 100644 index 000000000..e70e8d8bd --- /dev/null +++ b/Sources/General/ImageSource/LivePhotoSource.swift @@ -0,0 +1,204 @@ +// +// LivePhotoSource.swift +// Kingfisher +// +// Created by onevcat on 2024/10/01. +// +// Copyright (c) 2024 Wei Wang +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Foundation + +/// A type represents a loadable resource for a Live Photo, which consists of a still image and a video. +/// +/// Kingfisher expects a ``LivePhotoSource`` value to load a Live Photo with its high-level APIs. +/// A ``LivePhotoSource`` is typically a collection of two ``LivePhotoResource`` values, one for the still image and +/// one for the video. +public struct LivePhotoSource: Sendable { + + /// The resources of a Live Photo. + public let resources: [LivePhotoResource] + + /// Creates a Live Photo source with given resources. + /// - Parameter resources: The downloadable resource for a Live Photo. It should contain two resources, one for the + /// still image and one for the video. + public init(resources: [any Resource]) { + let livePhotoResources = resources.map { LivePhotoResource(resource: $0) } + self.init(livePhotoResources) + } + + /// Creates a Live Photo source with given URLs. + /// - Parameter urls: The URLs of the downloadable resources for a Live Photo. It should contain two URLs, one for + /// the still image and one for the video. + public init(urls: [URL]) { + let resources = urls.map { KF.ImageResource(downloadURL: $0) } + self.init(resources: resources) + } + + /// Creates a Live Photo source with given resources. + /// - Parameter resources: The resources for a Live Photo. It should contain two resources, one for the still image + /// and one for the video. + public init(_ resources: [LivePhotoResource]) { + self.resources = resources + } +} + + +/// A resource type representing a component of a Live Photo, which consists of a still image and a video. +/// +/// ``LivePhotoResource`` encapsulates the necessary information to download and cache a single components of a Live +/// Photo: it is either a still image (typically in HEIC format) or a video (typically in MOV format). Multiple +/// ``LivePhotoResource`` values (typically two, one for the image and one for the video) can form a ``LivePhotoSource``, +/// which is expected by Kingfisher in its live photo loading high level APIs. +/// +/// The Live Photo data can be retrieved by `PHAssetResourceManager.requestData` method and uploaded to your server. +/// You should not modify the metadata or other information of the data, otherwise, it is possible that the +/// `PHLivePhoto` class cannot read and recognize it anymore. For more information, please refer to Apple's +/// documentation of Photos framework. +public struct LivePhotoResource: Sendable { + + /// The file type of a ``LivePhotoResource``. + public enum FileType: Sendable, Equatable { + /// File type HEIC. Usually it represents the still image in a Live Photo. + case heic + /// File type MOV. Usually it represents the video in a Live Photo. + case mov + /// Other file types with the file extension. + case other(String) + + var fileExtension: String { + switch self { + case .heic: return "heic" + case .mov: return "mov" + case .other(let ext): return ext + } + } + } + + /// The data source of a Live Photo resource. + /// + /// This is a general ``Source`` type, which can be either a network resource (as ``Source/network(_:)``) or a + /// provided resource as ``Source/provider(_:)``. + public let dataSource: Source + + /// The file type of the resource. + public let referenceFileType: FileType + + var cacheKey: String { dataSource.cacheKey } + var downloadURL: URL? { dataSource.url } + + /// Creates a Live Photo resource with given download URL, cache key and file type. + /// - Parameters: + /// - downloadURL: The URL to download the resource. + /// - cacheKey: The cache key for the resource. If `nil`, Kingfisher will use the `absoluteString` of the URL as + /// the cache key. + /// - fileType: The file type of the resource. If `nil`, Kingfisher will try to guess the file type from the URL. + /// + /// The file type is important for Kingfisher to determine how to handle the downloaded data and store them + /// in the cache. Photos framework requires the still image to be in HEIC extension and the video to be in MOV + /// extension. Otherwise, the `PHLivePhoto` class might not be able to recognize the data. If you are not sure about + /// the file type, you can leave it as `nil` and Kingfisher will try to guess it from the URL and the downloaded + /// data. + public init(downloadURL: URL, cacheKey: String? = nil, fileType: FileType? = nil) { + let resource = KF.ImageResource(downloadURL: downloadURL, cacheKey: cacheKey) + dataSource = .network(resource) + referenceFileType = fileType ?? resource.guessedFileType + } + + /// Creates a Live Photo resource with given resource and file type. + /// - Parameters: + /// - resource: The resource to download the data. + /// - fileType: The file type of the resource. If `nil`, Kingfisher will try to guess the file type from the URL. + /// + /// The file type is important for Kingfisher to determine how to handle the downloaded data and store them + /// in the cache. Photos framework requires the still image to be in HEIC extension and the video to be in MOV + /// extension. Otherwise, the `PHLivePhoto` class might not be able to recognize the data. If you are not sure about + /// the file type, you can leave it as `nil` and Kingfisher will try to guess it from the URL and the downloaded + /// data. + public init(resource: any Resource, fileType: FileType? = nil) { + self.dataSource = .network(resource) + referenceFileType = fileType ?? resource.guessedFileType + } + + /// Creates a Live Photo resource with given data source and file type. + /// - Parameters: + /// - source: The data source of the resource. It can be either a network resource or a provided resource. + /// - fileType: The file type of the resource. If `nil`, Kingfisher will try to guess the file type from the URL. + /// + /// The file type is important for Kingfisher to determine how to handle the downloaded data and store them + /// in the cache. Photos framework requires the still image to be in HEIC extension and the video to be in MOV + /// extension. Otherwise, the `PHLivePhoto` class might not be able to recognize the data. If you are not sure about + /// the file type, you can leave it as `nil` and Kingfisher will try to guess it from the URL and the downloaded + /// data. + public init(source: Source, fileType: FileType? = nil) { + self.dataSource = source + referenceFileType = fileType ?? source.url?.guessedFileType ?? .other("") + } +} + +extension LivePhotoResource.FileType { + func determinedFileExtension(_ data: Data) -> String? { + switch self { + case .mov: return "mov" + case .heic: return "heic" + case .other(let ext): + if !ext.isEmpty { + return ext + } + return Self.guessedFileExtension(from: data) + } + } + + static let fytpChunk: [UInt8] = [0x66, 0x74, 0x79, 0x70] // fytp (file type box) + static let heicChunk: [UInt8] = [0x68, 0x65, 0x69, 0x63] // .heic + static let qtChunk: [UInt8] = [0x71, 0x74, 0x20, 0x20] // quicktime, .mov + + static func guessedFileExtension(from data: Data) -> String? { + + guard data.count >= 12 else { return nil } + + var buffer = [UInt8](repeating: 0, count: 12) + data.copyBytes(to: &buffer, count: 12) + + guard Array(buffer[4..<8]) == fytpChunk else { + return nil + } + + let fileTypeChunk = Array(buffer[8..<12]) + if fileTypeChunk == heicChunk { + return "heic" + } + if fileTypeChunk == qtChunk { + return "mov" + } + return nil + } +} + +extension Resource { + var guessedFileType: LivePhotoResource.FileType { + let pathExtension = downloadURL.pathExtension.lowercased() + return switch pathExtension { + case "mov": .mov + case "heic": .heic + default: .other(pathExtension) + } + } +} diff --git a/Sources/General/Kingfisher.swift b/Sources/General/Kingfisher.swift index f68d60735..b890d24b3 100644 --- a/Sources/General/Kingfisher.swift +++ b/Sources/General/Kingfisher.swift @@ -114,6 +114,12 @@ extension NSTextAttachment : KingfisherCompatible { } extension WKInterfaceImage : KingfisherCompatible { } #endif +#if canImport(PhotosUI) && !os(watchOS) +import PhotosUI +extension PHLivePhotoView : KingfisherCompatible { } +#endif + + #if os(tvOS) && canImport(TVUIKit) @available(tvOS 12.0, *) extension TVMonogramView : KingfisherCompatible { } diff --git a/Sources/General/KingfisherError.swift b/Sources/General/KingfisherError.swift index 1649747ed..619fab1ef 100644 --- a/Sources/General/KingfisherError.swift +++ b/Sources/General/KingfisherError.swift @@ -66,6 +66,14 @@ public enum KingfisherError: Error { /// /// Error Code: 1003 case taskCancelled(task: SessionDataTask, token: SessionDataTask.CancelToken) + + /// The live photo downloading task is canceled by the user. + /// + /// - Parameters: + /// - source: The live phot source. + /// + /// Error Code: 1004 + case livePhotoTaskCancelled(source: LivePhotoSource) } /// Represents the error reason during networking response phase. @@ -235,6 +243,13 @@ public enum KingfisherError: Error { /// /// Error Code: 3011 case diskStorageIsNotReady(cacheURL: URL) + + /// The resource is expected on the disk, but now missing for some reason. + /// + /// This happens when the expected resource is not on the disk for some reason during loading a live photo. + /// + /// Error Code: 3012 + case missingLivePhotoResourceOnDisk(_ resource: LivePhotoResource) } /// Represents the error reason during image processing phase. @@ -296,6 +311,44 @@ public enum KingfisherError: Error { /// /// Error Code: 5004 case alternativeSourcesExhausted([PropagationError]) + + /// The resource task is completed, but it is not the one that was expected. This typically occurs when you set + /// another resource on the view without canceling the current ongoing task. The previous task will fail with the + /// `.notCurrentLivePhotoSourceTask` error when a result is obtained, regardless of whether it was successful or + /// not for that task. + /// + /// This error is the live photo version of the `.notCurrentSourceTask` error (error 5002). + /// + /// - Parameters: + /// - result: The `RetrieveImageResult` if the source task is completed without any issues. `nil` if an error occurred. + /// - error: The `Error` if there was a problem during the image setting task. `nil` if the task completed successfully. + /// - source: The original source value of the task. + /// + /// Error Code: 5005 + case notCurrentLivePhotoSourceTask( + result: RetrieveLivePhotoResult?, error: (any Error)?, source: LivePhotoSource + ) + + /// The error happens during processing the live photo. + /// + /// When creating the final `PHLivePhoto` object from the downloaded image files, the internal Photos framework + /// method `PHLivePhoto.request(withResourceFileURLs:placeholderImage:targetSize:contentMode:resultHandler:)` + /// invokes its `resultHandler`. If the `info` dictionary in `resultHandler` contains `PHLivePhotoInfoErrorKey`, + /// Kingfisher raises this error reason to pass the information to outside. + /// + /// If the processing fails due to any error that is not a `KingfisherError` case, Kingfisher also reports it + /// with this reason. + /// + /// - Parameters: + /// - result: The `RetrieveLivePhotoResult` if the source task is completed and a result is already existing. + /// - error: The `NSError` if `PHLivePhotoInfoErrorKey` is contained in the `resultHandler` info dictionary. + /// - source: The original source value of the task. + /// + /// - Note: It is possible that both `result` and `error` are non-nil value. Check the + /// ``RetrieveLivePhotoResult/info`` property for the raw values that are from the Photos framework. + /// + /// Error Code: 5006 + case livePhotoResultError(result: RetrieveLivePhotoResult?, error: (any Error)?, source: LivePhotoSource) } // MARK: Member Cases @@ -445,6 +498,8 @@ extension KingfisherError.RequestErrorReason { return "The request contains an invalid or empty URL. Request: \(request)." case .taskCancelled(let task, let token): return "The session task was cancelled. Task: \(task), cancel token: \(token)." + case .livePhotoTaskCancelled(let source): + return "The live photo download task was cancelled. Source: \(source)" } } @@ -453,6 +508,7 @@ extension KingfisherError.RequestErrorReason { case .emptyRequest: return 1001 case .invalidURL: return 1002 case .taskCancelled: return 1003 + case .livePhotoTaskCancelled: return 1004 } } } @@ -521,6 +577,9 @@ extension KingfisherError.CacheErrorReason { case .diskStorageIsNotReady(let cacheURL): return "The disk storage is not ready to use yet at URL: '\(cacheURL)'. " + "This is usually caused by extremely lack of disk space. Ask users to free up some space and restart the app." + case .missingLivePhotoResourceOnDisk(let resource): + return "The live photo resource '\(resource)' is missing in the cache. Usually a re-download" + + " can fix this issue." } } @@ -537,6 +596,7 @@ extension KingfisherError.CacheErrorReason { case .cannotCreateCacheFile: return 3009 case .cannotSetCacheFileAttribute: return 3010 case .diskStorageIsNotReady: return 3011 + case .missingLivePhotoResourceOnDisk: return 3012 } } } @@ -575,6 +635,19 @@ extension KingfisherError.ImageSettingErrorReason { return "Image data provider fails to provide data. Provider: \(provider), error: \(error)" case .alternativeSourcesExhausted(let errors): return "Image setting from alternative sources failed: \(errors)" + case .notCurrentLivePhotoSourceTask(let result, let error, let source): + if let result = result { + return "Retrieving live photo resource succeeded, but this source is " + + "not the one currently expected. Result: \(result). Resource: \(source)." + } else if let error = error { + return "Retrieving live photo resource failed, and this resource is " + + "not the one currently expected. Error: \(error). Resource: \(source)." + } else { + return nil + } + case .livePhotoResultError(let result, let error, let source): + return "An error occurred while processing live photo. Source: \(source). " + + "Result: \(String(describing: result)). Error: \(String(describing: error))" } } @@ -584,6 +657,8 @@ extension KingfisherError.ImageSettingErrorReason { case .notCurrentSourceTask: return 5002 case .dataProviderError: return 5003 case .alternativeSourcesExhausted: return 5004 + case .notCurrentLivePhotoSourceTask: return 5005 + case .livePhotoResultError: return 5006 } } } diff --git a/Sources/General/KingfisherManager+LivePhoto.swift b/Sources/General/KingfisherManager+LivePhoto.swift new file mode 100644 index 000000000..81347c114 --- /dev/null +++ b/Sources/General/KingfisherManager+LivePhoto.swift @@ -0,0 +1,272 @@ +// +// KingfisherManager+LivePhoto.swift +// Kingfisher +// +// Created by onevcat on 2024/10/01. +// +// Copyright (c) 2024 Wei Wang +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +#if !os(watchOS) +@preconcurrency import Photos + +/// A structure that contains information about the result of loading a live photo. +public struct LivePhotoLoadingInfoResult: Sendable { + + /// Retrieves the live photo disk URLs from this result. + public let fileURLs: [URL] + + /// Retrieves the cache source of the image, indicating from which cache layer it was retrieved. + /// + /// If the image was freshly downloaded from the network and not retrieved from any cache, `.none` will be returned. + /// Otherwise, ``CacheType/disk`` will be returned for the live photo. ``CacheType/memory`` is not available for + /// live photos since it may take too much memory. All cached live photos are loaded from disk only. + public let cacheType: CacheType + + /// The ``LivePhotoSource`` to which this result is related. This indicates where the `livePhoto` referenced by + /// `self` is located. + public let source: LivePhotoSource + + /// The original ``LivePhotoSource`` from which the retrieval task begins. It may differ from the ``source`` property. + /// When an alternative source loading occurs, the ``source`` will represent the replacement loading target, while the + /// ``originalSource`` will retain the initial ``source`` that initiated the image loading process. + public let originalSource: LivePhotoSource + + /// Retrieves the data associated with this result. + /// + /// When this result is obtained from a network download (when `cacheType == .none`), calling this method returns + /// the downloaded data. If the result is from the cache, it serializes the image using the specified cache + /// serializer from the loading options and returns the result. + /// + /// - Note: Retrieving this data can be a time-consuming operation, so it is advisable to store it if you need to + /// use it multiple times and avoid frequent calls to this method. + public let data: @Sendable () -> [Data] +} + +extension KingfisherManager { + + /// Retrieves a live photo from the specified source. + /// + /// This method asynchronously loads a live photo from the given source, applying the specified options and + /// reporting progress if a progress block is provided. + /// + /// - Parameters: + /// - source: The ``LivePhotoSource`` from which to retrieve the live photo. + /// - options: A dictionary of options to apply to the retrieval process. If `nil`, the default options will be + /// used. + /// - progressBlock: An optional closure to be called periodically during the download process. + /// - referenceTaskIdentifierChecker: An optional closure that returns a Boolean value indicating whether the task + /// should proceed. + /// + /// - Returns: A ``LivePhotoLoadingInfoResult`` containing information about the retrieved live photo. + /// + /// - Throws: An error if the retrieval process fails. + /// + /// - Note: This method uses `LivePhotoImageProcessor` by default. Custom processors are not supported for live photos. + /// + /// - Warning: Not all options are working for this method. And currently the `progressBlock` is not working. + /// It will be implemented in the future. + public func retrieveLivePhoto( + with source: LivePhotoSource, + options: KingfisherOptionsInfo? = nil, + progressBlock: DownloadProgressBlock? = nil, + referenceTaskIdentifierChecker: (() -> Bool)? = nil + ) async throws -> LivePhotoLoadingInfoResult { + let fullOptions = currentDefaultOptions + (options ?? .empty) + var checkedOptions = KingfisherParsedOptionsInfo(fullOptions) + + if checkedOptions.processor == DefaultImageProcessor.default { + // The default processor is a default behavior so we replace it silently. + checkedOptions.processor = LivePhotoImageProcessor.default + } else if checkedOptions.processor != LivePhotoImageProcessor.default { + // Warn the framework user that the processor is not supported. + assertionFailure("[Kingfisher] Using of custom processors during loading of live photo resource is not supported.") + checkedOptions.processor = LivePhotoImageProcessor.default + } + + if let checker = referenceTaskIdentifierChecker { + checkedOptions.onDataReceived?.forEach { + $0.onShouldApply = checker + } + } + + // TODO. We ignore the retry of live photo and the progress now to suppress the complexity. + + let missingResources = missingResources(source, options: checkedOptions) + let resourcesResult = try await downloadAndCache(resources: missingResources, options: checkedOptions) + + let targetCache = checkedOptions.targetCache ?? cache + var fileURLs = [URL]() + for resource in source.resources { + let url = targetCache.possibleCacheFileURLIfOnDisk(resource: resource, options: checkedOptions) + guard let url else { + // This should not happen normally if the previous `downloadAndCache` done without issue, but in case. + throw KingfisherError.cacheError(reason: .missingLivePhotoResourceOnDisk(resource)) + } + fileURLs.append(url) + } + + return LivePhotoLoadingInfoResult( + fileURLs: fileURLs, + cacheType: missingResources.isEmpty ? .disk : .none, + source: source, + originalSource: source, + data: { + resourcesResult.map { $0.originalData } + }) + } + + // Returns the missing resources for the given source and options. If the resource is not in the cache, it will be + // returned as a missing resource. + func missingResources(_ source: LivePhotoSource, options: KingfisherParsedOptionsInfo) -> [LivePhotoResource] { + let missingResources: [LivePhotoResource] + if options.forceRefresh { + missingResources = source.resources + } else { + let targetCache = options.targetCache ?? cache + missingResources = source.resources.reduce([], { r, resource in + // Check if the resource is in the cache. It includes a guess of the file extension. + let cachedFileURL = targetCache.possibleCacheFileURLIfOnDisk(resource: resource, options: options) + if cachedFileURL == nil { + return r + [resource] + } else { + return r + } + }) + } + return missingResources + } + + // Download the resources and store them to the cache. + // If the resource does not specify a file extension (from either the URL extension or the explicit + // `referenceFileType`), we infer it from the file signature. + func downloadAndCache( + resources: [LivePhotoResource], + options: KingfisherParsedOptionsInfo + ) async throws -> [LivePhotoResourceDownloadingResult] { + if resources.isEmpty { + return [] + } + let downloader = options.downloader ?? downloader + let cache = options.targetCache ?? cache + + // Download all resources concurrently. + return try await withThrowingTaskGroup(of: LivePhotoResourceDownloadingResult.self) { + group in + + for resource in resources { + group.addTask { + + let downloadedResource: LivePhotoResourceDownloadingResult + + switch resource.dataSource { + case .network(let urlResource): + downloadedResource = try await downloader.downloadLivePhotoResource( + with: urlResource.downloadURL, + options: options + ) + case .provider(let provider): + downloadedResource = try await LivePhotoResourceDownloadingResult( + originalData: provider.data(), + url: provider.contentURL + ) + } + + // We need to specify the extension so the file is saved correctly. Live photo loading requires + // the file extension to be correct. Otherwise, a 3302 error will be thrown. + // https://developer.apple.com/documentation/photokit/phphotoserror/code/invalidresource + let fileExtension = resource.referenceFileType + .determinedFileExtension(downloadedResource.originalData) + try await cache.storeToDisk( + downloadedResource.originalData, + forKey: resource.cacheKey, + processorIdentifier: options.processor.identifier, + forcedExtension: fileExtension, + expiration: options.diskCacheExpiration + ) + return downloadedResource + } + } + + var result: [LivePhotoResourceDownloadingResult] = [] + for try await resource in group { + result.append(resource) + } + return result + } + } +} + +extension ImageCache { + + func possibleCacheFileURLIfOnDisk( + resource: LivePhotoResource, + options: KingfisherParsedOptionsInfo + ) -> URL? { + possibleCacheFileURLIfOnDisk( + forKey: resource.cacheKey, + processorIdentifier: options.processor.identifier, + referenceFileType: resource.referenceFileType + ) + } + + // Returns the possible cache file URL for the given key and processor identifier. If the file is on disk, it will + // return the URL. Otherwise, it will return `nil`. + // + // This method also tries to guess the file extension if it is not specified in the `referenceFileType`. + // `PHLivePhoto`'s `request` method requires the file extension to be correct on the disk, and we also stored the + // downloaded data with the correct extension (if it is not specified in the `referenceFileType`, we infer it from + // the file signature. See `FileType.determinedFileExtension` for more). + func possibleCacheFileURLIfOnDisk( + forKey key: String, + processorIdentifier identifier: String, + referenceFileType: LivePhotoResource.FileType + ) -> URL? { + switch referenceFileType { + case .heic, .mov: + // The extension is specified and is what necessary to load a live photo, use it. + return cacheFileURLIfOnDisk( + forKey: key, processorIdentifier: identifier, forcedExtension: referenceFileType.fileExtension + ) + case .other(let ext): + if ext.isEmpty { + // The extension is not specified. Guess from the default set of values. + let possibleFileTypes: [LivePhotoResource.FileType] = [.heic, .mov] + for fileType in possibleFileTypes { + let url = cacheFileURLIfOnDisk( + forKey: key, processorIdentifier: identifier, forcedExtension: fileType.fileExtension + ) + if url != nil { + // Found, early return. + return url + } + } + return nil + } else { + // The extension is specified but maybe not valid for live photo. Trust the user and use it to find the + // file. + return cacheFileURLIfOnDisk( + forKey: key, processorIdentifier: identifier, forcedExtension: ext + ) + } + } + } +} +#endif diff --git a/Sources/General/KingfisherManager.swift b/Sources/General/KingfisherManager.swift index fe19aa78f..f9429f0af 100644 --- a/Sources/General/KingfisherManager.swift +++ b/Sources/General/KingfisherManager.swift @@ -146,7 +146,7 @@ public class KingfisherManager: @unchecked Sendable { public var defaultOptions = KingfisherOptionsInfo.empty // Use `defaultOptions` to overwrite the `downloader` and `cache`. - private var currentDefaultOptions: KingfisherOptionsInfo { + var currentDefaultOptions: KingfisherOptionsInfo { return [.downloader(downloader), .targetCache(cache)] + defaultOptions } @@ -384,7 +384,7 @@ public class KingfisherManager: @unchecked Sendable { private func retrieveImage( with source: Source, - context: RetrievingContext, + context: RetrievingContext, completionHandler: (@Sendable (Result) -> Void)?) -> DownloadTask? { let options = context.options @@ -457,7 +457,7 @@ public class KingfisherManager: @unchecked Sendable { private func cacheImage( source: Source, options: KingfisherParsedOptionsInfo, - context: RetrievingContext, + context: RetrievingContext, result: Result, completionHandler: (@Sendable (Result) -> Void)? ) @@ -519,7 +519,7 @@ public class KingfisherManager: @unchecked Sendable { @discardableResult func loadAndCacheImage( source: Source, - context: RetrievingContext, + context: RetrievingContext, completionHandler: (@Sendable (Result) -> Void)?) -> DownloadTask.WrappedTask? { let options = context.options @@ -582,7 +582,7 @@ public class KingfisherManager: @unchecked Sendable { /// func retrieveImageFromCache( source: Source, - context: RetrievingContext, + context: RetrievingContext, completionHandler: (@Sendable (Result) -> Void)?) -> Bool { let options = context.options @@ -863,7 +863,7 @@ extension KingfisherManager { } } -class RetrievingContext: @unchecked Sendable { +class RetrievingContext: @unchecked Sendable { private let propertyQueue = DispatchQueue(label: "com.onevcat.Kingfisher.RetrievingContextPropertyQueue") @@ -873,10 +873,10 @@ class RetrievingContext: @unchecked Sendable { set { propertyQueue.sync { _options = newValue } } } - let originalSource: Source + let originalSource: SourceType var propagationErrors: [PropagationError] = [] - init(options: KingfisherParsedOptionsInfo, originalSource: Source) { + init(options: KingfisherParsedOptionsInfo, originalSource: SourceType) { self.originalSource = originalSource _options = options } diff --git a/Sources/General/KingfisherOptionsInfo.swift b/Sources/General/KingfisherOptionsInfo.swift index 6f032b860..57162a006 100644 --- a/Sources/General/KingfisherOptionsInfo.swift +++ b/Sources/General/KingfisherOptionsInfo.swift @@ -347,6 +347,8 @@ public enum KingfisherOptionsInfoItem: Sendable { /// If not set or if the associated optional ``Source`` value is `nil`, the device's Low Data Mode will be ignored, /// and the original source will be loaded following the system default behavior. case lowDataMode(Source?) + + case forcedCacheFileExtension(String?) } // MARK: - KingfisherParsedOptionsInfo @@ -397,6 +399,7 @@ public struct KingfisherParsedOptionsInfo: Sendable { public var alternativeSources: [Source]? = nil public var retryStrategy: (any RetryStrategy)? = nil public var lowDataModeSource: Source? = nil + public var forcedExtension: String? = nil var onDataReceived: [any DataReceivingSideEffect]? = nil @@ -440,6 +443,7 @@ public struct KingfisherParsedOptionsInfo: Sendable { case .alternativeSources(let sources): alternativeSources = sources case .retryStrategy(let strategy): retryStrategy = strategy case .lowDataMode(let source): lowDataModeSource = source + case .forcedCacheFileExtension(let ext): forcedExtension = ext } } diff --git a/Sources/Image/ImageProcessor.swift b/Sources/Image/ImageProcessor.swift index 772d5f3f2..fd033b88b 100644 --- a/Sources/Image/ImageProcessor.swift +++ b/Sources/Image/ImageProcessor.swift @@ -818,6 +818,25 @@ public struct DownsamplingImageProcessor: ImageProcessor { } } +// This is an internal processor to provide the same interface for Live Photos. +// It is not intended to be open and used from external. +struct LivePhotoImageProcessor: ImageProcessor { + + public static let `default` = LivePhotoImageProcessor() + private init() { } + + public let identifier = "com.onevcat.Kingfisher.LivePhotoImageProcessor" + + public func process(item: ImageProcessItem, options: KingfisherParsedOptionsInfo) -> KFCrossPlatformImage? { + switch item { + case .image(let image): + return image + case .data: + return KFCrossPlatformImage() + } + } +} + infix operator |>: AdditionPrecedence /// Concatenates two `ImageProcessor`s to create a new one, in which the `left` and `right` are combined in order to diff --git a/Sources/Networking/ImageDownloader+LivePhoto.swift b/Sources/Networking/ImageDownloader+LivePhoto.swift new file mode 100644 index 000000000..9d512348a --- /dev/null +++ b/Sources/Networking/ImageDownloader+LivePhoto.swift @@ -0,0 +1,102 @@ +// +// ImageDownloader+LivePhoto.swift +// Kingfisher +// +// Created by onevcat on 2024/10/01. +// +// Copyright (c) 2024 Wei Wang +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +#if os(macOS) +import AppKit +#else +import UIKit +#endif + +public struct LivePhotoResourceDownloadingResult: Sendable { + + /// The original URL of the image request. + public let url: URL? + + /// The raw data received from the downloader. + public let originalData: Data + + /// Creates an `ImageDownloadResult` object. + /// + /// - Parameters: + /// - image: The image of the download result. + /// - url: The URL from which the image was downloaded. + /// - originalData: The binary data of the image. + public init(originalData: Data, url: URL? = nil) { + self.url = url + self.originalData = originalData + } +} + +extension ImageDownloader { + + public func downloadLivePhotoResource( + with url: URL, + options: KingfisherParsedOptionsInfo + ) async throws -> LivePhotoResourceDownloadingResult { + let task = CancellationDownloadTask() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let downloadTask = downloadLivePhotoResource(with: url, options: options) { result in + continuation.resume(with: result) + } + if Task.isCancelled { + downloadTask.cancel() + } else { + Task { + await task.setTask(downloadTask) + } + } + } + } onCancel: { + Task { + await task.task?.cancel() + } + } + } + + @discardableResult + public func downloadLivePhotoResource( + with url: URL, + options: KingfisherParsedOptionsInfo, + completionHandler: (@Sendable (Result) -> Void)? = nil + ) -> DownloadTask { + var checkedOptions = options + if options.processor == DefaultImageProcessor.default { + // The default processor is a default behavior so we replace it silently. + checkedOptions.processor = LivePhotoImageProcessor.default + } else if options.processor != LivePhotoImageProcessor.default { + assertionFailure("[Kingfisher] Using of custom processors during loading of live photo resource is not supported.") + checkedOptions.processor = LivePhotoImageProcessor.default + } + return downloadImage(with: url, options: checkedOptions) { result in + guard let completionHandler else { + return + } + let newResult = result.map { LivePhotoResourceDownloadingResult(originalData: $0.originalData, url: $0.url) } + completionHandler(newResult) + } + } +} diff --git a/Sources/Networking/ImagePrefetcher.swift b/Sources/Networking/ImagePrefetcher.swift index 6fa9a6788..3b97d5e77 100644 --- a/Sources/Networking/ImagePrefetcher.swift +++ b/Sources/Networking/ImagePrefetcher.swift @@ -309,7 +309,8 @@ public class ImagePrefetcher: CustomStringConvertible, @unchecked Sendable { let cacheType = manager.cache.imageCachedType( forKey: source.cacheKey, - processorIdentifier: optionsInfo.processor.identifier) + processorIdentifier: optionsInfo.processor.identifier + ) switch cacheType { case .memory: append(cached: source) diff --git a/Tests/KingfisherTests/ImageCacheTests.swift b/Tests/KingfisherTests/ImageCacheTests.swift index 5ada85acb..2963ef77b 100644 --- a/Tests/KingfisherTests/ImageCacheTests.swift +++ b/Tests/KingfisherTests/ImageCacheTests.swift @@ -310,18 +310,15 @@ class ImageCacheTests: XCTestCase { XCTAssertTrue(cachePath.hasSuffix(".jpg")) } - func testCachedImageIsFetchedSynchronouslyFromTheMemoryCache() { + @MainActor func testCachedImageIsFetchedSynchronouslyFromTheMemoryCache() { cache.store(testImage, forKey: testKeys[0], toDisk: false) - let foundImage = ActorBox(nil) + var image: KFCrossPlatformImage? = nil cache.retrieveImage(forKey: testKeys[0]) { result in - Task { - await foundImage.setValue(result.value?.image) + MainActor.assumeIsolated { + image = try? result.get().image } } - Task { - let value = await foundImage.value - XCTAssertEqual(testImage, value) - } + XCTAssertEqual(testImage, image) } func testCachedImageIsFetchedSynchronouslyFromTheMemoryCacheAsync() async throws { @@ -765,6 +762,138 @@ class ImageCacheTests: XCTestCase { XCTAssertEqual(newSize, UInt(testImagePNGData.count * testKeys.count)) } + func testStoreFileWithForcedExtension() async throws { + let key = testKeys[0] + try await cache.store(testImage, forKey: key, forcedExtension: "jpg", toDisk: true) + + let pathWithoutExtension = cache.cachePath(forKey: key) + XCTAssertFalse(FileManager.default.fileExists(atPath: pathWithoutExtension)) + + let pathWithExtension = cache.cachePath(forKey: key, forcedExtension: "jpg") + XCTAssertTrue(FileManager.default.fileExists(atPath: pathWithExtension)) + + XCTAssertEqual(cache.imageCachedType(forKey: key), .memory) + XCTAssertEqual(cache.imageCachedType(forKey: key, forcedExtension: "jpg"), .memory) + + cache.clearMemoryCache() + XCTAssertEqual(cache.imageCachedType(forKey: key), .none) + XCTAssertEqual(cache.imageCachedType(forKey: key, forcedExtension: "jpg"), .disk) + } + + func testPossibleCacheFileURLIfOnDiskNotCached() { + let url = URL(string: "https://example.com/photo")! + let resource = LivePhotoResource(downloadURL: url) + + let fileURL = cache.possibleCacheFileURLIfOnDisk( + forKey: resource.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier, + referenceFileType: .heic + ) + + // Not cached + XCTAssertNil(fileURL) + } + + func testPossibleCacheFileURLIfOnDiskCachedWithWrongFileType() async throws { + let url = URL(string: "https://example.com/photo")! + let resource = LivePhotoResource(downloadURL: url, fileType: .heic) + + // Cache without a file type extension + try await cache.storeToDisk( + testImageData, + forKey: resource.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier + ) + + let fileURL = cache.possibleCacheFileURLIfOnDisk( + forKey: resource.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier, + referenceFileType: .heic + ) + + // Not cached + XCTAssertNil(fileURL) + } + + func testPossibleCacheFileURLIfOnDiskCachedWithExplicitFileType() async throws { + let url = URL(string: "https://example.com/photo")! + let resource = LivePhotoResource(downloadURL: url, fileType: .heic) + + // Cache without a file type extension + try await cache.storeToDisk( + testImageData, + forKey: resource.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: "heic" + ) + + let fileURL = cache.possibleCacheFileURLIfOnDisk( + forKey: resource.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier, + referenceFileType: .heic + ) + + let result = try XCTUnwrap(fileURL) + XCTAssertTrue(result.absoluteString.hasSuffix(".heic")) + } + + func testPossibleCacheFileURLIfOnDiskCachedGuessingFileTypeNotHit() async throws { + let url = URL(string: "https://example.com/photo")! + let resource = LivePhotoResource(downloadURL: url, fileType: .heic) + + let fileURL = cache.possibleCacheFileURLIfOnDisk( + forKey: resource.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier, + referenceFileType: .other("") + ) + + XCTAssertNil(fileURL) + } + + func testPossibleCacheFileURLIfOnDiskCachedGuessingFileType() async throws { + let url = URL(string: "https://example.com/photo")! + let resource = LivePhotoResource(downloadURL: url, fileType: .heic) + + // Cache without a file type extension + try await cache.storeToDisk( + testImageData, + forKey: resource.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: "heic" + ) + + let fileURL = cache.possibleCacheFileURLIfOnDisk( + forKey: resource.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier, + referenceFileType: .other("") + ) + + let result = try XCTUnwrap(fileURL) + XCTAssertTrue(result.absoluteString.hasSuffix(".heic")) + } + + func testPossibleCacheFileURLIfOnDiskCachedArbitraryFileType() async throws { + let url = URL(string: "https://example.com/photo")! + let resource = LivePhotoResource(downloadURL: url, fileType: .heic) + + // Cache without a file type extension + try await cache.storeToDisk( + testImageData, + forKey: resource.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: "myExt" + ) + + let fileURL = cache.possibleCacheFileURLIfOnDisk( + forKey: resource.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier, + referenceFileType: .other("myExt") + ) + + let result = try XCTUnwrap(fileURL) + XCTAssertTrue(result.absoluteString.hasSuffix(".myExt")) + } + // MARK: - Helper private func storeMultipleImages(_ completionHandler: @escaping () -> Void) { let group = DispatchGroup() diff --git a/Tests/KingfisherTests/ImageDownloaderTests.swift b/Tests/KingfisherTests/ImageDownloaderTests.swift index aa41bc643..f5d68bd9a 100644 --- a/Tests/KingfisherTests/ImageDownloaderTests.swift +++ b/Tests/KingfisherTests/ImageDownloaderTests.swift @@ -676,6 +676,14 @@ class ImageDownloaderTests: XCTestCase { } waitForExpectations(timeout: 3, handler: nil) } + + func testDownloadingLivePhotoResources() async throws { + let url = testURLs[0] + stub(url, data: testImageData) + let result = try await downloader.downloadLivePhotoResource(with: url, options: .init(.empty)) + XCTAssertEqual(result.originalData, testImageData) + XCTAssertEqual(result.url, url) + } } class URLNilDataModifier: ImageDownloaderDelegate { diff --git a/Tests/KingfisherTests/ImageViewExtensionTests.swift b/Tests/KingfisherTests/ImageViewExtensionTests.swift index 5d5f180d5..5c5a7e6cc 100644 --- a/Tests/KingfisherTests/ImageViewExtensionTests.swift +++ b/Tests/KingfisherTests/ImageViewExtensionTests.swift @@ -363,6 +363,7 @@ class ImageViewExtensionTests: XCTestCase { reason: .notCurrentSourceTask(let result, _, let source)) = result.error! { XCTAssertEqual(source.url, testURLs[0]) + XCTAssertEqual(result?.originalSource.url, testURLs[0]) XCTAssertNotEqual(result!.image, self.imageView.image) } else { XCTFail() diff --git a/Tests/KingfisherTests/KingfisherManagerTests.swift b/Tests/KingfisherTests/KingfisherManagerTests.swift index 495768d34..f4e279239 100644 --- a/Tests/KingfisherTests/KingfisherManagerTests.swift +++ b/Tests/KingfisherTests/KingfisherManagerTests.swift @@ -900,7 +900,7 @@ class KingfisherManagerTests: XCTestCase { .network(URL(string: "2")!) ] let info = KingfisherParsedOptionsInfo([.alternativeSources(allSources)]) - let context = RetrievingContext( + let context = RetrievingContext( options: info, originalSource: .network(URL(string: "0")!)) let source1 = context.popAlternativeSource() @@ -1351,6 +1351,319 @@ class KingfisherManagerTests: XCTestCase { } waitForExpectations(timeout: 3, handler: nil) } + + func testMissingResourceOfLivePhotoFound() { + let resource = KF.ImageResource(downloadURL: LivePhotoURL.mov) + let source = LivePhotoSource(resources: [resource]) + + let missing = manager.missingResources(source, options: .init(.empty)) + XCTAssertEqual(missing.count, 1) + } + + func testMissingResourceOfLivePhotoNotFound() async throws { + let resource = KF.ImageResource(downloadURL: LivePhotoURL.mov) + + try await manager.cache.storeToDisk( + testImageData, + forKey: resource.cacheKey, + forcedExtension: resource.downloadURL.pathExtension + ) + + let source = LivePhotoSource(resources: [resource]) + let missing = manager.missingResources(source, options: .init(.empty)) + XCTAssertEqual(missing.count, 0) + } + + func testMissingResourceOfLivePhotoFoundOne() async throws { + let resource1 = KF.ImageResource(downloadURL: LivePhotoURL.heic) + let resource2 = KF.ImageResource(downloadURL: LivePhotoURL.mov) + + try await manager.cache.storeToDisk( + testImageData, + forKey: resource1.cacheKey, + forcedExtension: resource1.downloadURL.pathExtension + ) + + let source = LivePhotoSource(resources: [resource1, resource2]) + let missing = manager.missingResources(source, options: .init(.empty)) + XCTAssertEqual(missing.count, 1) + XCTAssertEqual(missing[0].downloadURL, resource2.downloadURL) + } + + func testMissingResourceOfLivePhotoForceRefresh() async throws { + let resource1 = KF.ImageResource(downloadURL: LivePhotoURL.heic) + let resource2 = KF.ImageResource(downloadURL: LivePhotoURL.mov) + + try await manager.cache.storeToDisk( + testImageData, + forKey: resource1.cacheKey, + forcedExtension: resource1.downloadURL.pathExtension + ) + + let source = LivePhotoSource(resources: [resource1, resource2]) + let missing = manager.missingResources(source, options: .init([.forceRefresh])) + XCTAssertEqual(missing.count, 2) + XCTAssertEqual(missing[0].downloadURL, resource1.downloadURL) + XCTAssertEqual(missing[1].downloadURL, resource2.downloadURL) + } + + func testDownloadAndCacheLivePhotoResourcesAll() async throws { + let resource1 = KF.ImageResource(downloadURL: LivePhotoURL.mov) + let resource2 = KF.ImageResource(downloadURL: LivePhotoURL.heic) + + stub(resource1.downloadURL, data: testImageData) + stub(resource2.downloadURL, data: testImageData) + + let result = try await manager.downloadAndCache( + resources: [resource1, resource2].map { LivePhotoResource.init(resource: $0) + }, + options: .init(.empty)) + XCTAssertEqual(result.count, 2) + + let urls = result.compactMap(\.url) + XCTAssertTrue(urls.contains(LivePhotoURL.mov)) + XCTAssertTrue(urls.contains(LivePhotoURL.heic)) + + let resourceCached1 = manager.cache.imageCachedType( + forKey: resource1.cacheKey, + forcedExtension: resource1.downloadURL.pathExtension + ) + let resourceCached2 = manager.cache.imageCachedType( + forKey: resource2.cacheKey, + forcedExtension: resource2.downloadURL.pathExtension + ) + XCTAssertEqual(resourceCached1, .disk) + XCTAssertEqual(resourceCached2, .disk) + } + + func testRetrieveLivePhotoFromNetwork() async throws { + let resource1 = KF.ImageResource(downloadURL: LivePhotoURL.mov) + let resource2 = KF.ImageResource(downloadURL: LivePhotoURL.heic) + + stub(resource1.downloadURL, data: testImageData) + stub(resource2.downloadURL, data: testImageData) + + let resource1Cached = manager.cache.isCached( + forKey: resource1.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier + ) + let resource2Cached = manager.cache.isCached( + forKey: resource2.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier + ) + XCTAssertFalse(resource1Cached) + XCTAssertFalse(resource2Cached) + + let source = LivePhotoSource(resources: [resource1, resource2]) + let result = try await manager.retrieveLivePhoto(with: source) + XCTAssertEqual(result.fileURLs.count, 2) + result.fileURLs.forEach { url in + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + } + XCTAssertEqual(result.cacheType, .none) + XCTAssertEqual(result.data(), [testImageData, testImageData]) + let urlsInSource = result.source.resources.map(\.downloadURL) + XCTAssertTrue(urlsInSource.contains(LivePhotoURL.mov)) + XCTAssertTrue(urlsInSource.contains(LivePhotoURL.heic)) + } + + func testRetrieveLivePhotoFromLocal() async throws { + let resource1 = KF.ImageResource(downloadURL: LivePhotoURL.mov) + let resource2 = KF.ImageResource(downloadURL: LivePhotoURL.heic) + + try await manager.cache.storeToDisk( + testImageData, + forKey: resource1.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: resource1.downloadURL.pathExtension + ) + try await manager.cache.storeToDisk( + testImageData, + forKey: resource2.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: resource2.downloadURL.pathExtension + ) + + let resource1Cached = manager.cache.isCached( + forKey: resource1.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: resource1.downloadURL.pathExtension + ) + let resource2Cached = manager.cache.isCached( + forKey: resource2.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: resource2.downloadURL.pathExtension + ) + XCTAssertTrue(resource1Cached) + XCTAssertTrue(resource2Cached) + + let source = LivePhotoSource(resources: [resource1, resource2]) + let result = try await manager.retrieveLivePhoto(with: source) + XCTAssertEqual(result.fileURLs.count, 2) + result.fileURLs.forEach { url in + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + } + XCTAssertEqual(result.cacheType, .disk) + XCTAssertEqual(result.data(), []) + let urlsInSource = result.source.resources.map(\.downloadURL) + XCTAssertTrue(urlsInSource.contains(LivePhotoURL.mov)) + XCTAssertTrue(urlsInSource.contains(LivePhotoURL.heic)) + } + + func testRetrieveLivePhotoMixed() async throws { + let resource1 = KF.ImageResource(downloadURL: LivePhotoURL.mov) + let resource2 = KF.ImageResource(downloadURL: LivePhotoURL.heic) + + try await manager.cache.storeToDisk( + testImageData, + forKey: resource1.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: resource1.downloadURL.pathExtension + ) + stub(resource2.downloadURL, data: testImageData) + + let resource1Cached = manager.cache.isCached( + forKey: resource1.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: resource1.downloadURL.pathExtension + ) + let resource2Cached = manager.cache.isCached( + forKey: resource2.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: resource2.downloadURL.pathExtension + ) + XCTAssertTrue(resource1Cached) + XCTAssertFalse(resource2Cached) + + let source = LivePhotoSource(resources: [resource1, resource2]) + let result = try await manager.retrieveLivePhoto(with: source) + XCTAssertEqual(result.fileURLs.count, 2) + result.fileURLs.forEach { url in + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + } + XCTAssertEqual(result.cacheType, .none) + XCTAssertEqual(result.data(), [testImageData]) + let urlsInSource = result.source.resources.map(\.downloadURL) + XCTAssertTrue(urlsInSource.contains(LivePhotoURL.mov)) + XCTAssertTrue(urlsInSource.contains(LivePhotoURL.heic)) + } + + func testRetrieveLivePhotoNetworkThenCache() async throws { + let resource1 = KF.ImageResource(downloadURL: LivePhotoURL.mov) + let resource2 = KF.ImageResource(downloadURL: LivePhotoURL.heic) + + stub(resource1.downloadURL, data: testImageData) + stub(resource2.downloadURL, data: testImageData) + + let resource1Cached = manager.cache.isCached( + forKey: resource1.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: resource1.downloadURL.pathExtension + ) + let resource2Cached = manager.cache.isCached( + forKey: resource2.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: resource2.downloadURL.pathExtension + ) + XCTAssertFalse(resource1Cached) + XCTAssertFalse(resource2Cached) + + let source = LivePhotoSource(resources: [resource1, resource2]) + let result = try await manager.retrieveLivePhoto(with: source) + XCTAssertEqual(result.fileURLs.count, 2) + result.fileURLs.forEach { url in + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + } + XCTAssertEqual(result.cacheType, .none) + XCTAssertEqual(result.data(), [testImageData, testImageData]) + let urlsInSource = result.source.resources.map(\.downloadURL) + XCTAssertTrue(urlsInSource.contains(LivePhotoURL.mov)) + XCTAssertTrue(urlsInSource.contains(LivePhotoURL.heic)) + + let localResult = try await manager.retrieveLivePhoto(with: source) + XCTAssertEqual(localResult.fileURLs.count, 2) + XCTAssertEqual(localResult.cacheType, .disk) + } + + func testDownloadAndCacheLivePhotoWithEmptyResources() async throws { + let result = try await manager.downloadAndCache(resources: [], options: .init([])) + XCTAssertTrue(result.isEmpty) + } + + func testDownloadAndCacheLivePhotoWithSingleResource() async throws { + let resource = LivePhotoResource(downloadURL: LivePhotoURL.heic) + stub(resource.downloadURL!, data: testImageData) + + let result = try await manager.downloadAndCache(resources: [resource], options: .init([])) + XCTAssertEqual(result.count, 1) + + let t = manager.cache.imageCachedType(forKey: resource.cacheKey, forcedExtension: "heic") + XCTAssertEqual(t, .disk) + } + + func testDownloadAndCacheLivePhotoWithSingleResourceGuessingUnsupportedExtension() async throws { + let resource = LivePhotoResource(downloadURL: URL(string: "https://example.com")!) + stub(resource.downloadURL!, data: testImageData) + + XCTAssertEqual(resource.referenceFileType, .other("")) + + let result = try await manager.downloadAndCache(resources: [resource], options: .init([])) + XCTAssertEqual(result.count, 1) + + var cacheType = manager.cache.imageCachedType(forKey: resource.cacheKey, forcedExtension: "heic") + XCTAssertEqual(cacheType, .none) + + cacheType = manager.cache.imageCachedType(forKey: resource.cacheKey) + XCTAssertEqual(cacheType, .disk) + } + + func testDownloadAndCacheLivePhotoWithSingleResourceExplicitSetExtension() async throws { + let resource = LivePhotoResource(downloadURL: URL(string: "https://example.com")!, fileType: .heic) + stub(resource.downloadURL!, data: testImageData) + + XCTAssertEqual(resource.referenceFileType, .heic) + + let result = try await manager.downloadAndCache(resources: [resource], options: .init([])) + XCTAssertEqual(result.count, 1) + + var cacheType = manager.cache.imageCachedType(forKey: resource.cacheKey, forcedExtension: "heic") + XCTAssertEqual(cacheType, .disk) + + cacheType = manager.cache.imageCachedType(forKey: resource.cacheKey) + XCTAssertEqual(cacheType, .none) + } + + func testDownloadAndCacheLivePhotoWithSingleResourceGuessingHEICExtension() async throws { + let resource = LivePhotoResource(downloadURL: URL(string: "https://example.com")!) + stub(resource.downloadURL!, data: partitalHEICData) + + XCTAssertEqual(resource.referenceFileType, .other("")) + + let result = try await manager.downloadAndCache(resources: [resource], options: .init([])) + XCTAssertEqual(result.count, 1) + + var cacheType = manager.cache.imageCachedType(forKey: resource.cacheKey, forcedExtension: "heic") + XCTAssertEqual(cacheType, .disk) + + cacheType = manager.cache.imageCachedType(forKey: resource.cacheKey) + XCTAssertEqual(cacheType, .none) + } + + func testDownloadAndCacheLivePhotoWithSingleResourceGuessingMOVExtension() async throws { + let resource = LivePhotoResource(downloadURL: URL(string: "https://example.com")!) + stub(resource.downloadURL!, data: partitalMOVData) + + XCTAssertEqual(resource.referenceFileType, .other("")) + + let result = try await manager.downloadAndCache(resources: [resource], options: .init([])) + XCTAssertEqual(result.count, 1) + + var cacheType = manager.cache.imageCachedType(forKey: resource.cacheKey, forcedExtension: "mov") + XCTAssertEqual(cacheType, .disk) + + cacheType = manager.cache.imageCachedType(forKey: resource.cacheKey) + XCTAssertEqual(cacheType, .none) + } } private var imageCreatingOptionsKey: Void? diff --git a/Tests/KingfisherTests/KingfisherTestHelper.swift b/Tests/KingfisherTests/KingfisherTestHelper.swift index 42f468a92..494feb366 100644 --- a/Tests/KingfisherTests/KingfisherTestHelper.swift +++ b/Tests/KingfisherTests/KingfisherTestHelper.swift @@ -72,6 +72,9 @@ let testImageString = var testImage = KFCrossPlatformImage(data: testImageData)! let testImageData = Data(base64Encoded: testImageString)! +let partitalHEICData = Data(base64Encoded: "AAAALGZ0eXBoZWljAAAAAG1pZjFNaUhCTWlIRU1pUHI=")! +let partitalMOVData = Data(base64Encoded: "AAAAFGZ0eXBxdCAgAAAAAHF0ICAAAAAId2lkZQAgJto=")! + let testImagePNGData = testImage.kf.pngRepresentation()! let testImageJEPGData = testImage.kf.jpegRepresentation(compressionQuality: 1.0)! let testImageGIFData = Data(fileName: "dancing-banana.gif") @@ -84,6 +87,11 @@ let testKeys = [ "http://onevcat.com/content/images/2014/May/200.jpg?fads#kj1asf" ] +enum LivePhotoURL { + static let mov = URL(string: "https://example.com/sample.mov")! + static let heic = URL(string: "https://example.com/sample.heic")! +} + let testURLs = testKeys.map { URL(string: $0)! } func cleanDefaultCache() { diff --git a/Tests/KingfisherTests/LivePhotoSourceTests.swift b/Tests/KingfisherTests/LivePhotoSourceTests.swift new file mode 100644 index 000000000..0fe0a20f9 --- /dev/null +++ b/Tests/KingfisherTests/LivePhotoSourceTests.swift @@ -0,0 +1,183 @@ +// +// LivePhotoSourceTests.swift +// Kingfisher +// +// Created by onevcat on 2024/10/01. +// +// Copyright (c) 2024 Wei Wang +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import XCTest +@testable import Kingfisher + +class LivePhotoSourceTests: XCTestCase { + + func testLivePhotoResourceInitialization() { + let url = URL(string: "https://example.com/photo.heic")! + let resource = LivePhotoResource(downloadURL: url) + + XCTAssertEqual(resource.downloadURL, url) + XCTAssertEqual(resource.referenceFileType, .heic) + } + + func testLivePhotoResourceInitializationWithResource() { + let url = URL(string: "https://example.com/photo.mov")! + let imageResource = KF.ImageResource(downloadURL: url) + let resource = LivePhotoResource(resource: imageResource) + + XCTAssertEqual(resource.downloadURL, url) + XCTAssertEqual(resource.referenceFileType, .mov) + } + + func testLivePhotoResourceFileExtensionByType() { + let mov = LivePhotoResource.FileType.mov + XCTAssertEqual(mov.determinedFileExtension(Data()), "mov") + XCTAssertEqual(mov.fileExtension, "mov") + + let heic = LivePhotoResource.FileType.heic + XCTAssertEqual(heic.determinedFileExtension(Data()), "heic") + XCTAssertEqual(heic.fileExtension, "heic") + + let other = LivePhotoResource.FileType.other("exe") + XCTAssertEqual(other.fileExtension, "exe") + } + + func testLivePhotoResourceFileTypeDeterminationForHEIC() { + let data = Data([0x00, 0x00, 0x00, 0x00, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63]) + let fileType = LivePhotoResource.FileType.other("") + let determinedExtension = fileType.determinedFileExtension(data) + + XCTAssertEqual(determinedExtension, "heic") + } + + func testLivePhotoResourceFileTypeDeterminationForQT() { + let data = Data([0x00, 0x00, 0x00, 0x00, 0x66, 0x74, 0x79, 0x70, 0x71, 0x74, 0x20, 0x20]) + let fileType = LivePhotoResource.FileType.other("") + let determinedExtension = fileType.determinedFileExtension(data) + + XCTAssertEqual(determinedExtension, "mov") + } + + func testLivePhotoResourceFileTypeDeterminationForExplicitFileType() { + let data = Data([0x00, 0x00, 0x00, 0x00, 0x66, 0x74, 0x79, 0x70, 0x71, 0x74, 0x20, 0x20]) + let fileType = LivePhotoResource.FileType.other("ext") + let determinedExtension = fileType.determinedFileExtension(data) + + XCTAssertEqual(determinedExtension, "ext") + } + + func testLivePhotoResourceFileTypeDeterminationForUnknown() { + let data = Data([0x00, 0x00, 0x00, 0x00, 0x66, 0x74, 0x79, 0x70, 0x71, 0x74, 0x20, 0x22]) + let fileType = LivePhotoResource.FileType.other("") + let determinedExtension = fileType.determinedFileExtension(data) + + XCTAssertEqual(determinedExtension, nil) + } + + func testLivePhotoResourceFileTypeDeterminationForNonFYTP() { + let data = Data([0x00, 0x00, 0x00, 0x00, 0x12, 0x34, 0x56, 0x78, 0x71, 0x74, 0x20, 0x20]) + let fileType = LivePhotoResource.FileType.other("") + let determinedExtension = fileType.determinedFileExtension(data) + + XCTAssertEqual(determinedExtension, nil) + } + + func testLivePhotoResourceFileTypeDeterminationForNotEnoughData() { + let data = Data([0x00, 0x00, 0x00, 0x00]) + let fileType = LivePhotoResource.FileType.other("") + let determinedExtension = fileType.determinedFileExtension(data) + + XCTAssertEqual(determinedExtension, nil) + } + + func testLivePhotoSourceInitializationWithResources() { + let url1 = URL(string: "https://example.com/photo1.heic")! + let url2 = URL(string: "https://example.com/photo2.mov")! + let resources = [KF.ImageResource(downloadURL: url1), KF.ImageResource(downloadURL: url2)] + let livePhotoSource = LivePhotoSource(resources: resources) + + XCTAssertEqual(livePhotoSource.resources.count, 2) + XCTAssertEqual(livePhotoSource.resources[0].downloadURL, url1) + XCTAssertEqual(livePhotoSource.resources[1].downloadURL, url2) + } + + func testLivePhotoSourceInitializationWithURLs() { + let url1 = URL(string: "https://example.com/photo1.heic")! + let url2 = URL(string: "https://example.com/photo2.mov")! + let livePhotoSource = LivePhotoSource(urls: [url1, url2]) + + XCTAssertEqual(livePhotoSource.resources.count, 2) + XCTAssertEqual(livePhotoSource.resources[0].downloadURL, url1) + XCTAssertEqual(livePhotoSource.resources[1].downloadURL, url2) + } + + func testLivePhotoResourceInitializationWithCacheKey() { + let url = URL(string: "https://example.com/photo.heic")! + let cacheKey = "customCacheKey" + let resource = LivePhotoResource(downloadURL: url, cacheKey: cacheKey) + + XCTAssertEqual(resource.downloadURL, url) + XCTAssertEqual(resource.cacheKey, cacheKey) + XCTAssertEqual(resource.referenceFileType, .heic) + } + + func testLivePhotoResourceInitializationWithFileType() { + let url = URL(string: "https://example.com/photo.unknown")! + let resource = LivePhotoResource(downloadURL: url, fileType: .other("unknown")) + + XCTAssertEqual(resource.downloadURL, url) + XCTAssertEqual(resource.referenceFileType, .other("unknown")) + } + + func testLivePhotoResourceGuessedFileType() { + let url1 = URL(string: "https://example.com/photo.heic")! + let url2 = URL(string: "https://example.com/photo.mov")! + let url3 = URL(string: "https://example.com/photo.unknown")! + + let resource1 = KF.ImageResource(downloadURL: url1) + let resource2 = KF.ImageResource(downloadURL: url2) + let resource3 = KF.ImageResource(downloadURL: url3) + + XCTAssertEqual(resource1.guessedFileType, .heic) + XCTAssertEqual(resource2.guessedFileType, .mov) + XCTAssertEqual(resource3.guessedFileType, .other("unknown")) + } + + func testLivePhotoSourceInitializationWithMixedResources() { + let url1 = URL(string: "https://example.com/photo1.heic")! + let url2 = URL(string: "https://example.com/photo2.mov")! + let url3 = URL(string: "https://example.com/photo3.unknown")! + let resources = [ + KF.ImageResource(downloadURL: url1), + KF.ImageResource(downloadURL: url2), + KF.ImageResource(downloadURL: url3) + ] + let livePhotoSource = LivePhotoSource(resources: resources) + + XCTAssertEqual(livePhotoSource.resources.count, 3) + XCTAssertEqual(livePhotoSource.resources[0].downloadURL, url1) + XCTAssertEqual(livePhotoSource.resources[1].downloadURL, url2) + XCTAssertEqual(livePhotoSource.resources[2].downloadURL, url3) + XCTAssertEqual(livePhotoSource.resources[0].referenceFileType, .heic) + XCTAssertEqual(livePhotoSource.resources[1].referenceFileType, .mov) + XCTAssertEqual(livePhotoSource.resources[2].referenceFileType, .other("unknown")) + } + +}