Преглед изворни кода

修复封面缓存没有一天是命中导致的启动时都会向网络请求封面的问题

HapeLee пре 6 месеци
родитељ
комит
c6cb67ae94

+ 105 - 51
app/src/main/java/io/legado/app/help/coil/CoverFetcher.kt

@@ -13,98 +13,152 @@ import coil.request.Options
 import io.legado.app.data.entities.BaseSource
 import io.legado.app.model.ReadManga
 import io.legado.app.utils.ImageUtils
+import io.legado.app.utils.isWifiConnect
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
+import okhttp3.CacheControl
 import okhttp3.Call
 import okhttp3.OkHttpClient
 import okhttp3.Request
 import okio.Buffer
+import splitties.init.appCtx
 import java.io.IOException
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.TimeUnit
 
 class CoverFetcher(
     private val url: String,
     private val options: Options,
-    private val callFactory: Call.Factory
+    private val callFactory: Call.Factory,
+    private val loadOnlyWifi: Boolean,
 ) : Fetcher {
 
+    companion object {
+        /** Tag applied to cover requests so [cacheControlInterceptor] can identify them. */
+        val COVER_REQUEST_TAG = Unit
+
+        private const val FAIL_CACHE_TTL_MS = 5 * 60 * 1000L // 5 minutes
+
+        /** URL -> failure timestamp. Prevents infinite retries for permanently broken URLs. */
+        private val failCache = ConcurrentHashMap<String, Long>()
+
+        fun isFailed(url: String): Boolean {
+            val ts = failCache[url] ?: return false
+            if (System.currentTimeMillis() - ts > FAIL_CACHE_TTL_MS) {
+                failCache.remove(url)
+                return false
+            }
+            return true
+        }
+
+        fun markFailed(url: String) {
+            failCache[url] = System.currentTimeMillis()
+        }
+
+        fun clearFailCache() {
+            failCache.clear()
+        }
+    }
+
     override suspend fun fetch(): FetchResult {
         val source = options.tags.tag<BaseSource>()
         val isManga = options.parameters.value("manga") as? Boolean == true
 
-        val bytes = if (url.startsWith("data:", true)) {
+        if (url.startsWith("data:", true)) {
             val base64Data = url.substringAfter("base64,", "")
             if (base64Data.isEmpty()) {
                 throw IOException("Invalid data URI")
             }
-            Base64.decode(base64Data, Base64.DEFAULT)
-        } else {
-            val request = Request.Builder()
-                .url(url)
-                .headers(options.headers)
-                .build()
-
-            val response = withContext(Dispatchers.IO) {
-                callFactory.newCall(request).execute()
-            }
-            val body = response.body
-
-            if (!response.isSuccessful) {
-                body.close()
-                throw HttpException(response)
-            }
-
-            val b = body.use { body ->
-                body.bytes()
-            }
-            b
-        }
-
-        if (ImageUtils.skipDecode(source, !isManga)) {
+            val bytes = Base64.decode(base64Data, Base64.DEFAULT)
             return SourceResult(
                 source = ImageSource(source = Buffer().write(bytes), context = options.context),
                 mimeType = null,
-                dataSource = if (url.startsWith(
-                        "data:",
-                        true
-                    )
-                ) DataSource.MEMORY else DataSource.NETWORK
+                dataSource = DataSource.MEMORY
             )
         }
 
-        return withContext(Dispatchers.IO) {
-            val decodedBytes = if (isManga) {
-                ImageUtils.decode(url, bytes, false, source, ReadManga.book)
-            } else {
-                ImageUtils.decode(url, bytes, true, source)
-            } ?: throw IOException("图片解密失败")
+        if (loadOnlyWifi && !appCtx.isWifiConnect) {
+            throw IOException("WiFi not available, loadOnlyWifi enabled")
+        }
 
-            SourceResult(
-                source = ImageSource(
-                    source = Buffer().write(decodedBytes),
-                    context = options.context
-                ),
-                mimeType = null,
-                dataSource = if (url.startsWith(
-                        "data:",
-                        true
-                    )
-                ) DataSource.MEMORY else DataSource.NETWORK
-            )
+        if (isFailed(url)) {
+            throw IOException("URL previously failed, skipping: $url")
+        }
+
+        // Try OkHttp HTTP cache: FORCE_CACHE serves from cache or returns 504 on miss
+        val cacheRequest = Request.Builder()
+            .url(url)
+            .headers(options.headers)
+            .cacheControl(CacheControl.FORCE_CACHE)
+            .build()
+
+        var fromCache = false
+        val rawBytes = try {
+            withContext(Dispatchers.IO) {
+                val cacheResponse = callFactory.newCall(cacheRequest).execute()
+                if (cacheResponse.isSuccessful) {
+                    fromCache = true
+                    cacheResponse.body.use { it.bytes() }
+                } else {
+                    cacheResponse.close()
+                    // Cache miss, fetch from network
+                    val networkRequest = Request.Builder()
+                        .url(url)
+                        .headers(options.headers)
+                        .tag(COVER_REQUEST_TAG)
+                        .cacheControl(
+                            CacheControl.Builder()
+                                .maxAge(30, TimeUnit.DAYS)
+                                .build()
+                        )
+                        .build()
+                    val networkResponse = callFactory.newCall(networkRequest).execute()
+                    val body = networkResponse.body
+                    if (!networkResponse.isSuccessful) {
+                        body.close()
+                        throw HttpException(networkResponse)
+                    }
+                    body.use { it.bytes() }
+                }
+            }
+        } catch (e: Exception) {
+            markFailed(url)
+            throw e
+        }
+
+        // Decrypt if needed (applies to both cached and network bytes)
+        val decodedBytes = if (ImageUtils.skipDecode(source, !isManga)) {
+            rawBytes
+        } else {
+            withContext(Dispatchers.IO) {
+                if (isManga) {
+                    ImageUtils.decode(url, rawBytes, false, source, ReadManga.book)
+                } else {
+                    ImageUtils.decode(url, rawBytes, true, source)
+                }
+            } ?: throw IOException("图片解密失败")
         }
+
+        return SourceResult(
+            source = ImageSource(source = Buffer().write(decodedBytes), context = options.context),
+            mimeType = null,
+            dataSource = if (fromCache) DataSource.DISK else DataSource.NETWORK
+        )
     }
 
     class Factory(
         private val okHttpClient: OkHttpClient,
-        private val okHttpClientManga: OkHttpClient
+        private val okHttpClientManga: OkHttpClient,
     ) : Fetcher.Factory<Uri> {
         override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
             val scheme = data.scheme
             if (scheme != "http" && scheme != "https" && scheme != "data") return null
 
             val isManga = options.parameters.value("manga") as? Boolean == true
+            val loadOnlyWifi = options.parameters.value("loadOnlyWifi") as? Boolean == true
             val client = if (isManga) okHttpClientManga else okHttpClient
 
-            return CoverFetcher(data.toString(), options, client)
+            return CoverFetcher(data.toString(), options, client, loadOnlyWifi)
         }
     }
 }

+ 33 - 4
app/src/main/java/io/legado/app/help/coil/CoverInterceptor.kt

@@ -5,12 +5,32 @@ import coil.request.ImageResult
 import io.legado.app.data.entities.BaseSource
 import io.legado.app.help.source.SourceHelp
 import io.legado.app.model.analyzeRule.AnalyzeUrl
-import io.legado.app.utils.isWifiConnect
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
-import java.io.IOException
 
 class CoverInterceptor : Interceptor {
+
+    companion object {
+        private const val RESOLVED_URL_CACHE_MAX_SIZE = 100
+
+        /** LRU cache: "$url|$sourceOrigin" -> Pair(resolvedUrl, headers) */
+        private val resolvedUrlCache = object : LinkedHashMap<String, Pair<String, Map<String, String>>>(
+            16, 0.75f, true
+        ) {
+            override fun removeEldestEntry(
+                eldest: MutableMap.MutableEntry<String, Pair<String, Map<String, String>>>?
+            ): Boolean {
+                return size > RESOLVED_URL_CACHE_MAX_SIZE
+            }
+        }
+
+        fun clearResolvedUrlCache() {
+            synchronized(resolvedUrlCache) {
+                resolvedUrlCache.clear()
+            }
+        }
+    }
+
     override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
         val request = chain.request
         val data = request.data
@@ -19,8 +39,17 @@ class CoverInterceptor : Interceptor {
             val sourceOrigin = request.parameters.value("sourceOrigin") as? String
             val source = sourceOrigin?.let { SourceHelp.getSource(it) }
 
-            val (finalUrl, headers) = withContext(Dispatchers.IO) {
+            val cacheKey = "$data|$sourceOrigin"
+            val cached = synchronized(resolvedUrlCache) {
+                resolvedUrlCache[cacheKey]
+            }
+
+            val (finalUrl, headers) = cached ?: withContext(Dispatchers.IO) {
                 AnalyzeUrl(data, source = source).getUrlAndHeaders()
+            }.also { result ->
+                synchronized(resolvedUrlCache) {
+                    resolvedUrlCache[cacheKey] = result
+                }
             }
 
             val newRequest = request.newBuilder()
@@ -37,4 +66,4 @@ class CoverInterceptor : Interceptor {
         }
         return chain.proceed(request)
     }
-}
+}

+ 9 - 8
app/src/main/java/io/legado/app/ui/book/read/BaseReadBookActivity.kt

@@ -10,7 +10,7 @@ import android.view.WindowInsets
 import android.view.WindowManager
 import androidx.appcompat.app.AppCompatActivity
 import androidx.core.view.WindowInsetsCompat
-import androidx.core.view.doOnDetach
+import androidx.core.view.doOnAttach
 import androidx.core.view.isVisible
 import androidx.core.view.updateLayoutParams
 import com.google.android.material.datepicker.MaterialDatePicker
@@ -92,7 +92,7 @@ abstract class BaseReadBookActivity :
         setOrientation()
         upLayoutInDisplayCutoutMode()
         super.onCreate(savedInstanceState)
-        binding.navigationBar.doOnDetach {
+        binding.navigationBar.doOnAttach {
             binding.navigationBar.setOnApplyWindowInsetsListenerCompat { view, windowInsets ->
                 val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
                 view.updateLayoutParams {
@@ -243,13 +243,14 @@ abstract class BaseReadBookActivity :
 
     fun upNavigationBarColor() {
         upNavigationBar()
-        when {
-            binding.readMenu.isVisible -> window.setNavigationBarColorAuto(themeColor(com.google.android.material.R.attr.colorSurfaceContainer))
-            binding.searchMenu.bottomMenuVisible -> window.setNavigationBarColorAuto(themeColor(com.google.android.material.R.attr.colorSurface))
-            bottomDialog > 0 -> window.setNavigationBarColorAuto(themeColor(com.google.android.material.R.attr.colorSurface))
-            //!AppConfig.immNavigationBar -> super.upNavigationBarColor()
-            else -> window.setNavigationBarColorAuto(ReadBookConfig.bgMeanColor)
+        val navColor = when {
+            binding.readMenu.isVisible -> themeColor(com.google.android.material.R.attr.colorSurfaceContainer)
+            binding.searchMenu.bottomMenuVisible -> themeColor(com.google.android.material.R.attr.colorSurface)
+            bottomDialog > 0 -> themeColor(com.google.android.material.R.attr.colorSurface)
+            else -> ReadBookConfig.bgMeanColor
         }
+        window.setNavigationBarColorAuto(navColor)
+        binding.navigationBar.setBackgroundColor(navColor)
     }
 
     @SuppressLint("RtlHardcoded")

+ 6 - 1
app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt

@@ -25,7 +25,8 @@ fun ConfigNavScreen(
     onNavigateToRead: () -> Unit,
     onNavigateToCover: () -> Unit,
     onNavigateToTheme: () -> Unit,
-    onNavigateToBackup: () -> Unit
+    onNavigateToBackup: () -> Unit,
+    onNavigateToDownloadCache: () -> Unit
 ) {
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
 
@@ -66,6 +67,10 @@ fun ConfigNavScreen(
                         title = stringResource(R.string.cover_config),
                         onClick = onNavigateToCover
                     )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.download_cache_config),
+                        onClick = onNavigateToDownloadCache
+                    )
                     ClickableSettingItem(
                         title = stringResource(R.string.backup_restore),
                         onClick = onNavigateToBackup

+ 1 - 0
app/src/main/java/io/legado/app/ui/config/ConfigTag.kt

@@ -7,5 +7,6 @@ object ConfigTag {
     const val BACKUP_CONFIG = "backupConfig"
     const val COVER_CONFIG = "coverConfig"
     const val READ_CONFIG = "readConfig"
+    const val DOWNLOAD_CACHE_CONFIG = "downloadCacheConfig"
 
 }

+ 57 - 0
app/src/main/java/io/legado/app/ui/config/downloadCacheConfig/DownloadCacheConfig.kt

@@ -0,0 +1,57 @@
+package io.legado.app.ui.config.downloadCacheConfig
+
+import io.legado.app.BuildConfig
+import io.legado.app.constant.PreferKey
+import io.legado.app.ui.config.prefDelegate
+
+object DownloadCacheConfig {
+
+    var bitmapCacheSize by prefDelegate(
+        PreferKey.bitmapCacheSize,
+        50
+    )
+
+    var imageRetainNum by prefDelegate(
+        PreferKey.imageRetainNum,
+        0
+    )
+
+    var preDownloadNum by prefDelegate(
+        PreferKey.preDownloadNum,
+        10
+    )
+
+    var threadCount by prefDelegate(
+        PreferKey.threadCount,
+        16
+    )
+
+    var cacheBookThreadCount by prefDelegate(
+        PreferKey.cacheBookThreadCount,
+        16
+    )
+
+    private var _userAgent by prefDelegate(
+        PreferKey.userAgent,
+        ""
+    )
+
+    var userAgent: String
+        get() = _userAgent.ifBlank {
+            defaultUserAgent
+        }
+        set(value) {
+            _userAgent = value
+        }
+
+    var cronetEnable by prefDelegate(
+        PreferKey.cronet,
+        false
+    )
+
+    private val defaultUserAgent: String
+        get() = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
+                "AppleWebKit/537.36 (KHTML, like Gecko) " +
+                "Chrome/${BuildConfig.Cronet_Main_Version} Safari/537.36"
+
+}

+ 228 - 0
app/src/main/java/io/legado/app/ui/config/downloadCacheConfig/DownloadCacheConfigScreen.kt

@@ -0,0 +1,228 @@
+package io.legado.app.ui.config.downloadCacheConfig
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.model.CacheBook
+import io.legado.app.ui.theme.adaptiveContentPadding
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.SplicedColumnGroup
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
+import io.legado.app.ui.widget.components.settingItem.InputSettingItem
+import io.legado.app.ui.widget.components.settingItem.SliderSettingItem
+import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import org.koin.androidx.compose.koinViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DownloadCacheConfigScreen(
+    onBackClick: () -> Unit,
+    viewModel: DownloadCacheConfigViewModel = koinViewModel()
+) {
+    val context = LocalContext.current
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+
+    var showClearBookCacheDialog by remember { mutableStateOf(false) }
+    var showShrinkDbDialog by remember { mutableStateOf(false) }
+    var showClearCoverCacheDialog by remember { mutableStateOf(false) }
+    var showClearMangaCacheDialog by remember { mutableStateOf(false) }
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.download_cache_config),
+                scrollBehavior = scrollBehavior,
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBackClick)
+                }
+            )
+        }
+    ) { paddingValues ->
+        LazyColumn(
+            modifier = Modifier.fillMaxSize(),
+            contentPadding = adaptiveContentPadding(
+                top = paddingValues.calculateTopPadding(),
+                bottom = 120.dp
+            )
+        ) {
+            item {
+                SplicedColumnGroup(title = stringResource(R.string.http_cache)) {
+                    ClickableSettingItem(
+                        title = stringResource(R.string.cover_cache),
+                        description = stringResource(
+                            R.string.cache_size_mb,
+                            viewModel.coverCacheSize
+                        ),
+                        onClick = { showClearCoverCacheDialog = true }
+                    )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.manga_cache),
+                        description = stringResource(
+                            R.string.cache_size_mb,
+                            viewModel.mangaCacheSize
+                        ),
+                        onClick = { showClearMangaCacheDialog = true }
+                    )
+                }
+
+                SplicedColumnGroup(title = stringResource(R.string.download_setting)) {
+                    SliderSettingItem(
+                        title = stringResource(R.string.threads_num_title),
+                        description = stringResource(R.string.threads_num_summary),
+                        value = DownloadCacheConfig.threadCount.toFloat(),
+                        defaultValue = 8f,
+                        valueRange = 1f..256f,
+                        onValueChange = { DownloadCacheConfig.threadCount = it.toInt() }
+                    )
+
+                    SliderSettingItem(
+                        title = stringResource(R.string.cache_book_threads_num_title),
+                        description = stringResource(R.string.cache_book_threads_num_summary),
+                        value = DownloadCacheConfig.cacheBookThreadCount
+                            .coerceIn(1, CacheBook.maxDownloadConcurrency)
+                            .toFloat(),
+                        defaultValue = CacheBook.maxDownloadConcurrency.toFloat(),
+                        valueRange = 1f..CacheBook.maxDownloadConcurrency.toFloat(),
+                        onValueChange = {
+                            DownloadCacheConfig.cacheBookThreadCount =
+                                it.toInt().coerceIn(1, CacheBook.maxDownloadConcurrency)
+                        }
+                    )
+
+                    SliderSettingItem(
+                        title = stringResource(R.string.pre_download),
+                        description = stringResource(
+                            R.string.pre_download_s,
+                            DownloadCacheConfig.preDownloadNum
+                        ),
+                        value = DownloadCacheConfig.preDownloadNum.toFloat(),
+                        defaultValue = 10f,
+                        valueRange = 0f..100f,
+                        onValueChange = { DownloadCacheConfig.preDownloadNum = it.toInt() }
+                    )
+                }
+
+                SplicedColumnGroup(title = stringResource(R.string.image_cache)) {
+                    SliderSettingItem(
+                        title = stringResource(R.string.bitmap_cache_size),
+                        description = stringResource(
+                            R.string.bitmap_cache_size_summary,
+                            DownloadCacheConfig.bitmapCacheSize
+                        ),
+                        value = DownloadCacheConfig.bitmapCacheSize.toFloat(),
+                        defaultValue = 32f,
+                        valueRange = 1f..2047f,
+                        onValueChange = {
+                            viewModel.updateBitmapCacheSize(it.toInt())
+                        }
+                    )
+
+                    SliderSettingItem(
+                        title = stringResource(R.string.image_retain_number),
+                        description = stringResource(
+                            R.string.image_retain_number_summary,
+                            DownloadCacheConfig.imageRetainNum
+                        ),
+                        value = DownloadCacheConfig.imageRetainNum.toFloat(),
+                        defaultValue = 10f,
+                        valueRange = 0f..100f,
+                        onValueChange = { DownloadCacheConfig.imageRetainNum = it.toInt() }
+                    )
+                }
+
+                SplicedColumnGroup(title = stringResource(R.string.network)) {
+                    InputSettingItem(
+                        title = stringResource(R.string.user_agent),
+                        value = DownloadCacheConfig.userAgent,
+                        onConfirm = { viewModel.saveUserAgent(it) }
+                    )
+
+                    SwitchSettingItem(
+                        title = "Cronet",
+                        description = stringResource(R.string.pref_cronet_summary),
+                        checked = DownloadCacheConfig.cronetEnable,
+                        onCheckedChange = { DownloadCacheConfig.cronetEnable = it }
+                    )
+                }
+
+                SplicedColumnGroup(title = stringResource(R.string.other_setting)) {
+                    ClickableSettingItem(
+                        title = stringResource(R.string.clear_cache),
+                        description = stringResource(R.string.clear_cache_summary),
+                        onClick = { showClearBookCacheDialog = true }
+                    )
+
+                    ClickableSettingItem(
+                        title = stringResource(R.string.shrink_database),
+                        description = stringResource(R.string.shrink_database_summary),
+                        onClick = { showShrinkDbDialog = true }
+                    )
+                }
+            }
+        }
+
+        AppAlertDialog(
+            show = showClearBookCacheDialog,
+            onDismissRequest = { showClearBookCacheDialog = false },
+            title = stringResource(R.string.clear_cache),
+            text = stringResource(R.string.sure_del),
+            onConfirm = {
+                viewModel.clearBookCache(context)
+                showClearBookCacheDialog = false
+            },
+            onDismiss = { showClearBookCacheDialog = false }
+        )
+
+        AppAlertDialog(
+            show = showClearCoverCacheDialog,
+            onDismissRequest = { showClearCoverCacheDialog = false },
+            title = stringResource(R.string.cover_cache),
+            text = stringResource(R.string.sure_del),
+            onConfirm = {
+                viewModel.clearCoverCache()
+                showClearCoverCacheDialog = false
+            },
+            onDismiss = { showClearCoverCacheDialog = false }
+        )
+
+        AppAlertDialog(
+            show = showClearMangaCacheDialog,
+            onDismissRequest = { showClearMangaCacheDialog = false },
+            title = stringResource(R.string.manga_cache),
+            text = stringResource(R.string.sure_del),
+            onConfirm = {
+                viewModel.clearMangaCache()
+                showClearMangaCacheDialog = false
+            },
+            onDismiss = { showClearMangaCacheDialog = false }
+        )
+
+        AppAlertDialog(
+            show = showShrinkDbDialog,
+            onDismissRequest = { showShrinkDbDialog = false },
+            title = stringResource(R.string.shrink_database),
+            text = stringResource(R.string.sure),
+            onConfirm = {
+                viewModel.shrinkDatabase()
+                showShrinkDbDialog = false
+            },
+            onDismiss = { showShrinkDbDialog = false }
+        )
+    }
+}

