diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 4999f9a7f94ae..0839000dd054f 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -81,6 +81,7 @@ android { release { signingConfig signingConfigs.release + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } namespace 'app.alextran.immich' diff --git a/mobile/android/app/proguard-rules.pro b/mobile/android/app/proguard-rules.pro index 898caee06c78c..af43ae23c25fb 100644 --- a/mobile/android/app/proguard-rules.pro +++ b/mobile/android/app/proguard-rules.pro @@ -36,4 +36,12 @@ ##---------------End: proguard configuration for Gson ---------- # Keep all widget model classes and their fields for Gson --keep class app.alextran.immich.widget.model.** { *; } \ No newline at end of file +-keep class app.alextran.immich.widget.model.** { *; } + +##---------------Begin: proguard configuration for ok_http JNI ---------- +# The ok_http Dart plugin accesses OkHttp and Okio classes via JNI +# string-based reflection (JClass.forName), which R8 cannot trace. +-keep class okhttp3.** { *; } +-keep class okio.** { *; } +-keep class com.example.ok_http.** { *; } +##---------------End: proguard configuration for ok_http JNI ---------- diff --git a/mobile/android/app/src/main/cpp/native_buffer.c b/mobile/android/app/src/main/cpp/native_buffer.c index bcc9d5c7c8f03..bed10453823f0 100644 --- a/mobile/android/app/src/main/cpp/native_buffer.c +++ b/mobile/android/app/src/main/cpp/native_buffer.c @@ -36,3 +36,17 @@ Java_app_alextran_immich_NativeBuffer_copy( memcpy((void *) destAddress, (char *) src + offset, length); } } + +/** + * Creates a JNI global reference to the given object and returns its address. + * The caller is responsible for deleting the global reference when it's no longer needed. + */ +JNIEXPORT jlong JNICALL +Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) { + if (obj == NULL) { + return 0; + } + + jobject globalRef = (*env)->NewGlobalRef(env, obj); + return (jlong) globalRef; +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt index a9011f30473f9..74f02418508e4 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt @@ -23,6 +23,9 @@ object NativeBuffer { @JvmStatic external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int) + + @JvmStatic + external fun createGlobalRef(obj: Any): Long } class NativeByteBuffer(initialCapacity: Int) { diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt index ee92c2120ed55..37435a9f02711 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt @@ -1,11 +1,18 @@ package app.alextran.immich.core import android.content.Context +import android.content.SharedPreferences +import android.security.KeyChain +import androidx.core.content.edit import app.alextran.immich.BuildConfig +import app.alextran.immich.NativeBuffer import okhttp3.Cache import okhttp3.ConnectionPool import okhttp3.Dispatcher +import okhttp3.Headers +import okhttp3.Credentials import okhttp3.OkHttpClient +import org.json.JSONObject import java.io.ByteArrayInputStream import java.io.File import java.net.Socket @@ -20,8 +27,12 @@ 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}" +private const val CERT_ALIAS = "client_cert" +private const val PREFS_NAME = "immich.ssl" +private const val PREFS_CERT_ALIAS = "immich.client_cert" +private const val PREFS_HEADERS = "immich.request_headers" +private const val PREFS_SERVER_URL = "immich.server_url" /** * Manages a shared OkHttpClient with SSL configuration support. @@ -36,22 +47,56 @@ object HttpClientManager { private val clientChangedListeners = mutableListOf<() -> Unit>() private lateinit var client: OkHttpClient + private lateinit var appContext: Context + private lateinit var prefs: SharedPreferences private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - val isMtls: Boolean get() = keyStore.containsAlias(CERT_ALIAS) + var keyChainAlias: String? = null + private set + + var headers: Headers = Headers.headersOf() + private set + + val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS) fun initialize(context: Context) { if (initialized) return synchronized(this) { if (initialized) return + appContext = context.applicationContext + prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null) + + val savedHeaders = prefs.getString(PREFS_HEADERS, null) + if (savedHeaders != null) { + val json = JSONObject(savedHeaders) + val builder = Headers.Builder() + for (key in json.keys()) { + builder.add(key, json.getString(key)) + } + headers = builder.build() + } + val cacheDir = File(File(context.cacheDir, "okhttp"), "api") client = build(cacheDir) initialized = true } } + fun setKeyChainAlias(alias: String) { + synchronized(this) { + val wasMtls = isMtls + keyChainAlias = alias + prefs.edit { putString(PREFS_CERT_ALIAS, alias) } + + if (wasMtls != isMtls) { + clientChangedListeners.forEach { it() } + } + } + } + fun setKeyEntry(clientData: ByteArray, password: CharArray) { synchronized(this) { val wasMtls = isMtls @@ -63,7 +108,7 @@ object HttpClientManager { val key = tmpKeyStore.getKey(tmpAlias, password) val chain = tmpKeyStore.getCertificateChain(tmpAlias) - if (wasMtls) { + if (keyStore.containsAlias(CERT_ALIAS)) { keyStore.deleteEntry(CERT_ALIAS) } keyStore.setKeyEntry(CERT_ALIAS, key, null, chain) @@ -75,24 +120,58 @@ object HttpClientManager { fun deleteKeyEntry() { synchronized(this) { - if (!isMtls) { - return + val wasMtls = isMtls + + if (keyChainAlias != null) { + keyChainAlias = null + prefs.edit { remove(PREFS_CERT_ALIAS) } } keyStore.deleteEntry(CERT_ALIAS) - clientChangedListeners.forEach { it() } + + if (wasMtls) { + clientChangedListeners.forEach { it() } + } } } + private var clientGlobalRef: Long = 0L + @JvmStatic fun getClient(): OkHttpClient { return client } + fun getClientPointer(): Long { + if (clientGlobalRef == 0L) { + clientGlobalRef = NativeBuffer.createGlobalRef(client) + } + return clientGlobalRef + } + fun addClientChangedListener(listener: () -> Unit) { synchronized(this) { clientChangedListeners.add(listener) } } + fun setRequestHeaders(headerMap: Map, serverUrls: List) { + synchronized(this) { + val builder = Headers.Builder() + headerMap.forEach { (key, value) -> builder[key] = value } + val newHeaders = builder.build() + val headersChanged = headers != newHeaders + val newUrl = serverUrls.firstOrNull() + val urlChanged = newUrl != prefs.getString(PREFS_SERVER_URL, null) + if (!headersChanged && !urlChanged) return + headers = newHeaders + prefs.edit { + if (headersChanged) putString(PREFS_HEADERS, JSONObject(headerMap).toString()) + if (urlChanged) { + if (newUrl != null) putString(PREFS_SERVER_URL, newUrl) else remove(PREFS_SERVER_URL) + } + } + } + } + private fun build(cacheDir: File): OkHttpClient { val connectionPool = ConnectionPool( maxIdleConnections = KEEP_ALIVE_CONNECTIONS, @@ -109,8 +188,16 @@ object HttpClientManager { HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) return OkHttpClient.Builder() - .addInterceptor { chain -> - chain.proceed(chain.request().newBuilder().header("User-Agent", USER_AGENT).build()) + .addInterceptor { + val request = it.request() + val builder = request.newBuilder() + builder.header("User-Agent", USER_AGENT) + headers.forEach { (key, value) -> builder.header(key, value) } + val url = request.url + if (url.username.isNotEmpty()) { + builder.header("Authorization", Credentials.basic(url.username, url.password)) + } + it.proceed(builder.build()) } .connectionPool(connectionPool) .dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST }) @@ -119,23 +206,39 @@ object HttpClientManager { .build() } - // Reads from the key store rather than taking a snapshot at initialization time + /** + * Resolves client certificates dynamically at TLS handshake time. + * Checks the system KeyChain alias first, then falls back to the app's private KeyStore. + */ private class DynamicKeyManager : X509KeyManager { - override fun getClientAliases(keyType: String, issuers: Array?): Array? = - if (isMtls) arrayOf(CERT_ALIAS) else null + override fun getClientAliases(keyType: String, issuers: Array?): Array? { + val alias = chooseClientAlias(arrayOf(keyType), issuers, null) ?: return null + return arrayOf(alias) + } override fun chooseClientAlias( keyTypes: Array, issuers: Array?, socket: Socket? - ): String? = - if (isMtls) CERT_ALIAS else null + ): String? { + keyChainAlias?.let { return it } + if (keyStore.containsAlias(CERT_ALIAS)) return CERT_ALIAS + return null + } - override fun getCertificateChain(alias: String): Array? = - keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray() + override fun getCertificateChain(alias: String): Array? { + if (alias == keyChainAlias) { + return KeyChain.getCertificateChain(appContext, alias) + } + return keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray() + } - override fun getPrivateKey(alias: String): PrivateKey? = - keyStore.getKey(alias, null) as? PrivateKey + override fun getPrivateKey(alias: String): PrivateKey? { + if (alias == keyChainAlias) { + return KeyChain.getPrivateKey(appContext, alias) + } + return keyStore.getKey(alias, null) as? PrivateKey + } override fun getServerAliases(keyType: String, issuers: Array?): Array? = null diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt index 1e7156a147fe6..5e48d7fef537b 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt @@ -180,8 +180,11 @@ private open class NetworkPigeonCodec : StandardMessageCodec() { /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface NetworkApi { fun addCertificate(clientData: ClientCertData, callback: (Result) -> Unit) - fun selectCertificate(promptText: ClientCertPrompt, callback: (Result) -> Unit) + fun selectCertificate(promptText: ClientCertPrompt, callback: (Result) -> Unit) fun removeCertificate(callback: (Result) -> Unit) + fun hasCertificate(): Boolean + fun getClientPointer(): Long + fun setRequestHeaders(headers: Map, serverUrls: List) companion object { /** The codec used by NetworkApi. */ @@ -217,13 +220,12 @@ interface NetworkApi { channel.setMessageHandler { message, reply -> val args = message as List val promptTextArg = args[0] as ClientCertPrompt - api.selectCertificate(promptTextArg) { result: Result -> + api.selectCertificate(promptTextArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(NetworkPigeonUtils.wrapError(error)) } else { - val data = result.getOrNull() - reply.reply(NetworkPigeonUtils.wrapResult(data)) + reply.reply(NetworkPigeonUtils.wrapResult(null)) } } } @@ -248,6 +250,55 @@ interface NetworkApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.hasCertificate()) + } catch (exception: Throwable) { + NetworkPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getClientPointer()) + } catch (exception: Throwable) { + NetworkPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val headersArg = args[0] as Map + val serverUrlsArg = args[1] as List + val wrapped: List = try { + api.setRequestHeaders(headersArg, serverUrlsArg) + listOf(null) + } catch (exception: Throwable) { + NetworkPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt index 4f25896b2ffa7..384c94cce9735 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt @@ -2,20 +2,9 @@ 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 android.security.KeyChain +import app.alextran.immich.NativeBuffer import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -24,7 +13,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware { private var networkApi: NetworkApiImpl? = null override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - networkApi = NetworkApiImpl(binding.applicationContext) + networkApi = NetworkApiImpl() NetworkApi.setUp(binding.binaryMessenger, networkApi) } @@ -34,48 +23,24 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware { } override fun onAttachedToActivity(binding: ActivityPluginBinding) { - networkApi?.onAttachedToActivity(binding) + networkApi?.activity = binding.activity } override fun onDetachedFromActivityForConfigChanges() { - networkApi?.onDetachedFromActivityForConfigChanges() + networkApi?.activity = null } override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - networkApi?.onReattachedToActivityForConfigChanges(binding) + networkApi?.activity = binding.activity } override fun onDetachedFromActivity() { - networkApi?.onDetachedFromActivity() + networkApi?.activity = null } } -private class NetworkApiImpl(private val context: Context) : NetworkApi { - private var activity: Activity? = null - private var pendingCallback: ((Result) -> Unit)? = null - private var filePicker: ActivityResultLauncher>? = null - private var promptText: ClientCertPrompt? = null - - fun onAttachedToActivity(binding: ActivityPluginBinding) { - activity = binding.activity - (binding.activity as? ComponentActivity)?.let { componentActivity -> - filePicker = componentActivity.registerForActivityResult( - ActivityResultContracts.OpenDocument() - ) { uri -> uri?.let { handlePickedFile(it) } ?: pendingCallback?.invoke(Result.failure(OperationCanceledException())) } - } - } - - fun onDetachedFromActivityForConfigChanges() { - activity = null - } - - fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - activity = binding.activity - } - - fun onDetachedFromActivity() { - activity = null - } +private class NetworkApiImpl() : NetworkApi { + var activity: Activity? = null override fun addCertificate(clientData: ClientCertData, callback: (Result) -> Unit) { try { @@ -86,11 +51,19 @@ private class NetworkApiImpl(private val context: Context) : NetworkApi { } } - override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result) -> Unit) { - val picker = filePicker ?: return callback(Result.failure(IllegalStateException("No activity"))) - pendingCallback = callback - this.promptText = promptText - picker.launch(arrayOf("application/x-pkcs12", "application/x-pem-file")) + override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result) -> Unit) { + val currentActivity = activity + ?: return callback(Result.failure(IllegalStateException("No activity"))) + + val onAlias = { alias: String? -> + if (alias != null) { + HttpClientManager.setKeyChainAlias(alias) + callback(Result.success(Unit)) + } else { + callback(Result.failure(OperationCanceledException())) + } + } + KeyChain.choosePrivateKeyAlias(currentActivity, onAlias, null, null, null, null) } override fun removeCertificate(callback: (Result) -> Unit) { @@ -98,62 +71,15 @@ private class NetworkApiImpl(private val context: Context) : NetworkApi { 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)) - } + override fun hasCertificate(): Boolean { + return HttpClientManager.isMtls } - 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) - } - } + override fun getClientPointer(): Long { + return HttpClientManager.getClientPointer() + } - 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() + override fun setRequestHeaders(headers: Map, serverUrls: List) { + HttpClientManager.setRequestHeaders(headers, serverUrls) } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt index a04dedb676821..bef6418904829 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt @@ -47,7 +47,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() { /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface RemoteImageApi { - fun requestImage(url: String, headers: Map, requestId: Long, preferEncoded: Boolean, callback: (Result?>) -> Unit) + fun requestImage(url: String, requestId: Long, preferEncoded: Boolean, callback: (Result?>) -> Unit) fun cancelRequest(requestId: Long) fun clearCache(callback: (Result) -> Unit) @@ -66,10 +66,9 @@ interface RemoteImageApi { channel.setMessageHandler { message, reply -> val args = message as List val urlArg = args[0] as String - val headersArg = args[1] as Map - val requestIdArg = args[2] as Long - val preferEncodedArg = args[3] as Boolean - api.requestImage(urlArg, headersArg, requestIdArg, preferEncodedArg) { result: Result?> -> + val requestIdArg = args[1] as Long + val preferEncodedArg = args[2] as Boolean + api.requestImage(urlArg, requestIdArg, preferEncodedArg) { result: Result?> -> val error = result.exceptionOrNull() if (error != null) { reply.reply(RemoteImagesPigeonUtils.wrapError(error)) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index 6b15f33414427..21e3c603e612e 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -15,6 +15,8 @@ import okhttp3.Callback import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import okhttp3.Credentials +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.chromium.net.CronetEngine import org.chromium.net.CronetException import org.chromium.net.UrlRequest @@ -49,7 +51,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { override fun requestImage( url: String, - headers: Map, requestId: Long, @Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android callback: (Result?>) -> Unit @@ -59,7 +60,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { ImageFetcherManager.fetch( url, - headers, signal, onSuccess = { buffer -> requestMap.remove(requestId) @@ -120,12 +120,11 @@ private object ImageFetcherManager { fun fetch( url: String, - headers: Map, signal: CancellationSignal, onSuccess: (NativeByteBuffer) -> Unit, onFailure: (Exception) -> Unit, ) { - fetcher.fetch(url, headers, signal, onSuccess, onFailure) + fetcher.fetch(url, signal, onSuccess, onFailure) } fun clearCache(onCleared: (Result) -> Unit) { @@ -152,7 +151,6 @@ private object ImageFetcherManager { private sealed interface ImageFetcher { fun fetch( url: String, - headers: Map, signal: CancellationSignal, onSuccess: (NativeByteBuffer) -> Unit, onFailure: (Exception) -> Unit, @@ -179,7 +177,6 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche override fun fetch( url: String, - headers: Map, signal: CancellationSignal, onSuccess: (NativeByteBuffer) -> Unit, onFailure: (Exception) -> Unit, @@ -194,7 +191,12 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche val callback = FetchCallback(onSuccess, onFailure, ::onComplete) val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor) - headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } + HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } + url.toHttpUrlOrNull()?.let { httpUrl -> + if (httpUrl.username.isNotEmpty()) { + requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password)) + } + } val request = requestBuilder.build() signal.setOnCancelListener(request::cancel) request.start() @@ -391,7 +393,6 @@ private class OkHttpImageFetcher private constructor( override fun fetch( url: String, - headers: Map, signal: CancellationSignal, onSuccess: (NativeByteBuffer) -> Unit, onFailure: (Exception) -> Unit, @@ -404,7 +405,6 @@ private class OkHttpImageFetcher private constructor( } val requestBuilder = Request.Builder().url(url) - headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } val call = client.newCall(requestBuilder.build()) signal.setOnCancelListener(call::cancel) diff --git a/mobile/ios/Runner/Core/Network.g.swift b/mobile/ios/Runner/Core/Network.g.swift index 0f678ce4a4ae3..96294c1cd4737 100644 --- a/mobile/ios/Runner/Core/Network.g.swift +++ b/mobile/ios/Runner/Core/Network.g.swift @@ -221,8 +221,11 @@ class NetworkPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol NetworkApi { func addCertificate(clientData: ClientCertData, completion: @escaping (Result) -> Void) - func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result) -> Void) + func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result) -> Void) func removeCertificate(completion: @escaping (Result) -> Void) + func hasCertificate() throws -> Bool + func getClientPointer() throws -> Int64 + func setRequestHeaders(headers: [String: String], serverUrls: [String]) throws } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -255,8 +258,8 @@ class NetworkApiSetup { let promptTextArg = args[0] as! ClientCertPrompt api.selectCertificate(promptText: promptTextArg) { result in switch result { - case .success(let res): - reply(wrapResult(res)) + case .success: + reply(wrapResult(nil)) case .failure(let error): reply(wrapError(error)) } @@ -280,5 +283,47 @@ class NetworkApiSetup { } else { removeCertificateChannel.setMessageHandler(nil) } + let hasCertificateChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + hasCertificateChannel.setMessageHandler { _, reply in + do { + let result = try api.hasCertificate() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + hasCertificateChannel.setMessageHandler(nil) + } + let getClientPointerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getClientPointerChannel.setMessageHandler { _, reply in + do { + let result = try api.getClientPointer() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getClientPointerChannel.setMessageHandler(nil) + } + let setRequestHeadersChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setRequestHeadersChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let headersArg = args[0] as! [String: String] + let serverUrlsArg = args[1] as! [String] + do { + try api.setRequestHeaders(headers: headersArg, serverUrls: serverUrlsArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + setRequestHeadersChannel.setMessageHandler(nil) + } } } diff --git a/mobile/ios/Runner/Core/NetworkApiImpl.swift b/mobile/ios/Runner/Core/NetworkApiImpl.swift index d67c392a3a60f..480286b2af036 100644 --- a/mobile/ios/Runner/Core/NetworkApiImpl.swift +++ b/mobile/ios/Runner/Core/NetworkApiImpl.swift @@ -1,5 +1,6 @@ import Foundation import UniformTypeIdentifiers +import native_video_player enum ImportError: Error { case noFile @@ -16,14 +17,25 @@ class NetworkApiImpl: NetworkApi { self.viewController = viewController } - func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result) -> Void) { + func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result) -> Void) { let importer = CertImporter(promptText: promptText, completion: { [weak self] result in self?.activeImporter = nil - completion(result.map { ClientCertData(data: FlutterStandardTypedData(bytes: $0.0), password: $0.1) }) + completion(result) }, viewController: viewController) activeImporter = importer importer.load() } + + func hasCertificate() throws -> Bool { + 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) + return status == errSecSuccess + } func removeCertificate(completion: @escaping (Result) -> Void) { let status = clearCerts() @@ -40,14 +52,58 @@ class NetworkApiImpl: NetworkApi { } completion(.failure(ImportError.keychainError(status))) } + + func getClientPointer() throws -> Int64 { + let pointer = URLSessionManager.shared.sessionPointer + return Int64(Int(bitPattern: pointer)) + } + + func setRequestHeaders(headers: [String : String], serverUrls: [String]) throws { + var headers = headers + if let token = headers.removeValue(forKey: "x-immich-user-token") { + for serverUrl in serverUrls { + guard let url = URL(string: serverUrl), let domain = url.host else { continue } + let isSecure = serverUrl.hasPrefix("https") + let cookies: [(String, String, Bool)] = [ + ("immich_access_token", token, true), + ("immich_is_authenticated", "true", false), + ("immich_auth_type", "password", true), + ] + let expiry = Date().addingTimeInterval(400 * 24 * 60 * 60) + for (name, value, httpOnly) in cookies { + var properties: [HTTPCookiePropertyKey: Any] = [ + .name: name, + .value: value, + .domain: domain, + .path: "/", + .expires: expiry, + ] + if isSecure { properties[.secure] = "TRUE" } + if httpOnly { properties[.init("HttpOnly")] = "TRUE" } + if let cookie = HTTPCookie(properties: properties) { + URLSessionManager.cookieStorage.setCookie(cookie) + } + } + } + } + + if serverUrls.first != UserDefaults.group.string(forKey: SERVER_URL_KEY) { + UserDefaults.group.set(serverUrls.first, forKey: SERVER_URL_KEY) + } + + if headers != UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] { + UserDefaults.group.set(headers, forKey: HEADERS_KEY) + URLSessionManager.shared.recreateSession() // Recreate session to apply custom headers without app restart + } + } } private class CertImporter: NSObject, UIDocumentPickerDelegate { private let promptText: ClientCertPrompt - private var completion: ((Result<(Data, String), Error>) -> Void) + private var completion: ((Result) -> Void) private weak var viewController: UIViewController? - - init(promptText: ClientCertPrompt, completion: (@escaping (Result<(Data, String), Error>) -> Void), viewController: UIViewController?) { + + init(promptText: ClientCertPrompt, completion: (@escaping (Result) -> Void), viewController: UIViewController?) { self.promptText = promptText self.completion = completion self.viewController = viewController @@ -81,7 +137,7 @@ private class CertImporter: NSObject, UIDocumentPickerDelegate { } await URLSessionManager.shared.session.flush() - self.completion(.success((data, password))) + self.completion(.success(())) } catch { completion(.failure(error)) } diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift index 73145dbce56b7..411b828ea1b6e 100644 --- a/mobile/ios/Runner/Core/URLSessionManager.swift +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -1,49 +1,77 @@ import Foundation +import native_video_player let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity" +let HEADERS_KEY = "immich.request_headers" +let SERVER_URL_KEY = "immich.server_url" +let APP_GROUP = "group.app.immich.share" + +extension UserDefaults { + static let group = UserDefaults(suiteName: APP_GROUP)! +} /// Manages a shared URLSession with SSL configuration support. +/// Old sessions are kept alive by Dart's FFI retain until all isolates release them. 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) + private(set) var session: URLSession + let delegate: URLSessionManagerDelegate + private static let cacheDir: URL = { + let dir = 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 - + try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + }() + private static let urlCache = URLCache( + memoryCapacity: 0, + diskCapacity: 1024 * 1024 * 1024, + directory: cacheDir + ) + private static let userAgent: String = { let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" - config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"] - - return config + return "Immich_iOS_\(version)" }() + static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP) + + var sessionPointer: UnsafeMutableRawPointer { + Unmanaged.passUnretained(session).toOpaque() + } private override init() { - session = URLSession(configuration: configuration, delegate: URLSessionManagerDelegate(), delegateQueue: nil) + delegate = URLSessionManagerDelegate() + session = Self.buildSession(delegate: delegate) super.init() } + + func recreateSession() { + session = Self.buildSession(delegate: delegate) + } + + private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession { + let config = URLSessionConfiguration.default + config.urlCache = urlCache + config.httpCookieStorage = cookieStorage + config.httpMaximumConnectionsPerHost = 64 + config.timeoutIntervalForRequest = 60 + config.timeoutIntervalForResource = 300 + + var headers = UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] ?? [:] + headers["User-Agent"] = headers["User-Agent"] ?? userAgent + config.httpAdditionalHeaders = headers + + return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + } } -class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate { +class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate { func urlSession( _ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { - handleChallenge(challenge, completionHandler: completionHandler) + handleChallenge(session, challenge, completionHandler) } func urlSession( @@ -52,20 +80,24 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate { didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { - handleChallenge(challenge, completionHandler: completionHandler) + handleChallenge(session, challenge, completionHandler, task: task) } func handleChallenge( + _ session: URLSession, _ challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + _ completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void, + task: URLSessionTask? = nil ) { switch challenge.protectionSpace.authenticationMethod { - case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(completion: completionHandler) + case NSURLAuthenticationMethodClientCertificate: handleClientCertificate(session, completion: completionHandler) + case NSURLAuthenticationMethodHTTPBasic: handleBasicAuth(session, task: task, completion: completionHandler) default: completionHandler(.performDefaultHandling, nil) } } private func handleClientCertificate( + _ session: URLSession, completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { let query: [String: Any] = [ @@ -80,8 +112,29 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate { let credential = URLCredential(identity: identity as! SecIdentity, certificates: nil, persistence: .forSession) + if #available(iOS 15, *) { + VideoProxyServer.shared.session = session + } return completion(.useCredential, credential) } completion(.performDefaultHandling, nil) } + + private func handleBasicAuth( + _ session: URLSession, + task: URLSessionTask?, + completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard let url = task?.originalRequest?.url, + let user = url.user, + let password = url.password + else { + return completion(.performDefaultHandling, nil) + } + if #available(iOS 15, *) { + VideoProxyServer.shared.session = session + } + let credential = URLCredential(user: user, password: password, persistence: .forSession) + completion(.useCredential, credential) + } } diff --git a/mobile/ios/Runner/Images/RemoteImages.g.swift b/mobile/ios/Runner/Images/RemoteImages.g.swift index 5123a12f3ed4c..9fcffd4233e76 100644 --- a/mobile/ios/Runner/Images/RemoteImages.g.swift +++ b/mobile/ios/Runner/Images/RemoteImages.g.swift @@ -70,7 +70,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol RemoteImageApi { - func requestImage(url: String, headers: [String: String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) + func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) func cancelRequest(requestId: Int64) throws func clearCache(completion: @escaping (Result) -> Void) } @@ -86,10 +86,9 @@ class RemoteImageApiSetup { requestImageChannel.setMessageHandler { message, reply in let args = message as! [Any?] let urlArg = args[0] as! String - let headersArg = args[1] as! [String: String] - let requestIdArg = args[2] as! Int64 - let preferEncodedArg = args[3] as! Bool - api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in + let requestIdArg = args[1] as! Int64 + let preferEncodedArg = args[2] as! Bool + api.requestImage(url: urlArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in switch result { case .success(let res): reply(wrapResult(res)) diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index fe318800b8747..f2a0c37254b87 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -33,12 +33,9 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { kCGImageSourceCreateThumbnailFromImageAlways: true ] as CFDictionary - func requestImage(url: String, headers: [String : String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { + func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { var urlRequest = URLRequest(url: URL(string: url)!) urlRequest.cachePolicy = .returnCacheDataElseLoad - for (key, value) in headers { - urlRequest.setValue(value, forHTTPHeaderField: key) - } let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error) diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 6de13b624436e..93a2a14127797 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'dart:ui'; import 'package:background_downloader/background_downloader.dart'; -import 'package:cancellation_token_http/http.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; @@ -28,7 +27,6 @@ import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/wm_executor.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; @@ -64,7 +62,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { final Drift _drift; final DriftLogger _driftLogger; final BackgroundWorkerBgHostApi _backgroundHostApi; - final CancellationToken _cancellationToken = CancellationToken(); + final _cancellationToken = Completer(); final Logger _logger = Logger('BackgroundWorkerBgService'); bool _isCleanedUp = false; @@ -88,8 +86,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { Future init() async { try { - HttpSSLOptions.apply(); - await Future.wait( [ loadTranslations(), @@ -198,7 +194,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { _ref?.dispose(); _ref = null; - _cancellationToken.cancel(); + _cancellationToken.complete(); _logger.info("Cleaning up background worker"); final cleanupFutures = [ diff --git a/mobile/lib/infrastructure/loaders/remote_image_request.dart b/mobile/lib/infrastructure/loaders/remote_image_request.dart index 40ed304bbe8ac..bcfa9a93c7893 100644 --- a/mobile/lib/infrastructure/loaders/remote_image_request.dart +++ b/mobile/lib/infrastructure/loaders/remote_image_request.dart @@ -2,9 +2,8 @@ part of 'image_request.dart'; class RemoteImageRequest extends ImageRequest { final String uri; - final Map headers; - RemoteImageRequest({required this.uri, required this.headers}); + RemoteImageRequest({required this.uri}); @override Future load(ImageDecoderCallback decode, {double scale = 1.0}) async { @@ -12,7 +11,7 @@ class RemoteImageRequest extends ImageRequest { return null; } - final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: false); + final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false); // Android always returns encoded data, so we need to check for both shapes of the response. final frame = switch (info) { {'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length), @@ -29,7 +28,7 @@ class RemoteImageRequest extends ImageRequest { return null; } - final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: true); + final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: true); if (info == null) return null; final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null); diff --git a/mobile/lib/infrastructure/repositories/network.repository.dart b/mobile/lib/infrastructure/repositories/network.repository.dart index a73322cb5c408..adf1ee5694e8d 100644 --- a/mobile/lib/infrastructure/repositories/network.repository.dart +++ b/mobile/lib/infrastructure/repositories/network.repository.dart @@ -1,67 +1,55 @@ +import 'dart:ffi'; import 'dart:io'; -import 'package:cronet_http/cronet_http.dart'; import 'package:cupertino_http/cupertino_http.dart'; import 'package:http/http.dart' as http; -import 'package:immich_mobile/utils/user_agent.dart'; -import 'package:path_provider/path_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:ok_http/ok_http.dart'; +import 'package:web_socket/web_socket.dart'; class NetworkRepository { - static late Directory _cachePath; - static late String _userAgent; - static final _clients = {}; + static http.Client? _client; + static Pointer? _clientPointer; - static Future init() { - return ( - getTemporaryDirectory().then((cachePath) => _cachePath = cachePath), - getUserAgentString().then((userAgent) => _userAgent = userAgent), - ).wait; + static Future init() async { + final clientPointer = Pointer.fromAddress(await networkApi.getClientPointer()); + if (clientPointer == _clientPointer) { + return; + } + _clientPointer = clientPointer; + _client?.close(); + if (Platform.isIOS) { + final session = URLSession.fromRawPointer(clientPointer.cast()); + _client = CupertinoClient.fromSharedSession(session); + } else { + _client = OkHttpClient.fromJniGlobalRef(clientPointer); + } } - static void reset() { - Future.microtask(init); - for (final client in _clients.values) { - client.close(); + static Future setHeaders(Map headers, List serverUrls) async { + await networkApi.setRequestHeaders(headers, serverUrls); + if (Platform.isIOS) { + await init(); } - _clients.clear(); } - const NetworkRepository(); - - /// Note: when disk caching is enabled, only one client may use a given directory at a time. - /// Different isolates or engines must use different directories. - http.Client getHttpClient( - String directoryName, { - CacheMode cacheMode = CacheMode.memory, - int diskCapacity = 0, - int maxConnections = 6, - int memoryCapacity = 10 << 20, - }) { - final cachedClient = _clients[directoryName]; - if (cachedClient != null) { - return cachedClient; + // ignore: avoid-unused-parameters + static Future createWebSocket(Uri uri, {Map? headers, Iterable? protocols}) { + if (Platform.isIOS) { + final session = URLSession.fromRawPointer(_clientPointer!.cast()); + return CupertinoWebSocket.connectWithSession(session, uri, protocols: protocols); + } else { + return OkHttpWebSocket.connectFromJniGlobalRef(_clientPointer!, uri, protocols: protocols); } + } - final directory = Directory('${_cachePath.path}/$directoryName'); - directory.createSync(recursive: true); - if (Platform.isAndroid) { - final engine = CronetEngine.build( - cacheMode: cacheMode, - cacheMaxSize: diskCapacity, - storagePath: directory.path, - userAgent: _userAgent, - ); - return _clients[directoryName] = CronetClient.fromCronetEngine(engine, closeEngine: true); - } + const NetworkRepository(); - final config = URLSessionConfiguration.defaultSessionConfiguration() - ..httpMaximumConnectionsPerHost = maxConnections - ..cache = URLCache.withCapacity( - diskCapacity: diskCapacity, - memoryCapacity: memoryCapacity, - directory: directory.uri, - ) - ..httpAdditionalHeaders = {'User-Agent': _userAgent}; - return _clients[directoryName] = CupertinoClient.fromSessionConfiguration(config); - } + /// Returns a shared HTTP client that uses native SSL configuration. + /// + /// On iOS: Uses SharedURLSessionManager's URLSession. + /// On Android: Uses SharedHttpClientManager's OkHttpClient. + /// + /// Must call [init] before using this method. + static http.Client get client => _client!; } diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index 0e5c99edd7935..12e817f7061bf 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/semver.dart'; import 'package:logging/logging.dart'; @@ -32,15 +33,11 @@ class SyncApiRepository { http.Client? httpClient, }) async { final stopwatch = Stopwatch()..start(); - final client = httpClient ?? http.Client(); + final client = httpClient ?? NetworkRepository.client; final endpoint = "${_api.apiClient.basePath}/sync/stream"; final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'}; - final headerParams = {}; - await _api.applyToParams([], headerParams); - headers.addAll(headerParams); - final shouldReset = Store.get(StoreKey.shouldResetSync, false); final request = http.Request('POST', Uri.parse(endpoint)); request.headers.addAll(headers); @@ -119,8 +116,6 @@ class SyncApiRepository { } } catch (error, stack) { return Future.error(error, stack); - } finally { - client.close(); } stopwatch.stop(); _logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms"); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index c35c27e141253..7e7c709eeb638 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -40,7 +40,6 @@ import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/cache/widgets_binding.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/utils/licenses.dart'; import 'package:immich_mobile/utils/migration.dart'; import 'package:immich_mobile/wm_executor.dart'; @@ -60,7 +59,6 @@ void main() async { // Warm-up isolate pool for worker manager await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5)); await migrateDatabaseIfNeeded(isar, drift); - HttpSSLOptions.apply(); runApp( ProviderScope( @@ -246,7 +244,7 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve @override void reassemble() { if (kDebugMode) { - NetworkRepository.reset(); + NetworkRepository.init(); } super.reassemble(); } diff --git a/mobile/lib/models/backup/backup_state.model.dart b/mobile/lib/models/backup/backup_state.model.dart index 635d925c3f5ba..51a17de4fcd66 100644 --- a/mobile/lib/models/backup/backup_state.model.dart +++ b/mobile/lib/models/backup/backup_state.model.dart @@ -1,6 +1,5 @@ // ignore_for_file: public_member_api_docs, sort_constructors_first -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; @@ -21,7 +20,6 @@ class BackUpState { final DateTime progressInFileSpeedUpdateTime; final int progressInFileSpeedUpdateSentBytes; final double iCloudDownloadProgress; - final CancellationToken cancelToken; final ServerDiskInfo serverInfo; final bool autoBackup; final bool backgroundBackup; @@ -53,7 +51,6 @@ class BackUpState { required this.progressInFileSpeedUpdateTime, required this.progressInFileSpeedUpdateSentBytes, required this.iCloudDownloadProgress, - required this.cancelToken, required this.serverInfo, required this.autoBackup, required this.backgroundBackup, @@ -78,7 +75,6 @@ class BackUpState { DateTime? progressInFileSpeedUpdateTime, int? progressInFileSpeedUpdateSentBytes, double? iCloudDownloadProgress, - CancellationToken? cancelToken, ServerDiskInfo? serverInfo, bool? autoBackup, bool? backgroundBackup, @@ -102,7 +98,6 @@ class BackUpState { progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress, - cancelToken: cancelToken ?? this.cancelToken, serverInfo: serverInfo ?? this.serverInfo, autoBackup: autoBackup ?? this.autoBackup, backgroundBackup: backgroundBackup ?? this.backgroundBackup, @@ -120,7 +115,7 @@ class BackUpState { @override String toString() { - return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)'; + return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: $iCloudDownloadProgress, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)'; } @override @@ -137,7 +132,6 @@ class BackUpState { other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime && other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes && other.iCloudDownloadProgress == iCloudDownloadProgress && - other.cancelToken == cancelToken && other.serverInfo == serverInfo && other.autoBackup == autoBackup && other.backgroundBackup == backgroundBackup && @@ -163,7 +157,6 @@ class BackUpState { progressInFileSpeedUpdateTime.hashCode ^ progressInFileSpeedUpdateSentBytes.hashCode ^ iCloudDownloadProgress.hashCode ^ - cancelToken.hashCode ^ serverInfo.hashCode ^ autoBackup.hashCode ^ backgroundBackup.hashCode ^ diff --git a/mobile/lib/models/backup/manual_upload_state.model.dart b/mobile/lib/models/backup/manual_upload_state.model.dart index 7f797334deefb..120327c611cc9 100644 --- a/mobile/lib/models/backup/manual_upload_state.model.dart +++ b/mobile/lib/models/backup/manual_upload_state.model.dart @@ -1,11 +1,8 @@ -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; class ManualUploadState { - final CancellationToken cancelToken; - // Current Backup Asset final CurrentUploadAsset currentUploadAsset; final int currentAssetIndex; @@ -29,7 +26,6 @@ class ManualUploadState { required this.progressInFileSpeeds, required this.progressInFileSpeedUpdateTime, required this.progressInFileSpeedUpdateSentBytes, - required this.cancelToken, required this.currentUploadAsset, required this.totalAssetsToUpload, required this.currentAssetIndex, @@ -44,7 +40,6 @@ class ManualUploadState { List? progressInFileSpeeds, DateTime? progressInFileSpeedUpdateTime, int? progressInFileSpeedUpdateSentBytes, - CancellationToken? cancelToken, CurrentUploadAsset? currentUploadAsset, int? totalAssetsToUpload, int? successfulUploads, @@ -58,7 +53,6 @@ class ManualUploadState { progressInFileSpeeds: progressInFileSpeeds ?? this.progressInFileSpeeds, progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes, - cancelToken: cancelToken ?? this.cancelToken, currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset, totalAssetsToUpload: totalAssetsToUpload ?? this.totalAssetsToUpload, currentAssetIndex: currentAssetIndex ?? this.currentAssetIndex, @@ -69,7 +63,7 @@ class ManualUploadState { @override String toString() { - return 'ManualUploadState(progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, cancelToken: $cancelToken, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)'; + return 'ManualUploadState(progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)'; } @override @@ -84,7 +78,6 @@ class ManualUploadState { collectionEquals(other.progressInFileSpeeds, progressInFileSpeeds) && other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime && other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes && - other.cancelToken == cancelToken && other.currentUploadAsset == currentUploadAsset && other.totalAssetsToUpload == totalAssetsToUpload && other.currentAssetIndex == currentAssetIndex && @@ -100,7 +93,6 @@ class ManualUploadState { progressInFileSpeeds.hashCode ^ progressInFileSpeedUpdateTime.hashCode ^ progressInFileSpeedUpdateSentBytes.hashCode ^ - cancelToken.hashCode ^ currentUploadAsset.hashCode ^ totalAssetsToUpload.hashCode ^ currentAssetIndex.hashCode ^ diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index cd6c2a62b0c8b..c5084c0236167 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -96,10 +96,6 @@ class _DriftBackupPageState extends ConsumerState { await backupNotifier.startForegroundBackup(currentUser.id); } - Future stopBackup() async { - await backupNotifier.stopForegroundBackup(); - } - return Scaffold( appBar: AppBar( elevation: 0, @@ -136,9 +132,9 @@ class _DriftBackupPageState extends ConsumerState { const Divider(), BackupToggleButton( onStart: () async => await startBackup(), - onStop: () async { + onStop: () { syncSuccess = null; - await stopBackup(); + backupNotifier.stopForegroundBackup(); }, ), switch (error) { diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart index 93ab659032a00..17323856759ca 100644 --- a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart @@ -112,16 +112,15 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState backgroundSync.hashAssets())); if (isBackupEnabled) { + backupNotifier.stopForegroundBackup(); unawaited( - backupNotifier.stopForegroundBackup().whenComplete( - () => backgroundSync.syncRemote().then((success) { - if (success) { - return backupNotifier.startForegroundBackup(user.id); - } else { - Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup'); - } - }), - ), + backgroundSync.syncRemote().then((success) { + if (success) { + return backupNotifier.startForegroundBackup(user.id); + } else { + Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup'); + } + }), ); } } diff --git a/mobile/lib/pages/backup/drift_backup_options.page.dart b/mobile/lib/pages/backup/drift_backup_options.page.dart index f43c8b6a8e36a..79891d7002df7 100644 --- a/mobile/lib/pages/backup/drift_backup_options.page.dart +++ b/mobile/lib/pages/backup/drift_backup_options.page.dart @@ -59,16 +59,15 @@ class DriftBackupOptionsPage extends ConsumerWidget { final backupNotifier = ref.read(driftBackupProvider.notifier); final backgroundSync = ref.read(backgroundSyncProvider); + backupNotifier.stopForegroundBackup(); unawaited( - backupNotifier.stopForegroundBackup().whenComplete( - () => backgroundSync.syncRemote().then((success) { - if (success) { - return backupNotifier.startForegroundBackup(currentUser.id); - } else { - Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup'); - } - }), - ), + backgroundSync.syncRemote().then((success) { + if (success) { + return backupNotifier.startForegroundBackup(currentUser.id); + } else { + Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup'); + } + }), ); } }, diff --git a/mobile/lib/pages/common/headers_settings.page.dart b/mobile/lib/pages/common/headers_settings.page.dart index c7c34b9cd2ef7..6eba49442fd20 100644 --- a/mobile/lib/pages/common/headers_settings.page.dart +++ b/mobile/lib/pages/common/headers_settings.page.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; class SettingsHeader { String key = ""; @@ -20,7 +21,6 @@ class HeaderSettingsPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // final apiService = ref.watch(apiServiceProvider); final headers = useState>([]); final setInitialHeaders = useState(false); @@ -75,7 +75,7 @@ class HeaderSettingsPage extends HookConsumerWidget { ], ), body: PopScope( - onPopInvokedWithResult: (didPop, _) => saveHeaders(headers.value), + onPopInvokedWithResult: (didPop, _) => saveHeaders(ref, headers.value), child: ListView.separated( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0), itemCount: list.length, @@ -87,7 +87,7 @@ class HeaderSettingsPage extends HookConsumerWidget { ); } - saveHeaders(List headers) { + saveHeaders(WidgetRef ref, List headers) async { final headersMap = {}; for (var header in headers) { final key = header.key.trim(); @@ -98,7 +98,8 @@ class HeaderSettingsPage extends HookConsumerWidget { } var encoded = jsonEncode(headersMap); - Store.put(StoreKey.customHeaders, encoded); + await Store.put(StoreKey.customHeaders, encoded); + await ref.read(apiServiceProvider).updateHeaders(); } } diff --git a/mobile/lib/platform/network_api.g.dart b/mobile/lib/platform/network_api.g.dart index 6ddb3cdb7126f..314a943f7d64d 100644 --- a/mobile/lib/platform/network_api.g.dart +++ b/mobile/lib/platform/network_api.g.dart @@ -179,7 +179,7 @@ class NetworkApi { } } - Future selectCertificate(ClientCertPrompt promptText) async { + Future selectCertificate(ClientCertPrompt promptText) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( @@ -189,6 +189,52 @@ class NetworkApi { ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([promptText]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future removeCertificate() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future hasCertificate() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -203,13 +249,13 @@ class NetworkApi { message: 'Host platform returned null value for non-null return value.', ); } else { - return (pigeonVar_replyList[0] as ClientCertData?)!; + return (pigeonVar_replyList[0] as bool?)!; } } - Future removeCertificate() async { + Future getClientPointer() async { final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.NetworkApi.removeCertificate$pigeonVar_messageChannelSuffix'; + 'dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, @@ -217,6 +263,34 @@ class NetworkApi { ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else 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 int?)!; + } + } + + Future setRequestHeaders(Map headers, List serverUrls) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([headers, serverUrls]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { diff --git a/mobile/lib/platform/remote_image_api.g.dart b/mobile/lib/platform/remote_image_api.g.dart index 24390293c9fe2..474f033f1fe5d 100644 --- a/mobile/lib/platform/remote_image_api.g.dart +++ b/mobile/lib/platform/remote_image_api.g.dart @@ -49,12 +49,7 @@ class RemoteImageApi { final String pigeonVar_messageChannelSuffix; - Future?> requestImage( - String url, { - required Map headers, - required int requestId, - required bool preferEncoded, - }) async { + Future?> requestImage(String url, {required int requestId, required bool preferEncoded}) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( @@ -62,12 +57,7 @@ class RemoteImageApi { pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([ - url, - headers, - requestId, - preferEncoded, - ]); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([url, requestId, preferEncoded]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); diff --git a/mobile/lib/presentation/pages/editing/drift_edit.page.dart b/mobile/lib/presentation/pages/editing/drift_edit.page.dart index a10202973d661..6d4ea4d3a658d 100644 --- a/mobile/lib/presentation/pages/editing/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/editing/drift_edit.page.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:cancellation_token_http/http.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -62,7 +61,7 @@ class DriftEditImagePage extends ConsumerWidget { return; } - await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken()); + await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset]); } catch (e) { ImmichToast.show( durationInSecond: 6, diff --git a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart index d69c5bced3765..98eb09a4aa51f 100644 --- a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart @@ -101,7 +101,8 @@ class _UploadProgressDialog extends ConsumerWidget { actions: [ ImmichTextButton( onPressed: () { - ref.read(manualUploadCancelTokenProvider)?.cancel(); + ref.read(manualUploadCancelTokenProvider)?.complete(); + ref.read(manualUploadCancelTokenProvider.notifier).state = null; Navigator.of(context).pop(); }, labelText: 'cancel'.t(context: context), diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index e7e5deb6a6daa..f3877f2ad2c05 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -6,7 +6,6 @@ import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:openapi/api.dart'; @@ -37,7 +36,7 @@ class RemoteImageProvider extends CancellableImageProvider } Stream _codec(RemoteImageProvider key, ImageDecoderCallback decode) { - final request = this.request = RemoteImageRequest(uri: key.url, headers: ApiService.getRequestHeaders()); + final request = this.request = RemoteImageRequest(uri: key.url); return loadRequest(request, decode); } @@ -88,10 +87,8 @@ class RemoteFullImageProvider extends CancellableImageProvider { } } - Future _performPause() async { + Future _performPause() { if (_ref.read(authProvider).isAuthenticated) { if (!Store.isBetaTimelineEnabled) { // Do not cancel backup if manual upload is in progress @@ -240,15 +240,13 @@ class AppLifeCycleNotifier extends StateNotifier { _ref.read(backupProvider.notifier).cancelBackup(); } } else { - await _ref.read(driftBackupProvider.notifier).stopForegroundBackup(); + _ref.read(driftBackupProvider.notifier).stopForegroundBackup(); } _ref.read(websocketProvider.notifier).disconnect(); } - try { - await LogService.I.flush(); - } catch (_) {} + return LogService.I.flush().catchError((_) {}); } Future handleAppDetached() async { diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 49dc10240b719..ee3367eef2ae3 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -124,6 +124,7 @@ class AuthNotifier extends StateNotifier { Future saveAuthInfo({required String accessToken}) async { await _apiService.setAccessToken(accessToken); + await _apiService.updateHeaders(); final serverEndpoint = Store.get(StoreKey.serverEndpoint); final customHeaders = Store.tryGet(StoreKey.customHeaders); diff --git a/mobile/lib/providers/backup/asset_upload_progress.provider.dart b/mobile/lib/providers/backup/asset_upload_progress.provider.dart index e8aba430da845..60936ef871f53 100644 --- a/mobile/lib/providers/backup/asset_upload_progress.provider.dart +++ b/mobile/lib/providers/backup/asset_upload_progress.provider.dart @@ -1,4 +1,5 @@ -import 'package:cancellation_token_http/http.dart'; +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; /// Tracks per-asset upload progress. @@ -30,4 +31,4 @@ final assetUploadProgressProvider = NotifierProvider((ref) => null); +final manualUploadCancelTokenProvider = StateProvider?>((ref) => null); diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 9eb01b61090c4..5f3ad3d05830e 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -1,6 +1,6 @@ +import 'dart:async'; import 'dart:io'; -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; @@ -68,7 +68,6 @@ class BackupNotifier extends StateNotifier { progressInFileSpeeds: const [], progressInFileSpeedUpdateTime: DateTime.now(), progressInFileSpeedUpdateSentBytes: 0, - cancelToken: CancellationToken(), autoBackup: Store.get(StoreKey.autoBackup, false), backgroundBackup: Store.get(StoreKey.backgroundBackup, false), backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true), @@ -102,6 +101,7 @@ class BackupNotifier extends StateNotifier { final FileMediaRepository _fileMediaRepository; final BackupAlbumService _backupAlbumService; final Ref ref; + Completer? _cancelToken; /// /// UI INTERACTION @@ -454,7 +454,8 @@ class BackupNotifier extends StateNotifier { } // Perform Backup - state = state.copyWith(cancelToken: CancellationToken()); + _cancelToken?.complete(); + _cancelToken = Completer(); final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; @@ -465,7 +466,7 @@ class BackupNotifier extends StateNotifier { await _backupService.backupAsset( assetsWillBeBackup, - state.cancelToken, + _cancelToken!, pmProgressHandler: pmProgressHandler, onSuccess: _onAssetUploaded, onProgress: _onUploadProgress, @@ -494,7 +495,8 @@ class BackupNotifier extends StateNotifier { if (state.backupProgress != BackUpProgressEnum.inProgress) { notifyBackgroundServiceCanRun(); } - state.cancelToken.cancel(); + _cancelToken?.complete(); + _cancelToken = null; state = state.copyWith( backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0, diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index 624c21f1587ed..4507747c7d423 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:logging/logging.dart'; @@ -109,7 +108,6 @@ class DriftBackupState { final BackupError error; final Map uploadItems; - final CancellationToken? cancelToken; final Map iCloudDownloadProgress; @@ -121,7 +119,6 @@ class DriftBackupState { required this.isSyncing, this.error = BackupError.none, required this.uploadItems, - this.cancelToken, this.iCloudDownloadProgress = const {}, }); @@ -133,7 +130,6 @@ class DriftBackupState { bool? isSyncing, BackupError? error, Map? uploadItems, - CancellationToken? cancelToken, Map? iCloudDownloadProgress, }) { return DriftBackupState( @@ -144,7 +140,6 @@ class DriftBackupState { isSyncing: isSyncing ?? this.isSyncing, error: error ?? this.error, uploadItems: uploadItems ?? this.uploadItems, - cancelToken: cancelToken ?? this.cancelToken, iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress, ); } @@ -153,7 +148,7 @@ class DriftBackupState { @override String toString() { - return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, cancelToken: $cancelToken, iCloudDownloadProgress: $iCloudDownloadProgress)'; + return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, iCloudDownloadProgress: $iCloudDownloadProgress)'; } @override @@ -168,8 +163,7 @@ class DriftBackupState { other.isSyncing == isSyncing && other.error == error && mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) && - mapEquals(other.uploadItems, uploadItems) && - other.cancelToken == cancelToken; + mapEquals(other.uploadItems, uploadItems); } @override @@ -181,7 +175,6 @@ class DriftBackupState { isSyncing.hashCode ^ error.hashCode ^ uploadItems.hashCode ^ - cancelToken.hashCode ^ iCloudDownloadProgress.hashCode; } } @@ -211,6 +204,7 @@ class DriftBackupNotifier extends StateNotifier { final ForegroundUploadService _foregroundUploadService; final BackgroundUploadService _backgroundUploadService; final UploadSpeedManager _uploadSpeedManager; + Completer? _cancelToken; final _logger = Logger("DriftBackupNotifier"); @@ -246,7 +240,7 @@ class DriftBackupNotifier extends StateNotifier { ); } - void updateError(BackupError error) async { + void updateError(BackupError error) { if (!mounted) { _logger.warning("Skip updateError: notifier disposed"); return; @@ -254,24 +248,23 @@ class DriftBackupNotifier extends StateNotifier { state = state.copyWith(error: error); } - void updateSyncing(bool isSyncing) async { + void updateSyncing(bool isSyncing) { state = state.copyWith(isSyncing: isSyncing); } - Future startForegroundBackup(String userId) async { + Future startForegroundBackup(String userId) { // Cancel any existing backup before starting a new one - if (state.cancelToken != null) { - await stopForegroundBackup(); + if (_cancelToken != null) { + stopForegroundBackup(); } state = state.copyWith(error: BackupError.none); - final cancelToken = CancellationToken(); - state = state.copyWith(cancelToken: cancelToken); + _cancelToken = Completer(); return _foregroundUploadService.uploadCandidates( userId, - cancelToken, + _cancelToken!, callbacks: UploadCallbacks( onProgress: _handleForegroundBackupProgress, onSuccess: _handleForegroundBackupSuccess, @@ -281,10 +274,11 @@ class DriftBackupNotifier extends StateNotifier { ); } - Future stopForegroundBackup() async { - state.cancelToken?.cancel(); + void stopForegroundBackup() { + _cancelToken?.complete(); + _cancelToken = null; _uploadSpeedManager.clear(); - state = state.copyWith(cancelToken: null, uploadItems: {}, iCloudDownloadProgress: {}); + state = state.copyWith(uploadItems: {}, iCloudDownloadProgress: {}); } void _handleICloudProgress(String localAssetId, double progress) { @@ -300,7 +294,7 @@ class DriftBackupNotifier extends StateNotifier { } void _handleForegroundBackupProgress(String localAssetId, String filename, int bytes, int totalBytes) { - if (state.cancelToken == null) { + if (_cancelToken == null) { return; } @@ -399,7 +393,7 @@ class DriftBackupNotifier extends StateNotifier { } } -final driftBackupCandidateProvider = FutureProvider.autoDispose>((ref) async { +final driftBackupCandidateProvider = FutureProvider.autoDispose>((ref) { final user = ref.watch(currentUserProvider); if (user == null) { return []; diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart index 6ad8730356bd8..40efcd7422996 100644 --- a/mobile/lib/providers/backup/manual_upload.provider.dart +++ b/mobile/lib/providers/backup/manual_upload.provider.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:io'; -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/widgets.dart'; @@ -50,6 +49,7 @@ class ManualUploadNotifier extends StateNotifier { final BackupService _backupService; final BackupAlbumService _backupAlbumService; final Ref ref; + Completer? _cancelToken; ManualUploadNotifier( this._localNotificationService, @@ -65,7 +65,6 @@ class ManualUploadNotifier extends StateNotifier { progressInFileSpeeds: const [], progressInFileSpeedUpdateTime: DateTime.now(), progressInFileSpeedUpdateSentBytes: 0, - cancelToken: CancellationToken(), currentUploadAsset: CurrentUploadAsset( id: '...', fileCreatedAt: DateTime.parse('2020-10-04'), @@ -236,7 +235,6 @@ class ManualUploadNotifier extends StateNotifier { fileName: '...', fileType: '...', ), - cancelToken: CancellationToken(), ); // Reset Error List ref.watch(errorBackupListProvider.notifier).empty(); @@ -252,11 +250,13 @@ class ManualUploadNotifier extends StateNotifier { state = state.copyWith(showDetailedNotification: showDetailedNotification); final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; + _cancelToken?.complete(); + _cancelToken = Completer(); final bool ok = await ref .read(backupServiceProvider) .backupAsset( uploadAssets, - state.cancelToken, + _cancelToken!, pmProgressHandler: pmProgressHandler, onSuccess: _onAssetUploaded, onProgress: _onProgress, @@ -273,14 +273,14 @@ class ManualUploadNotifier extends StateNotifier { ); // User cancelled upload - if (!ok && state.cancelToken.isCancelled) { + if (!ok && _cancelToken == null) { await _localNotificationService.showOrUpdateManualUploadStatus( "backup_manual_title".tr(), "backup_manual_cancelled".tr(), presentBanner: true, ); hasErrors = true; - } else if (state.successfulUploads == 0 || (!ok && !state.cancelToken.isCancelled)) { + } else if (state.successfulUploads == 0 || (!ok && _cancelToken != null)) { await _localNotificationService.showOrUpdateManualUploadStatus( "backup_manual_title".tr(), "failed".tr(), @@ -324,7 +324,8 @@ class ManualUploadNotifier extends StateNotifier { _backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) { _backupProvider.notifyBackgroundServiceCanRun(); } - state.cancelToken.cancel(); + _cancelToken?.complete(); + _cancelToken = null; if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) { _backupProvider.updateBackupProgress(BackUpProgressEnum.idle); } diff --git a/mobile/lib/providers/image/cache/image_loader.dart b/mobile/lib/providers/image/cache/image_loader.dart deleted file mode 100644 index 50530f7cdfd3e..0000000000000 --- a/mobile/lib/providers/image/cache/image_loader.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'dart:async'; -import 'dart:ui' as ui; - -import 'package:flutter/material.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart'; -import 'package:immich_mobile/services/api.service.dart'; - -/// Loads the codec from the URI and sends the events to the [chunkEvents] stream -/// -/// Credit to [flutter_cached_network_image](https://github.com/Baseflow/flutter_cached_network_image/blob/develop/cached_network_image/lib/src/image_provider/_image_loader.dart) -/// for this wonderful implementation of their image loader -class ImageLoader { - static Future loadImageFromCache( - String uri, { - required CacheManager cache, - required ImageDecoderCallback decode, - StreamController? chunkEvents, - }) async { - final headers = ApiService.getRequestHeaders(); - - final stream = cache.getFileStream(uri, withProgress: chunkEvents != null, headers: headers); - - await for (final result in stream) { - if (result is DownloadProgress) { - // We are downloading the file, so update the [chunkEvents] - chunkEvents?.add( - ImageChunkEvent(cumulativeBytesLoaded: result.downloaded, expectedTotalBytes: result.totalSize), - ); - } else if (result is FileInfo) { - // We have the file - final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path); - return decode(buffer); - } - } - - // If we get here, the image failed to load from the cache stream - throw const ImageLoadingException('Could not load image from stream'); - } -} diff --git a/mobile/lib/providers/image/cache/remote_image_cache_manager.dart b/mobile/lib/providers/image/cache/remote_image_cache_manager.dart deleted file mode 100644 index d3de4b80c9e22..0000000000000 --- a/mobile/lib/providers/image/cache/remote_image_cache_manager.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; - -class RemoteImageCacheManager extends CacheManager { - static const key = 'remoteImageCacheKey'; - static final RemoteImageCacheManager _instance = RemoteImageCacheManager._(); - static final _config = Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30)); - - factory RemoteImageCacheManager() { - return _instance; - } - - RemoteImageCacheManager._() : super(_config); -} - -class RemoteThumbnailCacheManager extends CacheManager { - static const key = 'remoteThumbnailCacheKey'; - static final RemoteThumbnailCacheManager _instance = RemoteThumbnailCacheManager._(); - static final _config = Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30)); - - factory RemoteThumbnailCacheManager() { - return _instance; - } - - RemoteThumbnailCacheManager._() : super(_config); -} diff --git a/mobile/lib/providers/image/cache/thumbnail_image_cache_manager.dart b/mobile/lib/providers/image/cache/thumbnail_image_cache_manager.dart deleted file mode 100644 index bfea36eef6ea5..0000000000000 --- a/mobile/lib/providers/image/cache/thumbnail_image_cache_manager.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; - -/// The cache manager for thumbnail images [ImmichRemoteThumbnailProvider] -class ThumbnailImageCacheManager extends CacheManager { - static const key = 'thumbnailImageCacheKey'; - static final ThumbnailImageCacheManager _instance = ThumbnailImageCacheManager._(); - - factory ThumbnailImageCacheManager() { - return _instance; - } - - ThumbnailImageCacheManager._() : super(Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30))); -} diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index cd75af6354a78..bad0d986d079a 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:background_downloader/background_downloader.dart'; -import 'package:cancellation_token_http/http.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -455,7 +454,7 @@ class ActionNotifier extends Notifier { final assetsToUpload = assets ?? _getAssets(source).whereType().toList(); final progressNotifier = ref.read(assetUploadProgressProvider.notifier); - final cancelToken = CancellationToken(); + final cancelToken = Completer(); ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken; // Initialize progress for all assets @@ -466,7 +465,7 @@ class ActionNotifier extends Notifier { try { await _foregroundUploadService.uploadManual( assetsToUpload, - cancelToken, + cancelToken: cancelToken, callbacks: UploadCallbacks( onProgress: (localAssetId, filename, bytes, totalBytes) { final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index f9473ce4400e5..09f699ba7f07c 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -1,18 +1,17 @@ import 'dart:async'; -import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/utils/debug_print.dart'; @@ -99,11 +98,6 @@ class WebsocketNotifier extends StateNotifier { if (authenticationState.isAuthenticated) { try { final endpoint = Uri.parse(Store.get(StoreKey.serverEndpoint)); - final headers = ApiService.getRequestHeaders(); - if (endpoint.userInfo.isNotEmpty) { - headers["Authorization"] = "Basic ${base64.encode(utf8.encode(endpoint.userInfo))}"; - } - dPrint(() => "Attempting to connect to websocket"); // Configure socket transports must be specified Socket socket = io( @@ -111,11 +105,11 @@ class WebsocketNotifier extends StateNotifier { OptionBuilder() .setPath("${endpoint.path}/socket.io") .setTransports(['websocket']) + .setWebSocketConnector(NetworkRepository.createWebSocket) .enableReconnection() .enableForceNew() .enableForceNewConnection() .enableAutoConnect() - .setExtraHeaders(headers) .build(), ); @@ -160,11 +154,8 @@ class WebsocketNotifier extends StateNotifier { _batchedAssetUploadReady.clear(); - var socket = state.socket?.disconnect(); - - if (socket?.disconnected == true) { - state = WebsocketState(isConnected: false, socket: null, pendingChanges: state.pendingChanges); - } + state.socket?.dispose(); + state = WebsocketState(isConnected: false, socket: null, pendingChanges: state.pendingChanges); } void stopListenToEvent(String eventName) { diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index aff84683c3c5e..98c6202e193f9 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -3,21 +3,15 @@ import 'dart:convert'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; -import 'package:cancellation_token_http/http.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:logging/logging.dart'; +import 'package:http/http.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -class UploadTaskWithFile { - final File file; - final UploadTask task; - - const UploadTaskWithFile({required this.file, required this.task}); -} - final uploadRepositoryProvider = Provider((ref) => UploadRepository()); class UploadRepository { @@ -97,26 +91,27 @@ class UploadRepository { Future uploadFile({ required File file, required String originalFileName, - required Map headers, required Map fields, - required Client httpClient, - required CancellationToken cancelToken, - required void Function(int bytes, int totalBytes) onProgress, + required Completer? cancelToken, + void Function(int bytes, int totalBytes)? onProgress, required String logContext, }) async { final String savedEndpoint = Store.get(StoreKey.serverEndpoint); + final baseRequest = ProgressMultipartRequest( + 'POST', + Uri.parse('$savedEndpoint/assets'), + abortTrigger: cancelToken?.future, + onProgress: onProgress, + ); try { final fileStream = file.openRead(); final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName); - final baseRequest = _CustomMultipartRequest('POST', Uri.parse('$savedEndpoint/assets'), onProgress: onProgress); - - baseRequest.headers.addAll(headers); baseRequest.fields.addAll(fields); baseRequest.files.add(assetRawUploadData); - final response = await httpClient.send(baseRequest, cancellationToken: cancelToken); + final response = await NetworkRepository.client.send(baseRequest); final responseBodyString = await response.stream.bytesToString(); if (![200, 201].contains(response.statusCode)) { @@ -145,7 +140,7 @@ class UploadRepository { } catch (e) { return UploadResult.error(errorMessage: 'Failed to parse server response'); } - } on CancelledException { + } on RequestAbortedException { logger.warning("Upload $logContext was cancelled"); return UploadResult.cancelled(); } catch (error, stackTrace) { @@ -155,6 +150,34 @@ class UploadRepository { } } +class ProgressMultipartRequest extends MultipartRequest with Abortable { + ProgressMultipartRequest(super.method, super.url, {this.abortTrigger, this.onProgress}); + + @override + final Future? abortTrigger; + + final void Function(int bytes, int totalBytes)? onProgress; + + @override + ByteStream finalize() { + final byteStream = super.finalize(); + if (onProgress == null) return byteStream; + + final total = contentLength; + var bytes = 0; + final stream = byteStream.transform( + StreamTransformer.fromHandlers( + handleData: (List data, EventSink> sink) { + bytes += data.length; + onProgress!(bytes, total); + sink.add(data); + }, + ), + ); + return ByteStream(stream); + } +} + class UploadResult { final bool isSuccess; final bool isCancelled; @@ -182,26 +205,3 @@ class UploadResult { return const UploadResult(isSuccess: false, isCancelled: true); } } - -class _CustomMultipartRequest extends MultipartRequest { - _CustomMultipartRequest(super.method, super.url, {required this.onProgress}); - - final void Function(int bytes, int totalBytes) onProgress; - - @override - ByteStream finalize() { - final byteStream = super.finalize(); - final total = contentLength; - var bytes = 0; - - final t = StreamTransformer.fromHandlers( - handleData: (List data, EventSink> sink) { - bytes += data.length; - onProgress.call(bytes, total); - sink.add(data); - }, - ); - final stream = byteStream.transform(t); - return ByteStream(stream); - } -} diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index bafe780647735..566ec7aa31884 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -3,12 +3,11 @@ import 'dart:convert'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; -import 'package:http/http.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/url_helper.dart'; -import 'package:immich_mobile/utils/user_agent.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -49,9 +48,14 @@ class ApiService implements Authentication { String? _accessToken; final _log = Logger("ApiService"); + Future updateHeaders() async { + await NetworkRepository.setHeaders(getRequestHeaders(), getServerUrls()); + _apiClient.client = NetworkRepository.client; + } + setEndpoint(String endpoint) { _apiClient = ApiClient(basePath: endpoint, authentication: this); - _setUserAgentHeader(); + _apiClient.client = NetworkRepository.client; if (_accessToken != null) { setAccessToken(_accessToken!); } @@ -78,11 +82,6 @@ class ApiService implements Authentication { tagsApi = TagsApi(_apiClient); } - Future _setUserAgentHeader() async { - final userAgent = await getUserAgentString(); - _apiClient.addDefaultHeader('User-Agent', userAgent); - } - Future resolveAndSetEndpoint(String serverUrl) async { final endpoint = await resolveEndpoint(serverUrl); setEndpoint(endpoint); @@ -136,14 +135,9 @@ class ApiService implements Authentication { } Future _getWellKnownEndpoint(String baseUrl) async { - final Client client = Client(); - try { - var headers = {"Accept": "application/json"}; - headers.addAll(getRequestHeaders()); - - final res = await client - .get(Uri.parse("$baseUrl/.well-known/immich"), headers: headers) + final res = await NetworkRepository.client + .get(Uri.parse("$baseUrl/.well-known/immich")) .timeout(const Duration(seconds: 5)); if (res.statusCode == 200) { @@ -185,6 +179,31 @@ class ApiService implements Authentication { } } + static List getServerUrls() { + final urls = []; + final serverEndpoint = Store.tryGet(StoreKey.serverEndpoint); + if (serverEndpoint != null && serverEndpoint.isNotEmpty) { + urls.add(serverEndpoint); + } + final serverUrl = Store.tryGet(StoreKey.serverUrl); + if (serverUrl != null && serverUrl.isNotEmpty) { + urls.add(serverUrl); + } + final localEndpoint = Store.tryGet(StoreKey.localEndpoint); + if (localEndpoint != null && localEndpoint.isNotEmpty) { + urls.add(localEndpoint); + } + final externalJson = Store.tryGet(StoreKey.externalEndpointList); + if (externalJson != null) { + final List list = jsonDecode(externalJson); + for (final entry in list) { + final url = entry['url'] as String?; + if (url != null && url.isNotEmpty) urls.add(url); + } + } + return urls; + } + static Map getRequestHeaders() { var accessToken = Store.get(StoreKey.accessToken, ""); var customHeadersStr = Store.get(StoreKey.customHeaders, ""); @@ -207,10 +226,7 @@ class ApiService implements Authentication { @override Future applyToParams(List queryParams, Map headerParams) { - return Future(() { - var headers = ApiService.getRequestHeaders(); - headerParams.addAll(headers); - }); + return Future.value(); } ApiClient get apiClient => _apiClient; diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index 3173f499570c4..c5f3fa6a4a9a6 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -1,10 +1,10 @@ import 'dart:async'; -import 'dart:io'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/login_response.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; @@ -64,27 +64,16 @@ class AuthService { } Future validateAuxilaryServerUrl(String url) async { - final httpclient = HttpClient(); bool isValid = false; try { final uri = Uri.parse('$url/users/me'); - final request = await httpclient.getUrl(uri); - - // add auth token + any configured custom headers - final customHeaders = ApiService.getRequestHeaders(); - customHeaders.forEach((key, value) { - request.headers.add(key, value); - }); - - final response = await request.close(); + final response = await NetworkRepository.client.get(uri); if (response.statusCode == 200) { isValid = true; } } catch (error) { _log.severe("Error validating auxiliary endpoint", error); - } finally { - httpclient.close(); } return isValid; diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index b69aa530144a1..d022d9a5cf748 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -4,7 +4,6 @@ import 'dart:io'; import 'dart:isolate'; import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities; -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; @@ -30,7 +29,6 @@ import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:path_provider_foundation/path_provider_foundation.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; @@ -43,7 +41,7 @@ class BackgroundService { static const MethodChannel _backgroundChannel = MethodChannel('immich/backgroundChannel'); static const notifyInterval = Duration(milliseconds: 400); bool _isBackgroundInitialized = false; - CancellationToken? _cancellationToken; + Completer? _cancellationToken; bool _canceledBySystem = false; int _wantsLockTime = 0; bool _hasLock = false; @@ -321,7 +319,8 @@ class BackgroundService { } case "systemStop": _canceledBySystem = true; - _cancellationToken?.cancel(); + _cancellationToken?.complete(); + _cancellationToken = null; return true; default: dPrint(() => "Unknown method ${call.method}"); @@ -341,7 +340,6 @@ class BackgroundService { ], ); - HttpSSLOptions.apply(); await ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken)); await ref.read(authServiceProvider).setOpenApiServiceEndpoint(); dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}"); @@ -441,7 +439,8 @@ class BackgroundService { ), ); - _cancellationToken = CancellationToken(); + _cancellationToken?.complete(); + _cancellationToken = Completer(); final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; final bool ok = await backupService.backupAsset( @@ -455,7 +454,7 @@ class BackgroundService { isBackground: true, ); - if (!ok && !_cancellationToken!.isCancelled) { + if (!ok && !_cancellationToken!.isCompleted) { unawaited( _showErrorNotification( title: "backup_background_service_error_title".tr(), @@ -467,7 +466,7 @@ class BackgroundService { return ok; } - void _onAssetUploaded({bool shouldNotify = false}) async { + void _onAssetUploaded({bool shouldNotify = false}) { if (!shouldNotify) { return; } diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 539fd1fbd9346..9b6a26be0378f 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -2,14 +2,16 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:cancellation_token_http/http.dart' as http; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; +import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; @@ -43,7 +45,6 @@ final backupServiceProvider = Provider( ); class BackupService { - final httpClient = http.Client(); final ApiService _apiService; final Logger _log = Logger("BackupService"); final AppSettingsService _appSetting; @@ -233,7 +234,7 @@ class BackupService { Future backupAsset( Iterable assets, - http.CancellationToken cancelToken, { + Completer cancelToken, { bool isBackground = false, PMProgressHandler? pmProgressHandler, required void Function(SuccessUploadAsset result) onSuccess, @@ -306,20 +307,20 @@ class BackupService { } final fileStream = file.openRead(); - final assetRawUploadData = http.MultipartFile( + final assetRawUploadData = MultipartFile( "assetData", fileStream, file.lengthSync(), filename: originalFileName, ); - final baseRequest = MultipartRequest( + final baseRequest = ProgressMultipartRequest( 'POST', Uri.parse('$savedEndpoint/assets'), + abortTrigger: cancelToken.future, onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)), ); - baseRequest.headers.addAll(ApiService.getRequestHeaders()); baseRequest.fields['deviceAssetId'] = asset.localId!; baseRequest.fields['deviceId'] = deviceId; baseRequest.fields['fileCreatedAt'] = asset.fileCreatedAt.toUtc().toIso8601String(); @@ -348,7 +349,7 @@ class BackupService { baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId; } - final response = await httpClient.send(baseRequest, cancellationToken: cancelToken); + final response = await NetworkRepository.client.send(baseRequest); final responseBody = jsonDecode(await response.stream.bytesToString()); @@ -398,7 +399,7 @@ class BackupService { await _albumService.syncUploadAlbums(candidate.albumNames, [responseBody['id'] as String]); } } - } on http.CancelledException { + } on RequestAbortedException { dPrint(() => "Backup was cancelled by the user"); anyErrors = true; break; @@ -429,26 +430,26 @@ class BackupService { String originalFileName, File? livePhotoVideoFile, MultipartRequest baseRequest, - http.CancellationToken cancelToken, + Completer cancelToken, ) async { if (livePhotoVideoFile == null) { return null; } final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoVideoFile.path)); final fileStream = livePhotoVideoFile.openRead(); - final livePhotoRawUploadData = http.MultipartFile( + final livePhotoRawUploadData = MultipartFile( "assetData", fileStream, livePhotoVideoFile.lengthSync(), filename: livePhotoTitle, ); - final livePhotoReq = MultipartRequest(baseRequest.method, baseRequest.url, onProgress: baseRequest.onProgress) + final livePhotoReq = ProgressMultipartRequest(baseRequest.method, baseRequest.url, abortTrigger: cancelToken.future) ..headers.addAll(baseRequest.headers) ..fields.addAll(baseRequest.fields); livePhotoReq.files.add(livePhotoRawUploadData); - var response = await httpClient.send(livePhotoReq, cancellationToken: cancelToken); + var response = await NetworkRepository.client.send(livePhotoReq); var responseBody = jsonDecode(await response.stream.bytesToString()); @@ -470,31 +471,3 @@ class BackupService { AssetType.other => "OTHER", }; } - -class MultipartRequest extends http.MultipartRequest { - /// Creates a new [MultipartRequest]. - MultipartRequest(super.method, super.url, {required this.onProgress}); - - final void Function(int bytes, int totalBytes) onProgress; - - /// Freezes all mutable fields and returns a - /// single-subscription [http.ByteStream] - /// that will emit the request body. - @override - http.ByteStream finalize() { - final byteStream = super.finalize(); - - final total = contentLength; - var bytes = 0; - - final t = StreamTransformer.fromHandlers( - handleData: (List data, EventSink> sink) { - bytes += data.length; - onProgress.call(bytes, total); - sink.add(data); - }, - ); - final stream = byteStream.transform(t); - return http.ByteStream(stream); - } -} diff --git a/mobile/lib/services/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart index cd28942bd2564..ce02c9c56b789 100644 --- a/mobile/lib/services/foreground_upload.service.dart +++ b/mobile/lib/services/foreground_upload.service.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:cancellation_token_http/http.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -19,7 +18,6 @@ import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; @@ -82,7 +80,7 @@ class ForegroundUploadService { /// Bulk upload of backup candidates from selected albums Future uploadCandidates( String userId, - CancellationToken cancelToken, { + Completer cancelToken, { UploadCallbacks callbacks = const UploadCallbacks(), bool useSequentialUpload = false, }) async { @@ -105,7 +103,7 @@ class ForegroundUploadService { final requireWifi = _shouldRequireWiFi(asset); return requireWifi && !hasWifi; }, - processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks), + processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks), ); } } @@ -113,37 +111,32 @@ class ForegroundUploadService { /// Sequential upload - used for background isolate where concurrent HTTP clients may cause issues Future _uploadSequentially({ required List items, - required CancellationToken cancelToken, + required Completer cancelToken, required bool hasWifi, required UploadCallbacks callbacks, }) async { - final httpClient = Client(); await _storageRepository.clearCache(); shouldAbortUpload = false; - try { - for (final asset in items) { - if (shouldAbortUpload || cancelToken.isCancelled) { - break; - } - - final requireWifi = _shouldRequireWiFi(asset); - if (requireWifi && !hasWifi) { - _logger.warning('Skipping upload for ${asset.id} because it requires WiFi'); - continue; - } + for (final asset in items) { + if (shouldAbortUpload || cancelToken.isCompleted) { + break; + } - await _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks); + final requireWifi = _shouldRequireWiFi(asset); + if (requireWifi && !hasWifi) { + _logger.warning('Skipping upload for ${asset.id} because it requires WiFi'); + continue; } - } finally { - httpClient.close(); + + await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks); } } /// Manually upload picked local assets Future uploadManual( - List localAssets, - CancellationToken cancelToken, { + List localAssets, { + Completer? cancelToken, UploadCallbacks callbacks = const UploadCallbacks(), }) async { if (localAssets.isEmpty) { @@ -153,14 +146,14 @@ class ForegroundUploadService { await _executeWithWorkerPool( items: localAssets, cancelToken: cancelToken, - processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks), + processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks), ); } /// Upload files from shared intent Future uploadShareIntent( List files, { - CancellationToken? cancelToken, + Completer? cancelToken, void Function(String fileId, int bytes, int totalBytes)? onProgress, void Function(String fileId)? onSuccess, void Function(String fileId, String errorMessage)? onError, @@ -168,20 +161,16 @@ class ForegroundUploadService { if (files.isEmpty) { return; } - - final effectiveCancelToken = cancelToken ?? CancellationToken(); - await _executeWithWorkerPool( items: files, - cancelToken: effectiveCancelToken, - processItem: (file, httpClient) async { + cancelToken: cancelToken, + processItem: (file) async { final fileId = p.hash(file.path).toString(); final result = await _uploadSingleFile( file, deviceAssetId: fileId, - httpClient: httpClient, - cancelToken: effectiveCancelToken, + cancelToken: cancelToken, onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes), ); @@ -207,58 +196,49 @@ class ForegroundUploadService { /// [concurrentWorkers] - Number of concurrent workers (default: 3) Future _executeWithWorkerPool({ required List items, - required CancellationToken cancelToken, - required Future Function(T item, Client httpClient) processItem, + required Completer? cancelToken, + required Future Function(T item) processItem, bool Function(T item)? shouldSkip, int concurrentWorkers = 3, }) async { - final httpClients = List.generate(concurrentWorkers, (_) => Client()); - await _storageRepository.clearCache(); shouldAbortUpload = false; - try { - int currentIndex = 0; - - Future worker(Client httpClient) async { - while (true) { - if (shouldAbortUpload || cancelToken.isCancelled) { - break; - } + int currentIndex = 0; - final index = currentIndex; - if (index >= items.length) { - break; - } - currentIndex++; + Future worker() async { + while (true) { + if (shouldAbortUpload || (cancelToken != null && cancelToken.isCompleted)) { + break; + } - final item = items[index]; + final index = currentIndex; + if (index >= items.length) { + break; + } + currentIndex++; - if (shouldSkip?.call(item) ?? false) { - continue; - } + final item = items[index]; - await processItem(item, httpClient); + if (shouldSkip?.call(item) ?? false) { + continue; } - } - final workerFutures = >[]; - for (int i = 0; i < concurrentWorkers; i++) { - workerFutures.add(worker(httpClients[i])); + await processItem(item); } + } - await Future.wait(workerFutures); - } finally { - for (final client in httpClients) { - client.close(); - } + final workerFutures = >[]; + for (int i = 0; i < concurrentWorkers; i++) { + workerFutures.add(worker()); } + + await Future.wait(workerFutures); } Future _uploadSingleAsset( LocalAsset asset, - Client httpClient, - CancellationToken cancelToken, { + Completer? cancelToken, { required UploadCallbacks callbacks, }) async { File? file; @@ -343,7 +323,6 @@ class ForegroundUploadService { final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; final deviceId = Store.get(StoreKey.deviceId); - final headers = ApiService.getRequestHeaders(); final fields = { 'deviceAssetId': asset.localId!, 'deviceId': deviceId, @@ -358,15 +337,15 @@ class ForegroundUploadService { if (entity.isLivePhoto && livePhotoFile != null) { final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path)); + final onProgress = callbacks.onProgress; final livePhotoResult = await _uploadRepository.uploadFile( file: livePhotoFile, originalFileName: livePhotoTitle, - headers: headers, fields: fields, - httpClient: httpClient, cancelToken: cancelToken, - onProgress: (bytes, totalBytes) => - callbacks.onProgress?.call(asset.localId!, livePhotoTitle, bytes, totalBytes), + onProgress: onProgress != null + ? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes) + : null, logContext: 'livePhotoVideo[${asset.localId}]', ); @@ -395,15 +374,15 @@ class ForegroundUploadService { ]); } + final onProgress = callbacks.onProgress; final result = await _uploadRepository.uploadFile( file: file, originalFileName: originalFileName, - headers: headers, fields: fields, - httpClient: httpClient, cancelToken: cancelToken, - onProgress: (bytes, totalBytes) => - callbacks.onProgress?.call(asset.localId!, originalFileName, bytes, totalBytes), + onProgress: onProgress != null + ? (bytes, totalBytes) => onProgress(asset.localId!, originalFileName, bytes, totalBytes) + : null, logContext: 'asset[${asset.localId}]', ); @@ -442,8 +421,7 @@ class ForegroundUploadService { Future _uploadSingleFile( File file, { required String deviceAssetId, - required Client httpClient, - required CancellationToken cancelToken, + required Completer? cancelToken, void Function(int bytes, int totalBytes)? onProgress, }) async { try { @@ -452,12 +430,9 @@ class ForegroundUploadService { final fileModifiedAt = stats.modified; final filename = p.basename(file.path); - final headers = ApiService.getRequestHeaders(); - final deviceId = Store.get(StoreKey.deviceId); - final fields = { 'deviceAssetId': deviceAssetId, - 'deviceId': deviceId, + 'deviceId': Store.get(StoreKey.deviceId), 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), 'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(), 'isFavorite': 'false', @@ -467,11 +442,9 @@ class ForegroundUploadService { return await _uploadRepository.uploadFile( file: file, originalFileName: filename, - headers: headers, fields: fields, - httpClient: httpClient, cancelToken: cancelToken, - onProgress: onProgress ?? (_, __) {}, + onProgress: onProgress, logContext: 'shareIntent[$deviceAssetId]', ); } catch (e) { diff --git a/mobile/lib/utils/http_ssl_cert_override.dart b/mobile/lib/utils/http_ssl_cert_override.dart deleted file mode 100644 index a4c97a532f264..0000000000000 --- a/mobile/lib/utils/http_ssl_cert_override.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'dart:io'; - -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:logging/logging.dart'; - -class HttpSSLCertOverride extends HttpOverrides { - static final Logger _log = Logger("HttpSSLCertOverride"); - final bool _allowSelfSignedSSLCert; - final String? _serverHost; - final SSLClientCertStoreVal? _clientCert; - late final SecurityContext? _ctxWithCert; - - HttpSSLCertOverride(this._allowSelfSignedSSLCert, this._serverHost, this._clientCert) { - if (_clientCert != null) { - _ctxWithCert = SecurityContext(withTrustedRoots: true); - if (_ctxWithCert != null) { - setClientCert(_ctxWithCert, _clientCert); - } else { - _log.severe("Failed to create security context with client cert!"); - } - } else { - _ctxWithCert = null; - } - } - - static bool setClientCert(SecurityContext ctx, SSLClientCertStoreVal cert) { - try { - _log.info("Setting client certificate"); - ctx.usePrivateKeyBytes(cert.data, password: cert.password); - ctx.useCertificateChainBytes(cert.data, password: cert.password); - } catch (e) { - _log.severe("Failed to set SSL client cert: $e"); - return false; - } - return true; - } - - @override - HttpClient createHttpClient(SecurityContext? context) { - if (context != null) { - if (_clientCert != null) { - setClientCert(context, _clientCert); - } - } else { - context = _ctxWithCert; - } - - return super.createHttpClient(context) - ..badCertificateCallback = (X509Certificate cert, String host, int port) { - if (_allowSelfSignedSSLCert) { - // Conduct server host checks if user is logged in to avoid making - // insecure SSL connections to services that are not the immich server. - if (_serverHost == null || _serverHost.contains(host)) { - return true; - } - } - _log.severe("Invalid SSL certificate for $host:$port"); - return false; - }; - } -} diff --git a/mobile/lib/utils/http_ssl_options.dart b/mobile/lib/utils/http_ssl_options.dart deleted file mode 100644 index a93387c9dbae3..0000000000000 --- a/mobile/lib/utils/http_ssl_options.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'dart:io'; - -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; - -class HttpSSLOptions { - static void apply() { - AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert; - bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey, setting.defaultValue); - return _apply(allowSelfSignedSSLCert); - } - - static void applyFromSettings(bool newValue) => _apply(newValue); - - static void _apply(bool allowSelfSignedSSLCert) { - String? serverHost; - if (allowSelfSignedSSLCert && Store.tryGet(StoreKey.currentUser) != null) { - serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host; - } - - SSLClientCertStoreVal? clientCert = SSLClientCertStoreVal.load(); - - HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert); - } -} diff --git a/mobile/lib/utils/isolate.dart b/mobile/lib/utils/isolate.dart index 7ac120acb48fe..c8224b9c552f2 100644 --- a/mobile/lib/utils/isolate.dart +++ b/mobile/lib/utils/isolate.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/wm_executor.dart'; import 'package:logging/logging.dart'; import 'package:worker_manager/worker_manager.dart'; @@ -54,7 +53,6 @@ Cancelable runInIsolateGentle({ Logger log = Logger("IsolateLogger"); try { - HttpSSLOptions.apply(); result = await computation(ref); } on CanceledError { log.warning("Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}"); diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index e86d3132942ec..d5905a246c180 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -10,12 +10,10 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart'; import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart'; import 'package:immich_mobile/widgets/settings/local_storage_settings.dart'; @@ -31,15 +29,12 @@ class AdvancedSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - bool isLoggedIn = ref.read(currentUserProvider) != null; - final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting); final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid); final isManageMediaSupported = useState(false); final manageMediaAndroidPermission = useState(false); final levelId = useAppSettingsState(AppSettingsEnum.logLevel); final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); - final allowSelfSignedSSLCert = useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert); final useAlternatePMFilter = useAppSettingsState(AppSettingsEnum.photoManagerCustomFilter); final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled); @@ -120,15 +115,8 @@ class AdvancedSettings extends HookConsumerWidget { subtitle: "advanced_settings_prefer_remote_subtitle".tr(), ), if (!Store.isBetaTimelineEnabled) const LocalStorageSettings(), - SettingsSwitchListTile( - enabled: !isLoggedIn, - valueNotifier: allowSelfSignedSSLCert, - title: "advanced_settings_self_signed_ssl_title".tr(), - subtitle: "advanced_settings_self_signed_ssl_subtitle".tr(), - onChanged: HttpSSLOptions.applyFromSettings, - ), const CustomProxyHeaderSettings(), - SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null), + const SslClientCertSettings(), if (!Store.isBetaTimelineEnabled) SettingsSwitchListTile( valueNotifier: useAlternatePMFilter, diff --git a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart index fa210ee72043e..77ad7ee179b21 100644 --- a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart +++ b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart @@ -1,18 +1,16 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/platform/network_api.g.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:logging/logging.dart'; class SslClientCertSettings extends StatefulWidget { - const SslClientCertSettings({super.key, required this.isLoggedIn}); - - final bool isLoggedIn; + const SslClientCertSettings({super.key}); @override State createState() => _SslClientCertSettingsState(); @@ -21,9 +19,24 @@ class SslClientCertSettings extends StatefulWidget { class _SslClientCertSettingsState extends State { final _log = Logger("SslClientCertSettings"); - bool isCertExist; + bool isCertExist = false; + + @override + void initState() { + super.initState(); + unawaited(_checkCertificate()); + } - _SslClientCertSettingsState() : isCertExist = SSLClientCertStoreVal.load() != null; + Future _checkCertificate() async { + try { + final exists = await networkApi.hasCertificate(); + if (mounted && exists != isCertExist) { + setState(() => isCertExist = exists); + } + } catch (e) { + _log.warning("Failed to check certificate existence", e); + } + } @override Widget build(BuildContext context) { @@ -45,11 +58,8 @@ class _SslClientCertSettingsState extends State { mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.center, children: [ - ElevatedButton(onPressed: widget.isLoggedIn ? null : importCert, child: Text("client_cert_import".tr())), - ElevatedButton( - onPressed: widget.isLoggedIn || !isCertExist ? null : removeCert, - child: Text("remove".tr()), - ), + ElevatedButton(onPressed: importCert, child: Text("client_cert_import".tr())), + ElevatedButton(onPressed: !isCertExist ? null : removeCert, child: Text("remove".tr())), ], ), ], @@ -74,9 +84,7 @@ class _SslClientCertSettingsState extends State { cancel: "cancel".tr(), confirm: "confirm".tr(), ); - final cert = await networkApi.selectCertificate(styling); - await SSLClientCertStoreVal(cert.data, cert.password).save(); - HttpSSLOptions.apply(); + await networkApi.selectCertificate(styling); setState(() => isCertExist = true); showMessage("client_cert_import_success_msg".tr()); } catch (e) { @@ -91,8 +99,6 @@ class _SslClientCertSettingsState extends State { Future removeCert() async { try { await networkApi.removeCertificate(); - await SSLClientCertStoreVal.delete(); - HttpSSLOptions.apply(); setState(() => isCertExist = false); showMessage("client_cert_remove_msg".tr()); } catch (e) { diff --git a/mobile/pigeon/network_api.dart b/mobile/pigeon/network_api.dart index 68d2f7d8fc864..3ea29052d9b14 100644 --- a/mobile/pigeon/network_api.dart +++ b/mobile/pigeon/network_api.dart @@ -34,8 +34,14 @@ abstract class NetworkApi { void addCertificate(ClientCertData clientData); @async - ClientCertData selectCertificate(ClientCertPrompt promptText); + void selectCertificate(ClientCertPrompt promptText); @async void removeCertificate(); + + bool hasCertificate(); + + int getClientPointer(); + + void setRequestHeaders(Map headers, List serverUrls); } diff --git a/mobile/pigeon/remote_image_api.dart b/mobile/pigeon/remote_image_api.dart index 333f65a22583c..7f0135acb8010 100644 --- a/mobile/pigeon/remote_image_api.dart +++ b/mobile/pigeon/remote_image_api.dart @@ -5,8 +5,7 @@ import 'package:pigeon/pigeon.dart'; dartOut: 'lib/platform/remote_image_api.g.dart', swiftOut: 'ios/Runner/Images/RemoteImages.g.swift', swiftOptions: SwiftOptions(includeErrorClass: false), - kotlinOut: - 'android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt', + kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt', kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images', includeErrorClass: false), dartOptions: DartOptions(), dartPackageName: 'immich_mobile', @@ -15,12 +14,7 @@ import 'package:pigeon/pigeon.dart'; @HostApi() abstract class RemoteImageApi { @async - Map? requestImage( - String url, { - required Map headers, - required int requestId, - required bool preferEncoded, - }); + Map? requestImage(String url, {required int requestId, required bool preferEncoded}); void cancelRequest(int requestId); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 28adfc2ab7828..de116abb7e572 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -201,22 +201,6 @@ packages: url: "https://pub.dev" source: hosted version: "8.9.5" - cancellation_token: - dependency: transitive - description: - name: cancellation_token - sha256: ad95acf9d4b2f3563e25dc937f63587e46a70ce534e910b65d10e115490f1027 - url: "https://pub.dev" - source: hosted - version: "2.0.1" - cancellation_token_http: - dependency: "direct main" - description: - name: cancellation_token_http - sha256: "0fff478fe5153700396b3472ddf93303c219f1cb8d8e779e65b014cb9c7f0213" - url: "https://pub.dev" - source: hosted - version: "2.1.0" cast: dependency: "direct main" description: @@ -313,14 +297,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" - cronet_http: - dependency: "direct main" - description: - name: cronet_http - sha256: "1fff7f26ac0c4cda97fe2a9aa082494baee4775f167c27ba45f6c8e88571e3ab" - url: "https://pub.dev" - source: hosted - version: "1.7.0" crop_image: dependency: "direct main" description: @@ -356,11 +332,12 @@ packages: cupertino_http: dependency: "direct main" description: - name: cupertino_http - sha256: "82cbec60c90bf785a047a9525688b6dacac444e177e1d5a5876963d3c50369e8" - url: "https://pub.dev" - source: hosted - version: "2.4.0" + path: "pkgs/cupertino_http" + ref: a0a933358517c6d01cff37fc2a2752ee2d744a3c + resolved-ref: a0a933358517c6d01cff37fc2a2752ee2d744a3c + url: "https://github.com/mertalev/http" + source: git + version: "3.0.0-wip" custom_lint: dependency: "direct dev" description: @@ -1241,8 +1218,8 @@ packages: dependency: "direct main" description: path: "." - ref: e132bc3 - resolved-ref: e132bc3ecc6a6d8fc2089d96f849c8a13129500e + ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2" + resolved-ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2" url: "https://github.com/immich-app/native_video_player" source: git version: "1.3.1" @@ -1286,6 +1263,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + ok_http: + dependency: "direct main" + description: + path: "pkgs/ok_http" + ref: "549c24b0a4d3881a9a44b70f4873450d43c1c4af" + resolved-ref: "549c24b0a4d3881a9a44b70f4873450d43c1c4af" + url: "https://github.com/mertalev/http" + source: git + version: "0.1.1-wip" openapi: dependency: "direct main" description: @@ -1741,19 +1727,20 @@ packages: socket_io_client: dependency: "direct main" description: - name: socket_io_client - sha256: ede469f3e4c55e8528b4e023bdedbc20832e8811ab9b61679d1ba3ed5f01f23b - url: "https://pub.dev" - source: hosted - version: "2.0.3+1" + path: "." + ref: e1d813a240b5d5b7e2f141b2b605c5429b7cd006 + resolved-ref: e1d813a240b5d5b7e2f141b2b605c5429b7cd006 + url: "https://github.com/mertalev/socket.io-client-dart" + source: git + version: "3.1.4" socket_io_common: dependency: transitive description: name: socket_io_common - sha256: "2ab92f8ff3ebbd4b353bf4a98bee45cc157e3255464b2f90f66e09c4472047eb" + sha256: "162fbaecbf4bf9a9372a62a341b3550b51dcef2f02f3e5830a297fd48203d45b" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "3.1.1" source_gen: dependency: transitive description: @@ -2115,21 +2102,21 @@ packages: source: hosted version: "1.1.1" web_socket: - dependency: transitive + dependency: "direct main" description: name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" url: "https://pub.dev" source: hosted - version: "0.1.6" + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" webdriver: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 0b54dfc53e9f9..3a075d67ff086 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -12,7 +12,6 @@ dependencies: async: ^2.13.0 auto_route: ^9.2.0 background_downloader: ^9.3.0 - cancellation_token_http: ^2.1.0 cast: ^2.1.0 collection: ^1.19.1 connectivity_plus: ^6.1.3 @@ -57,7 +56,7 @@ dependencies: native_video_player: git: url: https://github.com/immich-app/native_video_player - ref: 'e132bc3' + ref: '0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2' network_info_plus: ^6.1.3 octo_image: ^2.1.0 openapi: @@ -76,7 +75,6 @@ dependencies: share_handler: ^0.0.25 share_plus: ^10.1.4 sliver_tools: ^0.2.12 - socket_io_client: ^2.0.3+1 stream_transform: ^2.1.1 thumbhash: 0.1.0+1 timezone: ^0.9.4 @@ -84,8 +82,21 @@ dependencies: uuid: ^4.5.1 wakelock_plus: ^1.3.0 worker_manager: ^7.2.7 - cronet_http: ^1.7.0 - cupertino_http: ^2.4.0 + web_socket: ^1.0.1 + socket_io_client: + git: + url: https://github.com/mertalev/socket.io-client-dart + ref: 'e1d813a240b5d5b7e2f141b2b605c5429b7cd006' # https://github.com/rikulo/socket.io-client-dart/pull/435 + cupertino_http: + git: + url: https://github.com/mertalev/http + ref: 'a0a933358517c6d01cff37fc2a2752ee2d744a3c' # https://github.com/dart-lang/http/pull/1876 + path: pkgs/cupertino_http/ + ok_http: + git: + url: https://github.com/mertalev/http + ref: '549c24b0a4d3881a9a44b70f4873450d43c1c4af' # https://github.com/dart-lang/http/pull/1877 + path: pkgs/ok_http/ dev_dependencies: auto_route_generator: ^9.0.0 diff --git a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart index 62aae4c0da5c3..85eebacb1463b 100644 --- a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart +++ b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart @@ -54,13 +54,10 @@ void main() { when(() => mockApiService.apiClient).thenReturn(mockApiClient); when(() => mockApiService.syncApi).thenReturn(mockSyncApi); when(() => mockApiClient.basePath).thenReturn('http://demo.immich.app/api'); - when(() => mockApiService.applyToParams(any(), any())).thenAnswer((_) async => {}); - // Mock HTTP client behavior when(() => mockHttpClient.send(any())).thenAnswer((_) async => mockStreamedResponse); when(() => mockStreamedResponse.statusCode).thenReturn(200); when(() => mockStreamedResponse.stream).thenAnswer((_) => http.ByteStream(responseStreamController.stream)); - when(() => mockHttpClient.close()).thenAnswer((_) => {}); sut = SyncApiRepository(mockApiService); }); @@ -133,7 +130,6 @@ void main() { expect(onDataCallCount, 1); expect(abortWasCalledInCallback, isTrue); expect(receivedEventsBatch1.length, testBatchSize); - verify(() => mockHttpClient.close()).called(1); }); test('streamChanges does not process remaining lines in finally block if aborted', () async { @@ -181,7 +177,6 @@ void main() { expect(onDataCallCount, 1); expect(abortWasCalledInCallback, isTrue); - verify(() => mockHttpClient.close()).called(1); }); test('streamChanges processes remaining lines in finally block if not aborted', () async { @@ -240,7 +235,6 @@ void main() { expect(onDataCallCount, 2); expect(receivedEventsBatch1.length, testBatchSize); expect(receivedEventsBatch2.length, 1); - verify(() => mockHttpClient.close()).called(1); }); test('streamChanges handles stream error gracefully', () async { @@ -265,7 +259,6 @@ void main() { await expectLater(streamChangesFuture, throwsA(streamError)); expect(onDataCallCount, 0); - verify(() => mockHttpClient.close()).called(1); }); test('streamChanges throws ApiException on non-200 status code', () async { @@ -293,6 +286,5 @@ void main() { ); expect(onDataCallCount, 0); - verify(() => mockHttpClient.close()).called(1); }); }