diff --git a/mobile/android/app/src/main/cpp/native_buffer.c b/mobile/android/app/src/main/cpp/native_buffer.c index bcc9d5c7c8..bed1045382 100644 --- a/mobile/android/app/src/main/cpp/native_buffer.c +++ b/mobile/android/app/src/main/cpp/native_buffer.c @@ -36,3 +36,17 @@ Java_app_alextran_immich_NativeBuffer_copy( memcpy((void *) destAddress, (char *) src + offset, length); } } + +/** + * Creates a JNI global reference to the given object and returns its address. + * The caller is responsible for deleting the global reference when it's no longer needed. + */ +JNIEXPORT jlong JNICALL +Java_app_alextran_immich_NativeBuffer_createGlobalRef(JNIEnv *env, jobject clazz, jobject obj) { + if (obj == NULL) { + return 0; + } + + jobject globalRef = (*env)->NewGlobalRef(env, obj); + return (jlong) globalRef; +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt index a9011f3047..74f0241850 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/NativeBuffer.kt @@ -23,6 +23,9 @@ object NativeBuffer { @JvmStatic external fun copy(buffer: ByteBuffer, destAddress: Long, offset: Int, length: Int) + + @JvmStatic + external fun createGlobalRef(obj: Any): Long } class NativeByteBuffer(initialCapacity: Int) { diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt index 1e7156a147..06e77d9b72 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/Network.g.kt @@ -182,6 +182,7 @@ interface NetworkApi { fun addCertificate(clientData: ClientCertData, callback: (Result) -> Unit) fun selectCertificate(promptText: ClientCertPrompt, callback: (Result) -> Unit) fun removeCertificate(callback: (Result) -> Unit) + fun getClientPointer(): Long companion object { /** The codec used by NetworkApi. */ @@ -248,6 +249,21 @@ interface NetworkApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getClientPointer()) + } catch (exception: Throwable) { + NetworkPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt index 4f25896b2f..ac71bc5ef6 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/NetworkApiPlugin.kt @@ -13,6 +13,7 @@ import android.widget.LinearLayout import androidx.activity.ComponentActivity import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import app.alextran.immich.NativeBuffer import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout @@ -98,6 +99,11 @@ private class NetworkApiImpl(private val context: Context) : NetworkApi { callback(Result.success(Unit)) } + override fun getClientPointer(): Long { + val client = HttpClientManager.getClient() + return NativeBuffer.createGlobalRef(client) + } + private fun handlePickedFile(uri: Uri) { val callback = pendingCallback ?: return pendingCallback = null diff --git a/mobile/ios/Runner/Core/Network.g.swift b/mobile/ios/Runner/Core/Network.g.swift index 0f678ce4a4..9dd81ee92b 100644 --- a/mobile/ios/Runner/Core/Network.g.swift +++ b/mobile/ios/Runner/Core/Network.g.swift @@ -223,6 +223,7 @@ protocol NetworkApi { func addCertificate(clientData: ClientCertData, completion: @escaping (Result) -> Void) func selectCertificate(promptText: ClientCertPrompt, completion: @escaping (Result) -> Void) func removeCertificate(completion: @escaping (Result) -> Void) + func getClientPointer() throws -> Int64 } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -280,5 +281,18 @@ class NetworkApiSetup { } else { removeCertificateChannel.setMessageHandler(nil) } + let getClientPointerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getClientPointerChannel.setMessageHandler { _, reply in + do { + let result = try api.getClientPointer() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getClientPointerChannel.setMessageHandler(nil) + } } } diff --git a/mobile/ios/Runner/Core/NetworkApiImpl.swift b/mobile/ios/Runner/Core/NetworkApiImpl.swift index d67c392a3a..b9faa89fce 100644 --- a/mobile/ios/Runner/Core/NetworkApiImpl.swift +++ b/mobile/ios/Runner/Core/NetworkApiImpl.swift @@ -40,6 +40,11 @@ class NetworkApiImpl: NetworkApi { } completion(.failure(ImportError.keychainError(status))) } + + func getClientPointer() throws -> Int64 { + let pointer = URLSessionManager.shared.sessionPointer + return Int64(Int(bitPattern: pointer)) + } } private class CertImporter: NSObject, UIDocumentPickerDelegate { diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift index 73145dbce5..1e127b312b 100644 --- a/mobile/ios/Runner/Core/URLSessionManager.swift +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -31,6 +31,10 @@ class URLSessionManager: NSObject { return config }() + var sessionPointer: UnsafeMutableRawPointer { + Unmanaged.passUnretained(session).toOpaque() + } + private override init() { session = URLSession(configuration: configuration, delegate: URLSessionManagerDelegate(), delegateQueue: nil) super.init() diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 6de13b6244..d3409ad8c4 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'dart:ui'; import 'package:background_downloader/background_downloader.dart'; -import 'package:cancellation_token_http/http.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; @@ -28,7 +27,6 @@ import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/wm_executor.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; @@ -64,7 +62,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { final Drift _drift; final DriftLogger _driftLogger; final BackgroundWorkerBgHostApi _backgroundHostApi; - final CancellationToken _cancellationToken = CancellationToken(); + final _cancellationToken = Completer(); final Logger _logger = Logger('BackgroundWorkerBgService'); bool _isCleanedUp = false; @@ -88,8 +86,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { Future init() async { try { - HttpSSLOptions.apply(); - await Future.wait( [ loadTranslations(), @@ -198,7 +194,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { _ref?.dispose(); _ref = null; - _cancellationToken.cancel(); + _cancellationToken.complete(); _logger.info("Cleaning up background worker"); final cleanupFutures = [ diff --git a/mobile/lib/infrastructure/repositories/network.repository.dart b/mobile/lib/infrastructure/repositories/network.repository.dart index a73322cb5c..9029479af8 100644 --- a/mobile/lib/infrastructure/repositories/network.repository.dart +++ b/mobile/lib/infrastructure/repositories/network.repository.dart @@ -1,67 +1,46 @@ +import 'dart:ffi'; import 'dart:io'; -import 'package:cronet_http/cronet_http.dart'; import 'package:cupertino_http/cupertino_http.dart'; import 'package:http/http.dart' as http; -import 'package:immich_mobile/utils/user_agent.dart'; -import 'package:path_provider/path_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:logging/logging.dart'; +import 'package:ok_http/ok_http.dart'; class NetworkRepository { - static late Directory _cachePath; - static late String _userAgent; - static final _clients = {}; + static final _log = Logger('NetworkRepository'); + static http.Client? _client; - static Future init() { - return ( - getTemporaryDirectory().then((cachePath) => _cachePath = cachePath), - getUserAgentString().then((userAgent) => _userAgent = userAgent), - ).wait; - } - - static void reset() { - Future.microtask(init); - for (final client in _clients.values) { - client.close(); + static Future init() async { + final pointer = await networkApi.getClientPointer(); + _client?.close(); + if (Platform.isIOS) { + _client = _createIOSClient(pointer); + } else { + _client = _createAndroidClient(pointer); } - _clients.clear(); } const NetworkRepository(); - /// Note: when disk caching is enabled, only one client may use a given directory at a time. - /// Different isolates or engines must use different directories. - http.Client getHttpClient( - String directoryName, { - CacheMode cacheMode = CacheMode.memory, - int diskCapacity = 0, - int maxConnections = 6, - int memoryCapacity = 10 << 20, - }) { - final cachedClient = _clients[directoryName]; - if (cachedClient != null) { - return cachedClient; - } + /// Returns a shared HTTP client that uses native SSL configuration. + /// + /// On iOS: Uses SharedURLSessionManager's URLSession. + /// On Android: Uses SharedHttpClientManager's OkHttpClient. + /// + /// Must call [init] before using this method. + static http.Client get client => _client!; - final directory = Directory('${_cachePath.path}/$directoryName'); - directory.createSync(recursive: true); - if (Platform.isAndroid) { - final engine = CronetEngine.build( - cacheMode: cacheMode, - cacheMaxSize: diskCapacity, - storagePath: directory.path, - userAgent: _userAgent, - ); - return _clients[directoryName] = CronetClient.fromCronetEngine(engine, closeEngine: true); - } + static http.Client _createIOSClient(int address) { + final pointer = Pointer.fromAddress(address); + final session = URLSession.fromRawPointer(pointer.cast()); + _log.info('Using shared native URLSession'); + return CupertinoClient.fromSharedSession(session); + } - final config = URLSessionConfiguration.defaultSessionConfiguration() - ..httpMaximumConnectionsPerHost = maxConnections - ..cache = URLCache.withCapacity( - diskCapacity: diskCapacity, - memoryCapacity: memoryCapacity, - directory: directory.uri, - ) - ..httpAdditionalHeaders = {'User-Agent': _userAgent}; - return _clients[directoryName] = CupertinoClient.fromSessionConfiguration(config); + static http.Client _createAndroidClient(int address) { + final pointer = Pointer.fromAddress(address); + _log.info('Using shared native OkHttpClient'); + return OkHttpClient.fromJniGlobalRef(pointer); } } diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index d13083d706..90ff1b1c2e 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -30,7 +31,7 @@ class SyncApiRepository { http.Client? httpClient, }) async { final stopwatch = Stopwatch()..start(); - final client = httpClient ?? http.Client(); + final client = httpClient ?? NetworkRepository.client; final endpoint = "${_api.apiClient.basePath}/sync/stream"; final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'}; @@ -116,8 +117,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 60bb1cb9c3..a7e6166e60 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -39,7 +39,6 @@ import 'package:immich_mobile/theme/theme_data.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/cache/widgets_binding.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/utils/licenses.dart'; import 'package:immich_mobile/utils/migration.dart'; import 'package:immich_mobile/wm_executor.dart'; @@ -57,7 +56,6 @@ void main() async { // Warm-up isolate pool for worker manager await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5)); await migrateDatabaseIfNeeded(isar, drift); - HttpSSLOptions.apply(); runApp( ProviderScope( @@ -241,7 +239,7 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve @override void reassemble() { if (kDebugMode) { - NetworkRepository.reset(); + NetworkRepository.init(); } super.reassemble(); } diff --git a/mobile/lib/models/backup/backup_state.model.dart b/mobile/lib/models/backup/backup_state.model.dart index 635d925c3f..f5d667ce66 100644 --- a/mobile/lib/models/backup/backup_state.model.dart +++ b/mobile/lib/models/backup/backup_state.model.dart @@ -1,6 +1,7 @@ // ignore_for_file: public_member_api_docs, sort_constructors_first -import 'package:cancellation_token_http/http.dart'; +import 'dart:async'; + import 'package:collection/collection.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; @@ -21,7 +22,7 @@ class BackUpState { final DateTime progressInFileSpeedUpdateTime; final int progressInFileSpeedUpdateSentBytes; final double iCloudDownloadProgress; - final CancellationToken cancelToken; + final Completer cancelToken; final ServerDiskInfo serverInfo; final bool autoBackup; final bool backgroundBackup; @@ -78,7 +79,7 @@ class BackUpState { DateTime? progressInFileSpeedUpdateTime, int? progressInFileSpeedUpdateSentBytes, double? iCloudDownloadProgress, - CancellationToken? cancelToken, + Completer? cancelToken, ServerDiskInfo? serverInfo, bool? autoBackup, bool? backgroundBackup, diff --git a/mobile/lib/models/backup/manual_upload_state.model.dart b/mobile/lib/models/backup/manual_upload_state.model.dart index 7f797334de..183e11b526 100644 --- a/mobile/lib/models/backup/manual_upload_state.model.dart +++ b/mobile/lib/models/backup/manual_upload_state.model.dart @@ -1,10 +1,11 @@ -import 'package:cancellation_token_http/http.dart'; +import 'dart:async'; + import 'package:collection/collection.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; class ManualUploadState { - final CancellationToken cancelToken; + final Completer cancelToken; // Current Backup Asset final CurrentUploadAsset currentUploadAsset; @@ -44,7 +45,7 @@ class ManualUploadState { List? progressInFileSpeeds, DateTime? progressInFileSpeedUpdateTime, int? progressInFileSpeedUpdateSentBytes, - CancellationToken? cancelToken, + Completer? cancelToken, CurrentUploadAsset? currentUploadAsset, int? totalAssetsToUpload, int? successfulUploads, diff --git a/mobile/lib/platform/network_api.g.dart b/mobile/lib/platform/network_api.g.dart index 6ddb3cdb71..92b454c591 100644 --- a/mobile/lib/platform/network_api.g.dart +++ b/mobile/lib/platform/network_api.g.dart @@ -229,4 +229,32 @@ class NetworkApi { return; } } + + Future getClientPointer() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.NetworkApi.getClientPointer$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as int?)!; + } + } } diff --git a/mobile/lib/presentation/pages/editing/drift_edit.page.dart b/mobile/lib/presentation/pages/editing/drift_edit.page.dart index 7e49348e19..dc6e92891b 100644 --- a/mobile/lib/presentation/pages/editing/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/editing/drift_edit.page.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:ui'; import 'package:auto_route/auto_route.dart'; -import 'package:cancellation_token_http/http.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -79,7 +78,7 @@ class DriftEditImagePage extends ConsumerWidget { return; } - await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken()); + await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], Completer()); } catch (e) { ImmichToast.show( durationInSecond: 6, diff --git a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart index d69c5bced3..92630799a1 100644 --- a/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart @@ -101,7 +101,7 @@ class _UploadProgressDialog extends ConsumerWidget { actions: [ ImmichTextButton( onPressed: () { - ref.read(manualUploadCancelTokenProvider)?.cancel(); + ref.read(manualUploadCancelTokenProvider)?.complete(); Navigator.of(context).pop(); }, labelText: 'cancel'.t(context: context), diff --git a/mobile/lib/providers/backup/asset_upload_progress.provider.dart b/mobile/lib/providers/backup/asset_upload_progress.provider.dart index e8aba430da..47ccac20f2 100644 --- a/mobile/lib/providers/backup/asset_upload_progress.provider.dart +++ b/mobile/lib/providers/backup/asset_upload_progress.provider.dart @@ -1,4 +1,5 @@ -import 'package:cancellation_token_http/http.dart'; +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; /// Tracks per-asset upload progress. @@ -30,4 +31,4 @@ final assetUploadProgressProvider = NotifierProvider((ref) => null); +final manualUploadCancelTokenProvider = StateProvider((ref) => null); diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 9eb01b6109..2df858645e 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -1,6 +1,6 @@ +import 'dart:async'; import 'dart:io'; -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; @@ -68,7 +68,7 @@ class BackupNotifier extends StateNotifier { progressInFileSpeeds: const [], progressInFileSpeedUpdateTime: DateTime.now(), progressInFileSpeedUpdateSentBytes: 0, - cancelToken: CancellationToken(), + cancelToken: Completer(), autoBackup: Store.get(StoreKey.autoBackup, false), backgroundBackup: Store.get(StoreKey.backgroundBackup, false), backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true), @@ -454,7 +454,7 @@ class BackupNotifier extends StateNotifier { } // Perform Backup - state = state.copyWith(cancelToken: CancellationToken()); + state = state.copyWith(cancelToken: Completer()); final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; @@ -494,7 +494,7 @@ class BackupNotifier extends StateNotifier { if (state.backupProgress != BackUpProgressEnum.inProgress) { notifyBackgroundServiceCanRun(); } - state.cancelToken.cancel(); + state.cancelToken.complete(); state = state.copyWith( backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0, diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index 624c21f158..704b2f8a4c 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:logging/logging.dart'; @@ -109,7 +108,7 @@ class DriftBackupState { final BackupError error; final Map uploadItems; - final CancellationToken? cancelToken; + final Completer? cancelToken; final Map iCloudDownloadProgress; @@ -133,7 +132,7 @@ class DriftBackupState { bool? isSyncing, BackupError? error, Map? uploadItems, - CancellationToken? cancelToken, + Completer? cancelToken, Map? iCloudDownloadProgress, }) { return DriftBackupState( @@ -266,7 +265,7 @@ class DriftBackupNotifier extends StateNotifier { state = state.copyWith(error: BackupError.none); - final cancelToken = CancellationToken(); + final cancelToken = Completer(); state = state.copyWith(cancelToken: cancelToken); return _foregroundUploadService.uploadCandidates( @@ -282,7 +281,7 @@ class DriftBackupNotifier extends StateNotifier { } Future stopForegroundBackup() async { - state.cancelToken?.cancel(); + state.cancelToken?.complete(); _uploadSpeedManager.clear(); state = state.copyWith(cancelToken: null, uploadItems: {}, iCloudDownloadProgress: {}); } diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart index 6ad8730356..635fef838b 100644 --- a/mobile/lib/providers/backup/manual_upload.provider.dart +++ b/mobile/lib/providers/backup/manual_upload.provider.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:io'; -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/widgets.dart'; @@ -65,7 +64,7 @@ class ManualUploadNotifier extends StateNotifier { progressInFileSpeeds: const [], progressInFileSpeedUpdateTime: DateTime.now(), progressInFileSpeedUpdateSentBytes: 0, - cancelToken: CancellationToken(), + cancelToken: Completer(), currentUploadAsset: CurrentUploadAsset( id: '...', fileCreatedAt: DateTime.parse('2020-10-04'), @@ -236,7 +235,7 @@ class ManualUploadNotifier extends StateNotifier { fileName: '...', fileType: '...', ), - cancelToken: CancellationToken(), + cancelToken: Completer(), ); // Reset Error List ref.watch(errorBackupListProvider.notifier).empty(); @@ -273,14 +272,14 @@ class ManualUploadNotifier extends StateNotifier { ); // User cancelled upload - if (!ok && state.cancelToken.isCancelled) { + if (!ok && state.cancelToken.isCompleted) { await _localNotificationService.showOrUpdateManualUploadStatus( "backup_manual_title".tr(), "backup_manual_cancelled".tr(), presentBanner: true, ); hasErrors = true; - } else if (state.successfulUploads == 0 || (!ok && !state.cancelToken.isCancelled)) { + } else if (state.successfulUploads == 0 || (!ok && !state.cancelToken.isCompleted)) { await _localNotificationService.showOrUpdateManualUploadStatus( "backup_manual_title".tr(), "failed".tr(), @@ -324,7 +323,7 @@ class ManualUploadNotifier extends StateNotifier { _backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) { _backupProvider.notifyBackgroundServiceCanRun(); } - state.cancelToken.cancel(); + state.cancelToken.complete(); if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) { _backupProvider.updateBackupProgress(BackUpProgressEnum.idle); } diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 75f40ca290..941001ab9f 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:background_downloader/background_downloader.dart'; -import 'package:cancellation_token_http/http.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -437,7 +436,7 @@ class ActionNotifier extends Notifier { final assetsToUpload = assets ?? _getAssets(source).whereType().toList(); final progressNotifier = ref.read(assetUploadProgressProvider.notifier); - final cancelToken = CancellationToken(); + final cancelToken = Completer(); ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken; // Initialize progress for all assets diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index aff84683c3..d868fb4423 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -3,21 +3,14 @@ import 'dart:convert'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; -import 'package:cancellation_token_http/http.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:logging/logging.dart'; +import 'package:http/http.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -class UploadTaskWithFile { - final File file; - final UploadTask task; - - const UploadTaskWithFile({required this.file, required this.task}); -} - final uploadRepositoryProvider = Provider((ref) => UploadRepository()); class UploadRepository { @@ -100,23 +93,26 @@ class UploadRepository { required Map headers, required Map fields, required Client httpClient, - required CancellationToken cancelToken, - required void Function(int bytes, int totalBytes) onProgress, + required Completer cancelToken, + required void Function(int bytes, int totalBytes) onProgress, // TODO: use onProgress required String logContext, }) async { final String savedEndpoint = Store.get(StoreKey.serverEndpoint); + final baseRequest = AbortableMultipartRequest( + 'POST', + Uri.parse('$savedEndpoint/assets'), + abortTrigger: cancelToken.future, + ); try { final fileStream = file.openRead(); final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName); - final baseRequest = _CustomMultipartRequest('POST', Uri.parse('$savedEndpoint/assets'), onProgress: onProgress); - baseRequest.headers.addAll(headers); baseRequest.fields.addAll(fields); baseRequest.files.add(assetRawUploadData); - final response = await httpClient.send(baseRequest, cancellationToken: cancelToken); + final response = await httpClient.send(baseRequest); final responseBodyString = await response.stream.bytesToString(); if (![200, 201].contains(response.statusCode)) { @@ -145,7 +141,7 @@ class UploadRepository { } catch (e) { return UploadResult.error(errorMessage: 'Failed to parse server response'); } - } on CancelledException { + } on RequestAbortedException { logger.warning("Upload $logContext was cancelled"); return UploadResult.cancelled(); } catch (error, stackTrace) { @@ -182,26 +178,3 @@ class UploadResult { return const UploadResult(isSuccess: false, isCancelled: true); } } - -class _CustomMultipartRequest extends MultipartRequest { - _CustomMultipartRequest(super.method, super.url, {required this.onProgress}); - - final void Function(int bytes, int totalBytes) onProgress; - - @override - ByteStream finalize() { - final byteStream = super.finalize(); - final total = contentLength; - var bytes = 0; - - final t = StreamTransformer.fromHandlers( - handleData: (List data, EventSink> sink) { - bytes += data.length; - onProgress.call(bytes, total); - sink.add(data); - }, - ); - final stream = byteStream.transform(t); - return ByteStream(stream); - } -} diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 1a714b6f40..2c860e68c1 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -3,12 +3,11 @@ import 'dart:convert'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; -import 'package:http/http.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/url_helper.dart'; -import 'package:immich_mobile/utils/user_agent.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -50,7 +49,7 @@ class ApiService implements Authentication { setEndpoint(String endpoint) { _apiClient = ApiClient(basePath: endpoint, authentication: this); - _setUserAgentHeader(); + _apiClient.client = NetworkRepository.client; if (_accessToken != null) { setAccessToken(_accessToken!); } @@ -76,11 +75,6 @@ class ApiService implements Authentication { sessionsApi = SessionsApi(_apiClient); } - Future _setUserAgentHeader() async { - final userAgent = await getUserAgentString(); - _apiClient.addDefaultHeader('User-Agent', userAgent); - } - Future resolveAndSetEndpoint(String serverUrl) async { final endpoint = await resolveEndpoint(serverUrl); setEndpoint(endpoint); @@ -134,13 +128,11 @@ class ApiService implements Authentication { } Future _getWellKnownEndpoint(String baseUrl) async { - final Client client = Client(); - try { var headers = {"Accept": "application/json"}; headers.addAll(getRequestHeaders()); - final res = await client + final res = await NetworkRepository.client .get(Uri.parse("$baseUrl/.well-known/immich"), headers: headers) .timeout(const Duration(seconds: 5)); diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index 3173f49957..0ed9328d3e 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -1,10 +1,10 @@ import 'dart:async'; -import 'dart:io'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/login_response.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; @@ -64,27 +64,16 @@ class AuthService { } Future validateAuxilaryServerUrl(String url) async { - final httpclient = HttpClient(); bool isValid = false; try { final uri = Uri.parse('$url/users/me'); - final request = await httpclient.getUrl(uri); - - // add auth token + any configured custom headers - final customHeaders = ApiService.getRequestHeaders(); - customHeaders.forEach((key, value) { - request.headers.add(key, value); - }); - - final response = await request.close(); + final response = await NetworkRepository.client.get(uri, headers: ApiService.getRequestHeaders()); 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/background.service.dart b/mobile/lib/services/background.service.dart index b69aa53014..4382a848cf 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -4,7 +4,6 @@ import 'dart:io'; import 'dart:isolate'; import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities; -import 'package:cancellation_token_http/http.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; @@ -30,7 +29,6 @@ import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:path_provider_foundation/path_provider_foundation.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; @@ -43,7 +41,7 @@ class BackgroundService { static const MethodChannel _backgroundChannel = MethodChannel('immich/backgroundChannel'); static const notifyInterval = Duration(milliseconds: 400); bool _isBackgroundInitialized = false; - CancellationToken? _cancellationToken; + Completer? _cancellationToken; bool _canceledBySystem = false; int _wantsLockTime = 0; bool _hasLock = false; @@ -321,7 +319,7 @@ class BackgroundService { } case "systemStop": _canceledBySystem = true; - _cancellationToken?.cancel(); + _cancellationToken?.complete(); return true; default: dPrint(() => "Unknown method ${call.method}"); @@ -341,7 +339,6 @@ class BackgroundService { ], ); - HttpSSLOptions.apply(); await ref.read(apiServiceProvider).setAccessToken(Store.get(StoreKey.accessToken)); await ref.read(authServiceProvider).setOpenApiServiceEndpoint(); dPrint(() => "[BG UPLOAD] Using endpoint: ${ref.read(apiServiceProvider).apiClient.basePath}"); @@ -441,7 +438,7 @@ class BackgroundService { ), ); - _cancellationToken = CancellationToken(); + _cancellationToken = Completer(); final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null; final bool ok = await backupService.backupAsset( @@ -455,7 +452,7 @@ class BackgroundService { isBackground: true, ); - if (!ok && !_cancellationToken!.isCancelled) { + if (!ok && !_cancellationToken!.isCompleted) { unawaited( _showErrorNotification( title: "backup_background_service_error_title".tr(), diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 539fd1fbd9..25dfcf57f6 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -2,14 +2,15 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:cancellation_token_http/http.dart' as http; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; @@ -43,7 +44,6 @@ final backupServiceProvider = Provider( ); class BackupService { - final httpClient = http.Client(); final ApiService _apiService; final Logger _log = Logger("BackupService"); final AppSettingsService _appSetting; @@ -233,11 +233,11 @@ class BackupService { Future backupAsset( Iterable assets, - http.CancellationToken cancelToken, { + Completer cancelToken, { bool isBackground = false, PMProgressHandler? pmProgressHandler, required void Function(SuccessUploadAsset result) onSuccess, - required void Function(int bytes, int totalBytes) onProgress, + required void Function(int bytes, int totalBytes) onProgress, // TODO: use onProgress required void Function(CurrentUploadAsset asset) onCurrentAsset, required void Function(ErrorUploadAsset error) onError, }) async { @@ -306,17 +306,17 @@ class BackupService { } final fileStream = file.openRead(); - final assetRawUploadData = http.MultipartFile( + final assetRawUploadData = MultipartFile( "assetData", fileStream, file.lengthSync(), filename: originalFileName, ); - final baseRequest = MultipartRequest( + final baseRequest = AbortableMultipartRequest( 'POST', Uri.parse('$savedEndpoint/assets'), - onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)), + abortTrigger: cancelToken.future, ); baseRequest.headers.addAll(ApiService.getRequestHeaders()); @@ -348,7 +348,7 @@ class BackupService { baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId; } - final response = await httpClient.send(baseRequest, cancellationToken: cancelToken); + final response = await NetworkRepository.client.send(baseRequest); final responseBody = jsonDecode(await response.stream.bytesToString()); @@ -398,7 +398,7 @@ class BackupService { await _albumService.syncUploadAlbums(candidate.albumNames, [responseBody['id'] as String]); } } - } on http.CancelledException { + } on RequestAbortedException { dPrint(() => "Backup was cancelled by the user"); anyErrors = true; break; @@ -429,26 +429,27 @@ class BackupService { String originalFileName, File? livePhotoVideoFile, MultipartRequest baseRequest, - http.CancellationToken cancelToken, + Completer cancelToken, ) async { if (livePhotoVideoFile == null) { return null; } final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoVideoFile.path)); final fileStream = livePhotoVideoFile.openRead(); - final livePhotoRawUploadData = http.MultipartFile( + final livePhotoRawUploadData = MultipartFile( "assetData", fileStream, livePhotoVideoFile.lengthSync(), filename: livePhotoTitle, ); - final livePhotoReq = MultipartRequest(baseRequest.method, baseRequest.url, onProgress: baseRequest.onProgress) + final livePhotoReq = + AbortableMultipartRequest(baseRequest.method, baseRequest.url, abortTrigger: cancelToken.future) ..headers.addAll(baseRequest.headers) ..fields.addAll(baseRequest.fields); livePhotoReq.files.add(livePhotoRawUploadData); - var response = await httpClient.send(livePhotoReq, cancellationToken: cancelToken); + var response = await NetworkRepository.client.send(livePhotoReq); var responseBody = jsonDecode(await response.stream.bytesToString()); @@ -470,31 +471,3 @@ class BackupService { AssetType.other => "OTHER", }; } - -class MultipartRequest extends http.MultipartRequest { - /// Creates a new [MultipartRequest]. - MultipartRequest(super.method, super.url, {required this.onProgress}); - - final void Function(int bytes, int totalBytes) onProgress; - - /// Freezes all mutable fields and returns a - /// single-subscription [http.ByteStream] - /// that will emit the request body. - @override - http.ByteStream finalize() { - final byteStream = super.finalize(); - - final total = contentLength; - var bytes = 0; - - final t = StreamTransformer.fromHandlers( - handleData: (List data, EventSink> sink) { - bytes += data.length; - onProgress.call(bytes, total); - sink.add(data); - }, - ); - final stream = byteStream.transform(t); - return http.ByteStream(stream); - } -} diff --git a/mobile/lib/services/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart index cd28942bd2..34f509413a 100644 --- a/mobile/lib/services/foreground_upload.service.dart +++ b/mobile/lib/services/foreground_upload.service.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:cancellation_token_http/http.dart'; +import 'package:http/http.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -12,6 +12,7 @@ import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/network_capability_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/platform/connectivity_api.g.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; @@ -82,7 +83,7 @@ class ForegroundUploadService { /// Bulk upload of backup candidates from selected albums Future uploadCandidates( String userId, - CancellationToken cancelToken, { + Completer cancelToken, { UploadCallbacks callbacks = const UploadCallbacks(), bool useSequentialUpload = false, }) async { @@ -105,7 +106,7 @@ class ForegroundUploadService { final requireWifi = _shouldRequireWiFi(asset); return requireWifi && !hasWifi; }, - processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks), + processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks), ); } } @@ -113,37 +114,32 @@ class ForegroundUploadService { /// Sequential upload - used for background isolate where concurrent HTTP clients may cause issues Future _uploadSequentially({ required List items, - required CancellationToken cancelToken, + required Completer cancelToken, required bool hasWifi, required UploadCallbacks callbacks, }) async { - final httpClient = Client(); await _storageRepository.clearCache(); shouldAbortUpload = false; - try { - for (final asset in items) { - if (shouldAbortUpload || cancelToken.isCancelled) { - break; - } - - final requireWifi = _shouldRequireWiFi(asset); - if (requireWifi && !hasWifi) { - _logger.warning('Skipping upload for ${asset.id} because it requires WiFi'); - continue; - } - - await _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks); + for (final asset in items) { + if (shouldAbortUpload || cancelToken.isCompleted) { + break; } - } finally { - httpClient.close(); + + final requireWifi = _shouldRequireWiFi(asset); + if (requireWifi && !hasWifi) { + _logger.warning('Skipping upload for ${asset.id} because it requires WiFi'); + continue; + } + + await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks); } } /// Manually upload picked local assets Future uploadManual( List localAssets, - CancellationToken cancelToken, { + Completer cancelToken, { UploadCallbacks callbacks = const UploadCallbacks(), }) async { if (localAssets.isEmpty) { @@ -153,14 +149,14 @@ class ForegroundUploadService { await _executeWithWorkerPool( items: localAssets, cancelToken: cancelToken, - processItem: (asset, httpClient) => _uploadSingleAsset(asset, httpClient, cancelToken, callbacks: callbacks), + processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks), ); } /// Upload files from shared intent Future uploadShareIntent( List files, { - CancellationToken? cancelToken, + Completer? cancelToken, void Function(String fileId, int bytes, int totalBytes)? onProgress, void Function(String fileId)? onSuccess, void Function(String fileId, String errorMessage)? onError, @@ -168,19 +164,18 @@ class ForegroundUploadService { if (files.isEmpty) { return; } - - final effectiveCancelToken = cancelToken ?? CancellationToken(); + final effectiveCancelToken = cancelToken ?? Completer(); await _executeWithWorkerPool( items: files, cancelToken: effectiveCancelToken, - processItem: (file, httpClient) async { + processItem: (file) async { final fileId = p.hash(file.path).toString(); final result = await _uploadSingleFile( file, deviceAssetId: fileId, - httpClient: httpClient, + httpClient: NetworkRepository.client, cancelToken: effectiveCancelToken, onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes), ); @@ -207,60 +202,47 @@ class ForegroundUploadService { /// [concurrentWorkers] - Number of concurrent workers (default: 3) Future _executeWithWorkerPool({ required List items, - required CancellationToken cancelToken, - required Future Function(T item, Client httpClient) processItem, + required Completer cancelToken, + required Future Function(T item) processItem, bool Function(T item)? shouldSkip, int concurrentWorkers = 3, }) async { - final httpClients = List.generate(concurrentWorkers, (_) => Client()); - await _storageRepository.clearCache(); shouldAbortUpload = false; - try { - int currentIndex = 0; + int currentIndex = 0; - Future worker(Client httpClient) async { - while (true) { - if (shouldAbortUpload || cancelToken.isCancelled) { - break; - } - - final index = currentIndex; - if (index >= items.length) { - break; - } - currentIndex++; - - final item = items[index]; - - if (shouldSkip?.call(item) ?? false) { - continue; - } - - await processItem(item, httpClient); + Future worker() async { + while (true) { + if (shouldAbortUpload || cancelToken.isCompleted) { + break; } - } - final workerFutures = >[]; - for (int i = 0; i < concurrentWorkers; i++) { - workerFutures.add(worker(httpClients[i])); - } + final index = currentIndex; + if (index >= items.length) { + break; + } + currentIndex++; - await Future.wait(workerFutures); - } finally { - for (final client in httpClients) { - client.close(); + final item = items[index]; + + if (shouldSkip?.call(item) ?? false) { + continue; + } + + await processItem(item); } } + + final workerFutures = >[]; + for (int i = 0; i < concurrentWorkers; i++) { + workerFutures.add(worker()); + } + + await Future.wait(workerFutures); } - Future _uploadSingleAsset( - LocalAsset asset, - Client httpClient, - CancellationToken cancelToken, { - required UploadCallbacks callbacks, - }) async { + Future _uploadSingleAsset(LocalAsset asset, Completer cancelToken, {required UploadCallbacks callbacks}) async { File? file; File? livePhotoFile; @@ -363,7 +345,7 @@ class ForegroundUploadService { originalFileName: livePhotoTitle, headers: headers, fields: fields, - httpClient: httpClient, + httpClient: NetworkRepository.client, cancelToken: cancelToken, onProgress: (bytes, totalBytes) => callbacks.onProgress?.call(asset.localId!, livePhotoTitle, bytes, totalBytes), @@ -400,7 +382,7 @@ class ForegroundUploadService { originalFileName: originalFileName, headers: headers, fields: fields, - httpClient: httpClient, + httpClient: NetworkRepository.client, cancelToken: cancelToken, onProgress: (bytes, totalBytes) => callbacks.onProgress?.call(asset.localId!, originalFileName, bytes, totalBytes), @@ -443,7 +425,7 @@ class ForegroundUploadService { File file, { required String deviceAssetId, required Client httpClient, - required CancellationToken cancelToken, + required Completer cancelToken, void Function(int bytes, int totalBytes)? onProgress, }) async { try { diff --git a/mobile/lib/utils/http_ssl_cert_override.dart b/mobile/lib/utils/http_ssl_cert_override.dart deleted file mode 100644 index a4c97a532f..0000000000 --- a/mobile/lib/utils/http_ssl_cert_override.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'dart:io'; - -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:logging/logging.dart'; - -class HttpSSLCertOverride extends HttpOverrides { - static final Logger _log = Logger("HttpSSLCertOverride"); - final bool _allowSelfSignedSSLCert; - final String? _serverHost; - final SSLClientCertStoreVal? _clientCert; - late final SecurityContext? _ctxWithCert; - - HttpSSLCertOverride(this._allowSelfSignedSSLCert, this._serverHost, this._clientCert) { - if (_clientCert != null) { - _ctxWithCert = SecurityContext(withTrustedRoots: true); - if (_ctxWithCert != null) { - setClientCert(_ctxWithCert, _clientCert); - } else { - _log.severe("Failed to create security context with client cert!"); - } - } else { - _ctxWithCert = null; - } - } - - static bool setClientCert(SecurityContext ctx, SSLClientCertStoreVal cert) { - try { - _log.info("Setting client certificate"); - ctx.usePrivateKeyBytes(cert.data, password: cert.password); - ctx.useCertificateChainBytes(cert.data, password: cert.password); - } catch (e) { - _log.severe("Failed to set SSL client cert: $e"); - return false; - } - return true; - } - - @override - HttpClient createHttpClient(SecurityContext? context) { - if (context != null) { - if (_clientCert != null) { - setClientCert(context, _clientCert); - } - } else { - context = _ctxWithCert; - } - - return super.createHttpClient(context) - ..badCertificateCallback = (X509Certificate cert, String host, int port) { - if (_allowSelfSignedSSLCert) { - // Conduct server host checks if user is logged in to avoid making - // insecure SSL connections to services that are not the immich server. - if (_serverHost == null || _serverHost.contains(host)) { - return true; - } - } - _log.severe("Invalid SSL certificate for $host:$port"); - return false; - }; - } -} diff --git a/mobile/lib/utils/http_ssl_options.dart b/mobile/lib/utils/http_ssl_options.dart deleted file mode 100644 index a93387c9db..0000000000 --- a/mobile/lib/utils/http_ssl_options.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'dart:io'; - -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; - -class HttpSSLOptions { - static void apply() { - AppSettingsEnum setting = AppSettingsEnum.allowSelfSignedSSLCert; - bool allowSelfSignedSSLCert = Store.get(setting.storeKey as StoreKey, setting.defaultValue); - return _apply(allowSelfSignedSSLCert); - } - - static void applyFromSettings(bool newValue) => _apply(newValue); - - static void _apply(bool allowSelfSignedSSLCert) { - String? serverHost; - if (allowSelfSignedSSLCert && Store.tryGet(StoreKey.currentUser) != null) { - serverHost = Uri.parse(Store.tryGet(StoreKey.serverEndpoint) ?? "").host; - } - - SSLClientCertStoreVal? clientCert = SSLClientCertStoreVal.load(); - - HttpOverrides.global = HttpSSLCertOverride(allowSelfSignedSSLCert, serverHost, clientCert); - } -} diff --git a/mobile/lib/utils/isolate.dart b/mobile/lib/utils/isolate.dart index 7ac120acb4..c8224b9c55 100644 --- a/mobile/lib/utils/isolate.dart +++ b/mobile/lib/utils/isolate.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/wm_executor.dart'; import 'package:logging/logging.dart'; import 'package:worker_manager/worker_manager.dart'; @@ -54,7 +53,6 @@ Cancelable runInIsolateGentle({ Logger log = Logger("IsolateLogger"); try { - HttpSSLOptions.apply(); result = await computation(ref); } on CanceledError { log.warning("Computation cancelled ${debugLabel == null ? '' : ' for $debugLabel'}"); diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index d6b516a078..34ffacdc13 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -10,12 +10,10 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart'; import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart'; import 'package:immich_mobile/widgets/settings/local_storage_settings.dart'; @@ -31,15 +29,12 @@ class AdvancedSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - bool isLoggedIn = ref.read(currentUserProvider) != null; - final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting); final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid); final isManageMediaSupported = useState(false); final manageMediaAndroidPermission = useState(false); final levelId = useAppSettingsState(AppSettingsEnum.logLevel); final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); - final allowSelfSignedSSLCert = useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert); final useAlternatePMFilter = useAppSettingsState(AppSettingsEnum.photoManagerCustomFilter); final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled); @@ -120,15 +115,8 @@ class AdvancedSettings extends HookConsumerWidget { subtitle: "advanced_settings_prefer_remote_subtitle".tr(), ), if (!Store.isBetaTimelineEnabled) const LocalStorageSettings(), - SettingsSwitchListTile( - enabled: !isLoggedIn, - valueNotifier: allowSelfSignedSSLCert, - title: "advanced_settings_self_signed_ssl_title".tr(), - subtitle: "advanced_settings_self_signed_ssl_subtitle".tr(), - onChanged: HttpSSLOptions.applyFromSettings, - ), const CustomProxyHeaderSettings(), - SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null), + const SslClientCertSettings(), if (!Store.isBetaTimelineEnabled) SettingsSwitchListTile( valueNotifier: useAlternatePMFilter, diff --git a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart index fa210ee720..4b712bda1f 100644 --- a/mobile/lib/widgets/settings/ssl_client_cert_settings.dart +++ b/mobile/lib/widgets/settings/ssl_client_cert_settings.dart @@ -6,13 +6,10 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/platform/network_api.g.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; -import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:logging/logging.dart'; class SslClientCertSettings extends StatefulWidget { - const SslClientCertSettings({super.key, required this.isLoggedIn}); - - final bool isLoggedIn; + const SslClientCertSettings({super.key}); @override State createState() => _SslClientCertSettingsState(); @@ -45,9 +42,9 @@ class _SslClientCertSettingsState extends State { mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.center, children: [ - ElevatedButton(onPressed: widget.isLoggedIn ? null : importCert, child: Text("client_cert_import".tr())), + ElevatedButton(onPressed: importCert, child: Text("client_cert_import".tr())), ElevatedButton( - onPressed: widget.isLoggedIn || !isCertExist ? null : removeCert, + onPressed: !isCertExist ? null : removeCert, child: Text("remove".tr()), ), ], @@ -76,7 +73,6 @@ class _SslClientCertSettingsState extends State { ); final cert = await networkApi.selectCertificate(styling); await SSLClientCertStoreVal(cert.data, cert.password).save(); - HttpSSLOptions.apply(); setState(() => isCertExist = true); showMessage("client_cert_import_success_msg".tr()); } catch (e) { @@ -92,7 +88,6 @@ class _SslClientCertSettingsState extends State { try { await networkApi.removeCertificate(); await SSLClientCertStoreVal.delete(); - HttpSSLOptions.apply(); setState(() => isCertExist = false); showMessage("client_cert_remove_msg".tr()); } catch (e) { diff --git a/mobile/pigeon/network_api.dart b/mobile/pigeon/network_api.dart index 68d2f7d8fc..b9326e2a60 100644 --- a/mobile/pigeon/network_api.dart +++ b/mobile/pigeon/network_api.dart @@ -38,4 +38,6 @@ abstract class NetworkApi { @async void removeCertificate(); + + int getClientPointer(); } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 077544b4f7..99296a8ee3 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -201,22 +201,6 @@ packages: url: "https://pub.dev" source: hosted version: "8.9.5" - cancellation_token: - dependency: transitive - description: - name: cancellation_token - sha256: ad95acf9d4b2f3563e25dc937f63587e46a70ce534e910b65d10e115490f1027 - url: "https://pub.dev" - source: hosted - version: "2.0.1" - cancellation_token_http: - dependency: "direct main" - description: - name: cancellation_token_http - sha256: "0fff478fe5153700396b3472ddf93303c219f1cb8d8e779e65b014cb9c7f0213" - url: "https://pub.dev" - source: hosted - version: "2.1.0" cast: dependency: "direct main" description: @@ -313,14 +297,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" - cronet_http: - dependency: "direct main" - description: - name: cronet_http - sha256: "1fff7f26ac0c4cda97fe2a9aa082494baee4775f167c27ba45f6c8e88571e3ab" - url: "https://pub.dev" - source: hosted - version: "1.7.0" crop_image: dependency: "direct main" description: @@ -356,11 +332,12 @@ packages: cupertino_http: dependency: "direct main" description: - name: cupertino_http - sha256: "82cbec60c90bf785a047a9525688b6dacac444e177e1d5a5876963d3c50369e8" - url: "https://pub.dev" - source: hosted - version: "2.4.0" + path: "pkgs/cupertino_http" + ref: "114b2807bdeee641457b5703f411318d722b67b5" + resolved-ref: "114b2807bdeee641457b5703f411318d722b67b5" + url: "https://github.com/mertalev/http" + source: git + version: "3.0.0-wip" custom_lint: dependency: "direct dev" description: @@ -1073,10 +1050,10 @@ packages: dependency: transitive description: name: jni - sha256: "8706a77e94c76fe9ec9315e18949cc9479cc03af97085ca9c1077b61323ea12d" + sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1 url: "https://pub.dev" source: hosted - version: "0.15.2" + version: "0.14.2" js: dependency: transitive description: @@ -1286,6 +1263,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + ok_http: + dependency: "direct main" + description: + path: "pkgs/ok_http" + ref: "114b2807bdeee641457b5703f411318d722b67b5" + resolved-ref: "114b2807bdeee641457b5703f411318d722b67b5" + url: "https://github.com/mertalev/http" + source: git + version: "0.1.1-wip" openapi: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 3c388601ab..6663f7c66f 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -12,7 +12,6 @@ dependencies: async: ^2.13.0 auto_route: ^9.2.0 background_downloader: ^9.3.0 - cancellation_token_http: ^2.1.0 cast: ^2.1.0 collection: ^1.19.1 connectivity_plus: ^6.1.3 @@ -84,8 +83,17 @@ dependencies: uuid: ^4.5.1 wakelock_plus: ^1.3.0 worker_manager: ^7.2.7 - cronet_http: ^1.7.0 - cupertino_http: ^2.4.0 + # TODO: upstream these changes + cupertino_http: + git: + url: https://github.com/mertalev/http + ref: '114b2807bdeee641457b5703f411318d722b67b5' + path: pkgs/cupertino_http/ + ok_http: + git: + url: https://github.com/mertalev/http + ref: '114b2807bdeee641457b5703f411318d722b67b5' + path: pkgs/ok_http/ dev_dependencies: auto_route_generator: ^9.0.0