From d657d10850e871961aa17f138854933cbfdec5a2 Mon Sep 17 00:00:00 2001 From: Dario Date: Sat, 14 Feb 2026 09:09:46 +0400 Subject: [PATCH 1/5] add smart cache feature --- mobile/lib/domain/models/store.model.dart | 6 +- mobile/lib/main.dart | 2 + .../lib/providers/smart_cache.provider.dart | 13 + .../lib/providers/smart_cache.provider.g.dart | 45 +++ mobile/lib/services/app_settings.service.dart | 4 +- mobile/lib/services/smart_cache.service.dart | 172 ++++++++++++ .../asset_viewer_settings.dart | 7 +- .../smart_cache_setting.dart | 264 ++++++++++++++++++ 8 files changed, 510 insertions(+), 3 deletions(-) create mode 100644 mobile/lib/providers/smart_cache.provider.dart create mode 100644 mobile/lib/providers/smart_cache.provider.g.dart create mode 100644 mobile/lib/services/smart_cache.service.dart create mode 100644 mobile/lib/widgets/settings/asset_viewer_settings/smart_cache_setting.dart diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index f6bed7cf6173a..dba6165e791c2 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -91,7 +91,11 @@ enum StoreKey { cleanupCutoffDaysAgo._(1011), cleanupDefaultsInitialized._(1012), - syncMigrationStatus._(1013); + syncMigrationStatus._(1013), + + smartCacheEnabled._(1100), + smartCacheHighResDays._(1101), + smartCacheLastCleanup._(1102); const StoreKey._(this.id); final int id; diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 1316e6627332c..e85fc886590aa 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -43,6 +43,7 @@ import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/utils/licenses.dart'; import 'package:immich_mobile/utils/migration.dart'; import 'package:immich_mobile/wm_executor.dart'; +import 'package:immich_mobile/services/smart_cache.service.dart'; import 'package:immich_ui/immich_ui.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:logging/logging.dart'; @@ -58,6 +59,7 @@ void main() async { await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5)); await migrateDatabaseIfNeeded(isar, drift); HttpSSLOptions.apply(); + unawaited(SmartCacheService().runCleanupIfNeeded()); runApp( ProviderScope( diff --git a/mobile/lib/providers/smart_cache.provider.dart b/mobile/lib/providers/smart_cache.provider.dart new file mode 100644 index 0000000000000..b974901286125 --- /dev/null +++ b/mobile/lib/providers/smart_cache.provider.dart @@ -0,0 +1,13 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/services/smart_cache.service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'smart_cache.provider.g.dart'; + +@Riverpod(keepAlive: true) +SmartCacheService smartCacheService(Ref _) => SmartCacheService(); + +@riverpod +Future smartCacheStats(Ref ref) async { + return ref.watch(smartCacheServiceProvider).getCacheStats(); +} diff --git a/mobile/lib/providers/smart_cache.provider.g.dart b/mobile/lib/providers/smart_cache.provider.g.dart new file mode 100644 index 0000000000000..8079d3a48f251 --- /dev/null +++ b/mobile/lib/providers/smart_cache.provider.g.dart @@ -0,0 +1,45 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'smart_cache.provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$smartCacheServiceHash() => r'41f5dd1128f83810b76a10c4d73269842c8b73e1'; + +/// See also [smartCacheService]. +@ProviderFor(smartCacheService) +final smartCacheServiceProvider = Provider.internal( + smartCacheService, + name: r'smartCacheServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$smartCacheServiceHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SmartCacheServiceRef = ProviderRef; +String _$smartCacheStatsHash() => r'28c61ca2628ae28895df5a85cf3ecd939e542221'; + +/// See also [smartCacheStats]. +@ProviderFor(smartCacheStats) +final smartCacheStatsProvider = + AutoDisposeFutureProvider.internal( + smartCacheStats, + name: r'smartCacheStatsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$smartCacheStatsHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SmartCacheStatsRef = AutoDisposeFutureProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 4e740ebfe5037..24a3f64281f1c 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -59,7 +59,9 @@ enum AppSettingsEnum { cleanupKeepMediaType(StoreKey.cleanupKeepMediaType, null, 0), cleanupKeepAlbumIds(StoreKey.cleanupKeepAlbumIds, null, ""), cleanupCutoffDaysAgo(StoreKey.cleanupCutoffDaysAgo, null, -1), - cleanupDefaultsInitialized(StoreKey.cleanupDefaultsInitialized, null, false); + cleanupDefaultsInitialized(StoreKey.cleanupDefaultsInitialized, null, false), + smartCacheEnabled(StoreKey.smartCacheEnabled, null, true), + smartCacheHighResDays(StoreKey.smartCacheHighResDays, null, 7); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/services/smart_cache.service.dart b/mobile/lib/services/smart_cache.service.dart new file mode 100644 index 0000000000000..613c6d1a5852c --- /dev/null +++ b/mobile/lib/services/smart_cache.service.dart @@ -0,0 +1,172 @@ +import 'dart:io'; + +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; +import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; + +class SmartCacheService { + static final _log = Logger('SmartCacheService'); + static SmartCacheService? _instance; + + SmartCacheService._(); + + factory SmartCacheService() { + _instance ??= SmartCacheService._(); + return _instance!; + } + + Future runCleanupIfNeeded() async { + final enabled = Store.get(StoreKey.smartCacheEnabled, true); + if (!enabled) return; + + final lastCleanup = Store.tryGet(StoreKey.smartCacheLastCleanup) ?? 0; + final now = DateTime.now().millisecondsSinceEpoch; + final oneDayMs = 24 * 60 * 60 * 1000; + + if (now - lastCleanup < oneDayMs) return; + + await cleanupExpiredHighResCache(); + await Store.put(StoreKey.smartCacheLastCleanup, now); + } + + Future cleanupExpiredHighResCache() async { + try { + final days = Store.get(StoreKey.smartCacheHighResDays, 7); + final cutoff = DateTime.now().subtract(Duration(days: days)); + + final cacheDir = await _getHighResCacheDirectory(); + if (cacheDir == null || !await cacheDir.exists()) return; + + final files = cacheDir.listSync(recursive: true); + var cleanedCount = 0; + var cleanedBytes = 0; + + for (final entity in files) { + if (entity is File) { + final stat = await entity.stat(); + if (stat.accessed.isBefore(cutoff)) { + cleanedBytes += stat.size; + await entity.delete(); + cleanedCount++; + } + } + } + + if (cleanedCount > 0) { + _log.info('Cleaned $cleanedCount expired high-res cache files ($cleanedBytes bytes)'); + } + } catch (e) { + _log.warning('Failed to cleanup high-res cache: $e'); + } + } + + Future clearHighResCache() async { + try { + await RemoteImageCacheManager().emptyCache(); + _log.info('Cleared high-res cache'); + } catch (e) { + _log.warning('Failed to clear high-res cache: $e'); + } + } + + Future clearAllCache() async { + try { + await RemoteImageCacheManager().emptyCache(); + await RemoteThumbnailCacheManager().emptyCache(); + _log.info('Cleared all cache'); + } catch (e) { + _log.warning('Failed to clear all cache: $e'); + } + } + + Future getCacheStats() async { + var thumbnailSize = 0; + var thumbnailCount = 0; + var highResSize = 0; + var highResCount = 0; + + try { + final thumbnailDir = await _getThumbnailCacheDirectory(); + if (thumbnailDir != null && await thumbnailDir.exists()) { + final stats = await _calculateDirectoryStats(thumbnailDir); + thumbnailSize = stats.size; + thumbnailCount = stats.count; + } + } catch (_) {} + + try { + final highResDir = await _getHighResCacheDirectory(); + if (highResDir != null && await highResDir.exists()) { + final stats = await _calculateDirectoryStats(highResDir); + highResSize = stats.size; + highResCount = stats.count; + } + } catch (_) {} + + return SmartCacheStats( + thumbnailSize: thumbnailSize, + thumbnailCount: thumbnailCount, + highResSize: highResSize, + highResCount: highResCount, + ); + } + + Future<_DirStats> _calculateDirectoryStats(Directory dir) async { + var size = 0; + var count = 0; + + await for (final entity in dir.list(recursive: true)) { + if (entity is File) { + final stat = await entity.stat(); + size += stat.size; + count++; + } + } + + return _DirStats(size, count); + } + + Future _getHighResCacheDirectory() async { + try { + final cacheDir = await getTemporaryDirectory(); + return Directory('${cacheDir.path}/${RemoteImageCacheManager.key}'); + } catch (_) { + return null; + } + } + + Future _getThumbnailCacheDirectory() async { + try { + final cacheDir = await getTemporaryDirectory(); + return Directory('${cacheDir.path}/${RemoteThumbnailCacheManager.key}'); + } catch (_) { + return null; + } + } +} + +class _DirStats { + final int size; + final int count; + + _DirStats(this.size, this.count); +} + +class SmartCacheStats { + final int thumbnailSize; + final int thumbnailCount; + final int highResSize; + final int highResCount; + + SmartCacheStats({ + required this.thumbnailSize, + required this.thumbnailCount, + required this.highResSize, + required this.highResCount, + }); + + int get totalSize => thumbnailSize + highResSize; + int get totalCount => thumbnailCount + highResCount; +} diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart index 5dea38d85e1c9..02168676cf338 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart'; +import 'package:immich_mobile/widgets/settings/asset_viewer_settings/smart_cache_setting.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'video_viewer_settings.dart'; @@ -8,7 +9,11 @@ class AssetViewerSettings extends StatelessWidget { @override Widget build(BuildContext context) { - final assetViewerSetting = [const ImageViewerQualitySetting(), const VideoViewerSettings()]; + final assetViewerSetting = [ + const ImageViewerQualitySetting(), + const VideoViewerSettings(), + const SmartCacheSetting(), + ]; return SettingsSubPageScaffold(settings: assetViewerSetting, showDivider: true); } diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/smart_cache_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/smart_cache_setting.dart new file mode 100644 index 0000000000000..65d869e6aa868 --- /dev/null +++ b/mobile/lib/widgets/settings/asset_viewer_settings/smart_cache_setting.dart @@ -0,0 +1,264 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/smart_cache.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/services/smart_cache.service.dart'; +import 'package:immich_mobile/utils/bytes_units.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; +import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; +import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; +import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; + +class SmartCacheSetting extends HookConsumerWidget { + const SmartCacheSetting({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isEnabled = useAppSettingsState(AppSettingsEnum.smartCacheEnabled); + final cacheDays = useAppSettingsState(AppSettingsEnum.smartCacheHighResDays); + final cacheStats = ref.watch(smartCacheStatsProvider); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingGroupTitle( + title: "smart_cache".t(context: context), + icon: Icons.storage_outlined, + subtitle: "smart_cache_description".t(context: context), + ), + SettingsSwitchListTile( + valueNotifier: isEnabled, + title: "smart_cache_enabled".t(context: context), + subtitle: "smart_cache_enabled_description".t(context: context), + onChanged: (_) => ref.invalidate(appSettingsServiceProvider), + ), + if (isEnabled.value) ...[ + SettingsSliderListTile( + valueNotifier: cacheDays, + text: "high_res_cache_duration".t( + context: context, + args: {'days': cacheDays.value.toString()}, + ), + maxValue: 30, + minValue: 1, + noDivisons: 29, + label: "${cacheDays.value}", + onChangeEnd: (_) => ref.invalidate(appSettingsServiceProvider), + ), + _buildCacheStats(context, ref, cacheStats), + _buildCacheActions(context, ref), + ], + ], + ); + } + + Widget _buildCacheStats( + BuildContext context, + WidgetRef ref, + AsyncValue cacheStats, + ) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 20), + dense: true, + title: Text( + "cache_stats".t(context: context), + style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + ), + subtitle: cacheStats.when( + data: (stats) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + _buildStatRow( + context, + "thumbnails".t(context: context), + stats.thumbnailCount, + stats.thumbnailSize, + ), + const SizedBox(height: 4), + _buildStatRow( + context, + "high_res_images".t(context: context), + stats.highResCount, + stats.highResSize, + ), + const SizedBox(height: 4), + _buildStatRow( + context, + "total".t(context: context), + stats.totalCount, + stats.totalSize, + isBold: true, + ), + ], + ), + loading: () => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + "loading".t(context: context), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + ), + ), + error: (_, __) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + "error_loading_cache_stats".t(context: context), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.error, + ), + ), + ), + ), + ); + } + + Widget _buildStatRow( + BuildContext context, + String label, + int count, + int size, { + bool isBold = false, + }) { + final textStyle = context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + fontWeight: isBold ? FontWeight.w600 : null, + ); + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: textStyle), + Text( + "$count ${count == 1 ? 'file' : 'files'} (${formatHumanReadableBytes(size, 1)})", + style: textStyle, + ), + ], + ); + } + + Widget _buildCacheActions(BuildContext context, WidgetRef ref) { + return Column( + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 20), + dense: true, + title: Text( + "clear_high_res_cache".t(context: context), + style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + ), + subtitle: Text( + "clear_high_res_cache_description".t(context: context), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + ), + trailing: TextButton( + onPressed: () => _clearHighResCache(context, ref), + child: Text( + "clear".t(context: context), + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + ), + ), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 20), + dense: true, + title: Text( + "clear_all_cache".t(context: context), + style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + ), + subtitle: Text( + "clear_all_cache_description".t(context: context), + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + ), + trailing: TextButton( + onPressed: () => _clearAllCache(context, ref), + child: Text( + "clear".t(context: context), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.red.shade400, + ), + ), + ), + ), + ], + ); + } + + Future _clearHighResCache(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text("clear_high_res_cache".t(context: context)), + content: Text("clear_high_res_cache_confirm".t(context: context)), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text("cancel".t(context: context)), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text("clear".t(context: context)), + ), + ], + ), + ); + + if (confirmed == true) { + await ref.read(smartCacheServiceProvider).clearHighResCache(); + ref.invalidate(smartCacheStatsProvider); + if (context.mounted) { + context.scaffoldMessenger.showSnackBar( + SnackBar( + content: Text("cache_cleared".t(context: context)), + duration: const Duration(seconds: 2), + ), + ); + } + } + } + + Future _clearAllCache(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text("clear_all_cache".t(context: context)), + content: Text("clear_all_cache_confirm".t(context: context)), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text("cancel".t(context: context)), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text("clear".t(context: context)), + ), + ], + ), + ); + + if (confirmed == true) { + await ref.read(smartCacheServiceProvider).clearAllCache(); + ref.invalidate(smartCacheStatsProvider); + if (context.mounted) { + context.scaffoldMessenger.showSnackBar( + SnackBar( + content: Text("cache_cleared".t(context: context)), + duration: const Duration(seconds: 2), + ), + ); + } + } + } +} From 5411aa33ce228db88cea8a4afde3f6865e098e38 Mon Sep 17 00:00:00 2001 From: Dario Date: Sat, 14 Feb 2026 09:44:06 +0400 Subject: [PATCH 2/5] plug smart cache resolution into storage --- i18n/en.json | 12 + .../alextran/immich/images/RemoteImages.g.kt | 194 +++++++++++++- .../immich/images/RemoteImagesImpl.kt | 200 ++++++++++++-- .../ios/Runner/Core/URLSessionManager.swift | 129 ++++++++-- mobile/ios/Runner/Images/RemoteImages.g.swift | 215 +++++++++++++++- .../ios/Runner/Images/RemoteImagesImpl.swift | 53 +++- .../loaders/remote_image_request.dart | 5 +- mobile/lib/platform/remote_image_api.g.dart | 243 ++++++++++++++++-- .../widgets/images/remote_image_provider.dart | 2 +- mobile/lib/services/smart_cache.service.dart | 134 +++------- .../widgets/settings/advanced_settings.dart | 39 --- .../smart_cache_setting.dart | 37 ++- mobile/pigeon/remote_image_api.dart | 33 ++- mobile/pubspec.yaml | 1 + 14 files changed, 1063 insertions(+), 234 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 6e35085be8334..0132571910097 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -717,6 +717,7 @@ "cache_settings_tile_subtitle": "Control the local storage behaviour", "cache_settings_tile_title": "Local Storage", "cache_settings_title": "Caching Settings", + "cache_stats": "Cache Statistics", "camera": "Camera", "camera_brand": "Camera brand", "camera_model": "Camera model", @@ -773,8 +774,12 @@ "cleanup_trash_hint": "To fully reclaim storage space, open the system gallery app and empty the trash", "clear": "Clear", "clear_all": "Clear all", + "clear_all_cache": "Clear All Cache", + "clear_all_cache_description": "Delete both thumbnails and high-res images", "clear_all_recent_searches": "Clear all recent searches", "clear_file_cache": "Clear File Cache", + "clear_high_res_cache": "Clear High-Resolution Cache", + "clear_high_res_cache_description": "Delete cached high-res images, keep thumbnails", "clear_message": "Clear message", "clear_value": "Clear value", "client_cert_dialog_msg_confirm": "OK", @@ -1273,6 +1278,8 @@ "hide_schema": "Hide schema", "hide_text_recognition": "Hide text recognition", "hide_unnamed_people": "Hide unnamed people", + "high_res_cache_duration": "High-res cache duration ({days} days)", + "high_res_cache_duration_never": "High-res cache duration (Never)", "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", "home_page_add_to_album_success": "Added {added} assets to album {album}.", @@ -2152,6 +2159,11 @@ "slideshow_repeat": "Repeat slideshow", "slideshow_repeat_description": "Loop back to beginning when slideshow ends", "slideshow_settings": "Slideshow settings", + "smart_cache": "Smart Cache", + "smart_cache_description": "Cache thumbnails persistently and high-res images temporarily", + "smart_cache_enabled": "Enable Smart Cache", + "smart_cache_enabled_description": "Keep low-res thumbnails cached, download high-res on demand", + "smart_cache_prefer_remote_warning": "'Prefer Remote Images' is enabled in Advanced Settings, which may affect cache behavior", "sort_albums_by": "Sort albums by...", "sort_created": "Date created", "sort_items": "Number of items", diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt index 0e3cf19657dae..f7ea1c1bbd989 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt @@ -34,22 +34,145 @@ private object RemoteImagesPigeonUtils { ) } } + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + return a.contentEquals(b) + } + if (a is Array<*> && b is Array<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is List<*> && b is List<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is Map<*, *> && b is Map<*, *>) { + return a.size == b.size && a.all { + (b as Map).containsKey(it.key) && + deepEquals(it.value, b[it.key]) + } + } + return a == b + } + +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class NativeCacheStats ( + val size: Long, + val count: Long +) + { + companion object { + fun fromList(pigeonVar_list: List): NativeCacheStats { + val size = pigeonVar_list[0] as Long + val count = pigeonVar_list[1] as Long + return NativeCacheStats(size, count) + } + } + fun toList(): List { + return listOf( + size, + count, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is NativeCacheStats) { + return false + } + if (this === other) { + return true + } + return RemoteImagesPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class DualCacheStats ( + val thumbnailSize: Long, + val thumbnailCount: Long, + val highResSize: Long, + val highResCount: Long +) + { + companion object { + fun fromList(pigeonVar_list: List): DualCacheStats { + val thumbnailSize = pigeonVar_list[0] as Long + val thumbnailCount = pigeonVar_list[1] as Long + val highResSize = pigeonVar_list[2] as Long + val highResCount = pigeonVar_list[3] as Long + return DualCacheStats(thumbnailSize, thumbnailCount, highResSize, highResCount) + } + } + fun toList(): List { + return listOf( + thumbnailSize, + thumbnailCount, + highResSize, + highResCount, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is DualCacheStats) { + return false + } + if (this === other) { + return true + } + return RemoteImagesPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() } private open class RemoteImagesPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { - return super.readValueOfType(type, buffer) + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as? List)?.let { + NativeCacheStats.fromList(it) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + DualCacheStats.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } } override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { - super.writeValue(stream, value) + when (value) { + is NativeCacheStats -> { + stream.write(129) + writeValue(stream, value.toList()) + } + is DualCacheStats -> { + stream.write(130) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } } } /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface RemoteImageApi { - fun requestImage(url: String, headers: Map, requestId: Long, callback: (Result?>) -> Unit) + fun requestImage(url: String, headers: Map, requestId: Long, isThumbnail: Boolean, callback: (Result?>) -> Unit) fun cancelRequest(requestId: Long) - fun clearCache(callback: (Result) -> Unit) + fun clearThumbnailCache(callback: (Result) -> Unit) + fun clearHighResCache(callback: (Result) -> Unit) + fun getDualCacheStats(callback: (Result) -> Unit) + fun cleanupExpiredHighRes(maxAgeDays: Long, callback: (Result) -> Unit) companion object { /** The codec used by RemoteImageApi. */ @@ -68,7 +191,8 @@ interface RemoteImageApi { val urlArg = args[0] as String val headersArg = args[1] as Map val requestIdArg = args[2] as Long - api.requestImage(urlArg, headersArg, requestIdArg) { result: Result?> -> + val isThumbnailArg = args[3] as Boolean + api.requestImage(urlArg, headersArg, requestIdArg, isThumbnailArg) { result: Result?> -> val error = result.exceptionOrNull() if (error != null) { reply.reply(RemoteImagesPigeonUtils.wrapError(error)) @@ -101,10 +225,66 @@ interface RemoteImageApi { } } run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearCache$separatedMessageChannelSuffix", codec) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearThumbnailCache$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.clearThumbnailCache{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(RemoteImagesPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(RemoteImagesPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearHighResCache$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.clearHighResCache{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(RemoteImagesPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(RemoteImagesPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.getDualCacheStats$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { _, reply -> - api.clearCache{ result: Result -> + api.getDualCacheStats{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(RemoteImagesPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(RemoteImagesPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.cleanupExpiredHighRes$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val maxAgeDaysArg = args[0] as Long + api.cleanupExpiredHighRes(maxAgeDaysArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(RemoteImagesPigeonUtils.wrapError(error)) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index 04a181cd6e618..8e0d8f75d92f5 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -28,6 +28,7 @@ import java.nio.file.Files import java.nio.file.Path import java.nio.file.SimpleFileVisitor import java.nio.file.attribute.BasicFileAttributes +import java.util.Properties import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors @@ -51,6 +52,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { url: String, headers: Map, requestId: Long, + isThumbnail: Boolean, callback: (Result?>) -> Unit ) { val signal = CancellationSignal() @@ -60,6 +62,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { url, headers, signal, + isThumbnail, onSuccess = { buffer -> requestMap.remove(requestId) if (signal.isCanceled) { @@ -88,10 +91,40 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { requestMap.remove(requestId)?.cancellationSignal?.cancel() } - override fun clearCache(callback: (Result) -> Unit) { + override fun clearThumbnailCache(callback: (Result) -> Unit) { CoroutineScope(Dispatchers.IO).launch { try { - ImageFetcherManager.clearCache(callback) + ImageFetcherManager.clearThumbnailCache(callback) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } + } + + override fun clearHighResCache(callback: (Result) -> Unit) { + CoroutineScope(Dispatchers.IO).launch { + try { + ImageFetcherManager.clearHighResCache(callback) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } + } + + override fun getDualCacheStats(callback: (Result) -> Unit) { + CoroutineScope(Dispatchers.IO).launch { + try { + ImageFetcherManager.getDualCacheStats(callback) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } + } + + override fun cleanupExpiredHighRes(maxAgeDays: Long, callback: (Result) -> Unit) { + CoroutineScope(Dispatchers.IO).launch { + try { + ImageFetcherManager.cleanupExpired(maxAgeDays.toInt(), callback) } catch (e: Exception) { callback(Result.failure(e)) } @@ -102,7 +135,9 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { private object ImageFetcherManager { private lateinit var appContext: Context private lateinit var cacheDir: File - private lateinit var fetcher: ImageFetcher + private lateinit var thumbnailFetcher: ImageFetcher + private lateinit var highResFetcher: ImageFetcher + private lateinit var accessTracker: AccessTracker private var initialized = false fun initialize(context: Context) { @@ -111,7 +146,9 @@ private object ImageFetcherManager { if (initialized) return appContext = context.applicationContext cacheDir = context.cacheDir - fetcher = build() + accessTracker = AccessTracker(File(cacheDir, "highres_access.properties")) + thumbnailFetcher = buildFetcher("thumbnails") + highResFetcher = buildFetcher("highres") HttpClientManager.addClientChangedListener(::invalidate) initialized = true } @@ -121,33 +158,123 @@ private object ImageFetcherManager { url: String, headers: Map, signal: CancellationSignal, + isThumbnail: Boolean, onSuccess: (NativeByteBuffer) -> Unit, onFailure: (Exception) -> Unit, ) { - fetcher.fetch(url, headers, signal, onSuccess, onFailure) + val fetcher = if (isThumbnail) thumbnailFetcher else highResFetcher + fetcher.fetch(url, headers, signal, { buffer -> + if (!isThumbnail) { + accessTracker.recordAccess(url) + } + onSuccess(buffer) + }, onFailure) + } + + fun clearThumbnailCache(onCleared: (Result) -> Unit) { + thumbnailFetcher.clearCache(onCleared) + } + + fun clearHighResCache(onCleared: (Result) -> Unit) { + highResFetcher.clearCache { result -> + accessTracker.clear() + onCleared(result) + } } - fun clearCache(onCleared: (Result) -> Unit) { - fetcher.clearCache(onCleared) + fun getDualCacheStats(onStats: (Result) -> Unit) { + thumbnailFetcher.getCacheStats { thumbResult -> + highResFetcher.getCacheStats { highResResult -> + val thumb = thumbResult.getOrNull() ?: NativeCacheStats(0, 0) + val highRes = highResResult.getOrNull() ?: NativeCacheStats(0, 0) + onStats(Result.success(DualCacheStats( + thumbnailSize = thumb.size, + thumbnailCount = thumb.count, + highResSize = highRes.size, + highResCount = highRes.count + ))) + } + } + } + + fun cleanupExpired(maxAgeDays: Int, onComplete: (Result) -> Unit) { + val expiredUrls = accessTracker.getExpiredUrls(maxAgeDays) + if (expiredUrls.isEmpty()) { + return onComplete(Result.success(0)) + } + accessTracker.removeUrls(expiredUrls) + onComplete(Result.success(0)) } private fun invalidate() { synchronized(this) { - val oldFetcher = fetcher - fetcher = build() - oldFetcher.drain() + val oldThumb = thumbnailFetcher + val oldHighRes = highResFetcher + thumbnailFetcher = buildFetcher("thumbnails") + highResFetcher = buildFetcher("highres") + oldThumb.drain() + oldHighRes.drain() } } - private fun build(): ImageFetcher { + private fun buildFetcher(subdir: String): ImageFetcher { + val dir = File(cacheDir, subdir) return if (HttpClientManager.isMtls) { - OkHttpImageFetcher.create(cacheDir) + OkHttpImageFetcher.create(dir) } else { - CronetImageFetcher(appContext, cacheDir) + CronetImageFetcher(appContext, dir) } } } +private class AccessTracker(private val file: File) { + private val lock = Any() + + fun recordAccess(url: String) { + synchronized(lock) { + val props = load() + props.setProperty(url, System.currentTimeMillis().toString()) + save(props) + } + } + + fun getExpiredUrls(maxAgeDays: Int): List { + val cutoff = System.currentTimeMillis() - maxAgeDays.toLong() * 24 * 60 * 60 * 1000 + synchronized(lock) { + val props = load() + return props.stringPropertyNames().filter { url -> + (props.getProperty(url)?.toLongOrNull() ?: 0) < cutoff + } + } + } + + fun removeUrls(urls: List) { + synchronized(lock) { + val props = load() + urls.forEach { props.remove(it) } + save(props) + } + } + + fun clear() { + synchronized(lock) { + save(Properties()) + } + } + + private fun load(): Properties { + val props = Properties() + if (file.exists()) { + file.inputStream().use { props.load(it) } + } + return props + } + + private fun save(props: Properties) { + file.outputStream().use { props.store(it, null) } + } +} + private sealed interface ImageFetcher { fun fetch( url: String, @@ -160,9 +287,11 @@ private sealed interface ImageFetcher { fun drain() fun clearCache(onCleared: (Result) -> Unit) + + fun getCacheStats(onStats: (Result) -> Unit) } -private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher { +private class CronetImageFetcher(context: Context, private val storageDir: File) : ImageFetcher { private val ctx = context private var engine: CronetEngine private val executor = Executors.newFixedThreadPool(4) @@ -170,9 +299,9 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche private var activeCount = 0 private var draining = false private var onCacheCleared: ((Result) -> Unit)? = null - private val storageDir = File(cacheDir, "cronet").apply { mkdirs() } init { + storageDir.mkdirs() engine = build(context) } @@ -243,7 +372,6 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche } else { CoroutineScope(Dispatchers.IO).launch { val result = runCatching { deleteFolderAndGetSize(storageDir.toPath()) } - // Cronet is very good at self-repair, so it shouldn't fail here regardless of clear result engine = build(ctx) synchronized(stateLock) { draining = false } onCacheCleared(result) @@ -261,6 +389,27 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche drain() } + override fun getCacheStats(onStats: (Result) -> Unit) { + CoroutineScope(Dispatchers.IO).launch { + try { + var totalSize = 0L + var totalCount = 0L + if (storageDir.exists()) { + Files.walkFileTree(storageDir.toPath(), object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + totalSize += attrs.size() + totalCount++ + return FileVisitResult.CONTINUE + } + }) + } + onStats(Result.success(NativeCacheStats(totalSize, totalCount))) + } catch (e: Exception) { + onStats(Result.failure(e)) + } + } + } + private class FetchCallback( private val onSuccess: (NativeByteBuffer) -> Unit, private val onFailure: (Exception) -> Unit, @@ -368,12 +517,10 @@ private class OkHttpImageFetcher private constructor( companion object { fun create(cacheDir: File): OkHttpImageFetcher { - val dir = File(cacheDir, "okhttp") - + cacheDir.mkdirs() val client = HttpClientManager.getClient().newBuilder() - .cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES)) + .cache(Cache(cacheDir, CACHE_SIZE_BYTES)) .build() - return OkHttpImageFetcher(client) } } @@ -479,4 +626,17 @@ private class OkHttpImageFetcher private constructor( onCleared(Result.failure(e)) } } + + override fun getCacheStats(onStats: (Result) -> Unit) { + try { + val cache = client.cache + if (cache != null) { + onStats(Result.success(NativeCacheStats(cache.size(), 0))) + } else { + onStats(Result.success(NativeCacheStats(0, 0))) + } + } catch (e: Exception) { + onStats(Result.failure(e)) + } + } } diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift index 73145dbce56b7..1cc2c358eaa71 100644 --- a/mobile/ios/Runner/Core/URLSessionManager.swift +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -2,39 +2,132 @@ import Foundation let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity" -/// Manages a shared URLSession with SSL configuration support. class URLSessionManager: NSObject { static let shared = URLSessionManager() - let session: URLSession - private let configuration = { + let thumbnailSession: URLSession + let highResSession: URLSession + private let thumbnailCacheDir: URL + private let highResCacheDir: URL + private let highResMetadataFile: URL + + var session: URLSession { highResSession } + + private static func createConfig(cacheDir: URL) -> URLSessionConfiguration { let config = URLSessionConfiguration.default - - let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) - .first! - .appendingPathComponent("api", isDirectory: true) try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) - - config.urlCache = URLCache( - memoryCapacity: 0, - diskCapacity: 1024 * 1024 * 1024, - directory: cacheDir - ) - + config.urlCache = URLCache(memoryCapacity: 0, diskCapacity: 1024 * 1024 * 1024, directory: cacheDir) config.httpMaximumConnectionsPerHost = 64 config.timeoutIntervalForRequest = 60 config.timeoutIntervalForResource = 300 - let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"] - return config - }() + } private override init() { - session = URLSession(configuration: configuration, delegate: URLSessionManagerDelegate(), delegateQueue: nil) + let cacheBase = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + thumbnailCacheDir = cacheBase.appendingPathComponent("thumbnails", isDirectory: true) + highResCacheDir = cacheBase.appendingPathComponent("highres", isDirectory: true) + highResMetadataFile = cacheBase.appendingPathComponent("highres_access.plist") + + let delegate = URLSessionManagerDelegate() + thumbnailSession = URLSession(configuration: Self.createConfig(cacheDir: thumbnailCacheDir), delegate: delegate, delegateQueue: nil) + highResSession = URLSession(configuration: Self.createConfig(cacheDir: highResCacheDir), delegate: delegate, delegateQueue: nil) super.init() } + + func recordAccess(for url: String) { + var metadata = loadMetadata() + metadata[url] = Date().timeIntervalSince1970 + saveMetadata(metadata) + } + + func cleanupExpired(maxAgeDays: Int) -> Int64 { + let metadata = loadMetadata() + let cutoff = Date().timeIntervalSince1970 - Double(maxAgeDays * 24 * 60 * 60) + var expiredUrls: [String] = [] + + for (url, timestamp) in metadata { + if timestamp < cutoff { + expiredUrls.append(url) + } + } + + guard !expiredUrls.isEmpty else { return 0 } + + let cache = highResSession.configuration.urlCache! + var bytesCleared: Int64 = 0 + + for urlString in expiredUrls { + if let url = URL(string: urlString) { + let request = URLRequest(url: url) + if let response = cache.cachedResponse(for: request) { + bytesCleared += Int64(response.data.count) + cache.removeCachedResponse(for: request) + } + } + } + + var newMetadata = metadata + for url in expiredUrls { + newMetadata.removeValue(forKey: url) + } + saveMetadata(newMetadata) + + return bytesCleared + } + + private func loadMetadata() -> [String: Double] { + guard let data = try? Data(contentsOf: highResMetadataFile), + let dict = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Double] else { + return [:] + } + return dict + } + + private func saveMetadata(_ metadata: [String: Double]) { + if let data = try? PropertyListSerialization.data(fromPropertyList: metadata, format: .binary, options: 0) { + try? data.write(to: highResMetadataFile) + } + } + + func thumbnailCacheStats() -> (size: Int64, count: Int64) { + return folderStats(thumbnailCacheDir) + } + + func highResCacheStats() -> (size: Int64, count: Int64) { + return folderStats(highResCacheDir) + } + + private func folderStats(_ dir: URL) -> (size: Int64, count: Int64) { + var totalSize: Int64 = 0 + var totalCount: Int64 = 0 + let fm = FileManager.default + guard let enumerator = fm.enumerator(at: dir, includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey]) else { + return (0, 0) + } + for case let fileURL as URL in enumerator { + guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]), + values.isRegularFile == true else { continue } + totalSize += Int64(values.fileSize ?? 0) + totalCount += 1 + } + return (totalSize, totalCount) + } + + func clearThumbnailCache() -> Int64 { + let size = Int64(thumbnailSession.configuration.urlCache?.currentDiskUsage ?? 0) + thumbnailSession.configuration.urlCache?.removeAllCachedResponses() + return size + } + + func clearHighResCache() -> Int64 { + let size = Int64(highResSession.configuration.urlCache?.currentDiskUsage ?? 0) + highResSession.configuration.urlCache?.removeAllCachedResponses() + saveMetadata([:]) + return size + } } class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate { diff --git a/mobile/ios/Runner/Images/RemoteImages.g.swift b/mobile/ios/Runner/Images/RemoteImages.g.swift index fc83b09d4bbbc..27732d8c3e474 100644 --- a/mobile/ios/Runner/Images/RemoteImages.g.swift +++ b/mobile/ios/Runner/Images/RemoteImages.g.swift @@ -46,11 +46,161 @@ private func nilOrValue(_ value: Any?) -> T? { return value as! T? } +func deepEqualsRemoteImages(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case is (Void, Void): + return true + + case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable): + return cleanLhsHashable == cleanRhsHashable + + case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]): + guard cleanLhsArray.count == cleanRhsArray.count else { return false } + for (index, element) in cleanLhsArray.enumerated() { + if !deepEqualsRemoteImages(element, cleanRhsArray[index]) { + return false + } + } + return true + + case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false } + for (key, cleanLhsValue) in cleanLhsDictionary { + guard cleanRhsDictionary.index(forKey: key) != nil else { return false } + if !deepEqualsRemoteImages(cleanLhsValue, cleanRhsDictionary[key]!) { + return false + } + } + return true + + default: + // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue. + return false + } +} + +func deepHashRemoteImages(value: Any?, hasher: inout Hasher) { + if let valueList = value as? [AnyHashable] { + for item in valueList { deepHashRemoteImages(value: item, hasher: &hasher) } + return + } + + if let valueDict = value as? [AnyHashable: AnyHashable] { + for key in valueDict.keys { + hasher.combine(key) + deepHashRemoteImages(value: valueDict[key]!, hasher: &hasher) + } + return + } + + if let hashableValue = value as? AnyHashable { + hasher.combine(hashableValue.hashValue) + } + + return hasher.combine(String(describing: value)) +} + + + +/// Generated class from Pigeon that represents data sent in messages. +struct NativeCacheStats: Hashable { + var size: Int64 + var count: Int64 + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> NativeCacheStats? { + let size = pigeonVar_list[0] as! Int64 + let count = pigeonVar_list[1] as! Int64 + + return NativeCacheStats( + size: size, + count: count + ) + } + func toList() -> [Any?] { + return [ + size, + count, + ] + } + static func == (lhs: NativeCacheStats, rhs: NativeCacheStats) -> Bool { + return deepEqualsRemoteImages(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashRemoteImages(value: toList(), hasher: &hasher) + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct DualCacheStats: Hashable { + var thumbnailSize: Int64 + var thumbnailCount: Int64 + var highResSize: Int64 + var highResCount: Int64 + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> DualCacheStats? { + let thumbnailSize = pigeonVar_list[0] as! Int64 + let thumbnailCount = pigeonVar_list[1] as! Int64 + let highResSize = pigeonVar_list[2] as! Int64 + let highResCount = pigeonVar_list[3] as! Int64 + + return DualCacheStats( + thumbnailSize: thumbnailSize, + thumbnailCount: thumbnailCount, + highResSize: highResSize, + highResCount: highResCount + ) + } + func toList() -> [Any?] { + return [ + thumbnailSize, + thumbnailCount, + highResSize, + highResCount, + ] + } + static func == (lhs: DualCacheStats, rhs: DualCacheStats) -> Bool { + return deepEqualsRemoteImages(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashRemoteImages(value: toList(), hasher: &hasher) + } +} private class RemoteImagesPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return NativeCacheStats.fromList(self.readValue() as! [Any?]) + case 130: + return DualCacheStats.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } } private class RemoteImagesPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? NativeCacheStats { + super.writeByte(129) + super.writeValue(value.toList()) + } else if let value = value as? DualCacheStats { + super.writeByte(130) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } } private class RemoteImagesPigeonCodecReaderWriter: FlutterStandardReaderWriter { @@ -70,9 +220,12 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol RemoteImageApi { - func requestImage(url: String, headers: [String: String], requestId: Int64, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) + func requestImage(url: String, headers: [String: String], requestId: Int64, isThumbnail: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) func cancelRequest(requestId: Int64) throws - func clearCache(completion: @escaping (Result) -> Void) + func clearThumbnailCache(completion: @escaping (Result) -> Void) + func clearHighResCache(completion: @escaping (Result) -> Void) + func getDualCacheStats(completion: @escaping (Result) -> Void) + func cleanupExpiredHighRes(maxAgeDays: Int64, completion: @escaping (Result) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -88,7 +241,8 @@ class RemoteImageApiSetup { let urlArg = args[0] as! String let headersArg = args[1] as! [String: String] let requestIdArg = args[2] as! Int64 - api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg) { result in + let isThumbnailArg = args[3] as! Bool + api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg, isThumbnail: isThumbnailArg) { result in switch result { case .success(let res): reply(wrapResult(res)) @@ -115,10 +269,57 @@ class RemoteImageApiSetup { } else { cancelRequestChannel.setMessageHandler(nil) } - let clearCacheChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearCache\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + let clearThumbnailCacheChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearThumbnailCache\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + clearThumbnailCacheChannel.setMessageHandler { _, reply in + api.clearThumbnailCache { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + clearThumbnailCacheChannel.setMessageHandler(nil) + } + let clearHighResCacheChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearHighResCache\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + clearHighResCacheChannel.setMessageHandler { _, reply in + api.clearHighResCache { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + clearHighResCacheChannel.setMessageHandler(nil) + } + let getDualCacheStatsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.getDualCacheStats\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getDualCacheStatsChannel.setMessageHandler { _, reply in + api.getDualCacheStats { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + getDualCacheStatsChannel.setMessageHandler(nil) + } + let cleanupExpiredHighResChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.cleanupExpiredHighRes\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { - clearCacheChannel.setMessageHandler { _, reply in - api.clearCache { result in + cleanupExpiredHighResChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let maxAgeDaysArg = args[0] as! Int64 + api.cleanupExpiredHighRes(maxAgeDays: maxAgeDaysArg) { result in switch result { case .success(let res): reply(wrapResult(res)) @@ -128,7 +329,7 @@ class RemoteImageApiSetup { } } } else { - clearCacheChannel.setMessageHandler(nil) + cleanupExpiredHighResChannel.setMessageHandler(nil) } } } diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index 56e8938521283..26e3baa5e0f64 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -8,10 +8,14 @@ class RemoteImageRequest { let id: Int64 var isCancelled = false let completion: (Result<[String: Int64]?, any Error>) -> Void + let url: String + let isThumbnail: Bool - init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { + init(id: Int64, task: URLSessionDataTask, url: String, isThumbnail: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { self.id = id self.task = task + self.url = url + self.isThumbnail = isThumbnail self.completion = completion } } @@ -33,18 +37,20 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { kCGImageSourceCreateThumbnailFromImageAlways: true ] as CFDictionary - func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { + func requestImage(url: String, headers: [String : String], requestId: Int64, isThumbnail: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { var urlRequest = URLRequest(url: URL(string: url)!) urlRequest.cachePolicy = .returnCacheDataElseLoad for (key, value) in headers { urlRequest.setValue(value, forHTTPHeaderField: key) } - let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in + let session = isThumbnail ? URLSessionManager.shared.thumbnailSession : URLSessionManager.shared.highResSession + + let task = session.dataTask(with: urlRequest) { data, response, error in Self.handleCompletion(requestId: requestId, data: data, response: response, error: error) } - let request = RemoteImageRequest(id: requestId, task: task, completion: completion) + let request = RemoteImageRequest(id: requestId, task: task, url: url, isThumbnail: isThumbnail, completion: completion) os_unfair_lock_lock(&Self.lock) Self.requests[requestId] = request @@ -76,6 +82,10 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil))) } + if !request.isThumbnail { + URLSessionManager.shared.recordAccess(for: request.url) + } + ImageProcessing.queue.async { ImageProcessing.semaphore.wait() defer { ImageProcessing.semaphore.signal() } @@ -124,12 +134,37 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { request.task?.cancel() } - func clearCache(completion: @escaping (Result) -> Void) { + func clearThumbnailCache(completion: @escaping (Result) -> Void) { + Task { + let size = URLSessionManager.shared.clearThumbnailCache() + completion(.success(size)) + } + } + + func clearHighResCache(completion: @escaping (Result) -> Void) { + Task { + let size = URLSessionManager.shared.clearHighResCache() + completion(.success(size)) + } + } + + func getDualCacheStats(completion: @escaping (Result) -> Void) { + Task { + let thumb = URLSessionManager.shared.thumbnailCacheStats() + let highRes = URLSessionManager.shared.highResCacheStats() + completion(.success(DualCacheStats( + thumbnailSize: thumb.size, + thumbnailCount: thumb.count, + highResSize: highRes.size, + highResCount: highRes.count + ))) + } + } + + func cleanupExpiredHighRes(maxAgeDays: Int64, completion: @escaping (Result) -> Void) { Task { - let cache = URLSessionManager.shared.session.configuration.urlCache! - let cacheSize = Int64(cache.currentDiskUsage) - cache.removeAllCachedResponses() - completion(.success(cacheSize)) + let cleared = URLSessionManager.shared.cleanupExpired(maxAgeDays: Int(maxAgeDays)) + completion(.success(cleared)) } } } diff --git a/mobile/lib/infrastructure/loaders/remote_image_request.dart b/mobile/lib/infrastructure/loaders/remote_image_request.dart index 2da70c3ae10c6..ee9626a67a837 100644 --- a/mobile/lib/infrastructure/loaders/remote_image_request.dart +++ b/mobile/lib/infrastructure/loaders/remote_image_request.dart @@ -3,8 +3,9 @@ part of 'image_request.dart'; class RemoteImageRequest extends ImageRequest { final String uri; final Map headers; + final bool isThumbnail; - RemoteImageRequest({required this.uri, required this.headers}); + RemoteImageRequest({required this.uri, required this.headers, this.isThumbnail = false}); @override Future load(ImageDecoderCallback decode, {double scale = 1.0}) async { @@ -12,7 +13,7 @@ class RemoteImageRequest extends ImageRequest { return null; } - final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId); + final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, isThumbnail: isThumbnail); final frame = switch (info) { {'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length), {'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} => diff --git a/mobile/lib/platform/remote_image_api.g.dart b/mobile/lib/platform/remote_image_api.g.dart index 410db03ece249..a560203677ff1 100644 --- a/mobile/lib/platform/remote_image_api.g.dart +++ b/mobile/lib/platform/remote_image_api.g.dart @@ -14,6 +14,123 @@ PlatformException _createConnectionError(String channelName) { message: 'Unable to establish connection on channel: "$channelName".', ); } +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + return a.length == b.length && a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); + } + return a == b; +} + + +class NativeCacheStats { + NativeCacheStats({ + required this.size, + required this.count, + }); + + int size; + + int count; + + List _toList() { + return [ + size, + count, + ]; + } + + Object encode() { + return _toList(); } + + static NativeCacheStats decode(Object result) { + result as List; + return NativeCacheStats( + size: result[0]! as int, + count: result[1]! as int, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NativeCacheStats || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +class DualCacheStats { + DualCacheStats({ + required this.thumbnailSize, + required this.thumbnailCount, + required this.highResSize, + required this.highResCount, + }); + + int thumbnailSize; + + int thumbnailCount; + + int highResSize; + + int highResCount; + + List _toList() { + return [ + thumbnailSize, + thumbnailCount, + highResSize, + highResCount, + ]; + } + + Object encode() { + return _toList(); } + + static DualCacheStats decode(Object result) { + result as List; + return DualCacheStats( + thumbnailSize: result[0]! as int, + thumbnailCount: result[1]! as int, + highResSize: result[2]! as int, + highResCount: result[3]! as int, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! DualCacheStats || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @@ -22,6 +139,12 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); + } else if (value is NativeCacheStats) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is DualCacheStats) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -30,6 +153,10 @@ class _PigeonCodec extends StandardMessageCodec { @override Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { + case 129: + return NativeCacheStats.decode(readValue(buffer)!); + case 130: + return DualCacheStats.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -41,28 +168,24 @@ class RemoteImageApi { /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. RemoteImageApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) - : pigeonVar_binaryMessenger = binaryMessenger, - pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; final BinaryMessenger? pigeonVar_binaryMessenger; static const MessageCodec pigeonChannelCodec = _PigeonCodec(); final String pigeonVar_messageChannelSuffix; - Future?> requestImage( - String url, { - required Map headers, - required int requestId, - }) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix'; + Future?> requestImage(String url, {required Map headers, required int requestId, required bool isThumbnail, }) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([url, headers, requestId]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final Future pigeonVar_sendFuture = pigeonVar_channel.send([url, headers, requestId, isThumbnail]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -77,15 +200,15 @@ class RemoteImageApi { } Future cancelRequest(int requestId) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest$pigeonVar_messageChannelSuffix'; + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.cancelRequest$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([requestId]); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { @@ -99,16 +222,100 @@ class RemoteImageApi { } } - Future clearCache() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearCache$pigeonVar_messageChannelSuffix'; + Future clearThumbnailCache() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearThumbnailCache$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as int?)!; + } + } + + Future clearHighResCache() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearHighResCache$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as int?)!; + } + } + + Future getDualCacheStats() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.getDualCacheStats$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as DualCacheStats?)!; + } + } + + Future cleanupExpiredHighRes(int maxAgeDays) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.cleanupExpiredHighRes$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([maxAgeDays]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { throw _createConnectionError(pigeonVar_channelName); } else if (pigeonVar_replyList.length > 1) { diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index 20db0cc1e1070..3fa8c3394592d 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -37,7 +37,7 @@ class RemoteImageProvider extends CancellableImageProvider } Stream _codec(RemoteImageProvider key, ImageDecoderCallback decode) { - final request = this.request = RemoteImageRequest(uri: key.url, headers: ApiService.getRequestHeaders()); + final request = this.request = RemoteImageRequest(uri: key.url, headers: ApiService.getRequestHeaders(), isThumbnail: true); return loadRequest(request, decode); } diff --git a/mobile/lib/services/smart_cache.service.dart b/mobile/lib/services/smart_cache.service.dart index 613c6d1a5852c..2e9599d6caed6 100644 --- a/mobile/lib/services/smart_cache.service.dart +++ b/mobile/lib/services/smart_cache.service.dart @@ -1,10 +1,7 @@ -import 'dart:io'; - import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:logging/logging.dart'; -import 'package:path_provider/path_provider.dart'; class SmartCacheService { static final _log = Logger('SmartCacheService'); @@ -27,44 +24,23 @@ class SmartCacheService { if (now - lastCleanup < oneDayMs) return; - await cleanupExpiredHighResCache(); - await Store.put(StoreKey.smartCacheLastCleanup, now); - } - - Future cleanupExpiredHighResCache() async { - try { - final days = Store.get(StoreKey.smartCacheHighResDays, 7); - final cutoff = DateTime.now().subtract(Duration(days: days)); - - final cacheDir = await _getHighResCacheDirectory(); - if (cacheDir == null || !await cacheDir.exists()) return; - - final files = cacheDir.listSync(recursive: true); - var cleanedCount = 0; - var cleanedBytes = 0; - - for (final entity in files) { - if (entity is File) { - final stat = await entity.stat(); - if (stat.accessed.isBefore(cutoff)) { - cleanedBytes += stat.size; - await entity.delete(); - cleanedCount++; - } - } - } - - if (cleanedCount > 0) { - _log.info('Cleaned $cleanedCount expired high-res cache files ($cleanedBytes bytes)'); + final maxAgeDays = Store.get(StoreKey.smartCacheHighResDays, 7); + + if (maxAgeDays > 0) { + try { + final cleared = await remoteImageApi.cleanupExpiredHighRes(maxAgeDays); + _log.info('Cleaned up expired high-res cache: $cleared bytes'); + } catch (e) { + _log.warning('Failed to cleanup high-res cache: $e'); } - } catch (e) { - _log.warning('Failed to cleanup high-res cache: $e'); } + + await Store.put(StoreKey.smartCacheLastCleanup, now); } Future clearHighResCache() async { try { - await RemoteImageCacheManager().emptyCache(); + await remoteImageApi.clearHighResCache(); _log.info('Cleared high-res cache'); } catch (e) { _log.warning('Failed to clear high-res cache: $e'); @@ -73,8 +49,8 @@ class SmartCacheService { Future clearAllCache() async { try { - await RemoteImageCacheManager().emptyCache(); - await RemoteThumbnailCacheManager().emptyCache(); + await remoteImageApi.clearThumbnailCache(); + await remoteImageApi.clearHighResCache(); _log.info('Cleared all cache'); } catch (e) { _log.warning('Failed to clear all cache: $e'); @@ -82,78 +58,26 @@ class SmartCacheService { } Future getCacheStats() async { - var thumbnailSize = 0; - var thumbnailCount = 0; - var highResSize = 0; - var highResCount = 0; - - try { - final thumbnailDir = await _getThumbnailCacheDirectory(); - if (thumbnailDir != null && await thumbnailDir.exists()) { - final stats = await _calculateDirectoryStats(thumbnailDir); - thumbnailSize = stats.size; - thumbnailCount = stats.count; - } - } catch (_) {} - - try { - final highResDir = await _getHighResCacheDirectory(); - if (highResDir != null && await highResDir.exists()) { - final stats = await _calculateDirectoryStats(highResDir); - highResSize = stats.size; - highResCount = stats.count; - } - } catch (_) {} - - return SmartCacheStats( - thumbnailSize: thumbnailSize, - thumbnailCount: thumbnailCount, - highResSize: highResSize, - highResCount: highResCount, - ); - } - - Future<_DirStats> _calculateDirectoryStats(Directory dir) async { - var size = 0; - var count = 0; - - await for (final entity in dir.list(recursive: true)) { - if (entity is File) { - final stat = await entity.stat(); - size += stat.size; - count++; - } - } - - return _DirStats(size, count); - } - - Future _getHighResCacheDirectory() async { - try { - final cacheDir = await getTemporaryDirectory(); - return Directory('${cacheDir.path}/${RemoteImageCacheManager.key}'); - } catch (_) { - return null; - } - } - - Future _getThumbnailCacheDirectory() async { try { - final cacheDir = await getTemporaryDirectory(); - return Directory('${cacheDir.path}/${RemoteThumbnailCacheManager.key}'); - } catch (_) { - return null; + final stats = await remoteImageApi.getDualCacheStats(); + return SmartCacheStats( + thumbnailSize: stats.thumbnailSize, + thumbnailCount: stats.thumbnailCount, + highResSize: stats.highResSize, + highResCount: stats.highResCount, + ); + } catch (e) { + _log.warning('Failed to get cache stats: $e'); + return SmartCacheStats( + thumbnailSize: 0, + thumbnailCount: 0, + highResSize: 0, + highResCount: 0, + ); } } } -class _DirStats { - final int size; - final int count; - - _DirStats(this.size, this.count); -} - class SmartCacheStats { final int thumbnailSize; final int thumbnailCount; diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index d6b516a078b03..b8de27fd44736 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -8,12 +8,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart'; @@ -155,43 +153,6 @@ class AdvancedSettings extends HookConsumerWidget { ); }, ), - ListTile( - title: Text("advanced_settings_clear_image_cache".tr(), style: const TextStyle(fontWeight: FontWeight.w500)), - leading: const Icon(Icons.playlist_remove_rounded), - onTap: () async { - final int clearedBytes; - try { - clearedBytes = await remoteImageApi.clearCache(); - } catch (e) { - context.scaffoldMessenger.showSnackBar( - SnackBar( - duration: const Duration(seconds: 2), - content: Text( - "advanced_settings_clear_image_cache_error".tr(), - style: context.textTheme.bodyLarge?.copyWith(color: context.themeData.colorScheme.error), - ), - ), - ); - return; - } - - if (clearedBytes < 0) { - return; - } - - // iOS always returns a small non-zero value - final clearedMB = clearedBytes < (256 * 1024) ? "0 MiB" : formatHumanReadableBytes(clearedBytes, 2); - context.scaffoldMessenger.showSnackBar( - SnackBar( - duration: const Duration(seconds: 2), - content: Text( - "advanced_settings_clear_image_cache_success".tr(namedArgs: {'size': clearedMB}), - style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), - ), - ), - ); - }, - ), const SizedBox(height: 60), ]; diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/smart_cache_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/smart_cache_setting.dart index 65d869e6aa868..455d03fa448ea 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/smart_cache_setting.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/smart_cache_setting.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; @@ -21,6 +23,7 @@ class SmartCacheSetting extends HookConsumerWidget { final isEnabled = useAppSettingsState(AppSettingsEnum.smartCacheEnabled); final cacheDays = useAppSettingsState(AppSettingsEnum.smartCacheHighResDays); final cacheStats = ref.watch(smartCacheStatsProvider); + final preferRemoteEnabled = Store.get(StoreKey.preferRemoteImage, false); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -30,6 +33,24 @@ class SmartCacheSetting extends HookConsumerWidget { icon: Icons.storage_outlined, subtitle: "smart_cache_description".t(context: context), ), + if (preferRemoteEnabled) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + children: [ + Icon(Icons.info_outline, size: 18, color: context.colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + "smart_cache_prefer_remote_warning".t(context: context), + style: context.textTheme.bodySmall?.copyWith( + color: context.colorScheme.primary, + ), + ), + ), + ], + ), + ), SettingsSwitchListTile( valueNotifier: isEnabled, title: "smart_cache_enabled".t(context: context), @@ -39,14 +60,16 @@ class SmartCacheSetting extends HookConsumerWidget { if (isEnabled.value) ...[ SettingsSliderListTile( valueNotifier: cacheDays, - text: "high_res_cache_duration".t( - context: context, - args: {'days': cacheDays.value.toString()}, - ), + text: cacheDays.value == 0 + ? "high_res_cache_duration_never".t(context: context) + : "high_res_cache_duration".t( + context: context, + args: {'days': cacheDays.value.toString()}, + ), maxValue: 30, - minValue: 1, - noDivisons: 29, - label: "${cacheDays.value}", + minValue: 0, + noDivisons: 30, + label: cacheDays.value == 0 ? "Never" : "${cacheDays.value}", onChangeEnd: (_) => ref.invalidate(appSettingsServiceProvider), ), _buildCacheStats(context, ref, cacheStats), diff --git a/mobile/pigeon/remote_image_api.dart b/mobile/pigeon/remote_image_api.dart index 749deb828e4e3..3e4adc1c2c5ca 100644 --- a/mobile/pigeon/remote_image_api.dart +++ b/mobile/pigeon/remote_image_api.dart @@ -12,6 +12,27 @@ import 'package:pigeon/pigeon.dart'; dartPackageName: 'immich_mobile', ), ) +class NativeCacheStats { + final int size; + final int count; + + NativeCacheStats({required this.size, required this.count}); +} + +class DualCacheStats { + final int thumbnailSize; + final int thumbnailCount; + final int highResSize; + final int highResCount; + + DualCacheStats({ + required this.thumbnailSize, + required this.thumbnailCount, + required this.highResSize, + required this.highResCount, + }); +} + @HostApi() abstract class RemoteImageApi { @async @@ -19,10 +40,20 @@ abstract class RemoteImageApi { String url, { required Map headers, required int requestId, + required bool isThumbnail, }); void cancelRequest(int requestId); @async - int clearCache(); + int clearThumbnailCache(); + + @async + int clearHighResCache(); + + @async + DualCacheStats getDualCacheStats(); + + @async + int cleanupExpiredHighRes(int maxAgeDays); } diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 0b54dfc53e9f9..390655c99aaff 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -126,6 +126,7 @@ flutter: uses-material-design: true assets: - assets/ + - assets/i18n/ fonts: - family: GoogleSans fonts: From a0cf2fedbae7bbf7dcb406ccbfa677020bd7c242 Mon Sep 17 00:00:00 2001 From: Dario Date: Sat, 14 Feb 2026 11:52:07 +0400 Subject: [PATCH 3/5] reduce spacing, changed description --- i18n/en.json | 4 ++-- .../settings/asset_viewer_settings/smart_cache_setting.dart | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 0132571910097..0d0cdb408d712 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2160,9 +2160,9 @@ "slideshow_repeat_description": "Loop back to beginning when slideshow ends", "slideshow_settings": "Slideshow settings", "smart_cache": "Smart Cache", - "smart_cache_description": "Cache thumbnails persistently and high-res images temporarily", + "smart_cache_description": "", "smart_cache_enabled": "Enable Smart Cache", - "smart_cache_enabled_description": "Keep low-res thumbnails cached, download high-res on demand", + "smart_cache_enabled_description": "Downloads and caches high-resolution images when opened, removing them after a set time to save space. Thumbnails remain cached for fast browsing.", "smart_cache_prefer_remote_warning": "'Prefer Remote Images' is enabled in Advanced Settings, which may affect cache behavior", "sort_albums_by": "Sort albums by...", "sort_created": "Date created", diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/smart_cache_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/smart_cache_setting.dart index 455d03fa448ea..fd9c9fbd07c7f 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/smart_cache_setting.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/smart_cache_setting.dart @@ -24,6 +24,7 @@ class SmartCacheSetting extends HookConsumerWidget { final cacheDays = useAppSettingsState(AppSettingsEnum.smartCacheHighResDays); final cacheStats = ref.watch(smartCacheStatsProvider); final preferRemoteEnabled = Store.get(StoreKey.preferRemoteImage, false); + final description = "smart_cache_description".t(context: context); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -31,7 +32,7 @@ class SmartCacheSetting extends HookConsumerWidget { SettingGroupTitle( title: "smart_cache".t(context: context), icon: Icons.storage_outlined, - subtitle: "smart_cache_description".t(context: context), + subtitle: description.isEmpty ? null : description, ), if (preferRemoteEnabled) Padding( From a56abdf27d95f10d08943b87d1d718e1fdc86d51 Mon Sep 17 00:00:00 2001 From: Dario Date: Sat, 14 Feb 2026 11:53:54 +0400 Subject: [PATCH 4/5] removed smartcache_description. Not used --- i18n/en.json | 1 - .../settings/asset_viewer_settings/smart_cache_setting.dart | 2 -- 2 files changed, 3 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 0d0cdb408d712..6c04e896174b2 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2160,7 +2160,6 @@ "slideshow_repeat_description": "Loop back to beginning when slideshow ends", "slideshow_settings": "Slideshow settings", "smart_cache": "Smart Cache", - "smart_cache_description": "", "smart_cache_enabled": "Enable Smart Cache", "smart_cache_enabled_description": "Downloads and caches high-resolution images when opened, removing them after a set time to save space. Thumbnails remain cached for fast browsing.", "smart_cache_prefer_remote_warning": "'Prefer Remote Images' is enabled in Advanced Settings, which may affect cache behavior", diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/smart_cache_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/smart_cache_setting.dart index fd9c9fbd07c7f..1512b75eb8af9 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/smart_cache_setting.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/smart_cache_setting.dart @@ -24,7 +24,6 @@ class SmartCacheSetting extends HookConsumerWidget { final cacheDays = useAppSettingsState(AppSettingsEnum.smartCacheHighResDays); final cacheStats = ref.watch(smartCacheStatsProvider); final preferRemoteEnabled = Store.get(StoreKey.preferRemoteImage, false); - final description = "smart_cache_description".t(context: context); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -32,7 +31,6 @@ class SmartCacheSetting extends HookConsumerWidget { SettingGroupTitle( title: "smart_cache".t(context: context), icon: Icons.storage_outlined, - subtitle: description.isEmpty ? null : description, ), if (preferRemoteEnabled) Padding( From fbb80043d80138b6b7c4039182a99771e7ee638d Mon Sep 17 00:00:00 2001 From: Dario Date: Sat, 14 Feb 2026 13:06:15 +0400 Subject: [PATCH 5/5] track last file access as cache criteria --- .../immich/images/RemoteImagesImpl.kt | 35 +++++++++++++++++-- .../ios/Runner/Core/URLSessionManager.swift | 21 ++++++++++- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index 8e0d8f75d92f5..e2716fa7ca224 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -202,8 +202,11 @@ private object ImageFetcherManager { if (expiredUrls.isEmpty()) { return onComplete(Result.success(0)) } - accessTracker.removeUrls(expiredUrls) - onComplete(Result.success(0)) + + highResFetcher.removeFromCache(expiredUrls) { bytesCleared -> + accessTracker.removeUrls(expiredUrls) + onComplete(Result.success(bytesCleared)) + } } private fun invalidate() { @@ -289,6 +292,8 @@ private sealed interface ImageFetcher { fun clearCache(onCleared: (Result) -> Unit) fun getCacheStats(onStats: (Result) -> Unit) + + fun removeFromCache(urls: List, onComplete: (Long) -> Unit) } private class CronetImageFetcher(context: Context, private val storageDir: File) : ImageFetcher { @@ -410,6 +415,10 @@ private class CronetImageFetcher(context: Context, private val storageDir: File) } } + override fun removeFromCache(urls: List, onComplete: (Long) -> Unit) { + onComplete(0) + } + private class FetchCallback( private val onSuccess: (NativeByteBuffer) -> Unit, private val onFailure: (Exception) -> Unit, @@ -639,4 +648,26 @@ private class OkHttpImageFetcher private constructor( onStats(Result.failure(e)) } } + + override fun removeFromCache(urls: List, onComplete: (Long) -> Unit) { + CoroutineScope(Dispatchers.IO).launch { + var bytesCleared = 0L + val cache = client.cache + if (cache != null) { + val urlSet = urls.toSet() + try { + val iterator = cache.urls() + while (iterator.hasNext()) { + val cachedUrl = iterator.next() + if (cachedUrl in urlSet) { + iterator.remove() + } + } + bytesCleared = urls.size.toLong() + } catch (e: Exception) { + } + } + onComplete(bytesCleared) + } + } } diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift index 1cc2c358eaa71..40dcb95f5d47c 100644 --- a/mobile/ios/Runner/Core/URLSessionManager.swift +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -12,7 +12,26 @@ class URLSessionManager: NSObject { private let highResMetadataFile: URL var session: URLSession { highResSession } - + + func fetchHighRes( + url: URL, + headers: [String: String] = [:], + completion: @escaping (Data?, URLResponse?, Error?) -> Void + ) { + var request = URLRequest(url: url) + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + + let task = highResSession.dataTask(with: request) { [weak self] data, response, error in + if let data = data, error == nil, let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) { + self?.recordAccess(for: url.absoluteString) + } + completion(data, response, error) + } + task.resume() + } + private static func createConfig(cacheDir: URL) -> URLSessionConfiguration { let config = URLSessionConfiguration.default try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)