mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
Merge 6de4921b99 into 3174a27902
This commit is contained in:
commit
58c7c891a2
23 changed files with 796 additions and 250 deletions
|
|
@ -25,9 +25,17 @@
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||||
|
|
||||||
<application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true"
|
<application
|
||||||
android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true"
|
android:label="Immich"
|
||||||
android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false">
|
android:name=".ImmichApp"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:requestLegacyExternalStorage="true"
|
||||||
|
android:largeHeap="true"
|
||||||
|
android:enableOnBackInvokedCallback="false"
|
||||||
|
android:allowBackup="false"
|
||||||
|
>
|
||||||
|
|
||||||
<profileable android:shell="true" />
|
<profileable android:shell="true" />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package app.alextran.immich
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.security.KeyChain
|
||||||
|
import android.security.KeyChainException
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.plugin.common.BinaryMessenger
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
|
@ -10,6 +12,8 @@ import java.io.ByteArrayInputStream
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
|
import java.security.Principal
|
||||||
|
import java.security.PrivateKey
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
import javax.net.ssl.HostnameVerifier
|
import javax.net.ssl.HostnameVerifier
|
||||||
import javax.net.ssl.HttpsURLConnection
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
|
@ -21,18 +25,21 @@ import javax.net.ssl.SSLSession
|
||||||
import javax.net.ssl.TrustManager
|
import javax.net.ssl.TrustManager
|
||||||
import javax.net.ssl.TrustManagerFactory
|
import javax.net.ssl.TrustManagerFactory
|
||||||
import javax.net.ssl.X509ExtendedTrustManager
|
import javax.net.ssl.X509ExtendedTrustManager
|
||||||
|
import javax.net.ssl.X509KeyManager
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Android plugin for Dart `HttpSSLOptions`
|
* Android plugin for Dart `HttpSSLOptions`
|
||||||
*/
|
*/
|
||||||
class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||||
private var methodChannel: MethodChannel? = null
|
private var methodChannel: MethodChannel? = null
|
||||||
|
private var context: Context? = null
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
|
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) {
|
private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) {
|
||||||
|
context = ctx
|
||||||
methodChannel = MethodChannel(messenger, "immich/httpSSLOptions")
|
methodChannel = MethodChannel(messenger, "immich/httpSSLOptions")
|
||||||
methodChannel?.setMethodCallHandler(this)
|
methodChannel?.setMethodCallHandler(this)
|
||||||
}
|
}
|
||||||
|
|
@ -44,6 +51,7 @@ class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||||
private fun onDetachedFromEngine() {
|
private fun onDetachedFromEngine() {
|
||||||
methodChannel?.setMethodCallHandler(null)
|
methodChannel?.setMethodCallHandler(null)
|
||||||
methodChannel = null
|
methodChannel = null
|
||||||
|
context = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
|
@ -57,26 +65,60 @@ class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||||
tm = arrayOf(AllowSelfSignedTrustManager(args[1] as? String))
|
tm = arrayOf(AllowSelfSignedTrustManager(args[1] as? String))
|
||||||
}
|
}
|
||||||
|
|
||||||
var km: Array<KeyManager>? = null
|
// var km: Array<KeyManager>? = null
|
||||||
if (args[2] != null) {
|
// if (args[2] != null) {
|
||||||
val cert = ByteArrayInputStream(args[2] as ByteArray)
|
// val cert = ByteArrayInputStream(args[2] as ByteArray)
|
||||||
val password = (args[3] as String).toCharArray()
|
// val password = (args[3] as String).toCharArray()
|
||||||
val keyStore = KeyStore.getInstance("PKCS12")
|
// val keyStore = KeyStore.getInstance("PKCS12")
|
||||||
keyStore.load(cert, password)
|
// keyStore.load(cert, password)
|
||||||
val keyManagerFactory =
|
// val keyManagerFactory =
|
||||||
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
|
// KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
|
||||||
keyManagerFactory.init(keyStore, null)
|
// keyManagerFactory.init(keyStore, null)
|
||||||
km = keyManagerFactory.keyManagers
|
// km = keyManagerFactory.keyManagers
|
||||||
}
|
// }
|
||||||
|
|
||||||
val sslContext = SSLContext.getInstance("TLS")
|
// val sslContext = SSLContext.getInstance("TLS")
|
||||||
sslContext.init(km, tm, null)
|
// sslContext.init(km, tm, null)
|
||||||
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
// HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
||||||
|
|
||||||
HttpsURLConnection.setDefaultHostnameVerifier(AllowSelfSignedHostnameVerifier(args[1] as? String))
|
// HttpsURLConnection.setDefaultHostnameVerifier(AllowSelfSignedHostnameVerifier(args[1] as? String))
|
||||||
|
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"applyWithUserCertificates" -> {
|
||||||
|
// val args = call.arguments<ArrayList<*>>()!!
|
||||||
|
// val serverHost = args[0] as? String
|
||||||
|
// val allowSelfSigned = args[1] as Boolean
|
||||||
|
|
||||||
|
// var tm: Array<TrustManager>? = null
|
||||||
|
// if (allowSelfSigned) {
|
||||||
|
// tm = arrayOf(AllowSelfSignedTrustManager(serverHost))
|
||||||
|
// } else {
|
||||||
|
// // Use system trust store with user certificates
|
||||||
|
// tm = createSystemTrustManagers()
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Create key managers that can access user certificates
|
||||||
|
// val km = createUserKeyManagers()
|
||||||
|
|
||||||
|
// val sslContext = SSLContext.getInstance("TLS")
|
||||||
|
// sslContext.init(km, tm, null)
|
||||||
|
// HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
||||||
|
|
||||||
|
// HttpsURLConnection.setDefaultHostnameVerifier(AllowSelfSignedHostnameVerifier(serverHost))
|
||||||
|
|
||||||
|
result.success(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
"getAvailableCertificates" -> {
|
||||||
|
try {
|
||||||
|
val certificates = getAvailableUserCertificates()
|
||||||
|
result.success(certificates)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("CERT_ERROR", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
|
|
@ -143,4 +185,112 @@ class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates trust managers that use the system trust store including user-installed certificates
|
||||||
|
*/
|
||||||
|
private fun createSystemTrustManagers(): Array<TrustManager> {
|
||||||
|
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
|
|
||||||
|
// Use AndroidKeyStore which includes user-installed certificates
|
||||||
|
val keyStore = KeyStore.getInstance("AndroidKeyStore")
|
||||||
|
keyStore.load(null)
|
||||||
|
|
||||||
|
trustManagerFactory.init(keyStore)
|
||||||
|
return trustManagerFactory.trustManagers
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates key managers that can access user certificates from the Android KeyChain
|
||||||
|
*/
|
||||||
|
private fun createUserKeyManagers(): Array<KeyManager>? {
|
||||||
|
return try {
|
||||||
|
val ctx = context ?: return null
|
||||||
|
// Create a key manager that can access certificates from KeyChain
|
||||||
|
arrayOf(UserCertificateKeyManager(ctx))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets available user certificates from the Android KeyChain
|
||||||
|
*/
|
||||||
|
private fun getAvailableUserCertificates(): List<Map<String, String>> {
|
||||||
|
val certificates = mutableListOf<Map<String, String>>()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// This would require implementing certificate enumeration
|
||||||
|
// For now, return empty list as KeyChain doesn't provide direct enumeration
|
||||||
|
// In a real implementation, you might need to use KeyChain.choosePrivateKeyAlias
|
||||||
|
// with a callback to let the user select certificates
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Log error but don't fail
|
||||||
|
}
|
||||||
|
|
||||||
|
return certificates
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom KeyManager that can access user certificates from Android KeyChain
|
||||||
|
*/
|
||||||
|
private inner class UserCertificateKeyManager(private val context: Context) : X509KeyManager {
|
||||||
|
override fun chooseClientAlias(
|
||||||
|
keyTypes: Array<out String>?,
|
||||||
|
issuers: Array<out Principal>?,
|
||||||
|
socket: Socket?
|
||||||
|
): String? {
|
||||||
|
// This would need to be implemented to prompt user for certificate selection
|
||||||
|
// For now, return null to let the system handle it
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chooseServerAlias(
|
||||||
|
keyType: String?,
|
||||||
|
issuers: Array<out Principal>?,
|
||||||
|
socket: Socket?
|
||||||
|
): String? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
|
||||||
|
return try {
|
||||||
|
// Retrieve certificate chain from KeyChain
|
||||||
|
if (alias != null) {
|
||||||
|
KeyChain.getCertificateChain(context, alias)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: KeyChainException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPrivateKey(alias: String?): PrivateKey? {
|
||||||
|
return try {
|
||||||
|
// Retrieve private key from KeyChain
|
||||||
|
if (alias != null) {
|
||||||
|
KeyChain.getPrivateKey(context, alias)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: KeyChainException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getClientAliases(
|
||||||
|
keyType: String?,
|
||||||
|
issuers: Array<out Principal>?
|
||||||
|
): Array<String>? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getServerAliases(
|
||||||
|
keyType: String?,
|
||||||
|
issuers: Array<out Principal>?
|
||||||
|
): Array<String>? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||||
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
||||||
|
|
||||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||||
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
// flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config>
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system"/>
|
||||||
|
<certificates src="user"/>
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
</network-security-config>
|
||||||
80
mobile/lib/common/http.dart
Normal file
80
mobile/lib/common/http.dart
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:http/io_client.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/utils/user_agent.dart';
|
||||||
|
import 'package:ok_http/ok_http.dart';
|
||||||
|
|
||||||
|
/// Top-level function for compute isolate to load private key and certificate chain
|
||||||
|
/// This must be top-level to work with compute()
|
||||||
|
(PrivateKey?, List<X509Certificate>?) _loadPrivateKeyAndCertificateChainFromAliasCompute(String alias) {
|
||||||
|
PrivateKey? pkey;
|
||||||
|
List<X509Certificate>? certs;
|
||||||
|
(pkey, certs) = loadPrivateKeyAndCertificateChainFromAlias(alias);
|
||||||
|
return (pkey, certs);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImmichHttpClientSingleton {
|
||||||
|
static _ImmichHttpClientSingleton? _instance;
|
||||||
|
Client? _client;
|
||||||
|
|
||||||
|
_ImmichHttpClientSingleton._();
|
||||||
|
|
||||||
|
static _ImmichHttpClientSingleton get instance {
|
||||||
|
_instance ??= _ImmichHttpClientSingleton._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Client getClient() {
|
||||||
|
if (_client == null) {
|
||||||
|
throw "Client is not initialized!";
|
||||||
|
}
|
||||||
|
return _client!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the HTTP client with proper async handling to avoid main thread deadlocks
|
||||||
|
Future<void> refreshClient() async {
|
||||||
|
String userAgent = getUserAgentString();
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
// Unfortunately cronet doesn't support mTLS - so we use OkHttpClient
|
||||||
|
String pKeyAlias = SSLClientCertStoreVal.load()?.privateKeyAlias ?? "";
|
||||||
|
PrivateKey? pKey;
|
||||||
|
List<X509Certificate>? certs;
|
||||||
|
if (pKeyAlias != "") {
|
||||||
|
// Run this in a compute isolate to avoid main thread deadlocks
|
||||||
|
(pKey, certs) = await compute(_loadPrivateKeyAndCertificateChainFromAliasCompute, pKeyAlias);
|
||||||
|
}
|
||||||
|
OkHttpClient okHttpClient = OkHttpClient(
|
||||||
|
configuration: OkHttpClientConfiguration(
|
||||||
|
clientPrivateKey: pKey,
|
||||||
|
clientCertificateChain: certs,
|
||||||
|
validateServerCertificates: true,
|
||||||
|
userAgent: userAgent,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_client = okHttpClient;
|
||||||
|
} else {
|
||||||
|
_client = IOClient(HttpClient()..userAgent = userAgent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_client?.close();
|
||||||
|
_client = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an optimized HTTP client based on the platform (singleton pattern)
|
||||||
|
///
|
||||||
|
/// On Android, uses CronetEngine for better performance with memory caching
|
||||||
|
/// On other platforms, falls back to standard HTTP client
|
||||||
|
/// Returns the same client instance for all calls after first initialization
|
||||||
|
Client immichHttpClient() {
|
||||||
|
return _ImmichHttpClientSingleton.instance.getClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshClient() async {
|
||||||
|
return _ImmichHttpClientSingleton.instance.refreshClient();
|
||||||
|
}
|
||||||
39
mobile/lib/common/package_info.dart
Normal file
39
mobile/lib/common/package_info.dart
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
class PackageInfoSingleton {
|
||||||
|
static PackageInfoSingleton? _instance;
|
||||||
|
static PackageInfoSingleton get instance {
|
||||||
|
_instance ??= PackageInfoSingleton._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
PackageInfoSingleton._();
|
||||||
|
|
||||||
|
PackageInfo? _packageInfo;
|
||||||
|
|
||||||
|
/// Initializes the package info by calling PackageInfo.fromPlatform()
|
||||||
|
/// This should be called once during app initialization
|
||||||
|
Future<void> init() async {
|
||||||
|
_packageInfo ??= await PackageInfo.fromPlatform();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the PackageInfo instance
|
||||||
|
/// Returns null if init() hasn't been called yet
|
||||||
|
PackageInfo? get packageInfo => _packageInfo;
|
||||||
|
|
||||||
|
/// Returns the app name
|
||||||
|
/// Returns null if init() hasn't been called yet
|
||||||
|
String? get appName => _packageInfo?.appName;
|
||||||
|
|
||||||
|
/// Returns the app version
|
||||||
|
/// Returns null if init() hasn't been called yet
|
||||||
|
String? get version => _packageInfo?.version;
|
||||||
|
|
||||||
|
/// Returns the build number
|
||||||
|
/// Returns null if init() hasn't been called yet
|
||||||
|
String? get buildNumber => _packageInfo?.buildNumber;
|
||||||
|
|
||||||
|
/// Returns the package name
|
||||||
|
/// Returns null if init() hasn't been called yet
|
||||||
|
String? get packageName => _packageInfo?.packageName;
|
||||||
|
}
|
||||||
|
|
@ -17,8 +17,8 @@ enum StoreKey<T> {
|
||||||
serverEndpoint<String>._(12),
|
serverEndpoint<String>._(12),
|
||||||
autoBackup<bool>._(13),
|
autoBackup<bool>._(13),
|
||||||
backgroundBackup<bool>._(14),
|
backgroundBackup<bool>._(14),
|
||||||
sslClientCertData<String>._(15),
|
sslClientCertData<String>._(15), // deprecated!
|
||||||
sslClientPasswd<String>._(16),
|
sslClientPasswd<String>._(16), // deprecated!
|
||||||
// user settings from [AppSettingsEnum] below:
|
// user settings from [AppSettingsEnum] below:
|
||||||
loadPreview<bool>._(100),
|
loadPreview<bool>._(100),
|
||||||
loadOriginal<bool>._(101),
|
loadOriginal<bool>._(101),
|
||||||
|
|
@ -42,33 +42,34 @@ enum StoreKey<T> {
|
||||||
mapShowFavoriteOnly<bool>._(118),
|
mapShowFavoriteOnly<bool>._(118),
|
||||||
mapRelativeDate<int>._(119),
|
mapRelativeDate<int>._(119),
|
||||||
selfSignedCert<bool>._(120),
|
selfSignedCert<bool>._(120),
|
||||||
mapIncludeArchived<bool>._(121),
|
useUserCertificates<bool>._(121),
|
||||||
ignoreIcloudAssets<bool>._(122),
|
mapIncludeArchived<bool>._(122),
|
||||||
selectedAlbumSortReverse<bool>._(123),
|
ignoreIcloudAssets<bool>._(123),
|
||||||
mapThemeMode<int>._(124),
|
selectedAlbumSortReverse<bool>._(124),
|
||||||
mapwithPartners<bool>._(125),
|
mapThemeMode<int>._(125),
|
||||||
enableHapticFeedback<bool>._(126),
|
mapwithPartners<bool>._(126),
|
||||||
customHeaders<String>._(127),
|
enableHapticFeedback<bool>._(127),
|
||||||
|
customHeaders<String>._(128),
|
||||||
|
|
||||||
// theme settings
|
// theme settings
|
||||||
primaryColor<String>._(128),
|
primaryColor<String>._(129),
|
||||||
dynamicTheme<bool>._(129),
|
dynamicTheme<bool>._(130),
|
||||||
colorfulInterface<bool>._(130),
|
colorfulInterface<bool>._(131),
|
||||||
|
|
||||||
syncAlbums<bool>._(131),
|
syncAlbums<bool>._(132),
|
||||||
|
|
||||||
// Auto endpoint switching
|
// Auto endpoint switching
|
||||||
autoEndpointSwitching<bool>._(132),
|
autoEndpointSwitching<bool>._(133),
|
||||||
preferredWifiName<String>._(133),
|
preferredWifiName<String>._(134),
|
||||||
localEndpoint<String>._(134),
|
localEndpoint<String>._(135),
|
||||||
externalEndpointList<String>._(135),
|
externalEndpointList<String>._(136),
|
||||||
|
|
||||||
// Video settings
|
// Video settings
|
||||||
loadOriginalVideo<bool>._(136),
|
loadOriginalVideo<bool>._(137),
|
||||||
manageLocalMediaAndroid<bool>._(137),
|
manageLocalMediaAndroid<bool>._(138),
|
||||||
|
|
||||||
// Read-only Mode settings
|
// Read-only Mode settings
|
||||||
readonlyModeEnabled<bool>._(138),
|
readonlyModeEnabled<bool>._(139),
|
||||||
|
|
||||||
autoPlayVideo<bool>._(139),
|
autoPlayVideo<bool>._(139),
|
||||||
|
|
||||||
|
|
@ -81,7 +82,10 @@ enum StoreKey<T> {
|
||||||
useWifiForUploadPhotos<bool>._(1005),
|
useWifiForUploadPhotos<bool>._(1005),
|
||||||
needBetaMigration<bool>._(1006),
|
needBetaMigration<bool>._(1006),
|
||||||
// TODO: Remove this after patching open-api
|
// TODO: Remove this after patching open-api
|
||||||
shouldResetSync<bool>._(1007);
|
shouldResetSync<bool>._(1007),
|
||||||
|
|
||||||
|
// mTLS
|
||||||
|
mTlsSelectedPrivateKey<String>._(1008);
|
||||||
|
|
||||||
const StoreKey._(this.id);
|
const StoreKey._(this.id);
|
||||||
final int id;
|
final int id;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
|
|
||||||
|
|
@ -8,31 +5,19 @@ import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
final Store = StoreService.I;
|
final Store = StoreService.I;
|
||||||
|
|
||||||
class SSLClientCertStoreVal {
|
class SSLClientCertStoreVal {
|
||||||
final Uint8List data;
|
final String privateKeyAlias;
|
||||||
final String? password;
|
const SSLClientCertStoreVal(this.privateKeyAlias);
|
||||||
|
|
||||||
const SSLClientCertStoreVal(this.data, this.password);
|
|
||||||
|
|
||||||
Future<void> save() async {
|
Future<void> save() async {
|
||||||
final b64Str = base64Encode(data);
|
await Store.put(StoreKey.mTlsSelectedPrivateKey, privateKeyAlias);
|
||||||
await Store.put(StoreKey.sslClientCertData, b64Str);
|
|
||||||
if (password != null) {
|
|
||||||
await Store.put(StoreKey.sslClientPasswd, password!);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static SSLClientCertStoreVal? load() {
|
static SSLClientCertStoreVal? load() {
|
||||||
final b64Str = Store.tryGet<String>(StoreKey.sslClientCertData);
|
final privateKeyAlias = Store.tryGet<String>(StoreKey.mTlsSelectedPrivateKey) ?? "";
|
||||||
if (b64Str == null) {
|
return SSLClientCertStoreVal(privateKeyAlias);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final Uint8List certData = base64Decode(b64Str);
|
|
||||||
final passwd = Store.tryGet<String>(StoreKey.sslClientPasswd);
|
|
||||||
return SSLClientCertStoreVal(certData, passwd);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> delete() async {
|
static Future<void> delete() async {
|
||||||
await Store.delete(StoreKey.sslClientCertData);
|
await Store.delete(StoreKey.mTlsSelectedPrivateKey);
|
||||||
await Store.delete(StoreKey.sslClientPasswd);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:immich_mobile/common/http.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/domain/models/sync_event.model.dart';
|
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||||
|
|
@ -13,7 +14,8 @@ import 'package:openapi/api.dart';
|
||||||
class SyncApiRepository {
|
class SyncApiRepository {
|
||||||
final Logger _logger = Logger('SyncApiRepository');
|
final Logger _logger = Logger('SyncApiRepository');
|
||||||
final ApiService _api;
|
final ApiService _api;
|
||||||
SyncApiRepository(this._api);
|
final http.Client httpClient;
|
||||||
|
SyncApiRepository(this._api, {http.Client? httpClient}) : httpClient = httpClient ?? immichHttpClient();
|
||||||
|
|
||||||
Future<void> ack(List<String> data) {
|
Future<void> ack(List<String> data) {
|
||||||
return _api.syncApi.sendSyncAck(SyncAckSetDto(acks: data));
|
return _api.syncApi.sendSyncAck(SyncAckSetDto(acks: data));
|
||||||
|
|
@ -23,10 +25,8 @@ class SyncApiRepository {
|
||||||
Future<void> Function(List<SyncEvent>, Function() abort, Function() reset) onData, {
|
Future<void> Function(List<SyncEvent>, Function() abort, Function() reset) onData, {
|
||||||
Function()? onReset,
|
Function()? onReset,
|
||||||
int batchSize = kSyncEventBatchSize,
|
int batchSize = kSyncEventBatchSize,
|
||||||
http.Client? httpClient,
|
|
||||||
}) async {
|
}) async {
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
final client = httpClient ?? http.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'};
|
||||||
|
|
@ -78,7 +78,7 @@ class SyncApiRepository {
|
||||||
final reset = onReset ?? () {};
|
final reset = onReset ?? () {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await client.send(request);
|
final response = await httpClient.send(request);
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
final errorBody = await response.stream.bytesToString();
|
final errorBody = await response.stream.bytesToString();
|
||||||
|
|
@ -111,8 +111,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");
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_displaymode/flutter_displaymode.dart';
|
import 'package:flutter_displaymode/flutter_displaymode.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/common/http.dart';
|
||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
import 'package:immich_mobile/constants/locales.dart';
|
import 'package:immich_mobile/constants/locales.dart';
|
||||||
import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
||||||
|
|
@ -40,32 +41,51 @@ import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
import 'package:immich_mobile/utils/http_ssl_options.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/pages/common/error_display_screen.dart';
|
||||||
|
import 'package:immich_mobile/common/package_info.dart';
|
||||||
import 'package:intl/date_symbol_data_local.dart';
|
import 'package:intl/date_symbol_data_local.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:timezone/data/latest.dart';
|
import 'package:timezone/data/latest.dart';
|
||||||
import 'package:worker_manager/worker_manager.dart';
|
import 'package:worker_manager/worker_manager.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
ImmichWidgetsBinding();
|
try {
|
||||||
unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock());
|
ImmichWidgetsBinding();
|
||||||
final (isar, drift, logDb) = await Bootstrap.initDB();
|
unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock());
|
||||||
await Bootstrap.initDomain(isar, drift, logDb);
|
final (isar, drift, logDb) = await Bootstrap.initDB();
|
||||||
await initApp();
|
await Bootstrap.initDomain(isar, drift, logDb);
|
||||||
// Warm-up isolate pool for worker manager
|
await initApp();
|
||||||
await workerManager.init(dynamicSpawning: true);
|
await setPackageInfo();
|
||||||
await migrateDatabaseIfNeeded(isar, drift);
|
await refreshClient();
|
||||||
HttpSSLOptions.apply();
|
// Warm-up isolate pool for worker manager
|
||||||
|
await workerManager.init(dynamicSpawning: true);
|
||||||
|
await migrateDatabaseIfNeeded(isar, drift);
|
||||||
|
HttpSSLOptions.apply();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
dbProvider.overrideWithValue(isar),
|
dbProvider.overrideWithValue(isar),
|
||||||
isarProvider.overrideWithValue(isar),
|
isarProvider.overrideWithValue(isar),
|
||||||
driftProvider.overrideWith(driftOverride(drift)),
|
driftProvider.overrideWith(driftOverride(drift)),
|
||||||
],
|
],
|
||||||
child: const MainWidget(),
|
child: const MainWidget(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
// Log the error for debugging
|
||||||
|
debugPrint('Fatal initialization error: $e');
|
||||||
|
debugPrint('Stack trace: $stackTrace');
|
||||||
|
|
||||||
|
// Display a prominent error message to the user
|
||||||
|
runApp(
|
||||||
|
MaterialApp(
|
||||||
|
title: 'Immich - Initialization Error',
|
||||||
|
home: ErrorDisplayScreen(error: e.toString(), stackTrace: stackTrace.toString()),
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initApp() async {
|
Future<void> initApp() async {
|
||||||
|
|
@ -120,6 +140,10 @@ Future<void> initApp() async {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setPackageInfo() async {
|
||||||
|
await PackageInfoSingleton.instance.init();
|
||||||
|
}
|
||||||
|
|
||||||
class ImmichApp extends ConsumerStatefulWidget {
|
class ImmichApp extends ConsumerStatefulWidget {
|
||||||
const ImmichApp({super.key});
|
const ImmichApp({super.key});
|
||||||
|
|
||||||
|
|
|
||||||
203
mobile/lib/pages/common/error_display_screen.dart
Normal file
203
mobile/lib/pages/common/error_display_screen.dart
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
class ErrorDisplayScreen extends StatelessWidget {
|
||||||
|
final String error;
|
||||||
|
final String stackTrace;
|
||||||
|
|
||||||
|
const ErrorDisplayScreen({
|
||||||
|
super.key,
|
||||||
|
required this.error,
|
||||||
|
required this.stackTrace,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// App logo with error indicator
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
Image.asset(
|
||||||
|
'assets/immich-logo.png',
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.error,
|
||||||
|
color: theme.colorScheme.onError,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Error title
|
||||||
|
Text(
|
||||||
|
'Initialization Failed',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
Text(
|
||||||
|
'Failed to start due to an error during initialization.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Expandable error details
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: theme.colorScheme.error),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Error Details:',
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () async {
|
||||||
|
String errorDetails;
|
||||||
|
try {
|
||||||
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
|
final appVersion = '${packageInfo.version} build.${packageInfo.buildNumber}';
|
||||||
|
errorDetails = 'App Version: $appVersion\n\nError: $error\n\nStack Trace:\n$stackTrace';
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback if package info fails
|
||||||
|
errorDetails = 'Error: $error\n\nStack Trace:\n$stackTrace';
|
||||||
|
}
|
||||||
|
|
||||||
|
Clipboard.setData(ClipboardData(text: errorDetails)).then((_) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('Error details copied to clipboard'),
|
||||||
|
backgroundColor: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
Icons.copy,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
error,
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ExpansionTile(
|
||||||
|
title: Text(
|
||||||
|
'Stack Trace',
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.tertiary,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
tilePadding: EdgeInsets.zero,
|
||||||
|
iconColor: theme.colorScheme.onSurfaceVariant,
|
||||||
|
collapsedIconColor: theme.colorScheme.onSurfaceVariant,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Text(
|
||||||
|
stackTrace,
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Restart button
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
// Attempt to restart the app
|
||||||
|
if (Platform.isAndroid || Platform.isIOS) {
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
label: const Text('Close App'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: theme.colorScheme.error,
|
||||||
|
foregroundColor: theme.colorScheme.onError,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,10 @@ 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:cancellation_token_http/http.dart' as http;
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:immich_mobile/common/http.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';
|
||||||
|
|
@ -92,8 +94,8 @@ class UploadRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> backupWithDartClient(Iterable<UploadTaskWithFile> tasks, CancellationToken cancelToken) async {
|
Future<void> backupWithDartClient(Iterable<UploadTaskWithFile> tasks, http.CancellationToken cancelToken) async {
|
||||||
final httpClient = Client();
|
final httpClient = immichHttpClient();
|
||||||
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
|
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||||
|
|
||||||
Logger logger = Logger('UploadRepository');
|
Logger logger = Logger('UploadRepository');
|
||||||
|
|
@ -118,7 +120,7 @@ class UploadRepository {
|
||||||
baseRequest.fields.addAll(candidate.task.fields);
|
baseRequest.fields.addAll(candidate.task.fields);
|
||||||
baseRequest.files.add(assetRawUploadData);
|
baseRequest.files.add(assetRawUploadData);
|
||||||
|
|
||||||
final response = await httpClient.send(baseRequest, cancellationToken: cancelToken);
|
final response = await httpClient.send(baseRequest);
|
||||||
|
|
||||||
final responseBody = jsonDecode(await response.stream.bytesToString());
|
final responseBody = jsonDecode(await response.stream.bytesToString());
|
||||||
|
|
||||||
|
|
@ -131,7 +133,7 @@ class UploadRepository {
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} on CancelledException {
|
} on http.CancelledException {
|
||||||
logger.warning("Backup was cancelled by the user");
|
logger.warning("Backup was cancelled by the user");
|
||||||
break;
|
break;
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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:http/http.dart';
|
||||||
|
import 'package:immich_mobile/common/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/utils/url_helper.dart';
|
import 'package:immich_mobile/utils/url_helper.dart';
|
||||||
|
|
@ -50,6 +51,7 @@ class ApiService implements Authentication {
|
||||||
|
|
||||||
setEndpoint(String endpoint) {
|
setEndpoint(String endpoint) {
|
||||||
_apiClient = ApiClient(basePath: endpoint, authentication: this);
|
_apiClient = ApiClient(basePath: endpoint, authentication: this);
|
||||||
|
_apiClient.client = immichHttpClient();
|
||||||
_setUserAgentHeader();
|
_setUserAgentHeader();
|
||||||
if (_accessToken != null) {
|
if (_accessToken != null) {
|
||||||
setAccessToken(_accessToken!);
|
setAccessToken(_accessToken!);
|
||||||
|
|
@ -77,7 +79,7 @@ class ApiService implements Authentication {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _setUserAgentHeader() async {
|
Future<void> _setUserAgentHeader() async {
|
||||||
final userAgent = await getUserAgentString();
|
final userAgent = getUserAgentString();
|
||||||
_apiClient.addDefaultHeader('User-Agent', userAgent);
|
_apiClient.addDefaultHeader('User-Agent', userAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ enum AppSettingsEnum<T> {
|
||||||
mapwithPartners<bool>(StoreKey.mapwithPartners, null, false),
|
mapwithPartners<bool>(StoreKey.mapwithPartners, null, false),
|
||||||
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
|
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
|
||||||
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
|
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
|
||||||
|
useUserCertificates<bool>(StoreKey.useUserCertificates, null, false),
|
||||||
ignoreIcloudAssets<bool>(StoreKey.ignoreIcloudAssets, null, false),
|
ignoreIcloudAssets<bool>(StoreKey.ignoreIcloudAssets, null, false),
|
||||||
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, false),
|
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, false),
|
||||||
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
|
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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/common/http.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';
|
||||||
|
|
@ -64,27 +63,18 @@ class AuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> validateAuxilaryServerUrl(String url) async {
|
Future<bool> validateAuxilaryServerUrl(String url) async {
|
||||||
final httpclient = HttpClient();
|
final httpclient = await immichHttpClient();
|
||||||
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 httpclient.get(uri, headers: ApiService.getRequestHeaders());
|
||||||
|
|
||||||
// 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;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import 'dart:io';
|
||||||
import 'package:cancellation_token_http/http.dart' as http;
|
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/common/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';
|
||||||
|
|
@ -43,8 +45,7 @@ final backupServiceProvider = Provider(
|
||||||
);
|
);
|
||||||
|
|
||||||
class BackupService {
|
class BackupService {
|
||||||
final httpClient = http.Client();
|
final dynamic _apiService;
|
||||||
final ApiService _apiService;
|
|
||||||
final Logger _log = Logger("BackupService");
|
final Logger _log = Logger("BackupService");
|
||||||
final AppSettingsService _appSetting;
|
final AppSettingsService _appSetting;
|
||||||
final AlbumService _albumService;
|
final AlbumService _albumService;
|
||||||
|
|
@ -52,6 +53,7 @@ class BackupService {
|
||||||
final FileMediaRepository _fileMediaRepository;
|
final FileMediaRepository _fileMediaRepository;
|
||||||
final AssetRepository _assetRepository;
|
final AssetRepository _assetRepository;
|
||||||
final AssetMediaRepository _assetMediaRepository;
|
final AssetMediaRepository _assetMediaRepository;
|
||||||
|
late final Client client;
|
||||||
|
|
||||||
BackupService(
|
BackupService(
|
||||||
this._apiService,
|
this._apiService,
|
||||||
|
|
@ -60,8 +62,9 @@ class BackupService {
|
||||||
this._albumMediaRepository,
|
this._albumMediaRepository,
|
||||||
this._fileMediaRepository,
|
this._fileMediaRepository,
|
||||||
this._assetRepository,
|
this._assetRepository,
|
||||||
this._assetMediaRepository,
|
this._assetMediaRepository, {
|
||||||
);
|
Client? client,
|
||||||
|
}) : client = client ?? immichHttpClient();
|
||||||
|
|
||||||
Future<List<String>?> getDeviceBackupAsset() async {
|
Future<List<String>?> getDeviceBackupAsset() async {
|
||||||
final String deviceId = Store.get(StoreKey.deviceId);
|
final String deviceId = Store.get(StoreKey.deviceId);
|
||||||
|
|
@ -306,14 +309,14 @@ 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'),
|
||||||
onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)),
|
onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)),
|
||||||
|
|
@ -348,7 +351,8 @@ class BackupService {
|
||||||
baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId;
|
baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId;
|
||||||
}
|
}
|
||||||
|
|
||||||
final response = await httpClient.send(baseRequest, cancellationToken: cancelToken);
|
// TODO: Re-add cancellation token
|
||||||
|
final response = await client.send(baseRequest);
|
||||||
|
|
||||||
final responseBody = jsonDecode(await response.stream.bytesToString());
|
final responseBody = jsonDecode(await response.stream.bytesToString());
|
||||||
|
|
||||||
|
|
@ -428,7 +432,7 @@ class BackupService {
|
||||||
Future<String?> uploadLivePhotoVideo(
|
Future<String?> uploadLivePhotoVideo(
|
||||||
String originalFileName,
|
String originalFileName,
|
||||||
File? livePhotoVideoFile,
|
File? livePhotoVideoFile,
|
||||||
MultipartRequest baseRequest,
|
ProgressMultipartRequest baseRequest,
|
||||||
http.CancellationToken cancelToken,
|
http.CancellationToken cancelToken,
|
||||||
) async {
|
) async {
|
||||||
if (livePhotoVideoFile == null) {
|
if (livePhotoVideoFile == null) {
|
||||||
|
|
@ -436,19 +440,21 @@ class BackupService {
|
||||||
}
|
}
|
||||||
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 =
|
||||||
..headers.addAll(baseRequest.headers)
|
ProgressMultipartRequest(baseRequest.method, baseRequest.url, onProgress: baseRequest.onProgress)
|
||||||
..fields.addAll(baseRequest.fields);
|
..headers.addAll(baseRequest.headers)
|
||||||
|
..fields.addAll(baseRequest.fields);
|
||||||
|
|
||||||
livePhotoReq.files.add(livePhotoRawUploadData);
|
livePhotoReq.files.add(livePhotoRawUploadData);
|
||||||
|
|
||||||
var response = await httpClient.send(livePhotoReq, cancellationToken: cancelToken);
|
final client = immichHttpClient();
|
||||||
|
var response = await client.send(livePhotoReq);
|
||||||
|
|
||||||
var responseBody = jsonDecode(await response.stream.bytesToString());
|
var responseBody = jsonDecode(await response.stream.bytesToString());
|
||||||
|
|
||||||
|
|
@ -471,17 +477,17 @@ class BackupService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class MultipartRequest extends http.MultipartRequest {
|
class ProgressMultipartRequest extends MultipartRequest {
|
||||||
/// Creates a new [MultipartRequest].
|
/// Creates a new [ProgressMultipartRequest].
|
||||||
MultipartRequest(super.method, super.url, {required this.onProgress});
|
ProgressMultipartRequest(super.method, super.url, {required this.onProgress});
|
||||||
|
|
||||||
final void Function(int bytes, int totalBytes) onProgress;
|
final void Function(int bytes, int totalBytes) onProgress;
|
||||||
|
|
||||||
/// Freezes all mutable fields and returns a
|
/// Freezes all mutable fields and returns a
|
||||||
/// single-subscription [http.ByteStream]
|
/// single-subscription [ByteStream]
|
||||||
/// that will emit the request body.
|
/// that will emit the request body.
|
||||||
@override
|
@override
|
||||||
http.ByteStream finalize() {
|
ByteStream finalize() {
|
||||||
final byteStream = super.finalize();
|
final byteStream = super.finalize();
|
||||||
|
|
||||||
final total = contentLength;
|
final total = contentLength;
|
||||||
|
|
@ -495,6 +501,6 @@ class MultipartRequest extends http.MultipartRequest {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final stream = byteStream.transform(t);
|
final stream = byteStream.transform(t);
|
||||||
return http.ByteStream(stream);
|
return ByteStream(stream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,18 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
class HttpSSLCertOverride extends HttpOverrides {
|
class HttpSSLCertOverride extends HttpOverrides {
|
||||||
static final Logger _log = Logger("HttpSSLCertOverride");
|
static final Logger _log = Logger("HttpSSLCertOverride");
|
||||||
final bool _allowSelfSignedSSLCert;
|
final bool _allowSelfSignedSSLCert;
|
||||||
final String? _serverHost;
|
final String? _serverHost;
|
||||||
final SSLClientCertStoreVal? _clientCert;
|
|
||||||
late final SecurityContext? _ctxWithCert;
|
|
||||||
|
|
||||||
HttpSSLCertOverride(this._allowSelfSignedSSLCert, this._serverHost, this._clientCert) {
|
HttpSSLCertOverride(this._allowSelfSignedSSLCert, this._serverHost);
|
||||||
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
|
@override
|
||||||
HttpClient createHttpClient(SecurityContext? context) {
|
HttpClient createHttpClient(SecurityContext? context) {
|
||||||
if (context != null) {
|
// Use system trust store with trusted roots if no client certificate is provided
|
||||||
if (_clientCert != null) {
|
context = SecurityContext(withTrustedRoots: true);
|
||||||
setClientCert(context, _clientCert);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
context = _ctxWithCert;
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.createHttpClient(context)
|
return super.createHttpClient(context)
|
||||||
..badCertificateCallback = (X509Certificate cert, String host, int port) {
|
..badCertificateCallback = (X509Certificate cert, String host, int port) {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
class HttpSSLOptions {
|
class HttpSSLOptions {
|
||||||
|
|
@ -13,11 +10,38 @@ class HttpSSLOptions {
|
||||||
static void apply({bool applyNative = true}) {
|
static void apply({bool applyNative = true}) {
|
||||||
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
|
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
|
||||||
bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue);
|
bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue);
|
||||||
_apply(allowSelfSignedSSLCert, applyNative: applyNative);
|
|
||||||
|
// Check if user certificates are enabled
|
||||||
|
bool useUserCerts = Store.get(
|
||||||
|
AppSettingsEnum.useUserCertificates.storeKey,
|
||||||
|
AppSettingsEnum.useUserCertificates.defaultValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (useUserCerts) {
|
||||||
|
_applyWithUserCertificates(allowSelfSignedSSLCert, applyNative: applyNative);
|
||||||
|
} else {
|
||||||
|
_apply(allowSelfSignedSSLCert, applyNative: applyNative);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void applyFromSettings(bool newValue) {
|
static void applyFromSettings(bool newValue) {
|
||||||
_apply(newValue);
|
// Check if user certificates are enabled
|
||||||
|
bool useUserCerts = Store.get(
|
||||||
|
AppSettingsEnum.useUserCertificates.storeKey,
|
||||||
|
AppSettingsEnum.useUserCertificates.defaultValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (useUserCerts) {
|
||||||
|
_applyWithUserCertificates(newValue);
|
||||||
|
} else {
|
||||||
|
_apply(newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void applyWithUserCertificates({bool applyNative = true}) {
|
||||||
|
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
|
||||||
|
bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey<bool>, setting.defaultValue);
|
||||||
|
_applyWithUserCertificates(allowSelfSignedSSLCert, applyNative: applyNative);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void _apply(bool allowSelfSignedSSLCert, {bool applyNative = true}) {
|
static void _apply(bool allowSelfSignedSSLCert, {bool applyNative = true}) {
|
||||||
|
|
@ -26,17 +50,45 @@ class HttpSSLOptions {
|
||||||
serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
|
serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
|
||||||
}
|
}
|
||||||
|
|
||||||
SSLClientCertStoreVal? clientCert = SSLClientCertStoreVal.load();
|
// HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert);
|
||||||
|
|
||||||
HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert);
|
// if (applyNative && Platform.isAndroid) {
|
||||||
|
// _channel
|
||||||
|
// .invokeMethod("apply", [allowSelfSignedSSLCert, serverHost, clientCert?.data, clientCert?.password])
|
||||||
|
// .onError<PlatformException>((e, _) {
|
||||||
|
// final log = Logger("HttpSSLOptions");
|
||||||
|
// log.severe('Failed to set SSL options', e.message);
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
if (applyNative && Platform.isAndroid) {
|
static void _applyWithUserCertificates(bool allowSelfSignedSSLCert, {bool applyNative = true}) {
|
||||||
_channel
|
String? serverHost;
|
||||||
.invokeMethod("apply", [allowSelfSignedSSLCert, serverHost, clientCert?.data, clientCert?.password])
|
if (Store.tryGet(StoreKey.currentUser) != null) {
|
||||||
.onError<PlatformException>((e, _) {
|
serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
|
||||||
final log = Logger("HttpSSLOptions");
|
}
|
||||||
log.severe('Failed to set SSL options', e.message);
|
|
||||||
});
|
// Create SSL override that uses user certificates from system store
|
||||||
|
// HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, null);
|
||||||
|
|
||||||
|
// if (applyNative) {
|
||||||
|
// _channel
|
||||||
|
// .invokeMethod("applyWithUserCertificates", [serverHost, allowSelfSignedSSLCert])
|
||||||
|
// .onError<PlatformException>((e, _) {
|
||||||
|
// final log = Logger("HttpSSLOptions");
|
||||||
|
// log.severe('Failed to set SSL options with user certificates', e.message);
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<Map<String, String>>> getAvailableUserCertificates() async {
|
||||||
|
try {
|
||||||
|
final List<dynamic> certificates = await _channel.invokeMethod("getAvailableCertificates");
|
||||||
|
return certificates.map((cert) => Map<String, String>.from(cert)).toList();
|
||||||
|
} catch (e) {
|
||||||
|
final log = Logger("HttpSSLOptions");
|
||||||
|
log.severe('Failed to get available user certificates: $e');
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:immich_mobile/common/package_info.dart';
|
||||||
|
|
||||||
Future<String> getUserAgentString() async {
|
String getUserAgentString() {
|
||||||
final packageInfo = await PackageInfo.fromPlatform();
|
final packageInfo = PackageInfoSingleton.instance;
|
||||||
String platform;
|
String platform;
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
platform = 'Android';
|
platform = 'Android';
|
||||||
|
|
@ -11,5 +11,6 @@ Future<String> getUserAgentString() async {
|
||||||
} else {
|
} else {
|
||||||
platform = 'Unknown';
|
platform = 'Unknown';
|
||||||
}
|
}
|
||||||
return 'Immich_${platform}_${packageInfo.version}';
|
final version = packageInfo.version ?? 'unknown';
|
||||||
|
return 'Immich_${platform}_$version';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:immich_mobile/common/http.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
import 'package:ok_http/ok_http.dart';
|
||||||
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
|
||||||
|
|
||||||
class SslClientCertSettings extends StatefulWidget {
|
class SslClientCertSettings extends StatefulWidget {
|
||||||
const SslClientCertSettings({super.key, required this.isLoggedIn});
|
const SslClientCertSettings({super.key, required this.isLoggedIn});
|
||||||
|
|
@ -20,9 +16,9 @@ class SslClientCertSettings extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SslClientCertSettingsState extends State<SslClientCertSettings> {
|
class _SslClientCertSettingsState extends State<SslClientCertSettings> {
|
||||||
_SslClientCertSettingsState() : isCertExist = SSLClientCertStoreVal.load() != null;
|
_SslClientCertSettingsState() : pKeyAlias = SSLClientCertStoreVal.load()?.privateKeyAlias ?? "";
|
||||||
|
|
||||||
bool isCertExist;
|
String pKeyAlias = "";
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -39,21 +35,57 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
|
||||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Row(
|
if (pKeyAlias != "")
|
||||||
mainAxisSize: MainAxisSize.max,
|
Center(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: Container(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
margin: const EdgeInsets.fromLTRB(0, 6, 0, 6),
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
ElevatedButton(
|
decoration: BoxDecoration(
|
||||||
onPressed: widget.isLoggedIn ? null : () => importCert(context),
|
color: context.colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||||
child: Text("client_cert_import".tr()),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: context.colorScheme.primary.withValues(alpha: 0.3), width: 1),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.lock_outline, size: 16, color: context.colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
pKeyAlias,
|
||||||
|
style: context.textTheme.bodySmall?.copyWith(
|
||||||
|
color: context.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 15),
|
),
|
||||||
ElevatedButton(
|
if (pKeyAlias == "")
|
||||||
onPressed: widget.isLoggedIn || !isCertExist ? null : () async => await removeCert(context),
|
Center(
|
||||||
child: Text("remove".tr()),
|
child: Container(
|
||||||
|
margin: const EdgeInsets.fromLTRB(0, 6, 0, 6),
|
||||||
|
child: Text("no_certificate_selected".tr(), style: const TextStyle(fontStyle: FontStyle.italic)),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
Center(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
spacing: 6,
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: widget.isLoggedIn ? null : () async => await selectCert(context),
|
||||||
|
child: Text("select".tr()),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: widget.isLoggedIn || pKeyAlias == "" ? null : () async => await removeCert(context),
|
||||||
|
child: Text("remove".tr()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -70,61 +102,19 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> storeCert(BuildContext context, Uint8List data, String? password) async {
|
Future<void> selectCert(BuildContext context) async {
|
||||||
if (password != null && password.isEmpty) {
|
String? chosenAlias = await choosePrivateKeyAlias();
|
||||||
password = null;
|
if (chosenAlias == null) {
|
||||||
}
|
|
||||||
final cert = SSLClientCertStoreVal(data, password);
|
|
||||||
// Test whether the certificate is valid
|
|
||||||
final isCertValid = HttpSSLCertOverride.setClientCert(SecurityContext(withTrustedRoots: true), cert);
|
|
||||||
if (!isCertValid) {
|
|
||||||
showMessage(context, "client_cert_invalid_msg".tr());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await cert.save();
|
setState(() => pKeyAlias = chosenAlias);
|
||||||
HttpSSLOptions.apply();
|
await SSLClientCertStoreVal(chosenAlias).save();
|
||||||
setState(() => isCertExist = true);
|
await refreshClient();
|
||||||
showMessage(context, "client_cert_import_success_msg".tr());
|
|
||||||
}
|
|
||||||
|
|
||||||
void setPassword(BuildContext context, Uint8List data) {
|
|
||||||
final password = TextEditingController();
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
content: TextField(
|
|
||||||
controller: password,
|
|
||||||
obscureText: true,
|
|
||||||
obscuringCharacter: "*",
|
|
||||||
decoration: InputDecoration(hintText: "client_cert_enter_password".tr()),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () async => {ctx.pop(), await storeCert(context, data, password.text)},
|
|
||||||
child: Text("client_cert_dialog_msg_confirm".tr()),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> importCert(BuildContext ctx) async {
|
|
||||||
FilePickerResult? res = await FilePicker.platform.pickFiles(
|
|
||||||
type: FileType.custom,
|
|
||||||
allowedExtensions: ['p12', 'pfx'],
|
|
||||||
);
|
|
||||||
if (res != null) {
|
|
||||||
File file = File(res.files.single.path!);
|
|
||||||
final bytes = await file.readAsBytes();
|
|
||||||
setPassword(ctx, bytes);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> removeCert(BuildContext context) async {
|
Future<void> removeCert(BuildContext context) async {
|
||||||
await SSLClientCertStoreVal.delete();
|
setState(() => pKeyAlias = "");
|
||||||
HttpSSLOptions.apply();
|
await const SSLClientCertStoreVal("").save();
|
||||||
setState(() => isCertExist = false);
|
await refreshClient();
|
||||||
showMessage(context, "client_cert_remove_msg".tr());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -919,6 +919,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
http_profile:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_profile
|
||||||
|
sha256: "7e679e355b09aaee2ab5010915c932cce3f2d1c11c3b2dc177891687014ffa78"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.0"
|
||||||
image:
|
image:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1053,6 +1061,14 @@ packages:
|
||||||
url: "https://github.com/immich-app/isar"
|
url: "https://github.com/immich-app/isar"
|
||||||
source: git
|
source: git
|
||||||
version: "3.1.8"
|
version: "3.1.8"
|
||||||
|
jni:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni
|
||||||
|
sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.14.2"
|
||||||
js:
|
js:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1254,6 +1270,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: "feature/ok-http-0.1.2"
|
||||||
|
resolved-ref: c285421e3d908f209930edb7535628bb63a7037a
|
||||||
|
url: "https://github.com/denysvitali/dart_lang_http"
|
||||||
|
source: git
|
||||||
|
version: "0.1.1-wip"
|
||||||
openapi:
|
openapi:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,14 @@ dependencies:
|
||||||
worker_manager: ^7.2.3
|
worker_manager: ^7.2.3
|
||||||
scroll_date_picker: ^3.8.0
|
scroll_date_picker: ^3.8.0
|
||||||
ffi: ^2.1.4
|
ffi: ^2.1.4
|
||||||
|
ok_http:
|
||||||
|
# TODO: Replace with 0.1.2 when
|
||||||
|
# - https://github.com/dart-lang/http/pull/1830 and
|
||||||
|
# - https://github.com/dart-lang/http/pull/1831 are merged
|
||||||
|
git:
|
||||||
|
url: https://github.com/denysvitali/dart_lang_http
|
||||||
|
ref: feature/ok-http-0.1.2
|
||||||
|
path: pkgs/ok_http
|
||||||
|
|
||||||
native_video_player:
|
native_video_player:
|
||||||
git:
|
git:
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ void main() {
|
||||||
when(() => mockStreamedResponse.stream).thenAnswer((_) => http.ByteStream(responseStreamController.stream));
|
when(() => mockStreamedResponse.stream).thenAnswer((_) => http.ByteStream(responseStreamController.stream));
|
||||||
when(() => mockHttpClient.close()).thenAnswer((_) => {});
|
when(() => mockHttpClient.close()).thenAnswer((_) => {});
|
||||||
|
|
||||||
sut = SyncApiRepository(mockApiService);
|
sut = SyncApiRepository(mockApiService, httpClient: mockHttpClient);
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
|
|
@ -73,7 +73,7 @@ void main() {
|
||||||
Future<void> streamChanges(
|
Future<void> streamChanges(
|
||||||
Future<void> Function(List<SyncEvent>, Function() abort, Function() reset) onDataCallback,
|
Future<void> Function(List<SyncEvent>, Function() abort, Function() reset) onDataCallback,
|
||||||
) {
|
) {
|
||||||
return sut.streamChanges(onDataCallback, batchSize: testBatchSize, httpClient: mockHttpClient);
|
return sut.streamChanges(onDataCallback, batchSize: testBatchSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
test('streamChanges stops processing stream when abort is called', () async {
|
test('streamChanges stops processing stream when abort is called', () async {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue