android improvements

This commit is contained in:
mertalev 2025-08-13 18:57:14 -04:00
parent 3100702e93
commit f039672a2a
No known key found for this signature in database
GPG key ID: DF6ABC77AAD98C95
14 changed files with 70 additions and 51 deletions

View file

@ -3,6 +3,8 @@ package app.alextran.immich
import android.os.Build import android.os.Build
import android.os.ext.SdkExtensions import android.os.ext.SdkExtensions
import androidx.annotation.NonNull import androidx.annotation.NonNull
import app.alextran.immich.images.ThumbnailApi
import app.alextran.immich.images.ThumbnailsImpl
import app.alextran.immich.sync.NativeSyncApi import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26 import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30 import app.alextran.immich.sync.NativeSyncApiImpl30
@ -23,5 +25,6 @@ class MainActivity : FlutterFragmentActivity() {
NativeSyncApiImpl30(this) NativeSyncApiImpl30(this)
} }
NativeSyncApi.setUp(flutterEngine.dartExecutor.binaryMessenger, nativeSyncApiImpl) NativeSyncApi.setUp(flutterEngine.dartExecutor.binaryMessenger, nativeSyncApiImpl)
ThumbnailApi.setUp(flutterEngine.dartExecutor.binaryMessenger, ThumbnailsImpl(this))
} }
} }

View file

