mirror of
https://github.com/immich-app/immich.git
synced 2026-01-26 11:24:44 -08:00
flutter-side decode
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
#include <jni.h>
|
||||
#include <stdlib.h>
|
||||
#include <android/bitmap.h>
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_images_LocalImagesImpl_allocateNative(
|
||||
@@ -15,25 +14,15 @@ Java_app_alextran_immich_images_LocalImagesImpl_freeNative(
|
||||
free((void *) address);
|
||||
}
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_images_LocalImagesImpl_reallocNative(
|
||||
JNIEnv *env, jclass clazz, jlong address, jint size) {
|
||||
void *ptr = realloc((void *) address, size);
|
||||
return (jlong) ptr;
|
||||
}
|
||||
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_app_alextran_immich_images_LocalImagesImpl_wrapAsBuffer(
|
||||
JNIEnv *env, jclass clazz, jlong address, jint capacity) {
|
||||
return (*env)->NewDirectByteBuffer(env, (void *) address, capacity);
|
||||
}
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_images_RemoteImagesImpl_lockBitmapPixels(
|
||||
JNIEnv *env, jclass clazz, jobject bitmap) {
|
||||
void *pixels = NULL;
|
||||
int result = AndroidBitmap_lockPixels(env, bitmap, &pixels);
|
||||
if (result != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
return 0;
|
||||
}
|
||||
return (jlong) pixels;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_app_alextran_immich_images_RemoteImagesImpl_unlockBitmapPixels(
|
||||
JNIEnv *env, jclass clazz, jobject bitmap) {
|
||||
AndroidBitmap_unlockPixels(env, bitmap);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,8 @@ fun Bitmap.toNativeBuffer(): Map<String, Long> {
|
||||
return mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to width.toLong(),
|
||||
"height" to height.toLong()
|
||||
"height" to height.toLong(),
|
||||
"rowBytes" to (width * 4).toLong()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
LocalImagesImpl.freeNative(pointer)
|
||||
@@ -83,6 +84,9 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
|
||||
@JvmStatic
|
||||
external fun freeNative(pointer: Long)
|
||||
|
||||
@JvmStatic
|
||||
external fun reallocNative(pointer: Long, size: Int): Long
|
||||
|
||||
@JvmStatic
|
||||
external fun wrapAsBuffer(address: Long, capacity: Int): ByteBuffer
|
||||
}
|
||||
@@ -95,7 +99,8 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
|
||||
val res = mapOf(
|
||||
"pointer" to image.pointer,
|
||||
"width" to image.width.toLong(),
|
||||
"height" to image.height.toLong()
|
||||
"height" to image.height.toLong(),
|
||||
"rowBytes" to (image.width * 4).toLong()
|
||||
)
|
||||
callback(Result.success(res))
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
package app.alextran.immich.images
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ColorSpace
|
||||
import android.graphics.ImageDecoder
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import app.alextran.immich.BuildConfig
|
||||
import app.alextran.immich.core.SSLConfig
|
||||
@@ -21,15 +16,14 @@ import okhttp3.Dispatcher
|
||||
import org.chromium.net.CronetEngine
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.Interceptor
|
||||
|
||||
data class RemoteRequest(
|
||||
val callback: (Result<Map<String, Long>>) -> Unit,
|
||||
val cancellationSignal: CancellationSignal,
|
||||
var pointer: Long = 0L,
|
||||
)
|
||||
|
||||
class UserAgentInterceptor : Interceptor {
|
||||
@@ -48,7 +42,6 @@ class UserAgentInterceptor : Interceptor {
|
||||
|
||||
class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
private val requestMap = ConcurrentHashMap<Long, RemoteRequest>()
|
||||
private val lockedBitmaps = ConcurrentHashMap<Long, Bitmap>()
|
||||
|
||||
init {
|
||||
appContext = context.applicationContext
|
||||
@@ -62,9 +55,9 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
|
||||
private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
|
||||
|
||||
private const val INITIAL_BUFFER_SIZE = 64 * 1024
|
||||
|
||||
val CANCELLED = Result.success<Map<String, Long>>(emptyMap())
|
||||
private val decodePool =
|
||||
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() / 2 + 1)
|
||||
|
||||
private var appContext: Context? = null
|
||||
private var cacheDir: File? = null
|
||||
@@ -76,12 +69,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
SSLConfig.addListener(::invalidateClient)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
external fun lockBitmapPixels(bitmap: Bitmap): Long
|
||||
|
||||
@JvmStatic
|
||||
external fun unlockBitmapPixels(bitmap: Bitmap)
|
||||
|
||||
private fun invalidateClient() {
|
||||
(client as? OkHttpClient)?.let {
|
||||
it.dispatcher.cancelAll()
|
||||
@@ -111,7 +98,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
.enableQuic(true)
|
||||
.enableBrotli(true)
|
||||
.setStoragePath(storageDir.absolutePath)
|
||||
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES)
|
||||
// .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES)
|
||||
.setUserAgent(UserAgentInterceptor.USER_AGENT)
|
||||
.build()
|
||||
.also { cronetEngine = it }
|
||||
@@ -168,60 +155,56 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
decodePool.execute {
|
||||
try {
|
||||
signal.throwIfCanceled()
|
||||
val bytes = response.takeIf { it.isSuccessful }?.body?.bytes()
|
||||
?: return@execute callback(Result.failure(IOException(response.toString())))
|
||||
signal.throwIfCanceled()
|
||||
val bitmap = decodeImage(bytes)
|
||||
signal.throwIfCanceled()
|
||||
var pointer = 0L
|
||||
var capacity: Int
|
||||
try {
|
||||
signal.throwIfCanceled()
|
||||
val body = response.takeIf { it.isSuccessful }?.body
|
||||
?: return callback(Result.failure(IOException(response.toString())))
|
||||
|
||||
val pointer = lockBitmapPixels(bitmap)
|
||||
if (pointer == 0L) {
|
||||
bitmap.recycle()
|
||||
return@execute callback(Result.failure(RuntimeException("Failed to lock bitmap pixels")))
|
||||
val contentLength = body.contentLength()
|
||||
capacity = if (contentLength > 0) contentLength.toInt() else INITIAL_BUFFER_SIZE
|
||||
pointer = LocalImagesImpl.allocateNative(capacity)
|
||||
request.pointer = pointer
|
||||
|
||||
var position = 0
|
||||
body.source().use { source ->
|
||||
while (!source.exhausted()) {
|
||||
signal.throwIfCanceled()
|
||||
if (position >= capacity) {
|
||||
capacity = maxOf(capacity * 2, position + 8192)
|
||||
pointer = LocalImagesImpl.reallocNative(pointer, capacity)
|
||||
request.pointer = pointer
|
||||
}
|
||||
val buffer = LocalImagesImpl.wrapAsBuffer(pointer + position, capacity - position)
|
||||
val read = source.read(buffer)
|
||||
if (read == -1) break
|
||||
position += read
|
||||
}
|
||||
|
||||
lockedBitmaps[requestId] = bitmap
|
||||
callback(Result.success(mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to bitmap.width.toLong(),
|
||||
"height" to bitmap.height.toLong(),
|
||||
"rowBytes" to bitmap.rowBytes.toLong()
|
||||
)))
|
||||
} catch (e: Exception) {
|
||||
val result = if (signal.isCanceled) CANCELLED else Result.failure(e)
|
||||
callback(result)
|
||||
} finally {
|
||||
requestMap.remove(requestId)
|
||||
response.close()
|
||||
}
|
||||
|
||||
signal.throwIfCanceled()
|
||||
request.pointer = 0L // Transfer ownership to Dart before callback
|
||||
callback(Result.success(mapOf(
|
||||
"pointer" to pointer,
|
||||
"length" to position.toLong()
|
||||
)))
|
||||
} catch (e: Exception) {
|
||||
if (pointer != 0L) LocalImagesImpl.freeNative(pointer)
|
||||
val result = if (signal.isCanceled) CANCELLED else Result.failure(e)
|
||||
callback(result)
|
||||
} finally {
|
||||
requestMap.remove(requestId)
|
||||
response.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun cancelRequest(requestId: Long) {
|
||||
requestMap.remove(requestId)?.cancellationSignal?.cancel()
|
||||
releaseImage(requestId)
|
||||
// Just cancel the signal - memory cleanup happens in onResponse/onFailure
|
||||
requestMap[requestId]?.cancellationSignal?.cancel()
|
||||
}
|
||||
|
||||
override fun releaseImage(requestId: Long) {
|
||||
val bitmap = lockedBitmaps.remove(requestId) ?: return
|
||||
unlockBitmapPixels(bitmap)
|
||||
bitmap.recycle()
|
||||
}
|
||||
|
||||
private fun decodeImage(bytes: ByteArray): Bitmap {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ImageDecoder.createSource(ByteBuffer.wrap(bytes)).decodeBitmap()
|
||||
} else {
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
inPreferredColorSpace = ColorSpace.get(ColorSpace.Named.SRGB)
|
||||
}
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
|
||||
}
|
||||
}
|
||||
override fun releaseImage(requestId: Long) {}
|
||||
}
|
||||
|
||||
@@ -80,8 +80,6 @@ PODS:
|
||||
- Flutter
|
||||
- network_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- objective_c (0.0.1):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
@@ -160,7 +158,6 @@ DEPENDENCIES:
|
||||
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
|
||||
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
|
||||
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
|
||||
- objective_c (from `.symlinks/plugins/objective_c/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
@@ -229,8 +226,6 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/native_video_player/ios"
|
||||
network_info_plus:
|
||||
:path: ".symlinks/plugins/network_info_plus/ios"
|
||||
objective_c:
|
||||
:path: ".symlinks/plugins/objective_c/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
@@ -282,7 +277,6 @@ SPEC CHECKSUMS:
|
||||
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
|
||||
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
|
||||
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
|
||||
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -35,35 +34,55 @@ abstract class ImageRequest {
|
||||
|
||||
void _onCancelled();
|
||||
|
||||
Future<ui.FrameInfo?> _fromPlatformImage(Map<String, int> info, {required bool shouldFree}) async {
|
||||
final address = info['pointer'];
|
||||
if (address == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async {
|
||||
final pointer = Pointer<Uint8>.fromAddress(address);
|
||||
if (_isCancelled) {
|
||||
if (shouldFree) {
|
||||
malloc.free(pointer);
|
||||
}
|
||||
malloc.free(pointer);
|
||||
return null;
|
||||
}
|
||||
|
||||
final int actualWidth;
|
||||
final int actualHeight;
|
||||
final int rowBytes;
|
||||
final int actualSize;
|
||||
final ui.ImmutableBuffer buffer;
|
||||
try {
|
||||
actualWidth = info['width']!;
|
||||
actualHeight = info['height']!;
|
||||
rowBytes = info['rowBytes'] ?? actualWidth * 4;
|
||||
actualSize = rowBytes * actualHeight;
|
||||
buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(actualSize));
|
||||
buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(length));
|
||||
} finally {
|
||||
if (shouldFree) {
|
||||
malloc.free(pointer);
|
||||
}
|
||||
malloc.free(pointer);
|
||||
}
|
||||
|
||||
if (_isCancelled) {
|
||||
buffer.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
final descriptor = await ui.ImageDescriptor.encoded(buffer);
|
||||
if (_isCancelled) {
|
||||
buffer.dispose();
|
||||
descriptor.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
final codec = await descriptor.instantiateCodec();
|
||||
if (_isCancelled) {
|
||||
buffer.dispose();
|
||||
descriptor.dispose();
|
||||
codec.dispose();
|
||||
return null;
|
||||
}
|
||||
return await codec.getNextFrame();
|
||||
}
|
||||
|
||||
Future<ui.FrameInfo?> _fromDecodedPlatformImage(int address, int width, int height, int rowBytes) async {
|
||||
final pointer = Pointer<Uint8>.fromAddress(address);
|
||||
if (_isCancelled) {
|
||||
malloc.free(pointer);
|
||||
return null;
|
||||
}
|
||||
|
||||
final size = rowBytes * height;
|
||||
final ui.ImmutableBuffer buffer;
|
||||
try {
|
||||
buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(size));
|
||||
} finally {
|
||||
malloc.free(pointer);
|
||||
}
|
||||
|
||||
if (_isCancelled) {
|
||||
@@ -73,8 +92,8 @@ abstract class ImageRequest {
|
||||
|
||||
final descriptor = ui.ImageDescriptor.raw(
|
||||
buffer,
|
||||
width: actualWidth,
|
||||
height: actualHeight,
|
||||
width: width,
|
||||
height: height,
|
||||
rowBytes: rowBytes,
|
||||
pixelFormat: ui.PixelFormat.rgba8888,
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ class LocalImageRequest extends ImageRequest {
|
||||
isVideo: assetType == AssetType.video,
|
||||
);
|
||||
|
||||
final frame = await _fromPlatformImage(info, shouldFree: true);
|
||||
final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!);
|
||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,15 +13,8 @@ class RemoteImageRequest extends ImageRequest {
|
||||
}
|
||||
|
||||
final Map<String, int> info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId);
|
||||
|
||||
try {
|
||||
final frame = await _fromPlatformImage(info, shouldFree: Platform.isIOS);
|
||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||
} finally {
|
||||
if (Platform.isAndroid) {
|
||||
unawaited(remoteImageApi.releaseImage(requestId));
|
||||
}
|
||||
}
|
||||
final frame = await _fromEncodedPlatformImage(info["pointer"]!, info["length"]!);
|
||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -12,7 +12,7 @@ class ThumbhashImageRequest extends ImageRequest {
|
||||
}
|
||||
|
||||
final Map<String, int> info = await localImageApi.getThumbhash(thumbhash);
|
||||
final frame = await _fromPlatformImage(info, shouldFree: true);
|
||||
final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!);
|
||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user