修复封面缓存没有一天是命中导致的启动时都会向网络请求封面的问题
This commit is contained in:
8 files changed
+513
-62
No files matched your search
@@ -13,98 +13,152 @@ import coil.request.Options
|
|||||||
import io.legado.app.data.entities.BaseSource
|
import io.legado.app.data.entities.BaseSource
|
||||||
import io.legado.app.model.ReadManga
|
import io.legado.app.model.ReadManga
|
||||||
import io.legado.app.utils.ImageUtils
|
import io.legado.app.utils.ImageUtils
|
||||||
|
import io.legado.app.utils.isWifiConnect
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.CacheControl
|
||||||
import okhttp3.Call
|
import okhttp3.Call
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
|
import splitties.init.appCtx
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class CoverFetcher(
|
class CoverFetcher(
|
||||||
private val url: String,
|
private val url: String,
|
||||||
private val options: Options,
|
private val options: Options,
|
||||||
private val callFactory: Call.Factory
|
private val callFactory: Call.Factory,
|
||||||
|
private val loadOnlyWifi: Boolean,
|
||||||
) : Fetcher {
|
) : 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 {
|
override suspend fun fetch(): FetchResult {
|
||||||
val source = options.tags.tag<BaseSource>()
|
val source = options.tags.tag<BaseSource>()
|
||||||
val isManga = options.parameters.value("manga") as? Boolean == true
|
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,", "")
|
val base64Data = url.substringAfter("base64,", "")
|
||||||
if (base64Data.isEmpty()) {
|
if (base64Data.isEmpty()) {
|
||||||
throw IOException("Invalid data URI")
|
throw IOException("Invalid data URI")
|
||||||
}
|
}
|
||||||
Base64.decode(base64Data, Base64.DEFAULT)
|
val bytes = 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)) {
|
|
||||||
return SourceResult(
|
return SourceResult(
|
||||||
source = ImageSource(source = Buffer().write(bytes), context = options.context),
|
source = ImageSource(source = Buffer().write(bytes), context = options.context),
|
||||||
mimeType = null,
|
mimeType = null,
|
||||||
dataSource = if (url.startsWith(
|
dataSource = DataSource.MEMORY
|
||||||
"data:",
|
|
||||||
true
|
|
||||||
)
|
|
||||||
) DataSource.MEMORY else DataSource.NETWORK
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return withContext(Dispatchers.IO) {
|
if (loadOnlyWifi && !appCtx.isWifiConnect) {
|
||||||
val decodedBytes = if (isManga) {
|
throw IOException("WiFi not available, loadOnlyWifi enabled")
|
||||||
ImageUtils.decode(url, bytes, false, source, ReadManga.book)
|
}
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
ImageUtils.decode(url, bytes, true, source)
|
cacheResponse.close()
|
||||||
} ?: throw IOException("图片解密失败")
|
// Cache miss, fetch from network
|
||||||
|
val networkRequest = Request.Builder()
|
||||||
SourceResult(
|
.url(url)
|
||||||
source = ImageSource(
|
.headers(options.headers)
|
||||||
source = Buffer().write(decodedBytes),
|
.tag(COVER_REQUEST_TAG)
|
||||||
context = options.context
|
.cacheControl(
|
||||||
),
|
CacheControl.Builder()
|
||||||
mimeType = null,
|
.maxAge(30, TimeUnit.DAYS)
|
||||||
dataSource = if (url.startsWith(
|
.build()
|
||||||
"data:",
|
|
||||||
true
|
|
||||||
)
|
|
||||||
) DataSource.MEMORY else DataSource.NETWORK
|
|
||||||
)
|
)
|
||||||
|
.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(
|
class Factory(
|
||||||
private val okHttpClient: OkHttpClient,
|
private val okHttpClient: OkHttpClient,
|
||||||
private val okHttpClientManga: OkHttpClient
|
private val okHttpClientManga: OkHttpClient,
|
||||||
) : Fetcher.Factory<Uri> {
|
) : Fetcher.Factory<Uri> {
|
||||||
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
|
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
|
||||||
val scheme = data.scheme
|
val scheme = data.scheme
|
||||||
if (scheme != "http" && scheme != "https" && scheme != "data") return null
|
if (scheme != "http" && scheme != "https" && scheme != "data") return null
|
||||||
|
|
||||||
val isManga = options.parameters.value("manga") as? Boolean == true
|
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
|
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.data.entities.BaseSource
|
||||||
import io.legado.app.help.source.SourceHelp
|
import io.legado.app.help.source.SourceHelp
|
||||||
import io.legado.app.model.analyzeRule.AnalyzeUrl
|
import io.legado.app.model.analyzeRule.AnalyzeUrl
|
||||||
import io.legado.app.utils.isWifiConnect
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
class CoverInterceptor : Interceptor {
|
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 {
|
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||||
val request = chain.request
|
val request = chain.request
|
||||||
val data = request.data
|
val data = request.data
|
||||||
@@ -19,8 +39,17 @@ class CoverInterceptor : Interceptor {
|
|||||||
val sourceOrigin = request.parameters.value("sourceOrigin") as? String
|
val sourceOrigin = request.parameters.value("sourceOrigin") as? String
|
||||||
val source = sourceOrigin?.let { SourceHelp.getSource(it) }
|
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()
|
AnalyzeUrl(data, source = source).getUrlAndHeaders()
|
||||||
|
}.also { result ->
|
||||||
|
synchronized(resolvedUrlCache) {
|
||||||
|
resolvedUrlCache[cacheKey] = result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val newRequest = request.newBuilder()
|
val newRequest = request.newBuilder()
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import android.view.WindowInsets
|
|||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.doOnDetach
|
import androidx.core.view.doOnAttach
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updateLayoutParams
|
import androidx.core.view.updateLayoutParams
|
||||||
import com.google.android.material.datepicker.MaterialDatePicker
|
import com.google.android.material.datepicker.MaterialDatePicker
|
||||||
@@ -92,7 +92,7 @@ abstract class BaseReadBookActivity :
|
|||||||
setOrientation()
|
setOrientation()
|
||||||
upLayoutInDisplayCutoutMode()
|
upLayoutInDisplayCutoutMode()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
binding.navigationBar.doOnDetach {
|
binding.navigationBar.doOnAttach {
|
||||||
binding.navigationBar.setOnApplyWindowInsetsListenerCompat { view, windowInsets ->
|
binding.navigationBar.setOnApplyWindowInsetsListenerCompat { view, windowInsets ->
|
||||||
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
view.updateLayoutParams {
|
view.updateLayoutParams {
|
||||||
@@ -243,13 +243,14 @@ abstract class BaseReadBookActivity :
|
|||||||
|
|
||||||
fun upNavigationBarColor() {
|
fun upNavigationBarColor() {
|
||||||
upNavigationBar()
|
upNavigationBar()
|
||||||
when {
|
val navColor = when {
|
||||||
binding.readMenu.isVisible -> window.setNavigationBarColorAuto(themeColor(com.google.android.material.R.attr.colorSurfaceContainer))
|
binding.readMenu.isVisible -> themeColor(com.google.android.material.R.attr.colorSurfaceContainer)
|
||||||
binding.searchMenu.bottomMenuVisible -> window.setNavigationBarColorAuto(themeColor(com.google.android.material.R.attr.colorSurface))
|
binding.searchMenu.bottomMenuVisible -> themeColor(com.google.android.material.R.attr.colorSurface)
|
||||||
bottomDialog > 0 -> window.setNavigationBarColorAuto(themeColor(com.google.android.material.R.attr.colorSurface))
|
bottomDialog > 0 -> themeColor(com.google.android.material.R.attr.colorSurface)
|
||||||
//!AppConfig.immNavigationBar -> super.upNavigationBarColor()
|
else -> ReadBookConfig.bgMeanColor
|
||||||
else -> window.setNavigationBarColorAuto(ReadBookConfig.bgMeanColor)
|
|
||||||
}
|
}
|
||||||
|
window.setNavigationBarColorAuto(navColor)
|
||||||
|
binding.navigationBar.setBackgroundColor(navColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("RtlHardcoded")
|
@SuppressLint("RtlHardcoded")
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ fun ConfigNavScreen(
|
|||||||
onNavigateToRead: () -> Unit,
|
onNavigateToRead: () -> Unit,
|
||||||
onNavigateToCover: () -> Unit,
|
onNavigateToCover: () -> Unit,
|
||||||
onNavigateToTheme: () -> Unit,
|
onNavigateToTheme: () -> Unit,
|
||||||
onNavigateToBackup: () -> Unit
|
onNavigateToBackup: () -> Unit,
|
||||||
|
onNavigateToDownloadCache: () -> Unit
|
||||||
) {
|
) {
|
||||||
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
||||||
|
|
||||||
@@ -66,6 +67,10 @@ fun ConfigNavScreen(
|
|||||||
title = stringResource(R.string.cover_config),
|
title = stringResource(R.string.cover_config),
|
||||||
onClick = onNavigateToCover
|
onClick = onNavigateToCover
|
||||||
)
|
)
|
||||||
|
ClickableSettingItem(
|
||||||
|
title = stringResource(R.string.download_cache_config),
|
||||||
|
onClick = onNavigateToDownloadCache
|
||||||
|
)
|
||||||
ClickableSettingItem(
|
ClickableSettingItem(
|
||||||
title = stringResource(R.string.backup_restore),
|
title = stringResource(R.string.backup_restore),
|
||||||
onClick = onNavigateToBackup
|
onClick = onNavigateToBackup
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ object ConfigTag {
|
|||||||
const val BACKUP_CONFIG = "backupConfig"
|
const val BACKUP_CONFIG = "backupConfig"
|
||||||
const val COVER_CONFIG = "coverConfig"
|
const val COVER_CONFIG = "coverConfig"
|
||||||
const val READ_CONFIG = "readConfig"
|
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"
|
||||||
|
|
||||||
|
}
|
||||||
+228
@@ -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,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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user