@ -9,10 +9,6 @@ import java.nio.ByteBuffer;
// modified to use native allocations // modified to use native allocations
public final class ThumbHash { public final class ThumbHash {
private static native long allocateNative(int size);
private static native ByteBuffer wrapAsBuffer(long address, int capacity);
/** /**
* Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A. * Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A.
* *
@ -60,9 +56,8 @@ public final class ThumbHash {
int w = Math.round(ratio > 1.0f ? 32.0f : 32.0f * ratio); int w = Math.round(ratio > 1.0f ? 32.0f : 32.0f * ratio);
int h = Math.round(ratio > 1.0f ? 32.0f / ratio : 32.0f); int h = Math.round(ratio > 1.0f ? 32.0f / ratio : 32.0f);
int size = w * h * 4; int size = w * h * 4;
long pointer = allocateNative(size); long pointer = ThumbnailsImpl.allocateNative(size);
ByteBuffer buffer = wrapAsBuffer(pointer, size); ByteBuffer rgba = ThumbnailsImpl.wrapAsBuffer(pointer, size);
byte[] rgba = buffer.array();
int cx_stop = Math.max(lx, hasAlpha ? 5 : 3); int cx_stop = Math.max(lx, hasAlpha ? 5 : 3);
int cy_stop = Math.max(ly, hasAlpha ? 5 : 3); int cy_stop = Math.max(ly, hasAlpha ? 5 : 3);
float[] fx = new float[cx_stop]; float[] fx = new float[cx_stop];
@ -106,10 +101,10 @@ public final class ThumbHash {
float b = l - 2.0f / 3.0f * p; float b = l - 2.0f / 3.0f * p;
float r = (3.0f * l - b + q) / 2.0f; float r = (3.0f * l - b + q) / 2.0f;
float g = r - q; float g = r - q;
rgba[i] = (byte) Math.max(0, Math.round(255.0f * Math.min(1, r))); rgba.put(i, (byte) Math.max(0, Math.round(255.0f * Math.min(1, r))));
rgba[i + 1] = (byte) Math.max(0, Math.round(255.0f * Math.min(1, g))); rgba.put(i + 1, (byte) Math.max(0, Math.round(255.0f * Math.min(1, g))));
rgba[i + 2] = (byte) Math.max(0, Math.round(255.0f * Math.min(1, b))); rgba.put(i + 2, (byte) Math.max(0, Math.round(255.0f * Math.min(1, b))));
rgba[i + 3] = (byte) Math.max(0, Math.round(255.0f * Math.min(1, a))); rgba.put(i + 3, (byte) Math.max(0, Math.round(255.0f * Math.min(1, a))));
} }
} }
return new Image(w, h, pointer); return new Image(w, h, pointer);

View file

@ -59,7 +59,7 @@ private open class ThumbnailsPigeonCodec : 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 ThumbnailApi { interface ThumbnailApi {
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, callback: (Result<Map<String, Long>>) -> Unit) fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, callback: (Result<Map<String, Long>>) -> Unit)
fun cancelImageRequest(requestId: Long) fun cancelImageRequest(requestId: Long)
fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit) fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit)
@ -81,7 +81,8 @@ interface ThumbnailApi {
val requestIdArg = args[1] as Long val requestIdArg = args[1] as Long
val widthArg = args[2] as Long val widthArg = args[2] as Long
val heightArg = args[3] as Long val heightArg = args[3] as Long
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg) { result: Result<Map<String, Long>> -> val isVideoArg = args[4] as Boolean
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result<Map<String, Long>> ->
val error = result.exceptionOrNull() val error = result.exceptionOrNull()
if (error != null) { if (error != null) {
reply.reply(ThumbnailsPigeonUtils.wrapError(error)) reply.reply(ThumbnailsPigeonUtils.wrapError(error))

View file

@ -21,6 +21,7 @@ import com.bumptech.glide.load.DecodeFormat
import java.util.Base64 import java.util.Base64
import java.util.HashMap import java.util.HashMap
import java.util.concurrent.CancellationException import java.util.concurrent.CancellationException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Future import java.util.concurrent.Future
data class Request( data class Request(
@ -33,9 +34,10 @@ data class Request(
class ThumbnailsImpl(context: Context) : ThumbnailApi { class ThumbnailsImpl(context: Context) : ThumbnailApi {
private val ctx: Context = context.applicationContext private val ctx: Context = context.applicationContext
private val resolver: ContentResolver = ctx.contentResolver private val resolver: ContentResolver = ctx.contentResolver
private val requestThread = Executors.newSingleThreadExecutor()
private val threadPool = private val threadPool =
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() / 2 + 1) Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() / 2 + 1)
private val requestMap = HashMap<Long, Request>() private val requestMap = ConcurrentHashMap<Long, Request>()
companion object { companion object {
val PROJECTION = arrayOf( val PROJECTION = arrayOf(
@ -86,12 +88,13 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
requestId: Long, requestId: Long,
width: Long, width: Long,
height: Long, height: Long,
isVideo: 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, callback, signal) 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)
@ -102,7 +105,8 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
requestMap.remove(requestId) requestMap.remove(requestId)
} }
} }
requestMap[requestId] = Request(requestId, task, signal, callback) val request = Request(requestId, task, signal, callback)
requestMap[requestId] = request
} }
override fun cancelImageRequest(requestId: Long) { override fun cancelImageRequest(requestId: Long) {
@ -110,7 +114,12 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
request.taskFuture.cancel(false) request.taskFuture.cancel(false)
request.cancellationSignal.cancel() request.cancellationSignal.cancel()
if (request.taskFuture.isCancelled) { if (request.taskFuture.isCancelled) {
requestThread.execute {
try {
request.callback(CANCELLED) request.callback(CANCELLED)
} catch (_: Exception) {
}
}
} }
} }
@ -118,6 +127,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
assetId: String, assetId: String,
width: Long, width: Long,
height: Long, height: Long,
isVideo: Boolean,
callback: (Result<Map<String, Long>>) -> Unit, callback: (Result<Map<String, Long>>) -> Unit,
signal: CancellationSignal signal: CancellationSignal
) { ) {
@ -126,25 +136,15 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
val targetHeight = height.toInt() val targetHeight = height.toInt()
val id = assetId.toLong() val id = assetId.toLong()
val cursor = resolver.query(URI, PROJECTION, SELECTION, arrayOf(assetId), null)
?: return callback(Result.failure(RuntimeException("Asset not found")))
signal.throwIfCanceled() signal.throwIfCanceled()
cursor.use { c -> val bitmap = if (isVideo) {
if (!c.moveToNext()) { decodeVideoThumbnail(id, targetWidth, targetHeight, signal)
return callback(Result.failure(RuntimeException("Asset not found"))) } else {
} decodeImage(id, targetWidth, targetHeight, signal)
val mediaType = c.getInt(1)
val bitmap = when (mediaType) {
MEDIA_TYPE_IMAGE -> decodeImage(id, targetWidth, targetHeight, signal)
MEDIA_TYPE_VIDEO -> decodeVideoThumbnail(id, targetWidth, targetHeight, signal)
else -> return callback(Result.failure(RuntimeException("Unsupported media type")))
} }
processBitmap(bitmap, callback, signal) processBitmap(bitmap, callback, signal)
} }
}
private fun processBitmap( private fun processBitmap(
bitmap: Bitmap, bitmap: Bitmap,
@ -162,6 +162,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
signal.throwIfCanceled() signal.throwIfCanceled()
val buffer = wrapAsBuffer(pointer, size) val buffer = wrapAsBuffer(pointer, size)
bitmap.copyPixelsToBuffer(buffer) bitmap.copyPixelsToBuffer(buffer)
bitmap.recycle()
signal.throwIfCanceled() signal.throwIfCanceled()
val res = mapOf( val res = mapOf(
"pointer" to pointer, "pointer" to pointer,

View file

@ -70,7 +70,7 @@ class ThumbnailsPigeonCodec: 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 ThumbnailApi { protocol ThumbnailApi {
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, completion: @escaping (Result<[String: Int64], Error>) -> Void) func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], Error>) -> Void)
func cancelImageRequest(requestId: Int64) throws func cancelImageRequest(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)
} }
@ -89,7 +89,8 @@ class ThumbnailApiSetup {
let requestIdArg = args[1] as! Int64 let requestIdArg = args[1] as! Int64
let widthArg = args[2] as! Int64 let widthArg = args[2] as! Int64
let heightArg = args[3] as! Int64 let heightArg = args[3] as! Int64
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg) { result in let isVideoArg = args[4] as! Bool
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg) { result in
switch result { switch result {
case .success(let res): case .success(let res):
reply(wrapResult(res)) reply(wrapResult(res))

View file

@ -28,7 +28,6 @@ const String kDownloadGroupLivePhoto = 'group_livephoto';
const int kTimelineNoneSegmentSize = 120; const int kTimelineNoneSegmentSize = 120;
const int kTimelineAssetLoadBatchSize = 1024; const int kTimelineAssetLoadBatchSize = 1024;
const int kTimelineAssetLoadOppositeSize = 64; const int kTimelineAssetLoadOppositeSize = 64;
const int kTimelineImageCacheMemory = 200 * 1024 * 1024;
// Widget keys // Widget keys
const String appShareGroupId = "group.app.immich.share"; const String appShareGroupId = "group.app.immich.share";

View file

@ -6,6 +6,7 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:ffi/ffi.dart'; import 'package:ffi/ffi.dart';
@ -111,8 +112,9 @@ class LocalImageRequest extends ImageRequest {
final String localId; final String localId;
final int width; final int width;
final int height; final int height;
final AssetType assetType;
LocalImageRequest({required this.localId, required ui.Size size}) LocalImageRequest({required this.localId, required ui.Size size, required this.assetType})
: width = size.width.toInt(), : width = size.width.toInt(),
height = size.height.toInt(); height = size.height.toInt();
@ -131,6 +133,7 @@ class LocalImageRequest extends ImageRequest {
requestId: requestId, requestId: requestId,
width: width, width: width,
height: height, height: height,
isVideo: assetType == AssetType.video,
); );
if (!kReleaseMode) { if (!kReleaseMode) {
stopwatch!.stop(); stopwatch!.stop();

View file

@ -71,7 +71,6 @@ Future<void> initApp() async {
} }
} }
PaintingBinding.instance.imageCache.maximumSizeBytes = kTimelineImageCacheMemory;
await DynamicTheme.fetchSystemPalette(); await DynamicTheme.fetchSystemPalette();
final log = Logger("ImmichErrorLogger"); final log = Logger("ImmichErrorLogger");

View file

@ -54,6 +54,7 @@ class ThumbnailApi {
required int requestId, required int requestId,
required int width, required int width,
required int height, required int height,
required bool isVideo,
}) async { }) async {
final String pigeonVar_channelName = final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage$pigeonVar_messageChannelSuffix';
@ -62,7 +63,13 @@ class ThumbnailApi {
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, binaryMessenger: pigeonVar_binaryMessenger,
); );
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, requestId, width, height]); final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[
assetId,
requestId,
width,
height,
isVideo,
]);
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

@ -30,7 +30,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
final ImageProvider provider; final ImageProvider provider;
if (_shouldUseLocalAsset(asset)) { if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
provider = LocalFullImageProvider(id: id, size: size); provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type);
} else { } else {
final String assetId; final String assetId;
if (asset is LocalAsset && asset.hasRemote) { if (asset is LocalAsset && asset.hasRemote) {
@ -55,7 +55,7 @@ ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Siz
if (_shouldUseLocalAsset(asset!)) { if (_shouldUseLocalAsset(asset!)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
return LocalThumbProvider(id: id, size: size); return LocalThumbProvider(id: id, size: size, assetType: asset.type);
} }
final String assetId; final String assetId;

View file

@ -3,6 +3,7 @@ import 'dart:ui';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.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/infrastructure/repositories/asset_media.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
@ -11,8 +12,9 @@ import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> with CancellableImageProviderMixin { class LocalThumbProvider extends ImageProvider<LocalThumbProvider> with CancellableImageProviderMixin {
final String id; final String id;
final Size size; final Size size;
final AssetType assetType;
LocalThumbProvider({required this.id, this.size = kThumbnailResolution}); LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution});
@override @override
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) { Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
@ -33,7 +35,7 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> with Cancella
} }
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) async* { Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) async* {
final request = this.request = LocalImageRequest(localId: key.id, size: size); final request = this.request = LocalImageRequest(localId: key.id, size: size, assetType: key.assetType);
try { try {
final image = await request.load(decode); final image = await request.load(decode);
if (image != null) { if (image != null) {
@ -60,8 +62,9 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> with Cancella
class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> with CancellableImageProviderMixin { class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> with CancellableImageProviderMixin {
final String id; final String id;
final Size size; final Size size;
final AssetType assetType;
LocalFullImageProvider({required this.id, required this.size}); LocalFullImageProvider({required this.id, required this.assetType, required this.size});
@override @override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) { Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@ -72,7 +75,7 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> with
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
final completer = OneFramePlaceholderImageStreamCompleter( final completer = OneFramePlaceholderImageStreamCompleter(
_codec(key, decode), _codec(key, decode),
initialImage: getCachedImage(LocalThumbProvider(id: key.id)), initialImage: getCachedImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
informationCollector: () => <DiagnosticsNode>[ informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this), DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id), DiagnosticsProperty<String>('Id', key.id),
@ -88,6 +91,7 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> with
final request = this.request = LocalImageRequest( final request = this.request = LocalImageRequest(
localId: key.id, localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio), size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
assetType: key.assetType,
); );
try { try {

View file

@ -3,7 +3,7 @@ import 'dart:ui';
const double kTimelineHeaderExtent = 80.0; const double kTimelineHeaderExtent = 80.0;
const double kTimelineFixedTileExtentPixels = 256; const double kTimelineFixedTileExtentPixels = 256;
const Size kTimelineFixedTileExtent = Size.square(kTimelineFixedTileExtentPixels); const Size kTimelineFixedTileExtent = Size.square(kTimelineFixedTileExtentPixels);
const Size kThumbnailResolution = Size.square(384); const Size kThumbnailResolution = Size.square(256);
const double kTimelineSpacing = 2.0; const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3; const int kTimelineColumnCount = 3;

View file

@ -28,7 +28,7 @@ abstract class SegmentBuilder {
dimension: size.height, dimension: size.height,
spacing: spacing, spacing: spacing,
textDirection: Directionality.of(context), textDirection: Directionality.of(context),
children: List.generate(count, (_) => ThumbnailPlaceholder(width: size.width, height: size.height)), children: List.filled(count, const ThumbnailPlaceholder()),
), ),
); );
} }

View file

@ -15,7 +15,13 @@ import 'package:pigeon/pigeon.dart';
@HostApi() @HostApi()
abstract class ThumbnailApi { abstract class ThumbnailApi {
@async @async
Map<String, int> requestImage(String assetId, {required int requestId, required int width, required int height}); Map<String, int> requestImage(
String assetId, {
required int requestId,
required int width,
required int height,
required bool isVideo,
});
void cancelImageRequest(int requestId); void cancelImageRequest(int requestId);