From 27a2808470f2fa691b1693e332622923b9e86b91 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 5 Feb 2026 12:42:53 -0500 Subject: [PATCH] fix(mobile): mtls on native clients (#25802) * handle mtls on ios * update android impl * ui improvements * dead code * no need to store data separately * improve concurrency * dead code * add migration * remove unused dependency * trust user-installed certs * removed print statement * fix ios * improve android styling * outdated comments * update lock file * handle translation * fix prompt cancellation * fix video playback * Apply suggestion from @shenlong-tanwen Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> * Apply suggestion from @shenlong-tanwen Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> * formatting --------- Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> --- i18n/en.json | 2 + mobile/android/app/build.gradle | 1 + .../android/app/src/main/AndroidManifest.xml | 3 +- .../alextran/immich/HttpSSLOptionsPlugin.kt | 153 ---------- .../app/alextran/immich/MainActivity.kt | 6 +- .../alextran/immich/core/HttpClientManager.kt | 149 +++++++++ .../app/alextran/immich/core/Network.g.kt | 253 ++++++++++++++++ .../alextran/immich/core/NetworkApiPlugin.kt | 159 ++++++++++ .../app/alextran/immich/core/SSLConfig.kt | 73 ----- .../immich/images/RemoteImagesImpl.kt | 66 +--- .../main/res/xml/network_security_config.xml | 9 + mobile/ios/Podfile.lock | 50 --- mobile/ios/Runner.xcodeproj/project.pbxproj | 4 + mobile/ios/Runner/AppDelegate.swift | 7 +- .../Runner/Background/BackgroundWorker.swift | 2 +- mobile/ios/Runner/Core/Network.g.swift | 284 ++++++++++++++++++ mobile/ios/Runner/Core/NetworkApiImpl.swift | 157 ++++++++++ .../ios/Runner/Core/URLSessionManager.swift | 87 ++++++ .../ios/Runner/Images/ImageProcessing.swift | 7 + .../ios/Runner/Images/LocalImagesImpl.swift | 25 +- .../ios/Runner/Images/RemoteImagesImpl.swift | 197 +++++------- .../services/background_worker.service.dart | 2 +- mobile/lib/platform/network_api.g.dart | 232 ++++++++++++++ .../infrastructure/platform.provider.dart | 3 + mobile/lib/utils/http_ssl_options.dart | 23 +- mobile/lib/utils/isolate.dart | 2 +- mobile/lib/utils/migration.dart | 11 +- .../settings/ssl_client_cert_settings.dart | 120 +++----- mobile/makefile | 2 + mobile/pigeon/network_api.dart | 41 +++ mobile/pubspec.lock | 8 - mobile/pubspec.yaml | 1 - 32 files changed, 1560 insertions(+), 579 deletions(-) delete mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/HttpSSLOptionsPlugin.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt delete mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/core/SSLConfig.kt create mode 100644 mobile/android/app/src/main/res/xml/network_security_config.xml create mode 100644 mobile/ios/Runner/Core/Network.g.swift create mode 100644 mobile/ios/Runner/Core/NetworkApiImpl.swift create mode 100644 mobile/ios/Runner/Core/URLSessionManager.swift create mode 100644 mobile/ios/Runner/Images/ImageProcessing.swift create mode 100644 mobile/lib/platform/network_api.g.dart create mode 100644 mobile/pigeon/network_api.dart diff --git a/i18n/en.json b/i18n/en.json index bdc16f15db..2d3d3680f8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -782,6 +782,8 @@ "client_cert_import": "Import", "client_cert_import_success_msg": "Client certificate is imported", "client_cert_invalid_msg": "Invalid certificate file or wrong password", + "client_cert_password_message": "Enter the password for this certificate", + "client_cert_password_title": "Certificate Password", "client_cert_remove_msg": "Client certificate is removed", "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate import/removal is available only before login", "client_cert_title": "SSL client certificate [EXPERIMENTAL]", diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 3360617a3d..4999f9a7f9 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -131,6 +131,7 @@ dependencies { implementation "androidx.compose.ui:ui-tooling:$compose_version" implementation "androidx.compose.material3:material3:1.2.1" implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2" + implementation "com.google.android.material:material:1.12.0" } // This is uncommented in F-Droid build script diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 0d4925077a..eacf75b7ed 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -27,7 +27,8 @@ + android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false" + android:networkSecurityConfig="@xml/network_security_config"> 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..ee92c2120e --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt @@ -0,0 +1,149 @@ +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.HttpsURLConnection +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. + */ +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) } + HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) + + 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..1e7156a147 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt @@ -0,0 +1,253 @@ +// 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() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class ClientCertPrompt ( + val title: String, + val message: String, + val cancel: String, + val confirm: String +) + { + companion object { + fun fromList(pigeonVar_list: List): ClientCertPrompt { + val title = pigeonVar_list[0] as String + val message = pigeonVar_list[1] as String + val cancel = pigeonVar_list[2] as String + val confirm = pigeonVar_list[3] as String + return ClientCertPrompt(title, message, cancel, confirm) + } + } + fun toList(): List { + return listOf( + title, + message, + cancel, + confirm, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is ClientCertPrompt) { + 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) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + ClientCertPrompt.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()) + } + is ClientCertPrompt -> { + stream.write(130) + 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(promptText: ClientCertPrompt, 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 { message, reply -> + val args = message as List + val promptTextArg = args[0] as ClientCertPrompt + api.selectCertificate(promptTextArg) { 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..4f25896b2f --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt @@ -0,0 +1,159 @@ +package app.alextran.immich.core + +import android.app.Activity +import android.content.Context +import android.net.Uri +import android.os.OperationCanceledException +import android.text.InputType +import android.view.ContextThemeWrapper +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.FrameLayout +import android.widget.LinearLayout +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +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 + private var promptText: ClientCertPrompt? = 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(promptText: ClientCertPrompt, callback: (Result) -> Unit) { + val picker = filePicker ?: return callback(Result.failure(IllegalStateException("No activity"))) + pendingCallback = callback + this.promptText = promptText + 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 -> + promptText = null + 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 themedContext = ContextThemeWrapper(activity, com.google.android.material.R.style.Theme_Material3_DayNight_Dialog) + val density = activity.resources.displayMetrics.density + val horizontalPadding = (24 * density).toInt() + + val textInputLayout = TextInputLayout(themedContext).apply { + hint = "Password" + endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE + layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply { + setMargins(horizontalPadding, 0, horizontalPadding, 0) + } + } + + val editText = TextInputEditText(textInputLayout.context).apply { + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + } + textInputLayout.addView(editText) + + val container = FrameLayout(themedContext).apply { addView(textInputLayout) } + + val text = promptText!! + MaterialAlertDialogBuilder(themedContext) + .setTitle(text.title) + .setMessage(text.message) + .setView(container) + .setPositiveButton(text.confirm) { _, _ -> callback(editText.text.toString()) } + .setNegativeButton(text.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..04a181cd6e 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 @@ -32,15 +30,8 @@ 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) @@ -121,7 +112,7 @@ private object ImageFetcherManager { appContext = context.applicationContext cacheDir = context.cacheDir fetcher = build() - SSLConfig.addListener(::invalidate) + HttpClientManager.addClientChangedListener(::invalidate) initialized = true } } @@ -143,18 +134,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 +367,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 +465,6 @@ private class OkHttpImageFetcher private constructor( draining = true activeCount == 0 } - client.connectionPool.evictAll() if (shouldClose) { client.cache?.close() } diff --git a/mobile/android/app/src/main/res/xml/network_security_config.xml b/mobile/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000000..8135436e53 --- /dev/null +++ b/mobile/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 77caaeceef..8b8e19fedf 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -11,40 +11,6 @@ PODS: - FlutterMacOS - device_info_plus (0.0.1): - Flutter - - DKImagePickerController/Core (4.3.9): - - DKImagePickerController/ImageDataManager - - DKImagePickerController/Resource - - DKImagePickerController/ImageDataManager (4.3.9) - - DKImagePickerController/PhotoGallery (4.3.9): - - DKImagePickerController/Core - - DKPhotoGallery - - DKImagePickerController/Resource (4.3.9) - - DKPhotoGallery (0.0.19): - - DKPhotoGallery/Core (= 0.0.19) - - DKPhotoGallery/Model (= 0.0.19) - - DKPhotoGallery/Preview (= 0.0.19) - - DKPhotoGallery/Resource (= 0.0.19) - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Core (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Preview - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Model (0.0.19): - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Preview (0.0.19): - - DKPhotoGallery/Model - - DKPhotoGallery/Resource - - SDWebImage - - SwiftyGif - - DKPhotoGallery/Resource (0.0.19): - - SDWebImage - - SwiftyGif - - file_picker (0.0.1): - - DKImagePickerController/PhotoGallery - - Flutter - Flutter (1.0.0) - flutter_local_notifications (0.0.1): - Flutter @@ -93,9 +59,6 @@ PODS: - Flutter - FlutterMacOS - SAMKeychain (1.5.3) - - SDWebImage (5.21.0): - - SDWebImage/Core (= 5.21.0) - - SDWebImage/Core (5.21.0) - share_handler_ios (0.0.14): - Flutter - share_handler_ios/share_handler_ios_models (= 0.0.14) @@ -131,7 +94,6 @@ PODS: - sqlite3/fts5 - sqlite3/perf-threadsafe - sqlite3/rtree - - SwiftyGif (5.4.5) - url_launcher_ios (0.0.1): - Flutter - wakelock_plus (0.0.1): @@ -143,7 +105,6 @@ DEPENDENCIES: - 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`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) @@ -176,13 +137,9 @@ DEPENDENCIES: SPEC REPOS: trunk: - - DKImagePickerController - - DKPhotoGallery - MapLibre - SAMKeychain - - SDWebImage - sqlite3 - - SwiftyGif EXTERNAL SOURCES: background_downloader: @@ -195,8 +152,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/cupertino_http/darwin" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" - file_picker: - :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter flutter_local_notifications: @@ -262,9 +217,6 @@ SPEC CHECKSUMS: connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe - DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c - DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf @@ -288,7 +240,6 @@ SPEC CHECKSUMS: permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62 SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c - SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868 share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a @@ -296,7 +247,6 @@ SPEC CHECKSUMS: sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983 sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241 - SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 991f075ad9..22a7abcbac 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ 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 */; }; + FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */; }; FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; }; FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; }; FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; }; @@ -124,6 +125,7 @@ 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 = ""; }; + FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = ""; }; FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -325,6 +327,7 @@ FED3B1952E253E9B0030FD97 /* Images */ = { isa = PBXGroup; children = ( + FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */, FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */, FE5499F52F11980E006016CB /* LocalImagesImpl.swift */, FE5499F12F1197D8006016CB /* LocalImages.g.swift */, @@ -609,6 +612,7 @@ FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */, FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */, FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */, + FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */, B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */, FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */, FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 60f97b6645..f842285b23 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -15,12 +15,12 @@ import UIKit ) -> Bool { // Required for flutter_local_notification if #available(iOS 10.0, *) { - UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate } GeneratedPluginRegistrant.register(with: self) let controller: FlutterViewController = window?.rootViewController as! FlutterViewController - AppDelegate.registerPlugins(with: controller.engine) + AppDelegate.registerPlugins(with: controller.engine, controller: controller) BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!) BackgroundServicePlugin.registerBackgroundProcessing() @@ -51,12 +51,13 @@ import UIKit return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - public static func registerPlugins(with engine: FlutterEngine) { + public static func registerPlugins(with engine: FlutterEngine, controller: FlutterViewController?) { NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!) 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()) + NetworkApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: NetworkApiImpl(viewController: controller)) } public static func cancelPlugins(with engine: FlutterEngine) { diff --git a/mobile/ios/Runner/Background/BackgroundWorker.swift b/mobile/ios/Runner/Background/BackgroundWorker.swift index 7dc450d76e..85e1a55d3d 100644 --- a/mobile/ios/Runner/Background/BackgroundWorker.swift +++ b/mobile/ios/Runner/Background/BackgroundWorker.swift @@ -95,7 +95,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi { // Register plugins in the new engine GeneratedPluginRegistrant.register(with: engine) // Register custom plugins - AppDelegate.registerPlugins(with: engine) + AppDelegate.registerPlugins(with: engine, controller: nil) flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger) BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self) diff --git a/mobile/ios/Runner/Core/Network.g.swift b/mobile/ios/Runner/Core/Network.g.swift new file mode 100644 index 0000000000..0f678ce4a4 --- /dev/null +++ b/mobile/ios/Runner/Core/Network.g.swift @@ -0,0 +1,284 @@ +// 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? +} + +func deepEqualsNetwork(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case is (Void, Void): + return true + + case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable): + return cleanLhsHashable == cleanRhsHashable + + case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]): + guard cleanLhsArray.count == cleanRhsArray.count else { return false } + for (index, element) in cleanLhsArray.enumerated() { + if !deepEqualsNetwork(element, cleanRhsArray[index]) { + return false + } + } + return true + + case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false } + for (key, cleanLhsValue) in cleanLhsDictionary { + guard cleanRhsDictionary.index(forKey: key) != nil else { return false } + if !deepEqualsNetwork(cleanLhsValue, cleanRhsDictionary[key]!) { + return false + } + } + return true + + default: + // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue. + return false + } +} + +func deepHashNetwork(value: Any?, hasher: inout Hasher) { + if let valueList = value as? [AnyHashable] { + for item in valueList { deepHashNetwork(value: item, hasher: &hasher) } + return + } + + if let valueDict = value as? [AnyHashable: AnyHashable] { + for key in valueDict.keys { + hasher.combine(key) + deepHashNetwork(value: valueDict[key]!, hasher: &hasher) + } + return + } + + if let hashableValue = value as? AnyHashable { + hasher.combine(hashableValue.hashValue) + } + + return hasher.combine(String(describing: value)) +} + + + +/// Generated class from Pigeon that represents data sent in messages. +struct ClientCertData: Hashable { + var data: FlutterStandardTypedData + var password: String + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> ClientCertData? { + let data = pigeonVar_list[0] as! FlutterStandardTypedData + let password = pigeonVar_list[1] as! String + + return ClientCertData( + data: data, + password: password + ) + } + func toList() -> [Any?] { + return [ + data, + password, + ] + } + static func == (lhs: ClientCertData, rhs: ClientCertData) -> Bool { + return deepEqualsNetwork(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashNetwork(value: toList(), hasher: &hasher) + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct ClientCertPrompt: Hashable { + var title: String + var message: String + var cancel: String + var confirm: String + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> ClientCertPrompt? { + let title = pigeonVar_list[0] as! String + let message = pigeonVar_list[1] as! String + let cancel = pigeonVar_list[2] as! String + let confirm = pigeonVar_list[3] as! String + + return ClientCertPrompt( + title: title, + message: message, + cancel: cancel, + confirm: confirm + ) + } + func toList() -> [Any?] { + return [ + title, + message, + cancel, + confirm, + ] + } + static func == (lhs: ClientCertPrompt, rhs: ClientCertPrompt) -> Bool { + return deepEqualsNetwork(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashNetwork(value: toList(), hasher: &hasher) + } +} + +private class NetworkPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return ClientCertData.fromList(self.readValue() as! [Any?]) + case 130: + return ClientCertPrompt.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class NetworkPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? ClientCertData { + super.writeByte(129) + super.writeValue(value.toList()) + } else if let value = value as? ClientCertPrompt { + super.writeByte(130) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class NetworkPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return NetworkPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return NetworkPigeonCodecWriter(data: data) + } +} + +class NetworkPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = NetworkPigeonCodec(readerWriter: NetworkPigeonCodecReaderWriter()) +} + + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol NetworkApi { + func addCertificate(clientData: ClientCertData, completion: @escaping (Result) -> Void) + func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result) -> Void) + func removeCertificate(completion: @escaping (Result) -> Void) +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class NetworkApiSetup { + static var codec: FlutterStandardMessageCodec { NetworkPigeonCodec.shared } + /// Sets up an instance of `NetworkApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: NetworkApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let addCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.addCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + addCertificateChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let clientDataArg = args[0] as! ClientCertData + api.addCertificate(clientData: clientDataArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + addCertificateChannel.setMessageHandler(nil) + } + let selectCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + selectCertificateChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let promptTextArg = args[0] as! ClientCertPrompt + api.selectCertificate(promptText: promptTextArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + selectCertificateChannel.setMessageHandler(nil) + } + let removeCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + removeCertificateChannel.setMessageHandler { _, reply in + api.removeCertificate { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + removeCertificateChannel.setMessageHandler(nil) + } + } +} diff --git a/mobile/ios/Runner/Core/NetworkApiImpl.swift b/mobile/ios/Runner/Core/NetworkApiImpl.swift new file mode 100644 index 0000000000..d67c392a3a --- /dev/null +++ b/mobile/ios/Runner/Core/NetworkApiImpl.swift @@ -0,0 +1,157 @@ +import Foundation +import UniformTypeIdentifiers + +enum ImportError: Error { + case noFile + case noViewController + case keychainError(OSStatus) + case cancelled +} + +class NetworkApiImpl: NetworkApi { + weak var viewController: UIViewController? + private var activeImporter: CertImporter? + + init(viewController: UIViewController?) { + self.viewController = viewController + } + + func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result) -> Void) { + let importer = CertImporter(promptText: promptText, completion: { [weak self] result in + self?.activeImporter = nil + completion(result.map { ClientCertData(data: FlutterStandardTypedData(bytes: $0.0), password: $0.1) }) + }, viewController: viewController) + activeImporter = importer + importer.load() + } + + func removeCertificate(completion: @escaping (Result) -> Void) { + let status = clearCerts() + if status == errSecSuccess || status == errSecItemNotFound { + return completion(.success(())) + } + completion(.failure(ImportError.keychainError(status))) + } + + func addCertificate(clientData: ClientCertData, completion: @escaping (Result) -> Void) { + let status = importCert(clientData: clientData.data.data, password: clientData.password) + if status == errSecSuccess { + return completion(.success(())) + } + completion(.failure(ImportError.keychainError(status))) + } +} + +private class CertImporter: NSObject, UIDocumentPickerDelegate { + private let promptText: ClientCertPrompt + private var completion: ((Result<(Data, String), Error>) -> Void) + private weak var viewController: UIViewController? + + init(promptText: ClientCertPrompt, completion: (@escaping (Result<(Data, String), Error>) -> Void), viewController: UIViewController?) { + self.promptText = promptText + self.completion = completion + self.viewController = viewController + } + + func load() { + guard let vc = viewController else { return completion(.failure(ImportError.noViewController)) } + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [ + UTType(filenameExtension: "p12")!, + UTType(filenameExtension: "pfx")!, + ]) + picker.delegate = self + picker.allowsMultipleSelection = false + vc.present(picker, animated: true) + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { + return completion(.failure(ImportError.noFile)) + } + + Task { @MainActor in + do { + let data = try readSecurityScoped(url: url) + guard let password = await promptForPassword() else { + return completion(.failure(ImportError.cancelled)) + } + let status = importCert(clientData: data, password: password) + if status != errSecSuccess { + return completion(.failure(ImportError.keychainError(status))) + } + + await URLSessionManager.shared.session.flush() + self.completion(.success((data, password))) + } catch { + completion(.failure(error)) + } + } + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + completion(.failure(ImportError.cancelled)) + } + + private func promptForPassword() async -> String? { + guard let vc = viewController else { return nil } + + return await withCheckedContinuation { continuation in + let alert = UIAlertController( + title: promptText.title, + message: promptText.message, + preferredStyle: .alert + ) + + alert.addTextField { $0.isSecureTextEntry = true } + + alert.addAction(UIAlertAction(title: promptText.cancel, style: .cancel) { _ in + continuation.resume(returning: nil) + }) + + alert.addAction(UIAlertAction(title: promptText.confirm, style: .default) { _ in + continuation.resume(returning: alert.textFields?.first?.text ?? "") + }) + + vc.present(alert, animated: true) + } + } + + private func readSecurityScoped(url: URL) throws -> Data { + guard url.startAccessingSecurityScopedResource() else { + throw ImportError.noFile + } + defer { url.stopAccessingSecurityScopedResource() } + return try Data(contentsOf: url) + } +} + +private func importCert(clientData: Data, password: String) -> OSStatus { + let options = [kSecImportExportPassphrase: password] as CFDictionary + var items: CFArray? + let status = SecPKCS12Import(clientData as CFData, options, &items) + + guard status == errSecSuccess, + let array = items as? [[String: Any]], + let first = array.first, + let identity = first[kSecImportItemIdentity as String] else { + return status + } + + clearCerts() + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecValueRef as String: identity, + kSecAttrLabel as String: CLIENT_CERT_LABEL, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + return SecItemAdd(addQuery as CFDictionary, nil) +} + +@discardableResult private func clearCerts() -> OSStatus { + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: CLIENT_CERT_LABEL, + ] + return SecItemDelete(deleteQuery as CFDictionary) +} diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift new file mode 100644 index 0000000000..73145dbce5 --- /dev/null +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -0,0 +1,87 @@ +import Foundation + +let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity" + +/// Manages a shared URLSession with SSL configuration support. +class URLSessionManager: NSObject { + static let shared = URLSessionManager() + + let session: URLSession + private let configuration = { + let config = URLSessionConfiguration.default + + let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) + .first! + .appendingPathComponent("api", isDirectory: true) + try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + + config.urlCache = URLCache( + memoryCapacity: 0, + diskCapacity: 1024 * 1024 * 1024, + directory: cacheDir + ) + + config.httpMaximumConnectionsPerHost = 64 + config.timeoutIntervalForRequest = 60 + config.timeoutIntervalForResource = 300 + + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" + config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"] + + return config + }() + + private override init() { + session = URLSession(configuration: configuration, delegate: URLSessionManagerDelegate(), delegateQueue: nil) + super.init() + } +} + +class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + handleChallenge(challenge, completionHandler: completionHandler) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + handleChallenge(challenge, completionHandler: completionHandler) + } + + func handleChallenge( + _ challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + switch challenge.protectionSpace.authenticationMethod { + case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(completion: completionHandler) + default: completionHandler(.performDefaultHandling, nil) + } + } + + private func handleClientCertificate( + completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: CLIENT_CERT_LABEL, + kSecReturnRef as String: true, + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecSuccess, let identity = item { + let credential = URLCredential(identity: identity as! SecIdentity, + certificates: nil, + persistence: .forSession) + return completion(.useCredential, credential) + } + completion(.performDefaultHandling, nil) + } +} diff --git a/mobile/ios/Runner/Images/ImageProcessing.swift b/mobile/ios/Runner/Images/ImageProcessing.swift new file mode 100644 index 0000000000..2270bbffac --- /dev/null +++ b/mobile/ios/Runner/Images/ImageProcessing.swift @@ -0,0 +1,7 @@ +import Foundation + +enum ImageProcessing { + static let queue = DispatchQueue(label: "thumbnail.processing", qos: .userInitiated, attributes: .concurrent) + static let semaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2) + static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil) +} diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift index 3af524f424..96e1b60a2f 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -34,7 +34,6 @@ class LocalImageApiImpl: LocalImageApi { private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated) private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated) private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default) - private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent) private static var rgbaFormat = vImage_CGImageFormat( bitsPerComponent: 8, @@ -44,8 +43,6 @@ class LocalImageApiImpl: LocalImageApi { 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 @@ -53,7 +50,7 @@ class LocalImageApiImpl: LocalImageApi { }() func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) { - Self.processingQueue.async { + ImageProcessing.queue.async { guard let data = Data(base64Encoded: thumbhash) else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))} @@ -71,16 +68,16 @@ class LocalImageApiImpl: LocalImageApi { let request = LocalImageRequest(callback: completion) let item = DispatchWorkItem { if request.isCancelled { - return completion(Self.cancelledResult) + return completion(ImageProcessing.cancelledResult) } - Self.concurrencySemaphore.wait() + ImageProcessing.semaphore.wait() defer { - Self.concurrencySemaphore.signal() + ImageProcessing.semaphore.signal() } if request.isCancelled { - return completion(Self.cancelledResult) + return completion(ImageProcessing.cancelledResult) } guard let asset = Self.requestAsset(assetId: assetId) @@ -91,7 +88,7 @@ class LocalImageApiImpl: LocalImageApi { } if request.isCancelled { - return completion(Self.cancelledResult) + return completion(ImageProcessing.cancelledResult) } var image: UIImage? @@ -106,7 +103,7 @@ class LocalImageApiImpl: LocalImageApi { ) if request.isCancelled { - return completion(Self.cancelledResult) + return completion(ImageProcessing.cancelledResult) } guard let image = image, @@ -116,7 +113,7 @@ class LocalImageApiImpl: LocalImageApi { } if request.isCancelled { - return completion(Self.cancelledResult) + return completion(ImageProcessing.cancelledResult) } do { @@ -124,7 +121,7 @@ class LocalImageApiImpl: LocalImageApi { if request.isCancelled { buffer.free() - return completion(Self.cancelledResult) + return completion(ImageProcessing.cancelledResult) } request.callback(.success([ @@ -142,7 +139,7 @@ class LocalImageApiImpl: LocalImageApi { request.workItem = item Self.add(requestId: requestId, request: request) - Self.processingQueue.async(execute: item) + ImageProcessing.queue.async(execute: item) } func cancelRequest(requestId: Int64) { @@ -163,7 +160,7 @@ class LocalImageApiImpl: LocalImageApi { request.isCancelled = true guard let item = request.workItem else { return } if item.isCancelled { - cancelQueue.async { request.callback(Self.cancelledResult) } + cancelQueue.async { request.callback(ImageProcessing.cancelledResult) } } } } diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index d59204b96e..56e8938521 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -7,64 +7,18 @@ 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 - config.requestCachePolicy = .returnCacheDataElseLoad - 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 lock = os_unfair_lock() + private static var requests = [Int64: RemoteImageRequest]() private static var rgbaFormat = vImage_CGImageFormat( bitsPerComponent: 8, bitsPerPixel: 32, @@ -72,9 +26,6 @@ class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate { 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, @@ -82,105 +33,103 @@ class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate { kCGImageSourceCreateThumbnailFromImageAlways: 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) + func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { + var urlRequest = URLRequest(url: URL(string: url)!) + urlRequest.cachePolicy = .returnCacheDataElseLoad + for (key, value) in headers { + urlRequest.setValue(value, forHTTPHeaderField: key) } - 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) + let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in + Self.handleCompletion(requestId: requestId, data: data, response: response, error: error) } + + let request = RemoteImageRequest(id: requestId, task: task, completion: completion) + + os_unfair_lock_lock(&Self.lock) + Self.requests[requestId] = request + os_unfair_lock_unlock(&Self.lock) + + task.resume() } - 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) } + private static func handleCompletion(requestId: Int64, data: Data?, response: URLResponse?, error: Error?) { + os_unfair_lock_lock(&Self.lock) + guard let request = requests[requestId] else { + return os_unfair_lock_unlock(&Self.lock) + } + requests[requestId] = nil + os_unfair_lock_unlock(&Self.lock) if let error = error { if request.isCancelled || (error as NSError).code == NSURLErrorCancelled { - return request.completion(Self.cancelledResult) + return request.completion(ImageProcessing.cancelledResult) } return request.completion(.failure(error)) } if request.isCancelled { - return request.completion(Self.cancelledResult) + return request.completion(ImageProcessing.cancelledResult) } - guard let data = request.data else { + guard let data = 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) + ImageProcessing.queue.async { + ImageProcessing.semaphore.wait() + defer { ImageProcessing.semaphore.signal() } if request.isCancelled { - buffer.free() - return request.completion(Self.cancelledResult) + return request.completion(ImageProcessing.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))) + guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil), + let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else { + return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil))) + } + + if request.isCancelled { + return request.completion(ImageProcessing.cancelledResult) + } + + do { + let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat) + + if request.isCancelled { + buffer.free() + return request.completion(ImageProcessing.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 } + func cancelRequest(requestId: Int64) { + os_unfair_lock_lock(&Self.lock) + let request = Self.requests[requestId] + os_unfair_lock_unlock(&Self.lock) + + guard let request = request else { return } request.isCancelled = true request.task?.cancel() } + + func clearCache(completion: @escaping (Result) -> Void) { + Task { + let cache = URLSessionManager.shared.session.configuration.urlCache! + let cacheSize = Int64(cache.currentDiskUsage) + cache.removeAllCachedResponses() + completion(.success(cacheSize)) + } + } } diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 9019db664d..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 { - HttpSSLOptions.apply(applyNative: false); + HttpSSLOptions.apply(); await Future.wait( [ diff --git a/mobile/lib/platform/network_api.g.dart b/mobile/lib/platform/network_api.g.dart new file mode 100644 index 0000000000..6ddb3cdb71 --- /dev/null +++ b/mobile/lib/platform/network_api.g.dart @@ -0,0 +1,232 @@ +// 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".', + ); +} + +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + return a.length == b.length && + a.entries.every( + (MapEntry entry) => + (b as Map).containsKey(entry.key) && _deepEquals(entry.value, b[entry.key]), + ); + } + return a == b; +} + +class ClientCertData { + ClientCertData({required this.data, required this.password}); + + Uint8List data; + + String password; + + List _toList() { + return [data, password]; + } + + Object encode() { + return _toList(); + } + + static ClientCertData decode(Object result) { + result as List; + return ClientCertData(data: result[0]! as Uint8List, password: result[1]! as String); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! ClientCertData || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +class ClientCertPrompt { + ClientCertPrompt({required this.title, required this.message, required this.cancel, required this.confirm}); + + String title; + + String message; + + String cancel; + + String confirm; + + List _toList() { + return [title, message, cancel, confirm]; + } + + Object encode() { + return _toList(); + } + + static ClientCertPrompt decode(Object result) { + result as List; + return ClientCertPrompt( + title: result[0]! as String, + message: result[1]! as String, + cancel: result[2]! as String, + confirm: result[3]! as String, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! ClientCertPrompt || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is ClientCertData) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is ClientCertPrompt) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return ClientCertData.decode(readValue(buffer)!); + case 130: + return ClientCertPrompt.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class NetworkApi { + /// Constructor for [NetworkApi]. 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. + NetworkApi({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 addCertificate(ClientCertData clientData) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NetworkApi.addCertificate$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([clientData]); + 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 selectCertificate(ClientCertPrompt promptText) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([promptText]); + 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 ClientCertData?)!; + } + } + + Future removeCertificate() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate$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 { + return; + } + } +} diff --git a/mobile/lib/providers/infrastructure/platform.provider.dart b/mobile/lib/providers/infrastructure/platform.provider.dart index 60300e74df..01d0f61d1c 100644 --- a/mobile/lib/providers/infrastructure/platform.provider.dart +++ b/mobile/lib/providers/infrastructure/platform.provider.dart @@ -5,6 +5,7 @@ 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/local_image_api.g.dart'; +import 'package:immich_mobile/platform/network_api.g.dart'; import 'package:immich_mobile/platform/remote_image_api.g.dart'; final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi())); @@ -20,3 +21,5 @@ final connectivityApiProvider = Provider((_) => ConnectivityApi final localImageApi = LocalImageApi(); final remoteImageApi = RemoteImageApi(); + +final networkApi = NetworkApi(); diff --git a/mobile/lib/utils/http_ssl_options.dart b/mobile/lib/utils/http_ssl_options.dart index c4e2ad69f7..a93387c9db 100644 --- a/mobile/lib/utils/http_ssl_options.dart +++ b/mobile/lib/utils/http_ssl_options.dart @@ -1,26 +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 void apply({bool applyNative = true}) { + static void apply() { AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert; bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey, setting.defaultValue); - _apply(allowSelfSignedSSLCert, applyNative: applyNative); + return _apply(allowSelfSignedSSLCert); } - static void applyFromSettings(bool newValue) { - _apply(newValue); - } + static void applyFromSettings(bool newValue) => _apply(newValue); - static void _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; @@ -29,14 +23,5 @@ class HttpSSLOptions { SSLClientCertStoreVal? clientCert = SSLClientCertStoreVal.load(); HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert); - - if (applyNative && Platform.isAndroid) { - _channel - .invokeMethod("apply", [allowSelfSignedSSLCert, serverHost, clientCert?.data, clientCert?.password]) - .onError((e, _) { - final log = Logger("HttpSSLOptions"); - log.severe('Failed to set SSL options', e.message); - }); - } } } diff --git a/mobile/lib/utils/isolate.dart b/mobile/lib/utils/isolate.dart index 491e1bf107..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 { - 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/utils/migration.dart b/mobile/lib/utils/migration.dart index 94ae69321f..70f9ba88c7 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -23,6 +23,8 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; +import 'package:immich_mobile/platform/network_api.g.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/datetime_helpers.dart'; import 'package:immich_mobile/utils/debug_print.dart'; @@ -32,7 +34,7 @@ import 'package:isar/isar.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 20; +const int targetVersion = 21; Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { final hasVersion = Store.tryGet(StoreKey.version) != null; @@ -91,6 +93,13 @@ Future migrateDatabaseIfNeeded(Isar db, Drift drift) async { await _syncLocalAlbumIsIosSharedAlbum(drift); } + if (version < 21) { + final certData = SSLClientCertStoreVal.load(); + if (certData != null) { + await networkApi.addCertificate(ClientCertData(data: certData.data, password: certData.password ?? "")); + } + } + if (targetVersion >= 12) { await Store.put(StoreKey.version, targetVersion); return; diff --git a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart index dc31acf0a4..fa210ee720 100644 --- a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart +++ b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart @@ -1,14 +1,13 @@ -import 'dart:io'; - import 'package:easy_localization/easy_localization.dart'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; +import 'package:immich_mobile/platform/network_api.g.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart'; +import 'package:logging/logging.dart'; class SslClientCertSettings extends StatefulWidget { const SslClientCertSettings({super.key, required this.isLoggedIn}); @@ -20,10 +19,12 @@ class SslClientCertSettings extends StatefulWidget { } class _SslClientCertSettingsState extends State { - _SslClientCertSettingsState() : isCertExist = SSLClientCertStoreVal.load() != null; + final _log = Logger("SslClientCertSettings"); bool isCertExist; + _SslClientCertSettingsState() : isCertExist = SSLClientCertStoreVal.load() != null; + @override Widget build(BuildContext context) { return ListTile( @@ -41,16 +42,12 @@ class _SslClientCertSettingsState extends State { const SizedBox(height: 6), Row( mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.center, children: [ + ElevatedButton(onPressed: widget.isLoggedIn ? null : importCert, child: Text("client_cert_import".tr())), ElevatedButton( - onPressed: widget.isLoggedIn ? null : () => importCert(context), - child: Text("client_cert_import".tr()), - ), - const SizedBox(width: 15), - ElevatedButton( - onPressed: widget.isLoggedIn || !isCertExist ? null : () async => await removeCert(context), + onPressed: widget.isLoggedIn || !isCertExist ? null : removeCert, child: Text("remove".tr()), ), ], @@ -60,71 +57,52 @@ class _SslClientCertSettingsState extends State { ); } - void showMessage(BuildContext context, String message) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - content: Text(message), - actions: [TextButton(onPressed: () => ctx.pop(), child: Text("client_cert_dialog_msg_confirm".tr()))], + void showMessage(String message) { + context.showSnackBar( + SnackBar( + duration: const Duration(seconds: 3), + content: Text(message, style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor)), ), ); } - Future storeCert(BuildContext context, Uint8List data, String? password) async { - if (password != null && password.isEmpty) { - password = null; - } - final cert = SSLClientCertStoreVal(data, password); - // Test whether the certificate is valid - final isCertValid = HttpSSLCertOverride.setClientCert(SecurityContext(withTrustedRoots: true), cert); - if (!isCertValid) { - showMessage(context, "client_cert_invalid_msg".tr()); - return; - } - await cert.save(); - HttpSSLOptions.apply(); - setState(() => isCertExist = true); - showMessage(context, "client_cert_import_success_msg".tr()); - } - - void setPassword(BuildContext context, Uint8List data) { - final password = TextEditingController(); - showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => AlertDialog( - content: TextField( - controller: password, - obscureText: true, - obscuringCharacter: "*", - decoration: InputDecoration(hintText: "client_cert_enter_password".tr()), - ), - actions: [ - TextButton( - onPressed: () async => {ctx.pop(), await storeCert(context, data, password.text)}, - child: Text("client_cert_dialog_msg_confirm".tr()), - ), - ], - ), - ); - } - - Future importCert(BuildContext ctx) async { - FilePickerResult? res = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['p12', 'pfx'], - ); - if (res != null) { - File file = File(res.files.single.path!); - final bytes = await file.readAsBytes(); - setPassword(ctx, bytes); + Future importCert() async { + try { + final styling = ClientCertPrompt( + title: "client_cert_password_title".tr(), + message: "client_cert_password_message".tr(), + cancel: "cancel".tr(), + confirm: "confirm".tr(), + ); + final cert = await networkApi.selectCertificate(styling); + await SSLClientCertStoreVal(cert.data, cert.password).save(); + HttpSSLOptions.apply(); + setState(() => isCertExist = true); + showMessage("client_cert_import_success_msg".tr()); + } catch (e) { + if (_isCancellation(e)) { + return; + } + _log.severe("Error importing client cert", e); + showMessage("client_cert_invalid_msg".tr()); } } - Future removeCert(BuildContext context) async { - await SSLClientCertStoreVal.delete(); - HttpSSLOptions.apply(); - setState(() => isCertExist = false); - showMessage(context, "client_cert_remove_msg".tr()); + Future removeCert() async { + try { + await networkApi.removeCertificate(); + await SSLClientCertStoreVal.delete(); + HttpSSLOptions.apply(); + setState(() => isCertExist = false); + showMessage("client_cert_remove_msg".tr()); + } catch (e) { + if (_isCancellation(e)) { + return; + } + _log.severe("Error removing client cert", e); + showMessage("client_cert_invalid_msg".tr()); + } } + + bool _isCancellation(Object e) => e is PlatformException && e.code.toLowerCase().contains("cancel"); } diff --git a/mobile/makefile b/mobile/makefile index 79b263c079..50fa2490f1 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -12,12 +12,14 @@ pigeon: 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 run pigeon --input pigeon/network_api.dart dart format lib/platform/native_sync_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 + dart format lib/platform/network_api.g.dart watch: dart run build_runner watch --delete-conflicting-outputs diff --git a/mobile/pigeon/network_api.dart b/mobile/pigeon/network_api.dart new file mode 100644 index 0000000000..68d2f7d8fc --- /dev/null +++ b/mobile/pigeon/network_api.dart @@ -0,0 +1,41 @@ +import 'package:pigeon/pigeon.dart'; + +class ClientCertData { + Uint8List data; + String password; + + ClientCertData(this.data, this.password); +} + +class ClientCertPrompt { + String title; + String message; + String cancel; + String confirm; + + ClientCertPrompt(this.title, this.message, this.cancel, this.confirm); +} + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/network_api.g.dart', + swiftOut: 'ios/Runner/Core/Network.g.swift', + swiftOptions: SwiftOptions(includeErrorClass: false), + kotlinOut: + 'android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.core', includeErrorClass: true), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) +@HostApi() +abstract class NetworkApi { + @async + void addCertificate(ClientCertData clientData); + + @async + ClientCertData selectCertificate(ClientCertPrompt promptText); + + @async + void removeCertificate(); +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 05ecb020ba..077544b4f7 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -514,14 +514,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - file_picker: - dependency: "direct main" - description: - name: file_picker - sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 - url: "https://pub.dev" - source: hosted - version: "8.3.7" file_selector_linux: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 0b7e1a379e..3c388601ab 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -25,7 +25,6 @@ dependencies: dynamic_color: ^1.8.1 easy_localization: ^3.0.8 ffi: ^2.1.4 - file_picker: ^8.0.0+1 flutter: sdk: flutter flutter_cache_manager: ^3.4.1