Просмотр исходного кода

Add extension methods and error cases for live photo

onevcat 1 год назад
Родитель
Сommit
8be1491df7

+ 4 - 0
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 = "<group>"; };
 		D12F67612CAC32B800AB63AB /* KingfisherManager+LivePhoto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KingfisherManager+LivePhoto.swift"; sourceTree = "<group>"; };
 		D12F67632CAC330600AB63AB /* LivePhotoSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivePhotoSource.swift; sourceTree = "<group>"; };
+		D12F67652CB022EB00AB63AB /* PHLivePhotoView+Kingfisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PHLivePhotoView+Kingfisher.swift"; sourceTree = "<group>"; };
 		D1356CEA2B273AEC009554C8 /* Documentation.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Documentation.docc; sourceTree = "<group>"; };
 		D13646732165A1A100A33652 /* Result.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Result.swift; sourceTree = "<group>"; };
 		D16CC3D524E02E9500F1A515 /* AVAssetImageDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVAssetImageDataProvider.swift; sourceTree = "<group>"; };
@@ -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;

+ 195 - 0
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 <onevcat@gmail.com>
+//
+//  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<Source.Identifier.Value>? = 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<RetrieveLivePhotoResult, KingfisherError>) -> 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))
+    }
+}

+ 64 - 0
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
         }
     }
 }

+ 6 - 6
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)
             }

+ 4 - 4
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<LivePhotoResourceLoadingResult, KingfisherError>) -> Void)? = nil
+        completionHandler: (@Sendable (Result<LivePhotoResourceDownloadingResult, KingfisherError>) -> 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)
         }
     }

+ 1 - 0
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()