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

This commit is contained in:
HapeLee
2026-05-13 01:28:59 +08:00
parent 56b28009e3
commit c6cb67ae94
8 changed files with 515 additions and 64 deletions
@@ -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)
if (loadOnlyWifi && !appCtx.isWifiConnect) {
throw IOException("WiFi not available, loadOnlyWifi enabled")
}
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("图片解密失败")
SourceResult(
source = ImageSource(
source = Buffer().write(decodedBytes),
context = options.context
),
mimeType = null,
dataSource = if (url.startsWith(
"data:",
true
)
) DataSource.MEMORY else DataSource.NETWORK
)
}
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)
}
}
}
@@ -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)
}
}
}
@@ -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")
@@ -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
@@ -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"
}
@@ -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"
}
@@ -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 }
)
}
}
@@ -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
}
}