clear cache button

This commit is contained in:
mertalev
2026-01-23 02:06:20 -05:00
parent 15c9eb12a8
commit 3555021173
9 changed files with 229 additions and 23 deletions
+3
View File
@@ -449,6 +449,9 @@
"admin_password": "Admin Password", "admin_password": "Admin Password",
"administration": "Administration", "administration": "Administration",
"advanced": "Advanced", "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_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_enable_alternate_media_filter_title": "[EXPERIMENTAL] Use alternate device album sync filter",
"advanced_settings_log_level_title": "Log level: {level}", "advanced_settings_log_level_title": "Log level: {level}",
@@ -49,6 +49,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() {
interface RemoteImageApi { interface RemoteImageApi {
fun requestImage(url: String, headers: Map<String, String>, requestId: Long, callback: (Result<Map<String, Long>?>) -> Unit) fun requestImage(url: String, headers: Map<String, String>, requestId: Long, callback: (Result<Map<String, Long>?>) -> Unit)
fun cancelRequest(requestId: Long) fun cancelRequest(requestId: Long)
fun clearCache(callback: (Result<Long>) -> Unit)
companion object { companion object {
/** The codec used by RemoteImageApi. */ /** The codec used by RemoteImageApi. */
@@ -99,6 +100,24 @@ interface RemoteImageApi {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearCache$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.clearCache{ result: Result<Long> ->
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)
}
}
} }
} }
} }
@@ -8,6 +8,7 @@ import app.alextran.immich.INITIAL_BUFFER_SIZE
import app.alextran.immich.NativeBuffer import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeByteBuffer import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.core.SSLConfig import app.alextran.immich.core.SSLConfig
import kotlinx.coroutines.*
import okhttp3.Cache import okhttp3.Cache
import okhttp3.Call import okhttp3.Call
import okhttp3.Callback import okhttp3.Callback
@@ -24,6 +25,11 @@ import java.io.EOFException
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer 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.ConcurrentHashMap
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -90,6 +96,16 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
override fun cancelRequest(requestId: Long) { override fun cancelRequest(requestId: Long) {
requestMap.remove(requestId)?.cancellationSignal?.cancel() requestMap.remove(requestId)?.cancellationSignal?.cancel()
} }
override fun clearCache(callback: (Result<Long>) -> Unit) {
CoroutineScope(Dispatchers.IO).launch {
try {
ImageFetcherManager.clearCache(callback)
} catch (e: Exception) {
callback(Result.failure(e))
}
}
}
} }
private object ImageFetcherManager { private object ImageFetcherManager {
@@ -120,6 +136,10 @@ private object ImageFetcherManager {
fetcher.fetch(url, headers, signal, onSuccess, onFailure) fetcher.fetch(url, headers, signal, onSuccess, onFailure)
} }
fun clearCache(onCleared: (Result<Long>) -> Unit) {
fetcher.clearCache(onCleared)
}
private fun invalidate() { private fun invalidate() {
synchronized(this) { synchronized(this) {
val oldFetcher = fetcher val oldFetcher = fetcher
@@ -151,25 +171,22 @@ private sealed interface ImageFetcher {
) )
fun drain() fun drain()
fun clearCache(onCleared: (Result<Long>) -> Unit)
} }
private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher { 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 executor = Executors.newFixedThreadPool(4)
private val stateLock = Any() private val stateLock = Any()
private var activeCount = 0 private var activeCount = 0
private var draining = false private var draining = false
private var onCacheCleared: ((Result<Long>) -> Unit)? = null
private val storageDir = File(cacheDir, "cronet").apply { mkdirs() }
init { init {
val storageDir = File(cacheDir, "cronet").apply { mkdirs() } engine = build(context)
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()
} }
override fun fetch( override fun fetch(
@@ -195,29 +212,68 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
request.start() 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() { private fun onComplete() {
val shouldShutdown = synchronized(stateLock) { val didDrain = synchronized(stateLock) {
activeCount-- activeCount--
draining && activeCount == 0 draining && activeCount == 0
} }
if (shouldShutdown) { if (didDrain) {
engine.shutdown() onDrained()
executor.shutdown()
} }
} }
override fun drain() { override fun drain() {
val shouldShutdown = synchronized(stateLock) { val didDrain = synchronized(stateLock) {
if (draining) return if (draining) return
draining = true draining = true
activeCount == 0 activeCount == 0
} }
if (shouldShutdown) { if (didDrain) {
engine.shutdown() onDrained()
executor.shutdown()
} }
} }
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<Long>) -> Unit) {
synchronized(stateLock) {
if (onCacheCleared != null) {
return onCleared(Result.success(-1))
}
onCacheCleared = onCleared
}
drain()
}
private class FetchCallback( private class FetchCallback(
private val onSuccess: (NativeByteBuffer) -> Unit, private val onSuccess: (NativeByteBuffer) -> Unit,
private val onFailure: (Exception) -> Unit, private val onFailure: (Exception) -> Unit,
@@ -283,6 +339,27 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
onComplete() onComplete()
} }
} }
suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
var totalSize = 0L
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
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( private class OkHttpImageFetcher private constructor(
@@ -412,6 +489,7 @@ private class OkHttpImageFetcher private constructor(
buffer.free() buffer.free()
onFailure(e) onFailure(e)
} }
onComplete()
} }
} }
} }
@@ -429,4 +507,14 @@ private class OkHttpImageFetcher private constructor(
client.cache?.close() client.cache?.close()
} }
} }
override fun clearCache(onCleared: (Result<Long>) -> Unit) {
try {
val size = client.cache!!.size()
client.cache!!.evictAll()
onCleared(Result.success(size))
} catch (e: Exception) {
onCleared(Result.failure(e))
}
}
} }
@@ -72,6 +72,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
protocol RemoteImageApi { 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, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func cancelRequest(requestId: Int64) throws func cancelRequest(requestId: Int64) throws
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
} }
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -114,5 +115,20 @@ class RemoteImageApiSetup {
} else { } else {
cancelRequestChannel.setMessageHandler(nil) 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)
}
} }
} }
@@ -20,18 +20,19 @@ class RemoteImageRequest {
class RemoteImageApiImpl: NSObject, RemoteImageApi { class RemoteImageApiImpl: NSObject, RemoteImageApi {
private static let delegate = RemoteImageApiDelegate() private static let delegate = RemoteImageApiDelegate()
static let cacheDir = FileManager.default.temporaryDirectory.appendingPathComponent(
"thumbnails", isDirectory: true)
static let session = { static let session = {
let config = URLSessionConfiguration.default let config = URLSessionConfiguration.default
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"] config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"]
let thumbnailPath = FileManager.default.temporaryDirectory.appendingPathComponent("thumbnails", isDirectory: true) try! FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
try! FileManager.default.createDirectory(at: thumbnailPath, withIntermediateDirectories: true)
config.urlCache = URLCache( config.urlCache = URLCache(
memoryCapacity: 0, memoryCapacity: 0,
diskCapacity: 1 << 30, diskCapacity: 1 << 30,
directory: thumbnailPath directory: cacheDir
) )
config.httpMaximumConnectionsPerHost = 64 config.httpMaximumConnectionsPerHost = 16
return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
}() }()
@@ -51,6 +52,15 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
func cancelRequest(requestId: Int64) { func cancelRequest(requestId: Int64) {
Self.delegate.cancel(requestId: requestId) Self.delegate.cancel(requestId: requestId)
} }
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {
Task {
let cache = Self.session.configuration.urlCache!
let cacheSize = Int64(cache.currentDiskUsage)
cache.removeAllCachedResponses()
completion(.success(cacheSize))
}
}
} }
class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate { class RemoteImageApiDelegate: NSObject, URLSessionDataDelegate {
+28
View File
@@ -98,4 +98,32 @@ class RemoteImageApi {
return; return;
} }
} }
Future<int> clearCache() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.clearCache$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
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?)!;
}
}
} }
+1 -1
View File
@@ -19,7 +19,7 @@ String formatBytes(int bytes) {
String formatHumanReadableBytes(int bytes, int decimals) { String formatHumanReadableBytes(int bytes, int decimals) {
if (bytes <= 0) return "0 B"; 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(); var i = (log(bytes) / log(1024)).floor();
return '${(bytes / pow(1024, i)).toStringAsFixed(decimals)} ${suffixes[i]}'; return '${(bytes / pow(1024, i)).toStringAsFixed(decimals)} ${suffixes[i]}';
} }
@@ -8,10 +8,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.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/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart';
import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.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); return SettingsSubPageScaffold(settings: advancedSettings);
+3
View File
@@ -22,4 +22,7 @@ abstract class RemoteImageApi {
}); });
void cancelRequest(int requestId); void cancelRequest(int requestId);
@async
int clearCache();
} }