From 1803692eabbfd2d72dce007cc50dd32a9d5aad72 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sat, 24 Jan 2026 14:34:29 -0500 Subject: [PATCH] feat(mobile): native clients (#21459) * platform clients * uppercase http method * fix hot reload * custom user agent * init before app launch * set defaults * move to bootstrap * unrelated change * disable disk cache by default * optimized decoding * remove incremental * android impl * memory optimization * lock approach is slower on ios * conditional cronet * clarify parameter * enable disk cache * set user agent * flutter-side decode * optimized http * fixed locking * refactor * potential race conditions * embedded cronet * refactor, fix capacity handling * fast path for known content length * ios optimizations * re-enable cache * formatting * bump concurrency * clear cache button * fix eviction race condition * add extra cancellation check * tighten dispose * better error handling * fix disposal --------- Co-authored-by: Alex --- i18n/en.json | 3 + mobile/android/app/CMakeLists.txt | 2 + mobile/android/app/build.gradle | 6 +- .../android/app/src/main/cpp/native_buffer.c | 46 +- .../alextran/immich/HttpSSLOptionsPlugin.kt | 15 +- .../app/alextran/immich/MainActivity.kt | 10 +- .../app/alextran/immich/NativeBuffer.kt | 52 ++ .../app/alextran/immich/core/SSLConfig.kt | 73 +++ .../{Thumbnails.g.kt => LocalImages.g.kt} | 38 +- .../{ThumbnailsImpl.kt => LocalImagesImpl.kt} | 95 ++-- .../alextran/immich/images/RemoteImages.g.kt | 123 ++++ .../immich/images/RemoteImagesImpl.kt | 530 ++++++++++++++++++ .../app/alextran/immich/images/ThumbHash.java | 6 +- mobile/ios/Podfile.lock | 13 + mobile/ios/Runner.xcodeproj/project.pbxproj | 24 +- mobile/ios/Runner/AppDelegate.swift | 3 +- ...Thumbnails.g.swift => LocalImages.g.swift} | 40 +- ...bnailsImpl.swift => LocalImagesImpl.swift} | 129 ++--- mobile/ios/Runner/Images/RemoteImages.g.swift | 134 +++++ .../ios/Runner/Images/RemoteImagesImpl.swift | 184 ++++++ .../infrastructure/loaders/image_request.dart | 81 ++- .../loaders/local_image_request.dart | 9 +- .../loaders/remote_image_request.dart | 172 +----- .../loaders/thumbhash_image_request.dart | 4 +- .../repositories/network.repository.dart | 67 +++ mobile/lib/main.dart | 9 + mobile/lib/platform/local_image_api.g.dart | 137 +++++ mobile/lib/platform/remote_image_api.g.dart | 129 +++++ .../widgets/images/image_provider.dart | 6 +- .../widgets/images/local_image_provider.dart | 5 +- .../widgets/images/remote_image_provider.dart | 11 +- .../widgets/timeline/constants.dart | 4 +- .../cache/remote_image_cache_manager.dart | 131 +---- .../infrastructure/platform.provider.dart | 7 +- mobile/lib/utils/bootstrap.dart | 3 + mobile/lib/utils/bytes_units.dart | 2 +- .../widgets/settings/advanced_settings.dart | 39 ++ mobile/makefile | 6 +- mobile/packages/ui/pubspec.lock | 4 +- ...humbnail_api.dart => local_image_api.dart} | 12 +- mobile/pigeon/remote_image_api.dart | 28 + mobile/pubspec.lock | 40 ++ mobile/pubspec.yaml | 2 + server/src/utils/file.ts | 3 +- 44 files changed, 1881 insertions(+), 556 deletions(-) create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/core/SSLConfig.kt rename mobile/android/app/src/main/kotlin/app/alextran/immich/images/{Thumbnails.g.kt => LocalImages.g.kt} (77%) rename mobile/android/app/src/main/kotlin/app/alextran/immich/images/{ThumbnailsImpl.kt => LocalImagesImpl.kt} (75%) create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt rename mobile/ios/Runner/Images/{Thumbnails.g.swift => LocalImages.g.swift} (68%) rename mobile/ios/Runner/Images/{ThumbnailsImpl.swift => LocalImagesImpl.swift} (58%) create mode 100644 mobile/ios/Runner/Images/RemoteImages.g.swift create mode 100644 mobile/ios/Runner/Images/RemoteImagesImpl.swift create mode 100644 mobile/lib/infrastructure/repositories/network.repository.dart create mode 100644 mobile/lib/platform/local_image_api.g.dart create mode 100644 mobile/lib/platform/remote_image_api.g.dart rename mobile/pigeon/{thumbnail_api.dart => local_image_api.dart} (71%) create mode 100644 mobile/pigeon/remote_image_api.dart diff --git a/i18n/en.json b/i18n/en.json index d0e66ab830..765e03d985 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -451,6 +451,9 @@ "admin_password": "Admin Password", "administration": "Administration", "advanced": "Advanced", + "advanced_settings_clear_image_cache": "Clear Image Cache", + "advanced_settings_clear_image_cache_error": "Failed to clear image cache", + "advanced_settings_clear_image_cache_success": "Successfully cleared {size}", "advanced_settings_enable_alternate_media_filter_subtitle": "Use this option to filter media during sync based on alternate criteria. Only try this if you have issues with the app detecting all albums.", "advanced_settings_enable_alternate_media_filter_title": "[EXPERIMENTAL] Use alternate device album sync filter", "advanced_settings_log_level_title": "Log level: {level}", diff --git a/mobile/android/app/CMakeLists.txt b/mobile/android/app/CMakeLists.txt index 1569f1859e..133bde4fc0 100644 --- a/mobile/android/app/CMakeLists.txt +++ b/mobile/android/app/CMakeLists.txt @@ -8,3 +8,5 @@ project(native_buffer LANGUAGES C) add_library(native_buffer SHARED src/main/cpp/native_buffer.c ) + +target_link_libraries(native_buffer jnigraphics) diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 3c2125e24e..3360617a3d 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -31,7 +31,7 @@ if (keystorePropertiesFile.exists()) { android { compileSdkVersion 35 - ndkVersion = "28.1.13356709" + ndkVersion = "28.2.13676358" compileOptions { sourceCompatibility JavaVersion.VERSION_17 @@ -48,6 +48,7 @@ android { } buildFeatures { + buildConfig true compose true } @@ -105,8 +106,11 @@ dependencies { def serialization_version = '1.8.1' def compose_version = '1.1.1' def gson_version = '2.10.1' + def okhttp_version = '4.12.0' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + implementation "com.squareup.okhttp3:okhttp:$okhttp_version" + implementation 'org.chromium.net:cronet-embedded:143.7445.0' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" implementation "androidx.work:work-runtime-ktx:$work_version" implementation "androidx.concurrent:concurrent-futures:$concurrent_version" diff --git a/mobile/android/app/src/main/cpp/native_buffer.c b/mobile/android/app/src/main/cpp/native_buffer.c index 3720d025f6..bcc9d5c7c8 100644 --- a/mobile/android/app/src/main/cpp/native_buffer.c +++ b/mobile/android/app/src/main/cpp/native_buffer.c @@ -1,40 +1,38 @@ #include #include +#include JNIEXPORT jlong JNICALL -Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_allocateNative( - JNIEnv *env, jclass clazz, jint size) { - void *ptr = malloc(size); - return (jlong) ptr; -} - -JNIEXPORT jlong JNICALL -Java_app_alextran_immich_images_ThumbnailsImpl_allocateNative( +Java_app_alextran_immich_NativeBuffer_allocate( JNIEnv *env, jclass clazz, jint size) { void *ptr = malloc(size); return (jlong) ptr; } JNIEXPORT void JNICALL -Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_freeNative( +Java_app_alextran_immich_NativeBuffer_free( JNIEnv *env, jclass clazz, jlong address) { free((void *) address); } +JNIEXPORT jlong JNICALL +Java_app_alextran_immich_NativeBuffer_realloc( + JNIEnv *env, jclass clazz, jlong address, jint size) { + void *ptr = realloc((void *) address, size); + return (jlong) ptr; +} + +JNIEXPORT jobject JNICALL +Java_app_alextran_immich_NativeBuffer_wrap( + JNIEnv *env, jclass clazz, jlong address, jint capacity) { + return (*env)->NewDirectByteBuffer(env, (void *) address, capacity); +} + JNIEXPORT void JNICALL -Java_app_alextran_immich_images_ThumbnailsImpl_freeNative( - JNIEnv *env, jclass clazz, jlong address) { - free((void *) address); -} - -JNIEXPORT jobject JNICALL -Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_wrapAsBuffer( - JNIEnv *env, jclass clazz, jlong address, jint capacity) { - return (*env)->NewDirectByteBuffer(env, (void *) address, capacity); -} - -JNIEXPORT jobject JNICALL -Java_app_alextran_immich_images_ThumbnailsImpl_wrapAsBuffer( - JNIEnv *env, jclass clazz, jlong address, jint capacity) { - return (*env)->NewDirectByteBuffer(env, (void *) address, capacity); +Java_app_alextran_immich_NativeBuffer_copy( + JNIEnv *env, jclass clazz, jobject buffer, jlong destAddress, jint offset, jint length) { + void *src = (*env)->GetDirectBufferAddress(env, buffer); + if (src != NULL) { + memcpy((void *) destAddress, (char *) src + offset, length); + } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/HttpSSLOptionsPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/HttpSSLOptionsPlugin.kt index 44d2aee2ce..6c22f9e284 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/HttpSSLOptionsPlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/HttpSSLOptionsPlugin.kt @@ -2,6 +2,7 @@ package app.alextran.immich import android.annotation.SuppressLint import android.content.Context +import app.alextran.immich.core.SSLConfig import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodCall @@ -51,15 +52,18 @@ class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { when (call.method) { "apply" -> { val args = call.arguments>()!! + val allowSelfSigned = args[0] as Boolean + val serverHost = args[1] as? String + val clientCertHash = (args[2] as? ByteArray) var tm: Array? = null - if (args[0] as Boolean) { - tm = arrayOf(AllowSelfSignedTrustManager(args[1] as? String)) + if (allowSelfSigned) { + tm = arrayOf(AllowSelfSignedTrustManager(serverHost)) } var km: Array? = null - if (args[2] != null) { - val cert = ByteArrayInputStream(args[2] as ByteArray) + if (clientCertHash != null) { + val cert = ByteArrayInputStream(clientCertHash) val password = (args[3] as String).toCharArray() val keyStore = KeyStore.getInstance("PKCS12") keyStore.load(cert, password) @@ -69,6 +73,9 @@ class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { km = keyManagerFactory.keyManagers } + // Update shared SSL config for OkHttp and other HTTP clients + SSLConfig.apply(km, tm, allowSelfSigned, serverHost, clientCertHash?.contentHashCode() ?: 0) + val sslContext = SSLContext.getInstance("TLS") sslContext.init(km, tm, null) HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index 4383b3098d..08790d9772 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -10,8 +10,10 @@ import app.alextran.immich.background.BackgroundWorkerLockApi import app.alextran.immich.connectivity.ConnectivityApi import app.alextran.immich.connectivity.ConnectivityApiImpl import app.alextran.immich.core.ImmichPlugin -import app.alextran.immich.images.ThumbnailApi -import app.alextran.immich.images.ThumbnailsImpl +import app.alextran.immich.images.LocalImageApi +import app.alextran.immich.images.LocalImagesImpl +import app.alextran.immich.images.RemoteImageApi +import app.alextran.immich.images.RemoteImagesImpl import app.alextran.immich.sync.NativeSyncApi import app.alextran.immich.sync.NativeSyncApiImpl26 import app.alextran.immich.sync.NativeSyncApiImpl30 @@ -36,7 +38,9 @@ class MainActivity : FlutterFragmentActivity() { NativeSyncApiImpl30(ctx) } NativeSyncApi.setUp(messenger, nativeSyncApiImpl) - ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx)) + LocalImageApi.setUp(messenger, LocalImagesImpl(ctx)) + RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx)) + BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx)) ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx)) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt new file mode 100644 index 0000000000..a9011f3047 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt @@ -0,0 +1,52 @@ +package app.alextran.immich + +import java.nio.ByteBuffer + +const val INITIAL_BUFFER_SIZE = 32 * 1024 + +object NativeBuffer { + init { + System.loadLibrary("native_buffer") + } + + @JvmStatic + external fun allocate(size: Int): Long + + @JvmStatic + external fun free(address: Long) + + @JvmStatic + external fun realloc(address: Long, size: Int): Long + + @JvmStatic + external fun wrap(address: Long, capacity: Int): ByteBuffer + + @JvmStatic + external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int) +} + +class NativeByteBuffer(initialCapacity: Int) { + var pointer = NativeBuffer.allocate(initialCapacity) + var capacity = initialCapacity + var offset = 0 + + inline fun ensureHeadroom() { + if (offset == capacity) { + capacity *= 2 + pointer = NativeBuffer.realloc(pointer, capacity) + } + } + + inline fun wrapRemaining() = NativeBuffer.wrap(pointer + offset, capacity - offset) + + inline fun advance(bytesRead: Int) { + offset += bytesRead + } + + inline fun free() { + if (pointer != 0L) { + NativeBuffer.free(pointer) + pointer = 0L + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/SSLConfig.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/SSLConfig.kt new file mode 100644 index 0000000000..f62042cd99 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/SSLConfig.kt @@ -0,0 +1,73 @@ +package app.alextran.immich.core + +import java.security.KeyStore +import javax.net.ssl.KeyManager +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManager +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + +/** + * Shared SSL configuration for OkHttp and HttpsURLConnection. + * Stores the SSLSocketFactory and X509TrustManager configured by HttpSSLOptionsPlugin. + */ +object SSLConfig { + var sslSocketFactory: SSLSocketFactory? = null + private set + + var trustManager: X509TrustManager? = null + private set + + var requiresCustomSSL: Boolean = false + private set + + private val listeners = mutableListOf<() -> Unit>() + private var configHash: Int = 0 + + fun addListener(listener: () -> Unit) { + listeners.add(listener) + } + + fun apply( + keyManagers: Array?, + trustManagers: Array?, + allowSelfSigned: Boolean, + serverHost: String?, + clientCertHash: Int + ) { + synchronized(this) { + val newHash = computeHash(allowSelfSigned, serverHost, clientCertHash) + val newRequiresCustomSSL = allowSelfSigned || keyManagers != null + if (newHash == configHash && sslSocketFactory != null && requiresCustomSSL == newRequiresCustomSSL) { + return // Config unchanged, skip + } + + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(keyManagers, trustManagers, null) + sslSocketFactory = sslContext.socketFactory + trustManager = trustManagers?.filterIsInstance()?.firstOrNull() + ?: getDefaultTrustManager() + requiresCustomSSL = newRequiresCustomSSL + configHash = newHash + notifyListeners() + } + } + + private fun computeHash(allowSelfSigned: Boolean, serverHost: String?, clientCertHash: Int): Int { + var result = allowSelfSigned.hashCode() + result = 31 * result + (serverHost?.hashCode() ?: 0) + result = 31 * result + clientCertHash + return result + } + + private fun notifyListeners() { + listeners.forEach { it() } + } + + private fun getDefaultTrustManager(): X509TrustManager { + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(null as KeyStore?) + return factory.trustManagers.filterIsInstance().first() + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt similarity index 77% rename from mobile/android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt rename to mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt index ae2cca4d7b..5b95daf38b 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt @@ -13,7 +13,7 @@ import io.flutter.plugin.common.StandardMethodCodec import io.flutter.plugin.common.StandardMessageCodec import java.io.ByteArrayOutputStream import java.nio.ByteBuffer -private object ThumbnailsPigeonUtils { +private object LocalImagesPigeonUtils { fun wrapResult(result: Any?): List { return listOf(result) @@ -47,7 +47,7 @@ class FlutterError ( override val message: String? = null, val details: Any? = null ) : Throwable() -private open class ThumbnailsPigeonCodec : StandardMessageCodec() { +private open class LocalImagesPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return super.readValueOfType(type, buffer) } @@ -58,22 +58,22 @@ private open class ThumbnailsPigeonCodec : StandardMessageCodec() { /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ -interface ThumbnailApi { - fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, callback: (Result>) -> Unit) - fun cancelImageRequest(requestId: Long) +interface LocalImageApi { + fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, callback: (Result?>) -> Unit) + fun cancelRequest(requestId: Long) fun getThumbhash(thumbhash: String, callback: (Result>) -> Unit) companion object { - /** The codec used by ThumbnailApi. */ + /** The codec used by LocalImageApi. */ val codec: MessageCodec by lazy { - ThumbnailsPigeonCodec() + LocalImagesPigeonCodec() } - /** Sets up an instance of `ThumbnailApi` to handle messages through the `binaryMessenger`. */ + /** Sets up an instance of `LocalImageApi` to handle messages through the `binaryMessenger`. */ @JvmOverloads - fun setUp(binaryMessenger: BinaryMessenger, api: ThumbnailApi?, messageChannelSuffix: String = "") { + fun setUp(binaryMessenger: BinaryMessenger, api: LocalImageApi?, messageChannelSuffix: String = "") { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage$separatedMessageChannelSuffix", codec) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { message, reply -> val args = message as List @@ -82,13 +82,13 @@ interface ThumbnailApi { val widthArg = args[2] as Long val heightArg = args[3] as Long val isVideoArg = args[4] as Boolean - api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result> -> + api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result?> -> val error = result.exceptionOrNull() if (error != null) { - reply.reply(ThumbnailsPigeonUtils.wrapError(error)) + reply.reply(LocalImagesPigeonUtils.wrapError(error)) } else { val data = result.getOrNull() - reply.reply(ThumbnailsPigeonUtils.wrapResult(data)) + reply.reply(LocalImagesPigeonUtils.wrapResult(data)) } } } @@ -97,16 +97,16 @@ interface ThumbnailApi { } } run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.cancelImageRequest$separatedMessageChannelSuffix", codec) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { message, reply -> val args = message as List val requestIdArg = args[0] as Long val wrapped: List = try { - api.cancelImageRequest(requestIdArg) + api.cancelRequest(requestIdArg) listOf(null) } catch (exception: Throwable) { - ThumbnailsPigeonUtils.wrapError(exception) + LocalImagesPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -115,7 +115,7 @@ interface ThumbnailApi { } } run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.getThumbhash$separatedMessageChannelSuffix", codec) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { message, reply -> val args = message as List @@ -123,10 +123,10 @@ interface ThumbnailApi { api.getThumbhash(thumbhashArg) { result: Result> -> val error = result.exceptionOrNull() if (error != null) { - reply.reply(ThumbnailsPigeonUtils.wrapError(error)) + reply.reply(LocalImagesPigeonUtils.wrapError(error)) } else { val data = result.getOrNull() - reply.reply(ThumbnailsPigeonUtils.wrapResult(data)) + reply.reply(LocalImagesPigeonUtils.wrapResult(data)) } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt similarity index 75% rename from mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt rename to mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt index a9d602c19c..50ff11b0c2 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt @@ -11,7 +11,8 @@ import android.os.OperationCanceledException import android.provider.MediaStore.Images import android.provider.MediaStore.Video import android.util.Size -import java.nio.ByteBuffer +import androidx.annotation.RequiresApi +import app.alextran.immich.NativeBuffer import kotlin.math.* import java.util.concurrent.Executors import com.bumptech.glide.Glide @@ -26,10 +27,42 @@ import java.util.concurrent.Future data class Request( val taskFuture: Future<*>, val cancellationSignal: CancellationSignal, - val callback: (Result>) -> Unit + val callback: (Result?>) -> Unit ) -class ThumbnailsImpl(context: Context) : ThumbnailApi { +@RequiresApi(Build.VERSION_CODES.Q) +inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap { + return ImageDecoder.decodeBitmap(this) { decoder, info, _ -> + if (target.width > 0 && target.height > 0) { + val sample = max(1, min(info.size.width / target.width, info.size.height / target.height)) + decoder.setTargetSampleSize(sample) + } + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB)) + } +} + +fun Bitmap.toNativeBuffer(): Map { + val size = width * height * 4 + val pointer = NativeBuffer.allocate(size) + try { + val buffer = NativeBuffer.wrap(pointer, size) + copyPixelsToBuffer(buffer) + recycle() + return mapOf( + "pointer" to pointer, + "width" to width.toLong(), + "height" to height.toLong(), + "rowBytes" to (width * 4).toLong() + ) + } catch (e: Exception) { + NativeBuffer.free(pointer) + recycle() + throw e + } +} + +class LocalImagesImpl(context: Context) : LocalImageApi { private val ctx: Context = context.applicationContext private val resolver: ContentResolver = ctx.contentResolver private val requestThread = Executors.newSingleThreadExecutor() @@ -38,21 +71,8 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi { private val requestMap = ConcurrentHashMap() companion object { - val CANCELLED = Result.success>(mapOf()) + val CANCELLED = Result.success?>(null) val OPTIONS = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 } - - init { - System.loadLibrary("native_buffer") - } - - @JvmStatic - external fun allocateNative(size: Int): Long - - @JvmStatic - external fun freeNative(pointer: Long) - - @JvmStatic - external fun wrapAsBuffer(address: Long, capacity: Int): ByteBuffer } override fun getThumbhash(thumbhash: String, callback: (Result>) -> Unit) { @@ -63,7 +83,8 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi { 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) { @@ -78,7 +99,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi { width: Long, height: Long, isVideo: Boolean, - callback: (Result>) -> Unit + callback: (Result?>) -> Unit ) { val signal = CancellationSignal() val task = threadPool.submit { @@ -98,7 +119,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi { requestMap[requestId] = request } - override fun cancelImageRequest(requestId: Long) { + override fun cancelRequest(requestId: Long) { val request = requestMap.remove(requestId) ?: return request.taskFuture.cancel(false) request.cancellationSignal.cancel() @@ -117,7 +138,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi { width: Long, height: Long, isVideo: Boolean, - callback: (Result>) -> Unit, + callback: (Result?>) -> Unit, signal: CancellationSignal ) { signal.throwIfCanceled() @@ -131,31 +152,12 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi { decodeImage(id, size, signal) } - processBitmap(bitmap, callback, signal) - } - - private fun processBitmap( - bitmap: Bitmap, callback: (Result>) -> Unit, signal: CancellationSignal - ) { - signal.throwIfCanceled() - val actualWidth = bitmap.width - val actualHeight = bitmap.height - - val size = actualWidth * actualHeight * 4 - val pointer = allocateNative(size) - try { signal.throwIfCanceled() - val buffer = wrapAsBuffer(pointer, size) - bitmap.copyPixelsToBuffer(buffer) - bitmap.recycle() + val res = bitmap.toNativeBuffer() signal.throwIfCanceled() - val res = mapOf( - "pointer" to pointer, "width" to actualWidth.toLong(), "height" to actualHeight.toLong() - ) callback(Result.success(res)) } catch (e: Exception) { - freeNative(pointer) callback(if (e is OperationCanceledException) CANCELLED else Result.failure(e)) } } @@ -191,16 +193,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi { private fun decodeSource(uri: Uri, target: Size, signal: CancellationSignal): Bitmap { signal.throwIfCanceled() return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val source = ImageDecoder.createSource(resolver, uri) - signal.throwIfCanceled() - ImageDecoder.decodeBitmap(source) { decoder, info, _ -> - if (target.width > 0 && target.height > 0) { - val sample = max(1, min(info.size.width / target.width, info.size.height / target.height)) - decoder.setTargetSampleSize(sample) - } - decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE - decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB)) - } + ImageDecoder.createSource(resolver, uri).decodeBitmap(target) } else { val ref = Glide.with(ctx).asBitmap().priority(Priority.IMMEDIATE).load(uri).disallowHardwareConfig() diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt new file mode 100644 index 0000000000..0e3cf19657 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt @@ -0,0 +1,123 @@ +// Autogenerated from Pigeon (v26.0.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package app.alextran.immich.images + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object RemoteImagesPigeonUtils { + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } +} +private open class RemoteImagesPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return super.readValueOfType(type, buffer) + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + super.writeValue(stream, value) + } +} + + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface RemoteImageApi { + fun requestImage(url: String, headers: Map, requestId: Long, callback: (Result?>) -> Unit) + fun cancelRequest(requestId: Long) + fun clearCache(callback: (Result) -> Unit) + + companion object { + /** The codec used by RemoteImageApi. */ + val codec: MessageCodec by lazy { + RemoteImagesPigeonCodec() + } + /** Sets up an instance of `RemoteImageApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: RemoteImageApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val urlArg = args[0] as String + val headersArg = args[1] as Map + val requestIdArg = args[2] as Long + api.requestImage(urlArg, headersArg, requestIdArg) { result: Result?> -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(RemoteImagesPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(RemoteImagesPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val requestIdArg = args[0] as Long + val wrapped: List = try { + api.cancelRequest(requestIdArg) + listOf(null) + } catch (exception: Throwable) { + RemoteImagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearCache$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.clearCache{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(RemoteImagesPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(RemoteImagesPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt new file mode 100644 index 0000000000..6800b45a70 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -0,0 +1,530 @@ +package app.alextran.immich.images + +import android.content.Context +import android.os.CancellationSignal +import android.os.OperationCanceledException +import app.alextran.immich.BuildConfig +import app.alextran.immich.INITIAL_BUFFER_SIZE +import app.alextran.immich.NativeBuffer +import app.alextran.immich.NativeByteBuffer +import app.alextran.immich.core.SSLConfig +import kotlinx.coroutines.* +import okhttp3.Cache +import okhttp3.Call +import okhttp3.Callback +import okhttp3.ConnectionPool +import okhttp3.Dispatcher +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.chromium.net.CronetEngine +import org.chromium.net.CronetException +import org.chromium.net.UrlRequest +import org.chromium.net.UrlResponseInfo +import java.io.EOFException +import java.io.File +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.X509TrustManager + + +private const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}" +private const val MAX_REQUESTS_PER_HOST = 64 +private const val KEEP_ALIVE_CONNECTIONS = 10 +private const val KEEP_ALIVE_DURATION_MINUTES = 5L +private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024 + +private class RemoteRequest(val cancellationSignal: CancellationSignal) + +class RemoteImagesImpl(context: Context) : RemoteImageApi { + private val requestMap = ConcurrentHashMap() + + init { + ImageFetcherManager.initialize(context) + } + + companion object { + val CANCELLED = Result.success?>(null) + } + + override fun requestImage( + url: String, + headers: Map, + requestId: Long, + callback: (Result?>) -> Unit + ) { + val signal = CancellationSignal() + requestMap[requestId] = RemoteRequest(signal) + + ImageFetcherManager.fetch( + url, + headers, + signal, + onSuccess = { buffer -> + requestMap.remove(requestId) + if (signal.isCanceled) { + NativeBuffer.free(buffer.pointer) + return@fetch callback(CANCELLED) + } + + callback( + Result.success( + mapOf( + "pointer" to buffer.pointer, + "length" to buffer.offset.toLong() + ) + ) + ) + }, + onFailure = { e -> + requestMap.remove(requestId) + val result = if (signal.isCanceled) CANCELLED else Result.failure(e) + callback(result) + } + ) + } + + override fun cancelRequest(requestId: Long) { + requestMap.remove(requestId)?.cancellationSignal?.cancel() + } + + override fun clearCache(callback: (Result) -> Unit) { + CoroutineScope(Dispatchers.IO).launch { + try { + ImageFetcherManager.clearCache(callback) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } + } +} + +private object ImageFetcherManager { + private lateinit var appContext: Context + private lateinit var cacheDir: File + private lateinit var fetcher: ImageFetcher + private var initialized = false + + fun initialize(context: Context) { + if (initialized) return + synchronized(this) { + if (initialized) return + appContext = context.applicationContext + cacheDir = context.cacheDir + fetcher = build() + SSLConfig.addListener(::invalidate) + initialized = true + } + } + + fun fetch( + url: String, + headers: Map, + signal: CancellationSignal, + onSuccess: (NativeByteBuffer) -> Unit, + onFailure: (Exception) -> Unit, + ) { + fetcher.fetch(url, headers, signal, onSuccess, onFailure) + } + + fun clearCache(onCleared: (Result) -> Unit) { + fetcher.clearCache(onCleared) + } + + private fun invalidate() { + synchronized(this) { + val oldFetcher = fetcher + if (oldFetcher is OkHttpImageFetcher && SSLConfig.requiresCustomSSL) { + fetcher = oldFetcher.reconfigure(SSLConfig.sslSocketFactory, SSLConfig.trustManager) + return + } + fetcher = build() + oldFetcher.drain() + } + } + + private fun build(): ImageFetcher { + return if (SSLConfig.requiresCustomSSL) { + OkHttpImageFetcher.create(cacheDir, SSLConfig.sslSocketFactory, SSLConfig.trustManager) + } else { + CronetImageFetcher(appContext, cacheDir) + } + } +} + +private sealed interface ImageFetcher { + fun fetch( + url: String, + headers: Map, + signal: CancellationSignal, + onSuccess: (NativeByteBuffer) -> Unit, + onFailure: (Exception) -> Unit, + ) + + fun drain() + + fun clearCache(onCleared: (Result) -> Unit) +} + +private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher { + private val ctx = context + private var engine: CronetEngine + private val executor = Executors.newFixedThreadPool(4) + private val stateLock = Any() + private var activeCount = 0 + private var draining = false + private var onCacheCleared: ((Result) -> Unit)? = null + private val storageDir = File(cacheDir, "cronet").apply { mkdirs() } + + init { + engine = build(context) + } + + override fun fetch( + url: String, + headers: Map, + signal: CancellationSignal, + onSuccess: (NativeByteBuffer) -> Unit, + onFailure: (Exception) -> Unit, + ) { + synchronized(stateLock) { + if (draining) { + onFailure(IllegalStateException("Engine is draining")) + return + } + activeCount++ + } + + val callback = FetchCallback(onSuccess, onFailure, ::onComplete) + val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor) + headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } + val request = requestBuilder.build() + signal.setOnCancelListener(request::cancel) + request.start() + } + + private fun build(ctx: Context): CronetEngine { + return CronetEngine.Builder(ctx) + .enableHttp2(true) + .enableQuic(true) + .enableBrotli(true) + .setStoragePath(storageDir.absolutePath) + .setUserAgent(USER_AGENT) + .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES) + .build() + } + + private fun onComplete() { + val didDrain = synchronized(stateLock) { + activeCount-- + draining && activeCount == 0 + } + if (didDrain) { + onDrained() + } + } + + override fun drain() { + val didDrain = synchronized(stateLock) { + if (draining) return + draining = true + activeCount == 0 + } + if (didDrain) { + onDrained() + } + } + + private fun onDrained() { + engine.shutdown() + val onCacheCleared = synchronized(stateLock) { + val onCacheCleared = onCacheCleared + this.onCacheCleared = null + onCacheCleared + } + if (onCacheCleared == null) { + executor.shutdown() + } else { + CoroutineScope(Dispatchers.IO).launch { + val result = runCatching { deleteFolderAndGetSize(storageDir.toPath()) } + // Cronet is very good at self-repair, so it shouldn't fail here regardless of clear result + engine = build(ctx) + synchronized(stateLock) { draining = false } + onCacheCleared(result) + } + } + } + + override fun clearCache(onCleared: (Result) -> Unit) { + synchronized(stateLock) { + if (onCacheCleared != null) { + return onCleared(Result.success(-1)) + } + onCacheCleared = onCleared + } + drain() + } + + private class FetchCallback( + private val onSuccess: (NativeByteBuffer) -> Unit, + private val onFailure: (Exception) -> Unit, + private val onComplete: () -> Unit, + ) : UrlRequest.Callback() { + private var buffer: NativeByteBuffer? = null + private var wrapped: ByteBuffer? = null + private var error: Exception? = null + + override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) { + request.followRedirect() + } + + override fun onResponseStarted(request: UrlRequest, info: UrlResponseInfo) { + if (info.httpStatusCode !in 200..299) { + error = IOException("HTTP ${info.httpStatusCode}: ${info.httpStatusText}") + return request.cancel() + } + + try { + val contentLength = info.allHeaders["content-length"]?.firstOrNull()?.toIntOrNull() ?: 0 + if (contentLength > 0) { + buffer = NativeByteBuffer(contentLength + 1) + wrapped = NativeBuffer.wrap(buffer!!.pointer, contentLength + 1) + request.read(wrapped) + } else { + buffer = NativeByteBuffer(INITIAL_BUFFER_SIZE) + request.read(buffer!!.wrapRemaining()) + } + } catch (e: Exception) { + error = e + return request.cancel() + } + } + + override fun onReadCompleted( + request: UrlRequest, + info: UrlResponseInfo, + byteBuffer: ByteBuffer + ) { + try { + val buf = if (wrapped == null) { + buffer!!.run { + advance(byteBuffer.position()) + ensureHeadroom() + wrapRemaining() + } + } else { + wrapped + } + request.read(buf) + } catch (e: Exception) { + error = e + return request.cancel() + } + } + + override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) { + wrapped?.let { buffer!!.advance(it.position()) } + onSuccess(buffer!!) + onComplete() + } + + override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException) { + buffer?.free() + onFailure(error) + onComplete() + } + + override fun onCanceled(request: UrlRequest, info: UrlResponseInfo?) { + buffer?.free() + onFailure(error ?: OperationCanceledException()) + onComplete() + } + } + + suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) { + var totalSize = 0L + + Files.walkFileTree(root, object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + totalSize += attrs.size() + Files.delete(file) + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { + if (dir != root) { + Files.delete(dir) + } + return FileVisitResult.CONTINUE + } + }) + + totalSize + } +} + +private class OkHttpImageFetcher private constructor( + private val client: OkHttpClient, +) : ImageFetcher { + private val stateLock = Any() + private var activeCount = 0 + private var draining = false + + companion object { + fun create( + cacheDir: File, + sslSocketFactory: SSLSocketFactory?, + trustManager: X509TrustManager?, + ): OkHttpImageFetcher { + val dir = File(cacheDir, "okhttp") + val connectionPool = ConnectionPool( + maxIdleConnections = KEEP_ALIVE_CONNECTIONS, + keepAliveDuration = KEEP_ALIVE_DURATION_MINUTES, + timeUnit = TimeUnit.MINUTES + ) + + val builder = OkHttpClient.Builder() + .addInterceptor { chain -> + chain.proceed( + chain.request().newBuilder() + .header("User-Agent", USER_AGENT) + .build() + ) + } + .dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST }) + .connectionPool(connectionPool) + .cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES)) + + if (sslSocketFactory != null && trustManager != null) { + builder.sslSocketFactory(sslSocketFactory, trustManager) + } + + return OkHttpImageFetcher(builder.build()) + } + } + + fun reconfigure( + sslSocketFactory: SSLSocketFactory?, + trustManager: X509TrustManager?, + ): OkHttpImageFetcher { + val builder = client.newBuilder() + if (sslSocketFactory != null && trustManager != null) { + builder.sslSocketFactory(sslSocketFactory, trustManager) + } + // Evict idle connections using old SSL config + client.connectionPool.evictAll() + return OkHttpImageFetcher(builder.build()) + } + + private fun onComplete() { + val shouldClose = synchronized(stateLock) { + activeCount-- + draining && activeCount == 0 + } + if (shouldClose) { + client.cache?.close() + } + } + + override fun fetch( + url: String, + headers: Map, + signal: CancellationSignal, + onSuccess: (NativeByteBuffer) -> Unit, + onFailure: (Exception) -> Unit, + ) { + synchronized(stateLock) { + if (draining) { + return onFailure(IllegalStateException("Client is draining")) + } + activeCount++ + } + + val requestBuilder = Request.Builder().url(url) + headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } + val call = client.newCall(requestBuilder.build()) + signal.setOnCancelListener(call::cancel) + + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + onFailure(e) + onComplete() + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (!response.isSuccessful) { + return onFailure(IOException("HTTP ${response.code}: ${response.message}")).also { onComplete() } + } + + val body = response.body + ?: return onFailure(IOException("Empty response body")).also { onComplete() } + + if (call.isCanceled()) { + onFailure(OperationCanceledException()) + return onComplete() + } + + body.source().use { source -> + val length = body.contentLength().toInt() + val buffer = NativeByteBuffer(if (length > 0) length else INITIAL_BUFFER_SIZE) + try { + if (length > 0) { + val wrapped = NativeBuffer.wrap(buffer.pointer, length) + while (wrapped.hasRemaining()) { + if (call.isCanceled()) throw OperationCanceledException() + if (source.read(wrapped) == -1) throw EOFException() + } + buffer.advance(length) + } else { + while (true) { + if (call.isCanceled()) throw OperationCanceledException() + val bytesRead = source.read(buffer.wrapRemaining()) + if (bytesRead == -1) break + buffer.advance(bytesRead) + buffer.ensureHeadroom() + } + } + onSuccess(buffer) + } catch (e: Exception) { + buffer.free() + onFailure(e) + } + onComplete() + } + } + } + }) + } + + override fun drain() { + val shouldClose = synchronized(stateLock) { + if (draining) return + draining = true + activeCount == 0 + } + client.connectionPool.evictAll() + if (shouldClose) { + client.cache?.close() + } + } + + override fun clearCache(onCleared: (Result) -> Unit) { + try { + val size = client.cache!!.size() + client.cache!!.evictAll() + onCleared(Result.success(size)) + } catch (e: Exception) { + onCleared(Result.failure(e)) + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbHash.java b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbHash.java index 3af76b5763..02b11b61da 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbHash.java +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbHash.java @@ -7,6 +7,8 @@ package app.alextran.immich.images; import java.nio.ByteBuffer; +import app.alextran.immich.NativeBuffer; + // modified to use native allocations public final class ThumbHash { /** @@ -56,8 +58,8 @@ public final class ThumbHash { 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 size = w * h * 4; - long pointer = ThumbnailsImpl.allocateNative(size); - ByteBuffer rgba = ThumbnailsImpl.wrapAsBuffer(pointer, size); + long pointer = NativeBuffer.allocate(size); + ByteBuffer rgba = NativeBuffer.wrap(pointer, size); int cx_stop = Math.max(lx, hasAlpha ? 5 : 3); int cy_stop = Math.max(ly, hasAlpha ? 5 : 3); float[] fx = new float[cx_stop]; diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index d869aa9c08..77caaeceef 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -6,6 +6,9 @@ PODS: - FlutterMacOS - connectivity_plus (0.0.1): - Flutter + - cupertino_http (0.0.1): + - Flutter + - FlutterMacOS - device_info_plus (0.0.1): - Flutter - DKImagePickerController/Core (4.3.9): @@ -77,6 +80,8 @@ 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): @@ -136,6 +141,7 @@ DEPENDENCIES: - background_downloader (from `.symlinks/plugins/background_downloader/ios`) - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) @@ -154,6 +160,7 @@ 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`) @@ -184,6 +191,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/bonsoir_darwin/darwin" connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" + cupertino_http: + :path: ".symlinks/plugins/cupertino_http/darwin" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" file_picker: @@ -220,6 +229,8 @@ 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: @@ -249,6 +260,7 @@ SPEC CHECKSUMS: background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd + cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 @@ -270,6 +282,7 @@ 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 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 599e7990f4..7a9a3770d0 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -29,9 +29,11 @@ FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; }; FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; }; + FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F12F1197D8006016CB /* LocalImages.g.swift */; }; + FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F22F1197D8006016CB /* RemoteImages.g.swift */; }; + FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F52F11980E006016CB /* LocalImagesImpl.swift */; }; + FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */; }; FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; }; - FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; }; - FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; }; FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; }; FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; }; FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; }; @@ -118,9 +120,11 @@ FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = ""; }; + FE5499F12F1197D8006016CB /* LocalImages.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalImages.g.swift; sourceTree = ""; }; + FE5499F22F1197D8006016CB /* RemoteImages.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImages.g.swift; sourceTree = ""; }; + FE5499F52F11980E006016CB /* LocalImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalImagesImpl.swift; sourceTree = ""; }; + FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImagesImpl.swift; sourceTree = ""; }; FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = ""; }; - FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = ""; }; - FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailsImpl.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -321,9 +325,11 @@ FED3B1952E253E9B0030FD97 /* Images */ = { isa = PBXGroup; children = ( + FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */, + FE5499F52F11980E006016CB /* LocalImagesImpl.swift */, + FE5499F12F1197D8006016CB /* LocalImages.g.swift */, + FE5499F22F1197D8006016CB /* RemoteImages.g.swift */, FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */, - FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */, - FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */, ); path = Images; sourceTree = ""; @@ -600,12 +606,14 @@ 65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */, + FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */, + FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */, + FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */, B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */, + FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */, FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */, B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */, - FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */, B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */, - FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */, 65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 108fb7e2aa..60f97b6645 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -53,7 +53,8 @@ import UIKit public static func registerPlugins(with engine: FlutterEngine) { NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!) - ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl()) + LocalImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: LocalImageApiImpl()) + RemoteImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: RemoteImageApiImpl()) BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl()) ConnectivityApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ConnectivityApiImpl()) } diff --git a/mobile/ios/Runner/Images/Thumbnails.g.swift b/mobile/ios/Runner/Images/LocalImages.g.swift similarity index 68% rename from mobile/ios/Runner/Images/Thumbnails.g.swift rename to mobile/ios/Runner/Images/LocalImages.g.swift index fbaef294d3..d417f10222 100644 --- a/mobile/ios/Runner/Images/Thumbnails.g.swift +++ b/mobile/ios/Runner/Images/LocalImages.g.swift @@ -47,41 +47,41 @@ private func nilOrValue(_ value: Any?) -> T? { } -private class ThumbnailsPigeonCodecReader: FlutterStandardReader { +private class LocalImagesPigeonCodecReader: FlutterStandardReader { } -private class ThumbnailsPigeonCodecWriter: FlutterStandardWriter { +private class LocalImagesPigeonCodecWriter: FlutterStandardWriter { } -private class ThumbnailsPigeonCodecReaderWriter: FlutterStandardReaderWriter { +private class LocalImagesPigeonCodecReaderWriter: FlutterStandardReaderWriter { override func reader(with data: Data) -> FlutterStandardReader { - return ThumbnailsPigeonCodecReader(data: data) + return LocalImagesPigeonCodecReader(data: data) } override func writer(with data: NSMutableData) -> FlutterStandardWriter { - return ThumbnailsPigeonCodecWriter(data: data) + return LocalImagesPigeonCodecWriter(data: data) } } -class ThumbnailsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { - static let shared = ThumbnailsPigeonCodec(readerWriter: ThumbnailsPigeonCodecReaderWriter()) +class LocalImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = LocalImagesPigeonCodec(readerWriter: LocalImagesPigeonCodecReaderWriter()) } /// Generated protocol from Pigeon that represents a handler of messages from Flutter. -protocol ThumbnailApi { - func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], Error>) -> Void) - func cancelImageRequest(requestId: Int64) throws +protocol LocalImageApi { + func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) + func cancelRequest(requestId: Int64) throws func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. -class ThumbnailApiSetup { - static var codec: FlutterStandardMessageCodec { ThumbnailsPigeonCodec.shared } - /// Sets up an instance of `ThumbnailApi` to handle messages through the `binaryMessenger`. - static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ThumbnailApi?, messageChannelSuffix: String = "") { +class LocalImageApiSetup { + static var codec: FlutterStandardMessageCodec { LocalImagesPigeonCodec.shared } + /// Sets up an instance of `LocalImageApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: LocalImageApi?, messageChannelSuffix: String = "") { let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" - let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { requestImageChannel.setMessageHandler { message, reply in let args = message as! [Any?] @@ -102,22 +102,22 @@ class ThumbnailApiSetup { } else { requestImageChannel.setMessageHandler(nil) } - let cancelImageRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.cancelImageRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + let cancelRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { - cancelImageRequestChannel.setMessageHandler { message, reply in + cancelRequestChannel.setMessageHandler { message, reply in let args = message as! [Any?] let requestIdArg = args[0] as! Int64 do { - try api.cancelImageRequest(requestId: requestIdArg) + try api.cancelRequest(requestId: requestIdArg) reply(wrapResult(nil)) } catch { reply(wrapError(error)) } } } else { - cancelImageRequestChannel.setMessageHandler(nil) + cancelRequestChannel.setMessageHandler(nil) } - let getThumbhashChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.getThumbhash\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + let getThumbhashChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { getThumbhashChannel.setMessageHandler { message, reply in let args = message as! [Any?] diff --git a/mobile/ios/Runner/Images/ThumbnailsImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift similarity index 58% rename from mobile/ios/Runner/Images/ThumbnailsImpl.swift rename to mobile/ios/Runner/Images/LocalImagesImpl.swift index 452ca62377..4f2090443a 100644 --- a/mobile/ios/Runner/Images/ThumbnailsImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -1,19 +1,19 @@ -import CryptoKit +import Accelerate import Flutter import MobileCoreServices import Photos -class Request { +class LocalImageRequest { weak var workItem: DispatchWorkItem? 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 } } -class ThumbnailApiImpl: ThumbnailApi { +class LocalImageApiImpl: LocalImageApi { private static let imageManager = PHImageManager.default() private static let fetchOptions = { let fetchOptions = PHFetchOptions() @@ -36,47 +36,39 @@ class ThumbnailApiImpl: ThumbnailApi { private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default) private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent) - private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB() - private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue - private static var requests = [Int64: Request]() - private static let cancelledResult = Result<[String: Int64], any Error>.success([:]) + private static var rgbaFormat = vImage_CGImageFormat( + bitsPerComponent: 8, + bitsPerPixel: 32, + colorSpace: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), + renderingIntent: .defaultIntent + )! + private static var requests = [Int64: LocalImageRequest]() + private static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil) private static let concurrencySemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2) private static let assetCache = { let assetCache = NSCache() assetCache.countLimit = 10000 return assetCache }() - private static let activitySemaphore = DispatchSemaphore(value: 1) - private static let willResignActiveObserver = NotificationCenter.default.addObserver( - forName: UIApplication.willResignActiveNotification, - object: nil, - queue: .main - ) { _ in - processingQueue.suspend() - activitySemaphore.wait() - } - private static let didBecomeActiveObserver = NotificationCenter.default.addObserver( - forName: UIApplication.didBecomeActiveNotification, - object: nil, - queue: .main - ) { _ in - processingQueue.resume() - activitySemaphore.signal() - } func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) { Self.processingQueue.async { guard let data = Data(base64Encoded: thumbhash) else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))} - + let (width, height, pointer) = thumbHashToRGBA(hash: data) - self.waitForActiveState() - completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)])) + completion(.success([ + "pointer": Int64(Int(bitPattern: pointer.baseAddress)), + "width": Int64(width), + "height": Int64(height), + "rowBytes": Int64(width * 4) + ])) } } - func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], any Error>) -> Void) { - let request = Request(callback: completion) + func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { + let request = LocalImageRequest(callback: completion) let item = DispatchWorkItem { if request.isCancelled { return completion(Self.cancelledResult) @@ -93,7 +85,7 @@ class ThumbnailApiImpl: ThumbnailApi { guard let asset = Self.requestAsset(assetId: assetId) else { - Self.removeRequest(requestId: requestId) + Self.remove(requestId: requestId) completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))) return } @@ -119,70 +111,54 @@ class ThumbnailApiImpl: ThumbnailApi { guard let image = image, let cgImage = image.cgImage else { - Self.removeRequest(requestId: requestId) + Self.remove(requestId: requestId) return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) } - let pointer = UnsafeMutableRawPointer.allocate( - byteCount: Int(cgImage.width) * Int(cgImage.height) * 4, - alignment: MemoryLayout.alignment - ) - if request.isCancelled { - pointer.deallocate() return completion(Self.cancelledResult) } - guard let context = CGContext( - data: pointer, - width: cgImage.width, - height: cgImage.height, - bitsPerComponent: 8, - bytesPerRow: cgImage.width * 4, - space: Self.rgbColorSpace, - bitmapInfo: Self.bitmapInfo - ) else { - pointer.deallocate() - Self.removeRequest(requestId: requestId) - return completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil))) + do { + let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat) + + if request.isCancelled { + buffer.free() + return completion(Self.cancelledResult) + } + + request.callback(.success([ + "pointer": Int64(Int(bitPattern: buffer.data)), + "width": Int64(buffer.width), + "height": Int64(buffer.height), + "rowBytes": Int64(buffer.rowBytes) + ])) + print("Successful response for \(requestId)") + Self.remove(requestId: requestId) + } catch { + Self.remove(requestId: requestId) + return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil))) } - - if request.isCancelled { - pointer.deallocate() - return completion(Self.cancelledResult) - } - - context.interpolationQuality = .none - context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height)) - - if request.isCancelled { - pointer.deallocate() - return completion(Self.cancelledResult) - } - - self.waitForActiveState() - completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)])) - Self.removeRequest(requestId: requestId) } request.workItem = item - Self.addRequest(requestId: requestId, request: request) + Self.add(requestId: requestId, request: request) Self.processingQueue.async(execute: item) } - func cancelImageRequest(requestId: Int64) { - Self.cancelRequest(requestId: requestId) + func cancelRequest(requestId: Int64) { + Self.cancel(requestId: requestId) } - private static func addRequest(requestId: Int64, request: Request) -> Void { + private static func add(requestId: Int64, request: LocalImageRequest) -> Void { requestQueue.sync { requests[requestId] = request } } - private static func removeRequest(requestId: Int64) -> Void { + private static func remove(requestId: Int64) -> Void { requestQueue.sync { requests[requestId] = nil } } - private static func cancelRequest(requestId: Int64) -> Void { + private static func cancel(requestId: Int64) -> Void { requestQueue.async { guard let request = requests.removeValue(forKey: requestId) else { return } request.isCancelled = true @@ -203,9 +179,4 @@ class ThumbnailApiImpl: ThumbnailApi { assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) } return asset } - - func waitForActiveState() { - Self.activitySemaphore.wait() - Self.activitySemaphore.signal() - } } diff --git a/mobile/ios/Runner/Images/RemoteImages.g.swift b/mobile/ios/Runner/Images/RemoteImages.g.swift new file mode 100644 index 0000000000..fc83b09d4b --- /dev/null +++ b/mobile/ios/Runner/Images/RemoteImages.g.swift @@ -0,0 +1,134 @@ +// Autogenerated from Pigeon (v26.0.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + + +private class RemoteImagesPigeonCodecReader: FlutterStandardReader { +} + +private class RemoteImagesPigeonCodecWriter: FlutterStandardWriter { +} + +private class RemoteImagesPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return RemoteImagesPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return RemoteImagesPigeonCodecWriter(data: data) + } +} + +class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = RemoteImagesPigeonCodec(readerWriter: RemoteImagesPigeonCodecReaderWriter()) +} + + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol RemoteImageApi { + func requestImage(url: String, headers: [String: String], requestId: Int64, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) + func cancelRequest(requestId: Int64) throws + func clearCache(completion: @escaping (Result) -> Void) +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class RemoteImageApiSetup { + static var codec: FlutterStandardMessageCodec { RemoteImagesPigeonCodec.shared } + /// Sets up an instance of `RemoteImageApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: RemoteImageApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + requestImageChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let urlArg = args[0] as! String + let headersArg = args[1] as! [String: String] + let requestIdArg = args[2] as! Int64 + api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + requestImageChannel.setMessageHandler(nil) + } + let cancelRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + cancelRequestChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let requestIdArg = args[0] as! Int64 + do { + try api.cancelRequest(requestId: requestIdArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + cancelRequestChannel.setMessageHandler(nil) + } + let clearCacheChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearCache\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + clearCacheChannel.setMessageHandler { _, reply in + api.clearCache { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + clearCacheChannel.setMessageHandler(nil) + } + } +} diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift new file mode 100644 index 0000000000..5c242e18a9 --- /dev/null +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -0,0 +1,184 @@ +import Accelerate +import Flutter +import MobileCoreServices +import Photos + +class RemoteImageRequest { + weak var task: URLSessionDataTask? + let id: Int64 + var isCancelled = false + var data: CFMutableData? + let completion: (Result<[String: Int64]?, any Error>) -> Void + + init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { + self.id = id + self.task = task + self.data = nil + self.completion = completion + } +} + +class RemoteImageApiImpl: NSObject, RemoteImageApi { + private static let delegate = RemoteImageApiDelegate() + static let session = { + let cacheDir = FileManager.default.temporaryDirectory.appendingPathComponent("thumbnails", isDirectory: true) + let config = URLSessionConfiguration.default + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" + config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"] + try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + config.urlCache = URLCache( + memoryCapacity: 0, + diskCapacity: 1 << 30, + directory: cacheDir + ) + config.httpMaximumConnectionsPerHost = 64 + return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + }() + + func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { + var urlRequest = URLRequest(url: URL(string: url)!) + for (key, value) in headers { + urlRequest.setValue(value, forHTTPHeaderField: key) + } + let task = Self.session.dataTask(with: urlRequest) + + let imageRequest = RemoteImageRequest(id: requestId, task: task, completion: completion) + Self.delegate.add(taskId: task.taskIdentifier, request: imageRequest) + + task.resume() + } + + func cancelRequest(requestId: Int64) { + Self.delegate.cancel(requestId: requestId) + } + + func clearCache(completion: @escaping (Result) -> Void) { + Task { + let cache = Self.session.configuration.urlCache! + let cacheSize = Int64(cache.currentDiskUsage) + cache.removeAllCachedResponses() + completion(.success(cacheSize)) + } + } +} + +class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate { + private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated, attributes: .concurrent) + private static var rgbaFormat = vImage_CGImageFormat( + bitsPerComponent: 8, + bitsPerPixel: 32, + colorSpace: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), + renderingIntent: .perceptual + )! + private static var requestByTaskId = [Int: RemoteImageRequest]() + private static var taskIdByRequestId = [Int64: Int]() + private static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil) + private static let decodeOptions = [ + kCGImageSourceShouldCache: false, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceCreateThumbnailWithTransform: true, + ] as CFDictionary + + func urlSession( + _ session: URLSession, dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + guard let request = get(taskId: dataTask.taskIdentifier) + else { + return completionHandler(.cancel) + } + + let capacity = max(Int(response.expectedContentLength), 0) + request.data = CFDataCreateMutable(nil, capacity) + + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive data: Data) { + guard let request = get(taskId: dataTask.taskIdentifier) else { return } + + data.withUnsafeBytes { bytes in + CFDataAppendBytes(request.data, bytes.bindMemory(to: UInt8.self).baseAddress, data.count) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, + didCompleteWithError error: Error?) { + guard let request = get(taskId: task.taskIdentifier) else { return } + + defer { remove(taskId: task.taskIdentifier, requestId: request.id) } + + if let error = error { + if request.isCancelled || (error as NSError).code == NSURLErrorCancelled { + return request.completion(Self.cancelledResult) + } + return request.completion(.failure(error)) + } + + if request.isCancelled { + return request.completion(Self.cancelledResult) + } + + guard let data = request.data else { + return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil))) + } + + guard let imageSource = CGImageSourceCreateWithData(data, nil), + let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, Self.decodeOptions) else { + return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil))) + } + + if request.isCancelled { + return request.completion(Self.cancelledResult) + } + + do { + let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat) + + if request.isCancelled { + buffer.free() + return request.completion(Self.cancelledResult) + } + + request.completion( + .success([ + "pointer": Int64(Int(bitPattern: buffer.data)), + "width": Int64(buffer.width), + "height": Int64(buffer.height), + "rowBytes": Int64(buffer.rowBytes), + ])) + } catch { + return request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil))) + } + } + + @inline(__always) func get(taskId: Int) -> RemoteImageRequest? { + Self.requestQueue.sync { Self.requestByTaskId[taskId] } + } + + @inline(__always) func add(taskId: Int, request: RemoteImageRequest) -> Void { + Self.requestQueue.async(flags: .barrier) { + Self.requestByTaskId[taskId] = request + Self.taskIdByRequestId[request.id] = taskId + } + } + + @inline(__always) func remove(taskId: Int, requestId: Int64) -> Void { + Self.requestQueue.async(flags: .barrier) { + Self.taskIdByRequestId[requestId] = nil + Self.requestByTaskId[taskId] = nil + } + } + + @inline(__always) func cancel(requestId: Int64) -> Void { + guard let request: RemoteImageRequest = (Self.requestQueue.sync { + guard let taskId = Self.taskIdByRequestId[requestId] else { return nil } + return Self.requestByTaskId[taskId] + }) else { return } + request.isCancelled = true + request.task?.cancel() + } +} diff --git a/mobile/lib/infrastructure/loaders/image_request.dart b/mobile/lib/infrastructure/loaders/image_request.dart index d839b8bdf6..5be7b57835 100644 --- a/mobile/lib/infrastructure/loaders/image_request.dart +++ b/mobile/lib/infrastructure/loaders/image_request.dart @@ -1,15 +1,12 @@ import 'dart:async'; import 'dart:ffi'; -import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:ffi/ffi.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/infrastructure/platform.provider.dart'; -import 'package:logging/logging.dart'; part 'local_image_request.dart'; part 'thumbhash_image_request.dart'; @@ -37,27 +34,61 @@ abstract class ImageRequest { void _onCancelled(); - Future _fromPlatformImage(Map info) async { - final address = info['pointer']; - if (address == null) { - return null; - } - + Future _fromEncodedPlatformImage(int address, int length) async { final pointer = Pointer.fromAddress(address); if (_isCancelled) { malloc.free(pointer); return null; } - final int actualWidth; - final int actualHeight; - final int actualSize; final ui.ImmutableBuffer buffer; try { - actualWidth = info['width']!; - actualHeight = info['height']!; - actualSize = actualWidth * actualHeight * 4; - buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(actualSize)); + buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(length)); + } finally { + malloc.free(pointer); + } + + if (_isCancelled) { + buffer.dispose(); + return null; + } + + final descriptor = await ui.ImageDescriptor.encoded(buffer); + buffer.dispose(); + if (_isCancelled) { + descriptor.dispose(); + return null; + } + + final codec = await descriptor.instantiateCodec(); + if (_isCancelled) { + descriptor.dispose(); + codec.dispose(); + return null; + } + + final frame = await codec.getNextFrame(); + descriptor.dispose(); + codec.dispose(); + if (_isCancelled) { + frame.image.dispose(); + return null; + } + + return frame; + } + + Future _fromDecodedPlatformImage(int address, int width, int height, int rowBytes) async { + final pointer = Pointer.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); } @@ -69,18 +100,28 @@ abstract class ImageRequest { final descriptor = ui.ImageDescriptor.raw( buffer, - width: actualWidth, - height: actualHeight, + width: width, + height: height, + rowBytes: rowBytes, pixelFormat: ui.PixelFormat.rgba8888, ); + buffer.dispose(); + final codec = await descriptor.instantiateCodec(); if (_isCancelled) { - buffer.dispose(); descriptor.dispose(); codec.dispose(); return null; } - return await codec.getNextFrame(); + final frame = await codec.getNextFrame(); + descriptor.dispose(); + codec.dispose(); + if (_isCancelled) { + frame.image.dispose(); + return null; + } + + return frame; } } diff --git a/mobile/lib/infrastructure/loaders/local_image_request.dart b/mobile/lib/infrastructure/loaders/local_image_request.dart index 7a1b3d8957..c2e3165aad 100644 --- a/mobile/lib/infrastructure/loaders/local_image_request.dart +++ b/mobile/lib/infrastructure/loaders/local_image_request.dart @@ -16,20 +16,23 @@ class LocalImageRequest extends ImageRequest { return null; } - final Map info = await thumbnailApi.requestImage( + final info = await localImageApi.requestImage( localId, requestId: requestId, width: width, height: height, isVideo: assetType == AssetType.video, ); + if (info == null) { + return null; + } - final frame = await _fromPlatformImage(info); + final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!); return frame == null ? null : ImageInfo(image: frame.image, scale: scale); } @override Future _onCancelled() { - return thumbnailApi.cancelImageRequest(requestId); + return localImageApi.cancelRequest(requestId); } } diff --git a/mobile/lib/infrastructure/loaders/remote_image_request.dart b/mobile/lib/infrastructure/loaders/remote_image_request.dart index 03dcd6454a..2da70c3ae1 100644 --- a/mobile/lib/infrastructure/loaders/remote_image_request.dart +++ b/mobile/lib/infrastructure/loaders/remote_image_request.dart @@ -1,14 +1,10 @@ part of 'image_request.dart'; class RemoteImageRequest extends ImageRequest { - static final log = Logger('RemoteImageRequest'); - static final client = HttpClient()..maxConnectionsPerHost = 16; - final RemoteCacheManager? cacheManager; final String uri; final Map headers; - HttpClientRequest? _request; - RemoteImageRequest({required this.uri, required this.headers, this.cacheManager}); + RemoteImageRequest({required this.uri, required this.headers}); @override Future load(ImageDecoderCallback decode, {double scale = 1.0}) async { @@ -16,164 +12,18 @@ class RemoteImageRequest extends ImageRequest { return null; } - // TODO: the cache manager makes everything sequential with its DB calls and its operations cannot be cancelled, - // so it ends up being a bottleneck. We only prefer fetching from it when it can skip the DB call. - final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: true); - if (cachedFileImage != null) { - return cachedFileImage; - } - - try { - final buffer = await _downloadImage(uri); - if (buffer == null) { - return null; - } - - return await _decodeBuffer(buffer, decode, scale); - } catch (e) { - if (_isCancelled) { - return null; - } - - final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: false); - if (cachedFileImage != null) { - return cachedFileImage; - } - - rethrow; - } finally { - _request = null; - } - } - - Future _downloadImage(String url) async { - if (_isCancelled) { - return null; - } - - final request = _request = await client.getUrl(Uri.parse(url)); - if (_isCancelled) { - request.abort(); - return _request = null; - } - - for (final entry in headers.entries) { - request.headers.set(entry.key, entry.value); - } - final response = await request.close(); - if (_isCancelled) { - return null; - } - - final cacheManager = this.cacheManager; - final streamController = StreamController>(sync: true); - final Stream> stream; - unawaited(cacheManager?.putStreamedFile(url, streamController.stream)); - stream = response.map((chunk) { - if (_isCancelled) { - throw StateError('Cancelled request'); - } - if (cacheManager != null) { - streamController.add(chunk); - } - return chunk; - }); - - try { - final Uint8List bytes = await _downloadBytes(stream, response.contentLength); - unawaited(streamController.close()); - return await ImmutableBuffer.fromUint8List(bytes); - } catch (e) { - streamController.addError(e); - unawaited(streamController.close()); - if (_isCancelled) { - return null; - } - rethrow; - } - } - - Future _downloadBytes(Stream> stream, int length) async { - final Uint8List bytes; - int offset = 0; - if (length > 0) { - // Known content length - use pre-allocated buffer - bytes = Uint8List(length); - await stream.listen((chunk) { - bytes.setAll(offset, chunk); - offset += chunk.length; - }, cancelOnError: true).asFuture(); - } else { - // Unknown content length - collect chunks dynamically - final chunks = >[]; - int totalLength = 0; - await stream.listen((chunk) { - chunks.add(chunk); - totalLength += chunk.length; - }, cancelOnError: true).asFuture(); - - bytes = Uint8List(totalLength); - for (final chunk in chunks) { - bytes.setAll(offset, chunk); - offset += chunk.length; - } - } - - return bytes; - } - - Future _loadCachedFile( - String url, - ImageDecoderCallback decode, - double scale, { - required bool inMemoryOnly, - }) async { - final cacheManager = this.cacheManager; - if (_isCancelled || cacheManager == null) { - return null; - } - - final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url)); - if (_isCancelled || file == null) { - return null; - } - - try { - final buffer = await ImmutableBuffer.fromFilePath(file.file.path); - return await _decodeBuffer(buffer, decode, scale); - } catch (e) { - log.severe('Failed to decode cached image', e); - unawaited(_evictFile(url)); - return null; - } - } - - Future _evictFile(String url) async { - try { - await cacheManager?.removeFile(url); - } catch (e) { - log.severe('Failed to remove cached image', e); - } - } - - Future _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async { - if (_isCancelled) { - buffer.dispose(); - return null; - } - final codec = await decode(buffer); - if (_isCancelled) { - buffer.dispose(); - codec.dispose(); - return null; - } - final frame = await codec.getNextFrame(); - return ImageInfo(image: frame.image, scale: scale); + final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId); + final frame = switch (info) { + {'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length), + {'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} => + await _fromDecodedPlatformImage(pointer, width, height, rowBytes), + _ => null, + }; + return frame == null ? null : ImageInfo(image: frame.image, scale: scale); } @override - void _onCancelled() { - _request?.abort(); - _request = null; + Future _onCancelled() { + return remoteImageApi.cancelRequest(requestId); } } diff --git a/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart b/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart index a876020984..2ced28b810 100644 --- a/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart +++ b/mobile/lib/infrastructure/loaders/thumbhash_image_request.dart @@ -11,8 +11,8 @@ class ThumbhashImageRequest extends ImageRequest { return null; } - final Map info = await thumbnailApi.getThumbhash(thumbhash); - final frame = await _fromPlatformImage(info); + final Map info = await localImageApi.getThumbhash(thumbhash); + final frame = await _fromDecodedPlatformImage(info["pointer"]!, info["width"]!, info["height"]!, info["rowBytes"]!); return frame == null ? null : ImageInfo(image: frame.image, scale: scale); } diff --git a/mobile/lib/infrastructure/repositories/network.repository.dart b/mobile/lib/infrastructure/repositories/network.repository.dart new file mode 100644 index 0000000000..a73322cb5c --- /dev/null +++ b/mobile/lib/infrastructure/repositories/network.repository.dart @@ -0,0 +1,67 @@ +import 'dart:io'; + +import 'package:cronet_http/cronet_http.dart'; +import 'package:cupertino_http/cupertino_http.dart'; +import 'package:http/http.dart' as http; +import 'package:immich_mobile/utils/user_agent.dart'; +import 'package:path_provider/path_provider.dart'; + +class NetworkRepository { + static late Directory _cachePath; + static late String _userAgent; + static final _clients = {}; + + static Future init() { + return ( + getTemporaryDirectory().then((cachePath) => _cachePath = cachePath), + getUserAgentString().then((userAgent) => _userAgent = userAgent), + ).wait; + } + + static void reset() { + Future.microtask(init); + for (final client in _clients.values) { + client.close(); + } + _clients.clear(); + } + + const NetworkRepository(); + + /// Note: when disk caching is enabled, only one client may use a given directory at a time. + /// Different isolates or engines must use different directories. + http.Client getHttpClient( + String directoryName, { + CacheMode cacheMode = CacheMode.memory, + int diskCapacity = 0, + int maxConnections = 6, + int memoryCapacity = 10 << 20, + }) { + final cachedClient = _clients[directoryName]; + if (cachedClient != null) { + return cachedClient; + } + + final directory = Directory('${_cachePath.path}/$directoryName'); + directory.createSync(recursive: true); + if (Platform.isAndroid) { + final engine = CronetEngine.build( + cacheMode: cacheMode, + cacheMaxSize: diskCapacity, + storagePath: directory.path, + userAgent: _userAgent, + ); + return _clients[directoryName] = CronetClient.fromCronetEngine(engine, closeEngine: true); + } + + final config = URLSessionConfiguration.defaultSessionConfiguration() + ..httpMaximumConnectionsPerHost = maxConnections + ..cache = URLCache.withCapacity( + diskCapacity: diskCapacity, + memoryCapacity: memoryCapacity, + directory: directory.uri, + ) + ..httpAdditionalHeaders = {'User-Agent': _userAgent}; + return _clients[directoryName] = CupertinoClient.fromSessionConfiguration(config); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 83bc840df1..60bb1cb9c3 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -19,6 +19,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/generated/intl_keys.g.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; @@ -237,6 +238,14 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve super.dispose(); } + @override + void reassemble() { + if (kDebugMode) { + NetworkRepository.reset(); + } + super.reassemble(); + } + @override Widget build(BuildContext context) { final router = ref.watch(appRouterProvider); diff --git a/mobile/lib/platform/local_image_api.g.dart b/mobile/lib/platform/local_image_api.g.dart new file mode 100644 index 0000000000..8b7c82f15d --- /dev/null +++ b/mobile/lib/platform/local_image_api.g.dart @@ -0,0 +1,137 @@ +// Autogenerated from Pigeon (v26.0.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + default: + return super.readValueOfType(type, buffer); + } + } +} + +class LocalImageApi { + /// Constructor for [LocalImageApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + LocalImageApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future?> requestImage( + String assetId, { + required int requestId, + required int width, + required int height, + required bool isVideo, + }) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([ + assetId, + requestId, + width, + height, + isVideo, + ]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return (pigeonVar_replyList[0] as Map?)?.cast(); + } + } + + Future cancelRequest(int requestId) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.LocalImageApi.cancelRequest$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([requestId]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future> getThumbhash(String thumbhash) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.LocalImageApi.getThumbhash$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([thumbhash]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as Map?)!.cast(); + } + } +} diff --git a/mobile/lib/platform/remote_image_api.g.dart b/mobile/lib/platform/remote_image_api.g.dart new file mode 100644 index 0000000000..410db03ece --- /dev/null +++ b/mobile/lib/platform/remote_image_api.g.dart @@ -0,0 +1,129 @@ +// Autogenerated from Pigeon (v26.0.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + default: + return super.readValueOfType(type, buffer); + } + } +} + +class RemoteImageApi { + /// Constructor for [RemoteImageApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + RemoteImageApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future?> requestImage( + String url, { + required Map headers, + required int requestId, + }) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([url, headers, requestId]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return (pigeonVar_replyList[0] as Map?)?.cast(); + } + } + + Future cancelRequest(int requestId) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([requestId]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future clearCache() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearCache$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as int?)!; + } + } +} diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index ad7d53af13..09b9719ddf 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:async/async.dart'; import 'package:flutter/widgets.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -53,14 +51,14 @@ mixin CancellableImageProviderMixin on CancellableImageProvide Stream loadRequest(ImageRequest request, ImageDecoderCallback decode) async* { if (isCancelled) { this.request = null; - unawaited(evict()); + PaintingBinding.instance.imageCache.evict(this); return; } try { final image = await request.load(decode); if (image == null || isCancelled) { - unawaited(evict()); + PaintingBinding.instance.imageCache.evict(this); return; } yield image; diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index c5dca57f9c..d7454c0c89 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:ui'; import 'package:flutter/foundation.dart'; @@ -85,7 +84,7 @@ class LocalFullImageProvider extends CancellableImageProvider with CancellableImageProviderMixin { - static final cacheManager = RemoteThumbnailCacheManager(); final String assetId; final String thumbhash; @@ -41,7 +37,6 @@ class RemoteThumbProvider extends CancellableImageProvider final request = this.request = RemoteImageRequest( uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash), headers: ApiService.getRequestHeaders(), - cacheManager: cacheManager, ); return loadRequest(request, decode); } @@ -62,7 +57,6 @@ class RemoteThumbProvider extends CancellableImageProvider class RemoteFullImageProvider extends CancellableImageProvider with CancellableImageProviderMixin { - static final cacheManager = RemoteThumbnailCacheManager(); final String assetId; final String thumbhash; @@ -90,7 +84,7 @@ class RemoteFullImageProvider extends CancellableImageProvider putStreamedFile( - String url, - Stream> source, { - String? key, - String? eTag, - Duration maxAge = const Duration(days: 30), - String fileExtension = 'file', - }); - - // Unlike `putFileStream`, this method handles request cancellation, - // does not make a (slow) DB call checking if the file is already cached, - // does not synchronously check if a file exists, - // and deletes the file on cancellation without making these checks again. - Future putStreamedFileToStore( - CacheStore store, - String url, - Stream> source, { - String? key, - String? eTag, - Duration maxAge = const Duration(days: 30), - String fileExtension = 'file', - }) async { - final path = '${const Uuid().v1()}.$fileExtension'; - final file = await store.fileSystem.createFile(path); - final sink = file.openWrite(); - try { - await source.listen(sink.add, cancelOnError: true).asFuture(); - } catch (e) { - try { - await sink.close(); - await file.delete(); - } catch (e) { - _log.severe('Failed to delete incomplete cache file: $e'); - } - return; - } - - try { - await sink.flush(); - await sink.close(); - } catch (e) { - try { - await file.delete(); - } catch (e) { - _log.severe('Failed to delete incomplete cache file: $e'); - } - return; - } - - final cacheObject = CacheObject( - url, - key: key, - relativePath: path, - validTill: DateTime.now().add(maxAge), - eTag: eTag, - ); - try { - await store.putFile(cacheObject); - } catch (e) { - try { - await file.delete(); - } catch (e) { - _log.severe('Failed to delete untracked cache file: $e'); - } - } - } -} - -class RemoteImageCacheManager extends RemoteCacheManager { +class RemoteImageCacheManager extends CacheManager { static const key = 'remoteImageCacheKey'; static final RemoteImageCacheManager _instance = RemoteImageCacheManager._(); static final _config = Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30)); - static final _store = CacheStore(_config); factory RemoteImageCacheManager() { return _instance; } - RemoteImageCacheManager._() : super.custom(_config, _store); - - @override - Future putStreamedFile( - String url, - Stream> source, { - String? key, - String? eTag, - Duration maxAge = const Duration(days: 30), - String fileExtension = 'file', - }) { - return putStreamedFileToStore( - _store, - url, - source, - key: key, - eTag: eTag, - maxAge: maxAge, - fileExtension: fileExtension, - ); - } + RemoteImageCacheManager._() : super(_config); } -/// The cache manager for full size images [ImmichRemoteImageProvider] -class RemoteThumbnailCacheManager extends RemoteCacheManager { +class RemoteThumbnailCacheManager extends CacheManager { static const key = 'remoteThumbnailCacheKey'; static final RemoteThumbnailCacheManager _instance = RemoteThumbnailCacheManager._(); static final _config = Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30)); - static final _store = CacheStore(_config); factory RemoteThumbnailCacheManager() { return _instance; } - RemoteThumbnailCacheManager._() : super.custom(_config, _store); - - @override - Future putStreamedFile( - String url, - Stream> source, { - String? key, - String? eTag, - Duration maxAge = const Duration(days: 30), - String fileExtension = 'file', - }) { - return putStreamedFileToStore( - _store, - url, - source, - key: key, - eTag: eTag, - maxAge: maxAge, - fileExtension: fileExtension, - ); - } + RemoteThumbnailCacheManager._() : super(_config); } diff --git a/mobile/lib/providers/infrastructure/platform.provider.dart b/mobile/lib/providers/infrastructure/platform.provider.dart index 11c5280c02..60300e74df 100644 --- a/mobile/lib/providers/infrastructure/platform.provider.dart +++ b/mobile/lib/providers/infrastructure/platform.provider.dart @@ -4,7 +4,8 @@ import 'package:immich_mobile/platform/background_worker_api.g.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/platform/connectivity_api.g.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; -import 'package:immich_mobile/platform/thumbnail_api.g.dart'; +import 'package:immich_mobile/platform/local_image_api.g.dart'; +import 'package:immich_mobile/platform/remote_image_api.g.dart'; final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi())); @@ -16,4 +17,6 @@ final nativeSyncApiProvider = Provider((_) => NativeSyncApi()); final connectivityApiProvider = Provider((_) => ConnectivityApi()); -final thumbnailApi = ThumbnailApi(); +final localImageApi = LocalImageApi(); + +final remoteImageApi = RemoteImageApi(); diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index f5c7513d1b..25ca64e8c3 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -21,6 +21,7 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:isar/isar.dart'; import 'package:path_provider/path_provider.dart'; @@ -106,5 +107,7 @@ abstract final class Bootstrap { storeRepository: storeRepo, shouldBuffer: shouldBufferLogs, ); + + await NetworkRepository.init(); } } diff --git a/mobile/lib/utils/bytes_units.dart b/mobile/lib/utils/bytes_units.dart index 3a73e5b320..66de6493ab 100644 --- a/mobile/lib/utils/bytes_units.dart +++ b/mobile/lib/utils/bytes_units.dart @@ -19,7 +19,7 @@ String formatBytes(int bytes) { String formatHumanReadableBytes(int bytes, int decimals) { if (bytes <= 0) return "0 B"; - const suffixes = ["B", "KB", "MB", "GB", "TB"]; + const suffixes = ["B", "KiB", "MiB", "GiB", "TiB"]; var i = (log(bytes) / log(1024)).floor(); return '${(bytes / pow(1024, i)).toStringAsFixed(decimals)} ${suffixes[i]}'; } diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index b8de27fd44..d6b516a078 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -8,10 +8,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart'; @@ -153,6 +155,43 @@ class AdvancedSettings extends HookConsumerWidget { ); }, ), + ListTile( + title: Text("advanced_settings_clear_image_cache".tr(), style: const TextStyle(fontWeight: FontWeight.w500)), + leading: const Icon(Icons.playlist_remove_rounded), + onTap: () async { + final int clearedBytes; + try { + clearedBytes = await remoteImageApi.clearCache(); + } catch (e) { + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + "advanced_settings_clear_image_cache_error".tr(), + style: context.textTheme.bodyLarge?.copyWith(color: context.themeData.colorScheme.error), + ), + ), + ); + return; + } + + if (clearedBytes < 0) { + return; + } + + // iOS always returns a small non-zero value + final clearedMB = clearedBytes < (256 * 1024) ? "0 MiB" : formatHumanReadableBytes(clearedBytes, 2); + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + "advanced_settings_clear_image_cache_success".tr(namedArgs: {'size': clearedMB}), + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), + ), + ), + ); + }, + ), const SizedBox(height: 60), ]; diff --git a/mobile/makefile b/mobile/makefile index 3b211bcd09..79b263c079 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -7,12 +7,14 @@ build: pigeon: dart run pigeon --input pigeon/native_sync_api.dart - dart run pigeon --input pigeon/thumbnail_api.dart + dart run pigeon --input pigeon/local_image_api.dart + dart run pigeon --input pigeon/remote_image_api.dart dart run pigeon --input pigeon/background_worker_api.dart dart run pigeon --input pigeon/background_worker_lock_api.dart dart run pigeon --input pigeon/connectivity_api.dart dart format lib/platform/native_sync_api.g.dart - dart format lib/platform/thumbnail_api.g.dart + dart format lib/platform/local_image_api.g.dart + dart format lib/platform/remote_image_api.g.dart dart format lib/platform/background_worker_api.g.dart dart format lib/platform/background_worker_lock_api.g.dart dart format lib/platform/connectivity_api.g.dart diff --git a/mobile/packages/ui/pubspec.lock b/mobile/packages/ui/pubspec.lock index b9d150f174..fa0b425230 100644 --- a/mobile/packages/ui/pubspec.lock +++ b/mobile/packages/ui/pubspec.lock @@ -34,10 +34,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" sky_engine: dependency: transitive description: flutter diff --git a/mobile/pigeon/thumbnail_api.dart b/mobile/pigeon/local_image_api.dart similarity index 71% rename from mobile/pigeon/thumbnail_api.dart rename to mobile/pigeon/local_image_api.dart index 0698e7cdc9..35b6734568 100644 --- a/mobile/pigeon/thumbnail_api.dart +++ b/mobile/pigeon/local_image_api.dart @@ -2,20 +2,20 @@ import 'package:pigeon/pigeon.dart'; @ConfigurePigeon( PigeonOptions( - dartOut: 'lib/platform/thumbnail_api.g.dart', - swiftOut: 'ios/Runner/Images/Thumbnails.g.swift', + dartOut: 'lib/platform/local_image_api.g.dart', + swiftOut: 'ios/Runner/Images/LocalImages.g.swift', swiftOptions: SwiftOptions(includeErrorClass: false), kotlinOut: - 'android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt', + 'android/app/src/main/kotlin/app/alextran/immich/images/LocalImages.g.kt', kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images'), dartOptions: DartOptions(), dartPackageName: 'immich_mobile', ), ) @HostApi() -abstract class ThumbnailApi { +abstract class LocalImageApi { @async - Map requestImage( + Map? requestImage( String assetId, { required int requestId, required int width, @@ -23,7 +23,7 @@ abstract class ThumbnailApi { required bool isVideo, }); - void cancelImageRequest(int requestId); + void cancelRequest(int requestId); @async Map getThumbhash(String thumbhash); diff --git a/mobile/pigeon/remote_image_api.dart b/mobile/pigeon/remote_image_api.dart new file mode 100644 index 0000000000..749deb828e --- /dev/null +++ b/mobile/pigeon/remote_image_api.dart @@ -0,0 +1,28 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/remote_image_api.g.dart', + swiftOut: 'ios/Runner/Images/RemoteImages.g.swift', + swiftOptions: SwiftOptions(includeErrorClass: false), + kotlinOut: + 'android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images', includeErrorClass: false), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) +@HostApi() +abstract class RemoteImageApi { + @async + Map? requestImage( + String url, { + required Map headers, + required int requestId, + }); + + void cancelRequest(int requestId); + + @async + int clearCache(); +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 3179d71bd1..d237c02023 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -337,6 +337,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cronet_http: + dependency: "direct main" + description: + name: cronet_http + sha256: "1fff7f26ac0c4cda97fe2a9aa082494baee4775f167c27ba45f6c8e88571e3ab" + url: "https://pub.dev" + source: hosted + version: "1.7.0" crop_image: dependency: "direct main" description: @@ -369,6 +377,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + cupertino_http: + dependency: "direct main" + description: + name: cupertino_http + sha256: "82cbec60c90bf785a047a9525688b6dacac444e177e1d5a5876963d3c50369e8" + url: "https://pub.dev" + source: hosted + version: "2.4.0" custom_lint: dependency: "direct dev" description: @@ -936,6 +952,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + http_profile: + dependency: transitive + description: + name: http_profile + sha256: "7e679e355b09aaee2ab5010915c932cce3f2d1c11c3b2dc177891687014ffa78" + url: "https://pub.dev" + source: hosted + version: "0.1.0" image: dependency: transitive description: @@ -1077,6 +1101,14 @@ packages: url: "https://github.com/immich-app/isar" source: git version: "3.1.8" + jni: + dependency: transitive + description: + name: jni + sha256: "8706a77e94c76fe9ec9315e18949cc9479cc03af97085ca9c1077b61323ea12d" + url: "https://pub.dev" + source: hosted + version: "0.15.2" js: dependency: transitive description: @@ -1270,6 +1302,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "1f81ed9e41909d44162d7ec8663b2c647c202317cc0b56d3d56f6a13146a0b64" + url: "https://pub.dev" + source: hosted + version: "9.1.0" octo_image: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 64d7205444..df75b9fc15 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -86,6 +86,8 @@ dependencies: uuid: ^4.5.1 wakelock_plus: ^1.3.0 worker_manager: ^7.2.7 + cronet_http: ^1.7.0 + cupertino_http: ^2.4.0 dev_dependencies: auto_route_generator: ^9.0.0 diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index 6ec78b0f21..04f1ce48d9 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -34,7 +34,8 @@ type SendFile = Parameters; type SendFileOptions = SendFile[1]; const cacheControlHeaders: Record = { - [CacheControl.PrivateWithCache]: 'private, max-age=86400, no-transform', + [CacheControl.PrivateWithCache]: + 'private, max-age=86400, no-transform, stale-while-revalidate=2592000, stale-if-error=2592000', [CacheControl.PrivateWithoutCache]: 'private, no-cache, no-transform', [CacheControl.None]: null, // falsy value to prevent adding Cache-Control header };