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/ImageRequest.swift b/mobile/ios/Runner/Images/ImageRequest.swift new file mode 100644 index 0000000000000..e480425f9506c --- /dev/null +++ b/mobile/ios/Runner/Images/ImageRequest.swift @@ -0,0 +1,91 @@ +import Accelerate +import Foundation + +class ImageRequest { + private let lock = UnfairLock() + private var _isCancelled = false + private var callback: ((Result<[String: Int64]?, any Error>) -> Void)? + + var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return _isCancelled + } + + init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) { + self.callback = callback + } + + func cancel() { + lock.lock() + _isCancelled = true + let cb = callback + callback = nil + lock.unlock() + + onCancel() + cb?(ImageProcessing.cancelledResult) + } + + func finish(with result: Result<[String: Int64]?, any Error>) { + lock.lock() + let cb = callback + callback = nil + lock.unlock() + + cb?(result) + } + + func onCancel() {} + + func encodeToPointer(_ 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 cgImageToPointer(_ cgImage: CGImage, format: vImage_CGImageFormat) throws { + var fmt = format + let buffer = try vImage_Buffer(cgImage: cgImage, format: fmt) + 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]() + + func add(requestId: Int64, request: T) { + lock.lock() + requests[requestId] = request + lock.unlock() + } + + @discardableResult + func remove(requestId: Int64) -> T? { + lock.lock() + defer { lock.unlock() } + return 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 f0710bf75f14a..c6d413c4a340a 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -3,13 +3,11 @@ 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 + override func onCancel() { + operation?.cancel() } } @@ -31,18 +29,15 @@ 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( + private static let rgbaFormat = vImage_CGImageFormat( bitsPerComponent: 8, bitsPerPixel: 32, colorSpace: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), renderingIntent: .defaultIntent )! - private static var requests = [Int64: LocalImageRequest]() private static let assetCache = { let assetCache = NSCache() assetCache.countLimit = 10000 @@ -67,19 +62,17 @@ 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.remove(requestId: requestId) - completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))) - return + Self.registry.remove(requestId: requestId) + return request.finish(with: .failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))) } if request.isCancelled { - return completion(ImageProcessing.cancelledResult) + Self.registry.remove(requestId: requestId) + return } if preferEncoded { @@ -98,30 +91,17 @@ class LocalImageApiImpl: LocalImageApi { ) if request.isCancelled { - Self.remove(requestId: requestId) - return completion(ImageProcessing.cancelledResult) + Self.registry.remove(requestId: requestId) + return } guard let data = imageData else { - Self.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) - Self.remove(requestId: requestId) - return completion(ImageProcessing.cancelledResult) + Self.registry.remove(requestId: requestId) + 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), - ])) - Self.remove(requestId: requestId) + request.encodeToPointer(data) + Self.registry.remove(requestId: requestId) return } @@ -137,76 +117,47 @@ class LocalImageApiImpl: LocalImageApi { ) if request.isCancelled { - return completion(ImageProcessing.cancelledResult) + Self.registry.remove(requestId: requestId) + return } guard let image = image, let cgImage = image.cgImage else { - Self.remove(requestId: requestId) - return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) + Self.registry.remove(requestId: requestId) + return request.finish(with: .failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) } if request.isCancelled { - return completion(ImageProcessing.cancelledResult) + Self.registry.remove(requestId: requestId) + 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) - ])) - Self.remove(requestId: requestId) + try request.cgImageToPointer(cgImage, format: Self.rgbaFormat) + Self.registry.remove(requestId: requestId) } catch { - Self.remove(requestId: requestId) - return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil))) + Self.registry.remove(requestId: requestId) + return request.finish(with: .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..97a2332bfca2a 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -3,23 +3,24 @@ 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) + } + + override func onCancel() { + task?.cancel() } } class RemoteImageApiImpl: NSObject, RemoteImageApi { - private static var lock = os_unfair_lock() - private static var requests = [Int64: RemoteImageRequest]() - private static var rgbaFormat = vImage_CGImageFormat( + private static let registry = RequestRegistry() + private static let rgbaFormat = vImage_CGImageFormat( bitsPerComponent: 8, bitsPerPixel: 32, colorSpace: CGColorSpaceCreateDeviceRGB(), @@ -43,97 +44,54 @@ 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 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.encodeToPointer(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.cgImageToPointer(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))) } } } 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) { diff --git a/mobile/ios/Runner/Images/UnfairLock.swift b/mobile/ios/Runner/Images/UnfairLock.swift new file mode 100644 index 0000000000000..24cd9c0f265c4 --- /dev/null +++ b/mobile/ios/Runner/Images/UnfairLock.swift @@ -0,0 +1,19 @@ +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() + } + + func lock() { os_unfair_lock_lock(_lock) } + func unlock() { os_unfair_lock_unlock(_lock) } +}