From fd37da748117bc1ef24c5cce3ecd29f595aad4a2 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 1 Oct 2024 22:23:52 +0900 Subject: [PATCH 01/26] Downloader support for live photo Ignores processor since only file representation is required --- Kingfisher.xcodeproj/project.pbxproj | 4 + Sources/Image/ImageProcessor.swift | 19 ++++ .../ImageDownloader+LivePhoto.swift | 99 +++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 Sources/Networking/ImageDownloader+LivePhoto.swift diff --git a/Kingfisher.xcodeproj/project.pbxproj b/Kingfisher.xcodeproj/project.pbxproj index bf1bcd281..3cfed2358 100644 --- a/Kingfisher.xcodeproj/project.pbxproj +++ b/Kingfisher.xcodeproj/project.pbxproj @@ -66,6 +66,7 @@ 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 */; }; 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 */; }; @@ -213,6 +214,7 @@ 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 = ""; }; 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 = ""; }; @@ -361,6 +363,7 @@ D12AB69D215D2BB50013BA68 /* RequestModifier.swift */, D8FCF6A721C5A0E500F9ABC0 /* RedirectHandler.swift */, D12AB69F215D2BB50013BA68 /* ImageDownloader.swift */, + D12F675F2CAC2DB700AB63AB /* ImageDownloader+LivePhoto.swift */, 4BD821612189FC0C0084CC21 /* SessionDelegate.swift */, 4BD821662189FD330084CC21 /* SessionDataTask.swift */, 4B8E2916216F3F7F0095FAD1 /* ImageDownloaderDelegate.swift */, @@ -857,6 +860,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 */, 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..f6b03cefa --- /dev/null +++ b/Sources/Networking/ImageDownloader+LivePhoto.swift @@ -0,0 +1,99 @@ +// +// 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 LivePhotoResourceLoadingResult: 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 -> LivePhotoResourceLoadingResult { + 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 != 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 { LivePhotoResourceLoadingResult(originalData: $0.originalData, url: $0.url) } + completionHandler(newResult) + } + } +} From 31002aafbbe8ab095fdd8746d168a222dfb7151d Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 1 Oct 2024 22:30:52 +0900 Subject: [PATCH 02/26] Not assertion for default processor --- Sources/Networking/ImageDownloader+LivePhoto.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/Networking/ImageDownloader+LivePhoto.swift b/Sources/Networking/ImageDownloader+LivePhoto.swift index f6b03cefa..565374fc4 100644 --- a/Sources/Networking/ImageDownloader+LivePhoto.swift +++ b/Sources/Networking/ImageDownloader+LivePhoto.swift @@ -84,7 +84,10 @@ extension ImageDownloader { completionHandler: (@Sendable (Result) -> Void)? = nil ) -> DownloadTask { var checkedOptions = options - if options.processor != LivePhotoImageProcessor.default { + 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 } From a17c444032e64fad5f72beb3708ab1aad5822b77 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 1 Oct 2024 22:37:35 +0900 Subject: [PATCH 03/26] Test for live photo downloading --- Tests/KingfisherTests/ImageDownloaderTests.swift | 8 ++++++++ 1 file changed, 8 insertions(+) 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 { From 297e0f732a5d04e340316c95bef922e29d40b655 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 2 Oct 2024 22:28:08 +0900 Subject: [PATCH 04/26] Add Kingfisher manager support for live photo --- Kingfisher.xcodeproj/project.pbxproj | 8 + Sources/Cache/ImageCache.swift | 8 + .../General/ImageSource/LivePhotoSource.swift | 40 +++++ .../General/KingfisherManager+LivePhoto.swift | 165 ++++++++++++++++++ Sources/General/KingfisherManager.swift | 16 +- 5 files changed, 229 insertions(+), 8 deletions(-) create mode 100644 Sources/General/ImageSource/LivePhotoSource.swift create mode 100644 Sources/General/KingfisherManager+LivePhoto.swift diff --git a/Kingfisher.xcodeproj/project.pbxproj b/Kingfisher.xcodeproj/project.pbxproj index 3cfed2358..06202bef1 100644 --- a/Kingfisher.xcodeproj/project.pbxproj +++ b/Kingfisher.xcodeproj/project.pbxproj @@ -67,6 +67,8 @@ 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 */; }; 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 */; }; @@ -215,6 +217,8 @@ 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 = ""; }; 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 = ""; }; @@ -415,6 +419,7 @@ D1132C9625919F69003E528D /* KFOptionsSetter.swift */, D12AB6B2215D2BB50013BA68 /* KingfisherError.swift */, D12AB6B3215D2BB50013BA68 /* KingfisherManager.swift */, + D12F67612CAC32B800AB63AB /* KingfisherManager+LivePhoto.swift */, D12AB6B4215D2BB50013BA68 /* KingfisherOptionsInfo.swift */, ); path = General; @@ -656,6 +661,7 @@ isa = PBXGroup; children = ( D1A1CC99219FAB4B00263AD8 /* Source.swift */, + D12F67632CAC330600AB63AB /* LivePhotoSource.swift */, D12AB69E215D2BB50013BA68 /* Resource.swift */, D1E56444219B16330057AAE3 /* ImageDataProvider.swift */, D16CC3D524E02E9500F1A515 /* AVAssetImageDataProvider.swift */, @@ -829,11 +835,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 */, diff --git a/Sources/Cache/ImageCache.swift b/Sources/Cache/ImageCache.swift index cb636903d..918c63979 100644 --- a/Sources/Cache/ImageCache.swift +++ b/Sources/Cache/ImageCache.swift @@ -954,6 +954,14 @@ open class ImageCache: @unchecked Sendable { return diskStorage.cacheFileURL(forKey: computedKey).path } + open func cacheFileURLIfOnDisk( + forKey key: String, + processorIdentifier identifier: String = DefaultImageProcessor.default.identifier) -> URL? + { + let computedKey = key.computedKey(with: identifier) + return diskStorage.isCached(forKey: computedKey) ? diskStorage.cacheFileURL(forKey: computedKey) : nil + } + // MARK: - Concurrency /// Stores an image to the cache. diff --git a/Sources/General/ImageSource/LivePhotoSource.swift b/Sources/General/ImageSource/LivePhotoSource.swift new file mode 100644 index 000000000..ae3109137 --- /dev/null +++ b/Sources/General/ImageSource/LivePhotoSource.swift @@ -0,0 +1,40 @@ +// +// 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 + +public struct LivePhotoSource: Sendable { + + public let resources: [any Resource] + + public init(resources: [any Resource]) { + self.resources = resources + } + + public init(urls: [URL]) { + self.resources = urls.map { KF.ImageResource(downloadURL: $0) } + } +} diff --git a/Sources/General/KingfisherManager+LivePhoto.swift b/Sources/General/KingfisherManager+LivePhoto.swift new file mode 100644 index 000000000..b430e7b3c --- /dev/null +++ b/Sources/General/KingfisherManager+LivePhoto.swift @@ -0,0 +1,165 @@ +// +// 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. + +@preconcurrency import Photos + +public struct RetrieveLivePhotoResult: 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 { + public func retrieveLivePhoto( + with source: LivePhotoSource, + options: KingfisherOptionsInfo? = nil, + progressBlock: DownloadProgressBlock? = nil, + referenceTaskIdentifierChecker: (() -> Bool)? = nil + ) async throws -> RetrieveLivePhotoResult { + 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 { + 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 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 + let fileURLs = source.resources.map { + targetCache.cacheFileURLIfOnDisk( + forKey: $0.cacheKey, + processorIdentifier: checkedOptions.processor.identifier + ) + } + if fileURLs.contains(nil) { + // not all file done. throw error + } + return RetrieveLivePhotoResult( + fileURLs: fileURLs.compactMap { $0 }, + cacheType: missingResources.isEmpty ? .disk : .none, + source: source, + originalSource: source, + data: { + resourcesResult.map { $0.originalData } + }) + } + + func missingResources(_ source: LivePhotoSource, options: KingfisherParsedOptionsInfo) -> [any Resource] { + let missingResources: [any Resource] + if options.forceRefresh { + missingResources = source.resources + } else { + let targetCache = options.targetCache ?? cache + missingResources = source.resources.reduce([], { r, resource in + let cacheKey = resource.cacheKey + let existingCachedFileURL = targetCache.cacheFileURLIfOnDisk( + forKey: cacheKey, + processorIdentifier: options.processor.identifier + ) + if existingCachedFileURL == nil { + return r + [resource] + } else { + return r + } + }) + } + return missingResources + } + + private func downloadAndCache( + resources: [any Resource], + options: KingfisherParsedOptionsInfo + ) async throws -> [LivePhotoResourceLoadingResult] { + if resources.isEmpty { + return [] + } + let downloader = options.downloader ?? downloader + let cache = options.targetCache ?? cache + return try await withThrowingTaskGroup(of: LivePhotoResourceLoadingResult.self) { group in + for resource in resources { + group.addTask { + let downloadedResource = try await downloader.downloadLivePhotoResource( + with: resource.downloadURL, + options: options + ) + try await cache.storeToDisk( + downloadedResource.originalData, + forKey: resource.cacheKey, + processorIdentifier: options.processor.identifier, + expiration: options.diskCacheExpiration + ) + return downloadedResource + } + } + + var result: [LivePhotoResourceLoadingResult] = [] + for try await resource in group { + result.append(resource) + } + return result + } + } +} 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 } From de8fd842082aad041571e93afcec669cc2817c70 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 2 Oct 2024 23:21:38 +0900 Subject: [PATCH 05/26] Unit test for live photo methods in manager --- .../General/KingfisherManager+LivePhoto.swift | 2 +- .../KingfisherManagerTests.swift | 52 ++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/Sources/General/KingfisherManager+LivePhoto.swift b/Sources/General/KingfisherManager+LivePhoto.swift index b430e7b3c..9c09c2c62 100644 --- a/Sources/General/KingfisherManager+LivePhoto.swift +++ b/Sources/General/KingfisherManager+LivePhoto.swift @@ -129,7 +129,7 @@ extension KingfisherManager { return missingResources } - private func downloadAndCache( + func downloadAndCache( resources: [any Resource], options: KingfisherParsedOptionsInfo ) async throws -> [LivePhotoResourceLoadingResult] { diff --git a/Tests/KingfisherTests/KingfisherManagerTests.swift b/Tests/KingfisherTests/KingfisherManagerTests.swift index 495768d34..ca556b59a 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,56 @@ class KingfisherManagerTests: XCTestCase { } waitForExpectations(timeout: 3, handler: nil) } + + func testMissingResourceOfLivePhotoFound() { + let resource = KF.ImageResource(downloadURL: testURLs[0]) + 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: testURLs[0]) + + try await manager.cache.storeToDisk(testImageData, forKey: resource.cacheKey) + + 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: testURLs[0]) + let resource2 = KF.ImageResource(downloadURL: testURLs[1]) + + try await manager.cache.storeToDisk(testImageData, forKey: resource1.cacheKey) + + 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 testDownloadAndCacheLivePhotoResourcesAll() async throws { + let resource1 = KF.ImageResource(downloadURL: testURLs[0]) + let resource2 = KF.ImageResource(downloadURL: testURLs[1]) + + stub(resource1.downloadURL, data: testImageData) + stub(resource2.downloadURL, data: testImageData) + + let result = try await manager.downloadAndCache(resources: [resource1, resource2], options: .init(.empty)) + XCTAssertEqual(result.count, 2) + + let urls = result.compactMap(\.url) + XCTAssertTrue(urls.contains(testURLs[0])) + XCTAssertTrue(urls.contains(testURLs[1])) + + let resourceCached1 = manager.cache.imageCachedType(forKey: resource1.cacheKey) + let resourceCached2 = manager.cache.imageCachedType(forKey: resource1.cacheKey) + XCTAssertEqual(resourceCached1, .disk) + XCTAssertEqual(resourceCached2, .disk) + } } private var imageCreatingOptionsKey: Void? From 388fb8411aa2a5fa800fdc3d8f63256423e48bc2 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 4 Oct 2024 22:16:10 +0900 Subject: [PATCH 06/26] Add tests for retrieving live photo --- .../KingfisherManagerTests.swift | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/Tests/KingfisherTests/KingfisherManagerTests.swift b/Tests/KingfisherTests/KingfisherManagerTests.swift index ca556b59a..1421d8c5e 100644 --- a/Tests/KingfisherTests/KingfisherManagerTests.swift +++ b/Tests/KingfisherTests/KingfisherManagerTests.swift @@ -1401,6 +1401,146 @@ class KingfisherManagerTests: XCTestCase { XCTAssertEqual(resourceCached1, .disk) XCTAssertEqual(resourceCached2, .disk) } + + func testRetrieveLivePhotoFromNetwork() async throws { + let resource1 = KF.ImageResource(downloadURL: testURLs[0]) + let resource2 = KF.ImageResource(downloadURL: testURLs[1]) + + 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(testURLs[0])) + XCTAssertTrue(urlsInSource.contains(testURLs[1])) + } + + func testRetrieveLivePhotoFromLocal() async throws { + let resource1 = KF.ImageResource(downloadURL: testURLs[0]) + let resource2 = KF.ImageResource(downloadURL: testURLs[1]) + + try await manager.cache.storeToDisk( + testImageData, + forKey: resource1.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier + ) + try await manager.cache.storeToDisk( + testImageData, + forKey: resource2.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier + ) + + let resource1Cached = manager.cache.isCached( + forKey: resource1.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier + ) + let resource2Cached = manager.cache.isCached( + forKey: resource2.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier + ) + 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(testURLs[0])) + XCTAssertTrue(urlsInSource.contains(testURLs[1])) + } + + func testRetrieveLivePhotoMixed() async throws { + let resource1 = KF.ImageResource(downloadURL: testURLs[0]) + let resource2 = KF.ImageResource(downloadURL: testURLs[1]) + + try await manager.cache.storeToDisk( + testImageData, + forKey: resource1.cacheKey, + processorIdentifier: LivePhotoImageProcessor.default.identifier + ) + 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 + ) + 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(testURLs[0])) + XCTAssertTrue(urlsInSource.contains(testURLs[1])) + } + + func testRetrieveLivePhotoNetworkThenCache() async throws { + let resource1 = KF.ImageResource(downloadURL: testURLs[0]) + let resource2 = KF.ImageResource(downloadURL: testURLs[1]) + + 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(testURLs[0])) + XCTAssertTrue(urlsInSource.contains(testURLs[1])) + + let localResult = try await manager.retrieveLivePhoto(with: source) + XCTAssertEqual(localResult.fileURLs.count, 2) + XCTAssertEqual(localResult.cacheType, .disk) + } } private var imageCreatingOptionsKey: Void? From 8be1491df7dc6f300f91b9e586c3efa17e167943 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 5 Oct 2024 14:43:39 +0900 Subject: [PATCH 07/26] Add extension methods and error cases for live photo --- Kingfisher.xcodeproj/project.pbxproj | 4 + .../PHLivePhotoView+Kingfisher.swift | 195 ++++++++++++++++++ Sources/General/KingfisherError.swift | 64 ++++++ .../General/KingfisherManager+LivePhoto.swift | 12 +- .../ImageDownloader+LivePhoto.swift | 8 +- .../ImageViewExtensionTests.swift | 1 + 6 files changed, 274 insertions(+), 10 deletions(-) create mode 100644 Sources/Extensions/PHLivePhotoView+Kingfisher.swift diff --git a/Kingfisher.xcodeproj/project.pbxproj b/Kingfisher.xcodeproj/project.pbxproj index 06202bef1..16b3a3366 100644 --- a/Kingfisher.xcodeproj/project.pbxproj +++ b/Kingfisher.xcodeproj/project.pbxproj @@ -69,6 +69,7 @@ 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 */; }; @@ -219,6 +220,7 @@ 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 = ""; }; @@ -400,6 +402,7 @@ D12AB6AB215D2BB50013BA68 /* Extensions */ = { isa = PBXGroup; children = ( + D12F67652CB022EB00AB63AB /* PHLivePhotoView+Kingfisher.swift */, D12EB83B24DD8EFC00329EE1 /* NSTextAttachment+Kingfisher.swift */, D12AB6AC215D2BB50013BA68 /* ImageView+Kingfisher.swift */, D12AB6AD215D2BB50013BA68 /* NSButton+Kingfisher.swift */, @@ -886,6 +889,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; diff --git a/Sources/Extensions/PHLivePhotoView+Kingfisher.swift b/Sources/Extensions/PHLivePhotoView+Kingfisher.swift new file mode 100644 index 000000000..5e532946a --- /dev/null +++ b/Sources/Extensions/PHLivePhotoView+Kingfisher.swift @@ -0,0 +1,195 @@ +// +// 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. + +@preconcurrency import PhotosUI + +public struct RetrieveLivePhotoResult: @unchecked Sendable { + public let loadingInfo: LivePhotoLoadingInfoResult + 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 + 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 { + + 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) + } + } + + public var targetSize: CGSize { + get { getAssociatedObject(base, &targetSizeKey) ?? .zero } + set { setRetainedAssociatedObject(base, &targetSizeKey, newValue) } + } + + public var contentMode: PHImageContentMode { + get { getAssociatedObject(base, &contentModeKey) ?? .default } + set { setRetainedAssociatedObject(base, &contentModeKey, newValue) } + } + + @discardableResult + public func setImage( + with source: LivePhotoSource?, + placeholder: KFCrossPlatformImage? = nil, + options: KingfisherOptionsInfo? = nil, + progressBlock: DownloadProgressBlock? = nil, + completionHandler: (@MainActor @Sendable (Result) -> Void)? = nil + ) -> Task<(), Never>? { + var mutatingSelf = self + guard let source = source else { + PHLivePhoto.request( + withResourceFileURLs: [], + placeholderImage: placeholder, + targetSize: .zero, + contentMode: .default + ) { photo, _ in + base.livePhoto = photo + } + mutatingSelf.taskIdentifier = nil + completionHandler?(.failure(KingfisherError.imageSettingError(reason: .emptySource))) + return nil + } + + let issuedIdentifier = Source.Identifier.next() + mutatingSelf.taskIdentifier = issuedIdentifier + + let taskIdentifierChecking = { issuedIdentifier == self.taskIdentifier } + + let task = Task { @MainActor in + do { + let loadingInfo = try await KingfisherManager.shared.retrieveLivePhoto( + with: source, + options: options, + progressBlock: progressBlock, + 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: placeholder, + 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 + } + + if info.keys.contains(PHLivePhotoInfoCancelledKey) { + let cancelled = (info[PHLivePhotoInfoCancelledKey] as? NSNumber)?.boolValue ?? false + if cancelled { + completionHandler?(.failure( + .requestError(reason: .livePhotoTaskCancelled(source: source))) + ) + 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)) + } +} diff --git a/Sources/General/KingfisherError.swift b/Sources/General/KingfisherError.swift index 1649747ed..297d19da4 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. @@ -296,6 +304,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 +491,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 +501,7 @@ extension KingfisherError.RequestErrorReason { case .emptyRequest: return 1001 case .invalidURL: return 1002 case .taskCancelled: return 1003 + case .livePhotoTaskCancelled: return 1004 } } } @@ -575,6 +624,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 +646,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 index 9c09c2c62..96eb8b688 100644 --- a/Sources/General/KingfisherManager+LivePhoto.swift +++ b/Sources/General/KingfisherManager+LivePhoto.swift @@ -26,7 +26,7 @@ @preconcurrency import Photos -public struct RetrieveLivePhotoResult: Sendable { +public struct LivePhotoLoadingInfoResult: Sendable { /// Retrieves the live photo disk URLs from this result. public let fileURLs: [URL] @@ -64,7 +64,7 @@ extension KingfisherManager { options: KingfisherOptionsInfo? = nil, progressBlock: DownloadProgressBlock? = nil, referenceTaskIdentifierChecker: (() -> Bool)? = nil - ) async throws -> RetrieveLivePhotoResult { + ) async throws -> LivePhotoLoadingInfoResult { let fullOptions = currentDefaultOptions + (options ?? .empty) var checkedOptions = KingfisherParsedOptionsInfo(fullOptions) @@ -97,7 +97,7 @@ extension KingfisherManager { if fileURLs.contains(nil) { // not all file done. throw error } - return RetrieveLivePhotoResult( + return LivePhotoLoadingInfoResult( fileURLs: fileURLs.compactMap { $0 }, cacheType: missingResources.isEmpty ? .disk : .none, source: source, @@ -132,13 +132,13 @@ extension KingfisherManager { func downloadAndCache( resources: [any Resource], options: KingfisherParsedOptionsInfo - ) async throws -> [LivePhotoResourceLoadingResult] { + ) async throws -> [LivePhotoResourceDownloadingResult] { if resources.isEmpty { return [] } let downloader = options.downloader ?? downloader let cache = options.targetCache ?? cache - return try await withThrowingTaskGroup(of: LivePhotoResourceLoadingResult.self) { group in + return try await withThrowingTaskGroup(of: LivePhotoResourceDownloadingResult.self) { group in for resource in resources { group.addTask { let downloadedResource = try await downloader.downloadLivePhotoResource( @@ -155,7 +155,7 @@ extension KingfisherManager { } } - var result: [LivePhotoResourceLoadingResult] = [] + var result: [LivePhotoResourceDownloadingResult] = [] for try await resource in group { result.append(resource) } diff --git a/Sources/Networking/ImageDownloader+LivePhoto.swift b/Sources/Networking/ImageDownloader+LivePhoto.swift index 565374fc4..9d512348a 100644 --- a/Sources/Networking/ImageDownloader+LivePhoto.swift +++ b/Sources/Networking/ImageDownloader+LivePhoto.swift @@ -30,7 +30,7 @@ import AppKit import UIKit #endif -public struct LivePhotoResourceLoadingResult: Sendable { +public struct LivePhotoResourceDownloadingResult: Sendable { /// The original URL of the image request. public let url: URL? @@ -55,7 +55,7 @@ extension ImageDownloader { public func downloadLivePhotoResource( with url: URL, options: KingfisherParsedOptionsInfo - ) async throws -> LivePhotoResourceLoadingResult { + ) async throws -> LivePhotoResourceDownloadingResult { let task = CancellationDownloadTask() return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in @@ -81,7 +81,7 @@ extension ImageDownloader { public func downloadLivePhotoResource( with url: URL, options: KingfisherParsedOptionsInfo, - completionHandler: (@Sendable (Result) -> Void)? = nil + completionHandler: (@Sendable (Result) -> Void)? = nil ) -> DownloadTask { var checkedOptions = options if options.processor == DefaultImageProcessor.default { @@ -95,7 +95,7 @@ extension ImageDownloader { guard let completionHandler else { return } - let newResult = result.map { LivePhotoResourceLoadingResult(originalData: $0.originalData, url: $0.url) } + let newResult = result.map { LivePhotoResourceDownloadingResult(originalData: $0.originalData, url: $0.url) } completionHandler(newResult) } } 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() From 51a51e39ff56b9988d8c7e001222c17e1e643f07 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 5 Oct 2024 19:44:06 +0900 Subject: [PATCH 08/26] File extension guessing --- .../General/ImageSource/LivePhotoSource.swift | 49 +++++++++++++++-- Sources/General/Kingfisher.swift | 6 +++ .../General/KingfisherManager+LivePhoto.swift | 6 +-- .../KingfisherManagerTests.swift | 53 ++++++++++--------- .../KingfisherTestHelper.swift | 5 ++ 5 files changed, 88 insertions(+), 31 deletions(-) diff --git a/Sources/General/ImageSource/LivePhotoSource.swift b/Sources/General/ImageSource/LivePhotoSource.swift index ae3109137..212e3e3fb 100644 --- a/Sources/General/ImageSource/LivePhotoSource.swift +++ b/Sources/General/ImageSource/LivePhotoSource.swift @@ -26,15 +26,58 @@ import Foundation +public struct LivePhotoResource: Sendable { + + public enum FileType: Sendable { + case heic + case mov + } + + public let resource: any Resource + public let referenceFileType: FileType + + var cacheKey: String { resource.cacheKey } + var downloadURL: URL { resource.downloadURL } + + public init(downloadURL: URL, cacheKey: String? = nil, fileType: FileType? = nil) { + resource = KF.ImageResource(downloadURL: downloadURL, cacheKey: cacheKey) + referenceFileType = fileType ?? resource.guessedFileType + } + + public init(resource: any Resource, fileType: FileType? = nil) { + self.resource = resource + referenceFileType = fileType ?? resource.guessedFileType + } +} + +extension Resource { + var guessedFileType: LivePhotoResource.FileType { + let pathExtension = downloadURL.pathExtension.lowercased() + switch pathExtension { + case "mov": return .mov + case "heic": return .heic + default: + assertionFailure("Explicit file type is necessary in the download URL as its extension. Otherwise, set the file type of the LivePhoto resource manually with `LivePhotoSource.init(resources:)`.") + return .heic + } + } +} + public struct LivePhotoSource: Sendable { - public let resources: [any Resource] + public let resources: [LivePhotoResource] public init(resources: [any Resource]) { - self.resources = resources + let livePhotoResources = resources.map { LivePhotoResource(resource: $0) } + self.init(livePhotoResources) } public init(urls: [URL]) { - self.resources = urls.map { KF.ImageResource(downloadURL: $0) } + let resources = urls.map { KF.ImageResource(downloadURL: $0) } + self.init(resources: resources) + } + + public init(_ resources: [LivePhotoResource]) { + self.resources = resources } } diff --git a/Sources/General/Kingfisher.swift b/Sources/General/Kingfisher.swift index f68d60735..53a91ddbb 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) +import PhotosUI +extension PHLivePhotoView : KingfisherCompatible { } +#endif + + #if os(tvOS) && canImport(TVUIKit) @available(tvOS 12.0, *) extension TVMonogramView : KingfisherCompatible { } diff --git a/Sources/General/KingfisherManager+LivePhoto.swift b/Sources/General/KingfisherManager+LivePhoto.swift index 96eb8b688..2b2baedb9 100644 --- a/Sources/General/KingfisherManager+LivePhoto.swift +++ b/Sources/General/KingfisherManager+LivePhoto.swift @@ -107,8 +107,8 @@ extension KingfisherManager { }) } - func missingResources(_ source: LivePhotoSource, options: KingfisherParsedOptionsInfo) -> [any Resource] { - let missingResources: [any Resource] + func missingResources(_ source: LivePhotoSource, options: KingfisherParsedOptionsInfo) -> [LivePhotoResource] { + let missingResources: [LivePhotoResource] if options.forceRefresh { missingResources = source.resources } else { @@ -130,7 +130,7 @@ extension KingfisherManager { } func downloadAndCache( - resources: [any Resource], + resources: [LivePhotoResource], options: KingfisherParsedOptionsInfo ) async throws -> [LivePhotoResourceDownloadingResult] { if resources.isEmpty { diff --git a/Tests/KingfisherTests/KingfisherManagerTests.swift b/Tests/KingfisherTests/KingfisherManagerTests.swift index 1421d8c5e..4f53c100a 100644 --- a/Tests/KingfisherTests/KingfisherManagerTests.swift +++ b/Tests/KingfisherTests/KingfisherManagerTests.swift @@ -1353,7 +1353,7 @@ class KingfisherManagerTests: XCTestCase { } func testMissingResourceOfLivePhotoFound() { - let resource = KF.ImageResource(downloadURL: testURLs[0]) + let resource = KF.ImageResource(downloadURL: LivePhotoURL.mov) let source = LivePhotoSource(resources: [resource]) let missing = manager.missingResources(source, options: .init(.empty)) @@ -1361,7 +1361,7 @@ class KingfisherManagerTests: XCTestCase { } func testMissingResourceOfLivePhotoNotFound() async throws { - let resource = KF.ImageResource(downloadURL: testURLs[0]) + let resource = KF.ImageResource(downloadURL: LivePhotoURL.mov) try await manager.cache.storeToDisk(testImageData, forKey: resource.cacheKey) @@ -1371,8 +1371,8 @@ class KingfisherManagerTests: XCTestCase { } func testMissingResourceOfLivePhotoFoundOne() async throws { - let resource1 = KF.ImageResource(downloadURL: testURLs[0]) - let resource2 = KF.ImageResource(downloadURL: testURLs[1]) + let resource1 = KF.ImageResource(downloadURL: LivePhotoURL.heic) + let resource2 = KF.ImageResource(downloadURL: LivePhotoURL.mov) try await manager.cache.storeToDisk(testImageData, forKey: resource1.cacheKey) @@ -1383,18 +1383,21 @@ class KingfisherManagerTests: XCTestCase { } func testDownloadAndCacheLivePhotoResourcesAll() async throws { - let resource1 = KF.ImageResource(downloadURL: testURLs[0]) - let resource2 = KF.ImageResource(downloadURL: testURLs[1]) + 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], options: .init(.empty)) + 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(testURLs[0])) - XCTAssertTrue(urls.contains(testURLs[1])) + XCTAssertTrue(urls.contains(LivePhotoURL.mov)) + XCTAssertTrue(urls.contains(LivePhotoURL.heic)) let resourceCached1 = manager.cache.imageCachedType(forKey: resource1.cacheKey) let resourceCached2 = manager.cache.imageCachedType(forKey: resource1.cacheKey) @@ -1403,8 +1406,8 @@ class KingfisherManagerTests: XCTestCase { } func testRetrieveLivePhotoFromNetwork() async throws { - let resource1 = KF.ImageResource(downloadURL: testURLs[0]) - let resource2 = KF.ImageResource(downloadURL: testURLs[1]) + let resource1 = KF.ImageResource(downloadURL: LivePhotoURL.mov) + let resource2 = KF.ImageResource(downloadURL: LivePhotoURL.heic) stub(resource1.downloadURL, data: testImageData) stub(resource2.downloadURL, data: testImageData) @@ -1429,13 +1432,13 @@ class KingfisherManagerTests: XCTestCase { XCTAssertEqual(result.cacheType, .none) XCTAssertEqual(result.data(), [testImageData, testImageData]) let urlsInSource = result.source.resources.map(\.downloadURL) - XCTAssertTrue(urlsInSource.contains(testURLs[0])) - XCTAssertTrue(urlsInSource.contains(testURLs[1])) + XCTAssertTrue(urlsInSource.contains(LivePhotoURL.mov)) + XCTAssertTrue(urlsInSource.contains(LivePhotoURL.heic)) } func testRetrieveLivePhotoFromLocal() async throws { - let resource1 = KF.ImageResource(downloadURL: testURLs[0]) - let resource2 = KF.ImageResource(downloadURL: testURLs[1]) + let resource1 = KF.ImageResource(downloadURL: LivePhotoURL.mov) + let resource2 = KF.ImageResource(downloadURL: LivePhotoURL.heic) try await manager.cache.storeToDisk( testImageData, @@ -1468,13 +1471,13 @@ class KingfisherManagerTests: XCTestCase { XCTAssertEqual(result.cacheType, .disk) XCTAssertEqual(result.data(), []) let urlsInSource = result.source.resources.map(\.downloadURL) - XCTAssertTrue(urlsInSource.contains(testURLs[0])) - XCTAssertTrue(urlsInSource.contains(testURLs[1])) + XCTAssertTrue(urlsInSource.contains(LivePhotoURL.mov)) + XCTAssertTrue(urlsInSource.contains(LivePhotoURL.heic)) } func testRetrieveLivePhotoMixed() async throws { - let resource1 = KF.ImageResource(downloadURL: testURLs[0]) - let resource2 = KF.ImageResource(downloadURL: testURLs[1]) + let resource1 = KF.ImageResource(downloadURL: LivePhotoURL.mov) + let resource2 = KF.ImageResource(downloadURL: LivePhotoURL.heic) try await manager.cache.storeToDisk( testImageData, @@ -1503,13 +1506,13 @@ class KingfisherManagerTests: XCTestCase { XCTAssertEqual(result.cacheType, .none) XCTAssertEqual(result.data(), [testImageData]) let urlsInSource = result.source.resources.map(\.downloadURL) - XCTAssertTrue(urlsInSource.contains(testURLs[0])) - XCTAssertTrue(urlsInSource.contains(testURLs[1])) + XCTAssertTrue(urlsInSource.contains(LivePhotoURL.mov)) + XCTAssertTrue(urlsInSource.contains(LivePhotoURL.heic)) } func testRetrieveLivePhotoNetworkThenCache() async throws { - let resource1 = KF.ImageResource(downloadURL: testURLs[0]) - let resource2 = KF.ImageResource(downloadURL: testURLs[1]) + let resource1 = KF.ImageResource(downloadURL: LivePhotoURL.mov) + let resource2 = KF.ImageResource(downloadURL: LivePhotoURL.heic) stub(resource1.downloadURL, data: testImageData) stub(resource2.downloadURL, data: testImageData) @@ -1534,8 +1537,8 @@ class KingfisherManagerTests: XCTestCase { XCTAssertEqual(result.cacheType, .none) XCTAssertEqual(result.data(), [testImageData, testImageData]) let urlsInSource = result.source.resources.map(\.downloadURL) - XCTAssertTrue(urlsInSource.contains(testURLs[0])) - XCTAssertTrue(urlsInSource.contains(testURLs[1])) + XCTAssertTrue(urlsInSource.contains(LivePhotoURL.mov)) + XCTAssertTrue(urlsInSource.contains(LivePhotoURL.heic)) let localResult = try await manager.retrieveLivePhoto(with: source) XCTAssertEqual(localResult.fileURLs.count, 2) diff --git a/Tests/KingfisherTests/KingfisherTestHelper.swift b/Tests/KingfisherTests/KingfisherTestHelper.swift index 42f468a92..36018df11 100644 --- a/Tests/KingfisherTests/KingfisherTestHelper.swift +++ b/Tests/KingfisherTests/KingfisherTestHelper.swift @@ -84,6 +84,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() { From 880fbff88aad718e2f292cd59f7afcfd597c2713 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 5 Oct 2024 20:31:38 +0900 Subject: [PATCH 09/26] Add support for force cache file extension --- Sources/Cache/DiskStorage.swift | 54 +++++++---- Sources/Cache/ImageCache.swift | 92 ++++++++++++++----- .../General/ImageSource/LivePhotoSource.swift | 11 +-- Sources/General/KingfisherOptionsInfo.swift | 4 + Sources/Networking/ImagePrefetcher.swift | 3 +- 5 files changed, 115 insertions(+), 49 deletions(-) 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 918c63979..04537f000 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, @@ -410,6 +411,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 +428,24 @@ 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 + ) } 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 +454,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 +476,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 @@ -501,6 +520,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 +530,7 @@ open class ImageCache: @unchecked Sendable { removeImage( forKey: key, processorIdentifier: identifier, + forcedExtension: forcedExtension, fromMemory: fromMemory, fromDisk: fromDisk, callbackQueue: callbackQueue, @@ -517,12 +538,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 +562,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 +710,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 { @@ -865,11 +892,13 @@ open class ImageCache: @unchecked Sendable { /// 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 } @@ -886,9 +915,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. @@ -904,10 +935,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. @@ -948,18 +981,25 @@ 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 } open func cacheFileURLIfOnDisk( forKey key: String, - processorIdentifier identifier: String = DefaultImageProcessor.default.identifier) -> URL? + processorIdentifier identifier: String = DefaultImageProcessor.default.identifier, + forcedExtension: String? = nil + ) -> URL? { let computedKey = key.computedKey(with: identifier) - return diskStorage.isCached(forKey: computedKey) ? diskStorage.cacheFileURL(forKey: computedKey) : nil + return diskStorage.isCached( + forKey: computedKey, + forcedExtension: forcedExtension + ) ? diskStorage.cacheFileURL(forKey: computedKey, forcedExtension: forcedExtension) : nil } // MARK: - Concurrency @@ -1012,6 +1052,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 { @@ -1021,6 +1062,7 @@ open class ImageCache: @unchecked Sendable { original: original, forKey: key, processorIdentifier: identifier, + forcedExtension: forcedExtension, cacheSerializer: serializer, toDisk: toDisk) { // Only `diskCacheResult` can fail @@ -1033,6 +1075,7 @@ open class ImageCache: @unchecked Sendable { _ data: Data, forKey key: String, processorIdentifier identifier: String = "", + forcedExtension: String? = nil, expiration: StorageExpiration? = nil ) async throws { @@ -1041,6 +1084,7 @@ open class ImageCache: @unchecked Sendable { data, forKey: key, processorIdentifier: identifier, + forcedExtension: forcedExtension, expiration: expiration) { // Only `diskCacheResult` can fail continuation.resume(with: $0.diskCacheResult) @@ -1061,6 +1105,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 { @@ -1068,6 +1113,7 @@ open class ImageCache: @unchecked Sendable { removeImage( forKey: key, processorIdentifier: identifier, + forcedExtension: forcedExtension, fromMemory: fromMemory, fromDisk: fromDisk, completionHandler: { error in diff --git a/Sources/General/ImageSource/LivePhotoSource.swift b/Sources/General/ImageSource/LivePhotoSource.swift index 212e3e3fb..90d12be42 100644 --- a/Sources/General/ImageSource/LivePhotoSource.swift +++ b/Sources/General/ImageSource/LivePhotoSource.swift @@ -31,6 +31,7 @@ public struct LivePhotoResource: Sendable { public enum FileType: Sendable { case heic case mov + case other(String) } public let resource: any Resource @@ -53,12 +54,10 @@ public struct LivePhotoResource: Sendable { extension Resource { var guessedFileType: LivePhotoResource.FileType { let pathExtension = downloadURL.pathExtension.lowercased() - switch pathExtension { - case "mov": return .mov - case "heic": return .heic - default: - assertionFailure("Explicit file type is necessary in the download URL as its extension. Otherwise, set the file type of the LivePhoto resource manually with `LivePhotoSource.init(resources:)`.") - return .heic + return switch pathExtension { + case "mov": .mov + case "heic": .heic + default: .other(pathExtension) } } } 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/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) From c13a80507ce809b6cc0892de841645e41169ac49 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 5 Oct 2024 20:41:19 +0900 Subject: [PATCH 10/26] Fix a flaky test due to task env --- Tests/KingfisherTests/ImageCacheTests.swift | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/Tests/KingfisherTests/ImageCacheTests.swift b/Tests/KingfisherTests/ImageCacheTests.swift index 5ada85acb..da13330fb 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 { From c7c1e3d2e7c88bcd1e9ef68a4dd7f4ded760105f Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 5 Oct 2024 21:59:37 +0900 Subject: [PATCH 11/26] Add file extension guessing from ref and data --- .../General/ImageSource/LivePhotoSource.swift | 39 +++++++++++++++++++ .../General/KingfisherManager+LivePhoto.swift | 4 ++ 2 files changed, 43 insertions(+) diff --git a/Sources/General/ImageSource/LivePhotoSource.swift b/Sources/General/ImageSource/LivePhotoSource.swift index 90d12be42..4348b0eef 100644 --- a/Sources/General/ImageSource/LivePhotoSource.swift +++ b/Sources/General/ImageSource/LivePhotoSource.swift @@ -51,6 +51,45 @@ public struct LivePhotoResource: Sendable { } } +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] + static let heicChunk: [UInt8] = [0x68, 0x65, 0x69, 0x63] + static let qtChunk: [UInt8] = [0x71, 0x74, 0x20, 0x20] // quicktime + + 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() diff --git a/Sources/General/KingfisherManager+LivePhoto.swift b/Sources/General/KingfisherManager+LivePhoto.swift index 2b2baedb9..d599a31d0 100644 --- a/Sources/General/KingfisherManager+LivePhoto.swift +++ b/Sources/General/KingfisherManager+LivePhoto.swift @@ -145,10 +145,13 @@ extension KingfisherManager { with: resource.downloadURL, options: options ) + 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 @@ -163,3 +166,4 @@ extension KingfisherManager { } } } + From 790b9b6e753f69a3d38a3ac070b28ef193afd13f Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 5 Oct 2024 22:10:10 +0900 Subject: [PATCH 12/26] Make it work with brutal extension --- .../Base.lproj/Main.storyboard | 46 ++++++++++++++- .../LivePhotoViewController.swift | 59 +++++++++++++++++++ .../Kingfisher-Demo.xcodeproj/project.pbxproj | 4 ++ .../General/KingfisherManager+LivePhoto.swift | 6 +- 4 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 Demo/Demo/Kingfisher-Demo/ViewControllers/LivePhotoViewController.swift 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..2d1d553e0 --- /dev/null +++ b/Demo/Demo/Kingfisher-Demo/ViewControllers/LivePhotoViewController.swift @@ -0,0 +1,59 @@ +// +// 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 source = LivePhotoSource(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: source, completionHandler: { result in + print(result) + }) + } +} 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/Sources/General/KingfisherManager+LivePhoto.swift b/Sources/General/KingfisherManager+LivePhoto.swift index d599a31d0..7c3c6d031 100644 --- a/Sources/General/KingfisherManager+LivePhoto.swift +++ b/Sources/General/KingfisherManager+LivePhoto.swift @@ -91,7 +91,8 @@ extension KingfisherManager { let fileURLs = source.resources.map { targetCache.cacheFileURLIfOnDisk( forKey: $0.cacheKey, - processorIdentifier: checkedOptions.processor.identifier + processorIdentifier: checkedOptions.processor.identifier, + forcedExtension: $0.referenceFileType.determinedFileExtension(.init()) ) } if fileURLs.contains(nil) { @@ -117,7 +118,8 @@ extension KingfisherManager { let cacheKey = resource.cacheKey let existingCachedFileURL = targetCache.cacheFileURLIfOnDisk( forKey: cacheKey, - processorIdentifier: options.processor.identifier + processorIdentifier: options.processor.identifier, + forcedExtension: resource.referenceFileType.determinedFileExtension(.init()) ) if existingCachedFileURL == nil { return r + [resource] From bf2039e15a0d1490c1b2edd9867b9b2ade5dbb43 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 5 Oct 2024 22:21:48 +0900 Subject: [PATCH 13/26] Try to guess file extension when getting from cache --- .../General/ImageSource/LivePhotoSource.swift | 9 +++++ .../General/KingfisherManager+LivePhoto.swift | 40 +++++++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/Sources/General/ImageSource/LivePhotoSource.swift b/Sources/General/ImageSource/LivePhotoSource.swift index 4348b0eef..f8aee760e 100644 --- a/Sources/General/ImageSource/LivePhotoSource.swift +++ b/Sources/General/ImageSource/LivePhotoSource.swift @@ -32,6 +32,15 @@ public struct LivePhotoResource: Sendable { case heic case mov case other(String) + + var fileExtension: String { + switch self { + case .heic: return "heic" + case .mov: return "mov" + case .other(let ext): return ext + } + } + } public let resource: any Resource diff --git a/Sources/General/KingfisherManager+LivePhoto.swift b/Sources/General/KingfisherManager+LivePhoto.swift index 7c3c6d031..5e22e716b 100644 --- a/Sources/General/KingfisherManager+LivePhoto.swift +++ b/Sources/General/KingfisherManager+LivePhoto.swift @@ -89,10 +89,10 @@ extension KingfisherManager { let targetCache = checkedOptions.targetCache ?? cache let fileURLs = source.resources.map { - targetCache.cacheFileURLIfOnDisk( + targetCache.possibleCacheFileURLIfOnDisk( forKey: $0.cacheKey, processorIdentifier: checkedOptions.processor.identifier, - forcedExtension: $0.referenceFileType.determinedFileExtension(.init()) + referenceFileType: $0.referenceFileType ) } if fileURLs.contains(nil) { @@ -116,10 +116,10 @@ extension KingfisherManager { let targetCache = options.targetCache ?? cache missingResources = source.resources.reduce([], { r, resource in let cacheKey = resource.cacheKey - let existingCachedFileURL = targetCache.cacheFileURLIfOnDisk( + let existingCachedFileURL = targetCache.possibleCacheFileURLIfOnDisk( forKey: cacheKey, processorIdentifier: options.processor.identifier, - forcedExtension: resource.referenceFileType.determinedFileExtension(.init()) + referenceFileType: resource.referenceFileType ) if existingCachedFileURL == nil { return r + [resource] @@ -169,3 +169,35 @@ extension KingfisherManager { } } +extension ImageCache { + func possibleCacheFileURLIfOnDisk( + forKey key: String, + processorIdentifier identifier: String, + referenceFileType: LivePhotoResource.FileType + ) -> URL? { + switch referenceFileType { + case .heic, .mov: + 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 values. + let possibleFileTypes: [LivePhotoResource.FileType] = [.heic, .mov] + for fileType in possibleFileTypes { + let url = cacheFileURLIfOnDisk( + forKey: key, processorIdentifier: identifier, forcedExtension: fileType.fileExtension + ) + if url != nil { + return url + } + } + return nil + } else { + return cacheFileURLIfOnDisk( + forKey: key, processorIdentifier: identifier, forcedExtension: ext + ) + } + } + } +} From 444fabec94c39d61887b75f9659125179dcca4ba Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 5 Oct 2024 22:41:00 +0900 Subject: [PATCH 14/26] Fix failing tests caused by extension cache --- .../KingfisherManagerTests.swift | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/Tests/KingfisherTests/KingfisherManagerTests.swift b/Tests/KingfisherTests/KingfisherManagerTests.swift index 4f53c100a..391ceb6ad 100644 --- a/Tests/KingfisherTests/KingfisherManagerTests.swift +++ b/Tests/KingfisherTests/KingfisherManagerTests.swift @@ -1363,7 +1363,11 @@ class KingfisherManagerTests: XCTestCase { func testMissingResourceOfLivePhotoNotFound() async throws { let resource = KF.ImageResource(downloadURL: LivePhotoURL.mov) - try await manager.cache.storeToDisk(testImageData, forKey: resource.cacheKey) + 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)) @@ -1374,7 +1378,11 @@ class KingfisherManagerTests: XCTestCase { let resource1 = KF.ImageResource(downloadURL: LivePhotoURL.heic) let resource2 = KF.ImageResource(downloadURL: LivePhotoURL.mov) - try await manager.cache.storeToDisk(testImageData, forKey: resource1.cacheKey) + 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)) @@ -1399,8 +1407,14 @@ class KingfisherManagerTests: XCTestCase { XCTAssertTrue(urls.contains(LivePhotoURL.mov)) XCTAssertTrue(urls.contains(LivePhotoURL.heic)) - let resourceCached1 = manager.cache.imageCachedType(forKey: resource1.cacheKey) - let resourceCached2 = manager.cache.imageCachedType(forKey: resource1.cacheKey) + 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) } @@ -1443,21 +1457,25 @@ class KingfisherManagerTests: XCTestCase { try await manager.cache.storeToDisk( testImageData, forKey: resource1.cacheKey, - processorIdentifier: LivePhotoImageProcessor.default.identifier + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: resource1.downloadURL.pathExtension ) try await manager.cache.storeToDisk( testImageData, forKey: resource2.cacheKey, - processorIdentifier: LivePhotoImageProcessor.default.identifier + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: resource2.downloadURL.pathExtension ) let resource1Cached = manager.cache.isCached( forKey: resource1.cacheKey, - processorIdentifier: LivePhotoImageProcessor.default.identifier + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: resource1.downloadURL.pathExtension ) let resource2Cached = manager.cache.isCached( forKey: resource2.cacheKey, - processorIdentifier: LivePhotoImageProcessor.default.identifier + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: resource2.downloadURL.pathExtension ) XCTAssertTrue(resource1Cached) XCTAssertTrue(resource2Cached) @@ -1482,17 +1500,20 @@ class KingfisherManagerTests: XCTestCase { try await manager.cache.storeToDisk( testImageData, forKey: resource1.cacheKey, - processorIdentifier: LivePhotoImageProcessor.default.identifier + 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 + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: resource1.downloadURL.pathExtension ) let resource2Cached = manager.cache.isCached( forKey: resource2.cacheKey, - processorIdentifier: LivePhotoImageProcessor.default.identifier + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: resource2.downloadURL.pathExtension ) XCTAssertTrue(resource1Cached) XCTAssertFalse(resource2Cached) From 1fdae3b3edca2b47dedd3c4cfc72a927c9fdc0ab Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 6 Oct 2024 22:10:57 +0900 Subject: [PATCH 15/26] Opt out watchOS target for live photo --- Sources/Extensions/PHLivePhotoView+Kingfisher.swift | 6 ++++++ Sources/General/Kingfisher.swift | 2 +- Sources/General/KingfisherManager+LivePhoto.swift | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/Extensions/PHLivePhotoView+Kingfisher.swift b/Sources/Extensions/PHLivePhotoView+Kingfisher.swift index 5e532946a..ae22398bb 100644 --- a/Sources/Extensions/PHLivePhotoView+Kingfisher.swift +++ b/Sources/Extensions/PHLivePhotoView+Kingfisher.swift @@ -24,6 +24,11 @@ // 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 public struct RetrieveLivePhotoResult: @unchecked Sendable { @@ -193,3 +198,4 @@ extension KingfisherWrapper where Base: PHLivePhotoView { return .imageSettingError(reason: .notCurrentLivePhotoSourceTask(result: result, error: error, source: source)) } } +#endif diff --git a/Sources/General/Kingfisher.swift b/Sources/General/Kingfisher.swift index 53a91ddbb..b890d24b3 100644 --- a/Sources/General/Kingfisher.swift +++ b/Sources/General/Kingfisher.swift @@ -114,7 +114,7 @@ extension NSTextAttachment : KingfisherCompatible { } extension WKInterfaceImage : KingfisherCompatible { } #endif -#if canImport(PhotosUI) +#if canImport(PhotosUI) && !os(watchOS) import PhotosUI extension PHLivePhotoView : KingfisherCompatible { } #endif diff --git a/Sources/General/KingfisherManager+LivePhoto.swift b/Sources/General/KingfisherManager+LivePhoto.swift index 5e22e716b..46be06e37 100644 --- a/Sources/General/KingfisherManager+LivePhoto.swift +++ b/Sources/General/KingfisherManager+LivePhoto.swift @@ -24,6 +24,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. +#if !os(watchOS) @preconcurrency import Photos public struct LivePhotoLoadingInfoResult: Sendable { @@ -201,3 +202,4 @@ extension ImageCache { } } } +#endif From f79bdecc009da1f2eaffd173dd670374feddb243 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 6 Oct 2024 23:14:54 +0900 Subject: [PATCH 16/26] Add unit tests for resource extension and type guessing --- Kingfisher.xcodeproj/project.pbxproj | 4 + .../General/ImageSource/LivePhotoSource.swift | 4 +- .../General/KingfisherManager+LivePhoto.swift | 2 +- .../LivePhotoSourceTests.swift | 183 ++++++++++++++++++ 4 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 Tests/KingfisherTests/LivePhotoSourceTests.swift diff --git a/Kingfisher.xcodeproj/project.pbxproj b/Kingfisher.xcodeproj/project.pbxproj index 16b3a3366..93bd0ab46 100644 --- a/Kingfisher.xcodeproj/project.pbxproj +++ b/Kingfisher.xcodeproj/project.pbxproj @@ -121,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 */; }; @@ -301,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 = ""; }; @@ -491,6 +493,7 @@ 4BCFF7A9219932390055AAC4 /* DiskStorageTests.swift */, D1E564402199C21E0057AAE3 /* StorageExpirationTests.swift */, D1A1CC9E21A0F98600263AD8 /* ImageDataProviderTests.swift */, + D1F66CC02CB2CF2D004959F3 /* LivePhotoSourceTests.swift */, D1BFED94222ACC6B009330C8 /* ImageProcessorTests.swift */, 4BA3BF1D228BCDD100909201 /* DataReceivingSideEffectTests.swift */, D1F1F6FE24625EC600910725 /* RetryStrategyTests.swift */, @@ -921,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/General/ImageSource/LivePhotoSource.swift b/Sources/General/ImageSource/LivePhotoSource.swift index f8aee760e..c02716a87 100644 --- a/Sources/General/ImageSource/LivePhotoSource.swift +++ b/Sources/General/ImageSource/LivePhotoSource.swift @@ -28,7 +28,7 @@ import Foundation public struct LivePhotoResource: Sendable { - public enum FileType: Sendable { + public enum FileType: Sendable, Equatable { case heic case mov case other(String) @@ -79,7 +79,7 @@ extension LivePhotoResource.FileType { static func guessedFileExtension(from data: Data) -> String? { - guard data.count > 12 else { return nil } + guard data.count >= 12 else { return nil } var buffer = [UInt8](repeating: 0, count: 12) data.copyBytes(to: &buffer, count: 12) diff --git a/Sources/General/KingfisherManager+LivePhoto.swift b/Sources/General/KingfisherManager+LivePhoto.swift index 46be06e37..8a40fbba0 100644 --- a/Sources/General/KingfisherManager+LivePhoto.swift +++ b/Sources/General/KingfisherManager+LivePhoto.swift @@ -83,7 +83,7 @@ extension KingfisherManager { } } - // TODO. We ignore the retry of live photo now to suppress the complexity. + // 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) 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")) + } + +} From 126e2d094ece3b87ea460876114907d90da46e9a Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 7 Oct 2024 00:07:01 +0900 Subject: [PATCH 17/26] Add extension file store test --- Tests/KingfisherTests/ImageCacheTests.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Tests/KingfisherTests/ImageCacheTests.swift b/Tests/KingfisherTests/ImageCacheTests.swift index da13330fb..0fa8e32da 100644 --- a/Tests/KingfisherTests/ImageCacheTests.swift +++ b/Tests/KingfisherTests/ImageCacheTests.swift @@ -762,6 +762,24 @@ 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) + } + // MARK: - Helper private func storeMultipleImages(_ completionHandler: @escaping () -> Void) { let group = DispatchGroup() From 253226efdf78255b3ecbbe7ef05bea54c03d4436 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 7 Oct 2024 21:23:29 +0900 Subject: [PATCH 18/26] Minor refactoring and more tests --- .../General/KingfisherManager+LivePhoto.swift | 27 +++-- Tests/KingfisherTests/ImageCacheTests.swift | 114 ++++++++++++++++++ .../KingfisherManagerTests.swift | 103 +++++++++++++++- .../KingfisherTestHelper.swift | 3 + 4 files changed, 233 insertions(+), 14 deletions(-) diff --git a/Sources/General/KingfisherManager+LivePhoto.swift b/Sources/General/KingfisherManager+LivePhoto.swift index 8a40fbba0..bbafbb4ed 100644 --- a/Sources/General/KingfisherManager+LivePhoto.swift +++ b/Sources/General/KingfisherManager+LivePhoto.swift @@ -90,11 +90,7 @@ extension KingfisherManager { let targetCache = checkedOptions.targetCache ?? cache let fileURLs = source.resources.map { - targetCache.possibleCacheFileURLIfOnDisk( - forKey: $0.cacheKey, - processorIdentifier: checkedOptions.processor.identifier, - referenceFileType: $0.referenceFileType - ) + targetCache.possibleCacheFileURLIfOnDisk(resource: $0, options: checkedOptions) } if fileURLs.contains(nil) { // not all file done. throw error @@ -116,13 +112,8 @@ extension KingfisherManager { } else { let targetCache = options.targetCache ?? cache missingResources = source.resources.reduce([], { r, resource in - let cacheKey = resource.cacheKey - let existingCachedFileURL = targetCache.possibleCacheFileURLIfOnDisk( - forKey: cacheKey, - processorIdentifier: options.processor.identifier, - referenceFileType: resource.referenceFileType - ) - if existingCachedFileURL == nil { + let cachedFileURL = targetCache.possibleCacheFileURLIfOnDisk(resource: resource, options: options) + if cachedFileURL == nil { return r + [resource] } else { return r @@ -171,6 +162,18 @@ extension KingfisherManager { } extension ImageCache { + + func possibleCacheFileURLIfOnDisk( + resource: LivePhotoResource, + options: KingfisherParsedOptionsInfo + ) -> URL? { + possibleCacheFileURLIfOnDisk( + forKey: resource.cacheKey, + processorIdentifier: options.processor.identifier, + referenceFileType: resource.referenceFileType + ) + } + func possibleCacheFileURLIfOnDisk( forKey key: String, processorIdentifier identifier: String, diff --git a/Tests/KingfisherTests/ImageCacheTests.swift b/Tests/KingfisherTests/ImageCacheTests.swift index 0fa8e32da..2963ef77b 100644 --- a/Tests/KingfisherTests/ImageCacheTests.swift +++ b/Tests/KingfisherTests/ImageCacheTests.swift @@ -780,6 +780,120 @@ class ImageCacheTests: XCTestCase { 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/KingfisherManagerTests.swift b/Tests/KingfisherTests/KingfisherManagerTests.swift index 391ceb6ad..0e5d95d8f 100644 --- a/Tests/KingfisherTests/KingfisherManagerTests.swift +++ b/Tests/KingfisherTests/KingfisherManagerTests.swift @@ -1390,6 +1390,23 @@ class KingfisherManagerTests: XCTestCase { 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) @@ -1540,11 +1557,13 @@ class KingfisherManagerTests: XCTestCase { let resource1Cached = manager.cache.isCached( forKey: resource1.cacheKey, - processorIdentifier: LivePhotoImageProcessor.default.identifier + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: resource1.downloadURL.pathExtension ) let resource2Cached = manager.cache.isCached( forKey: resource2.cacheKey, - processorIdentifier: LivePhotoImageProcessor.default.identifier + processorIdentifier: LivePhotoImageProcessor.default.identifier, + forcedExtension: resource2.downloadURL.pathExtension ) XCTAssertFalse(resource1Cached) XCTAssertFalse(resource2Cached) @@ -1565,6 +1584,86 @@ class KingfisherManagerTests: XCTestCase { 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 36018df11..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") From b9c4a7e0accec01593aba9439ffc251f0c23b2cb Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 7 Oct 2024 23:54:49 +0900 Subject: [PATCH 19/26] Add missing resource error for disk cache Normally this should not happen, but in case. --- Sources/General/KingfisherError.swift | 11 +++++++++++ .../General/KingfisherManager+LivePhoto.swift | 16 ++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/Sources/General/KingfisherError.swift b/Sources/General/KingfisherError.swift index 297d19da4..8567e2e1e 100644 --- a/Sources/General/KingfisherError.swift +++ b/Sources/General/KingfisherError.swift @@ -243,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. @@ -570,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.downloadURL)' is missing in the cache. Usually a re-download" + + " can fix this issue." } } @@ -586,6 +596,7 @@ extension KingfisherError.CacheErrorReason { case .cannotCreateCacheFile: return 3009 case .cannotSetCacheFileAttribute: return 3010 case .diskStorageIsNotReady: return 3011 + case .missingLivePhotoResourceOnDisk: return 3012 } } } diff --git a/Sources/General/KingfisherManager+LivePhoto.swift b/Sources/General/KingfisherManager+LivePhoto.swift index bbafbb4ed..a80f6c7ba 100644 --- a/Sources/General/KingfisherManager+LivePhoto.swift +++ b/Sources/General/KingfisherManager+LivePhoto.swift @@ -89,14 +89,18 @@ extension KingfisherManager { let resourcesResult = try await downloadAndCache(resources: missingResources, options: checkedOptions) let targetCache = checkedOptions.targetCache ?? cache - let fileURLs = source.resources.map { - targetCache.possibleCacheFileURLIfOnDisk(resource: $0, options: checkedOptions) - } - if fileURLs.contains(nil) { - // not all file done. throw error + 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.compactMap { $0 }, + fileURLs: fileURLs, cacheType: missingResources.isEmpty ? .disk : .none, source: source, originalSource: source, From eb7ce5ae421287fd1c879572f3ba06a4d735c443 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 8 Oct 2024 00:20:36 +0900 Subject: [PATCH 20/26] Prevent reentrance issue and mark progress unavailable --- Sources/Extensions/PHLivePhotoView+Kingfisher.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Sources/Extensions/PHLivePhotoView+Kingfisher.swift b/Sources/Extensions/PHLivePhotoView+Kingfisher.swift index ae22398bb..edb06658f 100644 --- a/Sources/Extensions/PHLivePhotoView+Kingfisher.swift +++ b/Sources/Extensions/PHLivePhotoView+Kingfisher.swift @@ -73,7 +73,7 @@ extension KingfisherWrapper where Base: PHLivePhotoView { with source: LivePhotoSource?, placeholder: KFCrossPlatformImage? = nil, options: KingfisherOptionsInfo? = nil, - progressBlock: DownloadProgressBlock? = nil, + // progressBlock: DownloadProgressBlock? = nil, // Not supported yet completionHandler: (@MainActor @Sendable (Result) -> Void)? = nil ) -> Task<(), Never>? { var mutatingSelf = self @@ -96,12 +96,16 @@ extension KingfisherWrapper where Base: PHLivePhotoView { let taskIdentifierChecking = { issuedIdentifier == self.taskIdentifier } + // Copy these associated values in case of re-entry. + let targetSize = targetSize + let contentMode = contentMode + let task = Task { @MainActor in do { let loadingInfo = try await KingfisherManager.shared.retrieveLivePhoto( with: source, options: options, - progressBlock: progressBlock, + progressBlock: nil, // progressBlock, // Not supported yet referenceTaskIdentifierChecker: taskIdentifierChecking ) if let notCurrentTaskError = self.checkNotCurrentTask( @@ -119,9 +123,7 @@ extension KingfisherWrapper where Base: PHLivePhotoView { placeholderImage: placeholder, targetSize: targetSize, contentMode: contentMode, - resultHandler: { - livePhoto, - info in + resultHandler: { livePhoto, info in let result = RetrieveLivePhotoResult( loadingInfo: loadingInfo, livePhoto: livePhoto, From b7d33539c9cebbcc581f401909594f087d1dfca1 Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 8 Oct 2024 22:55:56 +0900 Subject: [PATCH 21/26] Refine API and prevent multiple completion invokes --- .../LivePhotoViewController.swift | 8 ++++- .../PHLivePhotoView+Kingfisher.swift | 35 +++++++++---------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/Demo/Demo/Kingfisher-Demo/ViewControllers/LivePhotoViewController.swift b/Demo/Demo/Kingfisher-Demo/ViewControllers/LivePhotoViewController.swift index 2d1d553e0..05215c602 100644 --- a/Demo/Demo/Kingfisher-Demo/ViewControllers/LivePhotoViewController.swift +++ b/Demo/Demo/Kingfisher-Demo/ViewControllers/LivePhotoViewController.swift @@ -53,7 +53,13 @@ class LivePhotoViewController: UIViewController { "https://github.com/onevcat/Kingfisher-TestImages/raw/refs/heads/master/LivePhotos/live_photo_sample.MOV" ].compactMap(URL.init)) livePhotoView.kf.setImage(with: source, completionHandler: { result in - print(result) + 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/Sources/Extensions/PHLivePhotoView+Kingfisher.swift b/Sources/Extensions/PHLivePhotoView+Kingfisher.swift index edb06658f..cda73e59b 100644 --- a/Sources/Extensions/PHLivePhotoView+Kingfisher.swift +++ b/Sources/Extensions/PHLivePhotoView+Kingfisher.swift @@ -71,21 +71,14 @@ extension KingfisherWrapper where Base: PHLivePhotoView { @discardableResult public func setImage( with source: LivePhotoSource?, - placeholder: KFCrossPlatformImage? = nil, + // 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 guard let source = source else { - PHLivePhoto.request( - withResourceFileURLs: [], - placeholderImage: placeholder, - targetSize: .zero, - contentMode: .default - ) { photo, _ in - base.livePhoto = photo - } + base.livePhoto = nil mutatingSelf.taskIdentifier = nil completionHandler?(.failure(KingfisherError.imageSettingError(reason: .emptySource))) return nil @@ -120,7 +113,7 @@ extension KingfisherWrapper where Base: PHLivePhotoView { PHLivePhoto.request( withResourceFileURLs: loadingInfo.fileURLs, - placeholderImage: placeholder, + placeholderImage: nil, targetSize: targetSize, contentMode: contentMode, resultHandler: { livePhoto, info in @@ -149,16 +142,20 @@ extension KingfisherWrapper where Base: PHLivePhotoView { return } - if info.keys.contains(PHLivePhotoInfoCancelledKey) { - let cancelled = (info[PHLivePhotoInfoCancelledKey] as? NSNumber)?.boolValue ?? false - if cancelled { - completionHandler?(.failure( - .requestError(reason: .livePhotoTaskCancelled(source: source))) - ) - return - } + 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 makes `completionHandler` be only called once. + return } - + completionHandler?(.success(result)) } ) From b10ee063277fabceb83d7e111d082eaf5993dde7 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 9 Oct 2024 23:56:18 +0900 Subject: [PATCH 22/26] Add documentation for most components of live photo support --- Sources/Cache/ImageCache.swift | 40 ++++++++++++++ .../PHLivePhotoView+Kingfisher.swift | 48 +++++++++++++++-- .../General/ImageSource/LivePhotoSource.swift | 16 +++++- .../General/KingfisherManager+LivePhoto.swift | 53 ++++++++++++++++++- 4 files changed, 151 insertions(+), 6 deletions(-) diff --git a/Sources/Cache/ImageCache.swift b/Sources/Cache/ImageCache.swift index 04537f000..54073b9ff 100644 --- a/Sources/Cache/ImageCache.swift +++ b/Sources/Cache/ImageCache.swift @@ -396,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. @@ -441,6 +443,22 @@ open class ImageCache: @unchecked Sendable { ) } + /// 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, @@ -510,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 @@ -888,6 +908,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 ``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( @@ -908,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. @@ -928,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 @@ -972,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 @@ -989,6 +1021,14 @@ open class ImageCache: @unchecked Sendable { 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, diff --git a/Sources/Extensions/PHLivePhotoView+Kingfisher.swift b/Sources/Extensions/PHLivePhotoView+Kingfisher.swift index cda73e59b..59cd45dc2 100644 --- a/Sources/Extensions/PHLivePhotoView+Kingfisher.swift +++ b/Sources/Extensions/PHLivePhotoView+Kingfisher.swift @@ -31,12 +31,26 @@ 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]? } @@ -46,7 +60,7 @@ public struct RetrieveLivePhotoResult: @unchecked Sendable { @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) @@ -58,16 +72,38 @@ extension KingfisherWrapper where Base: PHLivePhotoView { } } + /// 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 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. @discardableResult public func setImage( with source: LivePhotoSource?, @@ -77,6 +113,8 @@ extension KingfisherWrapper where Base: PHLivePhotoView { 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 @@ -89,7 +127,7 @@ extension KingfisherWrapper where Base: PHLivePhotoView { let taskIdentifierChecking = { issuedIdentifier == self.taskIdentifier } - // Copy these associated values in case of re-entry. + // Copy these associated values to prevent issues from reentrance. let targetSize = targetSize let contentMode = contentMode @@ -142,6 +180,10 @@ extension KingfisherWrapper where Base: PHLivePhotoView { 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))) @@ -152,7 +194,7 @@ extension KingfisherWrapper where Base: PHLivePhotoView { // 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 makes `completionHandler` be only called once. + // This ensures `completionHandler` be only called once. return } diff --git a/Sources/General/ImageSource/LivePhotoSource.swift b/Sources/General/ImageSource/LivePhotoSource.swift index c02716a87..6c0f99c1e 100644 --- a/Sources/General/ImageSource/LivePhotoSource.swift +++ b/Sources/General/ImageSource/LivePhotoSource.swift @@ -26,11 +26,26 @@ import Foundation +/// 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 { @@ -40,7 +55,6 @@ public struct LivePhotoResource: Sendable { case .other(let ext): return ext } } - } public let resource: any Resource diff --git a/Sources/General/KingfisherManager+LivePhoto.swift b/Sources/General/KingfisherManager+LivePhoto.swift index a80f6c7ba..6bc6eb81d 100644 --- a/Sources/General/KingfisherManager+LivePhoto.swift +++ b/Sources/General/KingfisherManager+LivePhoto.swift @@ -27,6 +27,7 @@ #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. @@ -60,6 +61,28 @@ public struct LivePhotoLoadingInfoResult: Sendable { } 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, @@ -73,6 +96,7 @@ extension KingfisherManager { // 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 } @@ -109,6 +133,8 @@ extension KingfisherManager { }) } + // 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 { @@ -116,6 +142,7 @@ extension KingfisherManager { } 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] @@ -127,6 +154,9 @@ extension KingfisherManager { 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 @@ -136,13 +166,21 @@ extension KingfisherManager { } let downloader = options.downloader ?? downloader let cache = options.targetCache ?? cache - return try await withThrowingTaskGroup(of: LivePhotoResourceDownloadingResult.self) { group in + + // Download all resources concurrently. + return try await withThrowingTaskGroup(of: LivePhotoResourceDownloadingResult.self) { + group in + for resource in resources { group.addTask { let downloadedResource = try await downloader.downloadLivePhotoResource( with: resource.downloadURL, options: options ) + + // 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( @@ -178,6 +216,13 @@ extension ImageCache { ) } + // 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, @@ -185,23 +230,27 @@ extension ImageCache { ) -> 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 values. + // 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 ) From a51a4cc72160eeb1b87f16b3685ce544241ea98a Mon Sep 17 00:00:00 2001 From: onevcat Date: Thu, 10 Oct 2024 00:08:47 +0900 Subject: [PATCH 23/26] Support using data provider to get live photo data --- .../ImageSource/ImageDataProvider.swift | 8 +++++++ .../General/ImageSource/LivePhotoSource.swift | 11 +++++----- Sources/General/KingfisherError.swift | 2 +- .../General/KingfisherManager+LivePhoto.swift | 21 ++++++++++++++----- 4 files changed, 31 insertions(+), 11 deletions(-) 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 index 6c0f99c1e..8eb7228c0 100644 --- a/Sources/General/ImageSource/LivePhotoSource.swift +++ b/Sources/General/ImageSource/LivePhotoSource.swift @@ -57,19 +57,20 @@ public struct LivePhotoResource: Sendable { } } - public let resource: any Resource + public let dataSource: Source public let referenceFileType: FileType - var cacheKey: String { resource.cacheKey } - var downloadURL: URL { resource.downloadURL } + var cacheKey: String { dataSource.cacheKey } + var downloadURL: URL? { dataSource.url } public init(downloadURL: URL, cacheKey: String? = nil, fileType: FileType? = nil) { - resource = KF.ImageResource(downloadURL: downloadURL, cacheKey: cacheKey) + let resource = KF.ImageResource(downloadURL: downloadURL, cacheKey: cacheKey) + dataSource = .network(resource) referenceFileType = fileType ?? resource.guessedFileType } public init(resource: any Resource, fileType: FileType? = nil) { - self.resource = resource + self.dataSource = .network(resource) referenceFileType = fileType ?? resource.guessedFileType } } diff --git a/Sources/General/KingfisherError.swift b/Sources/General/KingfisherError.swift index 8567e2e1e..619fab1ef 100644 --- a/Sources/General/KingfisherError.swift +++ b/Sources/General/KingfisherError.swift @@ -578,7 +578,7 @@ extension KingfisherError.CacheErrorReason { 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.downloadURL)' is missing in the cache. Usually a re-download" + + return "The live photo resource '\(resource)' is missing in the cache. Usually a re-download" + " can fix this issue." } } diff --git a/Sources/General/KingfisherManager+LivePhoto.swift b/Sources/General/KingfisherManager+LivePhoto.swift index 6bc6eb81d..81347c114 100644 --- a/Sources/General/KingfisherManager+LivePhoto.swift +++ b/Sources/General/KingfisherManager+LivePhoto.swift @@ -173,11 +173,22 @@ extension KingfisherManager { for resource in resources { group.addTask { - let downloadedResource = try await downloader.downloadLivePhotoResource( - with: resource.downloadURL, - options: options - ) - + + 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 From 7a6306d2e5dac076455c3dd5f23242de2991f8e0 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 11 Oct 2024 00:26:21 +0900 Subject: [PATCH 24/26] More documentations --- .../LivePhotoViewController.swift | 6 +- .../PHLivePhotoView+Kingfisher.swift | 73 +++++++++++- .../General/ImageSource/LivePhotoSource.swift | 105 ++++++++++++++---- 3 files changed, 154 insertions(+), 30 deletions(-) diff --git a/Demo/Demo/Kingfisher-Demo/ViewControllers/LivePhotoViewController.swift b/Demo/Demo/Kingfisher-Demo/ViewControllers/LivePhotoViewController.swift index 05215c602..77d7eb4a7 100644 --- a/Demo/Demo/Kingfisher-Demo/ViewControllers/LivePhotoViewController.swift +++ b/Demo/Demo/Kingfisher-Demo/ViewControllers/LivePhotoViewController.swift @@ -48,11 +48,11 @@ class LivePhotoViewController: UIViewController { livePhotoView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -30) ]) - let source = LivePhotoSource(urls: [ + 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: source, completionHandler: { result in + ].compactMap(URL.init) + livePhotoView.kf.setImage(with: urls, completionHandler: { result in switch result { case .success(let r): print("Live Photo done. \(r.loadingInfo.cacheType)") diff --git a/Sources/Extensions/PHLivePhotoView+Kingfisher.swift b/Sources/Extensions/PHLivePhotoView+Kingfisher.swift index 59cd45dc2..097fb1d87 100644 --- a/Sources/Extensions/PHLivePhotoView+Kingfisher.swift +++ b/Sources/Extensions/PHLivePhotoView+Kingfisher.swift @@ -92,18 +92,83 @@ extension KingfisherWrapper where Base: PHLivePhotoView { set { setRetainedAssociatedObject(base, &contentModeKey, newValue) } } - /// Sets a live photo to the view with a `LivePhotoSource`. + /// Sets a live photo to the view with an array of `URL`. /// /// - Parameters: - /// - source: The `LivePhotoSource` object defining the live photo resource. + /// - 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. + /// + /// Sample: + /// ```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 + /// - 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?, diff --git a/Sources/General/ImageSource/LivePhotoSource.swift b/Sources/General/ImageSource/LivePhotoSource.swift index 8eb7228c0..e70e8d8bd 100644 --- a/Sources/General/ImageSource/LivePhotoSource.swift +++ b/Sources/General/ImageSource/LivePhotoSource.swift @@ -26,6 +26,41 @@ 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 @@ -57,22 +92,65 @@ public struct LivePhotoResource: Sendable { } } + /// 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 { @@ -88,9 +166,9 @@ extension LivePhotoResource.FileType { } } - static let fytpChunk: [UInt8] = [0x66, 0x74, 0x79, 0x70] - static let heicChunk: [UInt8] = [0x68, 0x65, 0x69, 0x63] - static let qtChunk: [UInt8] = [0x71, 0x74, 0x20, 0x20] // quicktime + 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? { @@ -124,22 +202,3 @@ extension Resource { } } } - -public struct LivePhotoSource: Sendable { - - public let resources: [LivePhotoResource] - - public init(resources: [any Resource]) { - let livePhotoResources = resources.map { LivePhotoResource(resource: $0) } - self.init(livePhotoResources) - } - - public init(urls: [URL]) { - let resources = urls.map { KF.ImageResource(downloadURL: $0) } - self.init(resources: resources) - } - - public init(_ resources: [LivePhotoResource]) { - self.resources = resources - } -} From 6506de2cfa4115bf58b883e8553a19ca0a0090b5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 11 Oct 2024 00:49:23 +0900 Subject: [PATCH 25/26] Fix tests --- Tests/KingfisherTests/KingfisherManagerTests.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Tests/KingfisherTests/KingfisherManagerTests.swift b/Tests/KingfisherTests/KingfisherManagerTests.swift index 0e5d95d8f..f4e279239 100644 --- a/Tests/KingfisherTests/KingfisherManagerTests.swift +++ b/Tests/KingfisherTests/KingfisherManagerTests.swift @@ -1592,7 +1592,7 @@ class KingfisherManagerTests: XCTestCase { func testDownloadAndCacheLivePhotoWithSingleResource() async throws { let resource = LivePhotoResource(downloadURL: LivePhotoURL.heic) - stub(resource.downloadURL, data: testImageData) + stub(resource.downloadURL!, data: testImageData) let result = try await manager.downloadAndCache(resources: [resource], options: .init([])) XCTAssertEqual(result.count, 1) @@ -1603,7 +1603,7 @@ class KingfisherManagerTests: XCTestCase { func testDownloadAndCacheLivePhotoWithSingleResourceGuessingUnsupportedExtension() async throws { let resource = LivePhotoResource(downloadURL: URL(string: "https://example.com")!) - stub(resource.downloadURL, data: testImageData) + stub(resource.downloadURL!, data: testImageData) XCTAssertEqual(resource.referenceFileType, .other("")) @@ -1619,7 +1619,7 @@ class KingfisherManagerTests: XCTestCase { func testDownloadAndCacheLivePhotoWithSingleResourceExplicitSetExtension() async throws { let resource = LivePhotoResource(downloadURL: URL(string: "https://example.com")!, fileType: .heic) - stub(resource.downloadURL, data: testImageData) + stub(resource.downloadURL!, data: testImageData) XCTAssertEqual(resource.referenceFileType, .heic) @@ -1635,7 +1635,7 @@ class KingfisherManagerTests: XCTestCase { func testDownloadAndCacheLivePhotoWithSingleResourceGuessingHEICExtension() async throws { let resource = LivePhotoResource(downloadURL: URL(string: "https://example.com")!) - stub(resource.downloadURL, data: partitalHEICData) + stub(resource.downloadURL!, data: partitalHEICData) XCTAssertEqual(resource.referenceFileType, .other("")) @@ -1651,7 +1651,7 @@ class KingfisherManagerTests: XCTestCase { func testDownloadAndCacheLivePhotoWithSingleResourceGuessingMOVExtension() async throws { let resource = LivePhotoResource(downloadURL: URL(string: "https://example.com")!) - stub(resource.downloadURL, data: partitalMOVData) + stub(resource.downloadURL!, data: partitalMOVData) XCTAssertEqual(resource.referenceFileType, .other("")) From 27f4a7343e304a06ceb8e90748717d9aad81c69c Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 12 Oct 2024 00:01:22 +0900 Subject: [PATCH 26/26] Rename comment --- Sources/Extensions/PHLivePhotoView+Kingfisher.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Extensions/PHLivePhotoView+Kingfisher.swift b/Sources/Extensions/PHLivePhotoView+Kingfisher.swift index 097fb1d87..10fe5999a 100644 --- a/Sources/Extensions/PHLivePhotoView+Kingfisher.swift +++ b/Sources/Extensions/PHLivePhotoView+Kingfisher.swift @@ -109,7 +109,8 @@ extension KingfisherWrapper where Base: PHLivePhotoView { /// - Note: To get refined control of the resources, use the ``setImage(with:options:completionHandler:)-1n4p2`` /// method with a ``LivePhotoSource`` object. /// - /// Sample: + /// Example: + /// /// ```swift /// let urls = [ /// URL(string: "https://example.com/image.heic")!, // imageURL