diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt index e703e879e..74848ab22 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt @@ -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, - 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), + ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt index 870435239..e17e8477c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt @@ -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 { 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 } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt index cdc2bba8b..191cda877 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt @@ -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 { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt index 5506d028e..d3f97fe7b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -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, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/Caches.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/Caches.kt new file mode 100644 index 000000000..03ac1735a --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/Caches.kt @@ -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 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageCache.kt similarity index 78% rename from app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageCache.kt index e9f7faf55..7013aa646 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageCache.kt @@ -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) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt index ba7dd3d24..e46e4c275 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -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() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsFragment.kt index 6b29bb9a5..7e0b946d6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsFragment.kt @@ -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 } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsViewModel.kt index c57be32ee..66868bac4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsViewModel.kt @@ -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 } }