Coil 2.x upgrade

Co-Authored-By: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com>
This commit is contained in:
Jays2Kings 2022-05-02 17:07:06 -04:00
parent cd1e3e1f11
commit bc778347fd
27 changed files with 533 additions and 346 deletions

View file

@ -200,7 +200,7 @@ dependencies {
implementation("com.github.inorichi.injekt:injekt-core:65b0440")
// Image library
val coilVersion = "1.3.2"
val coilVersion = "2.0.0-rc03"
implementation("io.coil-kt:coil:$coilVersion")
implementation("io.coil-kt:coil-gif:$coilVersion")
implementation("io.coil-kt:coil-svg:$coilVersion")
@ -270,6 +270,7 @@ tasks {
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.InternalCoroutinesApi",
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
"-opt-in=coil.annotation.ExperimentalCoilApi",
)
}

View file

@ -112,7 +112,7 @@ class CoverCache(val context: Context) {
),
)
}
context.imageLoader.memoryCache.clear()
context.imageLoader.memoryCache?.clear()
lastClean = System.currentTimeMillis()
}
@ -169,7 +169,7 @@ class CoverCache(val context: Context) {
fun setCustomCoverToCache(manga: Manga, inputStream: InputStream) {
getCustomCoverFile(manga).outputStream().use {
inputStream.copyTo(it)
context.imageLoader.memoryCache.remove(MemoryCache.Key(manga.key()))
context.imageLoader.memoryCache?.remove(MemoryCache.Key(manga.key()))
}
}
@ -183,7 +183,7 @@ class CoverCache(val context: Context) {
val result = getCustomCoverFile(manga).let {
it.exists() && it.delete()
}
context.imageLoader.memoryCache.remove(MemoryCache.Key(manga.key()))
context.imageLoader.memoryCache?.remove(MemoryCache.Key(manga.key()))
return result
}
@ -205,7 +205,7 @@ class CoverCache(val context: Context) {
fun deleteFromCache(name: String?) {
if (name.isNullOrEmpty()) return
val file = getCoverFile(MangaImpl().apply { thumbnail_url = name })
context.imageLoader.memoryCache.remove(MemoryCache.Key(file.name))
context.imageLoader.memoryCache?.remove(MemoryCache.Key(file.name))
if (file.exists()) file.delete()
}
@ -226,7 +226,7 @@ class CoverCache(val context: Context) {
val file = getCoverFile(manga)
if (deleteCustom) deleteCustomCover(manga)
if (file.exists()) {
context.imageLoader.memoryCache.remove(MemoryCache.Key(manga.key()))
context.imageLoader.memoryCache?.remove(MemoryCache.Key(manga.key()))
file.delete()
}
}

View file

@ -1,30 +0,0 @@
package eu.kanade.tachiyomi.data.image.coil
import coil.bitmap.BitmapPool
import coil.decode.DataSource
import coil.decode.Options
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.size.Size
import okio.buffer
import okio.source
import java.io.ByteArrayInputStream
class ByteArrayFetcher : Fetcher<ByteArray> {
override fun key(data: ByteArray): String? = null
override suspend fun fetch(
pool: BitmapPool,
data: ByteArray,
size: Size,
options: Options,
): FetchResult {
return SourceResult(
source = ByteArrayInputStream(data).source().buffer(),
mimeType = "image/gif",
dataSource = DataSource.MEMORY,
)
}
}

View file

@ -3,37 +3,60 @@ package eu.kanade.tachiyomi.data.image.coil
import android.app.ActivityManager
import android.content.Context
import android.os.Build
import androidx.core.content.ContextCompat.getSystemService
import androidx.core.content.getSystemService
import coil.Coil
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil.disk.DiskCache
import coil.memory.MemoryCache
import eu.kanade.tachiyomi.network.NetworkHelper
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class CoilSetup(context: Context) {
init {
val imageLoader = ImageLoader.Builder(context)
.availableMemoryPercentage(0.40)
.crossfade(true)
.allowRgb565(context.getSystemService<ActivityManager>()!!.isLowRamDevice)
.allowHardware(true)
.componentRegistry {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder(context))
val imageLoader = ImageLoader.Builder(context).apply {
val callFactoryInit = { Injekt.get<NetworkHelper>().client }
val diskCacheInit = { CoilDiskCache.get(context) }
components {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder())
add(GifDecoder.Factory())
}
add(SvgDecoder(context))
add(MangaFetcher())
add(ByteArrayFetcher())
add(TachiyomiImageDecoder.Factory())
add(MangaCoverFetcher.Factory(lazy(callFactoryInit), lazy(diskCacheInit)))
add(MangaCoverKeyer())
}
.okHttpClient(Injekt.get<NetworkHelper>().coilClient)
.build()
callFactory(callFactoryInit)
diskCache(diskCacheInit)
memoryCache { MemoryCache.Builder(context).maxSizePercent(0.40).build() }
crossfade(true)
allowRgb565(context.getSystemService<ActivityManager>()!!.isLowRamDevice)
allowHardware(true)
}.build()
Coil.setImageLoader(imageLoader)
}
}
/**
* Direct copy of Coil's internal SingletonDiskCache so that [MangaCoverFetcher] can access it.
*/
internal object CoilDiskCache {
private const val FOLDER_NAME = "image_cache"
private var instance: DiskCache? = null
@Synchronized
fun get(context: Context): DiskCache {
return instance ?: run {
val safeCacheDir = context.cacheDir.apply { mkdirs() }
// Create the singleton disk cache instance.
DiskCache.Builder()
.directory(safeCacheDir.resolve(FOLDER_NAME))
.build()
.also { instance = it }
}
}
}

