From 25e68cf8262ace1524e82e48cf75ac1d6d5b59b8 Mon Sep 17 00:00:00 2001
From: Matthias Rupp <matthias.rupp@posteo.de>
Date: Tue, 30 Aug 2022 05:44:43 +0200
Subject: [PATCH] Better caching for mobile (#521)

* Use custom caches in all modules

* Cache Settings

* Fix wrong key

* Create custom cache repository based on hive

* Show cache usage in settings

* Show cache sizes

* Change settings ranges and default value

* Handle cache clear by operating system

* Resolve review comments
---
 mobile/assets/i18n/en-US.json                 |  15 +-
 .../album/ui/album_viewer_thumbnail.dart      |   8 +-
 .../album/ui/selection_thumbnail_image.dart   |  10 +-
 .../ui/shared_album_thumbnail_image.dart      |   7 +-
 .../album/views/album_viewer_page.dart        |   3 +
 .../asset_viewer/ui/remote_photo_view.dart    |  36 +++-
 .../asset_viewer/views/image_viewer_page.dart |   9 +
 mobile/lib/modules/home/ui/image_grid.dart    |   4 +
 .../lib/modules/home/ui/thumbnail_image.dart  |  43 ++--
 mobile/lib/modules/home/views/home_page.dart  |   3 +
 .../services/app_settings.service.dart        |   5 +-
 .../ui/cache_settings/cache_settings.dart     | 142 ++++++++++++
 .../cache_settings_slider_pref.dart           |  63 ++++++
 .../modules/settings/views/settings_page.dart |   2 +
 mobile/lib/shared/services/cache.service.dart |  68 +++++-
 mobile/lib/utils/bytes_units.dart             |  15 ++
 .../utils/immich_cache_info_repository.dart   | 204 ++++++++++++++++++
 17 files changed, 593 insertions(+), 44 deletions(-)
 create mode 100644 mobile/lib/modules/settings/ui/cache_settings/cache_settings.dart
 create mode 100644 mobile/lib/modules/settings/ui/cache_settings/cache_settings_slider_pref.dart
 create mode 100644 mobile/lib/utils/bytes_units.dart
 create mode 100644 mobile/lib/utils/immich_cache_info_repository.dart

diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json
index db18dca740..f9e7e843d4 100644
--- a/mobile/assets/i18n/en-US.json
+++ b/mobile/assets/i18n/en-US.json
@@ -149,5 +149,18 @@
   "setting_notifications_notify_immediately": "immediately",
   "setting_notifications_notify_minutes": "{} minutes",
   "setting_notifications_notify_hours": "{} hours",
-  "setting_notifications_notify_never": "never"
+  "setting_notifications_notify_never": "never",
+  "cache_settings_title": "Caching Settings",
+  "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application",
+  "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)",
+  "cache_settings_image_cache_size": "Image cache size ({} assets)",
+  "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)",
+  "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.",
+  "cache_settings_clear_cache_button": "Clear cache",
+  "cache_settings_statistics_title": "Cache usage",
+  "cache_settings_statistics_assets": "{} assets ({})",
+  "cache_settings_statistics_thumbnail": "Thumbnails",
+  "cache_settings_statistics_album": "Library thumbnails",
+  "cache_settings_statistics_shared": "Shared album thumbnails",
+  "cache_settings_statistics_full": "Full images"
 }
diff --git a/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart b/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart
index fba82cfb26..30ed8524a3 100644
--- a/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart
+++ b/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart
@@ -1,6 +1,7 @@
 import 'package:auto_route/auto_route.dart';
 import 'package:cached_network_image/cached_network_image.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter_cache_manager/flutter_cache_manager.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hive_flutter/hive_flutter.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -8,6 +9,7 @@ import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/shared/services/cache.service.dart';
 import 'package:immich_mobile/utils/image_url_builder.dart';
 import 'package:openapi/api.dart';
 
@@ -15,17 +17,18 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
   final AssetResponseDto asset;
   final List<AssetResponseDto> assetList;
   final bool showStorageIndicator;
+  final BaseCacheManager? cacheManager;
 
   const AlbumViewerThumbnail({
     Key? key,
     required this.asset,
     required this.assetList,
+    this.cacheManager,
     this.showStorageIndicator = true,
   }) : super(key: key);
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final cacheKey = useState(1);
     var box = Hive.box(userInfoBox);
     var thumbnailRequestUrl = getThumbnailUrl(asset);
     var deviceId = ref.watch(authenticationProvider).deviceId;
