diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index c6e04e5a10a84..1d433e63da3dc 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -25,9 +25,17 @@ - + diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/HttpSSLOptionsPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/HttpSSLOptionsPlugin.kt index 44d2aee2ce703..a5ba9f350c990 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/HttpSSLOptionsPlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/HttpSSLOptionsPlugin.kt @@ -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? = 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? = 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>()!! + // val serverHost = args[0] as? String + // val allowSelfSigned = args[1] as Boolean + + // var tm: Array? = 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 { + 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? { + 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> { + val certificates = mutableListOf>() + + 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?, + issuers: Array?, + 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?, + socket: Socket? + ): String? { + return null + } + + override fun getCertificateChain(alias: String?): Array? { + 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? + ): Array? { + return null + } + + override fun getServerAliases( + keyType: String?, + issuers: Array? + ): Array? { + return null + } + } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index 034f5ee72e09f..4aa331b4b5564 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -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) } } diff --git a/mobile/android/app/src/main/res/xml/network_security_config.xml b/mobile/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000000000..37a8e3f7657be --- /dev/null +++ b/mobile/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 502fd9008f9cd..9bff8cd8e231c 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -64,7 +64,7 @@ PODS: - Flutter - integration_test (0.0.1): - Flutter - - isar_flutter_libs (1.0.0): + - isar_community_flutter_libs (1.0.0): - Flutter - local_auth_darwin (0.0.1): - Flutter @@ -149,7 +149,7 @@ DEPENDENCIES: - home_widget (from `.symlinks/plugins/home_widget/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - - isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`) + - isar_community_flutter_libs (from `.symlinks/plugins/isar_community_flutter_libs/ios`) - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) - maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`) - native_video_player (from `.symlinks/plugins/native_video_player/ios`) @@ -210,8 +210,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/image_picker_ios/ios" integration_test: :path: ".symlinks/plugins/integration_test/ios" - isar_flutter_libs: - :path: ".symlinks/plugins/isar_flutter_libs/ios" + isar_community_flutter_libs: + :path: ".symlinks/plugins/isar_community_flutter_libs/ios" local_auth_darwin: :path: ".symlinks/plugins/local_auth_darwin/darwin" maplibre_gl: @@ -264,7 +264,7 @@ SPEC CHECKSUMS: home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e - isar_flutter_libs: bc909e72c3d756c2759f14c8776c13b5b0556e26 + isar_community_flutter_libs: bede843185a61a05ff364a05c9b23209523f7e0d local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391 MapLibre: 69e572367f4ef6287e18246cfafc39c80cdcabcd maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f diff --git a/mobile/lib/common/http.dart b/mobile/lib/common/http.dart new file mode 100644 index 0000000000000..0b15e3cbf097d --- /dev/null +++ b/mobile/lib/common/http.dart @@ -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?) _loadPrivateKeyAndCertificateChainFromAliasCompute(String alias) { + PrivateKey? pkey; + List? 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 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? 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 refreshClient() async { + return _ImmichHttpClientSingleton.instance.refreshClient(); +} diff --git a/mobile/lib/common/package_info.dart b/mobile/lib/common/package_info.dart new file mode 100644 index 0000000000000..fca4a3a3b401e --- /dev/null +++ b/mobile/lib/common/package_info.dart @@ -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 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; +} diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index efccc9bccd9f1..681941e7298f2 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -17,8 +17,8 @@ enum StoreKey { serverEndpoint._(12), autoBackup._(13), backgroundBackup._(14), - sslClientCertData._(15), - sslClientPasswd._(16), + sslClientCertData._(15), // deprecated! + sslClientPasswd._(16), // deprecated! // user settings from [AppSettingsEnum] below: loadPreview._(100), loadOriginal._(101), @@ -42,33 +42,34 @@ enum StoreKey { mapShowFavoriteOnly._(118), mapRelativeDate._(119), selfSignedCert._(120), - mapIncludeArchived._(121), - ignoreIcloudAssets._(122), - selectedAlbumSortReverse._(123), - mapThemeMode._(124), - mapwithPartners._(125), - enableHapticFeedback._(126), - customHeaders._(127), + useUserCertificates._(121), + mapIncludeArchived._(122), + ignoreIcloudAssets._(123), + selectedAlbumSortReverse._(124), + mapThemeMode._(125), + mapwithPartners._(126), + enableHapticFeedback._(127), + customHeaders._(128), // theme settings - primaryColor._(128), - dynamicTheme._(129), - colorfulInterface._(130), + primaryColor._(129), + dynamicTheme._(130), + colorfulInterface._(131), - syncAlbums._(131), + syncAlbums._(132), // Auto endpoint switching - autoEndpointSwitching._(132), - preferredWifiName._(133), - localEndpoint._(134), - externalEndpointList._(135), + autoEndpointSwitching._(133), + preferredWifiName._(134), + localEndpoint._(135), + externalEndpointList._(136), // Video settings - loadOriginalVideo._(136), - manageLocalMediaAndroid._(137), + loadOriginalVideo._(137), + manageLocalMediaAndroid._(138), // Read-only Mode settings - readonlyModeEnabled._(138), + readonlyModeEnabled._(139), // Experimental stuff photoManagerCustomFilter._(1000), @@ -79,7 +80,10 @@ enum StoreKey { useWifiForUploadPhotos._(1005), needBetaMigration._(1006), // TODO: Remove this after patching open-api - shouldResetSync._(1007); + shouldResetSync._(1007), + + // mTLS + mTlsSelectedPrivateKey._(1008); const StoreKey._(this.id); final int id; diff --git a/mobile/lib/entities/album.entity.g.dart b/mobile/lib/entities/album.entity.g.dart index e6ecde7f9a53f..ecbbab48c2ce9 100644 --- a/mobile/lib/entities/album.entity.g.dart +++ b/mobile/lib/entities/album.entity.g.dart @@ -132,7 +132,7 @@ const AlbumSchema = CollectionSchema( getId: _albumGetId, getLinks: _albumGetLinks, attach: _albumAttach, - version: '3.1.8', + version: '3.3.0-dev.3', ); int _albumEstimateSize( diff --git a/mobile/lib/entities/android_device_asset.entity.g.dart b/mobile/lib/entities/android_device_asset.entity.g.dart index 9034709b8e1ce..f8b1e32c729f9 100644 --- a/mobile/lib/entities/android_device_asset.entity.g.dart +++ b/mobile/lib/entities/android_device_asset.entity.g.dart @@ -47,7 +47,7 @@ const AndroidDeviceAssetSchema = CollectionSchema( getId: _androidDeviceAssetGetId, getLinks: _androidDeviceAssetGetLinks, attach: _androidDeviceAssetAttach, - version: '3.1.8', + version: '3.3.0-dev.3', ); int _androidDeviceAssetEstimateSize( diff --git a/mobile/lib/entities/asset.entity.g.dart b/mobile/lib/entities/asset.entity.g.dart index be5b427d01746..db6bc7233156b 100644 --- a/mobile/lib/entities/asset.entity.g.dart +++ b/mobile/lib/entities/asset.entity.g.dart @@ -168,7 +168,7 @@ const AssetSchema = CollectionSchema( getId: _assetGetId, getLinks: _assetGetLinks, attach: _assetAttach, - version: '3.1.8', + version: '3.3.0-dev.3', ); int _assetEstimateSize( diff --git a/mobile/lib/entities/backup_album.entity.g.dart b/mobile/lib/entities/backup_album.entity.g.dart index ed9850311941b..583aa55c4d7d2 100644 --- a/mobile/lib/entities/backup_album.entity.g.dart +++ b/mobile/lib/entities/backup_album.entity.g.dart @@ -43,7 +43,7 @@ const BackupAlbumSchema = CollectionSchema( getId: _backupAlbumGetId, getLinks: _backupAlbumGetLinks, attach: _backupAlbumAttach, - version: '3.1.8', + version: '3.3.0-dev.3', ); int _backupAlbumEstimateSize( diff --git a/mobile/lib/entities/duplicated_asset.entity.g.dart b/mobile/lib/entities/duplicated_asset.entity.g.dart index 6cf08ad9ccebe..80d2f344e6126 100644 --- a/mobile/lib/entities/duplicated_asset.entity.g.dart +++ b/mobile/lib/entities/duplicated_asset.entity.g.dart @@ -32,7 +32,7 @@ const DuplicatedAssetSchema = CollectionSchema( getId: _duplicatedAssetGetId, getLinks: _duplicatedAssetGetLinks, attach: _duplicatedAssetAttach, - version: '3.1.8', + version: '3.3.0-dev.3', ); int _duplicatedAssetEstimateSize( diff --git a/mobile/lib/entities/etag.entity.g.dart b/mobile/lib/entities/etag.entity.g.dart index b1abba6bb7fac..03b4ea9918064 100644 --- a/mobile/lib/entities/etag.entity.g.dart +++ b/mobile/lib/entities/etag.entity.g.dart @@ -52,7 +52,7 @@ const ETagSchema = CollectionSchema( getId: _eTagGetId, getLinks: _eTagGetLinks, attach: _eTagAttach, - version: '3.1.8', + version: '3.3.0-dev.3', ); int _eTagEstimateSize( diff --git a/mobile/lib/entities/ios_device_asset.entity.g.dart b/mobile/lib/entities/ios_device_asset.entity.g.dart index 8d8fec945b87a..252fe127bba9e 100644 --- a/mobile/lib/entities/ios_device_asset.entity.g.dart +++ b/mobile/lib/entities/ios_device_asset.entity.g.dart @@ -60,7 +60,7 @@ const IOSDeviceAssetSchema = CollectionSchema( getId: _iOSDeviceAssetGetId, getLinks: _iOSDeviceAssetGetLinks, attach: _iOSDeviceAssetAttach, - version: '3.1.8', + version: '3.3.0-dev.3', ); int _iOSDeviceAssetEstimateSize( diff --git a/mobile/lib/entities/store.entity.dart b/mobile/lib/entities/store.entity.dart index 7b59e119d636b..eb196cfb8f92d 100644 --- a/mobile/lib/entities/store.entity.dart +++ b/mobile/lib/entities/store.entity.dart @@ -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 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(StoreKey.sslClientCertData); - if (b64Str == null) { - return null; - } - final Uint8List certData = base64Decode(b64Str); - final passwd = Store.tryGet(StoreKey.sslClientPasswd); - return SSLClientCertStoreVal(certData, passwd); + final privateKeyAlias = Store.tryGet(StoreKey.mTlsSelectedPrivateKey) ?? ""; + return SSLClientCertStoreVal(privateKeyAlias); } static Future delete() async { - await Store.delete(StoreKey.sslClientCertData); - await Store.delete(StoreKey.sslClientPasswd); + await Store.delete(StoreKey.mTlsSelectedPrivateKey); } } diff --git a/mobile/lib/infrastructure/entities/device_asset.entity.g.dart b/mobile/lib/infrastructure/entities/device_asset.entity.g.dart index 87ae54ad40dc0..b6c30aca6f9c6 100644 --- a/mobile/lib/infrastructure/entities/device_asset.entity.g.dart +++ b/mobile/lib/infrastructure/entities/device_asset.entity.g.dart @@ -65,7 +65,7 @@ const DeviceAssetEntitySchema = CollectionSchema( getId: _deviceAssetEntityGetId, getLinks: _deviceAssetEntityGetLinks, attach: _deviceAssetEntityAttach, - version: '3.1.8', + version: '3.3.0-dev.3', ); int _deviceAssetEntityEstimateSize( diff --git a/mobile/lib/infrastructure/entities/exif.entity.g.dart b/mobile/lib/infrastructure/entities/exif.entity.g.dart index d2f9ebda277d4..ffbfd0d8f092b 100644 --- a/mobile/lib/infrastructure/entities/exif.entity.g.dart +++ b/mobile/lib/infrastructure/entities/exif.entity.g.dart @@ -68,7 +68,7 @@ const ExifInfoSchema = CollectionSchema( getId: _exifInfoGetId, getLinks: _exifInfoGetLinks, attach: _exifInfoAttach, - version: '3.1.8', + version: '3.3.0-dev.3', ); int _exifInfoEstimateSize( diff --git a/mobile/lib/infrastructure/entities/store.entity.g.dart b/mobile/lib/infrastructure/entities/store.entity.g.dart index 7da92cf778489..626c3084fe62d 100644 --- a/mobile/lib/infrastructure/entities/store.entity.g.dart +++ b/mobile/lib/infrastructure/entities/store.entity.g.dart @@ -37,7 +37,7 @@ const StoreValueSchema = CollectionSchema( getId: _storeValueGetId, getLinks: _storeValueGetLinks, attach: _storeValueAttach, - version: '3.1.8', + version: '3.3.0-dev.3', ); int _storeValueEstimateSize( diff --git a/mobile/lib/infrastructure/entities/user.entity.g.dart b/mobile/lib/infrastructure/entities/user.entity.g.dart index bb870517310f5..7e0af41b77ede 100644 --- a/mobile/lib/infrastructure/entities/user.entity.g.dart +++ b/mobile/lib/infrastructure/entities/user.entity.g.dart @@ -95,7 +95,7 @@ const UserSchema = CollectionSchema( getId: _userGetId, getLinks: _userGetLinks, attach: _userAttach, - version: '3.1.8', + version: '3.3.0-dev.3', ); int _userEstimateSize( diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index 8bf2e80579a81..d790d909fb83e 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -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 ack(List data) { return _api.syncApi.sendSyncAck(SyncAckSetDto(acks: data)); @@ -23,10 +25,8 @@ class SyncApiRepository { Future Function(List, 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"); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 263a5ef7695cb..3549b0bd762bc 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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(); - - runApp( - ProviderScope( - overrides: [ - dbProvider.overrideWithValue(isar), - isarProvider.overrideWithValue(isar), - driftProvider.overrideWith(driftOverride(drift)), - ], - child: const MainWidget(), - ), - ); + 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(), + ), + ); + } 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 initApp() async { @@ -120,6 +140,10 @@ Future initApp() async { }); } +Future setPackageInfo() async { + await PackageInfoSingleton.instance.init(); +} + class ImmichApp extends ConsumerStatefulWidget { const ImmichApp({super.key}); diff --git a/mobile/lib/pages/common/error_display_screen.dart b/mobile/lib/pages/common/error_display_screen.dart new file mode 100644 index 0000000000000..9e5dc0266252d --- /dev/null +++ b/mobile/lib/pages/common/error_display_screen.dart @@ -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), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index 38f2c22cf22a6..6cd6bdbddb6fa 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -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 backupWithDartClient(Iterable tasks, CancellationToken cancelToken) async { - final httpClient = Client(); + Future backupWithDartClient(Iterable 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) { diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 4033ffb184ce6..9b93dee90a5df 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -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 _setUserAgentHeader() async { - final userAgent = await getUserAgentString(); + final userAgent = getUserAgentString(); _apiClient.addDefaultHeader('User-Agent', userAgent); } diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 03d91328d15be..378a1fa2fd2e5 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -40,6 +40,7 @@ enum AppSettingsEnum { mapwithPartners(StoreKey.mapwithPartners, null, false), mapRelativeDate(StoreKey.mapRelativeDate, null, 0), allowSelfSignedSSLCert(StoreKey.selfSignedCert, null, false), + useUserCertificates(StoreKey.useUserCertificates, null, false), ignoreIcloudAssets(StoreKey.ignoreIcloudAssets, null, false), selectedAlbumSortReverse(StoreKey.selectedAlbumSortReverse, null, false), enableHapticFeedback(StoreKey.enableHapticFeedback, null, true), diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index 91c23cac1c423..3af526b89cb6d 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -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 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); - - // add auth token + any configured custom headers - final customHeaders = ApiService.getRequestHeaders(); - customHeaders.forEach((key, value) { - request.headers.add(key, value); - }); + final response = await httpclient.get(uri, headers: ApiService.getRequestHeaders()); - 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; diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 539fd1fbd9346..c788669a13412 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -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?> 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 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); } } diff --git a/mobile/lib/utils/http_ssl_cert_override.dart b/mobile/lib/utils/http_ssl_cert_override.dart index a4c97a532f264..e94a00ef065ee 100644 --- a/mobile/lib/utils/http_ssl_cert_override.dart +++ b/mobile/lib/utils/http_ssl_cert_override.dart @@ -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) { diff --git a/mobile/lib/utils/http_ssl_options.dart b/mobile/lib/utils/http_ssl_options.dart index c4e2ad69f7fce..7939ac7d677b4 100644 --- a/mobile/lib/utils/http_ssl_options.dart +++ b/mobile/lib/utils/http_ssl_options.dart @@ -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, 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, 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); + + // if (applyNative && Platform.isAndroid) { + // _channel + // .invokeMethod("apply", [allowSelfSignedSSLCert, serverHost, clientCert?.data, clientCert?.password]) + // .onError((e, _) { + // final log = Logger("HttpSSLOptions"); + // log.severe('Failed to set SSL options', e.message); + // }); + // } + } - HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert); + 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((e, _) { + // final log = Logger("HttpSSLOptions"); + // log.severe('Failed to set SSL options with user certificates', e.message); + // }); + // } + } - if (applyNative && Platform.isAndroid) { - _channel - .invokeMethod("apply", [allowSelfSignedSSLCert, serverHost, clientCert?.data, clientCert?.password]) - .onError((e, _) { - final log = Logger("HttpSSLOptions"); - log.severe('Failed to set SSL options', e.message); - }); + static Future>> getAvailableUserCertificates() async { + try { + final List certificates = await _channel.invokeMethod("getAvailableCertificates"); + return certificates.map((cert) => Map.from(cert)).toList(); + } catch (e) { + final log = Logger("HttpSSLOptions"); + log.severe('Failed to get available user certificates: $e'); + return []; } } } diff --git a/mobile/lib/utils/user_agent.dart b/mobile/lib/utils/user_agent.dart index 232bcaec38076..5baa6e9a37d8a 100644 --- a/mobile/lib/utils/user_agent.dart +++ b/mobile/lib/utils/user_agent.dart @@ -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 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 getUserAgentString() async { } else { platform = 'Unknown'; } - return 'Immich_${platform}_${packageInfo.version}'; + final version = packageInfo.version ?? 'unknown'; + return 'Immich_${platform}_$version'; } diff --git a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart index dc31acf0a4506..a77edfb31a098 100644 --- a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart +++ b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart @@ -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 { - _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 { 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 { ); } - Future 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 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 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 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(); } } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index ef1937e0273fc..aa0a42e52ecb6 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -317,10 +317,10 @@ packages: dependency: "direct main" description: name: connectivity_plus - sha256: "04bf81bb0b77de31557b58d052b24b3eee33f09a6e7a8c68a3e247c7df19ec27" + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec url: "https://pub.dev" source: hosted - version: "6.1.3" + version: "6.1.5" connectivity_plus_platform_interface: dependency: transitive description: @@ -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: @@ -1023,29 +1031,44 @@ packages: dependency: "direct main" description: path: "packages/isar" - ref: "3561848fe7f5811743b880ddd96bb18dede27306" - resolved-ref: "3561848fe7f5811743b880ddd96bb18dede27306" + ref: bb1dca40fe87a001122e5d43bc6254718cb49f3a + resolved-ref: bb1dca40fe87a001122e5d43bc6254718cb49f3a url: "https://github.com/immich-app/isar" source: git version: "3.1.8" - isar_flutter_libs: + isar_community: + dependency: transitive + description: + name: isar_community + sha256: "28f59e54636c45ba0bb1b3b7f2656f1c50325f740cea6efcd101900be3fba546" + url: "https://pub.dev" + source: hosted + version: "3.3.0-dev.3" + isar_community_flutter_libs: dependency: "direct main" description: - path: "packages/isar_flutter_libs" - ref: "3561848fe7f5811743b880ddd96bb18dede27306" - resolved-ref: "3561848fe7f5811743b880ddd96bb18dede27306" - url: "https://github.com/immich-app/isar" - source: git - version: "3.1.8" + name: isar_community_flutter_libs + sha256: c2934fe755bb3181cb67602fd5df0d080b3d3eb52799f98623aa4fc5acbea010 + url: "https://pub.dev" + source: hosted + version: "3.3.0-dev.3" isar_generator: dependency: "direct dev" description: path: "packages/isar_generator" - ref: "3561848fe7f5811743b880ddd96bb18dede27306" - resolved-ref: "3561848fe7f5811743b880ddd96bb18dede27306" + ref: bb1dca40fe87a001122e5d43bc6254718cb49f3a + resolved-ref: bb1dca40fe87a001122e5d43bc6254718cb49f3a 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: @@ -1247,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: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 83c7da5b7d79e..5131e4b427fd6 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -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: @@ -81,13 +89,9 @@ dependencies: isar: git: url: https://github.com/immich-app/isar - ref: '3561848fe7f5811743b880ddd96bb18dede27306' + ref: 'bb1dca40fe87a001122e5d43bc6254718cb49f3a' path: packages/isar/ - isar_flutter_libs: - git: - url: https://github.com/immich-app/isar - ref: '3561848fe7f5811743b880ddd96bb18dede27306' - path: packages/isar_flutter_libs/ + isar_community_flutter_libs: 3.3.0-dev.3 # DB drift: ^2.23.1 drift_flutter: ^0.2.4 @@ -103,7 +107,7 @@ dev_dependencies: isar_generator: git: url: https://github.com/immich-app/isar - ref: '3561848fe7f5811743b880ddd96bb18dede27306' + ref: 'bb1dca40fe87a001122e5d43bc6254718cb49f3a' path: packages/isar_generator/ integration_test: sdk: flutter diff --git a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart index 467e19bf3f8d1..2eb039c124c23 100644 --- a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart +++ b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart @@ -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 streamChanges( Future Function(List, 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 {