View file

@ -34,7 +34,7 @@ class LibraryMangaImageTarget(
BitmapFactory.decodeFile(file.path, options)
if (options.outWidth == -1 || options.outHeight == -1) {
file.delete()
view.context.imageLoader.memoryCache.remove(MemoryCache.Key(manga.key()))
view.context.imageLoader.memoryCache?.remove(MemoryCache.Key(manga.key()))
}
}
}

View file

@ -0,0 +1,320 @@
package eu.kanade.tachiyomi.data.image.coil
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.request.Parameters
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.manga.MangaCoverMetadata
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import okhttp3.CacheControl
import okhttp3.Call
import okhttp3.Request
import okhttp3.Response
import okhttp3.internal.closeQuietly
import okio.Path.Companion.toOkioPath
import okio.Source
import okio.buffer
import okio.sink
import okio.source
import timber.log.Timber
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.net.HttpURLConnection
import java.util.Date
class MangaCoverFetcher(
private val manga: Manga,
private val sourceLazy: Lazy<HttpSource?>,
private val options: Options,
private val coverCache: CoverCache,
private val callFactoryLazy: Lazy<Call.Factory>,
private val diskCacheLazy: Lazy<DiskCache>,
) : Fetcher {
// For non-custom cover
private val diskCacheKey: String? by lazy { MangaCoverKeyer().key(manga, options) }
private lateinit var url: String
val fileScope = CoroutineScope(Job() + Dispatchers.IO)
override suspend fun fetch(): FetchResult {
// diskCacheKey is thumbnail_url
url = manga.thumbnail_url ?: error("No cover specified")
return when (getResourceType(url)) {
Type.URL -> httpLoader()
Type.File -> fileLoader(File(url.substringAfter("file://")))
null -> error("Invalid image")
}
}
private suspend fun httpLoader(): FetchResult {
val diskRead = options.diskCachePolicy.readEnabled
val networkRead = options.networkCachePolicy.readEnabled
val onlyCache = !networkRead && diskRead
val shouldFetchRemotely = networkRead && !diskRead && !onlyCache
val useCustomCover = options.parameters.value(useCustomCover) ?: true
// Use custom cover if exists
if (!shouldFetchRemotely) {
val customCoverFile by lazy { coverCache.getCustomCoverFile(manga) }
if (useCustomCover && customCoverFile.exists()) {
setRatioAndColorsInScope(manga, customCoverFile)
return fileLoader(customCoverFile)
}
}
val coverFile = coverCache.getCoverFile(manga)
if (!shouldFetchRemotely && coverFile.exists() && options.diskCachePolicy.readEnabled) {
if (!manga.favorite) {
coverFile.setLastModified(Date().time)
}
setRatioAndColorsInScope(manga, coverFile)
return fileLoader(coverFile)
}
var snapshot = readFromDiskCache()
try {
// Fetch from disk cache
if (snapshot != null) {
val snapshotCoverCache = moveSnapshotToCoverCache(snapshot, coverFile)
if (snapshotCoverCache != null) {
// Read from cover cache after added to library
return fileLoader(snapshotCoverCache)
}
// Read from snapshot
return SourceResult(
source = snapshot.toImageSource(),
mimeType = "image/*",
dataSource = DataSource.DISK,
)
}
// Fetch from network
val response = executeNetworkRequest()
val responseBody = checkNotNull(response.body) { "Null response source" }
try {
// Read from cover cache after library manga cover updated
val responseCoverCache = writeResponseToCoverCache(response, coverFile)
if (responseCoverCache != null) {
return fileLoader(responseCoverCache)
}
// Read from disk cache
snapshot = writeToDiskCache(snapshot, response)
if (snapshot != null) {
return SourceResult(
source = snapshot.toImageSource(),
mimeType = "image/*",
dataSource = DataSource.NETWORK,
)
}
// Read from response if cache is unused or unusable
return SourceResult(
source = ImageSource(source = responseBody.source(), context = options.context),
mimeType = "image/*",
dataSource = if (response.cacheResponse != null) DataSource.DISK else DataSource.NETWORK,
)
} catch (e: Exception) {
responseBody.closeQuietly()
throw e
}
} catch (e: Exception) {
snapshot?.closeQuietly()
throw e
}
}
private suspend fun executeNetworkRequest(): Response {
val client = sourceLazy.value?.client ?: callFactoryLazy.value
val response = client.newCall(newRequest()).await()
if (!response.isSuccessful && response.code != HttpURLConnection.HTTP_NOT_MODIFIED) {
response.body?.closeQuietly()
throw HttpException(response)
}
return response
}
private fun newRequest(): Request {
val request = Request.Builder()
.url(url)
.headers(sourceLazy.value?.headers ?: options.headers)
// Support attaching custom data to the network request.
.tag(Parameters::class.java, options.parameters)
val diskRead = options.diskCachePolicy.readEnabled
val networkRead = options.networkCachePolicy.readEnabled
val onlyCache = !networkRead && diskRead
val forceNetwork = networkRead && !diskRead
when {
!networkRead && diskRead -> {
request.cacheControl(CacheControl.FORCE_CACHE)
}
networkRead && !diskRead -> if (options.diskCachePolicy.writeEnabled) {
request.cacheControl(CacheControl.FORCE_NETWORK)
} else {
request.cacheControl(CACHE_CONTROL_FORCE_NETWORK_NO_CACHE)
}
!networkRead && !diskRead -> {
// This causes the request to fail with a 504 Unsatisfiable Request.
request.cacheControl(CACHE_CONTROL_NO_NETWORK_NO_CACHE)
}
}
return request.build()
}
private fun moveSnapshotToCoverCache(snapshot: DiskCache.Snapshot, cacheFile: File?): File? {
if (cacheFile == null) return null
return try {
diskCacheLazy.value.run {
fileSystem.source(snapshot.data).use { input ->
writeSourceToCoverCache(input, cacheFile)
}
remove(diskCacheKey!!)
}
cacheFile.takeIf { it.exists() }
} catch (e: Exception) {
Timber.e(e, "Failed to write snapshot data to cover cache ${cacheFile.name}")
null
}
}
private fun writeResponseToCoverCache(response: Response, cacheFile: File?): File? {
if (cacheFile == null || !options.diskCachePolicy.writeEnabled) return null
return try {
response.peekBody(Long.MAX_VALUE).source().use { input ->
writeSourceToCoverCache(input, cacheFile)
}
cacheFile.takeIf { it.exists() }
} catch (e: Exception) {
Timber.e(e, "Failed to write response data to cover cache ${cacheFile.name}")
null
}
}
private fun writeSourceToCoverCache(input: Source, cacheFile: File) {
cacheFile.parentFile?.mkdirs()
cacheFile.delete()
try {
cacheFile.sink().buffer().use { output ->
output.writeAll(input)
}
} catch (e: Exception) {
cacheFile.delete()
throw e
}
}
private fun readFromDiskCache(): DiskCache.Snapshot? {
return if (options.diskCachePolicy.readEnabled) diskCacheLazy.value[diskCacheKey!!] else null
}
private fun writeToDiskCache(
snapshot: DiskCache.Snapshot?,
response: Response,
): DiskCache.Snapshot? {
if (!options.diskCachePolicy.writeEnabled) {
snapshot?.closeQuietly()
return null
}
val editor = if (snapshot != null) {
snapshot.closeAndEdit()
} else {
diskCacheLazy.value.edit(diskCacheKey!!)
} ?: return null
try {
diskCacheLazy.value.fileSystem.write(editor.data) {
response.body!!.source().readAll(this)
}
return editor.commitAndGet()
} catch (e: Exception) {
try {
editor.abort()
} catch (ignored: Exception) {
}
throw e
}
}
private fun DiskCache.Snapshot.toImageSource(): ImageSource {
return ImageSource(file = data, diskCacheKey = diskCacheKey, closeable = this)
}
private fun setRatioAndColorsInScope(manga: Manga, ogFile: File? = null, force: Boolean = false) {
fileScope.launch {
MangaCoverMetadata.setRatioAndColors(manga, ogFile, force)
}
}
/** Modified from [MimeTypeMap.getFileExtensionFromUrl] to be more permissive with special characters. */
private fun MimeTypeMap.getMimeTypeFromUrl(url: String?): String? {
if (url.isNullOrBlank()) {
return null
}
val extension = url
.substringBeforeLast('#') // Strip the fragment.
.substringBeforeLast('?') // Strip the query.
.substringAfterLast('/') // Get the last path segment.
.substringAfterLast('.', missingDelimiterValue = "") // Get the file extension.
return getMimeTypeFromExtension(extension)
}
private fun fileLoader(file: File): FetchResult {
return SourceResult(
source = ImageSource(file = file.toOkioPath(), diskCacheKey = diskCacheKey),
mimeType = "image/*",
dataSource = DataSource.DISK,
)
}
private fun getResourceType(cover: String?): Type? {
return when {
cover.isNullOrEmpty() -> null
cover.startsWith("http") || cover.startsWith("Custom-", true) -> Type.URL
cover.startsWith("/") || cover.startsWith("file://") -> Type.File
else -> null
}
}
class Factory(
private val callFactoryLazy: Lazy<Call.Factory>,
private val diskCacheLazy: Lazy<DiskCache>,
) : Fetcher.Factory<Manga> {
private val coverCache: CoverCache by injectLazy()
private val sourceManager: SourceManager by injectLazy()
override fun create(data: Manga, options: Options, imageLoader: ImageLoader): Fetcher {
val source = lazy { sourceManager.get(data.source) as? HttpSource }
return MangaCoverFetcher(data, source, options, coverCache, callFactoryLazy, diskCacheLazy)
}
}
private enum class Type {
File, URL;
}
companion object {
const val useCustomCover = "use_custom_cover"
private val CACHE_CONTROL_FORCE_NETWORK_NO_CACHE = CacheControl.Builder().noCache().noStore().build()
private val CACHE_CONTROL_NO_NETWORK_NO_CACHE = CacheControl.Builder().noCache().onlyIfCached().build()
}
}

