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