diff --git a/i18n/en.json b/i18n/en.json index a2da7b783b..5d36daf570 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -449,6 +449,9 @@ "admin_password": "Admin Password", "administration": "Administration", "advanced": "Advanced", + "advanced_settings_clear_image_cache": "Clear Image Cache", + "advanced_settings_clear_image_cache_error": "Failed to clear image cache", + "advanced_settings_clear_image_cache_success": "Successfully cleared {size}", "advanced_settings_enable_alternate_media_filter_subtitle": "Use this option to filter media during sync based on alternate criteria. Only try this if you have issues with the app detecting all albums.", "advanced_settings_enable_alternate_media_filter_title": "[EXPERIMENTAL] Use alternate device album sync filter", "advanced_settings_log_level_title": "Log level: {level}", 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 ec60530f86..0e3cf19657 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 @@ -49,6 +49,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() { interface RemoteImageApi { fun requestImage(url: String, headers: Map, requestId: Long, callback: (Result?>) -> Unit) fun cancelRequest(requestId: Long) + fun clearCache(callback: (Result) -> Unit) companion object { /** The codec used by RemoteImageApi. */ @@ -99,6 +100,24 @@ interface RemoteImageApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearCache$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.clearCache{ 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) + } + } } } } 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 bf80523743..33bcb94d62 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 @@ -8,6 +8,7 @@ import app.alextran.immich.INITIAL_BUFFER_SIZE import app.alextran.immich.NativeBuffer import app.alextran.immich.NativeByteBuffer import app.alextran.immich.core.SSLConfig +import kotlinx.coroutines.* import okhttp3.Cache import okhttp3.Call import okhttp3.Callback @@ -24,6 +25,11 @@ import java.io.EOFException import java.io.File import java.io.IOException import java.nio.ByteBuffer +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors import java.util.concurrent.TimeUnit @@ -90,6 +96,16 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { override fun cancelRequest(requestId: Long) { requestMap.remove(requestId)?.cancellationSignal?.cancel() } + + override fun clearCache(callback: (Result) -> Unit) { + CoroutineScope(Dispatchers.IO).launch { + try { + ImageFetcherManager.clearCache(callback) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } + } } private object ImageFetcherManager { @@ -120,6 +136,10 @@ private object ImageFetcherManager { fetcher.fetch(url, headers, signal, onSuccess, onFailure) } + fun clearCache(onCleared: (Result) -> Unit) { + fetcher.clearCache(onCleared) + } + private fun invalidate() { synchronized(this) { val oldFetcher = fetcher @@ -151,25 +171,22 @@ private sealed interface ImageFetcher { ) fun drain() + + fun clearCache(onCleared: (Result) -> Unit) } private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher { - private val engine: CronetEngine + private val ctx = context + private var engine: CronetEngine private val executor = Executors.newFixedThreadPool(4) private val stateLock = Any() private var activeCount = 0 private var draining = false + private var onCacheCleared: ((Result) -> Unit)? = null + private val storageDir = File(cacheDir, "cronet").apply { mkdirs() } init { - val storageDir = File(cacheDir, "cronet").apply { mkdirs() } - engine = CronetEngine.Builder(context) - .enableHttp2(true) - .enableQuic(true) - .enableBrotli(true) - .setStoragePath(storageDir.absolutePath) - .setUserAgent(USER_AGENT) - .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES) - .build() + engine = build(context) } override fun fetch( @@ -195,29 +212,68 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche request.start() } + private fun build(ctx: Context): CronetEngine { + return CronetEngine.Builder(ctx) + .enableHttp2(true) + .enableQuic(true) + .enableBrotli(true) + .setStoragePath(storageDir.absolutePath) + .setUserAgent(USER_AGENT) + .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES) + .build() + } + private fun onComplete() { - val shouldShutdown = synchronized(stateLock) { + val didDrain = synchronized(stateLock) { activeCount-- draining && activeCount == 0 } - if (shouldShutdown) { - engine.shutdown() - executor.shutdown() + if (didDrain) { + onDrained() } } override fun drain() { - val shouldShutdown = synchronized(stateLock) { + val didDrain = synchronized(stateLock) { if (draining) return draining = true activeCount == 0 } - if (shouldShutdown) { - engine.shutdown() - executor.shutdown() + if (didDrain) { + onDrained() } } + private fun onDrained() { + engine.shutdown() + val onCacheCleared = synchronized(stateLock) { + val onCacheCleared = onCacheCleared + this.onCacheCleared = null + onCacheCleared + } + if (onCacheCleared == null) { + executor.shutdown() + } 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) + } + } + } + + override fun clearCache(onCleared: (Result) -> Unit) { + synchronized(stateLock) { + if (onCacheCleared != null) { + return onCleared(Result.success(-1)) + } + onCacheCleared = onCleared + } + drain() + } + private class FetchCallback( private val onSuccess: (NativeByteBuffer) -> Unit, private val onFailure: (Exception) -> Unit, @@ -283,6 +339,27 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche onComplete() } } + + suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) { + var totalSize = 0L + + Files.walkFileTree(root, object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + totalSize += attrs.size() + Files.delete(file) + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { + if (dir != root) { + Files.delete(dir) + } + return FileVisitResult.CONTINUE + } + }) + + totalSize + } } private class OkHttpImageFetcher private constructor( @@ -412,6 +489,7 @@ private class OkHttpImageFetcher private constructor( buffer.free() onFailure(e) } + onComplete() } } } @@ -429,4 +507,14 @@ private class OkHttpImageFetcher private constructor( client.cache?.close() } } + + override fun clearCache(onCleared: (Result) -> Unit) { + try { + val size = client.cache!!.size() + client.cache!!.evictAll() + onCleared(Result.success(size)) + } catch (e: Exception) { + onCleared(Result.failure(e)) + } + } } diff --git a/mobile/ios/Runner/Images/RemoteImages.g.swift b/mobile/ios/Runner/Images/RemoteImages.g.swift index e3e76dd58f..fc83b09d4b 100644 --- a/mobile/ios/Runner/Images/RemoteImages.g.swift +++ b/mobile/ios/Runner/Images/RemoteImages.g.swift @@ -72,6 +72,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable protocol RemoteImageApi { func requestImage(url: String, headers: [String: String], requestId: Int64, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) func cancelRequest(requestId: Int64) throws + func clearCache(completion: @escaping (Result) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -114,5 +115,20 @@ class RemoteImageApiSetup { } else { cancelRequestChannel.setMessageHandler(nil) } + let clearCacheChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearCache\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + clearCacheChannel.setMessageHandler { _, reply in + api.clearCache { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + clearCacheChannel.setMessageHandler(nil) + } } } diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index 0e1da6c234..e654fb6ede 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -20,18 +20,19 @@ class RemoteImageRequest { class RemoteImageApiImpl: NSObject, RemoteImageApi { private static let delegate = RemoteImageApiDelegate() + static let cacheDir = FileManager.default.temporaryDirectory.appendingPathComponent( + "thumbnails", isDirectory: true) static let session = { let config = URLSessionConfiguration.default let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"] - let thumbnailPath = FileManager.default.temporaryDirectory.appendingPathComponent("thumbnails", isDirectory: true) - try! FileManager.default.createDirectory(at: thumbnailPath, withIntermediateDirectories: true) + try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) config.urlCache = URLCache( memoryCapacity: 0, diskCapacity: 1 << 30, - directory: thumbnailPath + directory: cacheDir ) - config.httpMaximumConnectionsPerHost = 64 + config.httpMaximumConnectionsPerHost = 16 return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) }() @@ -51,6 +52,15 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { func cancelRequest(requestId: Int64) { Self.delegate.cancel(requestId: requestId) } + + func clearCache(completion: @escaping (Result) -> Void) { + Task { + let cache = Self.session.configuration.urlCache! + let cacheSize = Int64(cache.currentDiskUsage) + cache.removeAllCachedResponses() + completion(.success(cacheSize)) + } + } } class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate { diff --git a/mobile/lib/platform/remote_image_api.g.dart b/mobile/lib/platform/remote_image_api.g.dart index 5a36e6773c..410db03ece 100644 --- a/mobile/lib/platform/remote_image_api.g.dart +++ b/mobile/lib/platform/remote_image_api.g.dart @@ -98,4 +98,32 @@ class RemoteImageApi { return; } } + + Future clearCache() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearCache$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?)!; + } + } } diff --git a/mobile/lib/utils/bytes_units.dart b/mobile/lib/utils/bytes_units.dart index 3a73e5b320..66de6493ab 100644 --- a/mobile/lib/utils/bytes_units.dart +++ b/mobile/lib/utils/bytes_units.dart @@ -19,7 +19,7 @@ String formatBytes(int bytes) { String formatHumanReadableBytes(int bytes, int decimals) { if (bytes <= 0) return "0 B"; - const suffixes = ["B", "KB", "MB", "GB", "TB"]; + const suffixes = ["B", "KiB", "MiB", "GiB", "TiB"]; var i = (log(bytes) / log(1024)).floor(); return '${(bytes / pow(1024, i)).toStringAsFixed(decimals)} ${suffixes[i]}'; } diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index aee28c9449..c64023331b 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -8,10 +8,12 @@ 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'; @@ -153,6 +155,43 @@ 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), + ), + ), + ); + }, + ), ]; return SettingsSubPageScaffold(settings: advancedSettings); diff --git a/mobile/pigeon/remote_image_api.dart b/mobile/pigeon/remote_image_api.dart index 0d544f262e..749deb828e 100644 --- a/mobile/pigeon/remote_image_api.dart +++ b/mobile/pigeon/remote_image_api.dart @@ -22,4 +22,7 @@ abstract class RemoteImageApi { }); void cancelRequest(int requestId); + + @async + int clearCache(); }