mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 13:29:21 +03:00
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:
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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!
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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() {}
|
||||||
}
|
}
|
||||||
|
|||||||
2
mobile/lib/platform/local_image_api.g.dart
generated
2
mobile/lib/platform/local_image_api.g.dart
generated
@@ -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) {
|
||||||
|
|||||||
8
mobile/lib/platform/remote_image_api.g.dart
generated
8
mobile/lib/platform/remote_image_api.g.dart
generated
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user