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 deleted file mode 100644 index 6c22f9e284..0000000000 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/HttpSSLOptionsPlugin.kt +++ /dev/null @@ -1,153 +0,0 @@ -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 -import io.flutter.plugin.common.MethodChannel -import java.io.ByteArrayInputStream -import java.net.InetSocketAddress -import java.net.Socket -import java.security.KeyStore -import java.security.cert.X509Certificate -import javax.net.ssl.HostnameVerifier -import javax.net.ssl.HttpsURLConnection -import javax.net.ssl.KeyManager -import javax.net.ssl.KeyManagerFactory -import javax.net.ssl.SSLContext -import javax.net.ssl.SSLEngine -import javax.net.ssl.SSLSession -import javax.net.ssl.TrustManager -import javax.net.ssl.TrustManagerFactory -import javax.net.ssl.X509ExtendedTrustManager - -/** - * Android plugin for Dart `HttpSSLOptions` - */ -class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { - private var methodChannel: MethodChannel? = null - - override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - onAttachedToEngine(binding.applicationContext, binding.binaryMessenger) - } - - private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) { - methodChannel = MethodChannel(messenger, "immich/httpSSLOptions") - methodChannel?.setMethodCallHandler(this) - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - onDetachedFromEngine() - } - - private fun onDetachedFromEngine() { - methodChannel?.setMethodCallHandler(null) - methodChannel = null - } - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - try { - 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 (allowSelfSigned) { - tm = arrayOf(AllowSelfSignedTrustManager(serverHost)) - } - - var km: Array? = null - if (clientCertHash != null) { - val cert = ByteArrayInputStream(clientCertHash) - val password = (args[3] as String).toCharArray() - val keyStore = KeyStore.getInstance("PKCS12") - keyStore.load(cert, password) - val keyManagerFactory = - KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) - keyManagerFactory.init(keyStore, null) - 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) - - HttpsURLConnection.setDefaultHostnameVerifier(AllowSelfSignedHostnameVerifier(args[1] as? String)) - - result.success(true) - } - - else -> result.notImplemented() - } - } catch (e: Throwable) { - result.error("error", e.message, null) - } - } - - @SuppressLint("CustomX509TrustManager") - class AllowSelfSignedTrustManager(private val serverHost: String?) : X509ExtendedTrustManager() { - private val defaultTrustManager: X509ExtendedTrustManager = getDefaultTrustManager() - - override fun checkClientTrusted(chain: Array?, authType: String?) = - defaultTrustManager.checkClientTrusted(chain, authType) - - override fun checkClientTrusted( - chain: Array?, authType: String?, socket: Socket? - ) = defaultTrustManager.checkClientTrusted(chain, authType, socket) - - override fun checkClientTrusted( - chain: Array?, authType: String?, engine: SSLEngine? - ) = defaultTrustManager.checkClientTrusted(chain, authType, engine) - - override fun checkServerTrusted(chain: Array?, authType: String?) { - if (serverHost == null) return - defaultTrustManager.checkServerTrusted(chain, authType) - } - - override fun checkServerTrusted( - chain: Array?, authType: String?, socket: Socket? - ) { - if (serverHost == null) return - val socketAddress = socket?.remoteSocketAddress - if (socketAddress is InetSocketAddress && socketAddress.hostName == serverHost) return - defaultTrustManager.checkServerTrusted(chain, authType, socket) - } - - override fun checkServerTrusted( - chain: Array?, authType: String?, engine: SSLEngine? - ) { - if (serverHost == null || engine?.peerHost == serverHost) return - defaultTrustManager.checkServerTrusted(chain, authType, engine) - } - - override fun getAcceptedIssuers(): Array = defaultTrustManager.acceptedIssuers - - private fun getDefaultTrustManager(): X509ExtendedTrustManager { - val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - factory.init(null as KeyStore?) - return factory.trustManagers.filterIsInstance().first() - } - } - - class AllowSelfSignedHostnameVerifier(private val serverHost: String?) : HostnameVerifier { - companion object { - private val _defaultHostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier() - } - - override fun verify(hostname: String?, session: SSLSession?): Boolean { - if (serverHost == null || hostname == serverHost) { - return true - } else { - return _defaultHostnameVerifier.verify(hostname, session) - } - } - } -} 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 08790d9772..a85929a0e9 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 @@ -9,7 +9,9 @@ import app.alextran.immich.background.BackgroundWorkerFgHostApi import app.alextran.immich.background.BackgroundWorkerLockApi import app.alextran.immich.connectivity.ConnectivityApi import app.alextran.immich.connectivity.ConnectivityApiImpl +import app.alextran.immich.core.HttpClientManager import app.alextran.immich.core.ImmichPlugin +import app.alextran.immich.core.NetworkApiPlugin import app.alextran.immich.images.LocalImageApi import app.alextran.immich.images.LocalImagesImpl import app.alextran.immich.images.RemoteImageApi @@ -28,6 +30,9 @@ class MainActivity : FlutterFragmentActivity() { companion object { fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { + HttpClientManager.initialize(ctx) + flutterEngine.plugins.add(NetworkApiPlugin()) + val messenger = flutterEngine.dartExecutor.binaryMessenger val backgroundEngineLockImpl = BackgroundEngineLock(ctx) BackgroundWorkerLockApi.setUp(messenger, backgroundEngineLockImpl) @@ -45,7 +50,6 @@ class MainActivity : FlutterFragmentActivity() { ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx)) flutterEngine.plugins.add(BackgroundServicePlugin()) - flutterEngine.plugins.add(HttpSSLOptionsPlugin()) flutterEngine.plugins.add(backgroundEngineLockImpl) flutterEngine.plugins.add(nativeSyncApiImpl) } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt new file mode 100644 index 0000000000..f0105bcea0 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt @@ -0,0 +1,138 @@ +package app.alextran.immich.core + +import android.content.Context +import app.alextran.immich.BuildConfig +import okhttp3.Cache +import okhttp3.ConnectionPool +import okhttp3.Dispatcher +import okhttp3.OkHttpClient +import java.io.ByteArrayInputStream +import java.io.File +import java.net.Socket +import java.security.KeyStore +import java.security.Principal +import java.security.PrivateKey +import java.security.cert.X509Certificate +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509KeyManager +import javax.net.ssl.X509TrustManager + +const val CERT_ALIAS = "client_cert" +const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}" + +/** + * Manages a shared OkHttpClient with SSL configuration support. + * The client is shared across all Dart isolates and native code. + */ +object HttpClientManager { + private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB + private const val KEEP_ALIVE_CONNECTIONS = 10 + private const val KEEP_ALIVE_DURATION_MINUTES = 5L + private const val MAX_REQUESTS_PER_HOST = 64 + + private var initialized = false + private val clientChangedListeners = mutableListOf<() -> Unit>() + + private lateinit var client: OkHttpClient + + private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + + val isMtls: Boolean get() = keyStore.containsAlias(CERT_ALIAS) + + fun initialize(context: Context) { + if (initialized) return + synchronized(this) { + if (initialized) return + + val cacheDir = File(File(context.cacheDir, "okhttp"), "api") + client = build(cacheDir) + initialized = true + } + } + + fun setKeyEntry(clientData: ByteArray, password: CharArray) { + synchronized(this) { + val wasMtls = isMtls + val tmpKeyStore = KeyStore.getInstance("PKCS12").apply { + ByteArrayInputStream(clientData).use { stream -> load(stream, password) } + } + val tmpAlias = tmpKeyStore.aliases().asSequence().firstOrNull { tmpKeyStore.isKeyEntry(it) } + ?: throw IllegalArgumentException("No private key found in PKCS12") + val key = tmpKeyStore.getKey(tmpAlias, password) + val chain = tmpKeyStore.getCertificateChain(tmpAlias) + + if (wasMtls) { + keyStore.deleteEntry(CERT_ALIAS) + } + keyStore.setKeyEntry(CERT_ALIAS, key, null, chain) + if (wasMtls != isMtls) { + clientChangedListeners.forEach { it() } + } + } + } + + fun deleteKeyEntry() { + synchronized(this) { + if (!isMtls) { + return + } + + keyStore.deleteEntry(CERT_ALIAS) + clientChangedListeners.forEach { it() } + } + } + + @JvmStatic + fun getClient(): OkHttpClient { + return client + } + + fun addClientChangedListener(listener: () -> Unit) { + synchronized(this) { clientChangedListeners.add(listener) } + } + + private fun build(cacheDir: File): OkHttpClient { + val connectionPool = ConnectionPool( + maxIdleConnections = KEEP_ALIVE_CONNECTIONS, + keepAliveDuration = KEEP_ALIVE_DURATION_MINUTES, + timeUnit = TimeUnit.MINUTES + ) + + val managerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + managerFactory.init(null as KeyStore?) + val trustManager = managerFactory.trustManagers.filterIsInstance().first() + + val sslContext = SSLContext.getInstance("TLS") + .apply { init(arrayOf(DynamicKeyManager()), arrayOf(trustManager), null) } + + return OkHttpClient.Builder() + .addInterceptor { chain -> + chain.proceed(chain.request().newBuilder().header("User-Agent", USER_AGENT).build()) + } + .connectionPool(connectionPool) + .dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST }) + .cache(Cache(cacheDir.apply { mkdirs() }, CACHE_SIZE_BYTES)) + .sslSocketFactory(sslContext.socketFactory, trustManager) + .build() + } + + // Reads from the key store rather than taking a snapshot at initialization time + private class DynamicKeyManager : X509KeyManager { + override fun getClientAliases(keyType: String, issuers: Array?): Array? = + if (isMtls) arrayOf(CERT_ALIAS) else null + + override fun chooseClientAlias(keyTypes: Array, issuers: Array?, socket: Socket?): String? = + if (isMtls) CERT_ALIAS else null + + override fun getCertificateChain(alias: String): Array? = + keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray() + + override fun getPrivateKey(alias: String): PrivateKey? = + keyStore.getKey(alias, null) as? PrivateKey + + override fun getServerAliases(keyType: String, issuers: Array?): Array? = null + override fun chooseServerAlias(keyType: String, issuers: Array?, socket: Socket?): String? = null + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt new file mode 100644 index 0000000000..aac9b6c806 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt @@ -0,0 +1,205 @@ +// 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.core + +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 NetworkPigeonUtils { + + 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) + ) + } + } + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + return a.contentEquals(b) + } + if (a is Array<*> && b is Array<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is List<*> && b is List<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is Map<*, *> && b is Map<*, *>) { + return a.size == b.size && a.all { + (b as Map).containsKey(it.key) && + deepEquals(it.value, b[it.key]) + } + } + return a == b + } + +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : Throwable() + +/** Generated class from Pigeon that represents data sent in messages. */ +data class ClientCertData ( + val data: ByteArray, + val password: String +) + { + companion object { + fun fromList(pigeonVar_list: List): ClientCertData { + val data = pigeonVar_list[0] as ByteArray + val password = pigeonVar_list[1] as String + return ClientCertData(data, password) + } + } + fun toList(): List { + return listOf( + data, + password, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is ClientCertData) { + return false + } + if (this === other) { + return true + } + return NetworkPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} +private open class NetworkPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as? List)?.let { + ClientCertData.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is ClientCertData -> { + stream.write(129) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface NetworkApi { + fun addCertificate(clientData: ClientCertData, callback: (Result) -> Unit) + fun selectCertificate(callback: (Result) -> Unit) + fun removeCertificate(callback: (Result) -> Unit) + + companion object { + /** The codec used by NetworkApi. */ + val codec: MessageCodec by lazy { + NetworkPigeonCodec() + } + /** Sets up an instance of `NetworkApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: NetworkApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.addCertificate$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val clientDataArg = args[0] as ClientCertData + api.addCertificate(clientDataArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(NetworkPigeonUtils.wrapError(error)) + } else { + reply.reply(NetworkPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.selectCertificate{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(NetworkPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(NetworkPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.removeCertificate{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(NetworkPigeonUtils.wrapError(error)) + } else { + reply.reply(NetworkPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt new file mode 100644 index 0000000000..bf30bc8bc8 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt @@ -0,0 +1,135 @@ +package app.alextran.immich.core + +import android.app.Activity +import android.content.Context +import android.net.Uri +import android.os.OperationCanceledException +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding + +class NetworkApiPlugin : FlutterPlugin, ActivityAware { + private var networkApi: NetworkApiImpl? = null + + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + networkApi = NetworkApiImpl(binding.applicationContext) + NetworkApi.setUp(binding.binaryMessenger, networkApi) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + NetworkApi.setUp(binding.binaryMessenger, null) + networkApi = null + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + networkApi?.onAttachedToActivity(binding) + } + + override fun onDetachedFromActivityForConfigChanges() { + networkApi?.onDetachedFromActivityForConfigChanges() + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + networkApi?.onReattachedToActivityForConfigChanges(binding) + } + + override fun onDetachedFromActivity() { + networkApi?.onDetachedFromActivity() + } +} + +private class NetworkApiImpl(private val context: Context) : NetworkApi { + private var activity: Activity? = null + private var pendingCallback: ((Result) -> Unit)? = null + private var filePicker: ActivityResultLauncher>? = null + + fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + (binding.activity as? ComponentActivity)?.let { componentActivity -> + filePicker = componentActivity.registerForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> uri?.let { handlePickedFile(it) } ?: pendingCallback?.invoke(Result.failure(OperationCanceledException())) } + } + } + + fun onDetachedFromActivityForConfigChanges() { + activity = null + } + + fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activity = binding.activity + } + + fun onDetachedFromActivity() { + activity = null + } + + override fun addCertificate(clientData: ClientCertData, callback: (Result) -> Unit) { + try { + HttpClientManager.setKeyEntry(clientData.data, clientData.password.toCharArray()) + callback(Result.success(Unit)) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } + + override fun selectCertificate(callback: (Result) -> Unit) { + val picker = filePicker ?: return callback(Result.failure(IllegalStateException("No activity"))) + pendingCallback = callback + picker.launch(arrayOf("application/x-pkcs12", "application/x-pem-file")) + } + + override fun removeCertificate(callback: (Result) -> Unit) { + HttpClientManager.deleteKeyEntry() + callback(Result.success(Unit)) + } + + private fun handlePickedFile(uri: Uri) { + val callback = pendingCallback ?: return + pendingCallback = null + + try { + val data = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw IllegalStateException("Could not read file") + + val activity = activity ?: throw IllegalStateException("No activity") + promptForPassword(activity) { password -> + if (password == null) { + callback(Result.failure(OperationCanceledException())) + return@promptForPassword + } + try { + HttpClientManager.setKeyEntry(data, password.toCharArray()) + callback(Result.success(ClientCertData(data, password))) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } + } catch (e: Exception) { + callback(Result.failure(e)) + } + } + + private fun promptForPassword(activity: Activity, callback: (String?) -> Unit) { + val builder = android.app.AlertDialog.Builder(activity) + .setTitle("Certificate Password") + .setMessage("Enter the password for this certificate") + + val input = android.widget.EditText(activity).apply { + inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD + } + + builder.setView(input) + .setPositiveButton("Import") { _, _ -> callback(input.text.toString()) } + .setNegativeButton("Cancel") { dialog, _ -> + dialog.cancel() + callback(null) + } + .setOnCancelListener { callback(null) } + .show() + } +} 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 deleted file mode 100644 index f62042cd99..0000000000 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/SSLConfig.kt +++ /dev/null @@ -1,73 +0,0 @@ -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/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index 6800b45a70..f63f3d8b66 100644 --- 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 @@ -3,17 +3,15 @@ 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 app.alextran.immich.core.HttpClientManager +import app.alextran.immich.core.USER_AGENT 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 @@ -33,14 +31,8 @@ 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) @@ -121,7 +113,9 @@ private object ImageFetcherManager { appContext = context.applicationContext cacheDir = context.cacheDir fetcher = build() - SSLConfig.addListener(::invalidate) + // Listen to SharedHttpClientManager instead of SSLConfig directly. + // This ensures the shared client is already rebuilt when we invalidate. + HttpClientManager.addClientChangedListener(::invalidate) initialized = true } } @@ -143,18 +137,14 @@ private object ImageFetcherManager { 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) + return if (HttpClientManager.isMtls) { + OkHttpImageFetcher.create(cacheDir) } else { CronetImageFetcher(appContext, cacheDir) } @@ -380,51 +370,17 @@ private class OkHttpImageFetcher private constructor( private var draining = false companion object { - fun create( - cacheDir: File, - sslSocketFactory: SSLSocketFactory?, - trustManager: X509TrustManager?, - ): OkHttpImageFetcher { + fun create(cacheDir: File): 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) + val client = HttpClientManager.getClient().newBuilder() .cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES)) + .build() - if (sslSocketFactory != null && trustManager != null) { - builder.sslSocketFactory(sslSocketFactory, trustManager) - } - - return OkHttpImageFetcher(builder.build()) + return OkHttpImageFetcher(client) } } - 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-- @@ -512,7 +468,6 @@ private class OkHttpImageFetcher private constructor( draining = true activeCount == 0 } - client.connectionPool.evictAll() if (shouldClose) { client.cache?.close() } diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index aa6fe9a729..6de13b6244 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -88,7 +88,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { Future init() async { try { - await HttpSSLOptions.apply(applyNative: false); + HttpSSLOptions.apply(); await Future.wait( [ diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index a7bb1fa164..60bb1cb9c3 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -57,7 +57,7 @@ void main() async { // Warm-up isolate pool for worker manager await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5)); await migrateDatabaseIfNeeded(isar, drift); - await HttpSSLOptions.apply(); + HttpSSLOptions.apply(); runApp( ProviderScope( diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index e0ed9e84ae..b69aa53014 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -341,7 +341,7 @@ class BackgroundService { ], ); - await HttpSSLOptions.apply(); + HttpSSLOptions.apply(); await ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken)); await ref.read(authServiceProvider).setOpenApiServiceEndpoint(); dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}"); diff --git a/mobile/lib/utils/http_ssl_options.dart b/mobile/lib/utils/http_ssl_options.dart index 901d37f614..a93387c9db 100644 --- a/mobile/lib/utils/http_ssl_options.dart +++ b/mobile/lib/utils/http_ssl_options.dart @@ -1,24 +1,20 @@ import 'dart:io'; -import 'package:flutter/services.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; -import 'package:logging/logging.dart'; class HttpSSLOptions { - static const MethodChannel _channel = MethodChannel('immich/httpSSLOptions'); - - static Future apply({bool applyNative = true}) { + static void apply() { AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert; bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey, setting.defaultValue); - return _apply(allowSelfSignedSSLCert, applyNative: applyNative); + return _apply(allowSelfSignedSSLCert); } - static Future applyFromSettings(bool newValue) => _apply(newValue); + static void applyFromSettings(bool newValue) => _apply(newValue); - static Future _apply(bool allowSelfSignedSSLCert, {bool applyNative = true}) { + static void _apply(bool allowSelfSignedSSLCert) { String? serverHost; if (allowSelfSignedSSLCert && Store.tryGet(StoreKey.currentUser) != null) { serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host; @@ -27,15 +23,5 @@ class HttpSSLOptions { SSLClientCertStoreVal? clientCert = SSLClientCertStoreVal.load(); HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert); - - if (applyNative && Platform.isAndroid) { - return _channel - .invokeMethod("apply", [allowSelfSignedSSLCert, serverHost, clientCert?.data, clientCert?.password]) - .onError((e, _) { - final log = Logger("HttpSSLOptions"); - log.severe('Failed to set SSL options', e.message); - }); - } - return Future.value(); } } diff --git a/mobile/lib/utils/isolate.dart b/mobile/lib/utils/isolate.dart index 182e2e0de1..7ac120acb4 100644 --- a/mobile/lib/utils/isolate.dart +++ b/mobile/lib/utils/isolate.dart @@ -54,7 +54,7 @@ Cancelable runInIsolateGentle({ Logger log = Logger("IsolateLogger"); try { - await HttpSSLOptions.apply(applyNative: false); + HttpSSLOptions.apply(); result = await computation(ref); } on CanceledError { log.warning("Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}"); diff --git a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart index 4fa70f86c1..2f74458bc6 100644 --- a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart +++ b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart @@ -63,7 +63,7 @@ class _SslClientCertSettingsState extends State { try { final cert = await networkApi.selectCertificate(); await SSLClientCertStoreVal(cert.data, cert.password).save(); - await HttpSSLOptions.apply(); + HttpSSLOptions.apply(); showMessage(context, "client_cert_import_success_msg".tr()); } catch (e) { showMessage(context, "client_cert_invalid_msg".tr()); @@ -74,7 +74,7 @@ class _SslClientCertSettingsState extends State { try { await networkApi.removeCertificate(); await SSLClientCertStoreVal.delete(); - await HttpSSLOptions.apply(); + HttpSSLOptions.apply(); showMessage(context, "client_cert_remove_msg".tr()); } catch (e) { showMessage(context, "client_cert_invalid_msg".tr());