diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 09bd36022b..ecd4b4679a 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/infrastructure/loaders/image_request.dart b/mobile/lib/infrastructure/loaders/image_request.dart index d839b8bdf6..a1552e1aae 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 78f6b9479b..a35cb79a78 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( + directoryName: '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,37 @@ 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)); - if (_isCancelled) { - request.abort(); - return _request = null; - } - - for (final entry in headers.entries) { - request.headers.set(entry.key, entry.value); - } - final response = await request.close(); + final req = http.AbortableRequest('get', Uri.parse(uri), abortTrigger: abortTrigger.future); + req.headers.addAll(headers); + final res = await _client.send(req); if (_isCancelled) { + _onCancelled(); return null; } - 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 +99,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 +116,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 0000000000..35981f7d1e --- /dev/null +++ b/mobile/lib/infrastructure/repositories/network.repository.dart @@ -0,0 +1,40 @@ +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:path_provider/path_provider.dart'; + +class NetworkRepository { + static late Directory _cachePath; + + static Future init() async { + _cachePath = await getTemporaryDirectory(); + } + + const NetworkRepository(); + + http.Client getHttpClient({ + required String directoryName, + required int diskCapacity, + required int memoryCapacity, + required int maxConnections, + required CacheMode cacheMode, + }) { + final directory = Directory('${_cachePath.path}/$directoryName'); + directory.createSync(recursive: true); + if (Platform.isAndroid) { + final engine = CronetEngine.build(cacheMode: cacheMode, cacheMaxSize: diskCapacity, storagePath: directory.path); + return CronetClient.fromCronetEngine(engine, closeEngine: true); + } + + final config = URLSessionConfiguration.defaultSessionConfiguration() + ..httpMaximumConnectionsPerHost = maxConnections + ..cache = URLCache.withCapacity( + diskCapacity: diskCapacity, + memoryCapacity: memoryCapacity, + directory: directory.uri, + ); + return CupertinoClient.fromSessionConfiguration(config); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 207e522587..889a9ca359 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/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; @@ -167,6 +168,7 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve } SystemChrome.setSystemUIOverlayStyle(overlayStyle); await ref.read(localNotificationService).setup(); + await NetworkRepository.init(); } Future _deepLinkBuilder(PlatformDeepLink deepLink) async { diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index dd87d2f228..b4b170a566 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -62,6 +62,11 @@ mixin CancellableImageProviderMixin on CancellableImageProvide return; } yield image; + } catch (e) { + evict(); + if (!isCancelled) { + _log.severe('Error loading image', e); + } } finally { this.request = null; } diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index 75fd186c8a..1da692007b 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/pubspec.lock b/mobile/pubspec.lock index 624ed1fe65..1ce04f093b 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 48b49280e3..9c12e2241a 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -90,6 +90,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/server/src/utils/file.ts b/server/src/utils/file.ts index 2331a45a62..1c2e682902 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 };