View file

@ -0,0 +1,17 @@
package eu.kanade.tachiyomi.data.image.coil
import coil.key.Keyer
import coil.request.Options
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.util.storage.DiskUtil
class MangaCoverKeyer : Keyer<Manga> {
override fun key(data: Manga, options: Options): String? {
if (data.thumbnail_url.isNullOrBlank()) return null
return if (!data.favorite) {
data.thumbnail_url!!
} else {
DiskUtil.hashKeyForDisk(data.thumbnail_url!!)
}
}
}

View file

@ -1,240 +0,0 @@
package eu.kanade.tachiyomi.data.image.coil
import android.graphics.BitmapFactory
import android.webkit.MimeTypeMap
import androidx.palette.graphics.Palette
import coil.bitmap.BitmapPool
import coil.decode.DataSource
import coil.decode.Options
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.size.Size
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.manga.MangaCoverMetadata
import eu.kanade.tachiyomi.util.storage.DiskUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import okhttp3.CacheControl
import okhttp3.Call
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody
import okio.buffer
import okio.sink
import okio.source
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.util.Date
class MangaFetcher : Fetcher<Manga> {
companion object {
const val realCover = "real_cover"
const val onlyCache = "only_cache"
const val onlyFetchRemotely = "only_fetch_remotely"
}
private val coverCache: CoverCache by injectLazy()
private val sourceManager: SourceManager by injectLazy()
private val defaultClient = Injekt.get<NetworkHelper>().client
val fileScope = CoroutineScope(Job() + Dispatchers.IO)
override fun key(data: Manga): String? {
if (data.thumbnail_url.isNullOrBlank()) return null
return if (!data.favorite) {
data.thumbnail_url!!
} else {
DiskUtil.hashKeyForDisk(data.thumbnail_url!!)
}
}
override suspend fun fetch(pool: BitmapPool, data: Manga, size: Size, options: Options): FetchResult {
val cover = data.thumbnail_url
return when (getResourceType(cover)) {
Type.URL -> httpLoader(data, options)
Type.File -> fileLoader(data)
null -> error("Invalid image")
}
}
private suspend fun httpLoader(manga: Manga, options: Options): FetchResult {
val onlyCache = options.parameters.value(onlyCache) == true
val shouldFetchRemotely = options.parameters.value(onlyFetchRemotely) == true && !onlyCache
if (!shouldFetchRemotely) {
val customCoverFile = coverCache.getCustomCoverFile(manga)
if (customCoverFile.exists() && options.parameters.value(realCover) != true) {
setRatioAndColorsInScope(manga, customCoverFile)
return fileLoader(customCoverFile)
}
}
val coverFile = coverCache.getCoverFile(manga)
if (!shouldFetchRemotely && coverFile.exists() && options.diskCachePolicy.readEnabled) {
if (!manga.favorite) {
coverFile.setLastModified(Date().time)
}
setRatioAndColorsInScope(manga, coverFile)
return fileLoader(coverFile)
}
val (response, body) = awaitGetCall(
manga,
if (manga.favorite) {
onlyCache
} else {
false
},
shouldFetchRemotely,
)
if (options.diskCachePolicy.writeEnabled) {
val tmpFile = File(coverFile.absolutePath + "_tmp")
body.source().use { input ->
tmpFile.sink().buffer().use { output ->
output.writeAll(input)
}
}
if (response.isSuccessful || !coverFile.exists()) {
if (coverFile.exists()) {
coverFile.delete()
}
tmpFile.renameTo(coverFile)
}
if (manga.favorite) {
coverCache.deleteCachedCovers()
}
}
setRatioAndColorsInScope(manga, coverFile, true)
return fileLoader(coverFile)
}
private fun setRatioAndColorsInScope(manga: Manga, ogFile: File? = null, force: Boolean = false) {
fileScope.launch {
setRatioAndColors(manga, ogFile, force)
}
}
fun setRatioAndColors(manga: Manga, ogFile: File? = null, force: Boolean = false) {
if (!manga.favorite) {
MangaCoverMetadata.remove(manga)
}
if (manga.vibrantCoverColor != null && !manga.favorite) return
val file = ogFile ?: coverCache.getCustomCoverFile(manga).takeIf { it.exists() } ?: coverCache.getCoverFile(manga)
// if the file exists and the there was still an error then the file is corrupted
if (file.exists()) {
val options = BitmapFactory.Options()
val hasVibrantColor = if (manga.favorite) manga.vibrantCoverColor != null else true
if (manga.dominantCoverColors != null && hasVibrantColor && !force) {
options.inJustDecodeBounds = true
} else {
options.inSampleSize = 4
}
val bitmap = BitmapFactory.decodeFile(file.path, options) ?: return
if (!options.inJustDecodeBounds) {
Palette.from(bitmap).generate {
if (it == null) return@generate
if (manga.favorite) {
it.dominantSwatch?.let { swatch ->
manga.dominantCoverColors = swatch.rgb to swatch.titleTextColor
}
}
val color = it.getBestColor() ?: return@generate
manga.vibrantCoverColor = color
}
}
if (manga.favorite && !(options.outWidth == -1 || options.outHeight == -1)) {
MangaCoverMetadata.addCoverRatio(manga, options.outWidth / options.outHeight.toFloat())
}
}
}
private suspend fun awaitGetCall(manga: Manga, onlyCache: Boolean = false, forceNetwork: Boolean): Pair<Response,
ResponseBody,> {
val call = getCall(manga, onlyCache, forceNetwork)
val response = call.await()
return response to checkNotNull(response.body) { "Null response source" }
}
private fun getCall(manga: Manga, onlyCache: Boolean, forceNetwork: Boolean): Call {
val source = sourceManager.get(manga.source) as? HttpSource
val client = source?.client ?: defaultClient
val newClient = client.newBuilder().build()
val request = Request.Builder().url(manga.thumbnail_url!!).also {
if (source != null) {
it.headers(source.headers)
}
if (forceNetwork) {
it.cacheControl(CacheControl.FORCE_NETWORK)
} else if (onlyCache) {
it.cacheControl(CacheControl.FORCE_CACHE)
}
}.build()
return newClient.newCall(request)
}
/**
* "text/plain" is often used as a default/fallback MIME type.
* Attempt to guess a better MIME type from the file extension.
*/
private fun getMimeType(data: String, body: ResponseBody): String? {
val rawContentType = body.contentType()?.toString()
return if (rawContentType == null || rawContentType.startsWith("text/plain")) {
MimeTypeMap.getSingleton().getMimeTypeFromUrl(data) ?: rawContentType
} else {
rawContentType
}
}
/** Modified from [MimeTypeMap.getFileExtensionFromUrl] to be more permissive with special characters. */
private fun MimeTypeMap.getMimeTypeFromUrl(url: String?): String? {
if (url.isNullOrBlank()) {
return null
}
val extension = url
.substringBeforeLast('#') // Strip the fragment.
.substringBeforeLast('?') // Strip the query.
.substringAfterLast('/') // Get the last path segment.
.substringAfterLast('.', missingDelimiterValue = "") // Get the file extension.
return getMimeTypeFromExtension(extension)
}
private fun fileLoader(manga: Manga): FetchResult {
return fileLoader(File(manga.thumbnail_url!!.substringAfter("file://")))
}
private fun fileLoader(file: File): FetchResult {
return SourceResult(
source = file.source().buffer(),
mimeType = "image/*",
dataSource = DataSource.DISK,
)
}
private fun getResourceType(cover: String?): Type? {
return when {
cover.isNullOrEmpty() -> null
cover.startsWith("http") || cover.startsWith("Custom-", true) -> Type.URL
cover.startsWith("/") || cover.startsWith("file://") -> Type.File
else -> null
}
}
private enum class Type {
File, URL;
}
}

