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_LOCATION" />
|
||||
|
||||
<application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true"
|
||||
android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true"
|
||||
android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false">
|
||||
<application
|
||||
android:label="Immich"
|
||||
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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package app.alextran.immich
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.security.KeyChain
|
||||
import android.security.KeyChainException
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
|
|
@ -10,6 +12,8 @@ import java.io.ByteArrayInputStream
|
|||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.security.KeyStore
|
||||
import java.security.Principal
|
||||
import java.security.PrivateKey
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
|
|
@ -21,18 +25,21 @@ import javax.net.ssl.SSLSession
|
|||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509ExtendedTrustManager
|
||||
import javax.net.ssl.X509KeyManager
|
||||
|
||||
/**
|
||||
* Android plugin for Dart `HttpSSLOptions`
|
||||
*/
|
||||
class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
private var methodChannel: MethodChannel? = null
|
||||
private var context: Context? = null
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
|
||||
}
|
||||
|
||||
private fun onAttachedToEngine(ctx: Context, messenger: BinaryMessenger) {
|
||||
context = ctx
|
||||
methodChannel = MethodChannel(messenger, "immich/httpSSLOptions")
|
||||
methodChannel?.setMethodCallHandler(this)
|
||||
}
|
||||
|
|
@ -44,6 +51,7 @@ class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
|||
private fun onDetachedFromEngine() {
|
||||
methodChannel?.setMethodCallHandler(null)
|
||||
methodChannel = null
|
||||
context = null
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
|
|
@ -57,26 +65,60 @@ class HttpSSLOptionsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
|||
tm = arrayOf(AllowSelfSignedTrustManager(args[1] as? String))
|
||||
}
|
||||
|
||||
var km: Array<KeyManager>? = null
|
||||
if (args[2] != null) {
|
||||
val cert = ByteArrayInputStream(args[2] as ByteArray)
|
||||
val password = (args[3] as String).toCharArray()
|
||||
val keyStore = KeyStore.getInstance("PKCS12")
|
||||
keyStore.load(cert, password)
|
||||
val keyManagerFactory =
|
||||
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
|
||||
keyManagerFactory.init(keyStore, null)
|
||||
km = keyManagerFactory.keyManagers
|
||||
}
|
||||
// var km: Array<KeyManager>? = null
|
||||
// if (args[2] != null) {
|
||||
// val cert = ByteArrayInputStream(args[2] as ByteArray)
|
||||
// val password = (args[3] as String).toCharArray()
|
||||
// val keyStore = KeyStore.getInstance("PKCS12")
|
||||
// keyStore.load(cert, password)
|
||||
// val keyManagerFactory =
|
||||
// KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
|
||||
// keyManagerFactory.init(keyStore, null)
|
||||
// km = keyManagerFactory.keyManagers
|
||||
// }
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(km, tm, null)
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
||||
// val sslContext = SSLContext.getInstance("TLS")
|
||||
// sslContext.init(km, tm, null)
|
||||
// HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
||||
|
||||
HttpsURLConnection.setDefaultHostnameVerifier(AllowSelfSignedHostnameVerifier(args[1] as? String))
|
||||
// HttpsURLConnection.setDefaultHostnameVerifier(AllowSelfSignedHostnameVerifier(args[1] as? String))
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||
// flutterEngine.plugins.add(HttpSSLOptionsPlugin())
|
||||
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),
|
||||
autoBackup<bool>._(13),
|
||||
backgroundBackup<bool>._(14),
|
||||
sslClientCertData<String>._(15),
|
||||
sslClientPasswd<String>._(16),
|
||||
sslClientCertData<String>._(15), // deprecated!
|
||||
sslClientPasswd<String>._(16), // deprecated!
|
||||
// user settings from [AppSettingsEnum] below:
|
||||
loadPreview<bool>._(100),
|
||||
loadOriginal<bool>._(101),
|
||||
|
|
@ -42,33 +42,34 @@ enum StoreKey<T> {
|
|||
mapShowFavoriteOnly<bool>._(118),
|
||||
mapRelativeDate<int>._(119),
|
||||
selfSignedCert<bool>._(120),
|
||||
mapIncludeArchived<bool>._(121),
|
||||
ignoreIcloudAssets<bool>._(122),
|
||||
selectedAlbumSortReverse<bool>._(123),
|
||||
mapThemeMode<int>._(124),
|
||||
mapwithPartners<bool>._(125),
|
||||
enableHapticFeedback<bool>._(126),
|
||||
customHeaders<String>._(127),
|
||||
useUserCertificates<bool>._(121),
|
||||
mapIncludeArchived<bool>._(122),
|
||||
ignoreIcloudAssets<bool>._(123),
|
||||
selectedAlbumSortReverse<bool>._(124),
|
||||
mapThemeMode<int>._(125),
|
||||
mapwithPartners<bool>._(126),
|
||||
enableHapticFeedback<bool>._(127),
|
||||
customHeaders<String>._(128),
|
||||
|
||||
// theme settings
|
||||
primaryColor<String>._(128),
|
||||
dynamicTheme<bool>._(129),
|
||||
colorfulInterface<bool>._(130),
|
||||
primaryColor<String>._(129),
|
||||
dynamicTheme<bool>._(130),
|
||||
colorfulInterface<bool>._(131),
|
||||
|
||||
syncAlbums<bool>._(131),
|
||||
syncAlbums<bool>._(132),
|
||||
|
||||
// Auto endpoint switching
|
||||
autoEndpointSwitching<bool>._(132),
|
||||
preferredWifiName<String>._(133),
|
||||
localEndpoint<String>._(134),
|
||||
externalEndpointList<String>._(135),
|
||||
autoEndpointSwitching<bool>._(133),
|
||||
preferredWifiName<String>._(134),
|
||||
localEndpoint<String>._(135),
|
||||
externalEndpointList<String>._(136),
|
||||
|
||||
// Video settings
|
||||
loadOriginalVideo<bool>._(136),
|
||||
manageLocalMediaAndroid<bool>._(137),
|
||||
loadOriginalVideo<bool>._(137),
|
||||
manageLocalMediaAndroid<bool>._(138),
|
||||
|
||||
// Read-only Mode settings
|
||||
readonlyModeEnabled<bool>._(138),
|
||||
readonlyModeEnabled<bool>._(139),
|
||||
|
||||
autoPlayVideo<bool>._(139),
|
||||
|
||||
|
|
@ -81,7 +82,10 @@ enum StoreKey<T> {
|
|||
useWifiForUploadPhotos<bool>._(1005),
|
||||
needBetaMigration<bool>._(1006),
|
||||
// TODO: Remove this after patching open-api
|
||||
shouldResetSync<bool>._(1007);
|
||||
shouldResetSync<bool>._(1007),
|
||||
|
||||
// mTLS
|
||||
mTlsSelectedPrivateKey<String>._(1008);
|
||||
|
||||
const StoreKey._(this.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/services/store.service.dart';
|
||||
|
||||
|
|
@ -8,31 +5,19 @@ import 'package:immich_mobile/domain/services/store.service.dart';
|
|||
final Store = StoreService.I;
|
||||
|
||||
class SSLClientCertStoreVal {
|
||||
final Uint8List data;
|
||||
final String? password;
|
||||
|
||||
const SSLClientCertStoreVal(this.data, this.password);
|
||||
final String privateKeyAlias;
|
||||
const SSLClientCertStoreVal(this.privateKeyAlias);
|
||||
|
||||
Future<void> save() async {
|
||||
final b64Str = base64Encode(data);
|
||||
await Store.put(StoreKey.sslClientCertData, b64Str);
|
||||
if (password != null) {
|
||||
await Store.put(StoreKey.sslClientPasswd, password!);
|
||||
}
|
||||
await Store.put(StoreKey.mTlsSelectedPrivateKey, privateKeyAlias);
|
||||
}
|
||||
|
||||
static SSLClientCertStoreVal? load() {
|
||||
final b64Str = Store.tryGet<String>(StoreKey.sslClientCertData);
|
||||
if (b64Str == null) {
|
||||
return null;
|
||||
}
|
||||
final Uint8List certData = base64Decode(b64Str);
|
||||
final passwd = Store.tryGet<String>(StoreKey.sslClientPasswd);
|
||||
return SSLClientCertStoreVal(certData, passwd);
|
||||
final privateKeyAlias = Store.tryGet<String>(StoreKey.mTlsSelectedPrivateKey) ?? "";
|
||||
return SSLClientCertStoreVal(privateKeyAlias);
|
||||
}
|
||||
|
||||
static Future<void> delete() async {
|
||||
await Store.delete(StoreKey.sslClientCertData);
|
||||
await Store.delete(StoreKey.sslClientPasswd);
|
||||
await Store.delete(StoreKey.mTlsSelectedPrivateKey);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||
import 'dart:convert';
|
||||
|
||||
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/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
|
|
@ -13,7 +14,8 @@ import 'package:openapi/api.dart';
|
|||
class SyncApiRepository {
|
||||
final Logger _logger = Logger('SyncApiRepository');
|
||||
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) {
|
||||
return _api.syncApi.sendSyncAck(SyncAckSetDto(acks: data));
|
||||
|
|
@ -23,10 +25,8 @@ class SyncApiRepository {
|
|||
Future<void> Function(List<SyncEvent>, Function() abort, Function() reset) onData, {
|
||||
Function()? onReset,
|
||||
int batchSize = kSyncEventBatchSize,
|
||||
http.Client? httpClient,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final client = httpClient ?? http.Client();
|
||||
final endpoint = "${_api.apiClient.basePath}/sync/stream";
|
||||
|
||||
final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'};
|
||||
|
|
@ -78,7 +78,7 @@ class SyncApiRepository {
|
|||
final reset = onReset ?? () {};
|
||||
|
||||
try {
|
||||
final response = await client.send(request);
|
||||
final response = await httpClient.send(request);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final errorBody = await response.stream.bytesToString();
|
||||
|
|
@ -111,8 +111,6 @@ class SyncApiRepository {
|
|||
}
|
||||
} catch (error, stack) {
|
||||
return Future.error(error, stack);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
stopwatch.stop();
|
||||
_logger.info("Remote Sync completed in ${stopwatch.elapsed.inMilliseconds}ms");
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_displaymode/flutter_displaymode.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/locales.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/licenses.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:logging/logging.dart';
|
||||
import 'package:timezone/data/latest.dart';
|
||||
import 'package:worker_manager/worker_manager.dart';
|
||||
|
||||
void main() async {
|
||||
ImmichWidgetsBinding();
|
||||
unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock());
|
||||
final (isar, drift, logDb) = await Bootstrap.initDB();
|
||||
await Bootstrap.initDomain(isar, drift, logDb);
|
||||
await initApp();
|
||||
// Warm-up isolate pool for worker manager
|
||||
await workerManager.init(dynamicSpawning: true);
|
||||
await migrateDatabaseIfNeeded(isar, drift);
|
||||
HttpSSLOptions.apply();
|
||||
try {
|
||||
ImmichWidgetsBinding();
|
||||
unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock());
|
||||
final (isar, drift, logDb) = await Bootstrap.initDB();
|
||||
await Bootstrap.initDomain(isar, drift, logDb);
|
||||
await initApp();
|
||||
await setPackageInfo();
|
||||
await refreshClient();
|
||||
// Warm-up isolate pool for worker manager
|
||||
await workerManager.init(dynamicSpawning: true);
|
||||
await migrateDatabaseIfNeeded(isar, drift);
|
||||
HttpSSLOptions.apply();
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
dbProvider.overrideWithValue(isar),
|
||||
isarProvider.overrideWithValue(isar),
|
||||
driftProvider.overrideWith(driftOverride(drift)),
|
||||
],
|
||||
child: const MainWidget(),
|
||||
),
|
||||
);
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
dbProvider.overrideWithValue(isar),
|
||||
isarProvider.overrideWithValue(isar),
|
||||
driftProvider.overrideWith(driftOverride(drift)),
|
||||
],
|
||||
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 {
|
||||
|
|
@ -120,6 +140,10 @@ Future<void> initApp() async {
|
|||
});
|
||||
}
|
||||
|
||||
Future<void> setPackageInfo() async {
|
||||
await PackageInfoSingleton.instance.init();
|
||||
}
|
||||
|
||||
class ImmichApp extends ConsumerStatefulWidget {
|
||||
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 '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:http/http.dart';
|
||||
import 'package:immich_mobile/common/http.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
|
|
@ -92,8 +94,8 @@ class UploadRepository {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> backupWithDartClient(Iterable<UploadTaskWithFile> tasks, CancellationToken cancelToken) async {
|
||||
final httpClient = Client();
|
||||
Future<void> backupWithDartClient(Iterable<UploadTaskWithFile> tasks, http.CancellationToken cancelToken) async {
|
||||
final httpClient = immichHttpClient();
|
||||
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
|
||||
Logger logger = Logger('UploadRepository');
|
||||
|
|
@ -118,7 +120,7 @@ class UploadRepository {
|
|||
baseRequest.fields.addAll(candidate.task.fields);
|
||||
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());
|
||||
|
||||
|
|
@ -131,7 +133,7 @@ class UploadRepository {
|
|||
|
||||
continue;
|
||||
}
|
||||
} on CancelledException {
|
||||
} on http.CancelledException {
|
||||
logger.warning("Backup was cancelled by the user");
|
||||
break;
|
||||
} catch (error, stackTrace) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:device_info_plus/device_info_plus.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/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
|
|
@ -50,6 +51,7 @@ class ApiService implements Authentication {
|
|||
|
||||
setEndpoint(String endpoint) {
|
||||
_apiClient = ApiClient(basePath: endpoint, authentication: this);
|
||||
_apiClient.client = immichHttpClient();
|
||||
_setUserAgentHeader();
|
||||
if (_accessToken != null) {
|
||||
setAccessToken(_accessToken!);
|
||||
|
|
@ -77,7 +79,7 @@ class ApiService implements Authentication {
|
|||
}
|
||||
|
||||
Future<void> _setUserAgentHeader() async {
|
||||
final userAgent = await getUserAgentString();
|
||||
final userAgent = getUserAgentString();
|
||||
_apiClient.addDefaultHeader('User-Agent', userAgent);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ enum AppSettingsEnum<T> {
|
|||
mapwithPartners<bool>(StoreKey.mapwithPartners, null, false),
|
||||
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
|
||||
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
|
||||
useUserCertificates<bool>(StoreKey.useUserCertificates, null, false),
|
||||
ignoreIcloudAssets<bool>(StoreKey.ignoreIcloudAssets, null, false),
|
||||
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, false),
|
||||
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
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/utils/background_sync.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
|
|
@ -64,27 +63,18 @@ class AuthService {
|
|||
}
|
||||
|
||||
Future<bool> validateAuxilaryServerUrl(String url) async {
|
||||
final httpclient = HttpClient();
|
||||
final httpclient = await immichHttpClient();
|
||||
bool isValid = false;
|
||||
|
||||
try {
|
||||
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) {
|
||||
isValid = true;
|
||||
}
|
||||
} catch (error) {
|
||||
_log.severe("Error validating auxiliary endpoint", error);
|
||||
} finally {
|
||||
httpclient.close();
|
||||
}
|
||||
|
||||
return isValid;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import 'dart:io';
|
|||
import 'package:cancellation_token_http/http.dart' as http;
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:immich_mobile/common/http.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
|
|
@ -43,8 +45,7 @@ final backupServiceProvider = Provider(
|
|||
);
|
||||
|
||||
class BackupService {
|
||||
final httpClient = http.Client();
|
||||
final ApiService _apiService;
|
||||
final dynamic _apiService;
|
||||
final Logger _log = Logger("BackupService");
|
||||
final AppSettingsService _appSetting;
|
||||
final AlbumService _albumService;
|
||||
|
|
@ -52,6 +53,7 @@ class BackupService {
|
|||
final FileMediaRepository _fileMediaRepository;
|
||||
final AssetRepository _assetRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
late final Client client;
|
||||
|
||||
BackupService(
|
||||
this._apiService,
|
||||
|
|
@ -60,8 +62,9 @@ class BackupService {
|
|||
this._albumMediaRepository,
|
||||
this._fileMediaRepository,
|
||||
this._assetRepository,
|
||||
this._assetMediaRepository,
|
||||
);
|
||||
this._assetMediaRepository, {
|
||||
Client? client,
|
||||
}) : client = client ?? immichHttpClient();
|
||||
|
||||
Future<List<String>?> getDeviceBackupAsset() async {
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
|
|
@ -306,14 +309,14 @@ class BackupService {
|
|||
}
|
||||
|
||||
final fileStream = file.openRead();
|
||||
final assetRawUploadData = http.MultipartFile(
|
||||
final assetRawUploadData = MultipartFile(
|
||||
"assetData",
|
||||
fileStream,
|
||||
file.lengthSync(),
|
||||
filename: originalFileName,
|
||||
);
|
||||
|
||||
final baseRequest = MultipartRequest(
|
||||
final baseRequest = ProgressMultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('$savedEndpoint/assets'),
|
||||
onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)),
|
||||
|
|
@ -348,7 +351,8 @@ class BackupService {
|
|||
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());
|
||||
|
||||
|
|
@ -428,7 +432,7 @@ class BackupService {
|
|||
Future<String?> uploadLivePhotoVideo(
|
||||
String originalFileName,
|
||||
File? livePhotoVideoFile,
|
||||
MultipartRequest baseRequest,
|
||||
ProgressMultipartRequest baseRequest,
|
||||
http.CancellationToken cancelToken,
|
||||
) async {
|
||||
if (livePhotoVideoFile == null) {
|
||||
|
|
@ -436,19 +440,21 @@ class BackupService {
|
|||
}
|
||||
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoVideoFile.path));
|
||||
final fileStream = livePhotoVideoFile.openRead();
|
||||
final livePhotoRawUploadData = http.MultipartFile(
|
||||
final livePhotoRawUploadData = MultipartFile(
|
||||
"assetData",
|
||||
fileStream,
|
||||
livePhotoVideoFile.lengthSync(),
|
||||
filename: livePhotoTitle,
|
||||
);
|
||||
final livePhotoReq = MultipartRequest(baseRequest.method, baseRequest.url, onProgress: baseRequest.onProgress)
|
||||
..headers.addAll(baseRequest.headers)
|
||||
..fields.addAll(baseRequest.fields);
|
||||
final livePhotoReq =
|
||||
ProgressMultipartRequest(baseRequest.method, baseRequest.url, onProgress: baseRequest.onProgress)
|
||||
..headers.addAll(baseRequest.headers)
|
||||
..fields.addAll(baseRequest.fields);
|
||||
|
||||
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());
|
||||
|
||||
|
|
@ -471,17 +477,17 @@ class BackupService {
|
|||
};
|
||||
}
|
||||
|
||||
class MultipartRequest extends http.MultipartRequest {
|
||||
/// Creates a new [MultipartRequest].
|
||||
MultipartRequest(super.method, super.url, {required this.onProgress});
|
||||
class ProgressMultipartRequest extends MultipartRequest {
|
||||
/// Creates a new [ProgressMultipartRequest].
|
||||
ProgressMultipartRequest(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]
|
||||
/// single-subscription [ByteStream]
|
||||
/// that will emit the request body.
|
||||
@override
|
||||
http.ByteStream finalize() {
|
||||
ByteStream finalize() {
|
||||
final byteStream = super.finalize();
|
||||
|
||||
final total = contentLength;
|
||||
|
|
@ -495,6 +501,6 @@ class MultipartRequest extends http.MultipartRequest {
|
|||
},
|
||||
);
|
||||
final stream = byteStream.transform(t);
|
||||
return http.ByteStream(stream);
|
||||
return ByteStream(stream);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,18 @@
|
|||
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;
|
||||
}
|
||||
HttpSSLCertOverride(this._allowSelfSignedSSLCert, this._serverHost);
|
||||
|
||||
@override
|
||||
HttpClient createHttpClient(SecurityContext? context) {
|
||||
if (context != null) {
|
||||
if (_clientCert != null) {
|
||||
setClientCert(context, _clientCert);
|
||||
}
|
||||
} else {
|
||||
context = _ctxWithCert;
|
||||
}
|
||||
// Use system trust store with trusted roots if no client certificate is provided
|
||||
context = SecurityContext(withTrustedRoots: true);
|
||||
|
||||
return super.createHttpClient(context)
|
||||
..badCertificateCallback = (X509Certificate cert, String host, int port) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
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';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class HttpSSLOptions {
|
||||
|
|
@ -13,11 +10,38 @@ class HttpSSLOptions {
|
|||
static void apply({bool applyNative = true}) {
|
||||
AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert;
|
||||
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) {
|
||||
_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}) {
|
||||
|
|
@ -26,17 +50,45 @@ class HttpSSLOptions {
|
|||
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) {
|
||||
_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);
|
||||
});
|
||||
static void _applyWithUserCertificates(bool allowSelfSignedSSLCert, {bool applyNative = true}) {
|
||||
String? serverHost;
|
||||
if (Store.tryGet(StoreKey.currentUser) != null) {
|
||||
serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host;
|
||||
}
|
||||
|
||||
// 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 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:immich_mobile/common/package_info.dart';
|
||||
|
||||
Future<String> getUserAgentString() async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
String getUserAgentString() {
|
||||
final packageInfo = PackageInfoSingleton.instance;
|
||||
String platform;
|
||||
if (Platform.isAndroid) {
|
||||
platform = 'Android';
|
||||
|
|
@ -11,5 +11,6 @@ Future<String> getUserAgentString() async {
|
|||
} else {
|
||||
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:file_picker/file_picker.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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
||||
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||
import 'package:ok_http/ok_http.dart';
|
||||
|
||||
class SslClientCertSettings extends StatefulWidget {
|
||||
const SslClientCertSettings({super.key, required this.isLoggedIn});
|
||||
|
|
@ -20,9 +16,9 @@ class SslClientCertSettings extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _SslClientCertSettingsState extends State<SslClientCertSettings> {
|
||||
_SslClientCertSettingsState() : isCertExist = SSLClientCertStoreVal.load() != null;
|
||||
_SslClientCertSettingsState() : pKeyAlias = SSLClientCertStoreVal.load()?.privateKeyAlias ?? "";
|
||||
|
||||
bool isCertExist;
|
||||
String pKeyAlias = "";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -39,21 +35,57 @@ class _SslClientCertSettingsState extends State<SslClientCertSettings> {
|
|||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: widget.isLoggedIn ? null : () => importCert(context),
|
||||
child: Text("client_cert_import".tr()),
|
||||
if (pKeyAlias != "")
|
||||
Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(0, 6, 0, 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
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(
|
||||
onPressed: widget.isLoggedIn || !isCertExist ? null : () async => await removeCert(context),
|
||||
child: Text("remove".tr()),
|
||||
),
|
||||
if (pKeyAlias == "")
|
||||
Center(
|
||||
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 {
|
||||
if (password != null && password.isEmpty) {
|
||||
password = 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());
|
||||
Future<void> selectCert(BuildContext context) async {
|
||||
String? chosenAlias = await choosePrivateKeyAlias();
|
||||
if (chosenAlias == null) {
|
||||
return;
|
||||
}
|
||||
await cert.save();
|
||||
HttpSSLOptions.apply();
|
||||
setState(() => isCertExist = true);
|
||||
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);
|
||||
}
|
||||
setState(() => pKeyAlias = chosenAlias);
|
||||
await SSLClientCertStoreVal(chosenAlias).save();
|
||||
await refreshClient();
|
||||
}
|
||||
|
||||
Future<void> removeCert(BuildContext context) async {
|
||||
await SSLClientCertStoreVal.delete();
|
||||
HttpSSLOptions.apply();
|
||||
setState(() => isCertExist = false);
|
||||
showMessage(context, "client_cert_remove_msg".tr());
|
||||
setState(() => pKeyAlias = "");
|
||||
await const SSLClientCertStoreVal("").save();
|
||||
await refreshClient();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -919,6 +919,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1053,6 +1061,14 @@ packages:
|
|||
url: "https://github.com/immich-app/isar"
|
||||
source: git
|
||||
version: "3.1.8"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.14.2"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1254,6 +1270,15 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -71,6 +71,14 @@ dependencies:
|
|||
worker_manager: ^7.2.3
|
||||
scroll_date_picker: ^3.8.0
|
||||
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:
|
||||
git:
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ void main() {
|
|||
when(() => mockStreamedResponse.stream).thenAnswer((_) => http.ByteStream(responseStreamController.stream));
|
||||
when(() => mockHttpClient.close()).thenAnswer((_) => {});
|
||||
|
||||
sut = SyncApiRepository(mockApiService);
|
||||
sut = SyncApiRepository(mockApiService, httpClient: mockHttpClient);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
|
|
@ -73,7 +73,7 @@ void main() {
|
|||
Future<void> streamChanges(
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue