flutter-side decode

This commit is contained in:
mertalev
2026-01-17 23:46:46 -05:00
parent 24ff08e094
commit 00ea98479f
8 changed files with 106 additions and 123 deletions
@@ -1,6 +1,5 @@
#include <jni.h> #include <jni.h>
#include <stdlib.h> #include <stdlib.h>
#include <android/bitmap.h>
JNIEXPORT jlong JNICALL JNIEXPORT jlong JNICALL
Java_app_alextran_immich_images_LocalImagesImpl_allocateNative( Java_app_alextran_immich_images_LocalImagesImpl_allocateNative(
@@ -15,25 +14,15 @@ Java_app_alextran_immich_images_LocalImagesImpl_freeNative(
free((void *) address); 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 JNIEXPORT jobject JNICALL
Java_app_alextran_immich_images_LocalImagesImpl_wrapAsBuffer( Java_app_alextran_immich_images_LocalImagesImpl_wrapAsBuffer(
JNIEnv *env, jclass clazz, jlong address, jint capacity) { JNIEnv *env, jclass clazz, jlong address, jint capacity) {
return (*env)->NewDirectByteBuffer(env, (void *) address, 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( return mapOf(
"pointer" to pointer, "pointer" to pointer,
"width" to width.toLong(), "width" to width.toLong(),
"height" to height.toLong() "height" to height.toLong(),
"rowBytes" to (width * 4).toLong()
) )
} catch (e: Exception) { } catch (e: Exception) {
LocalImagesImpl.freeNative(pointer) LocalImagesImpl.freeNative(pointer)
@@ -83,6 +84,9 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
@JvmStatic @JvmStatic
external fun freeNative(pointer: Long) external fun freeNative(pointer: Long)
@JvmStatic
external fun reallocNative(pointer: Long, size: Int): Long
@JvmStatic @JvmStatic
external fun wrapAsBuffer(address: Long, capacity: Int): ByteBuffer external fun wrapAsBuffer(address: Long, capacity: Int): ByteBuffer
} }
@@ -95,7 +99,8 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
val res = mapOf( val res = mapOf(
"pointer" to image.pointer, "pointer" to image.pointer,
"width" to image.width.toLong(), "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)) callback(Result.success(res))
} catch (e: Exception) { } catch (e: Exception) {
@@ -1,11 +1,6 @@
package app.alextran.immich.images package app.alextran.immich.images
import android.content.Context 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 android.os.CancellationSignal
import app.alextran.immich.BuildConfig import app.alextran.immich.BuildConfig
import app.alextran.immich.core.SSLConfig import app.alextran.immich.core.SSLConfig
@@ -21,15 +16,14 @@ import okhttp3.Dispatcher
import org.chromium.net.CronetEngine import org.chromium.net.CronetEngine
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import okhttp3.Interceptor import okhttp3.Interceptor
data class RemoteRequest( data class RemoteRequest(
val callback: (Result<Map<String, Long>>) -> Unit, val callback: (Result<Map<String, Long>>) -> Unit,
val cancellationSignal: CancellationSignal, val cancellationSignal: CancellationSignal,
var pointer: Long = 0L,
) )
class UserAgentInterceptor : Interceptor { class UserAgentInterceptor : Interceptor {
@@ -48,7 +42,6 @@ class UserAgentInterceptor : Interceptor {
class RemoteImagesImpl(context: Context) : RemoteImageApi { class RemoteImagesImpl(context: Context) : RemoteImageApi {
private val requestMap = ConcurrentHashMap<Long, RemoteRequest>() private val requestMap = ConcurrentHashMap<Long, RemoteRequest>()
private val lockedBitmaps = ConcurrentHashMap<Long, Bitmap>()
init { init {
appContext = context.applicationContext appContext = context.applicationContext
@@ -62,9 +55,9 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
private const val KEEP_ALIVE_DURATION_MINUTES = 5L private const val KEEP_ALIVE_DURATION_MINUTES = 5L
private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024 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()) 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 appContext: Context? = null
private var cacheDir: File? = null private var cacheDir: File? = null
@@ -76,12 +69,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
SSLConfig.addListener(::invalidateClient) SSLConfig.addListener(::invalidateClient)
} }
@JvmStatic
external fun lockBitmapPixels(bitmap: Bitmap): Long
@JvmStatic
external fun unlockBitmapPixels(bitmap: Bitmap)
private fun invalidateClient() { private fun invalidateClient() {
(client as? OkHttpClient)?.let { (client as? OkHttpClient)?.let {
it.dispatcher.cancelAll() it.dispatcher.cancelAll()
@@ -111,7 +98,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
.enableQuic(true) .enableQuic(true)
.enableBrotli(true) .enableBrotli(true)
.setStoragePath(storageDir.absolutePath) .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) .setUserAgent(UserAgentInterceptor.USER_AGENT)
.build() .build()
.also { cronetEngine = it } .also { cronetEngine = it }
@@ -168,60 +155,56 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
} }
override fun onResponse(call: Call, response: Response) { override fun onResponse(call: Call, response: Response) {
decodePool.execute { var pointer = 0L
try { var capacity: Int
signal.throwIfCanceled() try {
val bytes = response.takeIf { it.isSuccessful }?.body?.bytes() signal.throwIfCanceled()
?: return@execute callback(Result.failure(IOException(response.toString()))) val body = response.takeIf { it.isSuccessful }?.body
signal.throwIfCanceled() ?: return callback(Result.failure(IOException(response.toString())))
val bitmap = decodeImage(bytes)
signal.throwIfCanceled()
val pointer = lockBitmapPixels(bitmap) val contentLength = body.contentLength()
if (pointer == 0L) { capacity = if (contentLength > 0) contentLength.toInt() else INITIAL_BUFFER_SIZE
bitmap.recycle() pointer = LocalImagesImpl.allocateNative(capacity)
return@execute callback(Result.failure(RuntimeException("Failed to lock bitmap pixels"))) 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) { override fun cancelRequest(requestId: Long) {
requestMap.remove(requestId)?.cancellationSignal?.cancel() // Just cancel the signal - memory cleanup happens in onResponse/onFailure
releaseImage(requestId) requestMap[requestId]?.cancellationSignal?.cancel()
} }
override fun releaseImage(requestId: Long) { 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)
}
}
} }
-6
View File
@@ -80,8 +80,6 @@ PODS:
- Flutter - Flutter
- network_info_plus (0.0.1): - network_info_plus (0.0.1):
- Flutter - Flutter
- objective_c (0.0.1):
- Flutter
- package_info_plus (0.4.5): - package_info_plus (0.4.5):
- Flutter - Flutter
- path_provider_foundation (0.0.1): - path_provider_foundation (0.0.1):
@@ -160,7 +158,6 @@ DEPENDENCIES:
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`) - maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
- native_video_player (from `.symlinks/plugins/native_video_player/ios`) - native_video_player (from `.symlinks/plugins/native_video_player/ios`)
- network_info_plus (from `.symlinks/plugins/network_info_plus/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`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
@@ -229,8 +226,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/native_video_player/ios" :path: ".symlinks/plugins/native_video_player/ios"
network_info_plus: network_info_plus:
:path: ".symlinks/plugins/network_info_plus/ios" :path: ".symlinks/plugins/network_info_plus/ios"
objective_c:
:path: ".symlinks/plugins/objective_c/ios"
package_info_plus: package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios" :path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation: path_provider_foundation:
@@ -282,7 +277,6 @@ SPEC CHECKSUMS:
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
native_video_player: b65c58951ede2f93d103a25366bdebca95081265 native_video_player: b65c58951ede2f93d103a25366bdebca95081265
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:ffi'; import 'dart:ffi';
import 'dart:io';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -35,35 +34,55 @@ abstract class ImageRequest {
void _onCancelled(); void _onCancelled();
Future<ui.FrameInfo?> _fromPlatformImage(Map<String, int> info, {required bool shouldFree}) async { Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async {
final address = info['pointer'];
if (address == null) {
return null;
}
final pointer = Pointer<Uint8>.fromAddress(address); final pointer = Pointer<Uint8>.fromAddress(address);
if (_isCancelled) { if (_isCancelled) {
if (shouldFree) { malloc.free(pointer);
malloc.free(pointer);
}
return null; return null;
} }
final int actualWidth;
final int actualHeight;
final int rowBytes;
final int actualSize;
final ui.ImmutableBuffer buffer; final ui.ImmutableBuffer buffer;
try { try {
actualWidth = info['width']!; buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(length));
actualHeight = info['height']!;
rowBytes = info['rowBytes'] ?? actualWidth * 4;
actualSize = rowBytes * actualHeight;
buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(actualSize));
} finally { } 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) { if (_isCancelled) {
@@ -73,8 +92,8 @@ abstract class ImageRequest {
final descriptor = ui.ImageDescriptor.raw( final descriptor = ui.ImageDescriptor.raw(
buffer, buffer,
width: actualWidth, width: width,
height: actualHeight, height: height,
rowBytes: rowBytes, rowBytes: rowBytes,
pixelFormat: ui.PixelFormat.rgba8888, pixelFormat: ui.PixelFormat.rgba8888,
); );
@@ -24,7 +24,7 @@ class LocalImageRequest extends ImageRequest {
isVideo: assetType == AssetType.video, 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); 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); final Map<String, int> info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId);
final frame = await _fromEncodedPlatformImage(info["pointer"]!, info["length"]!);
try { return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
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));
}
}
} }
@override @override
@@ -12,7 +12,7 @@ class ThumbhashImageRequest extends ImageRequest {
} }
final Map<String, int> info = await localImageApi.getThumbhash(thumbhash); 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); return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
} }