View file

@ -0,0 +1,61 @@
package eu.kanade.tachiyomi.data.image.coil
import android.os.Build
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DecodeResult
import coil.decode.Decoder
import coil.decode.ImageDecoderDecoder
import coil.decode.ImageSource
import coil.fetch.SourceResult
import coil.request.Options
import eu.kanade.tachiyomi.util.system.ImageUtil
import okio.BufferedSource
import tachiyomi.decoder.ImageDecoder
/**
* A [Decoder] that uses built-in [ImageDecoder] to decode images that is not supported by the system.
*/
class TachiyomiImageDecoder(private val resources: ImageSource, private val options: Options) : Decoder {
override suspend fun decode(): DecodeResult {
val decoder = resources.sourceOrNull()?.use {
ImageDecoder.newInstance(it.inputStream())
}
check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder." }
val bitmap = decoder.decode(rgb565 = options.allowRgb565)
decoder.recycle()
check(bitmap != null) { "Failed to decode image." }
return DecodeResult(
drawable = bitmap.toDrawable(options.context.resources),
isSampled = false,
)
}
class Factory : Decoder.Factory {
override fun create(result: SourceResult, options: Options, imageLoader: ImageLoader): Decoder? {
if (!isApplicable(result.source.source())) return null
return TachiyomiImageDecoder(result.source, options)
}
private fun isApplicable(source: BufferedSource): Boolean {
val type = source.peek().inputStream().use {
ImageUtil.findImageType(it)
}
return when (type) {
ImageUtil.ImageType.AVIF/*, ImageUtil.ImageType.JXL */ -> true
ImageUtil.ImageType.HEIF -> Build.VERSION.SDK_INT < Build.VERSION_CODES.O
else -> false
}
}
override fun equals(other: Any?) = other is ImageDecoderDecoder.Factory
override fun hashCode() = javaClass.hashCode()
}
}

