mirror of
https://github.com/immich-app/immich.git
synced 2026-02-28 09:38:43 +03:00
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:
@@ -782,6 +782,8 @@
|
|||||||
"client_cert_import": "Import",
|
"client_cert_import": "Import",
|
||||||
"client_cert_import_success_msg": "Client certificate is imported",
|
"client_cert_import_success_msg": "Client certificate is imported",
|
||||||
"client_cert_invalid_msg": "Invalid certificate file or wrong password",
|
"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_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_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate import/removal is available only before login",
|
||||||
"client_cert_title": "SSL client certificate [EXPERIMENTAL]",
|
"client_cert_title": "SSL client certificate [EXPERIMENTAL]",
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ dependencies {
|
|||||||
implementation "androidx.compose.ui:ui-tooling:$compose_version"
|
implementation "androidx.compose.ui:ui-tooling:$compose_version"
|
||||||
implementation "androidx.compose.material3:material3:1.2.1"
|
implementation "androidx.compose.material3:material3:1.2.1"
|
||||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2"
|
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
|
// This is uncommented in F-Droid build script
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
|
|
||||||
<application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true"
|
<application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true"
|
||||||
android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="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" />
|
<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.background.BackgroundWorkerLockApi
|
||||||
import app.alextran.immich.connectivity.ConnectivityApi
|
import app.alextran.immich.connectivity.ConnectivityApi
|
||||||
import app.alextran.immich.connectivity.ConnectivityApiImpl
|
import app.alextran.immich.connectivity.ConnectivityApiImpl
|
||||||
|
import app.alextran.immich.core.HttpClientManager
|
||||||
import app.alextran.immich.core.ImmichPlugin
|
import app.alextran.immich.core.ImmichPlugin
|
||||||
|
import app.alextran.immich.core.NetworkApiPlugin
|
||||||
import app.alextran.immich.images.LocalImageApi
|
import app.alextran.immich.images.LocalImageApi
|
||||||
import app.alextran.immich.images.LocalImagesImpl
|
import app.alextran.immich.images.LocalImagesImpl
|
||||||
import app.alextran.immich.images.RemoteImageApi
|
import app.alextran.immich.images.RemoteImageApi
|
||||||
@@ -28,6 +30,9 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
||||||
|
HttpClientManager.initialize(ctx)
|
||||||
|
flutterEngine.plugins.add(NetworkApiPlugin())
|
||||||
|
|
||||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||||
val backgroundEngineLockImpl = BackgroundEngineLock(ctx)
|
val backgroundEngineLockImpl = BackgroundEngineLock(ctx)
|
||||||
BackgroundWorkerLockApi.setUp(messenger, backgroundEngineLockImpl)
|
BackgroundWorkerLockApi.setUp(messenger, backgroundEngineLockImpl)
|
||||||
@@ -45,7 +50,6 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
||||||
|
|
||||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||||
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
|
||||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||||
flutterEngine.plugins.add(nativeSyncApiImpl)
|
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.content.Context
|
||||||
import android.os.CancellationSignal
|
import android.os.CancellationSignal
|
||||||
import android.os.OperationCanceledException
|
import android.os.OperationCanceledException
|
||||||
import app.alextran.immich.BuildConfig
|
|
||||||
import app.alextran.immich.INITIAL_BUFFER_SIZE
|
import app.alextran.immich.INITIAL_BUFFER_SIZE
|
||||||
import app.alextran.immich.NativeBuffer
|
import app.alextran.immich.NativeBuffer
|
||||||
import app.alextran.immich.NativeByteBuffer
|
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 kotlinx.coroutines.*
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
import okhttp3.Call
|
import okhttp3.Call
|
||||||
import okhttp3.Callback
|
import okhttp3.Callback
|
||||||
import okhttp3.ConnectionPool
|
|
||||||
import okhttp3.Dispatcher
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
@@ -32,15 +30,8 @@ import java.nio.file.SimpleFileVisitor
|
|||||||
import java.nio.file.attribute.BasicFileAttributes
|
import java.nio.file.attribute.BasicFileAttributes
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.Executors
|
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 const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
|
||||||
|
|
||||||
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
||||||
@@ -121,7 +112,7 @@ private object ImageFetcherManager {
|
|||||||
appContext = context.applicationContext
|
appContext = context.applicationContext
|
||||||
cacheDir = context.cacheDir
|
cacheDir = context.cacheDir
|
||||||
fetcher = build()
|
fetcher = build()
|
||||||
SSLConfig.addListener(::invalidate)
|
HttpClientManager.addClientChangedListener(::invalidate)
|
||||||
initialized = true
|
initialized = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,18 +134,14 @@ private object ImageFetcherManager {
|
|||||||
private fun invalidate() {
|
private fun invalidate() {
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
val oldFetcher = fetcher
|
val oldFetcher = fetcher
|
||||||
if (oldFetcher is OkHttpImageFetcher && SSLConfig.requiresCustomSSL) {
|
|
||||||
fetcher = oldFetcher.reconfigure(SSLConfig.sslSocketFactory, SSLConfig.trustManager)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fetcher = build()
|
fetcher = build()
|
||||||
oldFetcher.drain()
|
oldFetcher.drain()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun build(): ImageFetcher {
|
private fun build(): ImageFetcher {
|
||||||
return if (SSLConfig.requiresCustomSSL) {
|
return if (HttpClientManager.isMtls) {
|
||||||
OkHttpImageFetcher.create(cacheDir, SSLConfig.sslSocketFactory, SSLConfig.trustManager)
|
OkHttpImageFetcher.create(cacheDir)
|
||||||
} else {
|
} else {
|
||||||
CronetImageFetcher(appContext, cacheDir)
|
CronetImageFetcher(appContext, cacheDir)
|
||||||
}
|
}
|
||||||
@@ -380,51 +367,17 @@ private class OkHttpImageFetcher private constructor(
|
|||||||
private var draining = false
|
private var draining = false
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun create(
|
fun create(cacheDir: File): OkHttpImageFetcher {
|
||||||
cacheDir: File,
|
|
||||||
sslSocketFactory: SSLSocketFactory?,
|
|
||||||
trustManager: X509TrustManager?,
|
|
||||||
): OkHttpImageFetcher {
|
|
||||||
val dir = File(cacheDir, "okhttp")
|
val dir = File(cacheDir, "okhttp")
|
||||||
val connectionPool = ConnectionPool(
|
|
||||||
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
|
|
||||||
keepAliveDuration = KEEP_ALIVE_DURATION_MINUTES,
|
|
||||||
timeUnit = TimeUnit.MINUTES
|
|
||||||
)
|
|
||||||
|
|
||||||
val builder = OkHttpClient.Builder()
|
val client = HttpClientManager.getClient().newBuilder()
|
||||||
.addInterceptor { chain ->
|
|
||||||
chain.proceed(
|
|
||||||
chain.request().newBuilder()
|
|
||||||
.header("User-Agent", USER_AGENT)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
|
|
||||||
.connectionPool(connectionPool)
|
|
||||||
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
|
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
|
||||||
|
.build()
|
||||||
|
|
||||||
if (sslSocketFactory != null && trustManager != null) {
|
return OkHttpImageFetcher(client)
|
||||||
builder.sslSocketFactory(sslSocketFactory, trustManager)
|
|
||||||
}
|
|
||||||
|
|
||||||
return OkHttpImageFetcher(builder.build())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reconfigure(
|
|
||||||
sslSocketFactory: SSLSocketFactory?,
|
|
||||||
trustManager: X509TrustManager?,
|
|
||||||
): OkHttpImageFetcher {
|
|
||||||
val builder = client.newBuilder()
|
|
||||||
if (sslSocketFactory != null && trustManager != null) {
|
|
||||||
builder.sslSocketFactory(sslSocketFactory, trustManager)
|
|
||||||
}
|
|
||||||
// Evict idle connections using old SSL config
|
|
||||||
client.connectionPool.evictAll()
|
|
||||||
return OkHttpImageFetcher(builder.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onComplete() {
|
private fun onComplete() {
|
||||||
val shouldClose = synchronized(stateLock) {
|
val shouldClose = synchronized(stateLock) {
|
||||||
activeCount--
|
activeCount--
|
||||||
@@ -512,7 +465,6 @@ private class OkHttpImageFetcher private constructor(
|
|||||||
draining = true
|
draining = true
|
||||||
activeCount == 0
|
activeCount == 0
|
||||||
}
|
}
|
||||||
client.connectionPool.evictAll()
|
|
||||||
if (shouldClose) {
|
if (shouldClose) {
|
||||||
client.cache?.close()
|
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>
|
||||||
@@ -11,40 +11,6 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- device_info_plus (0.0.1):
|
- device_info_plus (0.0.1):
|
||||||
- Flutter
|
- 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 (1.0.0)
|
||||||
- flutter_local_notifications (0.0.1):
|
- flutter_local_notifications (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
@@ -93,9 +59,6 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- SAMKeychain (1.5.3)
|
- SAMKeychain (1.5.3)
|
||||||
- SDWebImage (5.21.0):
|
|
||||||
- SDWebImage/Core (= 5.21.0)
|
|
||||||
- SDWebImage/Core (5.21.0)
|
|
||||||
- share_handler_ios (0.0.14):
|
- share_handler_ios (0.0.14):
|
||||||
- Flutter
|
- Flutter
|
||||||
- share_handler_ios/share_handler_ios_models (= 0.0.14)
|
- share_handler_ios/share_handler_ios_models (= 0.0.14)
|
||||||
@@ -131,7 +94,6 @@ PODS:
|
|||||||
- sqlite3/fts5
|
- sqlite3/fts5
|
||||||
- sqlite3/perf-threadsafe
|
- sqlite3/perf-threadsafe
|
||||||
- sqlite3/rtree
|
- sqlite3/rtree
|
||||||
- SwiftyGif (5.4.5)
|
|
||||||
- url_launcher_ios (0.0.1):
|
- url_launcher_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- wakelock_plus (0.0.1):
|
- wakelock_plus (0.0.1):
|
||||||
@@ -143,7 +105,6 @@ DEPENDENCIES:
|
|||||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||||
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
|
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
|
||||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||||
@@ -176,13 +137,9 @@ DEPENDENCIES:
|
|||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
- DKImagePickerController
|
|
||||||
- DKPhotoGallery
|
|
||||||
- MapLibre
|
- MapLibre
|
||||||
- SAMKeychain
|
- SAMKeychain
|
||||||
- SDWebImage
|
|
||||||
- sqlite3
|
- sqlite3
|
||||||
- SwiftyGif
|
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
background_downloader:
|
background_downloader:
|
||||||
@@ -195,8 +152,6 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/cupertino_http/darwin"
|
:path: ".symlinks/plugins/cupertino_http/darwin"
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||||
file_picker:
|
|
||||||
:path: ".symlinks/plugins/file_picker/ios"
|
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_local_notifications:
|
flutter_local_notifications:
|
||||||
@@ -262,9 +217,6 @@ SPEC CHECKSUMS:
|
|||||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||||
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
||||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
|
||||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
|
||||||
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
|
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
|
||||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||||
@@ -288,7 +240,6 @@ SPEC CHECKSUMS:
|
|||||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||||
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
|
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
|
||||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||||
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
|
|
||||||
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
|
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
|
||||||
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
|
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
|
||||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||||
@@ -296,7 +247,6 @@ SPEC CHECKSUMS:
|
|||||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||||
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
|
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
|
||||||
sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241
|
sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241
|
||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
|
||||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F22F1197D8006016CB /* RemoteImages.g.swift */; };
|
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F22F1197D8006016CB /* RemoteImages.g.swift */; };
|
||||||
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F52F11980E006016CB /* LocalImagesImpl.swift */; };
|
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F52F11980E006016CB /* LocalImagesImpl.swift */; };
|
||||||
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE5499F72F1198DE006016CB /* RemoteImagesImpl.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 */; };
|
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
|
||||||
FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
|
FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
|
||||||
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; };
|
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 = "<group>"; };
|
FE5499F22F1197D8006016CB /* RemoteImages.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImages.g.swift; sourceTree = "<group>"; };
|
||||||
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalImagesImpl.swift; sourceTree = "<group>"; };
|
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalImagesImpl.swift; sourceTree = "<group>"; };
|
||||||
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImagesImpl.swift; sourceTree = "<group>"; };
|
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImagesImpl.swift; sourceTree = "<group>"; };
|
||||||
|
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = "<group>"; };
|
||||||
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
|
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
@@ -325,6 +327,7 @@
|
|||||||
FED3B1952E253E9B0030FD97 /* Images */ = {
|
FED3B1952E253E9B0030FD97 /* Images */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */,
|
||||||
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */,
|
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */,
|
||||||
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */,
|
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */,
|
||||||
FE5499F12F1197D8006016CB /* LocalImages.g.swift */,
|
FE5499F12F1197D8006016CB /* LocalImages.g.swift */,
|
||||||
@@ -609,6 +612,7 @@
|
|||||||
FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */,
|
FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */,
|
||||||
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */,
|
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */,
|
||||||
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
|
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
|
||||||
|
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */,
|
||||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
|
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
|
||||||
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
|
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
|
||||||
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
|
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ import UIKit
|
|||||||
) -> Bool {
|
) -> Bool {
|
||||||
// Required for flutter_local_notification
|
// Required for flutter_local_notification
|
||||||
if #available(iOS 10.0, *) {
|
if #available(iOS 10.0, *) {
|
||||||
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||||
}
|
}
|
||||||
|
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
|
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.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
|
||||||
|
|
||||||
BackgroundServicePlugin.registerBackgroundProcessing()
|
BackgroundServicePlugin.registerBackgroundProcessing()
|
||||||
@@ -51,12 +51,13 @@ import UIKit
|
|||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
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)!)
|
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
||||||
LocalImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: LocalImageApiImpl())
|
LocalImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: LocalImageApiImpl())
|
||||||
RemoteImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: RemoteImageApiImpl())
|
RemoteImageApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: RemoteImageApiImpl())
|
||||||
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
|
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
|
||||||
ConnectivityApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ConnectivityApiImpl())
|
ConnectivityApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ConnectivityApiImpl())
|
||||||
|
NetworkApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: NetworkApiImpl(viewController: controller))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func cancelPlugins(with engine: FlutterEngine) {
|
public static func cancelPlugins(with engine: FlutterEngine) {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
|||||||
// Register plugins in the new engine
|
// Register plugins in the new engine
|
||||||
GeneratedPluginRegistrant.register(with: engine)
|
GeneratedPluginRegistrant.register(with: engine)
|
||||||
// Register custom plugins
|
// Register custom plugins
|
||||||
AppDelegate.registerPlugins(with: engine)
|
AppDelegate.registerPlugins(with: engine, controller: nil)
|
||||||
flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger)
|
flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger)
|
||||||
BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self)
|
BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self)
|
||||||
|
|
||||||
|
|||||||
284
mobile/ios/Runner/Core/Network.g.swift
Normal file
284
mobile/ios/Runner/Core/Network.g.swift
Normal file
@@ -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<T>(_ 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, Error>) -> Void)
|
||||||
|
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<ClientCertData, Error>) -> Void)
|
||||||
|
func removeCertificate(completion: @escaping (Result<Void, Error>) -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
157
mobile/ios/Runner/Core/NetworkApiImpl.swift
Normal file
157
mobile/ios/Runner/Core/NetworkApiImpl.swift
Normal file
@@ -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<ClientCertData, any Error>) -> 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, any Error>) -> 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, any Error>) -> 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)
|
||||||
|
}
|
||||||
87
mobile/ios/Runner/Core/URLSessionManager.swift
Normal file
87
mobile/ios/Runner/Core/URLSessionManager.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
mobile/ios/Runner/Images/ImageProcessing.swift
Normal file
7
mobile/ios/Runner/Images/ImageProcessing.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -34,7 +34,6 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
|
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
|
||||||
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", 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 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(
|
private static var rgbaFormat = vImage_CGImageFormat(
|
||||||
bitsPerComponent: 8,
|
bitsPerComponent: 8,
|
||||||
@@ -44,8 +43,6 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
renderingIntent: .defaultIntent
|
renderingIntent: .defaultIntent
|
||||||
)!
|
)!
|
||||||
private static var requests = [Int64: LocalImageRequest]()
|
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 = {
|
private static let assetCache = {
|
||||||
let assetCache = NSCache<NSString, PHAsset>()
|
let assetCache = NSCache<NSString, PHAsset>()
|
||||||
assetCache.countLimit = 10000
|
assetCache.countLimit = 10000
|
||||||
@@ -53,7 +50,7 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
|
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
|
||||||
Self.processingQueue.async {
|
ImageProcessing.queue.async {
|
||||||
guard let data = Data(base64Encoded: thumbhash)
|
guard let data = Data(base64Encoded: thumbhash)
|
||||||
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
|
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 request = LocalImageRequest(callback: completion)
|
||||||
let item = DispatchWorkItem {
|
let item = DispatchWorkItem {
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
return completion(Self.cancelledResult)
|
return completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
Self.concurrencySemaphore.wait()
|
ImageProcessing.semaphore.wait()
|
||||||
defer {
|
defer {
|
||||||
Self.concurrencySemaphore.signal()
|
ImageProcessing.semaphore.signal()
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
return completion(Self.cancelledResult)
|
return completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let asset = Self.requestAsset(assetId: assetId)
|
guard let asset = Self.requestAsset(assetId: assetId)
|
||||||
@@ -91,7 +88,7 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
return completion(Self.cancelledResult)
|
return completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
var image: UIImage?
|
var image: UIImage?
|
||||||
@@ -106,7 +103,7 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
return completion(Self.cancelledResult)
|
return completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let image = image,
|
guard let image = image,
|
||||||
@@ -116,7 +113,7 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
return completion(Self.cancelledResult)
|
return completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -124,7 +121,7 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
|
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
buffer.free()
|
buffer.free()
|
||||||
return completion(Self.cancelledResult)
|
return completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
request.callback(.success([
|
request.callback(.success([
|
||||||
@@ -142,7 +139,7 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
|
|
||||||
request.workItem = item
|
request.workItem = item
|
||||||
Self.add(requestId: requestId, request: request)
|
Self.add(requestId: requestId, request: request)
|
||||||
Self.processingQueue.async(execute: item)
|
ImageProcessing.queue.async(execute: item)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelRequest(requestId: Int64) {
|
func cancelRequest(requestId: Int64) {
|
||||||
@@ -163,7 +160,7 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
request.isCancelled = true
|
request.isCancelled = true
|
||||||
guard let item = request.workItem else { return }
|
guard let item = request.workItem else { return }
|
||||||
if item.isCancelled {
|
if item.isCancelled {
|
||||||
cancelQueue.async { request.callback(Self.cancelledResult) }
|
cancelQueue.async { request.callback(ImageProcessing.cancelledResult) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,64 +7,18 @@ class RemoteImageRequest {
|
|||||||
weak var task: URLSessionDataTask?
|
weak var task: URLSessionDataTask?
|
||||||
let id: Int64
|
let id: Int64
|
||||||
var isCancelled = false
|
var isCancelled = false
|
||||||
var data: CFMutableData?
|
|
||||||
let completion: (Result<[String: Int64]?, any Error>) -> Void
|
let completion: (Result<[String: Int64]?, any Error>) -> Void
|
||||||
|
|
||||||
init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.task = task
|
self.task = task
|
||||||
self.data = nil
|
|
||||||
self.completion = completion
|
self.completion = completion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
||||||
private static let delegate = RemoteImageApiDelegate()
|
private static var lock = os_unfair_lock()
|
||||||
static let session = {
|
private static var requests = [Int64: RemoteImageRequest]()
|
||||||
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<Int64, any Error>) -> Void) {
|
|
||||||
Task {
|
|
||||||
let cache = Self.session.configuration.urlCache!
|
|
||||||
let cacheSize = Int64(cache.currentDiskUsage)
|
|
||||||
cache.removeAllCachedResponses()
|
|
||||||
completion(.success(cacheSize))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate {
|
|
||||||
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated, attributes: .concurrent)
|
|
||||||
private static var rgbaFormat = vImage_CGImageFormat(
|
private static var rgbaFormat = vImage_CGImageFormat(
|
||||||
bitsPerComponent: 8,
|
bitsPerComponent: 8,
|
||||||
bitsPerPixel: 32,
|
bitsPerPixel: 32,
|
||||||
@@ -72,9 +26,6 @@ class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate {
|
|||||||
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
|
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
|
||||||
renderingIntent: .perceptual
|
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 = [
|
private static let decodeOptions = [
|
||||||
kCGImageSourceShouldCache: false,
|
kCGImageSourceShouldCache: false,
|
||||||
kCGImageSourceShouldCacheImmediately: true,
|
kCGImageSourceShouldCacheImmediately: true,
|
||||||
@@ -82,105 +33,103 @@ class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate {
|
|||||||
kCGImageSourceCreateThumbnailFromImageAlways: true
|
kCGImageSourceCreateThumbnailFromImageAlways: true
|
||||||
] as CFDictionary
|
] as CFDictionary
|
||||||
|
|
||||||
func urlSession(
|
func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
|
||||||
_ session: URLSession, dataTask: URLSessionDataTask,
|
var urlRequest = URLRequest(url: URL(string: url)!)
|
||||||
didReceive response: URLResponse,
|
urlRequest.cachePolicy = .returnCacheDataElseLoad
|
||||||
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
|
for (key, value) in headers {
|
||||||
) {
|
urlRequest.setValue(value, forHTTPHeaderField: key)
|
||||||
guard let request = get(taskId: dataTask.taskIdentifier)
|
|
||||||
else {
|
|
||||||
return completionHandler(.cancel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let capacity = max(Int(response.expectedContentLength), 0)
|
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
|
||||||
request.data = CFDataCreateMutable(nil, capacity)
|
Self.handleCompletion(requestId: requestId, data: data, response: response, error: error)
|
||||||
|
|
||||||
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 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,
|
private static func handleCompletion(requestId: Int64, data: Data?, response: URLResponse?, error: Error?) {
|
||||||
didCompleteWithError error: Error?) {
|
os_unfair_lock_lock(&Self.lock)
|
||||||
guard let request = get(taskId: task.taskIdentifier) else { return }
|
guard let request = requests[requestId] else {
|
||||||
|
return os_unfair_lock_unlock(&Self.lock)
|
||||||
defer { remove(taskId: task.taskIdentifier, requestId: request.id) }
|
}
|
||||||
|
requests[requestId] = nil
|
||||||
|
os_unfair_lock_unlock(&Self.lock)
|
||||||
|
|
||||||
if let error = error {
|
if let error = error {
|
||||||
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
|
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
|
||||||
return request.completion(Self.cancelledResult)
|
return request.completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
return request.completion(.failure(error))
|
return request.completion(.failure(error))
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.isCancelled {
|
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)))
|
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let imageSource = CGImageSourceCreateWithData(data, nil),
|
ImageProcessing.queue.async {
|
||||||
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, Self.decodeOptions) else {
|
ImageProcessing.semaphore.wait()
|
||||||
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
|
defer { ImageProcessing.semaphore.signal() }
|
||||||
}
|
|
||||||
|
|
||||||
if request.isCancelled {
|
|
||||||
return request.completion(Self.cancelledResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat)
|
|
||||||
|
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
buffer.free()
|
return request.completion(ImageProcessing.cancelledResult)
|
||||||
return request.completion(Self.cancelledResult)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
request.completion(
|
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
|
||||||
.success([
|
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else {
|
||||||
"pointer": Int64(Int(bitPattern: buffer.data)),
|
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
|
||||||
"width": Int64(buffer.width),
|
}
|
||||||
"height": Int64(buffer.height),
|
|
||||||
"rowBytes": Int64(buffer.rowBytes),
|
if request.isCancelled {
|
||||||
]))
|
return request.completion(ImageProcessing.cancelledResult)
|
||||||
} catch {
|
}
|
||||||
return request.completion(.failure(PigeonError(code: "", message: "Failed to convert image for request: \(error)", details: nil)))
|
|
||||||
|
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? {
|
func cancelRequest(requestId: Int64) {
|
||||||
Self.requestQueue.sync { Self.requestByTaskId[taskId] }
|
os_unfair_lock_lock(&Self.lock)
|
||||||
}
|
let request = Self.requests[requestId]
|
||||||
|
os_unfair_lock_unlock(&Self.lock)
|
||||||
|
|
||||||
@inline(__always) func add(taskId: Int, request: RemoteImageRequest) -> Void {
|
guard let request = request else { return }
|
||||||
Self.requestQueue.async(flags: .barrier) {
|
|
||||||
Self.requestByTaskId[taskId] = request
|
|
||||||
Self.taskIdByRequestId[request.id] = taskId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@inline(__always) func remove(taskId: Int, requestId: Int64) -> Void {
|
|
||||||
Self.requestQueue.async(flags: .barrier) {
|
|
||||||
Self.taskIdByRequestId[requestId] = nil
|
|
||||||
Self.requestByTaskId[taskId] = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@inline(__always) func cancel(requestId: Int64) -> Void {
|
|
||||||
guard let request: RemoteImageRequest = (Self.requestQueue.sync {
|
|
||||||
guard let taskId = Self.taskIdByRequestId[requestId] else { return nil }
|
|
||||||
return Self.requestByTaskId[taskId]
|
|
||||||
}) else { return }
|
|
||||||
request.isCancelled = true
|
request.isCancelled = true
|
||||||
request.task?.cancel()
|
request.task?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {
|
||||||
|
Task {
|
||||||
|
let cache = URLSessionManager.shared.session.configuration.urlCache!
|
||||||
|
let cacheSize = Int64(cache.currentDiskUsage)
|
||||||
|
cache.removeAllCachedResponses()
|
||||||
|
completion(.success(cacheSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
try {
|
try {
|
||||||
HttpSSLOptions.apply(applyNative: false);
|
HttpSSLOptions.apply();
|
||||||
|
|
||||||
await Future.wait(
|
await Future.wait(
|
||||||
[
|
[
|
||||||
|
|||||||
232
mobile/lib/platform/network_api.g.dart
generated
Normal file
232
mobile/lib/platform/network_api.g.dart
generated
Normal file
@@ -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<Object?, Object?> entry) =>
|
||||||
|
(b as Map<Object?, Object?>).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<Object?> _toList() {
|
||||||
|
return <Object?>[data, password];
|
||||||
|
}
|
||||||
|
|
||||||
|
Object encode() {
|
||||||
|
return _toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static ClientCertData decode(Object result) {
|
||||||
|
result as List<Object?>;
|
||||||
|
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<Object?> _toList() {
|
||||||
|
return <Object?>[title, message, cancel, confirm];
|
||||||
|
}
|
||||||
|
|
||||||
|
Object encode() {
|
||||||
|
return _toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static ClientCertPrompt decode(Object result) {
|
||||||
|
result as List<Object?>;
|
||||||
|
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<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
|
|
||||||
|
final String pigeonVar_messageChannelSuffix;
|
||||||
|
|
||||||
|
Future<void> addCertificate(ClientCertData clientData) async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.NetworkApi.addCertificate$pigeonVar_messageChannelSuffix';
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[clientData]);
|
||||||
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
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<ClientCertData> selectCertificate(ClientCertPrompt promptText) async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix';
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[promptText]);
|
||||||
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
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<void> removeCertificate() async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate$pigeonVar_messageChannelSuffix';
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/connectivity_api.g.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_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/local_image_api.g.dart';
|
||||||
|
import 'package:immich_mobile/platform/network_api.g.dart';
|
||||||
import 'package:immich_mobile/platform/remote_image_api.g.dart';
|
import 'package:immich_mobile/platform/remote_image_api.g.dart';
|
||||||
|
|
||||||
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
|
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
|
||||||
@@ -20,3 +21,5 @@ final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi
|
|||||||
final localImageApi = LocalImageApi();
|
final localImageApi = LocalImageApi();
|
||||||
|
|
||||||
final remoteImageApi = RemoteImageApi();
|
final remoteImageApi = RemoteImageApi();
|
||||||
|
|
||||||
|
final networkApi = NetworkApi();
|
||||||
|
|||||||
@@ -1,26 +1,20 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
|
|
||||||
class HttpSSLOptions {
|
class HttpSSLOptions {
|
||||||
static const MethodChannel _channel = MethodChannel('immich/httpSSLOptions');
|
static void apply() {
|
||||||
|
|
||||||
static void apply({bool applyNative = true}) {
|
|
||||||
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
|
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
|
||||||
bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue);
|
bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue);
|
||||||
_apply(allowSelfSignedSSLCert, applyNative: applyNative);
|
return _apply(allowSelfSignedSSLCert);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void applyFromSettings(bool newValue) {
|
static void applyFromSettings(bool newValue) => _apply(newValue);
|
||||||
_apply(newValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void _apply(bool allowSelfSignedSSLCert, {bool applyNative = true}) {
|
static void _apply(bool allowSelfSignedSSLCert) {
|
||||||
String? serverHost;
|
String? serverHost;
|
||||||
if (allowSelfSignedSSLCert && Store.tryGet(StoreKey.currentUser) != null) {
|
if (allowSelfSignedSSLCert && Store.tryGet(StoreKey.currentUser) != null) {
|
||||||
serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
|
serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
|
||||||
@@ -29,14 +23,5 @@ class HttpSSLOptions {
|
|||||||
SSLClientCertStoreVal? clientCert = SSLClientCertStoreVal.load();
|
SSLClientCertStoreVal? clientCert = SSLClientCertStoreVal.load();
|
||||||
|
|
||||||
HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert);
|
HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert);
|
||||||
|
|
||||||
if (applyNative && Platform.isAndroid) {
|
|
||||||
_channel
|
|
||||||
.invokeMethod("apply", [allowSelfSignedSSLCert, serverHost, clientCert?.data, clientCert?.password])
|
|
||||||
.onError<PlatformException>((e, _) {
|
|
||||||
final log = Logger("HttpSSLOptions");
|
|
||||||
log.severe('Failed to set SSL options', e.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ Cancelable<T?> runInIsolateGentle<T>({
|
|||||||
Logger log = Logger("IsolateLogger");
|
Logger log = Logger("IsolateLogger");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
HttpSSLOptions.apply(applyNative: false);
|
HttpSSLOptions.apply();
|
||||||
result = await computation(ref);
|
result = await computation(ref);
|
||||||
} on CanceledError {
|
} on CanceledError {
|
||||||
log.warning("Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}");
|
log.warning("Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}");
|
||||||
|
|||||||
@@ -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/db.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.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/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/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
@@ -32,7 +34,7 @@ import 'package:isar/isar.dart';
|
|||||||
// ignore: import_rule_photo_manager
|
// ignore: import_rule_photo_manager
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
const int targetVersion = 20;
|
const int targetVersion = 21;
|
||||||
|
|
||||||
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||||
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
||||||
@@ -91,6 +93,13 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
|||||||
await _syncLocalAlbumIsIosSharedAlbum(drift);
|
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) {
|
if (targetVersion >= 12) {
|
||||||
await Store.put(StoreKey.version, targetVersion);
|
await Store.put(StoreKey.version, targetVersion);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_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:immich_mobile/utils/http_ssl_options.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
class SslClientCertSettings extends StatefulWidget {
|
class SslClientCertSettings extends StatefulWidget {
|
||||||
const SslClientCertSettings({super.key, required this.isLoggedIn});
|
const SslClientCertSettings({super.key, required this.isLoggedIn});
|
||||||
@@ -20,10 +19,12 @@ class SslClientCertSettings extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SslClientCertSettingsState extends State<SslClientCertSettings> {
|
class _SslClientCertSettingsState extends State<SslClientCertSettings> {
|
||||||
_SslClientCertSettingsState() : isCertExist = SSLClientCertStoreVal.load() != null;
|
final _log = Logger("SslClientCertSettings");
|
||||||
|
|
||||||
bool isCertExist;
|
bool isCertExist;
|
||||||
|
|
||||||
|
_SslClientCertSettingsState() : isCertExist = SSLClientCertStoreVal.load() != null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
@@ -41,16 +42,12 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
|
|||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.max,
|
mainAxisSize: MainAxisSize.max,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
ElevatedButton(onPressed: widget.isLoggedIn ? null : importCert, child: Text("client_cert_import".tr())),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: widget.isLoggedIn ? null : () => importCert(context),
|
onPressed: widget.isLoggedIn || !isCertExist ? null : removeCert,
|
||||||
child: Text("client_cert_import".tr()),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 15),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: widget.isLoggedIn || !isCertExist ? null : () async => await removeCert(context),
|
|
||||||
child: Text("remove".tr()),
|
child: Text("remove".tr()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -60,71 +57,52 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void showMessage(BuildContext context, String message) {
|
void showMessage(String message) {
|
||||||
showDialog(
|
context.showSnackBar(
|
||||||
context: context,
|
SnackBar(
|
||||||
builder: (ctx) => AlertDialog(
|
duration: const Duration(seconds: 3),
|
||||||
content: Text(message),
|
content: Text(message, style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor)),
|
||||||
actions: [TextButton(onPressed: () => ctx.pop(), child: Text("client_cert_dialog_msg_confirm".tr()))],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> storeCert(BuildContext context, Uint8List data, String? password) async {
|
Future<void> importCert() async {
|
||||||
if (password != null && password.isEmpty) {
|
try {
|
||||||
password = null;
|
final styling = ClientCertPrompt(
|
||||||
}
|
title: "client_cert_password_title".tr(),
|
||||||
final cert = SSLClientCertStoreVal(data, password);
|
message: "client_cert_password_message".tr(),
|
||||||
// Test whether the certificate is valid
|
cancel: "cancel".tr(),
|
||||||
final isCertValid = HttpSSLCertOverride.setClientCert(SecurityContext(withTrustedRoots: true), cert);
|
confirm: "confirm".tr(),
|
||||||
if (!isCertValid) {
|
);
|
||||||
showMessage(context, "client_cert_invalid_msg".tr());
|
final cert = await networkApi.selectCertificate(styling);
|
||||||
return;
|
await SSLClientCertStoreVal(cert.data, cert.password).save();
|
||||||
}
|
HttpSSLOptions.apply();
|
||||||
await cert.save();
|
setState(() => isCertExist = true);
|
||||||
HttpSSLOptions.apply();
|
showMessage("client_cert_import_success_msg".tr());
|
||||||
setState(() => isCertExist = true);
|
} catch (e) {
|
||||||
showMessage(context, "client_cert_import_success_msg".tr());
|
if (_isCancellation(e)) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
void setPassword(BuildContext context, Uint8List data) {
|
_log.severe("Error importing client cert", e);
|
||||||
final password = TextEditingController();
|
showMessage("client_cert_invalid_msg".tr());
|
||||||
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<void> 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<void> removeCert(BuildContext context) async {
|
Future<void> removeCert() async {
|
||||||
await SSLClientCertStoreVal.delete();
|
try {
|
||||||
HttpSSLOptions.apply();
|
await networkApi.removeCertificate();
|
||||||
setState(() => isCertExist = false);
|
await SSLClientCertStoreVal.delete();
|
||||||
showMessage(context, "client_cert_remove_msg".tr());
|
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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ pigeon:
|
|||||||
dart run pigeon --input pigeon/background_worker_api.dart
|
dart run pigeon --input pigeon/background_worker_api.dart
|
||||||
dart run pigeon --input pigeon/background_worker_lock_api.dart
|
dart run pigeon --input pigeon/background_worker_lock_api.dart
|
||||||
dart run pigeon --input pigeon/connectivity_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/native_sync_api.g.dart
|
||||||
dart format lib/platform/local_image_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/remote_image_api.g.dart
|
||||||
dart format lib/platform/background_worker_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/background_worker_lock_api.g.dart
|
||||||
dart format lib/platform/connectivity_api.g.dart
|
dart format lib/platform/connectivity_api.g.dart
|
||||||
|
dart format lib/platform/network_api.g.dart
|
||||||
|
|
||||||
watch:
|
watch:
|
||||||
dart run build_runner watch --delete-conflicting-outputs
|
dart run build_runner watch --delete-conflicting-outputs
|
||||||
|
|||||||
41
mobile/pigeon/network_api.dart
Normal file
41
mobile/pigeon/network_api.dart
Normal file
@@ -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();
|
||||||
|
}
|
||||||
@@ -514,14 +514,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
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:
|
file_selector_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ dependencies:
|
|||||||
dynamic_color: ^1.8.1
|
dynamic_color: ^1.8.1
|
||||||
easy_localization: ^3.0.8
|
easy_localization: ^3.0.8
|
||||||
ffi: ^2.1.4
|
ffi: ^2.1.4
|
||||||
file_picker: ^8.0.0+1
|
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_cache_manager: ^3.4.1
|
flutter_cache_manager: ^3.4.1
|
||||||
|
|||||||
Reference in New Issue
Block a user