@@ -123,7 +126,8 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
       return Container(
         decoration: BoxDecoration(border: drawBorderColor()),
         child: CachedNetworkImage(
-          cacheKey: "${asset.id}-${cacheKey.value}",
+          cacheManager: cacheManager,
+          cacheKey: asset.id,
           width: 300,
           height: 300,
           memCacheHeight: 200,
diff --git a/mobile/lib/modules/album/ui/selection_thumbnail_image.dart b/mobile/lib/modules/album/ui/selection_thumbnail_image.dart
index 1019a18d42..cc2e2f300c 100644
--- a/mobile/lib/modules/album/ui/selection_thumbnail_image.dart
+++ b/mobile/lib/modules/album/ui/selection_thumbnail_image.dart
@@ -5,6 +5,8 @@ import 'package:hive_flutter/hive_flutter.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
+import 'package:immich_mobile/shared/services/cache.service.dart';
+import 'package:immich_mobile/utils/image_url_builder.dart';
 import 'package:openapi/api.dart';
 
 class SelectionThumbnailImage extends HookConsumerWidget {
@@ -15,15 +17,14 @@ class SelectionThumbnailImage extends HookConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final cacheKey = useState(1);
     var box = Hive.box(userInfoBox);
-    var thumbnailRequestUrl =
-        '${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}';
+    var thumbnailRequestUrl = getThumbnailUrl(asset);
     var selectedAsset =
         ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
     var newAssetsForAlbum =
         ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
     var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
+    final cacheService = ref.watch(cacheServiceProvider);
 
     Widget _buildSelectionIcon(AssetResponseDto asset) {
       var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
@@ -113,7 +114,8 @@ class SelectionThumbnailImage extends HookConsumerWidget {
           Container(
             decoration: BoxDecoration(border: drawBorderColor()),
             child: CachedNetworkImage(
-              cacheKey: "${asset.id}-${cacheKey.value}",
+              cacheManager: cacheService.getCache(CacheType.thumbnail),
+              cacheKey: asset.id,
               width: 150,
               height: 150,
               memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 150 : 150,
diff --git a/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart b/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart
index 4f90a8cf36..235642b700 100644
--- a/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart
+++ b/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart
@@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hive_flutter/hive_flutter.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
+import 'package:immich_mobile/shared/services/cache.service.dart';
 import 'package:immich_mobile/utils/image_url_builder.dart';
 import 'package:openapi/api.dart';
 
@@ -15,8 +16,7 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final cacheKey = useState(1);
-
+    final cacheService = ref.watch(cacheServiceProvider);
     var box = Hive.box(userInfoBox);
 
     return GestureDetector(
@@ -26,7 +26,8 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
       child: Stack(
         children: [
           CachedNetworkImage(
-            cacheKey: "${asset.id}-${cacheKey.value}",
+            cacheManager: cacheService.getCache(CacheType.thumbnail),
+            cacheKey: asset.id,
             width: 500,
             height: 500,
             memCacheHeight: 500,
diff --git a/mobile/lib/modules/album/views/album_viewer_page.dart b/mobile/lib/modules/album/views/album_viewer_page.dart
index c525c9922b..8c0436abed 100644
--- a/mobile/lib/modules/album/views/album_viewer_page.dart
+++ b/mobile/lib/modules/album/views/album_viewer_page.dart
@@ -16,6 +16,7 @@ import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/shared/services/cache.service.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
 import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@@ -191,6 +192,7 @@ class AlbumViewerPage extends HookConsumerWidget {
       final appSettingService = ref.watch(appSettingsServiceProvider);
       final bool showStorageIndicator =
           appSettingService.getSetting(AppSettingsEnum.storageIndicator);
+      final cacheService = ref.watch(cacheServiceProvider);
 
       if (albumInfo.assets.isNotEmpty) {
         return SliverPadding(
@@ -205,6 +207,7 @@ class AlbumViewerPage extends HookConsumerWidget {
             delegate: SliverChildBuilderDelegate(
               (BuildContext context, int index) {
                 return AlbumViewerThumbnail(
+                  cacheManager: cacheService.getCache(CacheType.thumbnail),
                   asset: albumInfo.assets[index],
                   assetList: albumInfo.assets,
                   showStorageIndicator: showStorageIndicator,
diff --git a/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart b/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart
index 3943c8a2ce..b6133bc256 100644
--- a/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart
+++ b/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart
@@ -1,6 +1,7 @@
 import 'package:cached_network_image/cached_network_image.dart';
 import 'package:flutter/cupertino.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter_cache_manager/flutter_cache_manager.dart';
 import 'package:photo_view/photo_view.dart';
 
 enum _RemoteImageStatus { empty, thumbnail, preview, full }
@@ -63,11 +64,13 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
     widget.onLoadingCompleted();
   }
 
-  CachedNetworkImageProvider _authorizedImageProvider(String url) {
+  CachedNetworkImageProvider _authorizedImageProvider(
+      String url, String cacheKey, BaseCacheManager? cacheManager) {
     return CachedNetworkImageProvider(
       url,
       headers: {"Authorization": widget.authToken},
-      cacheKey: url,
+      cacheKey: cacheKey,
+      cacheManager: cacheManager,
     );
   }
 
@@ -101,8 +104,11 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
   }
 
   void _loadImages() {
-    CachedNetworkImageProvider thumbnailProvider =
-        _authorizedImageProvider(widget.thumbnailUrl);
+    CachedNetworkImageProvider thumbnailProvider = _authorizedImageProvider(
+      widget.thumbnailUrl,
+      widget.cacheKey,
+      widget.thumbnailCacheManager,
+    );
     _imageProvider = thumbnailProvider;
 
     thumbnailProvider.resolve(const ImageConfiguration()).addListener(
@@ -115,8 +121,11 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
     );
 
     if (widget.previewUrl != null) {
-      CachedNetworkImageProvider previewProvider =
-          _authorizedImageProvider(widget.previewUrl!);
+      CachedNetworkImageProvider previewProvider = _authorizedImageProvider(
+        widget.previewUrl!,
+        "${widget.cacheKey}_previewStage",
+        widget.previewCacheManager,
+      );
       previewProvider.resolve(const ImageConfiguration()).addListener(
         ImageStreamListener((ImageInfo imageInfo, _) {
           _performStateTransition(_RemoteImageStatus.preview, previewProvider);
@@ -124,8 +133,11 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
       );
     }
 
-    CachedNetworkImageProvider fullProvider =
-        _authorizedImageProvider(widget.imageUrl);
+    CachedNetworkImageProvider fullProvider = _authorizedImageProvider(
+      widget.imageUrl,
+      "${widget.cacheKey}_fullStage",
+      widget.fullCacheManager,
+    );
     fullProvider.resolve(const ImageConfiguration()).addListener(
       ImageStreamListener((ImageInfo imageInfo, _) {
         _performStateTransition(_RemoteImageStatus.full, fullProvider);
@@ -153,6 +165,10 @@ class RemotePhotoView extends StatefulWidget {
     this.previewUrl,
     required this.onLoadingCompleted,
     required this.onLoadingStart,
+    this.thumbnailCacheManager,
+    this.previewCacheManager,
+    this.fullCacheManager,
+    required this.cacheKey,
   }) : super(key: key);
 
   final String thumbnailUrl;
@@ -161,6 +177,10 @@ class RemotePhotoView extends StatefulWidget {
   final String? previewUrl;
   final Function onLoadingCompleted;
   final Function onLoadingStart;
+  final BaseCacheManager? thumbnailCacheManager;
+  final BaseCacheManager? previewCacheManager;
+  final BaseCacheManager? fullCacheManager;
+  final String cacheKey;
 
   final void Function() onSwipeDown;
   final void Function() onSwipeUp;
diff --git a/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart
index 7f1ab21688..94129d6164 100644
--- a/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart
+++ b/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart
@@ -8,6 +8,7 @@ import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator
 import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
 import 'package:immich_mobile/modules/home/services/asset.service.dart';
+import 'package:immich_mobile/shared/services/cache.service.dart';
 import 'package:immich_mobile/utils/image_url_builder.dart';
 import 'package:openapi/api.dart';
 
@@ -40,6 +41,7 @@ class ImageViewerPage extends HookConsumerWidget {
   Widget build(BuildContext context, WidgetRef ref) {
     final downloadAssetStatus =
         ref.watch(imageViewerStateProvider).downloadAssetStatus;
+    final cacheService = ref.watch(cacheServiceProvider);
 
     getAssetExif() async {
       assetDetail =
@@ -73,6 +75,7 @@ class ImageViewerPage extends HookConsumerWidget {
             tag: heroTag,
             child: RemotePhotoView(
               thumbnailUrl: getThumbnailUrl(asset),
+              cacheKey: asset.id,
               imageUrl: getImageUrl(asset),
               previewUrl: threeStageLoading
                   ? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG)
@@ -84,6 +87,12 @@ class ImageViewerPage extends HookConsumerWidget {
               onSwipeUp: () => showInfo(),
               onLoadingCompleted: onLoadingCompleted,
               onLoadingStart: onLoadingStart,
+              thumbnailCacheManager:
+                  cacheService.getCache(CacheType.thumbnail),
+              previewCacheManager:
+                  cacheService.getCache(CacheType.imageViewerPreview),
+              fullCacheManager:
+                  cacheService.getCache(CacheType.imageViewerFull),
             ),
           ),
         ),
diff --git a/mobile/lib/modules/home/ui/image_grid.dart b/mobile/lib/modules/home/ui/image_grid.dart
index 30ad9b3938..e0a4b5f466 100644
--- a/mobile/lib/modules/home/ui/image_grid.dart
+++ b/mobile/lib/modules/home/ui/image_grid.dart
@@ -1,4 +1,5 @@
 import 'package:flutter/material.dart';
+import 'package:flutter_cache_manager/flutter_cache_manager.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart';
 import 'package:openapi/api.dart';
@@ -9,11 +10,13 @@ class ImageGrid extends ConsumerWidget {
   final List<AssetResponseDto> sortedAssetGroup;
   final int tilesPerRow;
   final bool showStorageIndicator;
+  final BaseCacheManager? cacheManager;
 
   ImageGrid({
     Key? key,
     required this.assetGroup,
     required this.sortedAssetGroup,
+    this.cacheManager,
     this.tilesPerRow = 4,
     this.showStorageIndicator = true,
   }) : super(key: key);
@@ -36,6 +39,7 @@ class ImageGrid extends ConsumerWidget {
             child: Stack(
               children: [
                 ThumbnailImage(
+                  cacheManager: cacheManager,
                   asset: assetGroup[index],
                   assetList: sortedAssetGroup,
                   showStorageIndicator: showStorageIndicator,
diff --git a/mobile/lib/modules/home/ui/thumbnail_image.dart b/mobile/lib/modules/home/ui/thumbnail_image.dart
index 025fafef42..fb7d7b4f63 100644
--- a/mobile/lib/modules/home/ui/thumbnail_image.dart
+++ b/mobile/lib/modules/home/ui/thumbnail_image.dart
@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
 import 'package:cached_network_image/cached_network_image.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
+import 'package:flutter_cache_manager/flutter_cache_manager.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hive_flutter/hive_flutter.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -16,18 +17,18 @@ class ThumbnailImage extends HookConsumerWidget {
   final AssetResponseDto asset;
   final List<AssetResponseDto> assetList;
   final bool showStorageIndicator;
+  final BaseCacheManager? cacheManager;
 
-  const ThumbnailImage(
-      {Key? key,
-      required this.asset,
-      required this.assetList,
-      this.showStorageIndicator = true})
-      : super(key: key);
+  const ThumbnailImage({
+    Key? key,
+    required this.asset,
+    required this.assetList,
+    this.cacheManager,
+    this.showStorageIndicator = true,
+  }) : super(key: key);
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final cacheKey = useState(1);
-
     var box = Hive.box(userInfoBox);
     var thumbnailRequestUrl = getThumbnailUrl(asset);
     var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
@@ -94,7 +95,8 @@ class ThumbnailImage extends HookConsumerWidget {
                     : const Border(),
               ),
               child: CachedNetworkImage(
-                cacheKey: "${asset.id}-${cacheKey.value}",
+                cacheKey: asset.id,
+                cacheManager: cacheManager,
                 width: 300,
                 height: 300,
                 memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 250 : 400,
@@ -128,17 +130,18 @@ class ThumbnailImage extends HookConsumerWidget {
                   child: _buildSelectionIcon(asset),
                 ),
               ),
-            if (showStorageIndicator) Positioned(
-              right: 10,
-              bottom: 5,
-              child: Icon(
-                (deviceId != asset.deviceId)
-                    ? Icons.cloud_done_outlined
-                    : Icons.photo_library_rounded,
-                color: Colors.white,
-                size: 18,
-              ),
-            )
+            if (showStorageIndicator)
+              Positioned(
+                right: 10,
+                bottom: 5,
+                child: Icon(
+                  (deviceId != asset.deviceId)
+                      ? Icons.cloud_done_outlined
+                      : Icons.photo_library_rounded,
+                  color: Colors.white,
+                  size: 18,
+                ),
+              )
           ],
         ),
       ),
diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart
index 46a6c29d74..5a4f770d40 100644
--- a/mobile/lib/modules/home/views/home_page.dart
+++ b/mobile/lib/modules/home/views/home_page.dart
@@ -16,6 +16,7 @@ import 'package:immich_mobile/modules/settings/services/app_settings.service.dar
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 import 'package:immich_mobile/shared/providers/websocket.provider.dart';
+import 'package:immich_mobile/shared/services/cache.service.dart';
 import 'package:openapi/api.dart';
 
 class HomePage extends HookConsumerWidget {
@@ -24,6 +25,7 @@ class HomePage extends HookConsumerWidget {
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     final appSettingService = ref.watch(appSettingsServiceProvider);
+    final cacheService = ref.watch(cacheServiceProvider);
 
     ScrollController scrollController = useScrollController();
     var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
@@ -89,6 +91,7 @@ class HomePage extends HookConsumerWidget {
 
             imageGridGroup.add(
               ImageGrid(
+                cacheManager: cacheService.getCache(CacheType.thumbnail),
                 assetGroup: immichAssetList,
                 sortedAssetGroup: sortedAssetList,
                 tilesPerRow:
diff --git a/mobile/lib/modules/settings/services/app_settings.service.dart b/mobile/lib/modules/settings/services/app_settings.service.dart
index 918d18b7a0..5eee76dca6 100644
--- a/mobile/lib/modules/settings/services/app_settings.service.dart
+++ b/mobile/lib/modules/settings/services/app_settings.service.dart
@@ -7,7 +7,10 @@ enum AppSettingsEnum<T> {
   tilesPerRow<int>("tilesPerRow", 4),
   uploadErrorNotificationGracePeriod<int>(
       "uploadErrorNotificationGracePeriod", 2),
-  storageIndicator<bool>("storageIndicator", true);
+  storageIndicator<bool>("storageIndicator", true),
+  thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
+  imageCacheSize<int>("imageCacheSize", 350),
+  albumThumbnailCacheSize<int>("albumThumbnailCacheSize", 200);
 
   const AppSettingsEnum(this.hiveKey, this.defaultValue);
 
diff --git a/mobile/lib/modules/settings/ui/cache_settings/cache_settings.dart b/mobile/lib/modules/settings/ui/cache_settings/cache_settings.dart
new file mode 100644
index 0000000000..a185dd5c4e
--- /dev/null
+++ b/mobile/lib/modules/settings/ui/cache_settings/cache_settings.dart
@@ -0,0 +1,142 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
+import 'package:immich_mobile/modules/settings/ui/cache_settings/cache_settings_slider_pref.dart';
+import 'package:immich_mobile/shared/services/cache.service.dart';
+import 'package:immich_mobile/utils/bytes_units.dart';
+
+class CacheSettings extends HookConsumerWidget {
+  const CacheSettings({
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final CacheService cacheService = ref.watch(cacheServiceProvider);
+
+    final clearCacheState = useState(false);
+
+    Future<void> clearCache() async {
+      await cacheService.emptyAllCaches();
+      clearCacheState.value = true;
+    }
+
+    Widget cacheStatisticsRow(String name, CacheType type) {
+      final cacheSize = useState(0);
+      final cacheAssets = useState(0);
+
+      if (!clearCacheState.value) {
+        final repo = cacheService.getCacheRepo(type);
+
+        repo.open().then((_) {
+          cacheSize.value = repo.getCacheSize();
+          cacheAssets.value = repo.getNumberOfCachedObjects();
+        });
+      } else {
+        cacheSize.value = 0;
+        cacheAssets.value = 0;
+      }
+
+      return Container(
+        margin: const EdgeInsets.only(left: 20, bottom: 10),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Text(
+              name,
+              style: const TextStyle(
+                fontWeight: FontWeight.bold,
+              ),
+            ),
+            const Text(
+              "cache_settings_statistics_assets",
+              style: TextStyle(color: Colors.grey),
+            ).tr(
+              args: ["${cacheAssets.value}", formatBytes(cacheSize.value)],
+            ),
+          ],
+        ),
+      );
+    }
+
+    return ExpansionTile(
+      expandedCrossAxisAlignment: CrossAxisAlignment.start,
+      textColor: Theme.of(context).primaryColor,
+      title: const Text(
+        'cache_settings_title',
+        style: TextStyle(
+          fontWeight: FontWeight.bold,
+        ),
+      ).tr(),
+      subtitle: const Text(
+        'cache_settings_subtitle',
+        style: TextStyle(
+          fontSize: 13,
+        ),
+      ).tr(),
+      children: [
+        const CacheSettingsSliderPref(
+          setting: AppSettingsEnum.thumbnailCacheSize,
+          translationKey: "cache_settings_thumbnail_size",
+          min: 1000,
+          max: 20000,
+          divisions: 19,
+        ),
+        const CacheSettingsSliderPref(
+          setting: AppSettingsEnum.imageCacheSize,
+          translationKey: "cache_settings_image_cache_size",
+          min: 0,
+          max: 1000,
+          divisions: 20,
+        ),
+        const CacheSettingsSliderPref(
+          setting: AppSettingsEnum.albumThumbnailCacheSize,
+          translationKey: "cache_settings_album_thumbnails",
+          min: 0,
+          max: 1000,
+          divisions: 20,
+        ),
+        ListTile(
+          title: const Text(
+            "cache_settings_statistics_title",
+            style: TextStyle(
+              fontSize: 12,
+              fontWeight: FontWeight.bold,
+            ),
+          ).tr(),
+        ),
+        cacheStatisticsRow(
+            "cache_settings_statistics_thumbnail".tr(), CacheType.thumbnail),
+        cacheStatisticsRow(
+            "cache_settings_statistics_album".tr(), CacheType.albumThumbnail),
+        cacheStatisticsRow("cache_settings_statistics_shared".tr(),
+            CacheType.sharedAlbumThumbnail),
+        cacheStatisticsRow(
+            "cache_settings_statistics_full".tr(), CacheType.imageViewerFull),
+        ListTile(
+          title: const Text(
+            "cache_settings_clear_cache_button_title",
+            style: TextStyle(
+              fontSize: 12,
+              fontWeight: FontWeight.bold,
+            ),
+          ).tr(),
+        ),
+        Container(
+          alignment: Alignment.center,
+          child: TextButton(
+            onPressed: clearCache,
+            child: Text(
+              "cache_settings_clear_cache_button",
+              style: TextStyle(
+                color: Theme.of(context).primaryColor,
+              ),
+            ).tr(),
+          ),
+        )
+      ],
+    );
+  }
+}
diff --git a/mobile/lib/modules/settings/ui/cache_settings/cache_settings_slider_pref.dart b/mobile/lib/modules/settings/ui/cache_settings/cache_settings_slider_pref.dart
new file mode 100644
index 0000000000..da9e8b030e
--- /dev/null
+++ b/mobile/lib/modules/settings/ui/cache_settings/cache_settings_slider_pref.dart
@@ -0,0 +1,63 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
+import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
+
+class CacheSettingsSliderPref extends HookConsumerWidget {
+  final AppSettingsEnum<int> setting;
+  final String translationKey;
+  final int min;
+  final int max;
+  final int divisions;
+
+  const CacheSettingsSliderPref({
+    Key? key,
+    required this.setting,
+    required this.translationKey,
+    required this.min,
+    required this.max,
+    required this.divisions,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final appSettingService = ref.watch(appSettingsServiceProvider);
+
+    final itemsValue = useState(appSettingService.getSetting<int>(setting));
+
+    void sliderChanged(double value) {
+      itemsValue.value = value.toInt();
+    }
+
+    void sliderChangedEnd(double value) {
+      appSettingService.setSetting(setting, value.toInt());
+    }
+
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        ListTile(
+          title: Text(
+            translationKey,
+            style: const TextStyle(
+              fontSize: 12,
+              fontWeight: FontWeight.bold,
+            ),
+          ).tr(args: ["${itemsValue.value.toInt()}"]),
+        ),
+        Slider(
+          onChangeEnd: sliderChangedEnd,
+          onChanged: sliderChanged,
+          value: itemsValue.value.toDouble(),
+          min: min.toDouble(),
+          max: max.toDouble(),
+          divisions: divisions,
+          label: "${itemsValue.value.toInt()}",
+          activeColor: Theme.of(context).primaryColor,
+        ),
+      ],
+    );
+  }
+}
diff --git a/mobile/lib/modules/settings/views/settings_page.dart b/mobile/lib/modules/settings/views/settings_page.dart
index 84264ed57b..c53b4d977d 100644
--- a/mobile/lib/modules/settings/views/settings_page.dart
+++ b/mobile/lib/modules/settings/views/settings_page.dart
@@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
+import 'package:immich_mobile/modules/settings/ui/cache_settings/cache_settings.dart';
 import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
 import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
 import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
@@ -41,6 +42,7 @@ class SettingsPage extends HookConsumerWidget {
               const ImageViewerQualitySetting(),
               const ThemeSetting(),
               const AssetListSettings(),
+              const CacheSettings(),
               if (Platform.isAndroid) const NotificationSetting(),
             ],
           ).toList(),
diff --git a/mobile/lib/shared/services/cache.service.dart b/mobile/lib/shared/services/cache.service.dart
index 25e08b63cb..d9049bd8c0 100644
--- a/mobile/lib/shared/services/cache.service.dart
+++ b/mobile/lib/shared/services/cache.service.dart
@@ -1,21 +1,79 @@
 import 'package:flutter_cache_manager/flutter_cache_manager.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
+import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
+import 'package:immich_mobile/utils/immich_cache_info_repository.dart';
 
 enum CacheType {
+  // Shared cache for asset thumbnails in various modules
+  thumbnail,
+
+  imageViewerPreview,
+  imageViewerFull,
   albumThumbnail,
   sharedAlbumThumbnail;
 }
 
-final cacheServiceProvider = Provider((_) => CacheService());
+final cacheServiceProvider = Provider(
+  (ref) => CacheService(ref.watch(appSettingsServiceProvider)),
+);
 
 class CacheService {
+  final AppSettingsService _settingsService;
+  final _cacheRepositoryInstances = <CacheType, ImmichCacheRepository>{};
+
+  CacheService(this._settingsService);
 
   BaseCacheManager getCache(CacheType type) {
-    return _getDefaultCache(type.name);
+    return _getDefaultCache(
+      type.name,
+      _getCacheSize(type) + 1,
+      getCacheRepo(type),
+    );
   }
 
-  BaseCacheManager _getDefaultCache(String cacheName) {
-    return CacheManager(Config(cacheName));
+  ImmichCacheRepository getCacheRepo(CacheType type) {
+    if (!_cacheRepositoryInstances.containsKey(type)) {
+      final repo = ImmichCacheInfoRepository(
+        "cache_${type.name}",
+        "cacheKeys_${type.name}",
+      );
+      _cacheRepositoryInstances[type] = repo;
+    }
+
+    return _cacheRepositoryInstances[type]!;
   }
 
-}
\ No newline at end of file
+  Future<void> emptyAllCaches() async {
+    for (var type in CacheType.values) {
+      await getCache(type).emptyCache();
+    }
+  }
+
+  int _getCacheSize(CacheType type) {
+    switch (type) {
+      case CacheType.thumbnail:
+        return _settingsService.getSetting(AppSettingsEnum.thumbnailCacheSize);
+      case CacheType.imageViewerPreview:
+      case CacheType.imageViewerFull:
+        return _settingsService.getSetting(AppSettingsEnum.imageCacheSize);
+      case CacheType.sharedAlbumThumbnail:
+      case CacheType.albumThumbnail:
+        return _settingsService
+            .getSetting(AppSettingsEnum.albumThumbnailCacheSize);
+      default:
+        return 200;
+    }
+  }
+
+  BaseCacheManager _getDefaultCache(
+      String cacheName, int size, CacheInfoRepository repo) {
+    return CacheManager(
+      Config(
+        cacheName,
+        maxNrOfCacheObjects: size,
+        repo: repo,
+      ),
+    );
+  }
+}
diff --git a/mobile/lib/utils/bytes_units.dart b/mobile/lib/utils/bytes_units.dart
new file mode 100644
index 0000000000..78e9f17df7
--- /dev/null
+++ b/mobile/lib/utils/bytes_units.dart
@@ -0,0 +1,15 @@
+
+String formatBytes(int bytes) {
+  if (bytes < 1000) {
+    return "$bytes B";
+  } else if (bytes < 1000000) {
+    final kb = (bytes / 1000).toStringAsFixed(1);
+    return "$kb kB";
+  } else if (bytes < 1000000000) {
+    final mb = (bytes / 1000000).toStringAsFixed(1);
+    return "$mb MB";
+  } else {
+    final gb = (bytes / 1000000000).toStringAsFixed(1);
+    return "$gb GB";
+  }
+}
\ No newline at end of file
diff --git a/mobile/lib/utils/immich_cache_info_repository.dart b/mobile/lib/utils/immich_cache_info_repository.dart
new file mode 100644
index 0000000000..d3a20134d6
--- /dev/null
+++ b/mobile/lib/utils/immich_cache_info_repository.dart
@@ -0,0 +1,204 @@
+import 'dart:io';
+import 'dart:math';
+
+import 'package:flutter_cache_manager/flutter_cache_manager.dart';
+import 'package:flutter_cache_manager/src/storage/cache_object.dart';
+import 'package:hive/hive.dart';
+import 'package:path/path.dart' as p;
+import 'package:path_provider/path_provider.dart';
+
+// Implementation of a CacheInfoRepository based on Hive
+abstract class ImmichCacheRepository extends CacheInfoRepository {
+  int getNumberOfCachedObjects();
+  int getCacheSize();
+}
+
+class ImmichCacheInfoRepository extends ImmichCacheRepository {
+  final String hiveBoxName;
+  final String keyLookupHiveBoxName;
+
+  // To circumvent some of the limitations of a non-relational key-value database,
+  // we use two hive boxes per cache.
+  // [cacheObjectLookupBox] maps ids to cache objects.
+  // [keyLookupHiveBox] maps keys to ids.
+  // The lookup of a cache object by key therefore involves two steps:
+  // id = keyLookupHiveBox[key]
+  // object = cacheObjectLookupBox[id]
+  late Box<Map<dynamic, dynamic>> cacheObjectLookupBox;
+  late Box<int> keyLookupHiveBox;
+
+  ImmichCacheInfoRepository(this.hiveBoxName, this.keyLookupHiveBoxName);
+
+  @override
+  Future<bool> close() async {
+    await cacheObjectLookupBox.close();
+    return true;
+  }
+
+  @override
+  Future<int> delete(int id) async {
+    if (cacheObjectLookupBox.containsKey(id)) {
+      await cacheObjectLookupBox.delete(id);
+      return 1;
+    }
+    return 0;
+  }
+
+  @override
+  Future<int> deleteAll(Iterable<int> ids) async {
+    int deleted = 0;
+    for (var id in ids) {
+      if (cacheObjectLookupBox.containsKey(id)) {
+        deleted++;
+        await cacheObjectLookupBox.delete(id);
+      }
+    }
+    return deleted;
+  }
+
+  @override
+  Future<void> deleteDataFile() async {
+    await cacheObjectLookupBox.clear();
+    await keyLookupHiveBox.clear();
+  }
+
+  @override
+  Future<bool> exists() async {
+    return cacheObjectLookupBox.isNotEmpty && keyLookupHiveBox.isNotEmpty;
+  }
+
+  @override
+  Future<CacheObject?> get(String key) async {
+    if (!keyLookupHiveBox.containsKey(key)) {
+      return null;
+    }
+    int id = keyLookupHiveBox.get(key)!;
+    if (!cacheObjectLookupBox.containsKey(id)) {
+      keyLookupHiveBox.delete(key);
+      return null;
+    }
+    return _deserialize(cacheObjectLookupBox.get(id)!);
+  }
+
+  @override
+  Future<List<CacheObject>> getAllObjects() async {
+    return cacheObjectLookupBox.values.map(_deserialize).toList();
+  }
+
+  @override
+  Future<List<CacheObject>> getObjectsOverCapacity(int capacity) async {
+    if (cacheObjectLookupBox.length <= capacity) {
+      return List.empty();
+    }
+    var values = cacheObjectLookupBox.values.map(_deserialize).toList();
+    values.sort((CacheObject a, CacheObject b) {
+      final aTouched = a.touched ?? DateTime.fromMicrosecondsSinceEpoch(0);
+      final bTouched = b.touched ?? DateTime.fromMicrosecondsSinceEpoch(0);
+
+      return aTouched.compareTo(bTouched);
+    });
+    return values.skip(capacity).toList();
+  }
+
+  @override
+  Future<List<CacheObject>> getOldObjects(Duration maxAge) async {
+    return cacheObjectLookupBox.values
+        .map(_deserialize)
+        .where((CacheObject element) {
+      DateTime touched =
+          element.touched ?? DateTime.fromMicrosecondsSinceEpoch(0);
+      return touched.isBefore(DateTime.now().subtract(maxAge));
+    }).toList();
+  }
+
+  @override
+  Future<CacheObject> insert(CacheObject cacheObject,
+      {bool setTouchedToNow = true}) async {
+    int newId = keyLookupHiveBox.length == 0
+        ? 0
+        : keyLookupHiveBox.values.reduce(max) + 1;
+    cacheObject = cacheObject.copyWith(id: newId);
+
+    keyLookupHiveBox.put(cacheObject.key, newId);
+    cacheObjectLookupBox.put(newId, cacheObject.toMap());
+
+    return cacheObject;
+  }
+
+  @override
+  Future<bool> open() async {
+    cacheObjectLookupBox = await Hive.openBox(hiveBoxName);
+    keyLookupHiveBox = await Hive.openBox(keyLookupHiveBoxName);
+
+    // The cache might have cleared by the operating system.
+    // This could create inconsistencies between the file system cache and database.
+    // To check whether the cache was cleared, a file within the cache directory
+    // is created for each database. If the file is absent, the cache was cleared and therefore
+    // the database has to be cleared as well.
+    if (!await _checkAndCreateAnchorFile()) {
+      await cacheObjectLookupBox.clear();
+      await keyLookupHiveBox.clear();
+    }
+
+    return cacheObjectLookupBox.isOpen;
+  }
+
+  @override
+  Future<int> update(CacheObject cacheObject,
+      {bool setTouchedToNow = true}) async {
+    if (cacheObject.id != null) {
+      cacheObjectLookupBox.put(cacheObject.id, cacheObject.toMap());
+      return 1;
+    }
+    return 0;
+  }
+
+  @override
+  Future updateOrInsert(CacheObject cacheObject) {
+    if (cacheObject.id == null) {
+      return insert(cacheObject);
+    } else {
+      return update(cacheObject);
+    }
+  }
+
+  @override
+  int getNumberOfCachedObjects() {
+    return cacheObjectLookupBox.length;
+  }
+
+  @override
+  int getCacheSize() {
+    final cacheElementsWithSize =
+        cacheObjectLookupBox.values.map(_deserialize).map((e) => e.length ?? 0);
+
+    if (cacheElementsWithSize.isEmpty) {
+      return 0;
+    }
+
+    return cacheElementsWithSize.reduce((value, element) => value + element);
+  }
+
+  CacheObject _deserialize(Map serData) {
+    Map<String, dynamic> converted = {};
+
+    serData.forEach((key, value) {
+      converted[key.toString()] = value;
+    });
+
+    return CacheObject.fromMap(converted);
+  }
+
+  Future<bool> _checkAndCreateAnchorFile() async {
+    final tmpDir = await getTemporaryDirectory();
+    final cacheFile = File(p.join(tmpDir.path, "$hiveBoxName.tmp"));
+
+    if (await cacheFile.exists()) {
+      return true;
+    }
+
+    await cacheFile.create();
+
+    return false;
+  }
+}