View file

@ -13,13 +13,11 @@ import androidx.core.content.ContextCompat
import coil.Coil
import coil.request.CachePolicy
import coil.request.ImageRequest
import coil.request.Parameters
import coil.transform.CircleCropTransformation
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.image.coil.MangaFetcher
import eu.kanade.tachiyomi.data.notification.NotificationHandler
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
@ -177,11 +175,8 @@ class LibraryUpdateNotifier(private val context: Context) {
setSmallIcon(R.drawable.ic_tachij2k_notification)
try {
val request = ImageRequest.Builder(context).data(manga)
.parameters(
Parameters.Builder().set(MangaFetcher.onlyCache, true)
.build(),
)
.networkCachePolicy(CachePolicy.READ_ONLY)
.networkCachePolicy(CachePolicy.DISABLED)
.diskCachePolicy(CachePolicy.ENABLED)
.transformations(CircleCropTransformation())
.size(width = ICON_SIZE, height = ICON_SIZE).build()

View file

@ -10,7 +10,6 @@ import androidx.work.NetworkType
import coil.Coil
import coil.request.CachePolicy
import coil.request.ImageRequest
import coil.request.Parameters
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
@ -21,7 +20,6 @@ import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.toMangaInfo
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.image.coil.MangaFetcher
import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.preference.MANGA_HAS_UNREAD
@ -505,7 +503,7 @@ class LibraryUpdateService(
val request =
ImageRequest.Builder(this@LibraryUpdateService).data(manga)
.memoryCachePolicy(CachePolicy.DISABLED)
.parameters(Parameters.Builder().set(MangaFetcher.onlyFetchRemotely, true).build())
.diskCachePolicy(CachePolicy.WRITE_ONLY)
.build()
Coil.imageLoader(this@LibraryUpdateService).execute(request)
}

View file

@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.network
import android.content.Context
import coil.util.CoilUtils
import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.api.ChuckerInterceptor
import eu.kanade.tachiyomi.BuildConfig
@ -57,8 +56,6 @@ class NetworkHelper(val context: Context) {
val client by lazy { baseClientBuilder.cache(Cache(cacheDir, cacheSize)).build() }
val coilClient by lazy { baseClientBuilder.cache(CoilUtils.createDefaultCache(context)).build() }
val cloudflareClient by lazy {
client.newBuilder()
.addInterceptor(CloudflareInterceptor(context))

View file

@ -9,7 +9,7 @@ import androidx.core.text.color
import androidx.core.text.scale
import androidx.core.view.isGone
import androidx.core.view.isVisible
import coil.clear
import coil.dispose
import coil.load
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.image.coil.CoverViewTarget
@ -99,7 +99,7 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
binding.installProgress.isVisible = item.sessionProgress != null
binding.cancelButton.isVisible = item.sessionProgress != null
binding.sourceImage.clear()
binding.sourceImage.dispose()
if (extension is Extension.Available) {
binding.sourceImage.load(extension.iconUrl) {

View file

@ -10,7 +10,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.core.view.marginBottom
import androidx.core.view.updateLayoutParams
import coil.clear
import coil.dispose
import coil.size.Precision
import coil.size.Scale
import eu.kanade.tachiyomi.R
@ -99,7 +99,7 @@ class LibraryGridHolder(
setSelected(adapter.isSelected(flexibleAdapterPosition))
// Update the cover.
binding.coverThumbnail.clear()
binding.coverThumbnail.dispose()
setCover(item.manga)
}

View file

@ -4,7 +4,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import coil.clear
import coil.dispose
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.image.coil.loadManga
import eu.kanade.tachiyomi.databinding.MangaListItemBinding
@ -90,7 +90,7 @@ class LibraryListHolder(
}
// Update the cover.
binding.coverThumbnail.clear()
binding.coverThumbnail.dispose()
binding.coverThumbnail.loadManga(item.manga)
}

View file

@ -10,7 +10,6 @@ import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.image.coil.MangaFetcher
import eu.kanade.tachiyomi.data.preference.DelayedLibrarySuggestionsJob
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.minusAssign
@ -1222,10 +1221,9 @@ class LibraryPresenter(
suspend fun updateRatiosAndColors() {
val db: DatabaseHelper = Injekt.get()
val mangaFetcher = MangaFetcher()
val libraryManga = db.getFavoriteMangas().executeOnIO()
libraryManga.forEach { manga ->
try { withUIContext { mangaFetcher.setRatioAndColors(manga) } } catch (_: Exception) { }
try { withUIContext { MangaCoverMetadata.setRatioAndColors(manga) } } catch (_: Exception) { }
}
MangaCoverMetadata.savePrefs()
}

View file

@ -13,14 +13,14 @@ import android.view.inputmethod.InputMethodManager
import androidx.core.graphics.ColorUtils
import androidx.core.view.children
import androidx.core.view.isVisible
import coil.loadAny
import coil.load
import coil.request.Parameters
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.image.coil.MangaFetcher
import eu.kanade.tachiyomi.data.image.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.data.image.coil.loadManga
import eu.kanade.tachiyomi.databinding.EditMangaDialogBinding
import eu.kanade.tachiyomi.source.LocalSource
@ -176,10 +176,10 @@ class EditMangaDialog : DialogController {
binding.resetCover.isVisible = !isLocal
binding.resetCover.setOnClickListener {
binding.mangaCover.loadAny(
binding.mangaCover.load(
manga,
builder = {
parameters(Parameters.Builder().set(MangaFetcher.realCover, true).build())
parameters(Parameters.Builder().set(MangaCoverFetcher.useCustomCover, false).build())
},
)
customCoverUri = null
@ -288,7 +288,7 @@ class EditMangaDialog : DialogController {
fun updateCover(uri: Uri) {
willResetCover = false
binding.mangaCover.loadAny(uri)
binding.mangaCover.load(uri)
customCoverUri = uri
}

View file

@ -9,7 +9,6 @@ import coil.imageLoader
import coil.memory.MemoryCache
import coil.request.CachePolicy
import coil.request.ImageRequest
import coil.request.Parameters
import coil.request.SuccessResult
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache
@ -22,7 +21,6 @@ import eu.kanade.tachiyomi.data.database.models.toMangaInfo
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.data.image.coil.MangaFetcher
import eu.kanade.tachiyomi.data.library.CustomMangaManager
import eu.kanade.tachiyomi.data.library.LibraryServiceListener
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
@ -354,14 +352,11 @@ class MangaDetailsPresenter(
val request =
ImageRequest.Builder(preferences.context).data(manga)
.memoryCachePolicy(CachePolicy.DISABLED)
.parameters(
Parameters.Builder().set(MangaFetcher.onlyFetchRemotely, true)
.build(),
)
.diskCachePolicy(CachePolicy.WRITE_ONLY)
.build()
if (Coil.imageLoader(preferences.context).execute(request) is SuccessResult) {
preferences.context.imageLoader.memoryCache.remove(MemoryCache.Key(manga.key()))
preferences.context.imageLoader.memoryCache?.remove(MemoryCache.Key(manga.key()))
withContext(Dispatchers.Main) {
controller?.setPaletteColor()
}

View file

@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.ui.manga.track
import android.view.View
import androidx.core.view.isVisible
import coil.clear
import coil.dispose
import coil.load
import com.google.android.material.shape.CornerFamily
import com.mikepenz.fastadapter.FastAdapter
@ -45,7 +45,7 @@ class TrackSearchItem(val trackSearch: TrackSearch) : AbstractItem<TrackSearchIt
binding.trackSearchTitle.text = track.title
binding.trackSearchSummary.text = track.summary
binding.trackSearchSummary.isVisible = track.summary.isNotBlank()
binding.trackSearchCover.clear()
binding.trackSearchCover.dispose()
if (track.cover_url.isNotEmpty()) {
binding.trackSearchCover.load(track.cover_url)
}

View file

@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.ui.migration
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import coil.clear
import coil.dispose
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.data.image.coil.loadManga
@ -28,7 +28,7 @@ class MangaHolder(
binding.subtitle.text = ""
// Update the cover.
binding.coverThumbnail.clear()
binding.coverThumbnail.dispose()
binding.coverThumbnail.loadManga(item.manga)
}
}

View file

@ -11,6 +11,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.image.coil.CoverViewTarget
import eu.kanade.tachiyomi.data.image.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.databinding.MangaGridItemBinding
import eu.kanade.tachiyomi.databinding.MigrationProcessItemBinding
import eu.kanade.tachiyomi.source.Source
@ -143,7 +144,9 @@ class MigrationProcessHolder(
progress.isVisible = false
val request = ImageRequest.Builder(view.context).data(manga)
.target(CoverViewTarget(coverThumbnail, progress)).build()
.target(CoverViewTarget(coverThumbnail, progress))
.setParameter(MangaCoverFetcher.useCustomCover, false)
.build()
Coil.imageLoader(view.context).enqueue(request)
compactTitle.isVisible = true

View file

@ -16,7 +16,7 @@ import androidx.annotation.CallSuper
import androidx.annotation.StyleRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.view.isVisible
import coil.clear
import coil.dispose
import coil.imageLoader
import coil.request.CachePolicy
import coil.request.ImageRequest
@ -98,7 +98,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
fun recycle() = pageView?.let {
when (it) {
is SubsamplingScaleImageView -> it.recycle()
is AppCompatImageView -> it.clear()
is AppCompatImageView -> it.dispose()
}
it.isVisible = false
}

View file

@ -23,7 +23,7 @@ import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.net.toUri
import androidx.core.view.isVisible
import coil.loadAny
import coil.load
import coil.request.CachePolicy
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
@ -954,7 +954,7 @@ class PagerPageHolder(
* Extension method to set a [stream] into this ImageView.
*/
private fun ImageView.setImage(stream: InputStream) {
this.loadAny(stream.readBytes()) {
this.load(stream.readBytes()) {
memoryCachePolicy(CachePolicy.DISABLED)
diskCachePolicy(CachePolicy.DISABLED)
target(GifViewTarget(this@setImage, progressBar, decodeErrorLayout))

View file

@ -5,12 +5,13 @@ import android.view.View
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import coil.Coil
import coil.clear
import coil.dispose
import coil.request.ImageRequest
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.image.coil.CoverViewTarget
import eu.kanade.tachiyomi.data.image.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.databinding.MangaGridItemBinding
import eu.kanade.tachiyomi.ui.library.LibraryCategoryAdapter
import eu.kanade.tachiyomi.util.view.setCards
@ -61,11 +62,13 @@ class BrowseSourceGridHolder(
override fun setImage(manga: Manga) {
if ((view.context as? Activity)?.isDestroyed == true) return
if (manga.thumbnail_url == null) {
binding.coverThumbnail.clear()
binding.coverThumbnail.dispose()
} else {
manga.id ?: return
val request = ImageRequest.Builder(view.context).data(manga)
.target(CoverViewTarget(binding.coverThumbnail, binding.progress)).build()
.target(CoverViewTarget(binding.coverThumbnail, binding.progress))
.setParameter(MangaCoverFetcher.useCustomCover, false)
.build()
Coil.imageLoader(view.context).enqueue(request)
}
}

View file

@ -4,12 +4,13 @@ import android.view.View
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import coil.Coil
import coil.clear
import coil.dispose
import coil.request.ImageRequest
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.image.coil.CoverViewTarget
import eu.kanade.tachiyomi.data.image.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.databinding.MangaListItemBinding
import eu.kanade.tachiyomi.util.view.setCards
@ -50,11 +51,13 @@ class BrowseSourceListHolder(
override fun setImage(manga: Manga) {
// Update the cover.
if (manga.thumbnail_url == null) {
binding.coverThumbnail.clear()
binding.coverThumbnail.dispose()
} else {
manga.id ?: return
val request = ImageRequest.Builder(view.context).data(manga)
.target(CoverViewTarget(binding.coverThumbnail)).build()
.target(CoverViewTarget(binding.coverThumbnail))
.setParameter(MangaCoverFetcher.useCustomCover, false)
.build()
Coil.imageLoader(view.context).enqueue(request)
}
}

View file

@ -4,11 +4,12 @@ import android.graphics.drawable.RippleDrawable
import android.view.View
import androidx.core.view.isVisible
import coil.Coil
import coil.clear
import coil.dispose
import coil.request.CachePolicy
import coil.request.ImageRequest
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.image.coil.CoverViewTarget
import eu.kanade.tachiyomi.data.image.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.databinding.SourceGlobalSearchControllerCardItemBinding
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import eu.kanade.tachiyomi.util.system.dpToPx
@ -50,12 +51,14 @@ class GlobalSearchMangaHolder(view: View, adapter: GlobalSearchCardAdapter) :
}
fun setImage(manga: Manga) {
binding.itemImage.clear()
binding.itemImage.dispose()
if (!manga.thumbnail_url.isNullOrEmpty()) {
val request = ImageRequest.Builder(itemView.context).data(manga)
.placeholder(android.R.color.transparent)
.memoryCachePolicy(CachePolicy.DISABLED)
.target(CoverViewTarget(binding.itemImage, binding.progress)).build()
.target(CoverViewTarget(binding.itemImage, binding.progress))
.setParameter(MangaCoverFetcher.useCustomCover, false)
.build()
Coil.imageLoader(itemView.context).enqueue(request)
}
}

View file

@ -1,9 +1,14 @@
package eu.kanade.tachiyomi.util.manga
import android.graphics.BitmapFactory
import androidx.annotation.ColorInt
import androidx.palette.graphics.Palette
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.image.coil.getBestColor
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.util.concurrent.ConcurrentHashMap
/** Object that holds info about a covers size ratio + dominant colors */
@ -11,6 +16,7 @@ object MangaCoverMetadata {
private var coverRatioMap = ConcurrentHashMap<Long, Float>()
private var coverColorMap = ConcurrentHashMap<Long, Pair<Int, Int>>()
val preferences by injectLazy<PreferencesHelper>()
val coverCache by injectLazy<CoverCache>()
fun load() {
val ratios = preferences.coverRatios().get()
@ -42,6 +48,40 @@ object MangaCoverMetadata {
)
}
fun setRatioAndColors(manga: Manga, ogFile: File? = null, force: Boolean = false) {
if (!manga.favorite) {
MangaCoverMetadata.remove(manga)
}
if (manga.vibrantCoverColor != null && !manga.favorite) return
val file = ogFile ?: coverCache.getCustomCoverFile(manga).takeIf { it.exists() } ?: coverCache.getCoverFile(manga)
// if the file exists and the there was still an error then the file is corrupted
if (file.exists()) {
val options = BitmapFactory.Options()
val hasVibrantColor = if (manga.favorite) manga.vibrantCoverColor != null else true
if (manga.dominantCoverColors != null && hasVibrantColor && !force) {
options.inJustDecodeBounds = true
} else {
options.inSampleSize = 4
}
val bitmap = BitmapFactory.decodeFile(file.path, options) ?: return
if (!options.inJustDecodeBounds) {
Palette.from(bitmap).generate {
if (it == null) return@generate
if (manga.favorite) {
it.dominantSwatch?.let { swatch ->
manga.dominantCoverColors = swatch.rgb to swatch.titleTextColor
}
}
val color = it.getBestColor() ?: return@generate
manga.vibrantCoverColor = color
}
}
if (manga.favorite && !(options.outWidth == -1 || options.outHeight == -1)) {
addCoverRatio(manga, options.outWidth / options.outHeight.toFloat())
}
}
}
fun remove(manga: Manga) {
val id = manga.id ?: return
coverRatioMap.remove(id)