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>
This commit is contained in:
Mert
2026-02-05 12:42:53 -05:00
committed by GitHub
parent 0a8a65a45e
commit 27a2808470
32 changed files with 1560 additions and 579 deletions
+1
View File
@@ -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
@@ -27,7 +27,8 @@
<application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true"
android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false">
android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false"
android:networkSecurityConfig="@xml/network_security_config">
<profileable android:shell="true" />
@@ -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<ArrayList<*>>()!!
val allowSelfSigned = args[0] as Boolean
val serverHost = args[1] as? String
val clientCertHash = (args[2] as? ByteArray)
var tm: Array<TrustManager>? = null
if (allowSelfSigned) {
tm = arrayOf(AllowSelfSignedTrustManager(serverHost))
}
var km: Array<KeyManager>? = 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<out X509Certificate>?, authType: String?) =
defaultTrustManager.checkClientTrusted(chain, authType)
override fun checkClientTrusted(
chain: Array<out X509Certificate>?, authType: String?, socket: Socket?
) = defaultTrustManager.checkClientTrusted(chain, authType, socket)
override fun checkClientTrusted(
chain: Array<out X509Certificate>?, authType: String?, engine: SSLEngine?
) = defaultTrustManager.checkClientTrusted(chain, authType, engine)
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
if (serverHost == null) return
defaultTrustManager.checkServerTrusted(chain, authType)
}
override fun checkServerTrusted(
chain: Array<out X509Certificate>?, 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<out X509Certificate>?, authType: String?, engine: SSLEngine?
) {
if (serverHost == null || engine?.peerHost == serverHost) return
defaultTrustManager.checkServerTrusted(chain, authType, engine)
}
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrustManager.acceptedIssuers
private fun getDefaultTrustManager(): X509ExtendedTrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return factory.trustManagers.filterIsInstance<X509ExtendedTrustManager>().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)
}
}
}
}
@@ -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)
}
@@ -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<X509TrustManager>().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<Principal>?): Array<String>? =
if (isMtls) arrayOf(CERT_ALIAS) else null
override fun chooseClientAlias(
keyTypes: Array<String>,
issuers: Array<Principal>?,
socket: Socket?
): String? =
if (isMtls) CERT_ALIAS else null
override fun getCertificateChain(alias: String): Array<X509Certificate>? =
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<Principal>?): Array<String>? =
null
override fun chooseServerAlias(
keyType: String,
issuers: Array<Principal>?,
socket: Socket?
): String? = null
}
}
@@ -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<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
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<Any?, Any?>).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<Any?>): ClientCertData {
val data = pigeonVar_list[0] as ByteArray
val password = pigeonVar_list[1] as String
return ClientCertData(data, password)
}
}
fun toList(): List<Any?> {
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<Any?>): 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<Any?> {
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<Any?>)?.let {
ClientCertData.fromList(it)
}
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.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>) -> Unit)
fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<ClientCertData>) -> Unit)
fun removeCertificate(callback: (Result<Unit>) -> Unit)
companion object {
/** The codec used by NetworkApi. */
val codec: MessageCodec<Any?> 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<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.addCertificate$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val clientDataArg = args[0] as ClientCertData
api.addCertificate(clientDataArg) { result: Result<Unit> ->
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<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val promptTextArg = args[0] as ClientCertPrompt
api.selectCertificate(promptTextArg) { result: Result<ClientCertData> ->
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<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.removeCertificate{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(NetworkPigeonUtils.wrapError(error))
} else {
reply.reply(NetworkPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -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<ClientCertData>) -> Unit)? = null
private var filePicker: ActivityResultLauncher<Array<String>>? = 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>) -> 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<ClientCertData>) -> 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>) -> 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()
}
}
@@ -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<KeyManager>?,
trustManagers: Array<TrustManager>?,
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<X509TrustManager>()?.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<X509TrustManager>().first()
}
}
@@ -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()
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config cleartextTrafficPermitted="true">
<base-config>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>