Parse favicons in runtime

This commit is contained in:
Koitharu
2022-07-12 09:35:52 +03:00
parent 9dc3ad38fc
commit 9c66f74a5b
15 changed files with 203 additions and 43 deletions

View File

@@ -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'

View File

@@ -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<Uri, HttpUrl> {
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()
}
}

View File

@@ -36,8 +36,11 @@ class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
override suspend fun getTags(): Set<MangaTag> = parser.getTags()
@Deprecated("")
fun getFaviconUrl(): String = parser.getFaviconUrl()
suspend fun getFavicons(): Favicons = parser.parseFavicons()
fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider
fun getConfigKeys(): List<ConfigKey<*>> = ArrayList<ConfigKey<*>>().also {

View File

@@ -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<DiskCache?>,
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<Any>, 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<Uri> {
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()
}

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -3,5 +3,6 @@ package org.koitharu.kotatsu.local.data
enum class CacheDir(val dir: String) {
THUMBS("image_cache"),
FAVICONS("favicons"),
PAGES("pages");
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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<SourceConfigItem.Header, SourceConfigItem, ItemFilterHeaderBinding>(
@@ -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)
}

View File

@@ -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

View File

@@ -12,10 +12,11 @@
android:padding="@dimen/list_spacing">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_cover"
android:id="@+id/imageView_icon"
android:layout_width="32dp"
android:layout_height="32dp"
android:scaleType="centerCrop"
android:labelFor="@id/textView_title"
android:scaleType="fitCenter"
app:shapeAppearance="?shapeAppearanceCornerSmall"
tools:src="@tools:sample/backgrounds/scenic" />

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -8,14 +9,14 @@
android:minHeight="?android:listPreferredItemHeightSmall"
android:orientation="horizontal">
<ImageView
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_icon"
android:layout_width="?android:listPreferredItemHeightSmall"
android:layout_height="?android:listPreferredItemHeightSmall"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginHorizontal="?listPreferredItemPaddingStart"
android:labelFor="@id/textView_title"
android:padding="8dp"
android:scaleType="fitCenter"
app:shapeAppearance="?shapeAppearanceCornerSmall"
tools:src="@tools:sample/avatars" />
<LinearLayout