diff --git a/app/build.gradle b/app/build.gradle index 3775f1ee4..f02ef258b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -76,7 +76,7 @@ afterEvaluate { } } dependencies { - implementation('com.github.nv95:kotatsu-parsers:26d951bc20') { + implementation('com.github.nv95:kotatsu-parsers:330495556a') { exclude group: 'org.json', module: 'json' } @@ -113,6 +113,7 @@ dependencies { implementation 'io.insert-koin:koin-android:3.2.0' implementation 'io.coil-kt:coil-base:2.1.0' + implementation 'io.coil-kt:coil-svg:2.1.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' implementation 'com.github.solkin:disk-lru-cache:1.4' diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt deleted file mode 100644 index ba5412a50..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.koitharu.kotatsu.core.parser - -import android.net.Uri -import coil.map.Mapper -import coil.request.Options -import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrl -import org.koitharu.kotatsu.parsers.model.MangaSource - -class FaviconMapper : Mapper { - - override fun map(data: Uri, options: Options): HttpUrl? { - if (data.scheme != "favicon") { - return null - } - val mangaSource = MangaSource.valueOf(data.schemeSpecificPart) - val repo = MangaRepository(mangaSource) as RemoteMangaRepository - return repo.getFaviconUrl().toHttpUrl() - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt index 999ecb09b..f74946ba6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt @@ -36,8 +36,11 @@ class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository { override suspend fun getTags(): Set = parser.getTags() + @Deprecated("") fun getFaviconUrl(): String = parser.getFaviconUrl() + suspend fun getFavicons(): Favicons = parser.parseFavicons() + fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider fun getConfigKeys(): List> = ArrayList>().also { diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt new file mode 100644 index 000000000..8162d16ac --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/favicon/FaviconFetcher.kt @@ -0,0 +1,159 @@ +package org.koitharu.kotatsu.core.parser.favicon + +import android.content.Context +import android.net.Uri +import android.webkit.MimeTypeMap +import coil.ImageLoader +import coil.decode.DataSource +import coil.decode.ImageSource +import coil.disk.DiskCache +import coil.fetch.FetchResult +import coil.fetch.Fetcher +import coil.fetch.SourceResult +import coil.network.HttpException +import coil.request.Options +import coil.size.Size +import coil.size.pxOrElse +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.internal.closeQuietly +import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.parser.RemoteMangaRepository +import org.koitharu.kotatsu.local.data.CacheDir +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.await +import java.net.HttpURLConnection + +private const val FALLBACK_SIZE = 9999 // largest icon + +class FaviconFetcher( + private val okHttpClient: OkHttpClient, + private val diskCache: Lazy, + private val mangaSource: MangaSource, + private val options: Options, +) : Fetcher { + + private val diskCacheKey + get() = options.diskCacheKey ?: "${mangaSource.name}[${mangaSource.ordinal}]x${options.size.toCacheKey()}" + + private val fileSystem + get() = checkNotNull(diskCache.value).fileSystem + + override suspend fun fetch(): FetchResult { + getCached(options)?.let { return it } + val repo = MangaRepository(mangaSource) as RemoteMangaRepository + val favicons = repo.getFavicons() + val sizePx = maxOf( + options.size.width.pxOrElse { FALLBACK_SIZE }, + options.size.height.pxOrElse { FALLBACK_SIZE }, + ) + val icon = checkNotNull(favicons.find(sizePx)) { "No favicons found" } + val response = loadIcon(icon.url, favicons.referer) + val responseBody = response.requireBody() + val source = writeToDiskCache(responseBody)?.toImageSource() ?: responseBody.toImageSource() + return SourceResult( + source = source, + mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(icon.type), + dataSource = response.toDataSource(), + ) + } + + private suspend fun loadIcon(url: String, referer: String): Response { + val request = Request.Builder() + .url(url) + .get() + .header(CommonHeaders.REFERER, referer) + @Suppress("UNCHECKED_CAST") + options.tags.asMap().forEach { request.tag(it.key as Class, it.value) } + val response = okHttpClient.newCall(request.build()).await() + if (!response.isSuccessful && response.code != HttpURLConnection.HTTP_NOT_MODIFIED) { + response.body?.closeQuietly() + throw HttpException(response) + } + return response + } + + private fun getCached(options: Options): SourceResult? { + if (!options.diskCachePolicy.readEnabled) { + return null + } + val snapshot = diskCache.value?.get(diskCacheKey) ?: return null + return SourceResult( + source = snapshot.toImageSource(), + mimeType = null, + dataSource = DataSource.DISK, + ) + } + + private fun writeToDiskCache(body: ResponseBody): DiskCache.Snapshot? { + if (!options.diskCachePolicy.writeEnabled || body.contentLength() == 0L) { + return null + } + val editor = diskCache.value?.edit(diskCacheKey) ?: return null + try { + fileSystem.write(editor.data) { + body.source().readAll(this) + } + return editor.commitAndGet() + } catch (e: Throwable) { + try { + editor.abort() + } catch (abortingError: Throwable) { + e.addSuppressed(abortingError) + } + body.closeQuietly() + throw e + } finally { + body.closeQuietly() + } + } + + private fun DiskCache.Snapshot.toImageSource(): ImageSource { + return ImageSource(data, fileSystem, diskCacheKey, this) + } + + private fun ResponseBody.toImageSource(): ImageSource { + return ImageSource(source(), options.context, FaviconMetadata(mangaSource)) + } + + private fun Response.toDataSource(): DataSource { + return if (networkResponse != null) DataSource.NETWORK else DataSource.DISK + } + + private fun Response.requireBody(): ResponseBody { + return checkNotNull(body) { "response body == null" } + } + + private fun Size.toCacheKey() = buildString { + append(width.toString()) + append('x') + append(height.toString()) + } + + class Factory( + context: Context, + private val okHttpClient: OkHttpClient, + ) : Fetcher.Factory { + + private val diskCache = lazy { + val rootDir = context.externalCacheDir ?: context.cacheDir + DiskCache.Builder() + .directory(rootDir.resolve(CacheDir.FAVICONS.dir)) + .build() + } + + override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? { + return if (data.scheme == URI_SCHEME_FAVICON) { + val mangaSource = MangaSource.valueOf(data.schemeSpecificPart) + FaviconFetcher(okHttpClient, diskCache, mangaSource, options) + } else { + null + } + } + } + + class FaviconMetadata(val source: MangaSource) : ImageSource.Metadata() +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/favicon/FaviconUri.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/favicon/FaviconUri.kt new file mode 100644 index 000000000..48f393325 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/favicon/FaviconUri.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.core.parser.favicon + +import android.net.Uri +import org.koitharu.kotatsu.parsers.model.MangaSource + +const val URI_SCHEME_FAVICON = "favicon" + +fun MangaSource.faviconUri(): Uri = Uri.fromParts(URI_SCHEME_FAVICON, name, null) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt index 889e37dfc..2c4616f0d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt @@ -3,12 +3,14 @@ package org.koitharu.kotatsu.core.ui import android.text.Html import coil.ComponentRegistry import coil.ImageLoader +import coil.decode.SvgDecoder import coil.disk.DiskCache +import coil.util.DebugLogger import kotlinx.coroutines.Dispatchers import okhttp3.OkHttpClient import org.koin.android.ext.koin.androidContext import org.koin.dsl.module -import org.koitharu.kotatsu.core.parser.FaviconMapper +import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CbzFetcher import org.koitharu.kotatsu.utils.ext.isLowRamDevice @@ -36,11 +38,13 @@ val uiModule .decoderDispatcher(Dispatchers.Default) .transformationDispatcher(Dispatchers.Default) .diskCache(diskCacheFactory) + .logger(DebugLogger()) .allowRgb565(isLowRamDevice(androidContext())) .components( ComponentRegistry.Builder() + .add(SvgDecoder.Factory()) .add(CbzFetcher.Factory()) - .add(FaviconMapper()) + .add(FaviconFetcher.Factory(androidContext(), get())) .build() ).build() } diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt index 5b870b8cc..e278a541b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt +++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt @@ -11,12 +11,14 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding import org.koitharu.kotatsu.databinding.ItemExploreHeaderBinding import org.koitharu.kotatsu.databinding.ItemExploreSourceBinding import org.koitharu.kotatsu.explore.ui.model.ExploreItem import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener +import org.koitharu.kotatsu.utils.ext.crossfade import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.setTextAndVisible import org.koitharu.kotatsu.utils.image.FaviconFallbackDrawable @@ -76,11 +78,12 @@ fun exploreSourceItemAD( binding.textViewTitle.text = item.source.title val fallbackIcon = FaviconFallbackDrawable(context, item.source.name) imageRequest = ImageRequest.Builder(context) - .data(item.faviconUrl) + .data(item.source.faviconUri()) + .target(binding.imageViewIcon) + .crossfade(context) .fallback(fallbackIcon) .placeholder(fallbackIcon) .error(fallbackIcon) - .target(binding.imageViewCover) .lifecycle(lifecycleOwner) .enqueueWith(coil) } diff --git a/app/src/main/java/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt b/app/src/main/java/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt index 8ff7c70ba..dcf871c8d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt +++ b/app/src/main/java/org/koitharu/kotatsu/explore/ui/model/ExploreItem.kt @@ -57,9 +57,6 @@ sealed interface ExploreItem : ListModel { val source: MangaSource, ) : ExploreItem { - val faviconUrl: Uri - get() = Uri.fromParts("favicon", source.name, null) - override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CacheDir.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CacheDir.kt index 1cc562d7b..2300526c9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/CacheDir.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CacheDir.kt @@ -3,5 +3,6 @@ package org.koitharu.kotatsu.local.data enum class CacheDir(val dir: String) { THUMBS("image_cache"), + FAVICONS("favicons"), PAGES("pages"); } diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt index ce80c6bc3..1e911a94c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt @@ -5,6 +5,7 @@ import coil.ImageLoader import coil.request.Disposable import coil.request.ImageRequest import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.databinding.ItemSearchSuggestionSourceBinding import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem @@ -33,7 +34,7 @@ fun searchSuggestionSourceAD( binding.switchLocal.isChecked = item.isEnabled val fallbackIcon = FaviconFallbackDrawable(context, item.source.name) imageRequest = ImageRequest.Builder(context) - .data(item.faviconUrl) + .data(item.source.faviconUri()) .fallback(fallbackIcon) .placeholder(fallbackIcon) .error(fallbackIcon) diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt index d1126f59c..572d9c75b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/model/SearchSuggestionItem.kt @@ -57,9 +57,6 @@ sealed interface SearchSuggestionItem { val isEnabled: Boolean, ) : SearchSuggestionItem { - val faviconUrl: Uri - get() = Uri.fromParts("favicon", source.name, null) - override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt index 775da0f6e..2f615ca0d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt @@ -11,13 +11,16 @@ import coil.request.ImageRequest import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.databinding.ItemExpandableBinding import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding import org.koitharu.kotatsu.databinding.ItemSourceConfigDraggableBinding import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem +import org.koitharu.kotatsu.utils.ext.crossfade import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.textAndVisible +import org.koitharu.kotatsu.utils.image.FaviconFallbackDrawable fun sourceConfigHeaderDelegate() = adapterDelegateViewBinding( @@ -64,10 +67,14 @@ fun sourceConfigItemDelegate( binding.textViewTitle.text = item.source.title binding.switchToggle.isChecked = item.isEnabled binding.textViewDescription.textAndVisible = item.summary + val fallbackIcon = FaviconFallbackDrawable(context, item.source.name) imageRequest = ImageRequest.Builder(context) - .data(item.faviconUrl) - .error(R.drawable.ic_favicon_fallback) + .data(item.source.faviconUri()) .target(binding.imageViewIcon) + .crossfade(context) + .error(fallbackIcon) + .placeholder(fallbackIcon) + .fallback(fallbackIcon) .lifecycle(lifecycleOwner) .enqueueWith(coil) } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt index 1aafbecd2..77f695002 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt @@ -54,9 +54,6 @@ sealed interface SourceConfigItem { val isDraggable: Boolean, ) : SourceConfigItem { - val faviconUrl: Uri - get() = Uri.fromParts("favicon", source.name, null) - override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false diff --git a/app/src/main/res/layout/item_explore_source.xml b/app/src/main/res/layout/item_explore_source.xml index 0dcd431ba..48b1a178b 100644 --- a/app/src/main/res/layout/item_explore_source.xml +++ b/app/src/main/res/layout/item_explore_source.xml @@ -12,10 +12,11 @@ android:padding="@dimen/list_spacing"> diff --git a/app/src/main/res/layout/item_source_config.xml b/app/src/main/res/layout/item_source_config.xml index de07db26a..d9ea40ae2 100644 --- a/app/src/main/res/layout/item_source_config.xml +++ b/app/src/main/res/layout/item_source_config.xml @@ -1,6 +1,7 @@ -