Disk cache for favicons

This commit is contained in:
Koitharu
2025-07-30 15:52:09 +03:00
parent dd77926dcb
commit 47f0bbee17
9 changed files with 139 additions and 46 deletions

View File

@@ -42,18 +42,21 @@ import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.util.AcraScreenLogger
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageFetcher
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.FaviconCache
import org.koitharu.kotatsu.local.data.LocalStorageCache
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PageCache
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
@@ -101,7 +104,7 @@ interface AppModule {
fun provideCoil(
@LocalizedAppContext context: Context,
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
mangaRepositoryFactory: MangaRepository.Factory,
faviconFetcherFactory: FaviconFetcher.Factory,
imageProxyInterceptor: ImageProxyInterceptor,
pageFetcherFactory: MangaPageFetcher.Factory,
coverRestoreInterceptor: CoverRestoreInterceptor,
@@ -138,7 +141,7 @@ interface AppModule {
add(SvgDecoder.Factory())
add(CbzFetcher.Factory())
add(AvifImageDecoder.Factory())
add(FaviconFetcher.Factory(mangaRepositoryFactory))
add(faviconFetcherFactory)
add(MangaPageKeyer())
add(pageFetcherFactory)
add(imageProxyInterceptor)
@@ -195,5 +198,29 @@ interface AppModule {
fun provideWorkManager(
@ApplicationContext context: Context,
): WorkManager = WorkManager.getInstance(context)
@Provides
@Singleton
@PageCache
fun providePageCache(
@ApplicationContext context: Context,
) = LocalStorageCache(
context = context,
dir = CacheDir.PAGES,
defaultSize = FileSize.MEGABYTES.convert(200, FileSize.BYTES),
minSize = FileSize.MEGABYTES.convert(20, FileSize.BYTES),
)
@Provides
@Singleton
@FaviconCache
fun provideFaviconCache(
@ApplicationContext context: Context,
) = LocalStorageCache(
context = context,
dir = CacheDir.FAVICONS,
defaultSize = FileSize.MEGABYTES.convert(8, FileSize.BYTES),
minSize = FileSize.MEGABYTES.convert(2, FileSize.BYTES),
)
}
}

View File

@@ -10,15 +10,20 @@ import coil3.ColorImage
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.size.pxOrElse
import coil3.toAndroidUri
import coil3.toBitmap
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.runInterruptible
import okio.FileSystem
import okio.IOException
import okio.Path.Companion.toOkioPath
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource
@@ -26,8 +31,16 @@ import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.ext.fetch
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
import org.koitharu.kotatsu.local.data.FaviconCache
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageCache
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File
import javax.inject.Inject
import kotlin.coroutines.coroutineContext
import coil3.Uri as CoilUri
@@ -36,6 +49,7 @@ class FaviconFetcher(
private val options: Options,
private val imageLoader: ImageLoader,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val localStorageCache: LocalStorageCache,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
@@ -61,6 +75,16 @@ class FaviconFetcher(
options.size.width.pxOrElse { FALLBACK_SIZE },
options.size.height.pxOrElse { FALLBACK_SIZE },
)
val cacheKey = options.diskCacheKey ?: "${repository.source.name}_$sizePx"
if (options.diskCachePolicy.readEnabled) {
localStorageCache[cacheKey]?.let { file ->
return SourceFetchResult(
source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM),
mimeType = MimeTypes.probeMimeType(file)?.toString(),
dataSource = DataSource.DISK,
)
}
}
var favicons = repository.getFavicons()
var lastError: Exception? = null
while (favicons.isNotEmpty()) {
@@ -69,7 +93,11 @@ class FaviconFetcher(
try {
val result = imageLoader.fetch(icon.url, options)
if (result != null) {
return result
return if (options.diskCachePolicy.writeEnabled) {
writeToCache(cacheKey, result)
} else {
result
}
} else {
favicons -= icon
}
@@ -97,8 +125,39 @@ class FaviconFetcher(
)
}
class Factory(
private suspend fun writeToCache(key: String, result: FetchResult): FetchResult = runCatchingCancellable {
when (result) {
is ImageFetchResult -> {
if (result.dataSource == DataSource.NETWORK) {
localStorageCache.set(key, result.image.toBitmap()).asFetchResult()
} else {
result
}
}
is SourceFetchResult -> {
if (result.dataSource == DataSource.NETWORK) {
result.source.source().use {
localStorageCache.set(key, it, result.mimeType?.toMimeTypeOrNull()).asFetchResult()
}
} else {
result
}
}
}
}.onFailure {
it.printStackTraceDebug()
}.getOrDefault(result)
private fun File.asFetchResult() = SourceFetchResult(
source = ImageSource(toOkioPath(), FileSystem.SYSTEM),
mimeType = MimeTypes.probeMimeType(this)?.toString(),
dataSource = DataSource.DISK,
)
class Factory @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
@FaviconCache private val faviconCache: LocalStorageCache,
) : Fetcher.Factory<CoilUri> {
override fun create(
@@ -106,7 +165,7 @@ class FaviconFetcher(
options: Options,
imageLoader: ImageLoader
): Fetcher? = if (data.scheme == URI_SCHEME_FAVICON) {
FaviconFetcher(data.toAndroidUri(), options, imageLoader, mangaRepositoryFactory)
FaviconFetcher(data.toAndroidUri(), options, imageLoader, mangaRepositoryFactory, faviconCache)
} else {
null
}

View File

@@ -24,7 +24,8 @@ import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.ext.fetch
import org.koitharu.kotatsu.core.util.ext.isNetworkUri
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.LocalStorageCache
import org.koitharu.kotatsu.local.data.PageCache
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.mimeType
import org.koitharu.kotatsu.parsers.util.requireBody
@@ -34,7 +35,7 @@ import javax.inject.Inject
class MangaPageFetcher(
private val okHttpClient: OkHttpClient,
private val pagesCache: PagesCache,
private val pagesCache: LocalStorageCache,
private val options: Options,
private val page: MangaPage,
private val mangaRepositoryFactory: MangaRepository.Factory,
@@ -53,7 +54,7 @@ class MangaPageFetcher(
val repo = mangaRepositoryFactory.create(page.source)
val pageUrl = repo.getPageUrl(page)
if (options.diskCachePolicy.readEnabled) {
pagesCache.get(pageUrl)?.let { file ->
pagesCache[pageUrl]?.let { file ->
return SourceFetchResult(
source = ImageSource(file.toOkioPath(), options.fileSystem),
mimeType = MimeTypes.getMimeTypeFromExtension(file.name)?.toString(),
@@ -78,7 +79,7 @@ class MangaPageFetcher(
}
val mimeType = response.mimeType?.toMimeTypeOrNull()
val file = response.requireBody().use {
pagesCache.put(pageUrl, it.source(), mimeType)
pagesCache.set(pageUrl, it.source(), mimeType)
}
SourceFetchResult(
source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM),
@@ -107,7 +108,7 @@ class MangaPageFetcher(
class Factory @Inject constructor(
@MangaHttpClient private val okHttpClient: OkHttpClient,
private val pagesCache: PagesCache,
@PageCache private val pagesCache: LocalStorageCache,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val imageProxyInterceptor: ImageProxyInterceptor,
) : Fetcher.Factory<MangaPage> {

View File

@@ -76,8 +76,9 @@ import org.koitharu.kotatsu.core.util.progress.RealtimeEtaEstimator
import org.koitharu.kotatsu.download.domain.DownloadProgress
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageCache
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.PageCache
import org.koitharu.kotatsu.local.data.TempFileFilter
import org.koitharu.kotatsu.local.data.input.LocalMangaParser
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
@@ -103,7 +104,7 @@ class DownloadWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
@MangaHttpClient private val okHttp: OkHttpClient,
private val cache: PagesCache,
@PageCache private val cache: LocalStorageCache,
private val localMangaRepository: LocalMangaRepository,
private val mangaLock: MangaLock,
private val mangaDataRepository: MangaDataRepository,
@@ -233,7 +234,7 @@ class DownloadWorker @AssistedInject constructor(
semaphore.withPermit {
runFailsafe {
val url = repo.getPageUrl(page)
val file = cache.get(url)
val file = cache[url]
?: downloadFile(url, destination, repo.source)
output.addPage(
chapter = chapter,

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.local.data
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class PageCache
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class FaviconCache

View File

@@ -5,7 +5,6 @@ import android.graphics.Bitmap
import android.os.StatFs
import android.webkit.MimeTypeMap
import com.tomclaw.cache.DiskLruCache
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
@@ -14,7 +13,6 @@ import okio.buffer
import okio.sink
import okio.use
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.ext.MimeType
import org.koitharu.kotatsu.core.util.ext.compressToPNG
@@ -28,22 +26,24 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import java.io.File
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PagesCache @Inject constructor(@ApplicationContext context: Context) {
class LocalStorageCache(
context: Context,
private val dir: CacheDir,
private val defaultSize: Long,
private val minSize: Long,
) {
private val cacheDir = suspendLazy {
val dirs = context.externalCacheDirs + context.cacheDir
dirs.firstNotNullOf {
it?.subdir(CacheDir.PAGES.dir)?.takeIfWriteable()
it?.subdir(dir.dir)?.takeIfWriteable()
}
}
private val lruCache = suspendLazy {
val dir = cacheDir.get()
val availableSize = (getAvailableSize() * 0.8).toLong()
val size = SIZE_DEFAULT.coerceAtMost(availableSize).coerceAtLeast(SIZE_MIN)
val size = defaultSize.coerceAtMost(availableSize).coerceAtLeast(minSize)
runCatchingCancellable {
DiskLruCache.create(dir, size)
}.recoverCatching { error ->
@@ -54,14 +54,14 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
}.getOrThrow()
}
suspend fun get(url: String): File? = withContext(Dispatchers.IO) {
suspend operator fun get(url: String): File? = withContext(Dispatchers.IO) {
val cache = lruCache.get()
runInterruptible {
cache.get(url)?.takeIfReadable()
}
}
suspend fun put(url: String, source: Source, mimeType: MimeType?): File = withContext(Dispatchers.IO) {
suspend operator fun set(url: String, source: Source, mimeType: MimeType?): File = withContext(Dispatchers.IO) {
val file = createBufferFile(url, mimeType)
try {
val bytes = file.sink(append = false).buffer().use {
@@ -79,7 +79,7 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
}
}
suspend fun put(url: String, bitmap: Bitmap): File = withContext(Dispatchers.IO) {
suspend operator fun set(url: String, bitmap: Bitmap): File = withContext(Dispatchers.IO) {
val file = createBufferFile(url, MimeType("image/png"))
try {
bitmap.compressToPNG(file)
@@ -107,7 +107,7 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
}
}.onFailure {
it.printStackTraceDebug()
}.getOrDefault(SIZE_DEFAULT)
}.getOrDefault(defaultSize)
private suspend fun createBufferFile(url: String, mimeType: MimeType?): File {
val ext = MimeTypes.getExtension(mimeType) ?: MimeTypeMap.getFileExtensionFromUrl(url).ifNullOrEmpty { "dat" }
@@ -116,13 +116,4 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
val name = UUID.randomUUID().toString() + "." + ext
return File(rootDir, name)
}
private companion object {
val SIZE_MIN
get() = FileSize.MEGABYTES.convert(20, FileSize.BYTES)
val SIZE_DEFAULT
get() = FileSize.MEGABYTES.convert(200, FileSize.BYTES)
}
}

View File

@@ -65,7 +65,8 @@ import org.koitharu.kotatsu.core.util.ext.use
import org.koitharu.kotatsu.core.util.ext.withProgress
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
import org.koitharu.kotatsu.download.ui.worker.DownloadSlowdownDispatcher
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.LocalStorageCache
import org.koitharu.kotatsu.local.data.PageCache
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.requireBody
@@ -84,7 +85,7 @@ class PageLoader @Inject constructor(
@LocalizedAppContext private val context: Context,
lifecycle: ActivityRetainedLifecycle,
@MangaHttpClient private val okHttp: OkHttpClient,
private val cache: PagesCache,
@PageCache private val cache: LocalStorageCache,
private val coil: ImageLoader,
private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory,
@@ -196,7 +197,7 @@ class PageLoader @Inject constructor(
}
}
}.use { image ->
cache.put(uri.toString(), image).toUri()
cache.set(uri.toString(), image).toUri()
}
} else {
val file = uri.toFile()
@@ -300,7 +301,7 @@ class PageLoader @Inject constructor(
val request = createPageRequest(pageUrl, page.source)
imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
response.requireBody().withProgress(progress).use {
cache.put(pageUrl, it.source(), it.contentType()?.toMimeType())
cache.set(pageUrl, it.source(), it.contentType()?.toMimeType())
}
}.toUri()
}

View File

@@ -85,7 +85,7 @@ class StorageManageSettingsFragment : BasePreferenceFragment(R.string.storage_us
}
AppSettings.KEY_THUMBS_CACHE_CLEAR -> {
viewModel.clearCache(preference.key, CacheDir.THUMBS)
viewModel.clearCache(preference.key, CacheDir.THUMBS, CacheDir.FAVICONS)
true
}

View File

@@ -82,16 +82,18 @@ class StorageManageSettingsViewModel @Inject constructor(
loadStorageUsage()
}
fun clearCache(key: String, cache: CacheDir) {
fun clearCache(key: String, vararg caches: CacheDir) {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + key }
storageManager.clearCache(cache)
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
loadStorageUsage()
if (cache == CacheDir.THUMBS || cache == CacheDir.FAVICONS) {
coil.memoryCache?.clear()
for (cache in caches) {
storageManager.clearCache(cache)
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
if (cache == CacheDir.THUMBS) {
coil.memoryCache?.clear()
}
}
loadStorageUsage()
} finally {
loadingKeys.update { it - key }
}