feat(mobile): use shared native client (#25942)

* use shared client in dart

fix android

* websocket integration

platform-side headers

update comment

consistent platform check

tweak websocket handling

support streaming

* redundant logging

* fix proguard

* formatting

* handle onProgress

* support videos on ios

* inline return

* improved ios impl

* cleanup

* sync stopForegroundBackup

* voidify

* future already completed

* stream request on android

* outdated ios ws code

* use `choosePrivateKeyAlias`

* return result

* formatting

* update tests

* redundant check

* handle custom headers

* move completer outside of state

* persist auth

* dispose old socket

* use group id for cookies

* redundant headers

* cache global ref

* handle network switching

* handle basic auth

* apply custom headers immediately

* video player update

* fix

* persist url

* potential logout fix

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Mert
2026-03-05 12:04:45 -05:00
committed by GitHub
parent 35a521c6ec
commit a05c8c6087
57 changed files with 880 additions and 855 deletions
+1
View File
@@ -81,6 +81,7 @@ android {
release { release {
signingConfig signingConfigs.release signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
} }
} }
namespace 'app.alextran.immich' namespace 'app.alextran.immich'
+9 -1
View File
@@ -36,4 +36,12 @@
##---------------End: proguard configuration for Gson ---------- ##---------------End: proguard configuration for Gson ----------
# Keep all widget model classes and their fields for Gson # Keep all widget model classes and their fields for Gson
-keep class app.alextran.immich.widget.model.** { *; } -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 ----------
@@ -36,3 +36,17 @@ Java_app_alextran_immich_NativeBuffer_copy(
memcpy((void *) destAddress, (char *) src + offset, length); 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;
}
@@ -23,6 +23,9 @@ object NativeBuffer {
@JvmStatic @JvmStatic
external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int) external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int)
@JvmStatic
external fun createGlobalRef(obj: Any): Long
} }
class NativeByteBuffer(initialCapacity: Int) { class NativeByteBuffer(initialCapacity: Int) {
@@ -1,11 +1,18 @@
package app.alextran.immich.core package app.alextran.immich.core
import android.content.Context 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.BuildConfig
import app.alextran.immich.NativeBuffer
import okhttp3.Cache import okhttp3.Cache
import okhttp3.ConnectionPool import okhttp3.ConnectionPool
import okhttp3.Dispatcher import okhttp3.Dispatcher
import okhttp3.Headers
import okhttp3.Credentials
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.json.JSONObject
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.net.Socket import java.net.Socket
@@ -20,8 +27,12 @@ import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509KeyManager import javax.net.ssl.X509KeyManager
import javax.net.ssl.X509TrustManager import javax.net.ssl.X509TrustManager
const val CERT_ALIAS = "client_cert"
const val USER_AGENT = "Immich_Android_${BuildConfig.VERSION_NAME}" 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. * Manages a shared OkHttpClient with SSL configuration support.
@@ -36,22 +47,56 @@ object HttpClientManager {
private val clientChangedListeners = mutableListOf<() -> Unit>() private val clientChangedListeners = mutableListOf<() -> Unit>()
private lateinit var client: OkHttpClient private lateinit var client: OkHttpClient
private lateinit var appContext: Context
private lateinit var prefs: SharedPreferences
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } 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) { fun initialize(context: Context) {
if (initialized) return if (initialized) return
synchronized(this) { synchronized(this) {
if (initialized) return 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") val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
client = build(cacheDir) client = build(cacheDir)
initialized = true 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) { fun setKeyEntry(clientData: ByteArray, password: CharArray) {
synchronized(this) { synchronized(this) {
val wasMtls = isMtls val wasMtls = isMtls
@@ -63,7 +108,7 @@ object HttpClientManager {
val key = tmpKeyStore.getKey(tmpAlias, password) val key = tmpKeyStore.getKey(tmpAlias, password)
val chain = tmpKeyStore.getCertificateChain(tmpAlias) val chain = tmpKeyStore.getCertificateChain(tmpAlias)
if (wasMtls) { if (keyStore.containsAlias(CERT_ALIAS)) {
keyStore.deleteEntry(CERT_ALIAS) keyStore.deleteEntry(CERT_ALIAS)
} }
keyStore.setKeyEntry(CERT_ALIAS, key, null, chain) keyStore.setKeyEntry(CERT_ALIAS, key, null, chain)
@@ -75,24 +120,58 @@ object HttpClientManager {
fun deleteKeyEntry() { fun deleteKeyEntry() {
synchronized(this) { synchronized(this) {
if (!isMtls) { val wasMtls = isMtls
return
if (keyChainAlias != null) {
keyChainAlias = null
prefs.edit { remove(PREFS_CERT_ALIAS) }
} }
keyStore.deleteEntry(CERT_ALIAS) keyStore.deleteEntry(CERT_ALIAS)
clientChangedListeners.forEach { it() }
if (wasMtls) {
clientChangedListeners.forEach { it() }
}
} }
} }
private var clientGlobalRef: Long = 0L
@JvmStatic @JvmStatic
fun getClient(): OkHttpClient { fun getClient(): OkHttpClient {
return client return client
} }
fun getClientPointer(): Long {
if (clientGlobalRef == 0L) {
clientGlobalRef = NativeBuffer.createGlobalRef(client)
}
return clientGlobalRef
}
fun addClientChangedListener(listener: () -> Unit) { fun addClientChangedListener(listener: () -> Unit) {
synchronized(this) { clientChangedListeners.add(listener) } synchronized(this) { clientChangedListeners.add(listener) }
} }
fun setRequestHeaders(headerMap: Map<String, String>, serverUrls: List<String>) {
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 { private fun build(cacheDir: File): OkHttpClient {
val connectionPool = ConnectionPool( val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS, maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
@@ -109,8 +188,16 @@ object HttpClientManager {
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
return OkHttpClient.Builder() return OkHttpClient.Builder()
.addInterceptor { chain -> .addInterceptor {
chain.proceed(chain.request().newBuilder().header("User-Agent", USER_AGENT).build()) 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) .connectionPool(connectionPool)
.dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST }) .dispatcher(Dispatcher().apply { maxRequestsPerHost = MAX_REQUESTS_PER_HOST })
@@ -119,23 +206,39 @@ object HttpClientManager {
.build() .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 { private class DynamicKeyManager : X509KeyManager {
override fun getClientAliases(keyType: String, issuers: Array<Principal>?): Array<String>? = override fun getClientAliases(keyType: String, issuers: Array<Principal>?): Array<String>? {
if (isMtls) arrayOf(CERT_ALIAS) else null val alias = chooseClientAlias(arrayOf(keyType), issuers, null) ?: return null
return arrayOf(alias)
}
override fun chooseClientAlias( override fun chooseClientAlias(
keyTypes: Array<String>, keyTypes: Array<String>,
issuers: Array<Principal>?, issuers: Array<Principal>?,
socket: Socket? socket: Socket?
): String? = ): String? {
if (isMtls) CERT_ALIAS else null keyChainAlias?.let { return it }
if (keyStore.containsAlias(CERT_ALIAS)) return CERT_ALIAS
return null
}
override fun getCertificateChain(alias: String): Array<X509Certificate>? = override fun getCertificateChain(alias: String): Array<X509Certificate>? {
keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray() if (alias == keyChainAlias) {
return KeyChain.getCertificateChain(appContext, alias)
}
return keyStore.getCertificateChain(alias)?.map { it as X509Certificate }?.toTypedArray()
}
override fun getPrivateKey(alias: String): PrivateKey? = override fun getPrivateKey(alias: String): PrivateKey? {
keyStore.getKey(alias, null) as? PrivateKey if (alias == keyChainAlias) {
return KeyChain.getPrivateKey(appContext, alias)
}
return keyStore.getKey(alias, null) as? PrivateKey
}
override fun getServerAliases(keyType: String, issuers: Array<Principal>?): Array<String>? = override fun getServerAliases(keyType: String, issuers: Array<Principal>?): Array<String>? =
null null
@@ -180,8 +180,11 @@ private open class NetworkPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface NetworkApi { interface NetworkApi {
fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit)
fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<ClientCertData>) -> Unit) fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<Unit>) -> Unit)
fun removeCertificate(callback: (Result<Unit>) -> Unit) fun removeCertificate(callback: (Result<Unit>) -> Unit)
fun hasCertificate(): Boolean
fun getClientPointer(): Long
fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>)
companion object { companion object {
/** The codec used by NetworkApi. */ /** The codec used by NetworkApi. */
@@ -217,13 +220,12 @@ interface NetworkApi {
channel.setMessageHandler { message, reply -> channel.setMessageHandler { message, reply ->
val args = message as List<Any?> val args = message as List<Any?>
val promptTextArg = args[0] as ClientCertPrompt val promptTextArg = args[0] as ClientCertPrompt
api.selectCertificate(promptTextArg) { result: Result<ClientCertData> -> api.selectCertificate(promptTextArg) { result: Result<Unit> ->
val error = result.exceptionOrNull() val error = result.exceptionOrNull()
if (error != null) { if (error != null) {
reply.reply(NetworkPigeonUtils.wrapError(error)) reply.reply(NetworkPigeonUtils.wrapError(error))
} else { } else {
val data = result.getOrNull() reply.reply(NetworkPigeonUtils.wrapResult(null))
reply.reply(NetworkPigeonUtils.wrapResult(data))
} }
} }
} }
@@ -248,6 +250,55 @@ interface NetworkApi {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.hasCertificate())
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getClientPointer())
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val headersArg = args[0] as Map<String, String>
val serverUrlsArg = args[1] as List<String>
val wrapped: List<Any?> = try {
api.setRequestHeaders(headersArg, serverUrlsArg)
listOf(null)
} catch (exception: Throwable) {
NetworkPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
} }
} }
} }
@@ -2,20 +2,9 @@ package app.alextran.immich.core
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.net.Uri
import android.os.OperationCanceledException import android.os.OperationCanceledException
import android.text.InputType import android.security.KeyChain
import android.view.ContextThemeWrapper import app.alextran.immich.NativeBuffer
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@@ -24,7 +13,7 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
private var networkApi: NetworkApiImpl? = null private var networkApi: NetworkApiImpl? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
networkApi = NetworkApiImpl(binding.applicationContext) networkApi = NetworkApiImpl()
NetworkApi.setUp(binding.binaryMessenger, networkApi) NetworkApi.setUp(binding.binaryMessenger, networkApi)
} }
@@ -34,48 +23,24 @@ class NetworkApiPlugin : FlutterPlugin, ActivityAware {
} }
override fun onAttachedToActivity(binding: ActivityPluginBinding) { override fun onAttachedToActivity(binding: ActivityPluginBinding) {
networkApi?.onAttachedToActivity(binding) networkApi?.activity = binding.activity
} }
override fun onDetachedFromActivityForConfigChanges() { override fun onDetachedFromActivityForConfigChanges() {
networkApi?.onDetachedFromActivityForConfigChanges() networkApi?.activity = null
} }
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
networkApi?.onReattachedToActivityForConfigChanges(binding) networkApi?.activity = binding.activity
} }
override fun onDetachedFromActivity() { override fun onDetachedFromActivity() {
networkApi?.onDetachedFromActivity() networkApi?.activity = null
} }
} }
private class NetworkApiImpl(private val context: Context) : NetworkApi { private class NetworkApiImpl() : NetworkApi {
private var activity: Activity? = null var activity: Activity? = null
private var pendingCallback: ((Result<ClientCertData>) -> Unit)? = null
private var filePicker: ActivityResultLauncher<Array<String>>? = null
private var promptText: ClientCertPrompt? = null
fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
(binding.activity as? ComponentActivity)?.let { componentActivity ->
filePicker = componentActivity.registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { handlePickedFile(it) } ?: pendingCallback?.invoke(Result.failure(OperationCanceledException())) }
}
}
fun onDetachedFromActivityForConfigChanges() {
activity = null
}
fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
}
fun onDetachedFromActivity() {
activity = null
}
override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) { override fun addCertificate(clientData: ClientCertData, callback: (Result<Unit>) -> Unit) {
try { try {
@@ -86,11 +51,19 @@ private class NetworkApiImpl(private val context: Context) : NetworkApi {
} }
} }
override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<ClientCertData>) -> Unit) { override fun selectCertificate(promptText: ClientCertPrompt, callback: (Result<Unit>) -> Unit) {
val picker = filePicker ?: return callback(Result.failure(IllegalStateException("No activity"))) val currentActivity = activity
pendingCallback = callback ?: return callback(Result.failure(IllegalStateException("No activity")))
this.promptText = promptText
picker.launch(arrayOf("application/x-pkcs12", "application/x-pem-file")) 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>) -> Unit) { override fun removeCertificate(callback: (Result<Unit>) -> Unit) {
@@ -98,62 +71,15 @@ private class NetworkApiImpl(private val context: Context) : NetworkApi {
callback(Result.success(Unit)) callback(Result.success(Unit))
} }
private fun handlePickedFile(uri: Uri) { override fun hasCertificate(): Boolean {
val callback = pendingCallback ?: return return HttpClientManager.isMtls
pendingCallback = null
try {
val data = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: throw IllegalStateException("Could not read file")
val activity = activity ?: throw IllegalStateException("No activity")
promptForPassword(activity) { password ->
promptText = null
if (password == null) {
callback(Result.failure(OperationCanceledException()))
return@promptForPassword
}
try {
HttpClientManager.setKeyEntry(data, password.toCharArray())
callback(Result.success(ClientCertData(data, password)))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
} catch (e: Exception) {
callback(Result.failure(e))
}
} }
private fun promptForPassword(activity: Activity, callback: (String?) -> Unit) { override fun getClientPointer(): Long {
val themedContext = ContextThemeWrapper(activity, com.google.android.material.R.style.Theme_Material3_DayNight_Dialog) return HttpClientManager.getClientPointer()
val density = activity.resources.displayMetrics.density }
val horizontalPadding = (24 * density).toInt()
val textInputLayout = TextInputLayout(themedContext).apply { override fun setRequestHeaders(headers: Map<String, String>, serverUrls: List<String>) {
hint = "Password" HttpClientManager.setRequestHeaders(headers, serverUrls)
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
setMargins(horizontalPadding, 0, horizontalPadding, 0)
}
}
val editText = TextInputEditText(textInputLayout.context).apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
}
textInputLayout.addView(editText)
val container = FrameLayout(themedContext).apply { addView(textInputLayout) }
val text = promptText!!
MaterialAlertDialogBuilder(themedContext)
.setTitle(text.title)
.setMessage(text.message)
.setView(container)
.setPositiveButton(text.confirm) { _, _ -> callback(editText.text.toString()) }
.setNegativeButton(text.cancel) { _, _ -> callback(null) }
.setOnCancelListener { callback(null) }
.show()
} }
} }
@@ -47,7 +47,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface RemoteImageApi { interface RemoteImageApi {
fun requestImage(url: String, headers: Map<String, String>, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit) fun requestImage(url: String, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
fun cancelRequest(requestId: Long) fun cancelRequest(requestId: Long)
fun clearCache(callback: (Result<Long>) -> Unit) fun clearCache(callback: (Result<Long>) -> Unit)
@@ -66,10 +66,9 @@ interface RemoteImageApi {
channel.setMessageHandler { message, reply -> channel.setMessageHandler { message, reply ->
val args = message as List<Any?> val args = message as List<Any?>
val urlArg = args[0] as String val urlArg = args[0] as String
val headersArg = args[1] as Map<String, String> val requestIdArg = args[1] as Long
val requestIdArg = args[2] as Long val preferEncodedArg = args[2] as Boolean
val preferEncodedArg = args[3] as Boolean api.requestImage(urlArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
api.requestImage(urlArg, headersArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull() val error = result.exceptionOrNull()
if (error != null) { if (error != null) {
reply.reply(RemoteImagesPigeonUtils.wrapError(error)) reply.reply(RemoteImagesPigeonUtils.wrapError(error))
@@ -15,6 +15,8 @@ import okhttp3.Callback
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.Credentials
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.chromium.net.CronetEngine import org.chromium.net.CronetEngine
import org.chromium.net.CronetException import org.chromium.net.CronetException
import org.chromium.net.UrlRequest import org.chromium.net.UrlRequest
@@ -49,7 +51,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
override fun requestImage( override fun requestImage(
url: String, url: String,
headers: Map<String, String>,
requestId: Long, requestId: Long,
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android @Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
callback: (Result<Map<String, Long>?>) -> Unit callback: (Result<Map<String, Long>?>) -> Unit
@@ -59,7 +60,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
ImageFetcherManager.fetch( ImageFetcherManager.fetch(
url, url,
headers,
signal, signal,
onSuccess = { buffer -> onSuccess = { buffer ->
requestMap.remove(requestId) requestMap.remove(requestId)
@@ -120,12 +120,11 @@ private object ImageFetcherManager {
fun fetch( fun fetch(
url: String, url: String,
headers: Map<String, String>,
signal: CancellationSignal, signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit, onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit, onFailure: (Exception) -> Unit,
) { ) {
fetcher.fetch(url, headers, signal, onSuccess, onFailure) fetcher.fetch(url, signal, onSuccess, onFailure)
} }
fun clearCache(onCleared: (Result<Long>) -> Unit) { fun clearCache(onCleared: (Result<Long>) -> Unit) {
@@ -152,7 +151,6 @@ private object ImageFetcherManager {
private sealed interface ImageFetcher { private sealed interface ImageFetcher {
fun fetch( fun fetch(
url: String, url: String,
headers: Map<String, String>,
signal: CancellationSignal, signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit, onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit, onFailure: (Exception) -> Unit,
@@ -179,7 +177,6 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
override fun fetch( override fun fetch(
url: String, url: String,
headers: Map<String, String>,
signal: CancellationSignal, signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit, onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit, onFailure: (Exception) -> Unit,
@@ -194,7 +191,12 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
val callback = FetchCallback(onSuccess, onFailure, ::onComplete) val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor) 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() val request = requestBuilder.build()
signal.setOnCancelListener(request::cancel) signal.setOnCancelListener(request::cancel)
request.start() request.start()
@@ -391,7 +393,6 @@ private class OkHttpImageFetcher private constructor(
override fun fetch( override fun fetch(
url: String, url: String,
headers: Map<String, String>,
signal: CancellationSignal, signal: CancellationSignal,
onSuccess: (NativeByteBuffer) -> Unit, onSuccess: (NativeByteBuffer) -> Unit,
onFailure: (Exception) -> Unit, onFailure: (Exception) -> Unit,
@@ -404,7 +405,6 @@ private class OkHttpImageFetcher private constructor(
} }
val requestBuilder = Request.Builder().url(url) val requestBuilder = Request.Builder().url(url)
headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
val call = client.newCall(requestBuilder.build()) val call = client.newCall(requestBuilder.build())
signal.setOnCancelListener(call::cancel) signal.setOnCancelListener(call::cancel)
+48 -3
View File
@@ -221,8 +221,11 @@ class NetworkPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
/// Generated protocol from Pigeon that represents a handler of messages from Flutter. /// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol NetworkApi { protocol NetworkApi {
func addCertificate(clientData: ClientCertData, completion: @escaping (Result<Void, Error>) -> Void) func addCertificate(clientData: ClientCertData, completion: @escaping (Result<Void, Error>) -> Void)
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<ClientCertData, Error>) -> Void) func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<Void, Error>) -> Void)
func removeCertificate(completion: @escaping (Result<Void, Error>) -> Void) func removeCertificate(completion: @escaping (Result<Void, Error>) -> 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`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -255,8 +258,8 @@ class NetworkApiSetup {
let promptTextArg = args[0] as! ClientCertPrompt let promptTextArg = args[0] as! ClientCertPrompt
api.selectCertificate(promptText: promptTextArg) { result in api.selectCertificate(promptText: promptTextArg) { result in
switch result { switch result {
case .success(let res): case .success:
reply(wrapResult(res)) reply(wrapResult(nil))
case .failure(let error): case .failure(let error):
reply(wrapError(error)) reply(wrapError(error))
} }
@@ -280,5 +283,47 @@ class NetworkApiSetup {
} else { } else {
removeCertificateChannel.setMessageHandler(nil) 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)
}
} }
} }
+62 -6
View File
@@ -1,5 +1,6 @@
import Foundation import Foundation
import UniformTypeIdentifiers import UniformTypeIdentifiers
import native_video_player
enum ImportError: Error { enum ImportError: Error {
case noFile case noFile
@@ -16,14 +17,25 @@ class NetworkApiImpl: NetworkApi {
self.viewController = viewController self.viewController = viewController
} }
func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<ClientCertData, any Error>) -> Void) { func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result<Void, any Error>) -> Void) {
let importer = CertImporter(promptText: promptText, completion: { [weak self] result in let importer = CertImporter(promptText: promptText, completion: { [weak self] result in
self?.activeImporter = nil self?.activeImporter = nil
completion(result.map { ClientCertData(data: FlutterStandardTypedData(bytes: $0.0), password: $0.1) }) completion(result)
}, viewController: viewController) }, viewController: viewController)
activeImporter = importer activeImporter = importer
importer.load() 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, any Error>) -> Void) { func removeCertificate(completion: @escaping (Result<Void, any Error>) -> Void) {
let status = clearCerts() let status = clearCerts()
@@ -40,14 +52,58 @@ class NetworkApiImpl: NetworkApi {
} }
completion(.failure(ImportError.keychainError(status))) 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 class CertImporter: NSObject, UIDocumentPickerDelegate {
private let promptText: ClientCertPrompt private let promptText: ClientCertPrompt
private var completion: ((Result<(Data, String), Error>) -> Void) private var completion: ((Result<Void, Error>) -> Void)
private weak var viewController: UIViewController? private weak var viewController: UIViewController?
init(promptText: ClientCertPrompt, completion: (@escaping (Result<(Data, String), Error>) -> Void), viewController: UIViewController?) { init(promptText: ClientCertPrompt, completion: (@escaping (Result<Void, Error>) -> Void), viewController: UIViewController?) {
self.promptText = promptText self.promptText = promptText
self.completion = completion self.completion = completion
self.viewController = viewController self.viewController = viewController
@@ -81,7 +137,7 @@ private class CertImporter: NSObject, UIDocumentPickerDelegate {
} }
await URLSessionManager.shared.session.flush() await URLSessionManager.shared.session.flush()
self.completion(.success((data, password))) self.completion(.success(()))
} catch { } catch {
completion(.failure(error)) completion(.failure(error))
} }
+81 -28
View File
@@ -1,49 +1,77 @@
import Foundation import Foundation
import native_video_player
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity" 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. /// 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 { class URLSessionManager: NSObject {
static let shared = URLSessionManager() static let shared = URLSessionManager()
let session: URLSession private(set) var session: URLSession
private let configuration = { let delegate: URLSessionManagerDelegate
let config = URLSessionConfiguration.default private static let cacheDir: URL = {
let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
.first! .first!
.appendingPathComponent("api", isDirectory: true) .appendingPathComponent("api", isDirectory: true)
try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
config.urlCache = URLCache( }()
memoryCapacity: 0, private static let urlCache = URLCache(
diskCapacity: 1024 * 1024 * 1024, memoryCapacity: 0,
directory: cacheDir diskCapacity: 1024 * 1024 * 1024,
) directory: cacheDir
)
private static let userAgent: String = {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
return "Immich_iOS_\(version)"
}()
static let cookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: APP_GROUP)
var sessionPointer: UnsafeMutableRawPointer {
Unmanaged.passUnretained(session).toOpaque()
}
private override init() {
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.httpMaximumConnectionsPerHost = 64
config.timeoutIntervalForRequest = 60 config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 300 config.timeoutIntervalForResource = 300
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" var headers = UserDefaults.group.dictionary(forKey: HEADERS_KEY) as? [String: String] ?? [:]
config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"] headers["User-Agent"] = headers["User-Agent"] ?? userAgent
config.httpAdditionalHeaders = headers
return config
}() return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
private override init() {
session = URLSession(configuration: configuration, delegate: URLSessionManagerDelegate(), delegateQueue: nil)
super.init()
} }
} }
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate { class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate {
func urlSession( func urlSession(
_ session: URLSession, _ session: URLSession,
didReceive challenge: URLAuthenticationChallenge, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) { ) {
handleChallenge(challenge, completionHandler: completionHandler) handleChallenge(session, challenge, completionHandler)
} }
func urlSession( func urlSession(
@@ -52,20 +80,24 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate {
didReceive challenge: URLAuthenticationChallenge, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) { ) {
handleChallenge(challenge, completionHandler: completionHandler) handleChallenge(session, challenge, completionHandler, task: task)
} }
func handleChallenge( func handleChallenge(
_ session: URLSession,
_ challenge: URLAuthenticationChallenge, _ challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void _ completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void,
task: URLSessionTask? = nil
) { ) {
switch challenge.protectionSpace.authenticationMethod { 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) default: completionHandler(.performDefaultHandling, nil)
} }
} }
private func handleClientCertificate( private func handleClientCertificate(
_ session: URLSession,
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) { ) {
let query: [String: Any] = [ let query: [String: Any] = [
@@ -80,8 +112,29 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate {
let credential = URLCredential(identity: identity as! SecIdentity, let credential = URLCredential(identity: identity as! SecIdentity,
certificates: nil, certificates: nil,
persistence: .forSession) persistence: .forSession)
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
return completion(.useCredential, credential) return completion(.useCredential, credential)
} }
completion(.performDefaultHandling, nil) 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)
}
} }
@@ -70,7 +70,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
/// Generated protocol from Pigeon that represents a handler of messages from Flutter. /// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol RemoteImageApi { 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 cancelRequest(requestId: Int64) throws
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void) func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
} }
@@ -86,10 +86,9 @@ class RemoteImageApiSetup {
requestImageChannel.setMessageHandler { message, reply in requestImageChannel.setMessageHandler { message, reply in
let args = message as! [Any?] let args = message as! [Any?]
let urlArg = args[0] as! String let urlArg = args[0] as! String
let headersArg = args[1] as! [String: String] let requestIdArg = args[1] as! Int64
let requestIdArg = args[2] as! Int64 let preferEncodedArg = args[2] as! Bool
let preferEncodedArg = args[3] as! Bool api.requestImage(url: urlArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
switch result { switch result {
case .success(let res): case .success(let res):
reply(wrapResult(res)) reply(wrapResult(res))
@@ -33,12 +33,9 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
kCGImageSourceCreateThumbnailFromImageAlways: true kCGImageSourceCreateThumbnailFromImageAlways: true
] as CFDictionary ] 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)!) var urlRequest = URLRequest(url: URL(string: url)!)
urlRequest.cachePolicy = .returnCacheDataElseLoad 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 let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error) Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error)
@@ -3,7 +3,6 @@ import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.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/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.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:immich_mobile/wm_executor.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -64,7 +62,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
final Drift _drift; final Drift _drift;
final DriftLogger _driftLogger; final DriftLogger _driftLogger;
final BackgroundWorkerBgHostApi _backgroundHostApi; final BackgroundWorkerBgHostApi _backgroundHostApi;
final CancellationToken _cancellationToken = CancellationToken(); final _cancellationToken = Completer<void>();
final Logger _logger = Logger('BackgroundWorkerBgService'); final Logger _logger = Logger('BackgroundWorkerBgService');
bool _isCleanedUp = false; bool _isCleanedUp = false;
@@ -88,8 +86,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
Future<void> init() async { Future<void> init() async {
try { try {
HttpSSLOptions.apply();
await Future.wait( await Future.wait(
[ [
loadTranslations(), loadTranslations(),
@@ -198,7 +194,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
_ref?.dispose(); _ref?.dispose();
_ref = null; _ref = null;
_cancellationToken.cancel(); _cancellationToken.complete();
_logger.info("Cleaning up background worker"); _logger.info("Cleaning up background worker");
final cleanupFutures = [ final cleanupFutures = [
@@ -2,9 +2,8 @@ part of 'image_request.dart';
class RemoteImageRequest extends ImageRequest { class RemoteImageRequest extends ImageRequest {
final String uri; final String uri;
final Map<String, String> headers;
RemoteImageRequest({required this.uri, required this.headers}); RemoteImageRequest({required this.uri});
@override @override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async { Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
@@ -12,7 +11,7 @@ class RemoteImageRequest extends ImageRequest {
return null; 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. // Android always returns encoded data, so we need to check for both shapes of the response.
final frame = switch (info) { final frame = switch (info) {
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length), {'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
@@ -29,7 +28,7 @@ class RemoteImageRequest extends ImageRequest {
return null; 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; if (info == null) return null;
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null); final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
@@ -1,67 +1,55 @@
import 'dart:ffi';
import 'dart:io'; import 'dart:io';
import 'package:cronet_http/cronet_http.dart';
import 'package:cupertino_http/cupertino_http.dart'; import 'package:cupertino_http/cupertino_http.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:immich_mobile/utils/user_agent.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:path_provider/path_provider.dart'; import 'package:ok_http/ok_http.dart';
import 'package:web_socket/web_socket.dart';
class NetworkRepository { class NetworkRepository {
static late Directory _cachePath; static http.Client? _client;
static late String _userAgent; static Pointer<Void>? _clientPointer;
static final _clients = <String, http.Client>{};
static Future<void> init() { static Future<void> init() async {
return ( final clientPointer = Pointer<Void>.fromAddress(await networkApi.getClientPointer());
getTemporaryDirectory().then((cachePath) => _cachePath = cachePath), if (clientPointer == _clientPointer) {
getUserAgentString().then((userAgent) => _userAgent = userAgent), return;
).wait; }
_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() { static Future<void> setHeaders(Map<String, String> headers, List<String> serverUrls) async {
Future.microtask(init); await networkApi.setRequestHeaders(headers, serverUrls);
for (final client in _clients.values) { if (Platform.isIOS) {
client.close(); await init();
}
}
// ignore: avoid-unused-parameters
static Future<WebSocket> createWebSocket(Uri uri, {Map<String, String>? headers, Iterable<String>? 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);
} }
_clients.clear();
} }
const NetworkRepository(); const NetworkRepository();
/// Note: when disk caching is enabled, only one client may use a given directory at a time. /// Returns a shared HTTP client that uses native SSL configuration.
/// Different isolates or engines must use different directories. ///
http.Client getHttpClient( /// On iOS: Uses SharedURLSessionManager's URLSession.
String directoryName, { /// On Android: Uses SharedHttpClientManager's OkHttpClient.
CacheMode cacheMode = CacheMode.memory, ///
int diskCapacity = 0, /// Must call [init] before using this method.
int maxConnections = 6, static http.Client get client => _client!;
int memoryCapacity = 10 << 20,
}) {
final cachedClient = _clients[directoryName];
if (cachedClient != null) {
return cachedClient;
}
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);
}
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);
}
} }
@@ -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/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.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/services/api.service.dart';
import 'package:immich_mobile/utils/semver.dart'; import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -32,15 +33,11 @@ class SyncApiRepository {
http.Client? httpClient, http.Client? httpClient,
}) async { }) async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
final client = httpClient ?? http.Client(); final client = httpClient ?? NetworkRepository.client;
final endpoint = "${_api.apiClient.basePath}/sync/stream"; final endpoint = "${_api.apiClient.basePath}/sync/stream";
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'}; final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
final headerParams = <String, String>{};
await _api.applyToParams([], headerParams);
headers.addAll(headerParams);
final shouldReset = Store.get(StoreKey.shouldResetSync, false); final shouldReset = Store.get(StoreKey.shouldResetSync, false);
final request = http.Request('POST', Uri.parse(endpoint)); final request = http.Request('POST', Uri.parse(endpoint));
request.headers.addAll(headers); request.headers.addAll(headers);
@@ -119,8 +116,6 @@ class SyncApiRepository {
} }
} catch (error, stack) { } catch (error, stack) {
return Future.error(error, stack); return Future.error(error, stack);
} finally {
client.close();
} }
stopwatch.stop(); stopwatch.stop();
_logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms"); _logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms");
+1 -3
View File
@@ -40,7 +40,6 @@ import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/cache/widgets_binding.dart'; import 'package:immich_mobile/utils/cache/widgets_binding.dart';
import 'package:immich_mobile/utils/debug_print.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/licenses.dart';
import 'package:immich_mobile/utils/migration.dart'; import 'package:immich_mobile/utils/migration.dart';
import 'package:immich_mobile/wm_executor.dart'; import 'package:immich_mobile/wm_executor.dart';
@@ -60,7 +59,6 @@ void main() async {
// Warm-up isolate pool for worker manager // Warm-up isolate pool for worker manager
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5)); await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
await migrateDatabaseIfNeeded(isar, drift); await migrateDatabaseIfNeeded(isar, drift);
HttpSSLOptions.apply();
runApp( runApp(
ProviderScope( ProviderScope(
@@ -246,7 +244,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
@override @override
void reassemble() { void reassemble() {
if (kDebugMode) { if (kDebugMode) {
NetworkRepository.reset(); NetworkRepository.init();
} }
super.reassemble(); super.reassemble();
} }
@@ -1,6 +1,5 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first // ignore_for_file: public_member_api_docs, sort_constructors_first
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
@@ -21,7 +20,6 @@ class BackUpState {
final DateTime progressInFileSpeedUpdateTime; final DateTime progressInFileSpeedUpdateTime;
final int progressInFileSpeedUpdateSentBytes; final int progressInFileSpeedUpdateSentBytes;
final double iCloudDownloadProgress; final double iCloudDownloadProgress;
final CancellationToken cancelToken;
final ServerDiskInfo serverInfo; final ServerDiskInfo serverInfo;
final bool autoBackup; final bool autoBackup;
final bool backgroundBackup; final bool backgroundBackup;
@@ -53,7 +51,6 @@ class BackUpState {
required this.progressInFileSpeedUpdateTime, required this.progressInFileSpeedUpdateTime,
required this.progressInFileSpeedUpdateSentBytes, required this.progressInFileSpeedUpdateSentBytes,
required this.iCloudDownloadProgress, required this.iCloudDownloadProgress,
required this.cancelToken,
required this.serverInfo, required this.serverInfo,
required this.autoBackup, required this.autoBackup,
required this.backgroundBackup, required this.backgroundBackup,
@@ -78,7 +75,6 @@ class BackUpState {
DateTime? progressInFileSpeedUpdateTime, DateTime? progressInFileSpeedUpdateTime,
int? progressInFileSpeedUpdateSentBytes, int? progressInFileSpeedUpdateSentBytes,
double? iCloudDownloadProgress, double? iCloudDownloadProgress,
CancellationToken? cancelToken,
ServerDiskInfo? serverInfo, ServerDiskInfo? serverInfo,
bool? autoBackup, bool? autoBackup,
bool? backgroundBackup, bool? backgroundBackup,
@@ -102,7 +98,6 @@ class BackUpState {
progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime, progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime,
progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes, progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes,
iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress, iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress,
cancelToken: cancelToken ?? this.cancelToken,
serverInfo: serverInfo ?? this.serverInfo, serverInfo: serverInfo ?? this.serverInfo,
autoBackup: autoBackup ?? this.autoBackup, autoBackup: autoBackup ?? this.autoBackup,
backgroundBackup: backgroundBackup ?? this.backgroundBackup, backgroundBackup: backgroundBackup ?? this.backgroundBackup,
@@ -120,7 +115,7 @@ class BackUpState {
@override @override
String toString() { 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 @override
@@ -137,7 +132,6 @@ class BackUpState {
other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime && other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime &&
other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes && other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes &&
other.iCloudDownloadProgress == iCloudDownloadProgress && other.iCloudDownloadProgress == iCloudDownloadProgress &&
other.cancelToken == cancelToken &&
other.serverInfo == serverInfo && other.serverInfo == serverInfo &&
other.autoBackup == autoBackup && other.autoBackup == autoBackup &&
other.backgroundBackup == backgroundBackup && other.backgroundBackup == backgroundBackup &&
@@ -163,7 +157,6 @@ class BackUpState {
progressInFileSpeedUpdateTime.hashCode ^ progressInFileSpeedUpdateTime.hashCode ^
progressInFileSpeedUpdateSentBytes.hashCode ^ progressInFileSpeedUpdateSentBytes.hashCode ^
iCloudDownloadProgress.hashCode ^ iCloudDownloadProgress.hashCode ^
cancelToken.hashCode ^
serverInfo.hashCode ^ serverInfo.hashCode ^
autoBackup.hashCode ^ autoBackup.hashCode ^
backgroundBackup.hashCode ^ backgroundBackup.hashCode ^
@@ -1,11 +1,8 @@
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
class ManualUploadState { class ManualUploadState {
final CancellationToken cancelToken;
// Current Backup Asset // Current Backup Asset
final CurrentUploadAsset currentUploadAsset; final CurrentUploadAsset currentUploadAsset;
final int currentAssetIndex; final int currentAssetIndex;
@@ -29,7 +26,6 @@ class ManualUploadState {
required this.progressInFileSpeeds, required this.progressInFileSpeeds,
required this.progressInFileSpeedUpdateTime, required this.progressInFileSpeedUpdateTime,
required this.progressInFileSpeedUpdateSentBytes, required this.progressInFileSpeedUpdateSentBytes,
required this.cancelToken,
required this.currentUploadAsset, required this.currentUploadAsset,
required this.totalAssetsToUpload, required this.totalAssetsToUpload,
required this.currentAssetIndex, required this.currentAssetIndex,
@@ -44,7 +40,6 @@ class ManualUploadState {
List<double>? progressInFileSpeeds, List<double>? progressInFileSpeeds,
DateTime? progressInFileSpeedUpdateTime, DateTime? progressInFileSpeedUpdateTime,
int? progressInFileSpeedUpdateSentBytes, int? progressInFileSpeedUpdateSentBytes,
CancellationToken? cancelToken,
CurrentUploadAsset? currentUploadAsset, CurrentUploadAsset? currentUploadAsset,
int? totalAssetsToUpload, int? totalAssetsToUpload,
int? successfulUploads, int? successfulUploads,
@@ -58,7 +53,6 @@ class ManualUploadState {
progressInFileSpeeds: progressInFileSpeeds ?? this.progressInFileSpeeds, progressInFileSpeeds: progressInFileSpeeds ?? this.progressInFileSpeeds,
progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime, progressInFileSpeedUpdateTime: progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime,
progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes, progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ?? this.progressInFileSpeedUpdateSentBytes,
cancelToken: cancelToken ?? this.cancelToken,
currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset, currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
totalAssetsToUpload: totalAssetsToUpload ?? this.totalAssetsToUpload, totalAssetsToUpload: totalAssetsToUpload ?? this.totalAssetsToUpload,
currentAssetIndex: currentAssetIndex ?? this.currentAssetIndex, currentAssetIndex: currentAssetIndex ?? this.currentAssetIndex,
@@ -69,7 +63,7 @@ class ManualUploadState {
@override @override
String toString() { 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 @override
@@ -84,7 +78,6 @@ class ManualUploadState {
collectionEquals(other.progressInFileSpeeds, progressInFileSpeeds) && collectionEquals(other.progressInFileSpeeds, progressInFileSpeeds) &&
other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime && other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime &&
other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes && other.progressInFileSpeedUpdateSentBytes == progressInFileSpeedUpdateSentBytes &&
other.cancelToken == cancelToken &&
other.currentUploadAsset == currentUploadAsset && other.currentUploadAsset == currentUploadAsset &&
other.totalAssetsToUpload == totalAssetsToUpload && other.totalAssetsToUpload == totalAssetsToUpload &&
other.currentAssetIndex == currentAssetIndex && other.currentAssetIndex == currentAssetIndex &&
@@ -100,7 +93,6 @@ class ManualUploadState {
progressInFileSpeeds.hashCode ^ progressInFileSpeeds.hashCode ^
progressInFileSpeedUpdateTime.hashCode ^ progressInFileSpeedUpdateTime.hashCode ^
progressInFileSpeedUpdateSentBytes.hashCode ^ progressInFileSpeedUpdateSentBytes.hashCode ^
cancelToken.hashCode ^
currentUploadAsset.hashCode ^ currentUploadAsset.hashCode ^
totalAssetsToUpload.hashCode ^ totalAssetsToUpload.hashCode ^
currentAssetIndex.hashCode ^ currentAssetIndex.hashCode ^
@@ -96,10 +96,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
await backupNotifier.startForegroundBackup(currentUser.id); await backupNotifier.startForegroundBackup(currentUser.id);
} }
Future<void> stopBackup() async {
await backupNotifier.stopForegroundBackup();
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
elevation: 0, elevation: 0,
@@ -136,9 +132,9 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
const Divider(), const Divider(),
BackupToggleButton( BackupToggleButton(
onStart: () async => await startBackup(), onStart: () async => await startBackup(),
onStop: () async { onStop: () {
syncSuccess = null; syncSuccess = null;
await stopBackup(); backupNotifier.stopForegroundBackup();
}, },
), ),
switch (error) { switch (error) {
@@ -112,16 +112,15 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
// Waits for hashing to be cancelled before starting a new one // Waits for hashing to be cancelled before starting a new one
unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets())); unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
if (isBackupEnabled) { if (isBackupEnabled) {
backupNotifier.stopForegroundBackup();
unawaited( unawaited(
backupNotifier.stopForegroundBackup().whenComplete( backgroundSync.syncRemote().then((success) {
() => backgroundSync.syncRemote().then((success) { if (success) {
if (success) { return backupNotifier.startForegroundBackup(user.id);
return backupNotifier.startForegroundBackup(user.id); } else {
} else { Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup');
Logger('DriftBackupAlbumSelectionPage').warning('Background sync failed, not starting backup'); }
} }),
}),
),
); );
} }
} }
@@ -59,16 +59,15 @@ class DriftBackupOptionsPage extends ConsumerWidget {
final backupNotifier = ref.read(driftBackupProvider.notifier); final backupNotifier = ref.read(driftBackupProvider.notifier);
final backgroundSync = ref.read(backgroundSyncProvider); final backgroundSync = ref.read(backgroundSyncProvider);
backupNotifier.stopForegroundBackup();
unawaited( unawaited(
backupNotifier.stopForegroundBackup().whenComplete( backgroundSync.syncRemote().then((success) {
() => backgroundSync.syncRemote().then((success) { if (success) {
if (success) { return backupNotifier.startForegroundBackup(currentUser.id);
return backupNotifier.startForegroundBackup(currentUser.id); } else {
} else { Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup');
Logger('DriftBackupOptionsPage').warning('Background sync failed, not starting backup'); }
} }),
}),
),
); );
} }
}, },
@@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/api.provider.dart';
class SettingsHeader { class SettingsHeader {
String key = ""; String key = "";
@@ -20,7 +21,6 @@ class HeaderSettingsPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// final apiService = ref.watch(apiServiceProvider);
final headers = useState<List<SettingsHeader>>([]); final headers = useState<List<SettingsHeader>>([]);
final setInitialHeaders = useState(false); final setInitialHeaders = useState(false);
@@ -75,7 +75,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
], ],
), ),
body: PopScope( body: PopScope(
onPopInvokedWithResult: (didPop, _) => saveHeaders(headers.value), onPopInvokedWithResult: (didPop, _) => saveHeaders(ref, headers.value),
child: ListView.separated( child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0), padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16.0),
itemCount: list.length, itemCount: list.length,
@@ -87,7 +87,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
); );
} }
saveHeaders(List<SettingsHeader> headers) { saveHeaders(WidgetRef ref, List<SettingsHeader> headers) async {
final headersMap = {}; final headersMap = {};
for (var header in headers) { for (var header in headers) {
final key = header.key.trim(); final key = header.key.trim();
@@ -98,7 +98,8 @@ class HeaderSettingsPage extends HookConsumerWidget {
} }
var encoded = jsonEncode(headersMap); var encoded = jsonEncode(headersMap);
Store.put(StoreKey.customHeaders, encoded); await Store.put(StoreKey.customHeaders, encoded);
await ref.read(apiServiceProvider).updateHeaders();
} }
} }
+81 -7
View File
@@ -179,7 +179,7 @@ class NetworkApi {
} }
} }
Future<ClientCertData> selectCertificate(ClientCertPrompt promptText) async { Future<void> selectCertificate(ClientCertPrompt promptText) async {
final String pigeonVar_channelName = final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.NetworkApi.selectCertificate$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>( final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
@@ -197,13 +197,8 @@ class NetworkApi {
message: pigeonVar_replyList[1] as String?, message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2], 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 { } else {
return (pigeonVar_replyList[0] as ClientCertData?)!; return;
} }
} }
@@ -229,4 +224,83 @@ class NetworkApi {
return; return;
} }
} }
Future<bool> hasCertificate() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.hasCertificate$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else 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 bool?)!;
}
}
Future<int> getClientPointer() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else 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<void> setRequestHeaders(Map<String, String> headers, List<String> serverUrls) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NetworkApi.setRequestHeaders$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[headers, serverUrls]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
} }
+2 -12
View File
@@ -49,12 +49,7 @@ class RemoteImageApi {
final String pigeonVar_messageChannelSuffix; final String pigeonVar_messageChannelSuffix;
Future<Map<String, int>?> requestImage( Future<Map<String, int>?> requestImage(String url, {required int requestId, required bool preferEncoded}) async {
String url, {
required Map<String, String> headers,
required int requestId,
required bool preferEncoded,
}) async {
final String pigeonVar_channelName = final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>( final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
@@ -62,12 +57,7 @@ class RemoteImageApi {
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, binaryMessenger: pigeonVar_binaryMessenger,
); );
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[ final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[url, requestId, preferEncoded]);
url,
headers,
requestId,
preferEncoded,
]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) { if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName); throw _createConnectionError(pigeonVar_channelName);
@@ -1,7 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -62,7 +61,7 @@ class DriftEditImagePage extends ConsumerWidget {
return; return;
} }
await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken()); await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset]);
} catch (e) { } catch (e) {
ImmichToast.show( ImmichToast.show(
durationInSecond: 6, durationInSecond: 6,
@@ -101,7 +101,8 @@ class _UploadProgressDialog extends ConsumerWidget {
actions: [ actions: [
ImmichTextButton( ImmichTextButton(
onPressed: () { onPressed: () {
ref.read(manualUploadCancelTokenProvider)?.cancel(); ref.read(manualUploadCancelTokenProvider)?.complete();
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
labelText: 'cancel'.t(context: context), labelText: 'cancel'.t(context: context),
@@ -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/infrastructure/loaders/image_request.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.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/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:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -37,7 +36,7 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
} }
Stream<ImageInfo> _codec(RemoteImageProvider key, ImageDecoderCallback decode) { Stream<ImageInfo> _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); return loadRequest(request, decode);
} }
@@ -88,10 +87,8 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
return; return;
} }
final headers = ApiService.getRequestHeaders();
final previewRequest = request = RemoteImageRequest( final previewRequest = request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash), uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
headers: headers,
); );
final loadOriginal = assetType == AssetType.image && AppSetting.get(Setting.loadOriginal); final loadOriginal = assetType == AssetType.image && AppSetting.get(Setting.loadOriginal);
yield* loadRequest(previewRequest, decode, evictOnError: !loadOriginal); yield* loadRequest(previewRequest, decode, evictOnError: !loadOriginal);
@@ -105,7 +102,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
return; return;
} }
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId), headers: headers); final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId));
yield* loadRequest(originalRequest, decode); yield* loadRequest(originalRequest, decode);
} }
@@ -232,7 +232,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
} }
} }
Future<void> _performPause() async { Future<void> _performPause() {
if (_ref.read(authProvider).isAuthenticated) { if (_ref.read(authProvider).isAuthenticated) {
if (!Store.isBetaTimelineEnabled) { if (!Store.isBetaTimelineEnabled) {
// Do not cancel backup if manual upload is in progress // Do not cancel backup if manual upload is in progress
@@ -240,15 +240,13 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_ref.read(backupProvider.notifier).cancelBackup(); _ref.read(backupProvider.notifier).cancelBackup();
} }
} else { } else {
await _ref.read(driftBackupProvider.notifier).stopForegroundBackup(); _ref.read(driftBackupProvider.notifier).stopForegroundBackup();
} }
_ref.read(websocketProvider.notifier).disconnect(); _ref.read(websocketProvider.notifier).disconnect();
} }
try { return LogService.I.flush().catchError((_) {});
await LogService.I.flush();
} catch (_) {}
} }
Future<void> handleAppDetached() async { Future<void> handleAppDetached() async {
+1
View File
@@ -124,6 +124,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<bool> saveAuthInfo({required String accessToken}) async { Future<bool> saveAuthInfo({required String accessToken}) async {
await _apiService.setAccessToken(accessToken); await _apiService.setAccessToken(accessToken);
await _apiService.updateHeaders();
final serverEndpoint = Store.get(StoreKey.serverEndpoint); final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final customHeaders = Store.tryGet(StoreKey.customHeaders); final customHeaders = Store.tryGet(StoreKey.customHeaders);
@@ -1,4 +1,5 @@
import 'package:cancellation_token_http/http.dart'; import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
/// Tracks per-asset upload progress. /// Tracks per-asset upload progress.
@@ -30,4 +31,4 @@ final assetUploadProgressProvider = NotifierProvider<AssetUploadProgressNotifier
AssetUploadProgressNotifier.new, AssetUploadProgressNotifier.new,
); );
final manualUploadCancelTokenProvider = StateProvider<CancellationToken?>((ref) => null); final manualUploadCancelTokenProvider = StateProvider<Completer<void>?>((ref) => null);
@@ -1,6 +1,6 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
@@ -68,7 +68,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
progressInFileSpeeds: const [], progressInFileSpeeds: const [],
progressInFileSpeedUpdateTime: DateTime.now(), progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0, progressInFileSpeedUpdateSentBytes: 0,
cancelToken: CancellationToken(),
autoBackup: Store.get(StoreKey.autoBackup, false), autoBackup: Store.get(StoreKey.autoBackup, false),
backgroundBackup: Store.get(StoreKey.backgroundBackup, false), backgroundBackup: Store.get(StoreKey.backgroundBackup, false),
backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true), backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true),
@@ -102,6 +101,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final FileMediaRepository _fileMediaRepository; final FileMediaRepository _fileMediaRepository;
final BackupAlbumService _backupAlbumService; final BackupAlbumService _backupAlbumService;
final Ref ref; final Ref ref;
Completer<void>? _cancelToken;
/// ///
/// UI INTERACTION /// UI INTERACTION
@@ -454,7 +454,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} }
// Perform Backup // Perform Backup
state = state.copyWith(cancelToken: CancellationToken()); _cancelToken?.complete();
_cancelToken = Completer<void>();
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
@@ -465,7 +466,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
await _backupService.backupAsset( await _backupService.backupAsset(
assetsWillBeBackup, assetsWillBeBackup,
state.cancelToken, _cancelToken!,
pmProgressHandler: pmProgressHandler, pmProgressHandler: pmProgressHandler,
onSuccess: _onAssetUploaded, onSuccess: _onAssetUploaded,
onProgress: _onUploadProgress, onProgress: _onUploadProgress,
@@ -494,7 +495,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
if (state.backupProgress != BackUpProgressEnum.inProgress) { if (state.backupProgress != BackUpProgressEnum.inProgress) {
notifyBackgroundServiceCanRun(); notifyBackgroundServiceCanRun();
} }
state.cancelToken.cancel(); _cancelToken?.complete();
_cancelToken = null;
state = state.copyWith( state = state.copyWith(
backupProgress: BackUpProgressEnum.idle, backupProgress: BackUpProgressEnum.idle,
progressInPercentage: 0.0, progressInPercentage: 0.0,
@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -109,7 +108,6 @@ class DriftBackupState {
final BackupError error; final BackupError error;
final Map<String, DriftUploadStatus> uploadItems; final Map<String, DriftUploadStatus> uploadItems;
final CancellationToken? cancelToken;
final Map<String, double> iCloudDownloadProgress; final Map<String, double> iCloudDownloadProgress;
@@ -121,7 +119,6 @@ class DriftBackupState {
required this.isSyncing, required this.isSyncing,
this.error = BackupError.none, this.error = BackupError.none,
required this.uploadItems, required this.uploadItems,
this.cancelToken,
this.iCloudDownloadProgress = const {}, this.iCloudDownloadProgress = const {},
}); });
@@ -133,7 +130,6 @@ class DriftBackupState {
bool? isSyncing, bool? isSyncing,
BackupError? error, BackupError? error,
Map<String, DriftUploadStatus>? uploadItems, Map<String, DriftUploadStatus>? uploadItems,
CancellationToken? cancelToken,
Map<String, double>? iCloudDownloadProgress, Map<String, double>? iCloudDownloadProgress,
}) { }) {
return DriftBackupState( return DriftBackupState(
@@ -144,7 +140,6 @@ class DriftBackupState {
isSyncing: isSyncing ?? this.isSyncing, isSyncing: isSyncing ?? this.isSyncing,
error: error ?? this.error, error: error ?? this.error,
uploadItems: uploadItems ?? this.uploadItems, uploadItems: uploadItems ?? this.uploadItems,
cancelToken: cancelToken ?? this.cancelToken,
iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress, iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress,
); );
} }
@@ -153,7 +148,7 @@ class DriftBackupState {
@override @override
String toString() { 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 @override
@@ -168,8 +163,7 @@ class DriftBackupState {
other.isSyncing == isSyncing && other.isSyncing == isSyncing &&
other.error == error && other.error == error &&
mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) && mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) &&
mapEquals(other.uploadItems, uploadItems) && mapEquals(other.uploadItems, uploadItems);
other.cancelToken == cancelToken;
} }
@override @override
@@ -181,7 +175,6 @@ class DriftBackupState {
isSyncing.hashCode ^ isSyncing.hashCode ^
error.hashCode ^ error.hashCode ^
uploadItems.hashCode ^ uploadItems.hashCode ^
cancelToken.hashCode ^
iCloudDownloadProgress.hashCode; iCloudDownloadProgress.hashCode;
} }
} }
@@ -211,6 +204,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
final ForegroundUploadService _foregroundUploadService; final ForegroundUploadService _foregroundUploadService;
final BackgroundUploadService _backgroundUploadService; final BackgroundUploadService _backgroundUploadService;
final UploadSpeedManager _uploadSpeedManager; final UploadSpeedManager _uploadSpeedManager;
Completer<void>? _cancelToken;
final _logger = Logger("DriftBackupNotifier"); final _logger = Logger("DriftBackupNotifier");
@@ -246,7 +240,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
); );
} }
void updateError(BackupError error) async { void updateError(BackupError error) {
if (!mounted) { if (!mounted) {
_logger.warning("Skip updateError: notifier disposed"); _logger.warning("Skip updateError: notifier disposed");
return; return;
@@ -254,24 +248,23 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
state = state.copyWith(error: error); state = state.copyWith(error: error);
} }
void updateSyncing(bool isSyncing) async { void updateSyncing(bool isSyncing) {
state = state.copyWith(isSyncing: isSyncing); state = state.copyWith(isSyncing: isSyncing);
} }
Future<void> startForegroundBackup(String userId) async { Future<void> startForegroundBackup(String userId) {
// Cancel any existing backup before starting a new one // Cancel any existing backup before starting a new one
if (state.cancelToken != null) { if (_cancelToken != null) {
await stopForegroundBackup(); stopForegroundBackup();
} }
state = state.copyWith(error: BackupError.none); state = state.copyWith(error: BackupError.none);
final cancelToken = CancellationToken(); _cancelToken = Completer<void>();
state = state.copyWith(cancelToken: cancelToken);
return _foregroundUploadService.uploadCandidates( return _foregroundUploadService.uploadCandidates(
userId, userId,
cancelToken, _cancelToken!,
callbacks: UploadCallbacks( callbacks: UploadCallbacks(
onProgress: _handleForegroundBackupProgress, onProgress: _handleForegroundBackupProgress,
onSuccess: _handleForegroundBackupSuccess, onSuccess: _handleForegroundBackupSuccess,
@@ -281,10 +274,11 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
); );
} }
Future<void> stopForegroundBackup() async { void stopForegroundBackup() {
state.cancelToken?.cancel(); _cancelToken?.complete();
_cancelToken = null;
_uploadSpeedManager.clear(); _uploadSpeedManager.clear();
state = state.copyWith(cancelToken: null, uploadItems: {}, iCloudDownloadProgress: {}); state = state.copyWith(uploadItems: {}, iCloudDownloadProgress: {});
} }
void _handleICloudProgress(String localAssetId, double progress) { void _handleICloudProgress(String localAssetId, double progress) {
@@ -300,7 +294,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
} }
void _handleForegroundBackupProgress(String localAssetId, String filename, int bytes, int totalBytes) { void _handleForegroundBackupProgress(String localAssetId, String filename, int bytes, int totalBytes) {
if (state.cancelToken == null) { if (_cancelToken == null) {
return; return;
} }
@@ -399,7 +393,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
} }
} }
final driftBackupCandidateProvider = FutureProvider.autoDispose<List<LocalAsset>>((ref) async { final driftBackupCandidateProvider = FutureProvider.autoDispose<List<LocalAsset>>((ref) {
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);
if (user == null) { if (user == null) {
return []; return [];
@@ -1,7 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -50,6 +49,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
final BackupService _backupService; final BackupService _backupService;
final BackupAlbumService _backupAlbumService; final BackupAlbumService _backupAlbumService;
final Ref ref; final Ref ref;
Completer<void>? _cancelToken;
ManualUploadNotifier( ManualUploadNotifier(
this._localNotificationService, this._localNotificationService,
@@ -65,7 +65,6 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
progressInFileSpeeds: const [], progressInFileSpeeds: const [],
progressInFileSpeedUpdateTime: DateTime.now(), progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0, progressInFileSpeedUpdateSentBytes: 0,
cancelToken: CancellationToken(),
currentUploadAsset: CurrentUploadAsset( currentUploadAsset: CurrentUploadAsset(
id: '...', id: '...',
fileCreatedAt: DateTime.parse('2020-10-04'), fileCreatedAt: DateTime.parse('2020-10-04'),
@@ -236,7 +235,6 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
fileName: '...', fileName: '...',
fileType: '...', fileType: '...',
), ),
cancelToken: CancellationToken(),
); );
// Reset Error List // Reset Error List
ref.watch(errorBackupListProvider.notifier).empty(); ref.watch(errorBackupListProvider.notifier).empty();
@@ -252,11 +250,13 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
state = state.copyWith(showDetailedNotification: showDetailedNotification); state = state.copyWith(showDetailedNotification: showDetailedNotification);
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
_cancelToken?.complete();
_cancelToken = Completer<void>();
final bool ok = await ref final bool ok = await ref
.read(backupServiceProvider) .read(backupServiceProvider)
.backupAsset( .backupAsset(
uploadAssets, uploadAssets,
state.cancelToken, _cancelToken!,
pmProgressHandler: pmProgressHandler, pmProgressHandler: pmProgressHandler,
onSuccess: _onAssetUploaded, onSuccess: _onAssetUploaded,
onProgress: _onProgress, onProgress: _onProgress,
@@ -273,14 +273,14 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
); );
// User cancelled upload // User cancelled upload
if (!ok && state.cancelToken.isCancelled) { if (!ok && _cancelToken == null) {
await _localNotificationService.showOrUpdateManualUploadStatus( await _localNotificationService.showOrUpdateManualUploadStatus(
"backup_manual_title".tr(), "backup_manual_title".tr(),
"backup_manual_cancelled".tr(), "backup_manual_cancelled".tr(),
presentBanner: true, presentBanner: true,
); );
hasErrors = true; hasErrors = true;
} else if (state.successfulUploads == 0 || (!ok && !state.cancelToken.isCancelled)) { } else if (state.successfulUploads == 0 || (!ok && _cancelToken != null)) {
await _localNotificationService.showOrUpdateManualUploadStatus( await _localNotificationService.showOrUpdateManualUploadStatus(
"backup_manual_title".tr(), "backup_manual_title".tr(),
"failed".tr(), "failed".tr(),
@@ -324,7 +324,8 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) { _backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
_backupProvider.notifyBackgroundServiceCanRun(); _backupProvider.notifyBackgroundServiceCanRun();
} }
state.cancelToken.cancel(); _cancelToken?.complete();
_cancelToken = null;
if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) { if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle); _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
} }
-40
View File
@@ -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<ui.Codec> loadImageFromCache(
String uri, {
required CacheManager cache,
required ImageDecoderCallback decode,
StreamController<ImageChunkEvent>? 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');
}
}
@@ -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);
}
@@ -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)));
}
@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -455,7 +454,7 @@ class ActionNotifier extends Notifier<void> {
final assetsToUpload = assets ?? _getAssets(source).whereType<LocalAsset>().toList(); final assetsToUpload = assets ?? _getAssets(source).whereType<LocalAsset>().toList();
final progressNotifier = ref.read(assetUploadProgressProvider.notifier); final progressNotifier = ref.read(assetUploadProgressProvider.notifier);
final cancelToken = CancellationToken(); final cancelToken = Completer<void>();
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken; ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
// Initialize progress for all assets // Initialize progress for all assets
@@ -466,7 +465,7 @@ class ActionNotifier extends Notifier<void> {
try { try {
await _foregroundUploadService.uploadManual( await _foregroundUploadService.uploadManual(
assetsToUpload, assetsToUpload,
cancelToken, cancelToken: cancelToken,
callbacks: UploadCallbacks( callbacks: UploadCallbacks(
onProgress: (localAssetId, filename, bytes, totalBytes) { onProgress: (localAssetId, filename, bytes, totalBytes) {
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0; final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
+4 -13
View File
@@ -1,18 +1,17 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.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/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/server_info.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/services/sync.service.dart';
import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
@@ -99,11 +98,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
if (authenticationState.isAuthenticated) { if (authenticationState.isAuthenticated) {
try { try {
final endpoint = Uri.parse(Store.get(StoreKey.serverEndpoint)); 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"); dPrint(() => "Attempting to connect to websocket");
// Configure socket transports must be specified // Configure socket transports must be specified
Socket socket = io( Socket socket = io(
@@ -111,11 +105,11 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
OptionBuilder() OptionBuilder()
.setPath("${endpoint.path}/socket.io") .setPath("${endpoint.path}/socket.io")
.setTransports(['websocket']) .setTransports(['websocket'])
.setWebSocketConnector(NetworkRepository.createWebSocket)
.enableReconnection() .enableReconnection()
.enableForceNew() .enableForceNew()
.enableForceNewConnection() .enableForceNewConnection()
.enableAutoConnect() .enableAutoConnect()
.setExtraHeaders(headers)
.build(), .build(),
); );
@@ -160,11 +154,8 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_batchedAssetUploadReady.clear(); _batchedAssetUploadReady.clear();
var socket = state.socket?.disconnect(); state.socket?.dispose();
state = WebsocketState(isConnected: false, socket: null, pendingChanges: state.pendingChanges);
if (socket?.disconnected == true) {
state = WebsocketState(isConnected: false, socket: null, pendingChanges: state.pendingChanges);
}
} }
void stopListenToEvent(String eventName) { void stopListenToEvent(String eventName) {
+40 -40
View File
@@ -3,21 +3,15 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/utils/debug_print.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()); final uploadRepositoryProvider = Provider((ref) => UploadRepository());
class UploadRepository { class UploadRepository {
@@ -97,26 +91,27 @@ class UploadRepository {
Future<UploadResult> uploadFile({ Future<UploadResult> uploadFile({
required File file, required File file,
required String originalFileName, required String originalFileName,
required Map<String, String> headers,
required Map<String, String> fields, required Map<String, String> fields,
required Client httpClient, required Completer<void>? cancelToken,
required CancellationToken cancelToken, void Function(int bytes, int totalBytes)? onProgress,
required void Function(int bytes, int totalBytes) onProgress,
required String logContext, required String logContext,
}) async { }) async {
final String savedEndpoint = Store.get(StoreKey.serverEndpoint); final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
final baseRequest = ProgressMultipartRequest(
'POST',
Uri.parse('$savedEndpoint/assets'),
abortTrigger: cancelToken?.future,
onProgress: onProgress,
);
try { try {
final fileStream = file.openRead(); final fileStream = file.openRead();
final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName); 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.fields.addAll(fields);
baseRequest.files.add(assetRawUploadData); 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(); final responseBodyString = await response.stream.bytesToString();
if (![200, 201].contains(response.statusCode)) { if (![200, 201].contains(response.statusCode)) {
@@ -145,7 +140,7 @@ class UploadRepository {
} catch (e) { } catch (e) {
return UploadResult.error(errorMessage: 'Failed to parse server response'); return UploadResult.error(errorMessage: 'Failed to parse server response');
} }
} on CancelledException { } on RequestAbortedException {
logger.warning("Upload $logContext was cancelled"); logger.warning("Upload $logContext was cancelled");
return UploadResult.cancelled(); return UploadResult.cancelled();
} catch (error, stackTrace) { } 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<void>? 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<int> data, EventSink<List<int>> sink) {
bytes += data.length;
onProgress!(bytes, total);
sink.add(data);
},
),
);
return ByteStream(stream);
}
}
class UploadResult { class UploadResult {
final bool isSuccess; final bool isSuccess;
final bool isCancelled; final bool isCancelled;
@@ -182,26 +205,3 @@ class UploadResult {
return const UploadResult(isSuccess: false, isCancelled: true); 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<int> data, EventSink<List<int>> sink) {
bytes += data.length;
onProgress.call(bytes, total);
sink.add(data);
},
);
final stream = byteStream.transform(t);
return ByteStream(stream);
}
}
+35 -19
View File
@@ -3,12 +3,11 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart'; 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/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/utils/user_agent.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -49,9 +48,14 @@ class ApiService implements Authentication {
String? _accessToken; String? _accessToken;
final _log = Logger("ApiService"); final _log = Logger("ApiService");
Future<void> updateHeaders() async {
await NetworkRepository.setHeaders(getRequestHeaders(), getServerUrls());
_apiClient.client = NetworkRepository.client;
}
setEndpoint(String endpoint) { setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint, authentication: this); _apiClient = ApiClient(basePath: endpoint, authentication: this);
_setUserAgentHeader(); _apiClient.client = NetworkRepository.client;
if (_accessToken != null) { if (_accessToken != null) {
setAccessToken(_accessToken!); setAccessToken(_accessToken!);
} }
@@ -78,11 +82,6 @@ class ApiService implements Authentication {
tagsApi = TagsApi(_apiClient); tagsApi = TagsApi(_apiClient);
} }
Future<void> _setUserAgentHeader() async {
final userAgent = await getUserAgentString();
_apiClient.addDefaultHeader('User-Agent', userAgent);
}
Future<String> resolveAndSetEndpoint(String serverUrl) async { Future<String> resolveAndSetEndpoint(String serverUrl) async {
final endpoint = await resolveEndpoint(serverUrl); final endpoint = await resolveEndpoint(serverUrl);
setEndpoint(endpoint); setEndpoint(endpoint);
@@ -136,14 +135,9 @@ class ApiService implements Authentication {
} }
Future<String> _getWellKnownEndpoint(String baseUrl) async { Future<String> _getWellKnownEndpoint(String baseUrl) async {
final Client client = Client();
try { try {
var headers = {"Accept": "application/json"}; final res = await NetworkRepository.client
headers.addAll(getRequestHeaders()); .get(Uri.parse("$baseUrl/.well-known/immich"))
final res = await client
.get(Uri.parse("$baseUrl/.well-known/immich"), headers: headers)
.timeout(const Duration(seconds: 5)); .timeout(const Duration(seconds: 5));
if (res.statusCode == 200) { if (res.statusCode == 200) {
@@ -185,6 +179,31 @@ class ApiService implements Authentication {
} }
} }
static List<String> getServerUrls() {
final urls = <String>[];
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<dynamic> 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<String, String> getRequestHeaders() { static Map<String, String> getRequestHeaders() {
var accessToken = Store.get(StoreKey.accessToken, ""); var accessToken = Store.get(StoreKey.accessToken, "");
var customHeadersStr = Store.get(StoreKey.customHeaders, ""); var customHeadersStr = Store.get(StoreKey.customHeaders, "");
@@ -207,10 +226,7 @@ class ApiService implements Authentication {
@override @override
Future<void> applyToParams(List<QueryParam> queryParams, Map<String, String> headerParams) { Future<void> applyToParams(List<QueryParam> queryParams, Map<String, String> headerParams) {
return Future<void>(() { return Future.value();
var headers = ApiService.getRequestHeaders();
headerParams.addAll(headers);
});
} }
ApiClient get apiClient => _apiClient; ApiClient get apiClient => _apiClient;
+2 -13
View File
@@ -1,10 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/entities/store.entity.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/auxilary_endpoint.model.dart';
import 'package:immich_mobile/models/auth/login_response.model.dart'; import 'package:immich_mobile/models/auth/login_response.model.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
@@ -64,27 +64,16 @@ class AuthService {
} }
Future<bool> validateAuxilaryServerUrl(String url) async { Future<bool> validateAuxilaryServerUrl(String url) async {
final httpclient = HttpClient();
bool isValid = false; bool isValid = false;
try { try {
final uri = Uri.parse('$url/users/me'); final uri = Uri.parse('$url/users/me');
final request = await httpclient.getUrl(uri); final response = await NetworkRepository.client.get(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();
if (response.statusCode == 200) { if (response.statusCode == 200) {
isValid = true; isValid = true;
} }
} catch (error) { } catch (error) {
_log.severe("Error validating auxiliary endpoint", error); _log.severe("Error validating auxiliary endpoint", error);
} finally {
httpclient.close();
} }
return isValid; return isValid;
+7 -8
View File
@@ -4,7 +4,6 @@ import 'dart:io';
import 'dart:isolate'; import 'dart:isolate';
import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities; import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.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/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/diff.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:path_provider_foundation/path_provider_foundation.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
@@ -43,7 +41,7 @@ class BackgroundService {
static const MethodChannel _backgroundChannel = MethodChannel('immich/backgroundChannel'); static const MethodChannel _backgroundChannel = MethodChannel('immich/backgroundChannel');
static const notifyInterval = Duration(milliseconds: 400); static const notifyInterval = Duration(milliseconds: 400);
bool _isBackgroundInitialized = false; bool _isBackgroundInitialized = false;
CancellationToken? _cancellationToken; Completer<void>? _cancellationToken;
bool _canceledBySystem = false; bool _canceledBySystem = false;
int _wantsLockTime = 0; int _wantsLockTime = 0;
bool _hasLock = false; bool _hasLock = false;
@@ -321,7 +319,8 @@ class BackgroundService {
} }
case "systemStop": case "systemStop":
_canceledBySystem = true; _canceledBySystem = true;
_cancellationToken?.cancel(); _cancellationToken?.complete();
_cancellationToken = null;
return true; return true;
default: default:
dPrint(() => "Unknown method ${call.method}"); 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(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken));
await ref.read(authServiceProvider).setOpenApiServiceEndpoint(); await ref.read(authServiceProvider).setOpenApiServiceEndpoint();
dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}"); dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}");
@@ -441,7 +439,8 @@ class BackgroundService {
), ),
); );
_cancellationToken = CancellationToken(); _cancellationToken?.complete();
_cancellationToken = Completer<void>();
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
final bool ok = await backupService.backupAsset( final bool ok = await backupService.backupAsset(
@@ -455,7 +454,7 @@ class BackgroundService {
isBackground: true, isBackground: true,
); );
if (!ok && !_cancellationToken!.isCancelled) { if (!ok && !_cancellationToken!.isCompleted) {
unawaited( unawaited(
_showErrorNotification( _showErrorNotification(
title: "backup_background_service_error_title".tr(), title: "backup_background_service_error_title".tr(),
@@ -467,7 +466,7 @@ class BackgroundService {
return ok; return ok;
} }
void _onAssetUploaded({bool shouldNotify = false}) async { void _onAssetUploaded({bool shouldNotify = false}) {
if (!shouldNotify) { if (!shouldNotify) {
return; return;
} }
+13 -40
View File
@@ -2,14 +2,16 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:cancellation_token_http/http.dart' as http;
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/store.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/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
@@ -43,7 +45,6 @@ final backupServiceProvider = Provider(
); );
class BackupService { class BackupService {
final httpClient = http.Client();
final ApiService _apiService; final ApiService _apiService;
final Logger _log = Logger("BackupService"); final Logger _log = Logger("BackupService");
final AppSettingsService _appSetting; final AppSettingsService _appSetting;
@@ -233,7 +234,7 @@ class BackupService {
Future<bool> backupAsset( Future<bool> backupAsset(
Iterable<BackupCandidate> assets, Iterable<BackupCandidate> assets,
http.CancellationToken cancelToken, { Completer<void> cancelToken, {
bool isBackground = false, bool isBackground = false,
PMProgressHandler? pmProgressHandler, PMProgressHandler? pmProgressHandler,
required void Function(SuccessUploadAsset result) onSuccess, required void Function(SuccessUploadAsset result) onSuccess,
@@ -306,20 +307,20 @@ class BackupService {
} }
final fileStream = file.openRead(); final fileStream = file.openRead();
final assetRawUploadData = http.MultipartFile( final assetRawUploadData = MultipartFile(
"assetData", "assetData",
fileStream, fileStream,
file.lengthSync(), file.lengthSync(),
filename: originalFileName, filename: originalFileName,
); );
final baseRequest = MultipartRequest( final baseRequest = ProgressMultipartRequest(
'POST', 'POST',
Uri.parse('$savedEndpoint/assets'), Uri.parse('$savedEndpoint/assets'),
abortTrigger: cancelToken.future,
onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)), onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)),
); );
baseRequest.headers.addAll(ApiService.getRequestHeaders());
baseRequest.fields['deviceAssetId'] = asset.localId!; baseRequest.fields['deviceAssetId'] = asset.localId!;
baseRequest.fields['deviceId'] = deviceId; baseRequest.fields['deviceId'] = deviceId;
baseRequest.fields['fileCreatedAt'] = asset.fileCreatedAt.toUtc().toIso8601String(); baseRequest.fields['fileCreatedAt'] = asset.fileCreatedAt.toUtc().toIso8601String();
@@ -348,7 +349,7 @@ class BackupService {
baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId; 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()); final responseBody = jsonDecode(await response.stream.bytesToString());
@@ -398,7 +399,7 @@ class BackupService {
await _albumService.syncUploadAlbums(candidate.albumNames, [responseBody['id'] as String]); await _albumService.syncUploadAlbums(candidate.albumNames, [responseBody['id'] as String]);
} }
} }
} on http.CancelledException { } on RequestAbortedException {
dPrint(() => "Backup was cancelled by the user"); dPrint(() => "Backup was cancelled by the user");
anyErrors = true; anyErrors = true;
break; break;
@@ -429,26 +430,26 @@ class BackupService {
String originalFileName, String originalFileName,
File? livePhotoVideoFile, File? livePhotoVideoFile,
MultipartRequest baseRequest, MultipartRequest baseRequest,
http.CancellationToken cancelToken, Completer cancelToken,
) async { ) async {
if (livePhotoVideoFile == null) { if (livePhotoVideoFile == null) {
return null; return null;
} }
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoVideoFile.path)); final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoVideoFile.path));
final fileStream = livePhotoVideoFile.openRead(); final fileStream = livePhotoVideoFile.openRead();
final livePhotoRawUploadData = http.MultipartFile( final livePhotoRawUploadData = MultipartFile(
"assetData", "assetData",
fileStream, fileStream,
livePhotoVideoFile.lengthSync(), livePhotoVideoFile.lengthSync(),
filename: livePhotoTitle, 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) ..headers.addAll(baseRequest.headers)
..fields.addAll(baseRequest.fields); ..fields.addAll(baseRequest.fields);
livePhotoReq.files.add(livePhotoRawUploadData); 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()); var responseBody = jsonDecode(await response.stream.bytesToString());
@@ -470,31 +471,3 @@ class BackupService {
AssetType.other => "OTHER", 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<int> data, EventSink<List<int>> sink) {
bytes += data.length;
onProgress.call(bytes, total);
sink.add(data);
},
);
final stream = byteStream.transform(t);
return http.ByteStream(stream);
}
}
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.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/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.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:immich_mobile/services/app_settings.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@@ -82,7 +80,7 @@ class ForegroundUploadService {
/// Bulk upload of backup candidates from selected albums /// Bulk upload of backup candidates from selected albums
Future<void> uploadCandidates( Future<void> uploadCandidates(
String userId, String userId,
CancellationToken cancelToken, { Completer<void> cancelToken, {
UploadCallbacks callbacks = const UploadCallbacks(), UploadCallbacks callbacks = const UploadCallbacks(),
bool useSequentialUpload = false, bool useSequentialUpload = false,
}) async { }) async {
@@ -105,7 +103,7 @@ class ForegroundUploadService {
final requireWifi = _shouldRequireWiFi(asset); final requireWifi = _shouldRequireWiFi(asset);
return requireWifi && !hasWifi; 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 /// Sequential upload - used for background isolate where concurrent HTTP clients may cause issues
Future<void> _uploadSequentially({ Future<void> _uploadSequentially({
required List<LocalAsset> items, required List<LocalAsset> items,
required CancellationToken cancelToken, required Completer<void> cancelToken,
required bool hasWifi, required bool hasWifi,
required UploadCallbacks callbacks, required UploadCallbacks callbacks,
}) async { }) async {
final httpClient = Client();
await _storageRepository.clearCache(); await _storageRepository.clearCache();
shouldAbortUpload = false; shouldAbortUpload = false;
try { for (final asset in items) {
for (final asset in items) { if (shouldAbortUpload || cancelToken.isCompleted) {
if (shouldAbortUpload || cancelToken.isCancelled) { break;
break;
}
final requireWifi = _shouldRequireWiFi(asset);
if (requireWifi && !hasWifi) {
_logger.warning('Skipping upload for ${asset.id} because it requires WiFi');
continue;
}
await _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks);
} }
} finally {
httpClient.close(); final requireWifi = _shouldRequireWiFi(asset);
if (requireWifi && !hasWifi) {
_logger.warning('Skipping upload for ${asset.id} because it requires WiFi');
continue;
}
await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
} }
} }
/// Manually upload picked local assets /// Manually upload picked local assets
Future<void> uploadManual( Future<void> uploadManual(
List<LocalAsset> localAssets, List<LocalAsset> localAssets, {
CancellationToken cancelToken, { Completer<void>? cancelToken,
UploadCallbacks callbacks = const UploadCallbacks(), UploadCallbacks callbacks = const UploadCallbacks(),
}) async { }) async {
if (localAssets.isEmpty) { if (localAssets.isEmpty) {
@@ -153,14 +146,14 @@ class ForegroundUploadService {
await _executeWithWorkerPool<LocalAsset>( await _executeWithWorkerPool<LocalAsset>(
items: localAssets, items: localAssets,
cancelToken: cancelToken, cancelToken: cancelToken,
processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks), processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
); );
} }
/// Upload files from shared intent /// Upload files from shared intent
Future<void> uploadShareIntent( Future<void> uploadShareIntent(
List<File> files, { List<File> files, {
CancellationToken? cancelToken, Completer<void>? cancelToken,
void Function(String fileId, int bytes, int totalBytes)? onProgress, void Function(String fileId, int bytes, int totalBytes)? onProgress,
void Function(String fileId)? onSuccess, void Function(String fileId)? onSuccess,
void Function(String fileId, String errorMessage)? onError, void Function(String fileId, String errorMessage)? onError,
@@ -168,20 +161,16 @@ class ForegroundUploadService {
if (files.isEmpty) { if (files.isEmpty) {
return; return;
} }
final effectiveCancelToken = cancelToken ?? CancellationToken();
await _executeWithWorkerPool<File>( await _executeWithWorkerPool<File>(
items: files, items: files,
cancelToken: effectiveCancelToken, cancelToken: cancelToken,
processItem: (file, httpClient) async { processItem: (file) async {
final fileId = p.hash(file.path).toString(); final fileId = p.hash(file.path).toString();
final result = await _uploadSingleFile( final result = await _uploadSingleFile(
file, file,
deviceAssetId: fileId, deviceAssetId: fileId,
httpClient: httpClient, cancelToken: cancelToken,
cancelToken: effectiveCancelToken,
onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes), onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes),
); );
@@ -207,58 +196,49 @@ class ForegroundUploadService {
/// [concurrentWorkers] - Number of concurrent workers (default: 3) /// [concurrentWorkers] - Number of concurrent workers (default: 3)
Future<void> _executeWithWorkerPool<T>({ Future<void> _executeWithWorkerPool<T>({
required List<T> items, required List<T> items,
required CancellationToken cancelToken, required Completer<void>? cancelToken,
required Future<void> Function(T item, Client httpClient) processItem, required Future<void> Function(T item) processItem,
bool Function(T item)? shouldSkip, bool Function(T item)? shouldSkip,
int concurrentWorkers = 3, int concurrentWorkers = 3,
}) async { }) async {
final httpClients = List.generate(concurrentWorkers, (_) => Client());
await _storageRepository.clearCache(); await _storageRepository.clearCache();
shouldAbortUpload = false; shouldAbortUpload = false;
try { int currentIndex = 0;
int currentIndex = 0;
Future<void> worker(Client httpClient) async { Future<void> worker() async {
while (true) { while (true) {
if (shouldAbortUpload || cancelToken.isCancelled) { if (shouldAbortUpload || (cancelToken != null && cancelToken.isCompleted)) {
break; break;
}
final index = currentIndex;
if (index >= items.length) {
break;
}
currentIndex++;
final item = items[index];
if (shouldSkip?.call(item) ?? false) {
continue;
}
await processItem(item, httpClient);
} }
}
final workerFutures = <Future<void>>[]; final index = currentIndex;
for (int i = 0; i < concurrentWorkers; i++) { if (index >= items.length) {
workerFutures.add(worker(httpClients[i])); break;
} }
currentIndex++;
await Future.wait(workerFutures); final item = items[index];
} finally {
for (final client in httpClients) { if (shouldSkip?.call(item) ?? false) {
client.close(); continue;
}
await processItem(item);
} }
} }
final workerFutures = <Future<void>>[];
for (int i = 0; i < concurrentWorkers; i++) {
workerFutures.add(worker());
}
await Future.wait(workerFutures);
} }
Future<void> _uploadSingleAsset( Future<void> _uploadSingleAsset(
LocalAsset asset, LocalAsset asset,
Client httpClient, Completer<void>? cancelToken, {
CancellationToken cancelToken, {
required UploadCallbacks callbacks, required UploadCallbacks callbacks,
}) async { }) async {
File? file; File? file;
@@ -343,7 +323,6 @@ class ForegroundUploadService {
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
final deviceId = Store.get(StoreKey.deviceId); final deviceId = Store.get(StoreKey.deviceId);
final headers = ApiService.getRequestHeaders();
final fields = { final fields = {
'deviceAssetId': asset.localId!, 'deviceAssetId': asset.localId!,
'deviceId': deviceId, 'deviceId': deviceId,
@@ -358,15 +337,15 @@ class ForegroundUploadService {
if (entity.isLivePhoto && livePhotoFile != null) { if (entity.isLivePhoto && livePhotoFile != null) {
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path)); final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
final onProgress = callbacks.onProgress;
final livePhotoResult = await _uploadRepository.uploadFile( final livePhotoResult = await _uploadRepository.uploadFile(
file: livePhotoFile, file: livePhotoFile,
originalFileName: livePhotoTitle, originalFileName: livePhotoTitle,
headers: headers,
fields: fields, fields: fields,
httpClient: httpClient,
cancelToken: cancelToken, cancelToken: cancelToken,
onProgress: (bytes, totalBytes) => onProgress: onProgress != null
callbacks.onProgress?.call(asset.localId!, livePhotoTitle, bytes, totalBytes), ? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes)
: null,
logContext: 'livePhotoVideo[${asset.localId}]', logContext: 'livePhotoVideo[${asset.localId}]',
); );
@@ -395,15 +374,15 @@ class ForegroundUploadService {
]); ]);
} }
final onProgress = callbacks.onProgress;
final result = await _uploadRepository.uploadFile( final result = await _uploadRepository.uploadFile(
file: file, file: file,
originalFileName: originalFileName, originalFileName: originalFileName,
headers: headers,
fields: fields, fields: fields,
httpClient: httpClient,
cancelToken: cancelToken, cancelToken: cancelToken,
onProgress: (bytes, totalBytes) => onProgress: onProgress != null
callbacks.onProgress?.call(asset.localId!, originalFileName, bytes, totalBytes), ? (bytes, totalBytes) => onProgress(asset.localId!, originalFileName, bytes, totalBytes)
: null,
logContext: 'asset[${asset.localId}]', logContext: 'asset[${asset.localId}]',
); );
@@ -442,8 +421,7 @@ class ForegroundUploadService {
Future<UploadResult> _uploadSingleFile( Future<UploadResult> _uploadSingleFile(
File file, { File file, {
required String deviceAssetId, required String deviceAssetId,
required Client httpClient, required Completer<void>? cancelToken,
required CancellationToken cancelToken,
void Function(int bytes, int totalBytes)? onProgress, void Function(int bytes, int totalBytes)? onProgress,
}) async { }) async {
try { try {
@@ -452,12 +430,9 @@ class ForegroundUploadService {
final fileModifiedAt = stats.modified; final fileModifiedAt = stats.modified;
final filename = p.basename(file.path); final filename = p.basename(file.path);
final headers = ApiService.getRequestHeaders();
final deviceId = Store.get(StoreKey.deviceId);
final fields = { final fields = {
'deviceAssetId': deviceAssetId, 'deviceAssetId': deviceAssetId,
'deviceId': deviceId, 'deviceId': Store.get(StoreKey.deviceId),
'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(), 'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(),
'isFavorite': 'false', 'isFavorite': 'false',
@@ -467,11 +442,9 @@ class ForegroundUploadService {
return await _uploadRepository.uploadFile( return await _uploadRepository.uploadFile(
file: file, file: file,
originalFileName: filename, originalFileName: filename,
headers: headers,
fields: fields, fields: fields,
httpClient: httpClient,
cancelToken: cancelToken, cancelToken: cancelToken,
onProgress: onProgress ?? (_, __) {}, onProgress: onProgress,
logContext: 'shareIntent[$deviceAssetId]', logContext: 'shareIntent[$deviceAssetId]',
); );
} catch (e) { } catch (e) {
@@ -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;
};
}
}
-27
View File
@@ -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<bool>, 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);
}
}
-2
View File
@@ -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/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/bootstrap.dart';
import 'package:immich_mobile/utils/debug_print.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:immich_mobile/wm_executor.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:worker_manager/worker_manager.dart'; import 'package:worker_manager/worker_manager.dart';
@@ -54,7 +53,6 @@ Cancelable<T?> runInIsolateGentle<T>({
Logger log = Logger("IsolateLogger"); Logger log = Logger("IsolateLogger");
try { try {
HttpSSLOptions.apply();
result = await computation(ref); result = await computation(ref);
} on CanceledError { } on CanceledError {
log.warning("Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}"); log.warning("Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}");
@@ -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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.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/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/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/bytes_units.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/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/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/custom_proxy_headers_settings/custom_proxy_headers_settings.dart';
import 'package:immich_mobile/widgets/settings/local_storage_settings.dart'; import 'package:immich_mobile/widgets/settings/local_storage_settings.dart';
@@ -31,15 +29,12 @@ class AdvancedSettings extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
bool isLoggedIn = ref.read(currentUserProvider) != null;
final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting); final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid); final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final isManageMediaSupported = useState(false); final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false); final manageMediaAndroidPermission = useState(false);
final levelId = useAppSettingsState(AppSettingsEnum.logLevel); final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
final allowSelfSignedSSLCert = useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert);
final useAlternatePMFilter = useAppSettingsState(AppSettingsEnum.photoManagerCustomFilter); final useAlternatePMFilter = useAppSettingsState(AppSettingsEnum.photoManagerCustomFilter);
final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled); final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled);
@@ -120,15 +115,8 @@ class AdvancedSettings extends HookConsumerWidget {
subtitle: "advanced_settings_prefer_remote_subtitle".tr(), subtitle: "advanced_settings_prefer_remote_subtitle".tr(),
), ),
if (!Store.isBetaTimelineEnabled) const LocalStorageSettings(), 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(), const CustomProxyHeaderSettings(),
SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null), const SslClientCertSettings(),
if (!Store.isBetaTimelineEnabled) if (!Store.isBetaTimelineEnabled)
SettingsSwitchListTile( SettingsSwitchListTile(
valueNotifier: useAlternatePMFilter, valueNotifier: useAlternatePMFilter,
@@ -1,18 +1,16 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/platform/network_api.g.dart'; import 'package:immich_mobile/platform/network_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
class SslClientCertSettings extends StatefulWidget { class SslClientCertSettings extends StatefulWidget {
const SslClientCertSettings({super.key, required this.isLoggedIn}); const SslClientCertSettings({super.key});
final bool isLoggedIn;
@override @override
State<StatefulWidget> createState() => _SslClientCertSettingsState(); State<StatefulWidget> createState() => _SslClientCertSettingsState();
@@ -21,9 +19,24 @@ class SslClientCertSettings extends StatefulWidget {
class _SslClientCertSettingsState extends State<SslClientCertSettings> { class _SslClientCertSettingsState extends State<SslClientCertSettings> {
final _log = Logger("SslClientCertSettings"); final _log = Logger("SslClientCertSettings");
bool isCertExist; bool isCertExist = false;
_SslClientCertSettingsState() : isCertExist = SSLClientCertStoreVal.load() != null; @override
void initState() {
super.initState();
unawaited(_checkCertificate());
}
Future<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -45,11 +58,8 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
ElevatedButton(onPressed: widget.isLoggedIn ? null : importCert, child: Text("client_cert_import".tr())), ElevatedButton(onPressed: importCert, child: Text("client_cert_import".tr())),
ElevatedButton( ElevatedButton(onPressed: !isCertExist ? null : removeCert, child: Text("remove".tr())),
onPressed: widget.isLoggedIn || !isCertExist ? null : removeCert,
child: Text("remove".tr()),
),
], ],
), ),
], ],
@@ -74,9 +84,7 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
cancel: "cancel".tr(), cancel: "cancel".tr(),
confirm: "confirm".tr(), confirm: "confirm".tr(),
); );
final cert = await networkApi.selectCertificate(styling); await networkApi.selectCertificate(styling);
await SSLClientCertStoreVal(cert.data, cert.password).save();
HttpSSLOptions.apply();
setState(() => isCertExist = true); setState(() => isCertExist = true);
showMessage("client_cert_import_success_msg".tr()); showMessage("client_cert_import_success_msg".tr());
} catch (e) { } catch (e) {
@@ -91,8 +99,6 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
Future<void> removeCert() async { Future<void> removeCert() async {
try { try {
await networkApi.removeCertificate(); await networkApi.removeCertificate();
await SSLClientCertStoreVal.delete();
HttpSSLOptions.apply();
setState(() => isCertExist = false); setState(() => isCertExist = false);
showMessage("client_cert_remove_msg".tr()); showMessage("client_cert_remove_msg".tr());
} catch (e) { } catch (e) {
+7 -1
View File
@@ -34,8 +34,14 @@ abstract class NetworkApi {
void addCertificate(ClientCertData clientData); void addCertificate(ClientCertData clientData);
@async @async
ClientCertData selectCertificate(ClientCertPrompt promptText); void selectCertificate(ClientCertPrompt promptText);
@async @async
void removeCertificate(); void removeCertificate();
bool hasCertificate();
int getClientPointer();
void setRequestHeaders(Map<String, String> headers, List<String> serverUrls);
} }
+2 -8
View File
@@ -5,8 +5,7 @@ import 'package:pigeon/pigeon.dart';
dartOut: 'lib/platform/remote_image_api.g.dart', dartOut: 'lib/platform/remote_image_api.g.dart',
swiftOut: 'ios/Runner/Images/RemoteImages.g.swift', swiftOut: 'ios/Runner/Images/RemoteImages.g.swift',
swiftOptions: SwiftOptions(includeErrorClass: false), swiftOptions: SwiftOptions(includeErrorClass: false),
kotlinOut: kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt',
'android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt',
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images', includeErrorClass: false), kotlinOptions: KotlinOptions(package: 'app.alextran.immich.images', includeErrorClass: false),
dartOptions: DartOptions(), dartOptions: DartOptions(),
dartPackageName: 'immich_mobile', dartPackageName: 'immich_mobile',
@@ -15,12 +14,7 @@ import 'package:pigeon/pigeon.dart';
@HostApi() @HostApi()
abstract class RemoteImageApi { abstract class RemoteImageApi {
@async @async
Map<String, int>? requestImage( Map<String, int>? requestImage(String url, {required int requestId, required bool preferEncoded});
String url, {
required Map<String, String> headers,
required int requestId,
required bool preferEncoded,
});
void cancelRequest(int requestId); void cancelRequest(int requestId);
+30 -43
View File
@@ -201,22 +201,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.9.5" 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: cast:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -313,14 +297,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" 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: crop_image:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -356,11 +332,12 @@ packages:
cupertino_http: cupertino_http:
dependency: "direct main" dependency: "direct main"
description: description:
name: cupertino_http path: "pkgs/cupertino_http"
sha256: "82cbec60c90bf785a047a9525688b6dacac444e177e1d5a5876963d3c50369e8" ref: a0a933358517c6d01cff37fc2a2752ee2d744a3c
url: "https://pub.dev" resolved-ref: a0a933358517c6d01cff37fc2a2752ee2d744a3c
source: hosted url: "https://github.com/mertalev/http"
version: "2.4.0" source: git
version: "3.0.0-wip"
custom_lint: custom_lint:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -1241,8 +1218,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: e132bc3 ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
resolved-ref: e132bc3ecc6a6d8fc2089d96f849c8a13129500e resolved-ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
url: "https://github.com/immich-app/native_video_player" url: "https://github.com/immich-app/native_video_player"
source: git source: git
version: "1.3.1" version: "1.3.1"
@@ -1286,6 +1263,15 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" 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: openapi:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1741,19 +1727,20 @@ packages:
socket_io_client: socket_io_client:
dependency: "direct main" dependency: "direct main"
description: description:
name: socket_io_client path: "."
sha256: ede469f3e4c55e8528b4e023bdedbc20832e8811ab9b61679d1ba3ed5f01f23b ref: e1d813a240b5d5b7e2f141b2b605c5429b7cd006
url: "https://pub.dev" resolved-ref: e1d813a240b5d5b7e2f141b2b605c5429b7cd006
source: hosted url: "https://github.com/mertalev/socket.io-client-dart"
version: "2.0.3+1" source: git
version: "3.1.4"
socket_io_common: socket_io_common:
dependency: transitive dependency: transitive
description: description:
name: socket_io_common name: socket_io_common
sha256: "2ab92f8ff3ebbd4b353bf4a98bee45cc157e3255464b2f90f66e09c4472047eb" sha256: "162fbaecbf4bf9a9372a62a341b3550b51dcef2f02f3e5830a297fd48203d45b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.3" version: "3.1.1"
source_gen: source_gen:
dependency: transitive dependency: transitive
description: description:
@@ -2115,21 +2102,21 @@ packages:
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
web_socket: web_socket:
dependency: transitive dependency: "direct main"
description: description:
name: web_socket name: web_socket
sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.6" version: "1.0.1"
web_socket_channel: web_socket_channel:
dependency: transitive dependency: transitive
description: description:
name: web_socket_channel name: web_socket_channel
sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.2" version: "3.0.3"
webdriver: webdriver:
dependency: transitive dependency: transitive
description: description:
+16 -5
View File
@@ -12,7 +12,6 @@ dependencies:
async: ^2.13.0 async: ^2.13.0
auto_route: ^9.2.0 auto_route: ^9.2.0
background_downloader: ^9.3.0 background_downloader: ^9.3.0
cancellation_token_http: ^2.1.0
cast: ^2.1.0 cast: ^2.1.0
collection: ^1.19.1 collection: ^1.19.1
connectivity_plus: ^6.1.3 connectivity_plus: ^6.1.3
@@ -57,7 +56,7 @@ dependencies:
native_video_player: native_video_player:
git: git:
url: https://github.com/immich-app/native_video_player url: https://github.com/immich-app/native_video_player
ref: 'e132bc3' ref: '0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2'
network_info_plus: ^6.1.3 network_info_plus: ^6.1.3
octo_image: ^2.1.0 octo_image: ^2.1.0
openapi: openapi:
@@ -76,7 +75,6 @@ dependencies:
share_handler: ^0.0.25 share_handler: ^0.0.25
share_plus: ^10.1.4 share_plus: ^10.1.4
sliver_tools: ^0.2.12 sliver_tools: ^0.2.12
socket_io_client: ^2.0.3+1
stream_transform: ^2.1.1 stream_transform: ^2.1.1
thumbhash: 0.1.0+1 thumbhash: 0.1.0+1
timezone: ^0.9.4 timezone: ^0.9.4
@@ -84,8 +82,21 @@ dependencies:
uuid: ^4.5.1 uuid: ^4.5.1
wakelock_plus: ^1.3.0 wakelock_plus: ^1.3.0
worker_manager: ^7.2.7 worker_manager: ^7.2.7
cronet_http: ^1.7.0 web_socket: ^1.0.1
cupertino_http: ^2.4.0 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: dev_dependencies:
auto_route_generator: ^9.0.0 auto_route_generator: ^9.0.0
@@ -54,13 +54,10 @@ void main() {
when(() => mockApiService.apiClient).thenReturn(mockApiClient); when(() => mockApiService.apiClient).thenReturn(mockApiClient);
when(() => mockApiService.syncApi).thenReturn(mockSyncApi); when(() => mockApiService.syncApi).thenReturn(mockSyncApi);
when(() => mockApiClient.basePath).thenReturn('http://demo.immich.app/api'); when(() => mockApiClient.basePath).thenReturn('http://demo.immich.app/api');
when(() => mockApiService.applyToParams(any(), any())).thenAnswer((_) async => {});
// Mock HTTP client behavior // Mock HTTP client behavior
when(() => mockHttpClient.send(any())).thenAnswer((_) async => mockStreamedResponse); when(() => mockHttpClient.send(any())).thenAnswer((_) async => mockStreamedResponse);
when(() => mockStreamedResponse.statusCode).thenReturn(200); when(() => mockStreamedResponse.statusCode).thenReturn(200);
when(() => mockStreamedResponse.stream).thenAnswer((_) => http.ByteStream(responseStreamController.stream)); when(() => mockStreamedResponse.stream).thenAnswer((_) => http.ByteStream(responseStreamController.stream));
when(() => mockHttpClient.close()).thenAnswer((_) => {});
sut = SyncApiRepository(mockApiService); sut = SyncApiRepository(mockApiService);
}); });
@@ -133,7 +130,6 @@ void main() {
expect(onDataCallCount, 1); expect(onDataCallCount, 1);
expect(abortWasCalledInCallback, isTrue); expect(abortWasCalledInCallback, isTrue);
expect(receivedEventsBatch1.length, testBatchSize); expect(receivedEventsBatch1.length, testBatchSize);
verify(() => mockHttpClient.close()).called(1);
}); });
test('streamChanges does not process remaining lines in finally block if aborted', () async { test('streamChanges does not process remaining lines in finally block if aborted', () async {
@@ -181,7 +177,6 @@ void main() {
expect(onDataCallCount, 1); expect(onDataCallCount, 1);
expect(abortWasCalledInCallback, isTrue); expect(abortWasCalledInCallback, isTrue);
verify(() => mockHttpClient.close()).called(1);
}); });
test('streamChanges processes remaining lines in finally block if not aborted', () async { test('streamChanges processes remaining lines in finally block if not aborted', () async {
@@ -240,7 +235,6 @@ void main() {
expect(onDataCallCount, 2); expect(onDataCallCount, 2);
expect(receivedEventsBatch1.length, testBatchSize); expect(receivedEventsBatch1.length, testBatchSize);
expect(receivedEventsBatch2.length, 1); expect(receivedEventsBatch2.length, 1);
verify(() => mockHttpClient.close()).called(1);
}); });
test('streamChanges handles stream error gracefully', () async { test('streamChanges handles stream error gracefully', () async {
@@ -265,7 +259,6 @@ void main() {
await expectLater(streamChangesFuture, throwsA(streamError)); await expectLater(streamChangesFuture, throwsA(streamError));
expect(onDataCallCount, 0); expect(onDataCallCount, 0);
verify(() => mockHttpClient.close()).called(1);
}); });
test('streamChanges throws ApiException on non-200 status code', () async { test('streamChanges throws ApiException on non-200 status code', () async {
@@ -293,6 +286,5 @@ void main() {
); );
expect(onDataCallCount, 0); expect(onDataCallCount, 0);
verify(() => mockHttpClient.close()).called(1);
}); });
} }