+ 76 - 0
app/src/main/java/io/legado/app/ui/config/downloadCacheConfig/DownloadCacheConfigViewModel.kt

@@ -0,0 +1,76 @@
+package io.legado.app.ui.config.downloadCacheConfig
+
+import android.content.Context
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableDoubleStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.domain.usecase.ClearBookCacheUseCase
+import io.legado.app.domain.usecase.ShrinkDatabaseUseCase
+import io.legado.app.help.config.AppConfig
+import io.legado.app.help.http.HttpCacheType
+import io.legado.app.help.http.clearHttpCache
+import io.legado.app.help.http.getHttpCacheSize
+import io.legado.app.model.ImageProvider
+import io.legado.app.utils.FileUtils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class DownloadCacheConfigViewModel(
+    private val clearBookCacheUseCase: ClearBookCacheUseCase,
+    private val shrinkDatabaseUseCase: ShrinkDatabaseUseCase
+) : ViewModel() {
+
+    var coverCacheSize by mutableDoubleStateOf(0.0)
+    var mangaCacheSize by mutableDoubleStateOf(0.0)
+
+    init {
+        loadCacheSizes()
+    }
+
+    fun loadCacheSizes() {
+        viewModelScope.launch(Dispatchers.IO) {
+            coverCacheSize = getHttpCacheSize(HttpCacheType.COVER) / (1024.0 * 1024.0)
+            mangaCacheSize = getHttpCacheSize(HttpCacheType.MANGA) / (1024.0 * 1024.0)
+        }
+    }
+
+    fun clearCoverCache() {
+        viewModelScope.launch(Dispatchers.IO) {
+            clearHttpCache(HttpCacheType.COVER)
+            coverCacheSize = 0.0
+        }
+    }
+
+    fun clearMangaCache() {
+        viewModelScope.launch(Dispatchers.IO) {
+            clearHttpCache(HttpCacheType.MANGA)
+            mangaCacheSize = 0.0
+        }
+    }
+
+    fun clearBookCache(context: Context) {
+        viewModelScope.launch(Dispatchers.IO) {
+            clearBookCacheUseCase.executeAll()
+            FileUtils.delete(context.cacheDir.absolutePath)
+            context.externalCacheDir?.deleteRecursively()
+        }
+    }
+
+    fun shrinkDatabase() {
+        viewModelScope.launch(Dispatchers.IO) {
+            shrinkDatabaseUseCase.execute()
+        }
+    }
+
+    fun updateBitmapCacheSize(size: Int) {
+        AppConfig.bitmapCacheSize = size
+        ImageProvider.bitmapLruCache.resize(ImageProvider.cacheSize)
+    }
+
+    fun saveUserAgent(input: String) {
+        DownloadCacheConfig.userAgent = input
+        AppConfig.userAgent = DownloadCacheConfig.userAgent
+    }
+}