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:
Matthias Rupp 2022-08-30 05:44:43 +02:00 committed by GitHub
parent e527685ebf
commit 25e68cf826
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 593 additions and 44 deletions

View file

@ -149,5 +149,18 @@
"setting_notifications_notify_immediately": "immediately", "setting_notifications_notify_immediately": "immediately",
"setting_notifications_notify_minutes": "{} minutes", "setting_notifications_notify_minutes": "{} minutes",
"setting_notifications_notify_hours": "{} hours", "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"
} }

View file

@ -1,6 +1,7 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/routing/router.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:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@ -15,17 +17,18 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
final AssetResponseDto asset; final AssetResponseDto asset;
final List<AssetResponseDto> assetList; final List<AssetResponseDto> assetList;
final bool showStorageIndicator; final bool showStorageIndicator;
final BaseCacheManager? cacheManager;
const AlbumViewerThumbnail({ const AlbumViewerThumbnail({
Key? key, Key? key,
required this.asset, required this.asset,
required this.assetList, required this.assetList,
this.cacheManager,
this.showStorageIndicator = true, this.showStorageIndicator = true,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final cacheKey = useState(1);
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = getThumbnailUrl(asset); var thumbnailRequestUrl = getThumbnailUrl(asset);
var deviceId = ref.watch(authenticationProvider).deviceId; var deviceId = ref.watch(authenticationProvider).deviceId;
@ -123,7 +126,8 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
return Container( return Container(
decoration: BoxDecoration(border: drawBorderColor()), decoration: BoxDecoration(border: drawBorderColor()),
child: CachedNetworkImage( child: CachedNetworkImage(
cacheKey: "${asset.id}-${cacheKey.value}", cacheManager: cacheManager,
cacheKey: asset.id,
width: 300, width: 300,
height: 300, height: 300,
memCacheHeight: 200, memCacheHeight: 200,

View file

@ -5,6 +5,8 @@ import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.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'; import 'package:openapi/api.dart';
class SelectionThumbnailImage extends HookConsumerWidget { class SelectionThumbnailImage extends HookConsumerWidget {
@ -15,15 +17,14 @@ class SelectionThumbnailImage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final cacheKey = useState(1);
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = var thumbnailRequestUrl = getThumbnailUrl(asset);
'${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}';
var selectedAsset = var selectedAsset =
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum; ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
var newAssetsForAlbum = var newAssetsForAlbum =
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum; ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist; var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
final cacheService = ref.watch(cacheServiceProvider);
Widget _buildSelectionIcon(AssetResponseDto asset) { Widget _buildSelectionIcon(AssetResponseDto asset) {
var isSelected = selectedAsset.map((item) => item.id).contains(asset.id); var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
@ -113,7 +114,8 @@ class SelectionThumbnailImage extends HookConsumerWidget {
Container( Container(
decoration: BoxDecoration(border: drawBorderColor()), decoration: BoxDecoration(border: drawBorderColor()),
child: CachedNetworkImage( child: CachedNetworkImage(
cacheKey: "${asset.id}-${cacheKey.value}", cacheManager: cacheService.getCache(CacheType.thumbnail),
cacheKey: asset.id,
width: 150, width: 150,
height: 150, height: 150,
memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 150 : 150, memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 150 : 150,

View file

@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.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:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@ -15,8 +16,7 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final cacheKey = useState(1); final cacheService = ref.watch(cacheServiceProvider);
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
return GestureDetector( return GestureDetector(
@ -26,7 +26,8 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
child: Stack( child: Stack(
children: [ children: [
CachedNetworkImage( CachedNetworkImage(
cacheKey: "${asset.id}-${cacheKey.value}", cacheManager: cacheService.getCache(CacheType.thumbnail),
cacheKey: asset.id,
width: 500, width: 500,
height: 500, height: 500,
memCacheHeight: 500, memCacheHeight: 500,

View file

@ -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/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/routing/router.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_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart'; import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.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 appSettingService = ref.watch(appSettingsServiceProvider);
final bool showStorageIndicator = final bool showStorageIndicator =
appSettingService.getSetting(AppSettingsEnum.storageIndicator); appSettingService.getSetting(AppSettingsEnum.storageIndicator);
final cacheService = ref.watch(cacheServiceProvider);
if (albumInfo.assets.isNotEmpty) { if (albumInfo.assets.isNotEmpty) {
return SliverPadding( return SliverPadding(
@ -205,6 +207,7 @@ class AlbumViewerPage extends HookConsumerWidget {
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) { (BuildContext context, int index) {
return AlbumViewerThumbnail( return AlbumViewerThumbnail(
cacheManager: cacheService.getCache(CacheType.thumbnail),
asset: albumInfo.assets[index], asset: albumInfo.assets[index],
assetList: albumInfo.assets, assetList: albumInfo.assets,
showStorageIndicator: showStorageIndicator, showStorageIndicator: showStorageIndicator,

View file

@ -1,6 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
enum _RemoteImageStatus { empty, thumbnail, preview, full } enum _RemoteImageStatus { empty, thumbnail, preview, full }
@ -63,11 +64,13 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
widget.onLoadingCompleted(); widget.onLoadingCompleted();
} }
CachedNetworkImageProvider _authorizedImageProvider(String url) { CachedNetworkImageProvider _authorizedImageProvider(
String url, String cacheKey, BaseCacheManager? cacheManager) {
return CachedNetworkImageProvider( return CachedNetworkImageProvider(
url, url,
headers: {"Authorization": widget.authToken}, headers: {"Authorization": widget.authToken},
cacheKey: url, cacheKey: cacheKey,
cacheManager: cacheManager,
); );
} }
@ -101,8 +104,11 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
} }
void _loadImages() { void _loadImages() {
CachedNetworkImageProvider thumbnailProvider = CachedNetworkImageProvider thumbnailProvider = _authorizedImageProvider(
_authorizedImageProvider(widget.thumbnailUrl); widget.thumbnailUrl,
widget.cacheKey,
widget.thumbnailCacheManager,
);
_imageProvider = thumbnailProvider; _imageProvider = thumbnailProvider;
thumbnailProvider.resolve(const ImageConfiguration()).addListener( thumbnailProvider.resolve(const ImageConfiguration()).addListener(
@ -115,8 +121,11 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
); );
if (widget.previewUrl != null) { if (widget.previewUrl != null) {
CachedNetworkImageProvider previewProvider = CachedNetworkImageProvider previewProvider = _authorizedImageProvider(
_authorizedImageProvider(widget.previewUrl!); widget.previewUrl!,
"${widget.cacheKey}_previewStage",
widget.previewCacheManager,
);
previewProvider.resolve(const ImageConfiguration()).addListener( previewProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) { ImageStreamListener((ImageInfo imageInfo, _) {
_performStateTransition(_RemoteImageStatus.preview, previewProvider); _performStateTransition(_RemoteImageStatus.preview, previewProvider);
@ -124,8 +133,11 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
); );
} }
CachedNetworkImageProvider fullProvider = CachedNetworkImageProvider fullProvider = _authorizedImageProvider(
_authorizedImageProvider(widget.imageUrl); widget.imageUrl,
"${widget.cacheKey}_fullStage",
widget.fullCacheManager,
);
fullProvider.resolve(const ImageConfiguration()).addListener( fullProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) { ImageStreamListener((ImageInfo imageInfo, _) {
_performStateTransition(_RemoteImageStatus.full, fullProvider); _performStateTransition(_RemoteImageStatus.full, fullProvider);
@ -153,6 +165,10 @@ class RemotePhotoView extends StatefulWidget {
this.previewUrl, this.previewUrl,
required this.onLoadingCompleted, required this.onLoadingCompleted,
required this.onLoadingStart, required this.onLoadingStart,
this.thumbnailCacheManager,
this.previewCacheManager,
this.fullCacheManager,
required this.cacheKey,
}) : super(key: key); }) : super(key: key);
final String thumbnailUrl; final String thumbnailUrl;
@ -161,6 +177,10 @@ class RemotePhotoView extends StatefulWidget {
final String? previewUrl; final String? previewUrl;
final Function onLoadingCompleted; final Function onLoadingCompleted;
final Function onLoadingStart; final Function onLoadingStart;
final BaseCacheManager? thumbnailCacheManager;
final BaseCacheManager? previewCacheManager;
final BaseCacheManager? fullCacheManager;
final String cacheKey;
final void Function() onSwipeDown; final void Function() onSwipeDown;
final void Function() onSwipeUp; final void Function() onSwipeUp;

View file

@ -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/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.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/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:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@ -40,6 +41,7 @@ class ImageViewerPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final downloadAssetStatus = final downloadAssetStatus =
ref.watch(imageViewerStateProvider).downloadAssetStatus; ref.watch(imageViewerStateProvider).downloadAssetStatus;
final cacheService = ref.watch(cacheServiceProvider);
getAssetExif() async { getAssetExif() async {
assetDetail = assetDetail =
@ -73,6 +75,7 @@ class ImageViewerPage extends HookConsumerWidget {
tag: heroTag, tag: heroTag,
child: RemotePhotoView( child: RemotePhotoView(
thumbnailUrl: getThumbnailUrl(asset), thumbnailUrl: getThumbnailUrl(asset),
cacheKey: asset.id,
imageUrl: getImageUrl(asset), imageUrl: getImageUrl(asset),
previewUrl: threeStageLoading previewUrl: threeStageLoading
? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG) ? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG)
@ -84,6 +87,12 @@ class ImageViewerPage extends HookConsumerWidget {
onSwipeUp: () => showInfo(), onSwipeUp: () => showInfo(),
onLoadingCompleted: onLoadingCompleted, onLoadingCompleted: onLoadingCompleted,
onLoadingStart: onLoadingStart, onLoadingStart: onLoadingStart,
thumbnailCacheManager:
cacheService.getCache(CacheType.thumbnail),
previewCacheManager:
cacheService.getCache(CacheType.imageViewerPreview),
fullCacheManager:
cacheService.getCache(CacheType.imageViewerFull),
), ),
), ),
), ),

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart'; import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@ -9,11 +10,13 @@ class ImageGrid extends ConsumerWidget {
final List<AssetResponseDto> sortedAssetGroup; final List<AssetResponseDto> sortedAssetGroup;
final int tilesPerRow; final int tilesPerRow;
final bool showStorageIndicator; final bool showStorageIndicator;
final BaseCacheManager? cacheManager;
ImageGrid({ ImageGrid({
Key? key, Key? key,
required this.assetGroup, required this.assetGroup,
required this.sortedAssetGroup, required this.sortedAssetGroup,
this.cacheManager,
this.tilesPerRow = 4, this.tilesPerRow = 4,
this.showStorageIndicator = true, this.showStorageIndicator = true,
}) : super(key: key); }) : super(key: key);
@ -36,6 +39,7 @@ class ImageGrid extends ConsumerWidget {
child: Stack( child: Stack(
children: [ children: [
ThumbnailImage( ThumbnailImage(
cacheManager: cacheManager,
asset: assetGroup[index], asset: assetGroup[index],
assetList: sortedAssetGroup, assetList: sortedAssetGroup,
showStorageIndicator: showStorageIndicator, showStorageIndicator: showStorageIndicator,

View file

@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -16,18 +17,18 @@ class ThumbnailImage extends HookConsumerWidget {
final AssetResponseDto asset; final AssetResponseDto asset;
final List<AssetResponseDto> assetList; final List<AssetResponseDto> assetList;
final bool showStorageIndicator; final bool showStorageIndicator;
final BaseCacheManager? cacheManager;
const ThumbnailImage( const ThumbnailImage({
{Key? key, Key? key,
required this.asset, required this.asset,
required this.assetList, required this.assetList,
this.showStorageIndicator = true}) this.cacheManager,
: super(key: key); this.showStorageIndicator = true,
}) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final cacheKey = useState(1);
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = getThumbnailUrl(asset); var thumbnailRequestUrl = getThumbnailUrl(asset);
var selectedAsset = ref.watch(homePageStateProvider).selectedItems; var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
@ -94,7 +95,8 @@ class ThumbnailImage extends HookConsumerWidget {
: const Border(), : const Border(),
), ),
child: CachedNetworkImage( child: CachedNetworkImage(
cacheKey: "${asset.id}-${cacheKey.value}", cacheKey: asset.id,
cacheManager: cacheManager,
width: 300, width: 300,
height: 300, height: 300,
memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 250 : 400, memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 250 : 400,
@ -128,17 +130,18 @@ class ThumbnailImage extends HookConsumerWidget {
child: _buildSelectionIcon(asset), child: _buildSelectionIcon(asset),
), ),
), ),
if (showStorageIndicator) Positioned( if (showStorageIndicator)
right: 10, Positioned(
bottom: 5, right: 10,
child: Icon( bottom: 5,
(deviceId != asset.deviceId) child: Icon(
? Icons.cloud_done_outlined (deviceId != asset.deviceId)
: Icons.photo_library_rounded, ? Icons.cloud_done_outlined
color: Colors.white, : Icons.photo_library_rounded,
size: 18, color: Colors.white,
), size: 18,
) ),
)
], ],
), ),
), ),

View file

@ -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/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.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/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class HomePage extends HookConsumerWidget { class HomePage extends HookConsumerWidget {
@ -24,6 +25,7 @@ class HomePage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider); final appSettingService = ref.watch(appSettingsServiceProvider);
final cacheService = ref.watch(cacheServiceProvider);
ScrollController scrollController = useScrollController(); ScrollController scrollController = useScrollController();
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider); var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
@ -89,6 +91,7 @@ class HomePage extends HookConsumerWidget {
imageGridGroup.add( imageGridGroup.add(
ImageGrid( ImageGrid(
cacheManager: cacheService.getCache(CacheType.thumbnail),
assetGroup: immichAssetList, assetGroup: immichAssetList,
sortedAssetGroup: sortedAssetList, sortedAssetGroup: sortedAssetList,
tilesPerRow: tilesPerRow:

View file

@ -7,7 +7,10 @@ enum AppSettingsEnum<T> {
tilesPerRow<int>("tilesPerRow", 4), tilesPerRow<int>("tilesPerRow", 4),
uploadErrorNotificationGracePeriod<int>( uploadErrorNotificationGracePeriod<int>(
"uploadErrorNotificationGracePeriod", 2), "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); const AppSettingsEnum(this.hiveKey, this.defaultValue);

View file

@ -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(),
),
)
],
);
}
}

View file

@ -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,
),
],
);
}
}

View file

@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/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/notification_setting/notification_setting.dart';
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_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 ImageViewerQualitySetting(),
const ThemeSetting(), const ThemeSetting(),
const AssetListSettings(), const AssetListSettings(),
const CacheSettings(),
if (Platform.isAndroid) const NotificationSetting(), if (Platform.isAndroid) const NotificationSetting(),
], ],
).toList(), ).toList(),

View file

@ -1,21 +1,79 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:hooks_riverpod/hooks_riverpod.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 { enum CacheType {
// Shared cache for asset thumbnails in various modules
thumbnail,
imageViewerPreview,
imageViewerFull,
albumThumbnail, albumThumbnail,
sharedAlbumThumbnail; sharedAlbumThumbnail;
} }
final cacheServiceProvider = Provider((_) => CacheService()); final cacheServiceProvider = Provider(
(ref) => CacheService(ref.watch(appSettingsServiceProvider)),
);
class CacheService { class CacheService {
final AppSettingsService _settingsService;
final _cacheRepositoryInstances = <CacheType, ImmichCacheRepository>{};
CacheService(this._settingsService);
BaseCacheManager getCache(CacheType type) { BaseCacheManager getCache(CacheType type) {
return _getDefaultCache(type.name); return _getDefaultCache(
type.name,
_getCacheSize(type) + 1,
getCacheRepo(type),
);
} }
BaseCacheManager _getDefaultCache(String cacheName) { ImmichCacheRepository getCacheRepo(CacheType type) {
return CacheManager(Config(cacheName)); 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,
),
);
}
} }

View 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";
}
}

View 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;
}
}