diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index db18dca74..f9e7e843d 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 fba82cfb2..30ed8524a 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 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 1019a18d4..cc2e2f300 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 4f90a8cf3..235642b70 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 c525c9922..8c0436abe 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 3943c8a2c..b6133bc25 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 { 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 { } 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 { ); 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 { ); } - 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 7f1ab2168..94129d616 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 30ad9b393..e0a4b5f46 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 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 025fafef4..fb7d7b4f6 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 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 46a6c29d7..5a4f770d4 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 918d18b7a..5eee76dca 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 { tilesPerRow("tilesPerRow", 4), uploadErrorNotificationGracePeriod( "uploadErrorNotificationGracePeriod", 2), - storageIndicator("storageIndicator", true); + storageIndicator("storageIndicator", true), + thumbnailCacheSize("thumbnailCacheSize", 10000), + imageCacheSize("imageCacheSize", 350), + albumThumbnailCacheSize("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 000000000..a185dd5c4 --- /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 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 000000000..da9e8b030 --- /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 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(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 84264ed57..c53b4d977 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 25e08b63c..d9049bd8c 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 = {}; + + 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 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 000000000..78e9f17df --- /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 000000000..d3a20134d --- /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> cacheObjectLookupBox; + late Box keyLookupHiveBox; + + ImmichCacheInfoRepository(this.hiveBoxName, this.keyLookupHiveBoxName); + + @override + Future close() async { + await cacheObjectLookupBox.close(); + return true; + } + + @override + Future delete(int id) async { + if (cacheObjectLookupBox.containsKey(id)) { + await cacheObjectLookupBox.delete(id); + return 1; + } + return 0; + } + + @override + Future deleteAll(Iterable ids) async { + int deleted = 0; + for (var id in ids) { + if (cacheObjectLookupBox.containsKey(id)) { + deleted++; + await cacheObjectLookupBox.delete(id); + } + } + return deleted; + } + + @override + Future deleteDataFile() async { + await cacheObjectLookupBox.clear(); + await keyLookupHiveBox.clear(); + } + + @override + Future exists() async { + return cacheObjectLookupBox.isNotEmpty && keyLookupHiveBox.isNotEmpty; + } + + @override + Future 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> getAllObjects() async { + return cacheObjectLookupBox.values.map(_deserialize).toList(); + } + + @override + Future> 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> 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 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 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 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 converted = {}; + + serData.forEach((key, value) { + converted[key.toString()] = value; + }); + + return CacheObject.fromMap(converted); + } + + Future _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; + } +}