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>
This commit is contained in:
Luis Nachtigall
2026-02-28 17:43:58 +01:00
committed by GitHub
parent d6c724b13b
commit ac5ef6a56d
17 changed files with 250 additions and 60 deletions

View File

@@ -59,7 +59,7 @@ private open class LocalImagesPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface LocalImageApi { interface LocalImageApi {
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, callback: (Result<Map<String, Long>?>) -> Unit) fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
fun cancelRequest(requestId: Long) fun cancelRequest(requestId: Long)
fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit) fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit)
@@ -82,7 +82,8 @@ interface LocalImageApi {
val widthArg = args[2] as Long val widthArg = args[2] as Long
val heightArg = args[3] as Long val heightArg = args[3] as Long
val isVideoArg = args[4] as Boolean val isVideoArg = args[4] as Boolean
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result<Map<String, Long>?> -> val preferEncodedArg = args[5] as Boolean
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull() val error = result.exceptionOrNull()
if (error != null) { if (error != null) {
reply.reply(LocalImagesPigeonUtils.wrapError(error)) reply.reply(LocalImagesPigeonUtils.wrapError(error))

View File

@@ -14,6 +14,7 @@ import android.util.Size
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import app.alextran.immich.NativeBuffer import app.alextran.immich.NativeBuffer
import kotlin.math.* import kotlin.math.*
import java.io.IOException
import java.util.concurrent.Executors import java.util.concurrent.Executors
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.Priority import com.bumptech.glide.Priority
@@ -99,12 +100,17 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
width: Long, width: Long,
height: Long, height: Long,
isVideo: Boolean, isVideo: Boolean,
preferEncoded: Boolean,
callback: (Result<Map<String, Long>?>) -> Unit callback: (Result<Map<String, Long>?>) -> Unit
) { ) {
val signal = CancellationSignal() val signal = CancellationSignal()
val task = threadPool.submit { val task = threadPool.submit {
try { 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) { } catch (e: Exception) {
when (e) { when (e) {
is OperationCanceledException -> callback(CANCELLED) is OperationCanceledException -> callback(CANCELLED)
@@ -133,6 +139,35 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
} }
} }
private fun getEncodedImageInternal(
assetId: String,
callback: (Result<Map<String, Long>?>) -> 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( private fun getThumbnailBufferInternal(
assetId: String, assetId: String,
width: Long, width: Long,

View File

@@ -47,7 +47,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface RemoteImageApi { interface RemoteImageApi {
fun requestImage(url: String, headers: Map<String, String>, requestId: Long, callback: (Result<Map<String, Long>?>) -> Unit) fun requestImage(url: String, headers: Map<String, String>, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
fun cancelRequest(requestId: Long) fun cancelRequest(requestId: Long)
fun clearCache(callback: (Result<Long>) -> Unit) fun clearCache(callback: (Result<Long>) -> Unit)
@@ -68,7 +68,8 @@ interface RemoteImageApi {
val urlArg = args[0] as String val urlArg = args[0] as String
val headersArg = args[1] as Map<String, String> val headersArg = args[1] as Map<String, String>
val requestIdArg = args[2] as Long val requestIdArg = args[2] as Long
api.requestImage(urlArg, headersArg, requestIdArg) { result: Result<Map<String, Long>?> -> val preferEncodedArg = args[3] as Boolean
api.requestImage(urlArg, headersArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull() val error = result.exceptionOrNull()
if (error != null) { if (error != null) {
reply.reply(RemoteImagesPigeonUtils.wrapError(error)) reply.reply(RemoteImagesPigeonUtils.wrapError(error))

View File

@@ -51,6 +51,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
url: String, url: String,
headers: Map<String, String>, headers: Map<String, String>,
requestId: Long, requestId: Long,
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
callback: (Result<Map<String, Long>?>) -> Unit callback: (Result<Map<String, Long>?>) -> Unit
) { ) {
val signal = CancellationSignal() val signal = CancellationSignal()

View File

@@ -70,7 +70,7 @@ class LocalImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
/// Generated protocol from Pigeon that represents a handler of messages from Flutter. /// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol LocalImageApi { 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 cancelRequest(requestId: Int64) throws
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void) func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void)
} }
@@ -90,7 +90,8 @@ class LocalImageApiSetup {
let widthArg = args[2] as! Int64 let widthArg = args[2] as! Int64
let heightArg = args[3] as! Int64 let heightArg = args[3] as! Int64
let isVideoArg = args[4] as! Bool 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 { switch result {
case .success(let res): case .success(let res):
reply(wrapResult(res)) reply(wrapResult(res))

View File

@@ -7,7 +7,7 @@ class LocalImageRequest {
weak var workItem: DispatchWorkItem? weak var workItem: DispatchWorkItem?
var isCancelled = false var isCancelled = false
let callback: (Result<[String: Int64]?, any Error>) -> Void let callback: (Result<[String: Int64]?, any Error>) -> Void
init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) { init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.callback = callback self.callback = callback
} }
@@ -30,11 +30,11 @@ class LocalImageApiImpl: LocalImageApi {
requestOptions.version = .current requestOptions.version = .current
return requestOptions return requestOptions
}() }()
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated) private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", 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 cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
private static var rgbaFormat = vImage_CGImageFormat( private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8, bitsPerComponent: 8,
bitsPerPixel: 32, bitsPerPixel: 32,
@@ -48,12 +48,12 @@ class LocalImageApiImpl: LocalImageApi {
assetCache.countLimit = 10000 assetCache.countLimit = 10000
return assetCache return assetCache
}() }()
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) { func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
ImageProcessing.queue.async { ImageProcessing.queue.async {
guard let data = Data(base64Encoded: thumbhash) guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))} else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
let (width, height, pointer) = thumbHashToRGBA(hash: data) let (width, height, pointer) = thumbHashToRGBA(hash: data)
completion(.success([ completion(.success([
"pointer": Int64(Int(bitPattern: pointer.baseAddress)), "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 request = LocalImageRequest(callback: completion)
let item = DispatchWorkItem { let item = DispatchWorkItem {
if request.isCancelled { if request.isCancelled {
return completion(ImageProcessing.cancelledResult) return completion(ImageProcessing.cancelledResult)
} }
ImageProcessing.semaphore.wait() ImageProcessing.semaphore.wait()
defer { defer {
ImageProcessing.semaphore.signal() ImageProcessing.semaphore.signal()
} }
if request.isCancelled { if request.isCancelled {
return completion(ImageProcessing.cancelledResult) return completion(ImageProcessing.cancelledResult)
} }
guard let asset = Self.requestAsset(assetId: assetId) guard let asset = Self.requestAsset(assetId: assetId)
else { else {
Self.remove(requestId: requestId) Self.remove(requestId: requestId)
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))) completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
return return
} }
if request.isCancelled { if request.isCancelled {
return completion(ImageProcessing.cancelledResult) 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? var image: UIImage?
Self.imageManager.requestImage( Self.imageManager.requestImage(
for: asset, for: asset,
@@ -101,29 +144,29 @@ class LocalImageApiImpl: LocalImageApi {
image = _image image = _image
} }
) )
if request.isCancelled { if request.isCancelled {
return completion(ImageProcessing.cancelledResult) return completion(ImageProcessing.cancelledResult)
} }
guard let image = image, guard let image = image,
let cgImage = image.cgImage else { let cgImage = image.cgImage else {
Self.remove(requestId: requestId) Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
} }
if request.isCancelled { if request.isCancelled {
return completion(ImageProcessing.cancelledResult) return completion(ImageProcessing.cancelledResult)
} }
do { do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat) let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat)
if request.isCancelled { if request.isCancelled {
buffer.free() buffer.free()
return completion(ImageProcessing.cancelledResult) return completion(ImageProcessing.cancelledResult)
} }
request.callback(.success([ request.callback(.success([
"pointer": Int64(Int(bitPattern: buffer.data)), "pointer": Int64(Int(bitPattern: buffer.data)),
"width": Int64(buffer.width), "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))) return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil)))
} }
} }
request.workItem = item request.workItem = item
Self.add(requestId: requestId, request: request) Self.add(requestId: requestId, request: request)
ImageProcessing.queue.async(execute: item) ImageProcessing.queue.async(execute: item)
} }
func cancelRequest(requestId: Int64) { func cancelRequest(requestId: Int64) {
Self.cancel(requestId: requestId) Self.cancel(requestId: requestId)
} }
private static func add(requestId: Int64, request: LocalImageRequest) -> Void { private static func add(requestId: Int64, request: LocalImageRequest) -> Void {
requestQueue.sync { requests[requestId] = request } requestQueue.sync { requests[requestId] = request }
} }
private static func remove(requestId: Int64) -> Void { private static func remove(requestId: Int64) -> Void {
requestQueue.sync { requests[requestId] = nil } requestQueue.sync { requests[requestId] = nil }
} }
private static func cancel(requestId: Int64) -> Void { private static func cancel(requestId: Int64) -> Void {
requestQueue.async { requestQueue.async {
guard let request = requests.removeValue(forKey: requestId) else { return } guard let request = requests.removeValue(forKey: requestId) else { return }
@@ -164,12 +207,12 @@ class LocalImageApiImpl: LocalImageApi {
} }
} }
} }
private static func requestAsset(assetId: String) -> PHAsset? { private static func requestAsset(assetId: String) -> PHAsset? {
var asset: PHAsset? var asset: PHAsset?
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) } assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
if asset != nil { return asset } if asset != nil { return asset }
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
else { return nil } else { return nil }
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) } assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }

View File

@@ -70,7 +70,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
/// Generated protocol from Pigeon that represents a handler of messages from Flutter. /// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol RemoteImageApi { 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 cancelRequest(requestId: Int64) throws
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void) func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
} }
@@ -88,7 +88,8 @@ class RemoteImageApiSetup {
let urlArg = args[0] as! String let urlArg = args[0] as! String
let headersArg = args[1] as! [String: String] let headersArg = args[1] as! [String: String]
let requestIdArg = args[2] as! Int64 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 { switch result {
case .success(let res): case .success(let res):
reply(wrapResult(res)) reply(wrapResult(res))

View File

@@ -8,7 +8,7 @@ class RemoteImageRequest {
let id: Int64 let id: Int64
var isCancelled = false var isCancelled = false
let completion: (Result<[String: Int64]?, any Error>) -> Void let completion: (Result<[String: Int64]?, any Error>) -> Void
init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.id = id self.id = id
self.task = task self.task = task
@@ -32,75 +32,93 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceCreateThumbnailFromImageAlways: true kCGImageSourceCreateThumbnailFromImageAlways: true
] as CFDictionary ] 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)!) var urlRequest = URLRequest(url: URL(string: url)!)
urlRequest.cachePolicy = .returnCacheDataElseLoad urlRequest.cachePolicy = .returnCacheDataElseLoad
for (key, value) in headers { for (key, value) in headers {
urlRequest.setValue(value, forHTTPHeaderField: key) urlRequest.setValue(value, forHTTPHeaderField: key)
} }
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in 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) let request = RemoteImageRequest(id: requestId, task: task, completion: completion)
os_unfair_lock_lock(&Self.lock) os_unfair_lock_lock(&Self.lock)
Self.requests[requestId] = request Self.requests[requestId] = request
os_unfair_lock_unlock(&Self.lock) os_unfair_lock_unlock(&Self.lock)
task.resume() 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) os_unfair_lock_lock(&Self.lock)
guard let request = requests[requestId] else { guard let request = requests[requestId] else {
return os_unfair_lock_unlock(&Self.lock) return os_unfair_lock_unlock(&Self.lock)
} }
requests[requestId] = nil requests[requestId] = nil
os_unfair_lock_unlock(&Self.lock) os_unfair_lock_unlock(&Self.lock)
if let error = error { if let error = error {
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled { if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
return request.completion(ImageProcessing.cancelledResult) return request.completion(ImageProcessing.cancelledResult)
} }
return request.completion(.failure(error)) return request.completion(.failure(error))
} }
if request.isCancelled { if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult) return request.completion(ImageProcessing.cancelledResult)
} }
guard let data = data else { guard let data = data else {
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil))) return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
} }
ImageProcessing.queue.async { ImageProcessing.queue.async {
ImageProcessing.semaphore.wait() ImageProcessing.semaphore.wait()
defer { ImageProcessing.semaphore.signal() } defer { ImageProcessing.semaphore.signal() }
if request.isCancelled { if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult) 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), guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else { let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else {
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil))) return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
} }
if request.isCancelled { if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult) return request.completion(ImageProcessing.cancelledResult)
} }
do { do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat) let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat)
if request.isCancelled { if request.isCancelled {
buffer.free() buffer.free()
return request.completion(ImageProcessing.cancelledResult) return request.completion(ImageProcessing.cancelledResult)
} }
request.completion( request.completion(
.success([ .success([
"pointer": Int64(Int(bitPattern: buffer.data)), "pointer": Int64(Int(bitPattern: buffer.data)),
@@ -113,17 +131,17 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
} }
} }
} }
func cancelRequest(requestId: Int64) { func cancelRequest(requestId: Int64) {
os_unfair_lock_lock(&Self.lock) os_unfair_lock_lock(&Self.lock)
let request = Self.requests[requestId] let request = Self.requests[requestId]
os_unfair_lock_unlock(&Self.lock) os_unfair_lock_unlock(&Self.lock)
guard let request = request else { return } guard let request = request else { return }
request.isCancelled = true request.isCancelled = true
request.task?.cancel() request.task?.cancel()
} }
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) { func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {
Task { Task {
let cache = URLSessionManager.shared.session.configuration.urlCache! let cache = URLSessionManager.shared.session.configuration.urlCache!

View File

@@ -24,6 +24,8 @@ abstract class ImageRequest {
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}); Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0});
Future<ui.Codec?> loadCodec();
void cancel() { void cancel() {
if (_isCancelled) { if (_isCancelled) {
return; return;
@@ -34,7 +36,7 @@ abstract class ImageRequest {
void _onCancelled(); void _onCancelled();
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async { Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage(int address, int length) async {
final pointer = Pointer<Uint8>.fromAddress(address); final pointer = Pointer<Uint8>.fromAddress(address);
if (_isCancelled) { if (_isCancelled) {
malloc.free(pointer); malloc.free(pointer);
@@ -67,6 +69,20 @@ abstract class ImageRequest {
return null; return null;
} }
return (codec, descriptor);
}
Future<ui.FrameInfo?> _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(); final frame = await codec.getNextFrame();
descriptor.dispose(); descriptor.dispose();
codec.dispose(); codec.dispose();

View File

@@ -22,6 +22,7 @@ class LocalImageRequest extends ImageRequest {
width: width, width: width,
height: height, height: height,
isVideo: assetType == AssetType.video, isVideo: assetType == AssetType.video,
preferEncoded: false,
); );
if (info == null) { if (info == null) {
return null; return null;
@@ -31,6 +32,26 @@ class LocalImageRequest extends ImageRequest {
return frame == null ? null : ImageInfo(image: frame.image, scale: scale); return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
} }
@override
Future<ui.Codec?> 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 @override
Future<void> _onCancelled() { Future<void> _onCancelled() {
return localImageApi.cancelRequest(requestId); return localImageApi.cancelRequest(requestId);

View File

@@ -12,7 +12,8 @@ class RemoteImageRequest extends ImageRequest {
return null; 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) { final frame = switch (info) {
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length), {'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} => {'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); return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
} }
@override
Future<ui.Codec?> 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 @override
Future<void> _onCancelled() { Future<void> _onCancelled() {
return remoteImageApi.cancelRequest(requestId); return remoteImageApi.cancelRequest(requestId);

View File

@@ -16,6 +16,9 @@ class ThumbhashImageRequest extends ImageRequest {
return frame == null ? null : ImageInfo(image: frame.image, scale: scale); return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
} }
@override
Future<ui.Codec?> loadCodec() => throw UnsupportedError('Thumbhash does not support codec loading');
@override @override
void _onCancelled() {} void _onCancelled() {}
} }

View File

@@ -55,6 +55,7 @@ class LocalImageApi {
required int width, required int width,
required int height, required int height,
required bool isVideo, required bool isVideo,
required bool preferEncoded,
}) async { }) async {
final String pigeonVar_channelName = final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix';
@@ -69,6 +70,7 @@ class LocalImageApi {
width, width,
height, height,
isVideo, isVideo,
preferEncoded,
]); ]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) { if (pigeonVar_replyList == null) {

View File

@@ -53,6 +53,7 @@ class RemoteImageApi {
String url, { String url, {
required Map<String, String> headers, required Map<String, String> headers,
required int requestId, required int requestId,
required bool preferEncoded,
}) async { }) async {
final String pigeonVar_channelName = final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
@@ -61,7 +62,12 @@ class RemoteImageApi {
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, binaryMessenger: pigeonVar_binaryMessenger,
); );
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[url, headers, requestId]); final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[
url,
headers,
requestId,
preferEncoded,
]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) { if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName); throw _createConnectionError(pigeonVar_channelName);

View File

@@ -1,3 +1,5 @@
import 'dart:ui' as ui;
import 'package:async/async.dart'; import 'package:async/async.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -75,6 +77,29 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
} }
} }
Future<ui.Codec?> 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<ImageInfo> initialImageStream() async* { Stream<ImageInfo> initialImageStream() async* {
final cachedOperation = this.cachedOperation; final cachedOperation = this.cachedOperation;
if (cachedOperation == null) { if (cachedOperation == null) {

View File

@@ -21,6 +21,7 @@ abstract class LocalImageApi {
required int width, required int width,
required int height, required int height,
required bool isVideo, required bool isVideo,
required bool preferEncoded,
}); });
void cancelRequest(int requestId); void cancelRequest(int requestId);

View File

@@ -19,6 +19,7 @@ abstract class RemoteImageApi {
String url, { String url, {
required Map<String, String> headers, required Map<String, String> headers,
required int requestId, required int requestId,
required bool preferEncoded,
}); });
void cancelRequest(int requestId); void cancelRequest(int requestId);