diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 502fd9008f9cd..f16d397e13aad 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -6,6 +6,9 @@ PODS: - FlutterMacOS - connectivity_plus (0.0.1): - Flutter + - cupertino_http (0.0.1): + - Flutter + - FlutterMacOS - device_info_plus (0.0.1): - Flutter - DKImagePickerController/Core (4.3.9): @@ -77,6 +80,8 @@ PODS: - Flutter - network_info_plus (0.0.1): - Flutter + - objective_c (0.0.1): + - Flutter - package_info_plus (0.4.5): - Flutter - path_provider_foundation (0.0.1): @@ -136,6 +141,7 @@ DEPENDENCIES: - background_downloader (from `.symlinks/plugins/background_downloader/ios`) - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) @@ -154,6 +160,7 @@ DEPENDENCIES: - maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`) - native_video_player (from `.symlinks/plugins/native_video_player/ios`) - network_info_plus (from `.symlinks/plugins/network_info_plus/ios`) + - objective_c (from `.symlinks/plugins/objective_c/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) @@ -184,6 +191,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/bonsoir_darwin/darwin" connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" + cupertino_http: + :path: ".symlinks/plugins/cupertino_http/darwin" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" file_picker: @@ -220,6 +229,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/native_video_player/ios" network_info_plus: :path: ".symlinks/plugins/network_info_plus/ios" + objective_c: + :path: ".symlinks/plugins/objective_c/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: @@ -249,6 +260,7 @@ SPEC CHECKSUMS: background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd + cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 @@ -270,6 +282,7 @@ SPEC CHECKSUMS: maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f native_video_player: b65c58951ede2f93d103a25366bdebca95081265 network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc + objective_c: 89e720c30d716b036faf9c9684022048eee1eee2 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 78dd1e980fe10..6be4d79855a78 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'; @@ -63,7 +62,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { final Drift _drift; final DriftLogger _driftLogger; final BackgroundWorkerBgHostApi _backgroundHostApi; - final CancellationToken _cancellationToken = CancellationToken(); + final Completer _cancellationToken = Completer(); final Logger _logger = Logger('BackgroundWorkerBgService'); bool _isCleanedUp = false; @@ -188,7 +187,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { _isCleanedUp = true; _ref.dispose(); - _cancellationToken.cancel(); + _cancellationToken.complete(); _logger.info("Cleaning up background worker"); final cleanupFutures = [ workerManager.dispose().catchError((_) async { diff --git a/mobile/lib/infrastructure/loaders/image_request.dart b/mobile/lib/infrastructure/loaders/image_request.dart index d839b8bdf6c6d..a1552e1aae5e5 100644 --- a/mobile/lib/infrastructure/loaders/image_request.dart +++ b/mobile/lib/infrastructure/loaders/image_request.dart @@ -1,15 +1,16 @@ import 'dart:async'; import 'dart:ffi'; -import 'dart:io'; import 'dart:ui' as ui; +import 'package:cronet_http/cronet_http.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:ffi/ffi.dart'; +import 'package:http/http.dart' as http; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; -import 'package:logging/logging.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; part 'local_image_request.dart'; part 'thumbhash_image_request.dart'; diff --git a/mobile/lib/infrastructure/loaders/remote_image_request.dart b/mobile/lib/infrastructure/loaders/remote_image_request.dart index 78f6b9479b950..415192830b3ee 100644 --- a/mobile/lib/infrastructure/loaders/remote_image_request.dart +++ b/mobile/lib/infrastructure/loaders/remote_image_request.dart @@ -1,14 +1,18 @@ part of 'image_request.dart'; class RemoteImageRequest extends ImageRequest { - static final log = Logger('RemoteImageRequest'); - static final client = HttpClient()..maxConnectionsPerHost = 16; - final RemoteCacheManager? cacheManager; + static final _client = const NetworkRepository().getHttpClient( + 'thumbnails', + diskCapacity: kThumbnailDiskCacheSize, + memoryCapacity: 0, + maxConnections: 16, + cacheMode: CacheMode.disk, + ); final String uri; final Map headers; - HttpClientRequest? _request; + final abortTrigger = Completer(); - RemoteImageRequest({required this.uri, required this.headers, this.cacheManager}); + RemoteImageRequest({required this.uri, required this.headers}); @override Future load(ImageDecoderCallback decode, {double scale = 1.0}) async { @@ -16,15 +20,8 @@ class RemoteImageRequest extends ImageRequest { return null; } - // TODO: the cache manager makes everything sequential with its DB calls and its operations cannot be cancelled, - // so it ends up being a bottleneck. We only prefer fetching from it when it can skip the DB call. - final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: true); - if (cachedFileImage != null) { - return cachedFileImage; - } - try { - final buffer = await _downloadImage(uri); + final buffer = await _downloadImage(); if (buffer == null) { return null; } @@ -35,57 +32,41 @@ class RemoteImageRequest extends ImageRequest { return null; } - final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: false); - if (cachedFileImage != null) { - return cachedFileImage; - } - rethrow; - } finally { - _request = null; } } - Future _downloadImage(String url) async { + Future _downloadImage() async { if (_isCancelled) { return null; } - final request = _request = await client.getUrl(Uri.parse(url)); + final req = http.AbortableRequest('GET', Uri.parse(uri), abortTrigger: abortTrigger.future); + req.headers.addAll(headers); + final res = await _client.send(req); if (_isCancelled) { - request.abort(); - return _request = null; + _onCancelled(); + return null; } - for (final entry in headers.entries) { - request.headers.set(entry.key, entry.value); - } - final response = await request.close(); - if (_isCancelled) { - return null; + if (res.statusCode != 200) { + throw Exception('Failed to download $uri: ${res.statusCode}'); } - final cacheManager = this.cacheManager; - final streamController = StreamController>(sync: true); - final Stream> stream; - cacheManager?.putStreamedFile(url, streamController.stream); - stream = response.map((chunk) { + final stream = res.stream.map((chunk) { if (_isCancelled) { throw StateError('Cancelled request'); } - if (cacheManager != null) { - streamController.add(chunk); - } return chunk; }); try { - final Uint8List bytes = await _downloadBytes(stream, response.contentLength); - streamController.close(); + final Uint8List bytes = await _downloadBytes(stream, res.contentLength ?? -1); + if (_isCancelled) { + return null; + } return await ImmutableBuffer.fromUint8List(bytes); } catch (e) { - streamController.addError(e); - streamController.close(); if (_isCancelled) { return null; } @@ -122,40 +103,6 @@ class RemoteImageRequest extends ImageRequest { return bytes; } - Future _loadCachedFile( - String url, - ImageDecoderCallback decode, - double scale, { - required bool inMemoryOnly, - }) async { - final cacheManager = this.cacheManager; - if (_isCancelled || cacheManager == null) { - return null; - } - - final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url)); - if (_isCancelled || file == null) { - return null; - } - - try { - final buffer = await ImmutableBuffer.fromFilePath(file.file.path); - return await _decodeBuffer(buffer, decode, scale); - } catch (e) { - log.severe('Failed to decode cached image', e); - _evictFile(url); - return null; - } - } - - Future _evictFile(String url) async { - try { - await cacheManager?.removeFile(url); - } catch (e) { - log.severe('Failed to remove cached image', e); - } - } - Future _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async { if (_isCancelled) { buffer.dispose(); @@ -173,7 +120,6 @@ class RemoteImageRequest extends ImageRequest { @override void _onCancelled() { - _request?.abort(); - _request = null; + abortTrigger.complete(); } } diff --git a/mobile/lib/infrastructure/repositories/network.repository.dart b/mobile/lib/infrastructure/repositories/network.repository.dart new file mode 100644 index 0000000000000..a73322cb5c408 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/network.repository.dart @@ -0,0 +1,67 @@ +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'; + +class NetworkRepository { + static late Directory _cachePath; + static late String _userAgent; + static final _clients = {}; + + 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(); + } + _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; + } + + final directory = Directory('${_cachePath.path}/$directoryName'); + directory.createSync(recursive: true); + if (Platform.isAndroid) { + final engine = CronetEngine.build( + cacheMode: cacheMode, + cacheMaxSize: diskCapacity, + storagePath: directory.path, + userAgent: _userAgent, + ); + return _clients[directoryName] = CronetClient.fromCronetEngine(engine, closeEngine: true); + } + + final config = URLSessionConfiguration.defaultSessionConfiguration() + ..httpMaximumConnectionsPerHost = maxConnections + ..cache = URLCache.withCapacity( + diskCapacity: diskCapacity, + memoryCapacity: memoryCapacity, + directory: directory.uri, + ) + ..httpAdditionalHeaders = {'User-Agent': _userAgent}; + return _clients[directoryName] = CupertinoClient.fromSessionConfiguration(config); + } +} diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index 3969286d284b5..4498d643eea9e 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -6,11 +6,13 @@ 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'; class SyncApiRepository { + static final _client = const NetworkRepository().getHttpClient('api'); final Logger _logger = Logger('SyncApiRepository'); final ApiService _api; SyncApiRepository(this._api); @@ -26,7 +28,7 @@ class SyncApiRepository { http.Client? httpClient, }) async { final stopwatch = Stopwatch()..start(); - final client = httpClient ?? http.Client(); + final client = httpClient ?? _client; final endpoint = "${_api.apiClient.basePath}/sync/stream"; final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'}; @@ -112,8 +114,6 @@ class SyncApiRepository { } catch (error, stack) { _logger.severe("Error processing stream", 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 7d944c54ce6c2..f4f580714b9ee 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; @@ -222,6 +223,14 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve super.dispose(); } + @override + void reassemble() { + if (kDebugMode) { + NetworkRepository.reset(); + } + super.reassemble(); + } + @override Widget build(BuildContext context) { final router = ref.watch(appRouterProvider); diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index 75fd186c8a748..1da692007b5fb 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -7,13 +7,11 @@ import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; -import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; class RemoteThumbProvider extends CancellableImageProvider with CancellableImageProviderMixin { - static final cacheManager = RemoteThumbnailCacheManager(); final String assetId; RemoteThumbProvider({required this.assetId}); @@ -39,7 +37,6 @@ class RemoteThumbProvider extends CancellableImageProvider final request = this.request = RemoteImageRequest( uri: getThumbnailUrlForRemoteId(key.assetId), headers: ApiService.getRequestHeaders(), - cacheManager: cacheManager, ); return loadRequest(request, decode); } @@ -60,7 +57,6 @@ class RemoteThumbProvider extends CancellableImageProvider class RemoteFullImageProvider extends CancellableImageProvider with CancellableImageProviderMixin { - static final cacheManager = RemoteThumbnailCacheManager(); final String assetId; RemoteFullImageProvider({required this.assetId}); @@ -92,11 +88,7 @@ class RemoteFullImageProvider extends CancellableImageProvider putStreamedFile( - String url, - Stream> source, { - String? key, - String? eTag, - Duration maxAge = const Duration(days: 30), - String fileExtension = 'file', - }); - - // Unlike `putFileStream`, this method handles request cancellation, - // does not make a (slow) DB call checking if the file is already cached, - // does not synchronously check if a file exists, - // and deletes the file on cancellation without making these checks again. - Future putStreamedFileToStore( - CacheStore store, - String url, - Stream> source, { - String? key, - String? eTag, - Duration maxAge = const Duration(days: 30), - String fileExtension = 'file', - }) async { - final path = '${const Uuid().v1()}.$fileExtension'; - final file = await store.fileSystem.createFile(path); - final sink = file.openWrite(); - try { - await source.listen(sink.add, cancelOnError: true).asFuture(); - } catch (e) { - try { - await sink.close(); - await file.delete(); - } catch (e) { - _log.severe('Failed to delete incomplete cache file: $e'); - } - return; - } - - try { - await sink.flush(); - await sink.close(); - } catch (e) { - try { - await file.delete(); - } catch (e) { - _log.severe('Failed to delete incomplete cache file: $e'); - } - return; - } - - final cacheObject = CacheObject( - url, - key: key, - relativePath: path, - validTill: DateTime.now().add(maxAge), - eTag: eTag, - ); - try { - await store.putFile(cacheObject); - } catch (e) { - try { - await file.delete(); - } catch (e) { - _log.severe('Failed to delete untracked cache file: $e'); - } - } - } -} - -class RemoteImageCacheManager extends RemoteCacheManager { +class RemoteImageCacheManager extends CacheManager { static const key = 'remoteImageCacheKey'; static final RemoteImageCacheManager _instance = RemoteImageCacheManager._(); static final _config = Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30)); - static final _store = CacheStore(_config); factory RemoteImageCacheManager() { return _instance; } - RemoteImageCacheManager._() : super.custom(_config, _store); - - @override - Future putStreamedFile( - String url, - Stream> source, { - String? key, - String? eTag, - Duration maxAge = const Duration(days: 30), - String fileExtension = 'file', - }) { - return putStreamedFileToStore( - _store, - url, - source, - key: key, - eTag: eTag, - maxAge: maxAge, - fileExtension: fileExtension, - ); - } + RemoteImageCacheManager._() : super(_config); } -/// The cache manager for full size images [ImmichRemoteImageProvider] -class RemoteThumbnailCacheManager extends RemoteCacheManager { +class RemoteThumbnailCacheManager extends CacheManager { static const key = 'remoteThumbnailCacheKey'; static final RemoteThumbnailCacheManager _instance = RemoteThumbnailCacheManager._(); static final _config = Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30)); - static final _store = CacheStore(_config); factory RemoteThumbnailCacheManager() { return _instance; } - RemoteThumbnailCacheManager._() : super.custom(_config, _store); - - @override - Future putStreamedFile( - String url, - Stream> source, { - String? key, - String? eTag, - Duration maxAge = const Duration(days: 30), - String fileExtension = 'file', - }) { - return putStreamedFileToStore( - _store, - url, - source, - key: key, - eTag: eTag, - maxAge: maxAge, - fileExtension: fileExtension, - ); - } + RemoteThumbnailCacheManager._() : super(_config); } diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index 38f2c22cf22a6..b079e81dee79e 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -1,12 +1,14 @@ +import 'dart:async'; 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:http/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'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:logging/logging.dart'; import 'package:immich_mobile/utils/debug_print.dart'; @@ -20,6 +22,8 @@ class UploadTaskWithFile { final uploadRepositoryProvider = Provider((ref) => UploadRepository()); class UploadRepository { + static final _client = const NetworkRepository().getHttpClient('upload'); + void Function(TaskStatusUpdate)? onUploadStatus; void Function(TaskProgressUpdate)? onTaskProgress; @@ -92,13 +96,12 @@ class UploadRepository { ); } - Future backupWithDartClient(Iterable tasks, CancellationToken cancelToken) async { - final httpClient = Client(); + Future backupWithDartClient(Iterable tasks, Completer cancelToken) async { final String savedEndpoint = Store.get(StoreKey.serverEndpoint); Logger logger = Logger('UploadRepository'); for (final candidate in tasks) { - if (cancelToken.isCancelled) { + if (cancelToken.isCompleted) { logger.warning("Backup was cancelled by the user"); break; } @@ -112,13 +115,17 @@ class UploadRepository { filename: candidate.task.filename, ); - final baseRequest = MultipartRequest('POST', Uri.parse('$savedEndpoint/assets')); + final baseRequest = AbortableMultipartRequest( + 'POST', + Uri.parse('$savedEndpoint/assets'), + abortTrigger: cancelToken.future, + )..headers['Accept'] = 'application/json'; baseRequest.headers.addAll(candidate.task.headers); baseRequest.fields.addAll(candidate.task.fields); baseRequest.files.add(assetRawUploadData); - final response = await httpClient.send(baseRequest, cancellationToken: cancelToken); + final response = await _client.send(baseRequest); final responseBody = jsonDecode(await response.stream.bytesToString()); @@ -131,7 +138,7 @@ class UploadRepository { continue; } - } on CancelledException { + } on RequestAbortedException { 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..8ffb2c06f0ad5 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -3,9 +3,9 @@ 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/url_helper.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -13,6 +13,7 @@ import 'package:immich_mobile/utils/user_agent.dart'; import 'package:immich_mobile/utils/debug_print.dart'; class ApiService implements Authentication { + static final _client = const NetworkRepository().getHttpClient('api'); late ApiClient _apiClient; late UsersApi usersApi; @@ -50,6 +51,7 @@ class ApiService implements Authentication { setEndpoint(String endpoint) { _apiClient = ApiClient(basePath: endpoint, authentication: this); + _apiClient.client = _client; _setUserAgentHeader(); if (_accessToken != null) { setAccessToken(_accessToken!); @@ -134,13 +136,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 _client .get(Uri.parse("$baseUrl/.well-known/immich"), headers: headers) .timeout(const Duration(seconds: 5)); diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart index 9e9c81076b633..5c7674df79944 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -3,7 +3,6 @@ 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/asset/base_asset.model.dart'; @@ -158,7 +157,7 @@ class UploadService { } } - Future startBackupWithHttpClient(String userId, bool hasWifi, CancellationToken token) async { + Future startBackupWithHttpClient(String userId, bool hasWifi, Completer token) async { await _storageRepository.clearCache(); shouldAbortQueuingTasks = false; @@ -170,7 +169,7 @@ class UploadService { const batchSize = 100; for (int i = 0; i < candidates.length; i += batchSize) { - if (shouldAbortQueuingTasks || token.isCancelled) { + if (shouldAbortQueuingTasks || token.isCompleted) { break; } diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index c77ceaa62d163..b7c3dcdd8a326 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -21,6 +21,7 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:isar/isar.dart'; import 'package:path_provider/path_provider.dart'; @@ -106,5 +107,7 @@ abstract final class Bootstrap { storeRepository: storeRepo, shouldBuffer: shouldBufferLogs, ); + + await NetworkRepository.init(); } } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index d0dc8e64d369d..107880347d5bc 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -337,6 +337,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cronet_http: + dependency: "direct main" + description: + name: cronet_http + sha256: "1b99ad5ae81aa9d2f12900e5f17d3681f3828629bb7f7fe7ad88076a34209840" + url: "https://pub.dev" + source: hosted + version: "1.5.0" crop_image: dependency: "direct main" description: @@ -369,6 +377,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + cupertino_http: + dependency: "direct main" + description: + name: cupertino_http + sha256: "72187f715837290a63479a5b0ae709f4fedad0ed6bd0441c275eceaa02d5abae" + url: "https://pub.dev" + source: hosted + version: "2.3.0" custom_lint: dependency: "direct dev" description: @@ -899,10 +915,10 @@ packages: dependency: "direct main" description: name: http - sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.5.0" http_multi_server: dependency: transitive description: @@ -919,6 +935,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: @@ -1044,6 +1068,14 @@ packages: url: "https://github.com/immich-app/isar" source: git version: "3.1.8" + jni: + dependency: transitive + description: + name: jni + sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1 + url: "https://pub.dev" + source: hosted + version: "0.14.2" js: dependency: transitive description: @@ -1237,6 +1269,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "9f034ba1eeca53ddb339bc8f4813cb07336a849cd735559b60cdc068ecce2dc7" + url: "https://pub.dev" + source: hosted + version: "7.1.0" octo_image: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 91f937125fe21..2d41ad471c634 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -89,6 +89,8 @@ dependencies: # DB drift: ^2.23.1 drift_flutter: ^0.2.4 + cronet_http: ^1.5.0 + cupertino_http: ^2.3.0 dev_dependencies: flutter_test: diff --git a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart index 467e19bf3f8d1..d38dda2edc1c7 100644 --- a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart +++ b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart @@ -118,7 +118,6 @@ void main() { expect(onDataCallCount, 1); expect(abortWasCalledInCallback, isTrue); expect(receivedEventsBatch1.length, testBatchSize); - verify(() => mockHttpClient.close()).called(1); }); test('streamChanges does not process remaining lines in finally block if aborted', () async { @@ -159,7 +158,6 @@ void main() { expect(onDataCallCount, 1); expect(abortWasCalledInCallback, isTrue); - verify(() => mockHttpClient.close()).called(1); }); test('streamChanges processes remaining lines in finally block if not aborted', () async { @@ -204,7 +202,6 @@ void main() { expect(onDataCallCount, 2); expect(receivedEventsBatch1.length, testBatchSize); expect(receivedEventsBatch2.length, 1); - verify(() => mockHttpClient.close()).called(1); }); test('streamChanges handles stream error gracefully', () async { @@ -229,7 +226,6 @@ void main() { await expectLater(streamChangesFuture, throwsA(streamError)); expect(onDataCallCount, 0); - verify(() => mockHttpClient.close()).called(1); }); test('streamChanges throws ApiException on non-200 status code', () async { @@ -257,6 +253,5 @@ void main() { ); expect(onDataCallCount, 0); - verify(() => mockHttpClient.close()).called(1); }); } diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index 29c7f6f77280a..f62f8ea599efb 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -34,7 +34,8 @@ type SendFile = Parameters; type SendFileOptions = SendFile[1]; const cacheControlHeaders: Record = { - [CacheControl.PrivateWithCache]: 'private, max-age=86400, no-transform', + [CacheControl.PrivateWithCache]: + 'private, max-age=86400, no-transform, stale-while-revalidate=2592000, stale-if-error=2592000', [CacheControl.PrivateWithoutCache]: 'private, no-cache, no-transform', [CacheControl.None]: null, // falsy value to prevent adding Cache-Control header };