From de9c217d51628a284fec457e37857bdcf5430042 Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:04:24 +0200 Subject: [PATCH 01/15] refactor: replace DispatchQueue + DispatchSemaphore with OperationQueue for image processing --- .../ios/Runner/Images/ImageProcessing.swift | 9 ++++++-- .../ios/Runner/Images/LocalImagesImpl.swift | 23 ++++++------------- .../ios/Runner/Images/RemoteImagesImpl.swift | 5 +--- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/mobile/ios/Runner/Images/ImageProcessing.swift b/mobile/ios/Runner/Images/ImageProcessing.swift index 2270bbffac871..686a3464a702f 100644 --- a/mobile/ios/Runner/Images/ImageProcessing.swift +++ b/mobile/ios/Runner/Images/ImageProcessing.swift @@ -1,7 +1,12 @@ import Foundation enum ImageProcessing { - static let queue = DispatchQueue(label: "thumbnail.processing", qos: .userInitiated, attributes: .concurrent) - static let semaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2) + static let queue = { + let q = OperationQueue() + q.name = "thumbnail.processing" + q.qualityOfService = .userInitiated + q.maxConcurrentOperationCount = ProcessInfo.processInfo.activeProcessorCount * 2 + return q + }() static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil) } diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift index 303ff5bc338d8..f0710bf75f14a 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -4,7 +4,7 @@ import MobileCoreServices import Photos class LocalImageRequest { - weak var workItem: DispatchWorkItem? + weak var operation: Operation? var isCancelled = false let callback: (Result<[String: Int64]?, any Error>) -> Void @@ -50,7 +50,7 @@ class LocalImageApiImpl: LocalImageApi { }() func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) { - ImageProcessing.queue.async { + ImageProcessing.queue.addOperation { guard let data = Data(base64Encoded: thumbhash) else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))} @@ -66,16 +66,7 @@ class LocalImageApiImpl: LocalImageApi { func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { let request = LocalImageRequest(callback: completion) - let item = DispatchWorkItem { - if request.isCancelled { - return completion(ImageProcessing.cancelledResult) - } - - ImageProcessing.semaphore.wait() - defer { - ImageProcessing.semaphore.signal() - } - + let operation = BlockOperation { if request.isCancelled { return completion(ImageProcessing.cancelledResult) } @@ -180,9 +171,9 @@ class LocalImageApiImpl: LocalImageApi { } } - request.workItem = item + request.operation = operation Self.add(requestId: requestId, request: request) - ImageProcessing.queue.async(execute: item) + ImageProcessing.queue.addOperation(operation) } func cancelRequest(requestId: Int64) { @@ -201,8 +192,8 @@ class LocalImageApiImpl: LocalImageApi { requestQueue.async { guard let request = requests.removeValue(forKey: requestId) else { return } request.isCancelled = true - guard let item = request.workItem else { return } - if item.isCancelled { + guard let operation = request.operation else { return } + if operation.isCancelled { cancelQueue.async { request.callback(ImageProcessing.cancelledResult) } } } diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index f2a0c37254b87..7c69acba7673f 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -73,10 +73,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil))) } - ImageProcessing.queue.async { - ImageProcessing.semaphore.wait() - defer { ImageProcessing.semaphore.signal() } - + ImageProcessing.queue.addOperation { if request.isCancelled { return request.completion(ImageProcessing.cancelledResult) } From d71afee0c325312f9270462d155d119e5acca547 Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:19:06 +0200 Subject: [PATCH 02/15] implement RequestRegistry and UnfairLock for managing cancellable requests --- mobile/ios/Runner/Images/ImageRequest.swift | 23 +++++++++++++++++++++ mobile/ios/Runner/Images/UnfairLock.swift | 23 +++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 mobile/ios/Runner/Images/ImageRequest.swift create mode 100644 mobile/ios/Runner/Images/UnfairLock.swift diff --git a/mobile/ios/Runner/Images/ImageRequest.swift b/mobile/ios/Runner/Images/ImageRequest.swift new file mode 100644 index 0000000000000..a3ab8490d35c7 --- /dev/null +++ b/mobile/ios/Runner/Images/ImageRequest.swift @@ -0,0 +1,23 @@ +import Foundation + +protocol Cancellable: AnyObject { + func cancel() +} + +class RequestRegistry { + private let lock = UnfairLock() + private var requests = [Int64: T]() + + func add(requestId: Int64, request: T) { + lock.withLock { requests[requestId] = request } + } + + @discardableResult + func remove(requestId: Int64) -> T? { + lock.withLock { requests.removeValue(forKey: requestId) } + } + + func cancel(requestId: Int64) { + remove(requestId: requestId)?.cancel() + } +} diff --git a/mobile/ios/Runner/Images/UnfairLock.swift b/mobile/ios/Runner/Images/UnfairLock.swift new file mode 100644 index 0000000000000..c94b013557ef9 --- /dev/null +++ b/mobile/ios/Runner/Images/UnfairLock.swift @@ -0,0 +1,23 @@ +import Foundation + +// Can be replaced with OSAllocatedUnfairLock when the deployment target is iOS 16+ +final class UnfairLock { + private let _lock: UnsafeMutablePointer + + init() { + _lock = .allocate(capacity: 1) + _lock.initialize(to: os_unfair_lock()) + } + + deinit { + _lock.deinitialize(count: 1) + _lock.deallocate() + } + + @discardableResult + func withLock(_ body: () throws -> T) rethrows -> T { + os_unfair_lock_lock(_lock) + defer { os_unfair_lock_unlock(_lock) } + return try body() + } +} From 272d0cea0896b7e5153eead41e8f5c90b775e8b7 Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:32:48 +0200 Subject: [PATCH 03/15] implement requests registry for local and remote image processing --- mobile/ios/Runner.xcodeproj/project.pbxproj | 8 +++ .../ios/Runner/Images/LocalImagesImpl.swift | 59 +++++++------------ .../ios/Runner/Images/RemoteImagesImpl.swift | 29 ++++----- 3 files changed, 39 insertions(+), 57 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 22a7abcbac8e9..3153c7140b8b2 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -16,6 +16,8 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + A01DD69A2F7F43B40049AB63 /* UnfairLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A01DD6992F7F43B40049AB63 /* UnfairLock.swift */; }; + A01DD69B2F7F43B40049AB63 /* ImageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A01DD6982F7F43B40049AB63 /* ImageRequest.swift */; }; B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */; }; B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; }; B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; }; @@ -102,6 +104,8 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A01DD6982F7F43B40049AB63 /* ImageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRequest.swift; sourceTree = ""; }; + A01DD6992F7F43B40049AB63 /* UnfairLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnfairLock.swift; sourceTree = ""; }; B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = ""; }; B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkerApiImpl.swift; sourceTree = ""; }; B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = ""; }; @@ -327,6 +331,8 @@ FED3B1952E253E9B0030FD97 /* Images */ = { isa = PBXGroup; children = ( + A01DD6982F7F43B40049AB63 /* ImageRequest.swift */, + A01DD6992F7F43B40049AB63 /* UnfairLock.swift */, FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */, FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */, FE5499F52F11980E006016CB /* LocalImagesImpl.swift */, @@ -608,6 +614,8 @@ files = ( 65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + A01DD69A2F7F43B40049AB63 /* UnfairLock.swift in Sources */, + A01DD69B2F7F43B40049AB63 /* ImageRequest.swift in Sources */, B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */, FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */, FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */, diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift index f0710bf75f14a..6604a2902f1e2 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -3,7 +3,7 @@ import Flutter import MobileCoreServices import Photos -class LocalImageRequest { +class LocalImageRequest: Cancellable { weak var operation: Operation? var isCancelled = false let callback: (Result<[String: Int64]?, any Error>) -> Void @@ -11,6 +11,11 @@ class LocalImageRequest { init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) { self.callback = callback } + + func cancel() { + isCancelled = true + operation?.cancel() + } } class LocalImageApiImpl: LocalImageApi { @@ -31,9 +36,7 @@ class LocalImageApiImpl: LocalImageApi { return requestOptions }() - private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated) - private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated) - private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default) + private static let registry = RequestRegistry() private static var rgbaFormat = vImage_CGImageFormat( bitsPerComponent: 8, @@ -42,7 +45,6 @@ class LocalImageApiImpl: LocalImageApi { bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), renderingIntent: .defaultIntent )! - private static var requests = [Int64: LocalImageRequest]() private static let assetCache = { let assetCache = NSCache() assetCache.countLimit = 10000 @@ -73,7 +75,7 @@ class LocalImageApiImpl: LocalImageApi { guard let asset = Self.requestAsset(assetId: assetId) else { - Self.remove(requestId: requestId) + Self.registry.remove(requestId: requestId) completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))) return } @@ -98,12 +100,11 @@ class LocalImageApiImpl: LocalImageApi { ) if request.isCancelled { - Self.remove(requestId: requestId) return completion(ImageProcessing.cancelledResult) } guard let data = imageData else { - Self.remove(requestId: requestId) + Self.registry.remove(requestId: requestId) return completion(.failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil))) } @@ -113,7 +114,6 @@ class LocalImageApiImpl: LocalImageApi { if request.isCancelled { free(pointer) - Self.remove(requestId: requestId) return completion(ImageProcessing.cancelledResult) } @@ -121,7 +121,7 @@ class LocalImageApiImpl: LocalImageApi { "pointer": Int64(Int(bitPattern: pointer)), "length": Int64(length), ])) - Self.remove(requestId: requestId) + Self.registry.remove(requestId: requestId) return } @@ -142,7 +142,7 @@ class LocalImageApiImpl: LocalImageApi { guard let image = image, let cgImage = image.cgImage else { - Self.remove(requestId: requestId) + Self.registry.remove(requestId: requestId) return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) } @@ -162,51 +162,32 @@ class LocalImageApiImpl: LocalImageApi { "pointer": Int64(Int(bitPattern: buffer.data)), "width": Int64(buffer.width), "height": Int64(buffer.height), - "rowBytes": Int64(buffer.rowBytes) + "rowBytes": Int64(buffer.rowBytes), ])) - Self.remove(requestId: requestId) + Self.registry.remove(requestId: requestId) } catch { - Self.remove(requestId: requestId) + Self.registry.remove(requestId: requestId) return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil))) } } request.operation = operation - Self.add(requestId: requestId, request: request) + Self.registry.add(requestId: requestId, request: request) ImageProcessing.queue.addOperation(operation) } func cancelRequest(requestId: Int64) { - Self.cancel(requestId: requestId) - } - - private static func add(requestId: Int64, request: LocalImageRequest) -> Void { - requestQueue.sync { requests[requestId] = request } - } - - private static func remove(requestId: Int64) -> Void { - requestQueue.sync { requests[requestId] = nil } - } - - private static func cancel(requestId: Int64) -> Void { - requestQueue.async { - guard let request = requests.removeValue(forKey: requestId) else { return } - request.isCancelled = true - guard let operation = request.operation else { return } - if operation.isCancelled { - cancelQueue.async { request.callback(ImageProcessing.cancelledResult) } - } - } + Self.registry.cancel(requestId: requestId) } private static func requestAsset(assetId: String) -> PHAsset? { - var asset: PHAsset? - assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) } - if asset != nil { return asset } + if let cached = assetCache.object(forKey: assetId as NSString) { + return cached + } guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject else { return nil } - assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) } + assetCache.setObject(asset, forKey: assetId as NSString) return asset } } diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index 7c69acba7673f..c34f9a56e51f4 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -3,7 +3,7 @@ import Flutter import MobileCoreServices import Photos -class RemoteImageRequest { +class RemoteImageRequest: Cancellable { weak var task: URLSessionDataTask? let id: Int64 var isCancelled = false @@ -14,11 +14,15 @@ class RemoteImageRequest { self.task = task self.completion = completion } + + func cancel() { + isCancelled = true + task?.cancel() + } } class RemoteImageApiImpl: NSObject, RemoteImageApi { - private static var lock = os_unfair_lock() - private static var requests = [Int64: RemoteImageRequest]() + private static let registry = RequestRegistry() private static var rgbaFormat = vImage_CGImageFormat( bitsPerComponent: 8, bitsPerPixel: 32, @@ -43,20 +47,15 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { let request = RemoteImageRequest(id: requestId, task: task, completion: completion) - os_unfair_lock_lock(&Self.lock) - Self.requests[requestId] = request - os_unfair_lock_unlock(&Self.lock) + Self.registry.add(requestId: requestId, request: request) task.resume() } private static func handleCompletion(requestId: Int64, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) { - os_unfair_lock_lock(&Self.lock) - guard let request = requests[requestId] else { - return os_unfair_lock_unlock(&Self.lock) + guard let request = registry.remove(requestId: requestId) else { + return } - requests[requestId] = nil - os_unfair_lock_unlock(&Self.lock) if let error = error { if request.isCancelled || (error as NSError).code == NSURLErrorCancelled { @@ -127,13 +126,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { } func cancelRequest(requestId: Int64) { - os_unfair_lock_lock(&Self.lock) - let request = Self.requests[requestId] - os_unfair_lock_unlock(&Self.lock) - - guard let request = request else { return } - request.isCancelled = true - request.task?.cancel() + Self.registry.cancel(requestId: requestId) } func clearCache(completion: @escaping (Result) -> Void) { From f5f3368501a8d7263ce9c02f7d68db3e070e8ebd Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Sun, 5 Apr 2026 19:52:42 +0200 Subject: [PATCH 04/15] remove Cancellable protocol and cancel method from request registry --- mobile/ios/Runner/Images/ImageRequest.swift | 10 +--------- mobile/ios/Runner/Images/LocalImagesImpl.swift | 4 ++-- mobile/ios/Runner/Images/RemoteImagesImpl.swift | 4 ++-- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/mobile/ios/Runner/Images/ImageRequest.swift b/mobile/ios/Runner/Images/ImageRequest.swift index a3ab8490d35c7..a5553083a91b7 100644 --- a/mobile/ios/Runner/Images/ImageRequest.swift +++ b/mobile/ios/Runner/Images/ImageRequest.swift @@ -1,10 +1,6 @@ import Foundation -protocol Cancellable: AnyObject { - func cancel() -} - -class RequestRegistry { +class RequestRegistry { private let lock = UnfairLock() private var requests = [Int64: T]() @@ -16,8 +12,4 @@ class RequestRegistry { func remove(requestId: Int64) -> T? { lock.withLock { requests.removeValue(forKey: requestId) } } - - func cancel(requestId: Int64) { - remove(requestId: requestId)?.cancel() - } } diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift index 6604a2902f1e2..713a24a2dd594 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -3,7 +3,7 @@ import Flutter import MobileCoreServices import Photos -class LocalImageRequest: Cancellable { +class LocalImageRequest { weak var operation: Operation? var isCancelled = false let callback: (Result<[String: Int64]?, any Error>) -> Void @@ -177,7 +177,7 @@ class LocalImageApiImpl: LocalImageApi { } func cancelRequest(requestId: Int64) { - Self.registry.cancel(requestId: requestId) + Self.registry.remove(requestId: requestId)?.cancel() } private static func requestAsset(assetId: String) -> PHAsset? { diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index c34f9a56e51f4..37d37f597be60 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -3,7 +3,7 @@ import Flutter import MobileCoreServices import Photos -class RemoteImageRequest: Cancellable { +class RemoteImageRequest { weak var task: URLSessionDataTask? let id: Int64 var isCancelled = false @@ -126,7 +126,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { } func cancelRequest(requestId: Int64) { - Self.registry.cancel(requestId: requestId) + Self.registry.remove(requestId: requestId)?.cancel() } func clearCache(completion: @escaping (Result) -> Void) { From 2149914ed009da02b56bcbb57cf82e9924afdef3 Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:22:58 +0200 Subject: [PATCH 05/15] refactor: introduce ImageRequest base class with unified cancellation and finish helpers --- mobile/ios/Runner/Images/ImageRequest.swift | 65 +++++++++++++++++- .../ios/Runner/Images/LocalImagesImpl.swift | 68 ++++--------------- .../ios/Runner/Images/RemoteImagesImpl.swift | 65 ++++-------------- 3 files changed, 92 insertions(+), 106 deletions(-) diff --git a/mobile/ios/Runner/Images/ImageRequest.swift b/mobile/ios/Runner/Images/ImageRequest.swift index a5553083a91b7..c9d36b41d2c23 100644 --- a/mobile/ios/Runner/Images/ImageRequest.swift +++ b/mobile/ios/Runner/Images/ImageRequest.swift @@ -1,6 +1,69 @@ +import Accelerate import Foundation -class RequestRegistry { +class ImageRequest { + private let lock = UnfairLock() + private var _isCancelled = false + private var callback: ((Result<[String: Int64]?, any Error>) -> Void)? + + var isCancelled: Bool { + lock.withLock { _isCancelled } + } + + init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) { + self.callback = callback + } + + func cancel() { + let cb = lock.withLock { + _isCancelled = true + defer { callback = nil } + return callback + } + onCancel() + cb?(ImageProcessing.cancelledResult) + } + + func finish(with result: Result<[String: Int64]?, any Error>) { + let cb = lock.withLock { + defer { callback = nil } + return callback + } + cb?(result) + } + + func onCancel() {} + + func finish(encoding data: Data) { + let length = data.count + let pointer = malloc(length)! + data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length) + if isCancelled { + free(pointer) + return + } + finish(with: .success([ + "pointer": Int64(Int(bitPattern: pointer)), + "length": Int64(length), + ])) + } + + func finish(cgImage: CGImage, format: inout vImage_CGImageFormat) throws { + let buffer = try vImage_Buffer(cgImage: cgImage, format: format) + if isCancelled { + buffer.free() + return + } + finish(with: .success([ + "pointer": Int64(Int(bitPattern: buffer.data)), + "width": Int64(buffer.width), + "height": Int64(buffer.height), + "rowBytes": Int64(buffer.rowBytes), + ])) + } +} + +class RequestRegistry { private let lock = UnfairLock() private var requests = [Int64: T]() diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift index 713a24a2dd594..06e8da7191343 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -3,17 +3,10 @@ import Flutter import MobileCoreServices import Photos -class LocalImageRequest { +class LocalImageRequest: ImageRequest { weak var operation: Operation? - var isCancelled = false - let callback: (Result<[String: Int64]?, any Error>) -> Void - init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) { - self.callback = callback - } - - func cancel() { - isCancelled = true + override func onCancel() { operation?.cancel() } } @@ -69,20 +62,15 @@ class LocalImageApiImpl: LocalImageApi { func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { let request = LocalImageRequest(callback: completion) let operation = BlockOperation { - if request.isCancelled { - return completion(ImageProcessing.cancelledResult) - } + if request.isCancelled { return } guard let asset = Self.requestAsset(assetId: assetId) else { Self.registry.remove(requestId: requestId) - completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))) - return + return request.finish(with: .failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))) } - if request.isCancelled { - return completion(ImageProcessing.cancelledResult) - } + if request.isCancelled { return } if preferEncoded { let dataOptions = PHImageRequestOptions() @@ -99,28 +87,14 @@ class LocalImageApiImpl: LocalImageApi { } ) - if request.isCancelled { - return completion(ImageProcessing.cancelledResult) - } + if request.isCancelled { return } guard let data = imageData else { Self.registry.remove(requestId: requestId) - return completion(.failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil))) - } - - let length = data.count - let pointer = malloc(length)! - data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length) - - if request.isCancelled { - free(pointer) - return completion(ImageProcessing.cancelledResult) + return request.finish(with: .failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil))) } - request.callback(.success([ - "pointer": Int64(Int(bitPattern: pointer)), - "length": Int64(length), - ])) + request.finish(encoding: data) Self.registry.remove(requestId: requestId) return } @@ -136,38 +110,22 @@ class LocalImageApiImpl: LocalImageApi { } ) - if request.isCancelled { - return completion(ImageProcessing.cancelledResult) - } + if request.isCancelled { return } guard let image = image, let cgImage = image.cgImage else { Self.registry.remove(requestId: requestId) - return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) + return request.finish(with: .failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) } - if request.isCancelled { - return completion(ImageProcessing.cancelledResult) - } + if request.isCancelled { return } do { - let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat) - - if request.isCancelled { - buffer.free() - return completion(ImageProcessing.cancelledResult) - } - - request.callback(.success([ - "pointer": Int64(Int(bitPattern: buffer.data)), - "width": Int64(buffer.width), - "height": Int64(buffer.height), - "rowBytes": Int64(buffer.rowBytes), - ])) + try request.finish(cgImage: cgImage, format: &Self.rgbaFormat) Self.registry.remove(requestId: requestId) } catch { Self.registry.remove(requestId: requestId) - return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil))) + return request.finish(with: .failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil))) } } diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index 37d37f597be60..c4d6325871494 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -3,20 +3,17 @@ import Flutter import MobileCoreServices import Photos -class RemoteImageRequest { +class RemoteImageRequest: ImageRequest { weak var task: URLSessionDataTask? let id: Int64 - var isCancelled = false - let completion: (Result<[String: Int64]?, any Error>) -> Void init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { self.id = id self.task = task - self.completion = completion + super.init(callback: completion) } - func cancel() { - isCancelled = true + override func onCancel() { task?.cancel() } } @@ -57,70 +54,38 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { return } + if request.isCancelled { return } + if let error = error { - if request.isCancelled || (error as NSError).code == NSURLErrorCancelled { - return request.completion(ImageProcessing.cancelledResult) + if (error as NSError).code == NSURLErrorCancelled { + return request.finish(with: ImageProcessing.cancelledResult) } - return request.completion(.failure(error)) - } - - if request.isCancelled { - return request.completion(ImageProcessing.cancelledResult) + return request.finish(with: .failure(error)) } guard let data = data else { - return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil))) + return request.finish(with: .failure(PigeonError(code: "", message: "No data received", details: nil))) } ImageProcessing.queue.addOperation { - if request.isCancelled { - return request.completion(ImageProcessing.cancelledResult) - } + if request.isCancelled { return } // Return raw encoded bytes when requested (for animated images) if encoded { - let length = data.count - let pointer = malloc(length)! - data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length) - - if request.isCancelled { - free(pointer) - return request.completion(ImageProcessing.cancelledResult) - } - - return request.completion( - .success([ - "pointer": Int64(Int(bitPattern: pointer)), - "length": Int64(length), - ])) + return request.finish(encoding: data) } guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil), let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else { - return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil))) + return request.finish(with: .failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil))) } - if request.isCancelled { - return request.completion(ImageProcessing.cancelledResult) - } + if request.isCancelled { return } do { - let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat) - - if request.isCancelled { - buffer.free() - return request.completion(ImageProcessing.cancelledResult) - } - - request.completion( - .success([ - "pointer": Int64(Int(bitPattern: buffer.data)), - "width": Int64(buffer.width), - "height": Int64(buffer.height), - "rowBytes": Int64(buffer.rowBytes), - ])) + try request.finish(cgImage: cgImage, format: &rgbaFormat) } catch { - return request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil))) + return request.finish(with: .failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil))) } } } From 656c7c2dfd841db01e86f671efda062f5498a20e Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:31:04 +0200 Subject: [PATCH 06/15] refactor: add get method to RequestRegistry and streamline request removal in image processing --- mobile/ios/Runner/Images/ImageRequest.swift | 4 ++++ mobile/ios/Runner/Images/LocalImagesImpl.swift | 13 +++++-------- mobile/ios/Runner/Images/RemoteImagesImpl.swift | 6 +++++- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/mobile/ios/Runner/Images/ImageRequest.swift b/mobile/ios/Runner/Images/ImageRequest.swift index c9d36b41d2c23..e0efa5a7637f2 100644 --- a/mobile/ios/Runner/Images/ImageRequest.swift +++ b/mobile/ios/Runner/Images/ImageRequest.swift @@ -71,6 +71,10 @@ class RequestRegistry { lock.withLock { requests[requestId] = request } } + func get(requestId: Int64) -> T? { + lock.withLock { requests[requestId] } + } + @discardableResult func remove(requestId: Int64) -> T? { lock.withLock { requests.removeValue(forKey: requestId) } diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift index 06e8da7191343..244cab13648ed 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -61,12 +61,15 @@ class LocalImageApiImpl: LocalImageApi { func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { let request = LocalImageRequest(callback: completion) + let request = LocalImageRequest { result in + Self.registry.remove(requestId: requestId) + completion(result) + } let operation = BlockOperation { if request.isCancelled { return } guard let asset = Self.requestAsset(assetId: assetId) else { - Self.registry.remove(requestId: requestId) return request.finish(with: .failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))) } @@ -90,13 +93,10 @@ class LocalImageApiImpl: LocalImageApi { if request.isCancelled { return } guard let data = imageData else { - Self.registry.remove(requestId: requestId) return request.finish(with: .failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil))) } - request.finish(encoding: data) - Self.registry.remove(requestId: requestId) - return + return request.finish(encoding: data) } var image: UIImage? @@ -114,7 +114,6 @@ class LocalImageApiImpl: LocalImageApi { guard let image = image, let cgImage = image.cgImage else { - Self.registry.remove(requestId: requestId) return request.finish(with: .failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) } @@ -122,9 +121,7 @@ class LocalImageApiImpl: LocalImageApi { do { try request.finish(cgImage: cgImage, format: &Self.rgbaFormat) - Self.registry.remove(requestId: requestId) } catch { - Self.registry.remove(requestId: requestId) return request.finish(with: .failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil))) } } diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index c4d6325871494..9924d23d5558b 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -43,6 +43,10 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { } let request = RemoteImageRequest(id: requestId, task: task, completion: completion) + let request = RemoteImageRequest(id: requestId, task: task) { result in + Self.registry.remove(requestId: requestId) + completion(result) + } Self.registry.add(requestId: requestId, request: request) @@ -50,7 +54,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { } private static func handleCompletion(requestId: Int64, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) { - guard let request = registry.remove(requestId: requestId) else { + guard let request = registry.get(requestId: requestId) else { return } From 44d0653ada6d266fd15a404186e622ed22a24d8e Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:34:36 +0200 Subject: [PATCH 07/15] add guard to cancel to prevent double onCancel calls --- mobile/ios/Runner/Images/ImageRequest.swift | 1 + mobile/ios/Runner/Images/RemoteImagesImpl.swift | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/ios/Runner/Images/ImageRequest.swift b/mobile/ios/Runner/Images/ImageRequest.swift index e0efa5a7637f2..869dfb99df25a 100644 --- a/mobile/ios/Runner/Images/ImageRequest.swift +++ b/mobile/ios/Runner/Images/ImageRequest.swift @@ -20,6 +20,7 @@ class ImageRequest { defer { callback = nil } return callback } + guard cb != nil else { return } onCancel() cb?(ImageProcessing.cancelledResult) } diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index 9924d23d5558b..03fe989192e02 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -42,7 +42,6 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error) } - let request = RemoteImageRequest(id: requestId, task: task, completion: completion) let request = RemoteImageRequest(id: requestId, task: task) { result in Self.registry.remove(requestId: requestId) completion(result) From 7a934ce8c532fc9cc119e0b74641a060cbd29de3 Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:16:22 +0200 Subject: [PATCH 08/15] fix duplicate code merge issue --- mobile/ios/Runner/Images/LocalImagesImpl.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift index 244cab13648ed..ec4d476ce07a7 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -60,7 +60,6 @@ class LocalImageApiImpl: LocalImageApi { } func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { - let request = LocalImageRequest(callback: completion) let request = LocalImageRequest { result in Self.registry.remove(requestId: requestId) completion(result) From 421bbb9a1053877d3c80b623ac1786c439b8e39f Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Mon, 6 Apr 2026 03:14:36 +0200 Subject: [PATCH 09/15] refactor(ios): enhance finish method to return callback status --- mobile/ios/Runner/Images/ImageRequest.swift | 31 ++++++++++----------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/mobile/ios/Runner/Images/ImageRequest.swift b/mobile/ios/Runner/Images/ImageRequest.swift index d2256ecf6844d..929e155dcd4b9 100644 --- a/mobile/ios/Runner/Images/ImageRequest.swift +++ b/mobile/ios/Runner/Images/ImageRequest.swift @@ -27,12 +27,15 @@ class ImageRequest { cb(ImageProcessing.cancelledResult) } - func finish(with result: Result<[String: Int64]?, any Error>) { - let cb = state.withLock { + /// Delivers the result to the callback. Returns true if the callback was called, false if it was already consumed. + @discardableResult + func finish(with result: Result<[String: Int64]?, any Error>) -> Bool { + guard let cb = state.withLock({ defer { $0.callback = nil } return $0.callback - } - cb?(result) + }) else { return false } + cb(result) + return true } func onCancel() {} @@ -41,28 +44,24 @@ class ImageRequest { let length = data.count let pointer = malloc(length)! data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length) - if isCancelled { - free(pointer) - return - } - finish(with: .success([ + if !finish(with: .success([ "pointer": Int64(Int(bitPattern: pointer)), "length": Int64(length), - ])) + ])) { + free(pointer) + } } func finish(cgImage: CGImage, format: inout vImage_CGImageFormat) throws { let buffer = try vImage_Buffer(cgImage: cgImage, format: format) - if isCancelled { - buffer.free() - return - } - finish(with: .success([ + if !finish(with: .success([ "pointer": Int64(Int(bitPattern: buffer.data)), "width": Int64(buffer.width), "height": Int64(buffer.height), "rowBytes": Int64(buffer.rowBytes), - ])) + ])) { + buffer.free() + } } } From d5e23c0a9d62c20951d4194abbd4ad1b72610828 Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Mon, 6 Apr 2026 03:46:43 +0200 Subject: [PATCH 10/15] remove unfitting methods form ImageRequest.swift and fix memory issue --- mobile/ios/Runner/Images/ImageRequest.swift | 45 +++++-------------- .../ios/Runner/Images/LocalImagesImpl.swift | 32 ++++++++++--- .../ios/Runner/Images/RemoteImagesImpl.swift | 42 ++++++++++++----- 3 files changed, 68 insertions(+), 51 deletions(-) diff --git a/mobile/ios/Runner/Images/ImageRequest.swift b/mobile/ios/Runner/Images/ImageRequest.swift index 929e155dcd4b9..eeca7cb542528 100644 --- a/mobile/ios/Runner/Images/ImageRequest.swift +++ b/mobile/ios/Runner/Images/ImageRequest.swift @@ -1,4 +1,3 @@ -import Accelerate import Foundation class ImageRequest { @@ -18,11 +17,12 @@ class ImageRequest { } func cancel() { - guard let cb = state.withLock({ - $0.isCancelled = true - defer { $0.callback = nil } - return $0.callback - }) else { return } + let cb = state.withLock { state in + state.isCancelled = true + defer { state.callback = nil } + return state.callback + } + guard let cb else { return } onCancel() cb(ImageProcessing.cancelledResult) } @@ -30,39 +30,16 @@ class ImageRequest { /// Delivers the result to the callback. Returns true if the callback was called, false if it was already consumed. @discardableResult func finish(with result: Result<[String: Int64]?, any Error>) -> Bool { - guard let cb = state.withLock({ - defer { $0.callback = nil } - return $0.callback - }) else { return false } + let cb = state.withLock { state in + defer { state.callback = nil } + return state.callback + } + guard let cb else { return false } cb(result) return true } func onCancel() {} - - func finish(encoding data: Data) { - let length = data.count - let pointer = malloc(length)! - data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length) - if !finish(with: .success([ - "pointer": Int64(Int(bitPattern: pointer)), - "length": Int64(length), - ])) { - free(pointer) - } - } - - func finish(cgImage: CGImage, format: inout vImage_CGImageFormat) throws { - let buffer = try vImage_Buffer(cgImage: cgImage, format: format) - if !finish(with: .success([ - "pointer": Int64(Int(bitPattern: buffer.data)), - "width": Int64(buffer.width), - "height": Int64(buffer.height), - "rowBytes": Int64(buffer.rowBytes), - ])) { - buffer.free() - } - } } class RequestRegistry { diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift index ec4d476ce07a7..73273dcd26da0 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -69,7 +69,8 @@ class LocalImageApiImpl: LocalImageApi { guard let asset = Self.requestAsset(assetId: assetId) else { - return request.finish(with: .failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))) + request.finish(with: .failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))) + return } if request.isCancelled { return } @@ -92,10 +93,20 @@ class LocalImageApiImpl: LocalImageApi { if request.isCancelled { return } guard let data = imageData else { - return request.finish(with: .failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil))) + request.finish(with: .failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil))) + return } - return request.finish(encoding: data) + let length = data.count + let pointer = malloc(length)! + data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length) + if !request.finish(with: .success([ + "pointer": Int64(Int(bitPattern: pointer)), + "length": Int64(length), + ])) { + free(pointer) + } + return } var image: UIImage? @@ -113,15 +124,24 @@ class LocalImageApiImpl: LocalImageApi { guard let image = image, let cgImage = image.cgImage else { - return request.finish(with: .failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) + request.finish(with: .failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) + return } if request.isCancelled { return } do { - try request.finish(cgImage: cgImage, format: &Self.rgbaFormat) + let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat) + if !request.finish(with: .success([ + "pointer": Int64(Int(bitPattern: buffer.data)), + "width": Int64(buffer.width), + "height": Int64(buffer.height), + "rowBytes": Int64(buffer.rowBytes), + ])) { + buffer.free() + } } catch { - return request.finish(with: .failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil))) + request.finish(with: .failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil))) } } diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index 03fe989192e02..04eabbcb8b186 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -61,34 +61,54 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { if let error = error { if (error as NSError).code == NSURLErrorCancelled { - return request.finish(with: ImageProcessing.cancelledResult) + request.finish(with: ImageProcessing.cancelledResult) + } else { + request.finish(with: .failure(error)) } - return request.finish(with: .failure(error)) + return } guard let data = data else { - return request.finish(with: .failure(PigeonError(code: "", message: "No data received", details: nil))) + request.finish(with: .failure(PigeonError(code: "", message: "No data received", details: nil))) + return + } + + if encoded { + let length = data.count + let pointer = malloc(length)! + data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length) + if !request.finish(with: .success([ + "pointer": Int64(Int(bitPattern: pointer)), + "length": Int64(length), + ])) { + free(pointer) + } + return } ImageProcessing.queue.addOperation { if request.isCancelled { return } - // Return raw encoded bytes when requested (for animated images) - if encoded { - return request.finish(encoding: data) - } - guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil), let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else { - return request.finish(with: .failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil))) + request.finish(with: .failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil))) + return } if request.isCancelled { return } do { - try request.finish(cgImage: cgImage, format: &rgbaFormat) + let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat) + if !request.finish(with: .success([ + "pointer": Int64(Int(bitPattern: buffer.data)), + "width": Int64(buffer.width), + "height": Int64(buffer.height), + "rowBytes": Int64(buffer.rowBytes), + ])) { + buffer.free() + } } catch { - return request.finish(with: .failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil))) + request.finish(with: .failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil))) } } } From b53243c8e9c6544a187b1b7538049f7a695ca488 Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Mon, 6 Apr 2026 04:32:25 +0200 Subject: [PATCH 11/15] revert bad merge --- mobile/ios/Runner/Images/ImageRequest.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/ios/Runner/Images/ImageRequest.swift b/mobile/ios/Runner/Images/ImageRequest.swift index eeca7cb542528..d5287f32ccdda 100644 --- a/mobile/ios/Runner/Images/ImageRequest.swift +++ b/mobile/ios/Runner/Images/ImageRequest.swift @@ -42,7 +42,7 @@ class ImageRequest { func onCancel() {} } -class RequestRegistry { +struct RequestRegistry: ~Copyable, Sendable { private let requests = Mutex<[Int64: T]>([:]) func add(requestId: Int64, request: T) { From 826388e44cb6106532ef6e2de3e885486f566b10 Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:23:57 +0200 Subject: [PATCH 12/15] refactor(ios): resolve cancellation issues --- mobile/ios/Runner/Images/ImageRequest.swift | 38 ++++------ .../ios/Runner/Images/LocalImagesImpl.swift | 73 ++++++++++++------- .../ios/Runner/Images/RemoteImagesImpl.swift | 73 +++++++++++-------- mobile/ios/Runner/Images/UnfairLock.swift | 23 ------ 4 files changed, 100 insertions(+), 107 deletions(-) delete mode 100644 mobile/ios/Runner/Images/UnfairLock.swift diff --git a/mobile/ios/Runner/Images/ImageRequest.swift b/mobile/ios/Runner/Images/ImageRequest.swift index d5287f32ccdda..f6c6cb801d6a5 100644 --- a/mobile/ios/Runner/Images/ImageRequest.swift +++ b/mobile/ios/Runner/Images/ImageRequest.swift @@ -1,42 +1,30 @@ import Foundation -class ImageRequest { - private struct State { +class ImageRequest: @unchecked Sendable { + private struct State: Sendable { var isCancelled = false - var callback: ((Result<[String: Int64]?, any Error>) -> Void)? } + let completion: @Sendable (Result<[String: Int64]?, any Error>) -> Void private let state: Mutex var isCancelled: Bool { - state.withLock { $0.isCancelled } + get { + state.withLock { $0.isCancelled } + } + set { + state.withLock { $0.isCancelled = newValue } + } } - init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) { - self.state = Mutex(State(callback: callback)) + init(completion: @escaping @Sendable (Result<[String: Int64]?, any Error>) -> Void) { + self.state = Mutex(State()) + self.completion = completion } func cancel() { - let cb = state.withLock { state in - state.isCancelled = true - defer { state.callback = nil } - return state.callback - } - guard let cb else { return } + isCancelled = true onCancel() - cb(ImageProcessing.cancelledResult) - } - - /// Delivers the result to the callback. Returns true if the callback was called, false if it was already consumed. - @discardableResult - func finish(with result: Result<[String: Int64]?, any Error>) -> Bool { - let cb = state.withLock { state in - defer { state.callback = nil } - return state.callback - } - guard let cb else { return false } - cb(result) - return true } func onCancel() {} diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift index 73273dcd26da0..27438ad4113da 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -3,11 +3,13 @@ import Flutter import MobileCoreServices import Photos -class LocalImageRequest: ImageRequest { +final class LocalImageRequest: ImageRequest { weak var operation: Operation? override func onCancel() { - operation?.cancel() + // only include if we can guarantee that for a queued operation that's + // cancelled we also call the callback with the cancelled result + // operation?.cancel() } } @@ -31,7 +33,7 @@ class LocalImageApiImpl: LocalImageApi { private static let registry = RequestRegistry() - private static var rgbaFormat = vImage_CGImageFormat( + private static let rgbaFormat = vImage_CGImageFormat( bitsPerComponent: 8, bitsPerPixel: 32, colorSpace: CGColorSpaceCreateDeviceRGB(), @@ -60,20 +62,21 @@ class LocalImageApiImpl: LocalImageApi { } func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { - let request = LocalImageRequest { result in - Self.registry.remove(requestId: requestId) - completion(result) - } + let request = LocalImageRequest(completion: completion) let operation = BlockOperation { - if request.isCancelled { return } + if request.isCancelled { + return request.completion(ImageProcessing.cancelledResult) + } guard let asset = Self.requestAsset(assetId: assetId) else { - request.finish(with: .failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))) - return + Self.registry.remove(requestId: requestId) + return request.completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))) } - if request.isCancelled { return } + if request.isCancelled { + return request.completion(ImageProcessing.cancelledResult) + } if preferEncoded { let dataOptions = PHImageRequestOptions() @@ -90,22 +93,29 @@ class LocalImageApiImpl: LocalImageApi { } ) - if request.isCancelled { return } + if request.isCancelled { + return request.completion(ImageProcessing.cancelledResult) + } guard let data = imageData else { - request.finish(with: .failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil))) - return + Self.registry.remove(requestId: requestId) + return request.completion(.failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil))) } let length = data.count let pointer = malloc(length)! data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length) - if !request.finish(with: .success([ - "pointer": Int64(Int(bitPattern: pointer)), - "length": Int64(length), - ])) { + + if request.isCancelled { free(pointer) + return request.completion(ImageProcessing.cancelledResult) } + + request.completion(.success([ + "pointer": Int64(Int(bitPattern: pointer)), + "length": Int64(length), + ])) + Self.registry.remove(requestId: requestId) return } @@ -120,29 +130,38 @@ class LocalImageApiImpl: LocalImageApi { } ) - if request.isCancelled { return } + if request.isCancelled { + return request.completion(ImageProcessing.cancelledResult) + } guard let image = image, let cgImage = image.cgImage else { - request.finish(with: .failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) - return + Self.registry.remove(requestId: requestId) + return request.completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) } - if request.isCancelled { return } + if request.isCancelled { + return request.completion(ImageProcessing.cancelledResult) + } do { let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat) - if !request.finish(with: .success([ + + if request.isCancelled { + buffer.free() + return request.completion(ImageProcessing.cancelledResult) + } + + request.completion(.success([ "pointer": Int64(Int(bitPattern: buffer.data)), "width": Int64(buffer.width), "height": Int64(buffer.height), "rowBytes": Int64(buffer.rowBytes), - ])) { - buffer.free() - } + ])) } catch { - request.finish(with: .failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil))) + request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil))) } + Self.registry.remove(requestId: requestId) } request.operation = operation diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index 04eabbcb8b186..d64d318d3d3df 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -3,24 +3,24 @@ import Flutter import MobileCoreServices import Photos -class RemoteImageRequest: ImageRequest { - weak var task: URLSessionDataTask? +final class RemoteImageRequest: ImageRequest { + let task: URLSessionDataTask let id: Int64 - init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { + init(id: Int64, task: URLSessionDataTask, completion: @escaping @Sendable (Result<[String: Int64]?, any Error>) -> Void) { self.id = id self.task = task - super.init(callback: completion) + super.init(completion: completion) } override func onCancel() { - task?.cancel() + task.cancel() } } class RemoteImageApiImpl: NSObject, RemoteImageApi { private static let registry = RequestRegistry() - private static var rgbaFormat = vImage_CGImageFormat( + private static let rgbaFormat = vImage_CGImageFormat( bitsPerComponent: 8, bitsPerPixel: 32, colorSpace: CGColorSpaceCreateDeviceRGB(), @@ -57,64 +57,73 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { return } - if request.isCancelled { return } + if request.isCancelled { + return request.completion(ImageProcessing.cancelledResult) + } if let error = error { - if (error as NSError).code == NSURLErrorCancelled { - request.finish(with: ImageProcessing.cancelledResult) - } else { - request.finish(with: .failure(error)) - } - return + return request.completion(.failure(error)) } guard let data = data else { - request.finish(with: .failure(PigeonError(code: "", message: "No data received", details: nil))) - return + return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil))) } if encoded { let length = data.count let pointer = malloc(length)! data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length) - if !request.finish(with: .success([ - "pointer": Int64(Int(bitPattern: pointer)), - "length": Int64(length), - ])) { + + if request.isCancelled { free(pointer) + return request.completion(ImageProcessing.cancelledResult) } - return + + return request.completion( + .success([ + "pointer": Int64(Int(bitPattern: pointer)), + "length": Int64(length), + ])) } ImageProcessing.queue.addOperation { - if request.isCancelled { return } + if request.isCancelled { + return request.completion(ImageProcessing.cancelledResult) + } guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil), let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else { - request.finish(with: .failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil))) - return + return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil))) } - if request.isCancelled { return } + if request.isCancelled { + return request.completion(ImageProcessing.cancelledResult) + } do { let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat) - if !request.finish(with: .success([ - "pointer": Int64(Int(bitPattern: buffer.data)), - "width": Int64(buffer.width), - "height": Int64(buffer.height), - "rowBytes": Int64(buffer.rowBytes), - ])) { + + if request.isCancelled { buffer.free() + return request.completion(ImageProcessing.cancelledResult) } + + request.completion( + .success([ + "pointer": Int64(Int(bitPattern: buffer.data)), + "width": Int64(buffer.width), + "height": Int64(buffer.height), + "rowBytes": Int64(buffer.rowBytes), + ])) } catch { - request.finish(with: .failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil))) + request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil))) } } } func cancelRequest(requestId: Int64) { - Self.registry.remove(requestId: requestId)?.cancel() + // For now don't call .remove here since we call completion in the handleCompletion method + Self.registry.get(requestId: requestId)?.cancel() } func clearCache(completion: @escaping (Result) -> Void) { diff --git a/mobile/ios/Runner/Images/UnfairLock.swift b/mobile/ios/Runner/Images/UnfairLock.swift deleted file mode 100644 index c94b013557ef9..0000000000000 --- a/mobile/ios/Runner/Images/UnfairLock.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -// Can be replaced with OSAllocatedUnfairLock when the deployment target is iOS 16+ -final class UnfairLock { - private let _lock: UnsafeMutablePointer - - init() { - _lock = .allocate(capacity: 1) - _lock.initialize(to: os_unfair_lock()) - } - - deinit { - _lock.deinitialize(count: 1) - _lock.deallocate() - } - - @discardableResult - func withLock(_ body: () throws -> T) rethrows -> T { - os_unfair_lock_lock(_lock) - defer { os_unfair_lock_unlock(_lock) } - return try body() - } -} From 0a38d29d20b8b741242fa45965bf73cacf8338ce Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:53:01 +0200 Subject: [PATCH 13/15] refactor(ios): streamline image request completion handling --- mobile/ios/Runner/Images/LocalImagesImpl.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift index 27438ad4113da..9c132b50feb70 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -111,12 +111,11 @@ class LocalImageApiImpl: LocalImageApi { return request.completion(ImageProcessing.cancelledResult) } - request.completion(.success([ + Self.registry.remove(requestId: requestId) + return request.completion(.success([ "pointer": Int64(Int(bitPattern: pointer)), "length": Int64(length), ])) - Self.registry.remove(requestId: requestId) - return } var image: UIImage? @@ -152,16 +151,17 @@ class LocalImageApiImpl: LocalImageApi { return request.completion(ImageProcessing.cancelledResult) } - request.completion(.success([ + Self.registry.remove(requestId: requestId) + return request.completion(.success([ "pointer": Int64(Int(bitPattern: buffer.data)), "width": Int64(buffer.width), "height": Int64(buffer.height), "rowBytes": Int64(buffer.rowBytes), ])) } catch { - request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil))) + Self.registry.remove(requestId: requestId) + return request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil))) } - Self.registry.remove(requestId: requestId) } request.operation = operation From c3a98a8463637d8e2310c2fa5526e59a74fa3cb4 Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:11:34 +0200 Subject: [PATCH 14/15] add return statements --- mobile/ios/Runner/Images/RemoteImagesImpl.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index d64d318d3d3df..48bd02dcbe34f 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -108,7 +108,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { return request.completion(ImageProcessing.cancelledResult) } - request.completion( + return request.completion( .success([ "pointer": Int64(Int(bitPattern: buffer.data)), "width": Int64(buffer.width), @@ -116,7 +116,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { "rowBytes": Int64(buffer.rowBytes), ])) } catch { - request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil))) + return request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil))) } } } From 7b206dc9ed0bdd015816e0a315c2753970fa462e Mon Sep 17 00:00:00 2001 From: LeLunZ <31982496+LeLunZ@users.noreply.github.com> Date: Fri, 10 Apr 2026 02:10:29 +0200 Subject: [PATCH 15/15] refactor(ios): simplify image request cancellation and registry handling --- mobile/ios/Runner/Images/ImageRequest.swift | 7 ---- .../ios/Runner/Images/LocalImagesImpl.swift | 15 ++------ .../ios/Runner/Images/RemoteImagesImpl.swift | 36 +++++++++---------- 3 files changed, 19 insertions(+), 39 deletions(-) diff --git a/mobile/ios/Runner/Images/ImageRequest.swift b/mobile/ios/Runner/Images/ImageRequest.swift index f6c6cb801d6a5..6c8bb04c70dfd 100644 --- a/mobile/ios/Runner/Images/ImageRequest.swift +++ b/mobile/ios/Runner/Images/ImageRequest.swift @@ -24,10 +24,7 @@ class ImageRequest: @unchecked Sendable { func cancel() { isCancelled = true - onCancel() } - - func onCancel() {} } struct RequestRegistry: ~Copyable, Sendable { @@ -37,10 +34,6 @@ struct RequestRegistry: ~Copyable, Sendable { requests.withLock { $0[requestId] = request } } - func get(requestId: Int64) -> T? { - requests.withLock { $0[requestId] } - } - @discardableResult func remove(requestId: Int64) -> T? { requests.withLock { $0.removeValue(forKey: requestId) } diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift index 9c132b50feb70..9c142da054bf9 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -3,16 +3,6 @@ import Flutter import MobileCoreServices import Photos -final class LocalImageRequest: ImageRequest { - weak var operation: Operation? - - override func onCancel() { - // only include if we can guarantee that for a queued operation that's - // cancelled we also call the callback with the cancelled result - // operation?.cancel() - } -} - class LocalImageApiImpl: LocalImageApi { private static let imageManager = PHImageManager.default() private static let fetchOptions = { @@ -31,7 +21,7 @@ class LocalImageApiImpl: LocalImageApi { return requestOptions }() - private static let registry = RequestRegistry() + private static let registry = RequestRegistry() private static let rgbaFormat = vImage_CGImageFormat( bitsPerComponent: 8, @@ -62,7 +52,7 @@ class LocalImageApiImpl: LocalImageApi { } func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { - let request = LocalImageRequest(completion: completion) + let request = ImageRequest(completion: completion) let operation = BlockOperation { if request.isCancelled { return request.completion(ImageProcessing.cancelledResult) @@ -164,7 +154,6 @@ class LocalImageApiImpl: LocalImageApi { } } - request.operation = operation Self.registry.add(requestId: requestId, request: request) ImageProcessing.queue.addOperation(operation) } diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index 48bd02dcbe34f..de1f6dec891a0 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -4,17 +4,17 @@ import MobileCoreServices import Photos final class RemoteImageRequest: ImageRequest { - let task: URLSessionDataTask + var task: URLSessionDataTask? let id: Int64 - init(id: Int64, task: URLSessionDataTask, completion: @escaping @Sendable (Result<[String: Int64]?, any Error>) -> Void) { + init(id: Int64, completion: @escaping @Sendable (Result<[String: Int64]?, any Error>) -> Void) { self.id = id - self.task = task super.init(completion: completion) } - override func onCancel() { - task.cancel() + override func cancel() { + super.cancel() + task?.cancel() } } @@ -38,34 +38,29 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { var urlRequest = URLRequest(url: URL(string: url)!) urlRequest.cachePolicy = .returnCacheDataElseLoad - let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in - Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error) - } + let request = RemoteImageRequest(id: requestId, completion: completion) - let request = RemoteImageRequest(id: requestId, task: task) { result in - Self.registry.remove(requestId: requestId) - completion(result) + let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in + Self.handleCompletion(request: request, encoded: preferEncoded, data: data, response: response, error: error) } + request.task = task Self.registry.add(requestId: requestId, request: request) - task.resume() } - private static func handleCompletion(requestId: Int64, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) { - guard let request = registry.get(requestId: requestId) else { - return - } - + private static func handleCompletion(request: RemoteImageRequest, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) { if request.isCancelled { return request.completion(ImageProcessing.cancelledResult) } if let error = error { + registry.remove(requestId: request.id) return request.completion(.failure(error)) } guard let data = data else { + registry.remove(requestId: request.id) return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil))) } @@ -79,6 +74,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { return request.completion(ImageProcessing.cancelledResult) } + registry.remove(requestId: request.id) return request.completion( .success([ "pointer": Int64(Int(bitPattern: pointer)), @@ -93,6 +89,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil), let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else { + registry.remove(requestId: request.id) return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil))) } @@ -108,6 +105,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { return request.completion(ImageProcessing.cancelledResult) } + registry.remove(requestId: request.id) return request.completion( .success([ "pointer": Int64(Int(bitPattern: buffer.data)), @@ -116,14 +114,14 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { "rowBytes": Int64(buffer.rowBytes), ])) } catch { + registry.remove(requestId: request.id) return request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil))) } } } func cancelRequest(requestId: Int64) { - // For now don't call .remove here since we call completion in the handleCompletion method - Self.registry.get(requestId: requestId)?.cancel() + Self.registry.remove(requestId: requestId)?.cancel() } func clearCache(completion: @escaping (Result) -> Void) {