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