Drop legacy decoder (#3786)

This commit is contained in:
w
2026-08-16 08:48:58 -07:00
committed by GitHub
parent 7e78849ed0
commit 00fb597ae9
15 changed files with 232 additions and 469 deletions
+2 -11
View File
@@ -30,10 +30,10 @@ import eu.kanade.tachiyomi.core.security.PrivacyPreferences
import eu.kanade.tachiyomi.crash.CrashActivity
import eu.kanade.tachiyomi.crash.GlobalExceptionHandler
import eu.kanade.tachiyomi.data.coil.BufferedSourceFetcher
import eu.kanade.tachiyomi.data.coil.ImageDecoder
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer
import eu.kanade.tachiyomi.data.coil.MangaKeyer
import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.di.AppModule
import eu.kanade.tachiyomi.di.PreferenceModule
@@ -41,7 +41,6 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.NetworkPreferences
import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.GLUtil
import eu.kanade.tachiyomi.util.system.WebViewUtil
import eu.kanade.tachiyomi.util.system.animatorDurationScale
import eu.kanade.tachiyomi.util.system.cancelNotification
@@ -144,14 +143,6 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
.onEach(TelemetryConfig::setCrashlyticsEnabled)
.launchIn(scope)
basePreferences.hardwareBitmapThreshold.let { preference ->
if (!preference.isSet()) preference.set(GLUtil.DEVICE_TEXTURE_LIMIT)
}
basePreferences.hardwareBitmapThreshold.changes()
.onEach { ImageUtil.hardwareBitmapThreshold = it }
.launchIn(scope)
setAppCompatDelegateThemeMode(Injekt.get<UiPreferences>().themeMode.get())
// Updates widget update
@@ -192,7 +183,7 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
// NetworkFetcher.Factory
add(OkHttpNetworkFetcherFactory(callFactoryLazy::value))
// Decoder.Factory
add(TachiyomiImageDecoder.Factory())
add(ImageDecoder.Factory())
// Fetcher.Factory
add(BufferedSourceFetcher.Factory())
add(MangaCoverFetcher.MangaCoverFactory(callFactoryLazy))
@@ -0,0 +1,128 @@
package eu.kanade.tachiyomi.data.coil
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import ca.mpreg.imagedecoder.ImageDecoder
import coil3.Canvas
import coil3.Image
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DecodeResult
import coil3.decode.DecodeUtils
import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import logcat.LogPriority
import okio.BufferedSource
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat
/**
* A [Decoder] that uses [ImageDecoder] (libvips-based) to decode image formats not supported
* by the Android system decoder (AVIF, JXL, HEIF, etc.).
*/
class ImageDecoder(private val resources: ImageSource, private val options: Options) : Decoder {
/**
* Wraps a raw [ImageDecoder.DecodeResult] as a Coil [Image] for callers that want
* direct access to the RGBA [java.nio.ByteBuffer] (e.g. the new-decoder path).
*/
class DecodeResultImage(val res: ImageDecoder.DecodeResult) : Image {
override val size: Long get() = res.image.capacity().toLong()
override val width: Int get() = res.width
override val height: Int get() = res.height
override val shareable: Boolean get() = true
override fun draw(canvas: Canvas) {}
}
override suspend fun decode(): DecodeResult {
val decoder = resources.sourceOrNull()?.use {
try {
ImageDecoder.new(it.inputStream())
} catch (e: ImageDecoder.DecodeException) {
logcat(LogPriority.ERROR, e) { "ImageDecoder.new failed: ${e.message}" }
null
}
}
check(decoder != null && decoder.pages > 0) { "Failed to initialize decoder" }
val res = decoder.decode()
val srcWidth = res.width
val srcHeight = res.height
// newDecoder path: caller wants the raw DecodeResult (e.g. for custom rendering).
// Hand it back as-is; sampling is the caller's responsibility.
if (options.newDecoder) {
return DecodeResult(
image = DecodeResultImage(res),
isSampled = false,
)
}
// Normal path: produce a Bitmap scaled to the requested output size.
val dstWidth = options.size.widthPx(options.scale) { srcWidth }
val dstHeight = options.size.heightPx(options.scale) { srcHeight }
val sampleSize = DecodeUtils.calculateInSampleSize(
srcWidth = srcWidth,
srcHeight = srcHeight,
dstWidth = dstWidth,
dstHeight = dstHeight,
scale = options.scale,
)
// Copy RGBA pixels from the native buffer into a full-resolution bitmap.
// We must do this while `res` (and its native memory) is still alive.
val fullBitmap = createBitmap(srcWidth, srcHeight)
res.image.rewind()
fullBitmap.copyPixelsFromBuffer(res.image)
// Downsample if needed. sampleSize is a power-of-two factor; the target
// dimensions are src / sampleSize, matching BitmapFactory inSampleSize behaviour.
val bitmap = if (sampleSize > 1) {
val scaledWidth = (srcWidth / sampleSize).coerceAtLeast(1)
val scaledHeight = (srcHeight / sampleSize).coerceAtLeast(1)
val scaled = fullBitmap.scale(scaledWidth, scaledHeight)
fullBitmap.recycle()
scaled
} else {
fullBitmap
}
return DecodeResult(
image = bitmap.asImage(),
isSampled = sampleSize > 1,
)
}
class Factory : Decoder.Factory {
override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? {
return if (options.newDecoder || options.customDecoder || isApplicable(result.source.source())) {
ImageDecoder(result.source, options)
} else {
null
}
}
private fun isApplicable(source: BufferedSource): Boolean {
val type = source.peek().inputStream().use {
ImageUtil.findImageType(it)
}
return when (type) {
ImageUtil.ImageType.AVIF,
ImageUtil.ImageType.JXL,
ImageUtil.ImageType.HEIF,
ImageUtil.ImageType.JP2,
-> true
else -> false
}
}
override fun equals(other: Any?) = other is Factory
override fun hashCode() = javaClass.hashCode()
}
}
@@ -1,118 +0,0 @@
package eu.kanade.tachiyomi.data.coil
import android.graphics.Bitmap
import coil3.Canvas
import coil3.Image
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DecodeResult
import coil3.decode.DecodeUtils
import coil3.decode.Decoder
import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.request.bitmapConfig
import okio.BufferedSource
import tachiyomi.core.common.util.system.ImageUtil
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(), options.cropBorders, displayProfile)
}
check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" }
val srcWidth = decoder.width
val srcHeight = decoder.height
val dstWidth = options.size.widthPx(options.scale) { srcWidth }
val dstHeight = options.size.heightPx(options.scale) { srcHeight }
val sampleSize = DecodeUtils.calculateInSampleSize(
srcWidth = srcWidth,
srcHeight = srcHeight,
dstWidth = dstWidth,
dstHeight = dstHeight,
scale = options.scale,
)
var bitmap = decoder.decode(sampleSize = sampleSize)
decoder.recycle()
check(bitmap != null) { "Failed to decode image" }
if (options.bitmapConfig == Bitmap.Config.HARDWARE && ImageUtil.canUseHardwareBitmap(bitmap)) {
val hwBitmap = bitmap.copy(Bitmap.Config.HARDWARE, false)
if (hwBitmap != null) {
bitmap.recycle()
bitmap = hwBitmap
}
}
return DecodeResult(
image = bitmap.asImage(),
isSampled = sampleSize > 1,
)
}
class Factory : Decoder.Factory {
override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? {
return if (options.newDecoder) {
ImageDecoder2(result.source, options)
} else if (options.customDecoder || isApplicable(result.source.source())) {
TachiyomiImageDecoder(result.source, options)
} else {
null
}
}
private fun isApplicable(source: BufferedSource): Boolean {
val type = source.peek().inputStream().use {
ImageUtil.findImageType(it)
}
return when (type) {
ImageUtil.ImageType.AVIF, ImageUtil.ImageType.JXL, ImageUtil.ImageType.HEIF -> true
else -> false
}
}
override fun equals(other: Any?) = other is Factory
override fun hashCode() = javaClass.hashCode()
}
companion object {
var displayProfile: ByteArray? = null
}
}
class ImageDecoder2(private val resources: ImageSource, private val options: Options) : Decoder {
class DecodeResultImage(val res: ca.mpreg.imagedecoder.ImageDecoder.DecodeResult) : Image {
override val size: Long get() = res.image.capacity().toLong()
override val width: Int get() = res.width
override val height: Int get() = res.height
override val shareable: Boolean get() = true
override fun draw(canvas: Canvas) {}
}
override suspend fun decode(): DecodeResult {
val source = resources.source()
val decoder = ca.mpreg.imagedecoder.ImageDecoder.new(source.inputStream())
val res = decoder.decode()
return DecodeResult(
image = DecodeResultImage(res),
isSampled = false,
)
}
}
@@ -59,7 +59,6 @@ import eu.kanade.presentation.reader.appbars.ReaderAppBars
import eu.kanade.presentation.reader.components.ChapterNavigatorType
import eu.kanade.presentation.reader.settings.ReaderSettingsDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.databinding.ReaderActivityBinding
@@ -868,10 +867,6 @@ class ReaderActivity : BaseActivity() {
}
.launchIn(lifecycleScope)
preferences.displayProfile.changes()
.onEach { setDisplayProfile(it) }
.launchIn(lifecycleScope)
readerPreferences.keepScreenOn.changes()
.onEach(::setKeepScreenOn)
.launchIn(lifecycleScope)
@@ -899,25 +894,6 @@ class ReaderActivity : BaseActivity() {
.launchIn(lifecycleScope)
}
/**
* Sets the display profile to [path].
*/
private fun setDisplayProfile(path: String) {
val file = UniFile.fromUri(baseContext, path.toUri())
if (file != null && file.exists()) {
val inputStream = file.openInputStream()
val outputStream = ByteArrayOutputStream()
inputStream.use { input ->
outputStream.use { output ->
input.copyTo(output)
}
}
val data = outputStream.toByteArray()
SubsamplingScaleImageView.setDisplayProfile(data)
TachiyomiImageDecoder.displayProfile = data
}
}
/**
* Sets the keep screen on mode according to [enabled].
*/
@@ -60,10 +60,6 @@ open class ReaderPageImageView @JvmOverloads constructor(
private val isWebtoon: Boolean = false,
) : FrameLayout(context, attrs, defStyleAttrs, defStyleRes) {
private val alwaysDecodeLongStripWithSSIV by lazy {
Injekt.get<BasePreferences>().alwaysDecodeLongStripWithSSIV.get()
}
private var pageView: View? = null
private var config: Config? = null
@@ -240,7 +236,6 @@ open class ReaderPageImageView @JvmOverloads constructor(
} else {
SubsamplingScaleImageView(context)
}.apply {
setMaxTileSize(ImageUtil.hardwareBitmapThreshold)
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER)
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
setMinimumTileDpi(180)
@@ -301,8 +296,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
isVisible = true
}
is BufferedSource -> {
if (!isWebtoon || alwaysDecodeLongStripWithSSIV) {
setHardwareConfig(ImageUtil.canUseHardwareBitmap(data))
if (!isWebtoon) {
setImage(ImageSource.inputStream(data.inputStream()))
isVisible = true
return@apply
@@ -4,7 +4,6 @@ import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PointF
import android.util.Log
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
@@ -52,6 +51,8 @@ import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
import java.util.concurrent.Executors
import kotlin.math.abs
import kotlin.math.min
@@ -170,14 +171,14 @@ open class WebGpuViewer(
is TransitionPage -> createTransitionPage(page)
}
} catch (e: Exception) {
Log.e("WebGpuViewer", "Decode error: ${pageKey(page)}", e)
logcat(LogPriority.ERROR, e) { "Decode error: ${pageKey(page)}" }
synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE }
}
}
} catch (_: InterruptedException) {
// Normal shutdown
} catch (e: Exception) {
Log.e("WebGpuViewer", "Decode worker died", e)
logcat(LogPriority.ERROR, e) { "Decode worker died" }
}
}
}
@@ -688,7 +689,7 @@ open class WebGpuViewer(
when (state) {
Page.State.Queue, Page.State.LoadPage, Page.State.DownloadImage -> true
is Page.State.Error -> {
Log.e("WebGpuViewer", "Page load error: ${state.error}")
logcat(LogPriority.ERROR) { "Page load error: ${state.error}" }
false
}
@@ -711,7 +712,7 @@ open class WebGpuViewer(
}
}
} catch (e: Exception) {
Log.e("WebGpuViewer", "startPageLoad error", e)
logcat(LogPriority.ERROR, e) { "startPageLoad error" }
synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE }
}
}
@@ -762,11 +763,60 @@ open class WebGpuViewer(
Image.Position.SINGLE
}
val dec = ImageDecoder.new(bytes?.inputStream() ?: input)
val dec = try {
ImageDecoder.new(bytes?.inputStream() ?: input)
} catch (e: ImageDecoder.DecodeException) {
logcat(LogPriority.ERROR, e) { "ImageDecoder.new failed: ${e.message}" }
val errorMessage = e.message ?: "Failed to decode image"
val bitmap = createBitmap(pager.state.width.coerceAtLeast(1), pager.state.height.coerceAtLeast(1))
val canvas = Canvas(bitmap)
canvas.drawColor(readerBackgroundColor())
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = readerOnBackgroundColor()
textSize = 36f
textAlign = Paint.Align.CENTER
}
val maxWidth = bitmap.width * 0.8f
val words = errorMessage.split(" ")
val lines = mutableListOf<String>()
var currentLine = StringBuilder()
for (word in words) {
val testLine = if (currentLine.isEmpty()) word else "$currentLine $word"
if (paint.measureText(testLine) <= maxWidth) {
currentLine = StringBuilder(testLine)
} else {
if (currentLine.isNotEmpty()) lines.add(currentLine.toString())
currentLine = StringBuilder(word)
}
}
if (currentLine.isNotEmpty()) lines.add(currentLine.toString())
val lineHeight = 40f
var y = bitmap.height / 2f - lines.size * lineHeight / 2
for (line in lines) {
canvas.drawText(line, bitmap.width / 2f, y, paint)
y += lineHeight
}
val errorPage = ImagePage(bitmap, createMipMaps = false).also {
it.image?.position = Image.Position.SINGLE
}
synchronized(lock) {
if (pageInCache(page) && !page.imagePage.isDecoded && !page.imagePage.destroyed) {
val oldImagePage = page.imagePage
page.imagePage = errorPage
page.state = PageState.IDLE
if (oldImagePage !is ImagePage.Dummy) oldImagePage.cleanup()
pager.state.invalidate()
} else {
if (pageInCache(page)) page.state = PageState.IDLE
errorPage.cleanup()
}
}
return
}
val pageCount = dec.pages
if (pageCount == 0) {
Log.e("WebGpuViewer", "decodeReaderPage: no frames decoded")
logcat(LogPriority.ERROR) { "decodeReaderPage: no frames decoded" }
synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE }
return
}
@@ -838,7 +888,7 @@ open class WebGpuViewer(
}
}
} catch (e: Exception) {
Log.e("WebGpuViewer", "decodeReaderPage error", e)
logcat(LogPriority.ERROR, e) { "decodeReaderPage error" }
synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE }
} finally {
imagePage?.cleanup()
@@ -921,7 +971,7 @@ open class WebGpuViewer(
}
}
} catch (e: Exception) {
Log.e("WebGpuViewer", "createTransitionPage error", e)
logcat(LogPriority.ERROR, e) { "createTransitionPage error" }
synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE }
}
}