mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-30 18:35:00 -04:00 
			
		
		
		
	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
This commit is contained in:
		
							parent
							
								
									e527685ebf
								
							
						
					
					
						commit
						25e68cf826
					
				| @ -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" | ||||
| } | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| @ -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), | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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, | ||||
|                 ), | ||||
|               ) | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|  | ||||
| @ -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: | ||||
|  | ||||
| @ -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); | ||||
| 
 | ||||
|  | ||||
| @ -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(), | ||||
|           ), | ||||
|         ) | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -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, | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -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(), | ||||
|  | ||||
| @ -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]!; | ||||
|   } | ||||
| 
 | ||||
| } | ||||
|   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, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
							
								
								
									
										15
									
								
								mobile/lib/utils/bytes_units.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								mobile/lib/utils/bytes_units.dart
									
									
									
									
									
										Normal file
									
								
							| @ -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"; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										204
									
								
								mobile/lib/utils/immich_cache_info_repository.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								mobile/lib/utils/immich_cache_info_repository.dart
									
									
									
									
									
										Normal file
									
								
							| @ -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; | ||||
|   } | ||||
| } | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user