Parse favicons in runtime

pull/163/head
Koitharu 4 years ago
parent 9dc3ad38fc
commit 9c66f74a5b
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -76,7 +76,7 @@ afterEvaluate {
} }
} }
dependencies { dependencies {
implementation('com.github.nv95:kotatsu-parsers:26d951bc20') { implementation('com.github.nv95:kotatsu-parsers:330495556a') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
@ -113,6 +113,7 @@ dependencies {
implementation 'io.insert-koin:koin-android:3.2.0' implementation 'io.insert-koin:koin-android:3.2.0'
implementation 'io.coil-kt:coil-base:2.1.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.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'

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

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

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

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

@ -3,12 +3,14 @@ package org.koitharu.kotatsu.core.ui
import android.text.Html import android.text.Html
import coil.ComponentRegistry import coil.ComponentRegistry
import coil.ImageLoader import coil.ImageLoader
import coil.decode.SvgDecoder
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.util.DebugLogger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module 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.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.utils.ext.isLowRamDevice import org.koitharu.kotatsu.utils.ext.isLowRamDevice
@ -36,11 +38,13 @@ val uiModule
.decoderDispatcher(Dispatchers.Default) .decoderDispatcher(Dispatchers.Default)
.transformationDispatcher(Dispatchers.Default) .transformationDispatcher(Dispatchers.Default)
.diskCache(diskCacheFactory) .diskCache(diskCacheFactory)
.logger(DebugLogger())
.allowRgb565(isLowRamDevice(androidContext())) .allowRgb565(isLowRamDevice(androidContext()))
.components( .components(
ComponentRegistry.Builder() ComponentRegistry.Builder()
.add(SvgDecoder.Factory())
.add(CbzFetcher.Factory()) .add(CbzFetcher.Factory())
.add(FaviconMapper()) .add(FaviconFetcher.Factory(androidContext(), get()))
.build() .build()
).build() ).build()
} }

@ -11,12 +11,14 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener 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.ItemEmptyCardBinding
import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding
import org.koitharu.kotatsu.databinding.ItemExploreHeaderBinding import org.koitharu.kotatsu.databinding.ItemExploreHeaderBinding
import org.koitharu.kotatsu.databinding.ItemExploreSourceBinding import org.koitharu.kotatsu.databinding.ItemExploreSourceBinding
import org.koitharu.kotatsu.explore.ui.model.ExploreItem import org.koitharu.kotatsu.explore.ui.model.ExploreItem
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener 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.enqueueWith
import org.koitharu.kotatsu.utils.ext.setTextAndVisible import org.koitharu.kotatsu.utils.ext.setTextAndVisible
import org.koitharu.kotatsu.utils.image.FaviconFallbackDrawable import org.koitharu.kotatsu.utils.image.FaviconFallbackDrawable
@ -76,11 +78,12 @@ fun exploreSourceItemAD(
binding.textViewTitle.text = item.source.title binding.textViewTitle.text = item.source.title
val fallbackIcon = FaviconFallbackDrawable(context, item.source.name) val fallbackIcon = FaviconFallbackDrawable(context, item.source.name)
imageRequest = ImageRequest.Builder(context) imageRequest = ImageRequest.Builder(context)
.data(item.faviconUrl) .data(item.source.faviconUri())
.target(binding.imageViewIcon)
.crossfade(context)
.fallback(fallbackIcon) .fallback(fallbackIcon)
.placeholder(fallbackIcon) .placeholder(fallbackIcon)
.error(fallbackIcon) .error(fallbackIcon)
.target(binding.imageViewCover)
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)
.enqueueWith(coil) .enqueueWith(coil)
} }

@ -57,9 +57,6 @@ sealed interface ExploreItem : ListModel {
val source: MangaSource, val source: MangaSource,
) : ExploreItem { ) : ExploreItem {
val faviconUrl: Uri
get() = Uri.fromParts("favicon", source.name, null)
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false

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

@ -5,6 +5,7 @@ import coil.ImageLoader
import coil.request.Disposable import coil.request.Disposable
import coil.request.ImageRequest import coil.request.ImageRequest
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.databinding.ItemSearchSuggestionSourceBinding import org.koitharu.kotatsu.databinding.ItemSearchSuggestionSourceBinding
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
@ -33,7 +34,7 @@ fun searchSuggestionSourceAD(
binding.switchLocal.isChecked = item.isEnabled binding.switchLocal.isChecked = item.isEnabled
val fallbackIcon = FaviconFallbackDrawable(context, item.source.name) val fallbackIcon = FaviconFallbackDrawable(context, item.source.name)
imageRequest = ImageRequest.Builder(context) imageRequest = ImageRequest.Builder(context)
.data(item.faviconUrl) .data(item.source.faviconUri())
.fallback(fallbackIcon) .fallback(fallbackIcon)
.placeholder(fallbackIcon) .placeholder(fallbackIcon)
.error(fallbackIcon) .error(fallbackIcon)

@ -57,9 +57,6 @@ sealed interface SearchSuggestionItem {
val isEnabled: Boolean, val isEnabled: Boolean,
) : SearchSuggestionItem { ) : SearchSuggestionItem {
val faviconUrl: Uri
get() = Uri.fromParts("favicon", source.name, null)
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false

@ -11,13 +11,16 @@ import coil.request.ImageRequest
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.databinding.ItemExpandableBinding import org.koitharu.kotatsu.databinding.ItemExpandableBinding
import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding
import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding
import org.koitharu.kotatsu.databinding.ItemSourceConfigDraggableBinding import org.koitharu.kotatsu.databinding.ItemSourceConfigDraggableBinding
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem 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.enqueueWith
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
import org.koitharu.kotatsu.utils.image.FaviconFallbackDrawable
fun sourceConfigHeaderDelegate() = fun sourceConfigHeaderDelegate() =
adapterDelegateViewBinding<SourceConfigItem.Header, SourceConfigItem, ItemFilterHeaderBinding>( adapterDelegateViewBinding<SourceConfigItem.Header, SourceConfigItem, ItemFilterHeaderBinding>(
@ -64,10 +67,14 @@ fun sourceConfigItemDelegate(
binding.textViewTitle.text = item.source.title binding.textViewTitle.text = item.source.title
binding.switchToggle.isChecked = item.isEnabled binding.switchToggle.isChecked = item.isEnabled
binding.textViewDescription.textAndVisible = item.summary binding.textViewDescription.textAndVisible = item.summary
val fallbackIcon = FaviconFallbackDrawable(context, item.source.name)
imageRequest = ImageRequest.Builder(context) imageRequest = ImageRequest.Builder(context)
.data(item.faviconUrl) .data(item.source.faviconUri())
.error(R.drawable.ic_favicon_fallback)
.target(binding.imageViewIcon) .target(binding.imageViewIcon)
.crossfade(context)
.error(fallbackIcon)
.placeholder(fallbackIcon)
.fallback(fallbackIcon)
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)
.enqueueWith(coil) .enqueueWith(coil)
} }

@ -54,9 +54,6 @@ sealed interface SourceConfigItem {
val isDraggable: Boolean, val isDraggable: Boolean,
) : SourceConfigItem { ) : SourceConfigItem {
val faviconUrl: Uri
get() = Uri.fromParts("favicon", source.name, null)
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false

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

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

Loading…
Cancel
Save