From ac5ef6a56d22542cfff6f7a778bed42fb7d9247e Mon Sep 17 00:00:00 2001 From: Luis Nachtigall <31982496+LeLunZ@users.noreply.github.com> Date: Sat, 28 Feb 2026 17:43:58 +0100 Subject: [PATCH] feat(mobile): add support for encoded image requests in local/remote image APIs (#26584) * feat(mobile): add support for encoded image requests in local and remote image APIs * fix(mobile): handle memory cleanup for cancelled image requests * refactor(mobile): simplify memory management and response handling for encoded image requests * fix(mobile): correct formatting in cancellation check for image requests * Apply suggestion from @mertalev Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com> * refactor(mobile): rename 'encoded' parameter to 'preferEncoded' for clarity in image request APIs * fix(mobile): ensure proper resource cleanup for cancelled image requests * refactor(mobile): streamline codec handling by removing unnecessary descriptor disposal in loadCodec request --------- Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com> --- .../alextran/immich/images/LocalImages.g.kt | 5 +- .../alextran/immich/images/LocalImagesImpl.kt | 37 +++++++- .../alextran/immich/images/RemoteImages.g.kt | 5 +- .../immich/images/RemoteImagesImpl.kt | 1 + mobile/ios/Runner/Images/LocalImages.g.swift | 5 +- .../ios/Runner/Images/LocalImagesImpl.swift | 93 ++++++++++++++----- mobile/ios/Runner/Images/RemoteImages.g.swift | 5 +- .../ios/Runner/Images/RemoteImagesImpl.swift | 64 ++++++++----- .../infrastructure/loaders/image_request.dart | 18 +++- .../loaders/local_image_request.dart | 21 +++++ .../loaders/remote_image_request.dart | 16 +++- .../loaders/thumbhash_image_request.dart | 3 + mobile/lib/platform/local_image_api.g.dart | 2 + mobile/lib/platform/remote_image_api.g.dart | 8 +- .../widgets/images/image_provider.dart | 25 +++++ mobile/pigeon/local_image_api.dart | 1 + mobile/pigeon/remote_image_api.dart | 1 + 17 files changed, 250 insertions(+), 60 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt index 5b95daf38b..7d998c2f48 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt @@ -59,7 +59,7 @@ private open class LocalImagesPigeonCodec : StandardMessageCodec() { /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface LocalImageApi { - fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, callback: (Result?>) -> Unit) + fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, preferEncoded: Boolean, callback: (Result?>) -> Unit) fun cancelRequest(requestId: Long) fun getThumbhash(thumbhash: String, callback: (Result>) -> Unit) @@ -82,7 +82,8 @@ interface LocalImageApi { val widthArg = args[2] as Long val heightArg = args[3] as Long val isVideoArg = args[4] as Boolean - api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result?> -> + val preferEncodedArg = args[5] as Boolean + api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg, preferEncodedArg) { result: Result?> -> val error = result.exceptionOrNull() if (error != null) { reply.reply(LocalImagesPigeonUtils.wrapError(error)) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt index 64e67cbfee..3babad2e37 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt @@ -14,6 +14,7 @@ import android.util.Size import androidx.annotation.RequiresApi import app.alextran.immich.NativeBuffer import kotlin.math.* +import java.io.IOException import java.util.concurrent.Executors import com.bumptech.glide.Glide import com.bumptech.glide.Priority @@ -99,12 +100,17 @@ class LocalImagesImpl(context: Context) : LocalImageApi { width: Long, height: Long, isVideo: Boolean, + preferEncoded: Boolean, callback: (Result?>) -> Unit ) { val signal = CancellationSignal() val task = threadPool.submit { try { - getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal) + if (preferEncoded) { + getEncodedImageInternal(assetId, callback, signal) + } else { + getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal) + } } catch (e: Exception) { when (e) { is OperationCanceledException -> callback(CANCELLED) @@ -133,6 +139,35 @@ class LocalImagesImpl(context: Context) : LocalImageApi { } } + private fun getEncodedImageInternal( + assetId: String, + callback: (Result?>) -> Unit, + signal: CancellationSignal + ) { + signal.throwIfCanceled() + val id = assetId.toLong() + val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id) + + signal.throwIfCanceled() + val bytes = resolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw IOException("Could not read image data for $assetId") + + signal.throwIfCanceled() + val pointer = NativeBuffer.allocate(bytes.size) + try { + val buffer = NativeBuffer.wrap(pointer, bytes.size) + buffer.put(bytes) + signal.throwIfCanceled() + callback(Result.success(mapOf( + "pointer" to pointer, + "length" to bytes.size.toLong() + ))) + } catch (e: Exception) { + NativeBuffer.free(pointer) + throw e + } + } + private fun getThumbnailBufferInternal( assetId: String, width: Long, diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt index 0e3cf19657..a04dedb676 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt @@ -47,7 +47,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() { /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface RemoteImageApi { - fun requestImage(url: String, headers: Map, requestId: Long, callback: (Result?>) -> Unit) + fun requestImage(url: String, headers: Map, requestId: Long, preferEncoded: Boolean, callback: (Result?>) -> Unit) fun cancelRequest(requestId: Long) fun clearCache(callback: (Result) -> Unit) @@ -68,7 +68,8 @@ interface RemoteImageApi { val urlArg = args[0] as String val headersArg = args[1] as Map val requestIdArg = args[2] as Long - api.requestImage(urlArg, headersArg, requestIdArg) { result: Result?> -> + val preferEncodedArg = args[3] as Boolean + api.requestImage(urlArg, headersArg, requestIdArg, preferEncodedArg) { result: Result?> -> val error = result.exceptionOrNull() if (error != null) { reply.reply(RemoteImagesPigeonUtils.wrapError(error)) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index 04a181cd6e..6b15f33414 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -51,6 +51,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { url: String, headers: Map, requestId: Long, + @Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android callback: (Result?>) -> Unit ) { val signal = CancellationSignal() diff --git a/mobile/ios/Runner/Images/LocalImages.g.swift b/mobile/ios/Runner/Images/LocalImages.g.swift index d417f10222..146950cd51 100644 --- a/mobile/ios/Runner/Images/LocalImages.g.swift +++ b/mobile/ios/Runner/Images/LocalImages.g.swift @@ -70,7 +70,7 @@ class LocalImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol LocalImageApi { - func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) + func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) func cancelRequest(requestId: Int64) throws func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void) } @@ -90,7 +90,8 @@ class LocalImageApiSetup { let widthArg = args[2] as! Int64 let heightArg = args[3] as! Int64 let isVideoArg = args[4] as! Bool - api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg) { result in + let preferEncodedArg = args[5] as! Bool + api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg, preferEncoded: preferEncodedArg) { result in switch result { case .success(let res): reply(wrapResult(res)) diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift index 96e1b60a2f..303ff5bc33 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -7,7 +7,7 @@ class LocalImageRequest { weak var workItem: DispatchWorkItem? var isCancelled = false let callback: (Result<[String: Int64]?, any Error>) -> Void - + init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) { self.callback = callback } @@ -30,11 +30,11 @@ class LocalImageApiImpl: LocalImageApi { requestOptions.version = .current 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 var rgbaFormat = vImage_CGImageFormat( bitsPerComponent: 8, bitsPerPixel: 32, @@ -48,12 +48,12 @@ class LocalImageApiImpl: LocalImageApi { assetCache.countLimit = 10000 return assetCache }() - + func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) { ImageProcessing.queue.async { guard let data = Data(base64Encoded: thumbhash) else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))} - + let (width, height, pointer) = thumbHashToRGBA(hash: data) completion(.success([ "pointer": Int64(Int(bitPattern: pointer.baseAddress)), @@ -63,34 +63,77 @@ class LocalImageApiImpl: LocalImageApi { ])) } } - - func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { + + 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() } - + if request.isCancelled { return completion(ImageProcessing.cancelledResult) } - + 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 } - + if request.isCancelled { return completion(ImageProcessing.cancelledResult) } - + + if preferEncoded { + let dataOptions = PHImageRequestOptions() + dataOptions.isNetworkAccessAllowed = true + dataOptions.isSynchronous = true + dataOptions.version = .current + + var imageData: Data? + Self.imageManager.requestImageDataAndOrientation( + for: asset, + options: dataOptions, + resultHandler: { (data, _, _, _) in + imageData = data + } + ) + + if request.isCancelled { + Self.remove(requestId: requestId) + return completion(ImageProcessing.cancelledResult) + } + + 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) + } + + request.callback(.success([ + "pointer": Int64(Int(bitPattern: pointer)), + "length": Int64(length), + ])) + Self.remove(requestId: requestId) + return + } + var image: UIImage? Self.imageManager.requestImage( for: asset, @@ -101,29 +144,29 @@ class LocalImageApiImpl: LocalImageApi { image = _image } ) - + if request.isCancelled { return completion(ImageProcessing.cancelledResult) } - + 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))) } - + if request.isCancelled { return completion(ImageProcessing.cancelledResult) } - + 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), @@ -136,24 +179,24 @@ class LocalImageApiImpl: LocalImageApi { return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil))) } } - + request.workItem = item Self.add(requestId: requestId, request: request) ImageProcessing.queue.async(execute: item) } - + 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 } @@ -164,12 +207,12 @@ class LocalImageApiImpl: LocalImageApi { } } } - + private static func requestAsset(assetId: String) -> PHAsset? { var asset: PHAsset? assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) } if asset != nil { return asset } - + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject else { return nil } assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) } diff --git a/mobile/ios/Runner/Images/RemoteImages.g.swift b/mobile/ios/Runner/Images/RemoteImages.g.swift index fc83b09d4b..5123a12f3e 100644 --- a/mobile/ios/Runner/Images/RemoteImages.g.swift +++ b/mobile/ios/Runner/Images/RemoteImages.g.swift @@ -70,7 +70,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol RemoteImageApi { - func requestImage(url: String, headers: [String: String], requestId: Int64, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) + func requestImage(url: String, headers: [String: String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) func cancelRequest(requestId: Int64) throws func clearCache(completion: @escaping (Result) -> Void) } @@ -88,7 +88,8 @@ class RemoteImageApiSetup { let urlArg = args[0] as! String let headersArg = args[1] as! [String: String] let requestIdArg = args[2] as! Int64 - api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg) { result in + let preferEncodedArg = args[3] as! Bool + api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in switch result { case .success(let res): reply(wrapResult(res)) diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index 56e8938521..fe318800b8 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -8,7 +8,7 @@ class RemoteImageRequest { 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 @@ -32,75 +32,93 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceCreateThumbnailFromImageAlways: true ] as CFDictionary - - func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { + + func requestImage(url: String, headers: [String : String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { var urlRequest = URLRequest(url: URL(string: url)!) urlRequest.cachePolicy = .returnCacheDataElseLoad for (key, value) in headers { urlRequest.setValue(value, forHTTPHeaderField: key) } - + let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in - Self.handleCompletion(requestId: requestId, data: data, response: response, error: error) + Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error) } - + 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) - + task.resume() } - - private static func handleCompletion(requestId: Int64, data: Data?, response: URLResponse?, error: Error?) { + + 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) } requests[requestId] = nil os_unfair_lock_unlock(&Self.lock) - + if let error = error { if request.isCancelled || (error as NSError).code == NSURLErrorCancelled { return request.completion(ImageProcessing.cancelledResult) } return request.completion(.failure(error)) } - + if request.isCancelled { return request.completion(ImageProcessing.cancelledResult) } - + guard let data = data else { return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil))) } - + ImageProcessing.queue.async { ImageProcessing.semaphore.wait() defer { ImageProcessing.semaphore.signal() } - + if request.isCancelled { return request.completion(ImageProcessing.cancelledResult) } - + + // 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), + ])) + } + 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))) } - + if request.isCancelled { return request.completion(ImageProcessing.cancelledResult) } - + 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)), @@ -113,17 +131,17 @@ 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() } - + func clearCache(completion: @escaping (Result) -> Void) { Task { let cache = URLSessionManager.shared.session.configuration.urlCache! diff --git a/mobile/lib/infrastructure/loaders/image_request.dart b/mobile/lib/infrastructure/loaders/image_request.dart index 5be7b57835..4df470277e 100644 --- a/mobile/lib/infrastructure/loaders/image_request.dart +++ b/mobile/lib/infrastructure/loaders/image_request.dart @@ -24,6 +24,8 @@ abstract class ImageRequest { Future load(ImageDecoderCallback decode, {double scale = 1.0}); + Future loadCodec(); + void cancel() { if (_isCancelled) { return; @@ -34,7 +36,7 @@ abstract class ImageRequest { void _onCancelled(); - Future _fromEncodedPlatformImage(int address, int length) async { + Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage(int address, int length) async { final pointer = Pointer.fromAddress(address); if (_isCancelled) { malloc.free(pointer); @@ -67,6 +69,20 @@ abstract class ImageRequest { return null; } + return (codec, descriptor); + } + + Future _fromEncodedPlatformImage(int address, int length) async { + final result = await _codecFromEncodedPlatformImage(address, length); + if (result == null) return null; + + final (codec, descriptor) = result; + if (_isCancelled) { + descriptor.dispose(); + codec.dispose(); + return null; + } + final frame = await codec.getNextFrame(); descriptor.dispose(); codec.dispose(); diff --git a/mobile/lib/infrastructure/loaders/local_image_request.dart b/mobile/lib/infrastructure/loaders/local_image_request.dart index c2e3165aad..a6c9fa2989 100644 --- a/mobile/lib/infrastructure/loaders/local_image_request.dart +++ b/mobile/lib/infrastructure/loaders/local_image_request.dart @@ -22,6 +22,7 @@ class LocalImageRequest extends ImageRequest { width: width, height: height, isVideo: assetType == AssetType.video, + preferEncoded: false, ); if (info == null) { return null; @@ -31,6 +32,26 @@ class LocalImageRequest extends ImageRequest { return frame == null ? null : ImageInfo(image: frame.image, scale: scale); } + @override + Future loadCodec() async { + if (_isCancelled) { + return null; + } + + final info = await localImageApi.requestImage( + localId, + requestId: requestId, + width: width, + height: height, + isVideo: assetType == AssetType.video, + preferEncoded: true, + ); + if (info == null) return null; + + final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null); + return codec; + } + @override Future _onCancelled() { return localImageApi.cancelRequest(requestId); diff --git a/mobile/lib/infrastructure/loaders/remote_image_request.dart b/mobile/lib/infrastructure/loaders/remote_image_request.dart index 2da70c3ae1..40ed304bbe 100644 --- a/mobile/lib/infrastructure/loaders/remote_image_request.dart +++ b/mobile/lib/infrastructure/loaders/remote_image_request.dart @@ -12,7 +12,8 @@ class RemoteImageRequest extends ImageRequest { return null; } - final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId); + final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: false); + // Android always returns encoded data, so we need to check for both shapes of the response. final frame = switch (info) { {'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length), {'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} => @@ -22,6 +23,19 @@ class RemoteImageRequest extends ImageRequest { return frame == null ? null : ImageInfo(image: frame.image, scale: scale); } + @override + Future loadCodec() async { + if (_isCancelled) { + return null; + } + + final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: true); + if (info == null) return null; + + final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null); + return codec; + } + @override Future _onCancelled() { return remoteImageApi.cancelRequest(requestId); diff --git a/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart b/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart index 2ced28b810..61e6a1b3ad 100644 --- a/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart +++ b/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart @@ -16,6 +16,9 @@ class ThumbhashImageRequest extends ImageRequest { return frame == null ? null : ImageInfo(image: frame.image, scale: scale); } + @override + Future loadCodec() => throw UnsupportedError('Thumbhash does not support codec loading'); + @override void _onCancelled() {} } diff --git a/mobile/lib/platform/local_image_api.g.dart b/mobile/lib/platform/local_image_api.g.dart index 8b7c82f15d..f23cb86ced 100644 --- a/mobile/lib/platform/local_image_api.g.dart +++ b/mobile/lib/platform/local_image_api.g.dart @@ -55,6 +55,7 @@ class LocalImageApi { required int width, required int height, required bool isVideo, + required bool preferEncoded, }) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix'; @@ -69,6 +70,7 @@ class LocalImageApi { width, height, isVideo, + preferEncoded, ]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { diff --git a/mobile/lib/platform/remote_image_api.g.dart b/mobile/lib/platform/remote_image_api.g.dart index 410db03ece..24390293c9 100644 --- a/mobile/lib/platform/remote_image_api.g.dart +++ b/mobile/lib/platform/remote_image_api.g.dart @@ -53,6 +53,7 @@ class RemoteImageApi { String url, { required Map headers, required int requestId, + required bool preferEncoded, }) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix'; @@ -61,7 +62,12 @@ class RemoteImageApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([url, headers, requestId]); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([ + url, + headers, + requestId, + preferEncoded, + ]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index c3cda46e81..259ac824bb 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -1,3 +1,5 @@ +import 'dart:ui' as ui; + import 'package:async/async.dart'; import 'package:flutter/widgets.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -75,6 +77,29 @@ mixin CancellableImageProviderMixin on CancellableImageProvide } } + Future loadCodecRequest(ImageRequest request) async { + if (isCancelled) { + this.request = null; + PaintingBinding.instance.imageCache.evict(this); + return null; + } + + try { + final codec = await request.loadCodec(); + if (codec == null || isCancelled) { + codec?.dispose(); + PaintingBinding.instance.imageCache.evict(this); + return null; + } + return codec; + } catch (e) { + PaintingBinding.instance.imageCache.evict(this); + rethrow; + } finally { + this.request = null; + } + } + Stream initialImageStream() async* { final cachedOperation = this.cachedOperation; if (cachedOperation == null) { diff --git a/mobile/pigeon/local_image_api.dart b/mobile/pigeon/local_image_api.dart index 35b6734568..eb538d7b1a 100644 --- a/mobile/pigeon/local_image_api.dart +++ b/mobile/pigeon/local_image_api.dart @@ -21,6 +21,7 @@ abstract class LocalImageApi { required int width, required int height, required bool isVideo, + required bool preferEncoded, }); void cancelRequest(int requestId); diff --git a/mobile/pigeon/remote_image_api.dart b/mobile/pigeon/remote_image_api.dart index 749deb828e..333f65a225 100644 --- a/mobile/pigeon/remote_image_api.dart +++ b/mobile/pigeon/remote_image_api.dart @@ -19,6 +19,7 @@ abstract class RemoteImageApi { String url, { required Map headers, required int requestId, + required bool preferEncoded, }); void cancelRequest(int requestId);