From ff08873aaed36909cd634b27d13602d9babbf211 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 16 Jan 2026 19:41:50 -0500 Subject: [PATCH] android impl --- mobile/android/app/build.gradle | 2 + .../alextran/immich/HttpSSLOptionsPlugin.kt | 15 +- .../app/alextran/immich/MainActivity.kt | 3 + .../app/alextran/immich/core/SSLConfig.kt | 66 ++++++++ .../alextran/immich/images/LocalImagesImpl.kt | 67 ++++---- .../immich/images/RemoteImagesImpl.kt | 156 ++++++++++++++++++ .../ios/Runner/Images/RemoteImagesImpl.swift | 2 +- 7 files changed, 276 insertions(+), 35 deletions(-) create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/core/SSLConfig.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 5eeacd65b9..6f0f21aa32 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -105,8 +105,10 @@ 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.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/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 a8230b04ae..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 @@ -12,6 +12,8 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl import app.alextran.immich.core.ImmichPlugin 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 @@ -37,6 +39,7 @@ class MainActivity : FlutterFragmentActivity() { } NativeSyncApi.setUp(messenger, nativeSyncApiImpl) 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/core/SSLConfig.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/SSLConfig.kt new file mode 100644 index 0000000000..b0327a1b3c --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/SSLConfig.kt @@ -0,0 +1,66 @@ +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 + + 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 + ) { + val newHash = computeHash(allowSelfSigned, serverHost, clientCertHash) + if (newHash == configHash && sslSocketFactory != null) { + return // Config unchanged, skip + } + + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(keyManagers, trustManagers, null) + sslSocketFactory = sslContext.socketFactory + trustManager = trustManagers?.filterIsInstance()?.firstOrNull() + ?: getDefaultTrustManager() + 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/LocalImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt index 2dfb9ebdeb..cd6474a54e 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt @@ -11,6 +11,10 @@ import android.os.OperationCanceledException import android.provider.MediaStore.Images import android.provider.MediaStore.Video import android.util.Size +import androidx.annotation.RequiresApi +import app.alextran.immich.images.LocalImagesImpl.Companion.allocateNative +import app.alextran.immich.images.LocalImagesImpl.Companion.freeNative +import app.alextran.immich.images.LocalImagesImpl.Companion.wrapAsBuffer import java.nio.ByteBuffer import kotlin.math.* import java.util.concurrent.Executors @@ -29,6 +33,37 @@ data class Request( val callback: (Result>) -> Unit ) +@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 = allocateNative(size) + try { + val buffer = wrapAsBuffer(pointer, size) + copyPixelsToBuffer(buffer) + recycle() + return mapOf( + "pointer" to pointer, + "width" to width.toLong(), + "height" to height.toLong() + ) + } catch (e: Exception) { + freeNative(pointer) + recycle() + throw e + } +} + class LocalImagesImpl(context: Context) : LocalImageApi { private val ctx: Context = context.applicationContext private val resolver: ContentResolver = ctx.contentResolver @@ -131,31 +166,12 @@ class LocalImagesImpl(context: Context) : LocalImageApi { 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 +207,7 @@ class LocalImagesImpl(context: Context) : LocalImageApi { 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/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt new file mode 100644 index 0000000000..69198ad687 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -0,0 +1,156 @@ +package app.alextran.immich.images + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.ColorSpace +import android.graphics.ImageDecoder +import android.os.Build +import android.os.CancellationSignal +import android.util.Size +import app.alextran.immich.core.SSLConfig +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.Cache +import okhttp3.ConnectionPool +import okhttp3.Dispatcher +import java.io.File +import java.io.IOException +import java.nio.ByteBuffer +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +data class RemoteRequest( + val callback: (Result>) -> Unit, + val cancellationSignal: CancellationSignal, +) + +class RemoteImagesImpl(context: Context) : RemoteImageApi { + private val requestMap = ConcurrentHashMap() + + init { + System.loadLibrary("native_buffer") + cacheDir = context.cacheDir + client = buildClient() + } + + companion object { + private const val MAX_REQUESTS_PER_HOST = 16 + private const val KEEP_ALIVE_CONNECTIONS = 10 + private const val KEEP_ALIVE_DURATION_MINUTES = 5L + private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024 + + val CANCELLED = Result.success>(emptyMap()) + private val decodePool = Executors.newFixedThreadPool( + (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(2) + ) + + private var cacheDir: File? = null + private var client: OkHttpClient? = null + + init { + SSLConfig.addListener(::invalidateClient) + } + + private fun invalidateClient() { + client?.let { + it.dispatcher.cancelAll() + it.connectionPool.evictAll() + it.cache?.close() + } + client = buildClient() + } + + private fun buildClient(): OkHttpClient { + val connectionPool = ConnectionPool( + maxIdleConnections = KEEP_ALIVE_CONNECTIONS, + keepAliveDuration = KEEP_ALIVE_DURATION_MINUTES, + timeUnit = TimeUnit.MINUTES + ) + + val builder = OkHttpClient.Builder() + .dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST }) + .connectionPool(connectionPool) + + cacheDir?.let { dir -> + val cacheSubdir = File(dir, "thumbnails") + builder.cache(Cache(cacheSubdir, CACHE_SIZE_BYTES)) + } + + val sslSocketFactory = SSLConfig.sslSocketFactory + val trustManager = SSLConfig.trustManager + if (sslSocketFactory != null && trustManager != null) { + builder.sslSocketFactory(sslSocketFactory, trustManager) + } + + return builder.build() + } + } + + override fun requestImage( + url: String, + headers: Map, + requestId: Long, + callback: (Result>) -> Unit + ) { + val client = client ?: return callback(Result.failure(RuntimeException("No client"))) + val signal = CancellationSignal() + val requestBuilder = Request.Builder().url(url) + headers.forEach(requestBuilder::addHeader) + + val call = client.newCall(requestBuilder.build()) + signal.setOnCancelListener(call::cancel) + val request = RemoteRequest(callback, signal) + requestMap[requestId] = request + + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + requestMap.remove(requestId) + val result = if (signal.isCanceled) CANCELLED else Result.failure(e) + callback(result) + } + + override fun onResponse(call: Call, response: Response) { + decodePool.execute { + try { + signal.throwIfCanceled() + val bytes = response.takeIf { it.isSuccessful }?.body?.bytes() + ?: return@execute callback(Result.failure(IOException(response.toString()))) + signal.throwIfCanceled() + val bitmap = decodeImage(bytes) + signal.throwIfCanceled() + val res = bitmap.toNativeBuffer() + callback(Result.success(res)) + } catch (e: Exception) { + val result = if (signal.isCanceled) CANCELLED else Result.failure(e) + callback(result) + } finally { + requestMap.remove(requestId) + response.close() + } + } + } + }) + } + + override fun cancelRequest(requestId: Long) { + val request = requestMap.remove(requestId) ?: return + request.cancellationSignal.cancel() + } + + private fun decodeImage(bytes: ByteArray): Bitmap { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ImageDecoder.createSource(ByteBuffer.wrap(bytes)).decodeBitmap() + } else { + val options = BitmapFactory.Options().apply { + inPreferredConfig = Bitmap.Config.ARGB_8888 + inPreferredColorSpace = ColorSpace.get(ColorSpace.Named.SRGB) + } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) + } + } +} diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index 050e0af50d..eee04858e1 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -24,7 +24,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { try! FileManager.default.createDirectory(at: thumbnailPath, withIntermediateDirectories: true) config.urlCache = URLCache( memoryCapacity: 0, - diskCapacity: 10 << 30, + diskCapacity: 1 << 30, directory: thumbnailPath ) config.httpMaximumConnectionsPerHost = 16