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
-1
View File
@@ -291,7 +291,6 @@ dependencies {
} }
implementation(libs.image.decoder) implementation(libs.image.decoder)
implementation(libs.image.decoder2)
implementation(libs.webgpuviewer) implementation(libs.webgpuviewer)
implementation(libs.kim) implementation(libs.kim)
@@ -2,7 +2,6 @@ package eu.kanade.domain.base
import android.content.Context import android.content.Context
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.util.system.GLUtil
import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@@ -35,18 +34,6 @@ class BasePreferences(
val highQualityRenderer: Preference<Boolean> = preferenceStore.getBoolean("pref_high_quality_renderer_key", false) val highQualityRenderer: Preference<Boolean> = preferenceStore.getBoolean("pref_high_quality_renderer_key", false)
val displayProfile: Preference<String> = preferenceStore.getString("pref_display_profile_key", "")
val hardwareBitmapThreshold: Preference<Int> = preferenceStore.getInt(
"pref_hardware_bitmap_threshold",
GLUtil.SAFE_TEXTURE_LIMIT,
)
val alwaysDecodeLongStripWithSSIV: Preference<Boolean> = preferenceStore.getBoolean(
"pref_always_decode_long_strip_with_ssiv",
false,
)
val installationId: Preference<String> = preferenceStore.getString(Preference.appStateKey("installation_id"), "") val installationId: Preference<String> = preferenceStore.getString(Preference.appStateKey("installation_id"), "")
val donationCampaignShown: Preference<Boolean> = preferenceStore.getBoolean( val donationCampaignShown: Preference<Boolean> = preferenceStore.getBoolean(
@@ -53,7 +53,7 @@ import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.DropdownMenu import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.presentation.manga.EditCoverAction import eu.kanade.presentation.manga.EditCoverAction
import eu.kanade.tachiyomi.data.coil.ImageDecoder2 import eu.kanade.tachiyomi.data.coil.ImageDecoder
import eu.kanade.tachiyomi.data.coil.newDecoder import eu.kanade.tachiyomi.data.coil.newDecoder
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -173,7 +173,7 @@ fun MangaCoverDialog(
.memoryCachePolicy(CachePolicy.DISABLED) .memoryCachePolicy(CachePolicy.DISABLED)
.newDecoder(true) .newDecoder(true)
.target { result -> .target { result ->
val res = (result as ImageDecoder2.DecodeResultImage).res val res = (result as ImageDecoder.DecodeResultImage).res
val page = runBlocking(WebGpuRenderer.dispatcher) { val page = runBlocking(WebGpuRenderer.dispatcher) {
ImagePage(res.image, res.width, res.height) ImagePage(res.image, res.width, res.height)
}.apply { }.apply {
@@ -47,7 +47,6 @@ import eu.kanade.tachiyomi.network.PREF_DOH_QUAD9
import eu.kanade.tachiyomi.network.PREF_DOH_SHECAN import eu.kanade.tachiyomi.network.PREF_DOH_SHECAN
import eu.kanade.tachiyomi.ui.more.OnboardingScreen import eu.kanade.tachiyomi.ui.more.OnboardingScreen
import eu.kanade.tachiyomi.util.CrashLogUtil import eu.kanade.tachiyomi.util.CrashLogUtil
import eu.kanade.tachiyomi.util.system.GLUtil
import eu.kanade.tachiyomi.util.system.isReleaseBuildType import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import eu.kanade.tachiyomi.util.system.isShizukuInstalled import eu.kanade.tachiyomi.util.system.isShizukuInstalled
import eu.kanade.tachiyomi.util.system.powerManager import eu.kanade.tachiyomi.util.system.powerManager
@@ -334,15 +333,6 @@ object SettingsAdvancedScreen : SearchableSettings {
basePreferences: BasePreferences, basePreferences: BasePreferences,
): Preference.PreferenceGroup { ): Preference.PreferenceGroup {
val context = LocalContext.current val context = LocalContext.current
val chooseColorProfile = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri ->
uri?.let {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, flags)
basePreferences.displayProfile.set(uri.toString())
}
}
return Preference.PreferenceGroup( return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_reader), title = stringResource(MR.strings.pref_category_reader),
preferenceItems = listOf( preferenceItems = listOf(
@@ -350,37 +340,6 @@ object SettingsAdvancedScreen : SearchableSettings {
preference = basePreferences.highQualityRenderer, preference = basePreferences.highQualityRenderer,
title = stringResource(MR.strings.pref_high_quality_renderer), title = stringResource(MR.strings.pref_high_quality_renderer),
), ),
Preference.PreferenceItem.ListPreference(
preference = basePreferences.hardwareBitmapThreshold,
entries = GLUtil.CUSTOM_TEXTURE_LIMIT_OPTIONS
.mapIndexed { index, option ->
val display = if (index == 0) {
stringResource(MR.strings.pref_hardware_bitmap_threshold_default, option)
} else {
option.toString()
}
option to display
}
.toMap(),
title = stringResource(MR.strings.pref_hardware_bitmap_threshold),
subtitleProvider = { value, options ->
stringResource(MR.strings.pref_hardware_bitmap_threshold_summary, options[value].orEmpty())
},
enabled = !ImageUtil.HARDWARE_BITMAP_UNSUPPORTED &&
GLUtil.DEVICE_TEXTURE_LIMIT > GLUtil.SAFE_TEXTURE_LIMIT,
),
Preference.PreferenceItem.SwitchPreference(
preference = basePreferences.alwaysDecodeLongStripWithSSIV,
title = stringResource(MR.strings.pref_always_decode_long_strip_with_ssiv_2),
subtitle = stringResource(MR.strings.pref_always_decode_long_strip_with_ssiv_summary),
),
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.pref_display_profile),
subtitle = basePreferences.displayProfile.get(),
onClick = {
chooseColorProfile.launch(arrayOf("*/*"))
},
),
), ),
) )
} }
+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.CrashActivity
import eu.kanade.tachiyomi.crash.GlobalExceptionHandler import eu.kanade.tachiyomi.crash.GlobalExceptionHandler
import eu.kanade.tachiyomi.data.coil.BufferedSourceFetcher 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.MangaCoverFetcher
import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer
import eu.kanade.tachiyomi.data.coil.MangaKeyer 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.data.notification.Notifications
import eu.kanade.tachiyomi.di.AppModule import eu.kanade.tachiyomi.di.AppModule
import eu.kanade.tachiyomi.di.PreferenceModule 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.network.NetworkPreferences
import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate
import eu.kanade.tachiyomi.util.system.DeviceUtil 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.WebViewUtil
import eu.kanade.tachiyomi.util.system.animatorDurationScale import eu.kanade.tachiyomi.util.system.animatorDurationScale
import eu.kanade.tachiyomi.util.system.cancelNotification import eu.kanade.tachiyomi.util.system.cancelNotification
@@ -144,14 +143,6 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
.onEach(TelemetryConfig::setCrashlyticsEnabled) .onEach(TelemetryConfig::setCrashlyticsEnabled)
.launchIn(scope) .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()) setAppCompatDelegateThemeMode(Injekt.get<UiPreferences>().themeMode.get())
// Updates widget update // Updates widget update
@@ -192,7 +183,7 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
// NetworkFetcher.Factory // NetworkFetcher.Factory
add(OkHttpNetworkFetcherFactory(callFactoryLazy::value)) add(OkHttpNetworkFetcherFactory(callFactoryLazy::value))
// Decoder.Factory // Decoder.Factory
add(TachiyomiImageDecoder.Factory()) add(ImageDecoder.Factory())
// Fetcher.Factory // Fetcher.Factory
add(BufferedSourceFetcher.Factory()) add(BufferedSourceFetcher.Factory())
add(MangaCoverFetcher.MangaCoverFactory(callFactoryLazy)) 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.components.ChapterNavigatorType
import eu.kanade.presentation.reader.settings.ReaderSettingsDialog import eu.kanade.presentation.reader.settings.ReaderSettingsDialog
import eu.kanade.tachiyomi.R 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.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.databinding.ReaderActivityBinding import eu.kanade.tachiyomi.databinding.ReaderActivityBinding
@@ -868,10 +867,6 @@ class ReaderActivity : BaseActivity() {
} }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
preferences.displayProfile.changes()
.onEach { setDisplayProfile(it) }
.launchIn(lifecycleScope)
readerPreferences.keepScreenOn.changes() readerPreferences.keepScreenOn.changes()
.onEach(::setKeepScreenOn) .onEach(::setKeepScreenOn)
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
@@ -899,25 +894,6 @@ class ReaderActivity : BaseActivity() {
.launchIn(lifecycleScope) .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]. * Sets the keep screen on mode according to [enabled].
*/ */
@@ -60,10 +60,6 @@ open class ReaderPageImageView @JvmOverloads constructor(
private val isWebtoon: Boolean = false, private val isWebtoon: Boolean = false,
) : FrameLayout(context, attrs, defStyleAttrs, defStyleRes) { ) : FrameLayout(context, attrs, defStyleAttrs, defStyleRes) {
private val alwaysDecodeLongStripWithSSIV by lazy {
Injekt.get<BasePreferences>().alwaysDecodeLongStripWithSSIV.get()
}
private var pageView: View? = null private var pageView: View? = null
private var config: Config? = null private var config: Config? = null
@@ -240,7 +236,6 @@ open class ReaderPageImageView @JvmOverloads constructor(
} else { } else {
SubsamplingScaleImageView(context) SubsamplingScaleImageView(context)
}.apply { }.apply {
setMaxTileSize(ImageUtil.hardwareBitmapThreshold)
setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER) setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER)
setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE) setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
setMinimumTileDpi(180) setMinimumTileDpi(180)
@@ -301,8 +296,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
isVisible = true isVisible = true
} }
is BufferedSource -> { is BufferedSource -> {
if (!isWebtoon || alwaysDecodeLongStripWithSSIV) { if (!isWebtoon) {
setHardwareConfig(ImageUtil.canUseHardwareBitmap(data))
setImage(ImageSource.inputStream(data.inputStream())) setImage(ImageSource.inputStream(data.inputStream()))
isVisible = true isVisible = true
return@apply return@apply
@@ -4,7 +4,6 @@ import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.PointF import android.graphics.PointF
import android.util.Log
import android.view.InputDevice import android.view.InputDevice
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
@@ -52,6 +51,8 @@ import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.min import kotlin.math.min
@@ -170,14 +171,14 @@ open class WebGpuViewer(
is TransitionPage -> createTransitionPage(page) is TransitionPage -> createTransitionPage(page)
} }
} catch (e: Exception) { } 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 } synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE }
} }
} }
} catch (_: InterruptedException) { } catch (_: InterruptedException) {
// Normal shutdown // Normal shutdown
} catch (e: Exception) { } 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) { when (state) {
Page.State.Queue, Page.State.LoadPage, Page.State.DownloadImage -> true Page.State.Queue, Page.State.LoadPage, Page.State.DownloadImage -> true
is Page.State.Error -> { is Page.State.Error -> {
Log.e("WebGpuViewer", "Page load error: ${state.error}") logcat(LogPriority.ERROR) { "Page load error: ${state.error}" }
false false
} }
@@ -711,7 +712,7 @@ open class WebGpuViewer(
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("WebGpuViewer", "startPageLoad error", e) logcat(LogPriority.ERROR, e) { "startPageLoad error" }
synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE } synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE }
} }
} }
@@ -762,11 +763,60 @@ open class WebGpuViewer(
Image.Position.SINGLE 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 val pageCount = dec.pages
if (pageCount == 0) { 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 } synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE }
return return
} }
@@ -838,7 +888,7 @@ open class WebGpuViewer(
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("WebGpuViewer", "decodeReaderPage error", e) logcat(LogPriority.ERROR, e) { "decodeReaderPage error" }
synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE } synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE }
} finally { } finally {
imagePage?.cleanup() imagePage?.cleanup()
@@ -921,7 +971,7 @@ open class WebGpuViewer(
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("WebGpuViewer", "createTransitionPage error", e) logcat(LogPriority.ERROR, e) { "createTransitionPage error" }
synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE } synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE }
} }
} }
-1
View File
@@ -32,7 +32,6 @@ dependencies {
api(libs.okio) api(libs.okio)
implementation(libs.image.decoder) implementation(libs.image.decoder)
implementation(libs.image.decoder2)
implementation(libs.unifile) implementation(libs.unifile)
implementation(libs.archive) implementation(libs.archive)
@@ -1,60 +0,0 @@
package eu.kanade.tachiyomi.util.system
import javax.microedition.khronos.egl.EGL10
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.egl.EGLContext
import kotlin.math.max
object GLUtil {
val DEVICE_TEXTURE_LIMIT: Int by lazy {
// Get EGL Display
val egl = EGLContext.getEGL() as EGL10
val display = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY)
// Initialise
val version = IntArray(2)
egl.eglInitialize(display, version)
// Query total number of configurations
val totalConfigurations = IntArray(1)
egl.eglGetConfigs(display, null, 0, totalConfigurations)
// Query actual list configurations
val configurationsList = arrayOfNulls<EGLConfig>(totalConfigurations[0])
egl.eglGetConfigs(display, configurationsList, totalConfigurations[0], totalConfigurations)
val textureSize = IntArray(1)
var maximumTextureSize = 0
// Iterate through all the configurations to located the maximum texture size
for (i in 0..<totalConfigurations[0]) {
// Only need to check for width since opengl textures are always squared
egl.eglGetConfigAttrib(display, configurationsList[i], EGL10.EGL_MAX_PBUFFER_WIDTH, textureSize)
// Keep track of the maximum texture size
if (maximumTextureSize < textureSize[0]) maximumTextureSize = textureSize[0]
}
// Release
egl.eglTerminate(display)
// Return largest texture size found (after making it a multiplier of [Multiplier]), or default
max(maximumTextureSize, SAFE_TEXTURE_LIMIT)
}
const val SAFE_TEXTURE_LIMIT: Int = 2048
val CUSTOM_TEXTURE_LIMIT_OPTIONS: List<Int> by lazy {
val steps = DEVICE_TEXTURE_LIMIT / MULTIPLIER
buildList(steps) {
add(DEVICE_TEXTURE_LIMIT)
for (step in steps downTo 2) {
val value = step * MULTIPLIER
if (value >= DEVICE_TEXTURE_LIMIT) continue
add(value)
}
}
}
}
private const val MULTIPLIER: Int = 1024
@@ -21,13 +21,11 @@ import androidx.core.graphics.drawable.toDrawable
import androidx.core.graphics.get import androidx.core.graphics.get
import androidx.core.graphics.green import androidx.core.graphics.green
import androidx.core.graphics.red import androidx.core.graphics.red
import ca.mpreg.imagedecoder.ImageDecoder
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.system.GLUtil
import logcat.LogPriority import logcat.LogPriority
import okio.Buffer import okio.Buffer
import okio.BufferedSource import okio.BufferedSource
import tachiyomi.decoder.Format
import tachiyomi.decoder.ImageDecoder
import java.io.InputStream import java.io.InputStream
import java.util.Locale import java.util.Locale
import kotlin.math.abs import kotlin.math.abs
@@ -49,17 +47,19 @@ object ImageUtil {
fun findImageType(stream: InputStream): ImageType? { fun findImageType(stream: InputStream): ImageType? {
return try { return try {
when (getImageType(stream)?.format) { val decoder = ImageDecoder.new(stream)
Format.Avif -> ImageType.AVIF when (decoder.format) {
Format.Gif -> ImageType.GIF "jpeg" -> ImageType.JPEG
Format.Heif -> ImageType.HEIF "png" -> ImageType.PNG
Format.Jpeg -> ImageType.JPEG "webp" -> ImageType.WEBP
Format.Jxl -> ImageType.JXL "gif" -> ImageType.GIF
Format.Png -> ImageType.PNG "heif" -> ImageType.HEIF
Format.Webp -> ImageType.WEBP "jxl" -> ImageType.JXL
"jp2" -> ImageType.JP2
else -> null else -> null
} }
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR) { "findImageType: ${e.message}" }
null null
} }
} }
@@ -71,38 +71,23 @@ object ImageUtil {
fun isAnimatedAndSupported(source: BufferedSource): Boolean { fun isAnimatedAndSupported(source: BufferedSource): Boolean {
return try { return try {
val type = getImageType(source.peek().inputStream()) ?: return false val type = findImageType(source.peek().inputStream()) ?: return false
// https://coil-kt.github.io/coil/getting_started/#supported-image-formats when (type) {
when (type.format) { ImageType.GIF -> true
Format.Gif -> true ImageType.WEBP, ImageType.HEIF -> {
// Animated WebP on Android 9+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return false
Format.Webp -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P val decoder = ImageDecoder.new(source.peek().inputStream())
// Animated Heif on Android 11+ decoder.pages > 1
Format.Heif -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R }
else -> false else -> false
} }
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR) { "isAnimatedAndSupported: ${e.message}" }
false false
} }
} }
private fun getImageType(stream: InputStream): tachiyomi.decoder.ImageType? {
val bytes = ByteArray(32)
val length = if (stream.markSupported()) {
stream.mark(bytes.size)
stream.read(bytes, 0, bytes.size).also { stream.reset() }
} else {
stream.read(bytes, 0, bytes.size)
}
if (length == -1) {
return null
}
return ImageDecoder.findType(bytes)
}
enum class ImageType(val mime: String, val extension: String) { enum class ImageType(val mime: String, val extension: String) {
AVIF("image/avif", "avif"), AVIF("image/avif", "avif"),
GIF("image/gif", "gif"), GIF("image/gif", "gif"),
@@ -111,6 +96,8 @@ object ImageUtil {
JXL("image/jxl", "jxl"), JXL("image/jxl", "jxl"),
PNG("image/png", "png"), PNG("image/png", "png"),
WEBP("image/webp", "webp"), WEBP("image/webp", "webp"),
JP2("image/jp2", "jp2"),
JPX("image/jpx", "jpx"),
} }
/** /**
@@ -312,30 +299,23 @@ object ImageUtil {
val bottomOffset = topOffset + splitHeight val bottomOffset = topOffset + splitHeight
} }
fun canUseHardwareBitmap(bitmap: Bitmap): Boolean {
return canUseHardwareBitmap(bitmap.width, bitmap.height)
}
fun canUseHardwareBitmap(imageSource: BufferedSource): Boolean {
return with(extractImageOptions(imageSource)) {
canUseHardwareBitmap(outWidth, outHeight)
}
}
var hardwareBitmapThreshold: Int = GLUtil.SAFE_TEXTURE_LIMIT
private fun canUseHardwareBitmap(width: Int, height: Int): Boolean {
if (HARDWARE_BITMAP_UNSUPPORTED) return false
return maxOf(width, height) <= hardwareBitmapThreshold
}
/** /**
* Algorithm for determining what background to accompany a comic/manga page * Algorithm for determining what background to accompany a comic/manga page
*/ */
fun chooseBackground(context: Context, imageStream: InputStream): Drawable { fun chooseBackground(context: Context, imageStream: InputStream): Drawable {
val decoder = ImageDecoder.newInstance(imageStream) val decoder = try {
val image = decoder?.decode() ImageDecoder.new(imageStream)
decoder?.recycle() } catch (e: Exception) {
logcat(LogPriority.ERROR) { "chooseBackground: ${e.message}" }
null
}
val result = decoder?.decode()
val image = result?.let {
createBitmap(it.width, it.height).also { bitmap ->
it.image.rewind()
bitmap.copyPixelsFromBuffer(it.image)
}
}
val whiteColor = Color.WHITE val whiteColor = Color.WHITE
if (image == null) return whiteColor.toDrawable() if (image == null) return whiteColor.toDrawable()
@@ -575,121 +555,6 @@ object ImageUtil {
} }
private val optimalImageHeight = getDisplayMaxHeightInPx * 2 private val optimalImageHeight = getDisplayMaxHeightInPx * 2
/**
* Taken from Coil
* (https://github.com/coil-kt/coil/blob/1674d3516f061aeacbe749a435b1924f9648fd41/coil-core/src/androidMain/kotlin/coil3/util/hardwareBitmaps.kt)
* ---
* Maintains a list of devices with broken/incomplete/unstable hardware bitmap implementations.
*
* Model names are retrieved from
* [Google's official device list](https://support.google.com/googleplay/answer/1727131?hl=en).
*
*/
val HARDWARE_BITMAP_UNSUPPORTED = when (Build.VERSION.SDK_INT) {
26 -> run {
val model = Build.MODEL ?: return@run false
// Samsung Galaxy (ALL)
if (model.removePrefix("SAMSUNG-").startsWith("SM-")) return@run true
val device = Build.DEVICE ?: return@run false
return@run device in arrayOf(
"nora", "nora_8917", "nora_8917_n", // Moto E5
"james", "rjames_f", "rjames_go", "pettyl", // Moto E5 Play
"hannah", "ahannah", "rhannah", // Moto E5 Plus
"ali", "ali_n", // Moto G6
"aljeter", "aljeter_n", "jeter", // Moto G6 Play
"evert", "evert_n", "evert_nt", // Moto G6 Plus
"G3112", "G3116", "G3121", "G3123", "G3125", // Xperia XA1
"G3412", "G3416", "G3421", "G3423", "G3426", // Xperia XA1 Plus
"G3212", "G3221", "G3223", "G3226", // Xperia XA1 Ultra
"BV6800Pro", // BlackView BV6800Pro
"CatS41", // Cat S41
"Hi9Pro", // CHUWI Hi9 Pro
"manning", // Lenovo K8 Note
"N5702L", // NUU Mobile G3
)
}
27 -> run {
val device = Build.DEVICE ?: return@run false
return@run device in arrayOf(
"mcv1s", // LG Tribute Empire
"mcv3", // LG K11
"mcv5a", // LG Q7
"mcv7a", // LG Stylo 4
"A30ATMO", // T-Mobile REVVL 2
"A70AXLTMO", // T-Mobile REVVL 2 PLUS
"A3A_8_4G_TMO", // Alcatel 9027W
"Edison_CKT", // Alcatel ONYX
"EDISON_TF", // Alcatel TCL XL2
"FERMI_TF", // Alcatel A501DL
"U50A_ATT", // Alcatel TETRA
"U50A_PLUS_ATT", // Alcatel 5059R
"U50A_PLUS_TF", // Alcatel TCL LX
"U50APLUSTMO", // Alcatel 5059Z
"U5A_PLUS_4G", // Alcatel 1X
"RCT6513W87DK5e", // RCA Galileo Pro
"RCT6873W42BMF9A", // RCA Voyager
"RCT6A03W13", // RCA 10 Viking
"RCT6B03W12", // RCA Atlas 10 Pro
"RCT6B03W13", // RCA Atlas 10 Pro+
"RCT6T06E13", // RCA Artemis 10
"A3_Pro", // Umidigi A3 Pro
"One", // Umidigi One
"One_Max", // Umidigi One Max
"One_Pro", // Umidigi One Pro
"Z2", // Umidigi Z2
"Z2_PRO", // Umidigi Z2 Pro
"Armor_3", // Ulefone Armor 3
"Armor_6", // Ulefone Armor 6
"Blackview", // Blackview BV6000
"BV9500", // Blackview BV9500
"BV9500Pro", // Blackview BV9500Pro
"A6L-C", // Nuu A6L-C
"N5002LA", // Nuu A7L
"N5501LA", // Nuu A5L
"Power_2_Pro", // Leagoo Power 2 Pro
"Power_5", // Leagoo Power 5
"Z9", // Leagoo Z9
"V0310WW", // Blu VIVO VI+
"V0330WW", // Blu VIVO XI
"A3", // BenQ A3
"ASUS_X018_4", // Asus ZenFone Max Plus M1 (ZB570TL)
"C210AE", // Wiko Life
"fireball", // DROID Incredible 4G LTE
"ILA_X1", // iLA X1
"Infinix-X605_sprout", // Infinix NOTE 5 Stylus
"j7maxlte", // Samsung Galaxy J7 Max
"KING_KONG_3", // Cubot King Kong 3
"M10500", // Packard Bell M10500
"S70", // Altice ALTICE S70
"S80Lite", // Doogee S80Lite
"SGINO6", // SGiNO 6
"st18c10bnn", // Barnes and Noble BNTV650
"TECNO-CA8", // Tecno CAMON X Pro,
"SHIFT6m", // SHIFT 6m
)
}
else -> false
}
} }
val getDisplayMaxHeightInPx: Int val getDisplayMaxHeightInPx: Int
+4 -6
View File
@@ -39,8 +39,7 @@ firebase-bom = "34.17.0"
firebase-crashlytics = "3.0.7" firebase-crashlytics = "3.0.7"
flexibleAdapter = "c8013533" flexibleAdapter = "c8013533"
google-services = "4.5.0" google-services = "4.5.0"
image-decoder = "e03b81e18a" image-decoder = "9"
imagedecoder2 = "9"
injekt = "91edab2317" injekt = "91edab2317"
jsoup = "1.23.1" jsoup = "1.23.1"
junit = "6.1.3" junit = "6.1.3"
@@ -69,7 +68,7 @@ spotless = "8.9.0"
sqldelight = "2.3.2" sqldelight = "2.3.2"
sqldelight-androidx-driver = "0637a964a1" sqldelight-androidx-driver = "0637a964a1"
stringSimilarity = "0.1.0" stringSimilarity = "0.1.0"
subsamplingScaleImageView = "66e0db195d" subsamplingScaleImageView = "94915e6f73"
swipe = "1.3.0" swipe = "1.3.0"
tapmoc = "0.4.2" tapmoc = "0.4.2"
unifile = "08f224c8f9" unifile = "08f224c8f9"
@@ -135,8 +134,7 @@ firebase-analytics = { module = "com.google.firebase:firebase-analytics" }
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" }
firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" }
flexibleAdapter = { module = "com.github.arkon.FlexibleAdapter:flexible-adapter", version.ref = "flexibleAdapter" } flexibleAdapter = { module = "com.github.arkon.FlexibleAdapter:flexible-adapter", version.ref = "flexibleAdapter" }
image-decoder = { module = "com.github.mihonapp:image-decoder", version.ref = "image-decoder" } image-decoder = { module = "ca.mpreg:imagedecoder", version.ref = "image-decoder" }
image-decoder2 = { module = "ca.mpreg:imagedecoder", version.ref = "imagedecoder2" }
injekt = { module = "com.github.mihonapp:injekt", version.ref = "injekt" } injekt = { module = "com.github.mihonapp:injekt", version.ref = "injekt" }
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
@@ -184,7 +182,7 @@ sqldelight-async = { module = "app.cash.sqldelight:async-extensions", version.re
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqldelight-sqliteDialect338 = { module = "app.cash.sqldelight:sqlite-3-38-dialect", version.ref = "sqldelight" } sqldelight-sqliteDialect338 = { module = "app.cash.sqldelight:sqlite-3-38-dialect", version.ref = "sqldelight" }
stringSimilarity = { module = "com.aallam.similarity:string-similarity-kotlin", version.ref = "stringSimilarity" } stringSimilarity = { module = "com.aallam.similarity:string-similarity-kotlin", version.ref = "stringSimilarity" }
subsamplingScaleImageView = { module = "com.github.tachiyomiorg:subsampling-scale-image-view", version.ref = "subsamplingScaleImageView" } subsamplingScaleImageView = { module = "com.github.mihonapp:subsampling-scale-image-view", version.ref = "subsamplingScaleImageView" }
swipe = { module = "me.saket.swipe:swipe", version.ref = "swipe" } swipe = { module = "me.saket.swipe:swipe", version.ref = "swipe" }
tapmoc-gradle = { module = "com.gradleup.tapmoc:tapmoc-gradle-plugin", version.ref = "tapmoc" } tapmoc-gradle = { module = "com.gradleup.tapmoc:tapmoc-gradle-plugin", version.ref = "tapmoc" }
unifile = { module = "com.github.mihon:unifile", version.ref = "unifile" } unifile = { module = "com.github.mihon:unifile", version.ref = "unifile" }
@@ -408,11 +408,6 @@
<string name="pref_show_reading_mode">Show reading mode</string> <string name="pref_show_reading_mode">Show reading mode</string>
<string name="pref_show_reading_mode_summary">Briefly show current mode when reader is opened</string> <string name="pref_show_reading_mode_summary">Briefly show current mode when reader is opened</string>
<string name="pref_high_quality_renderer">Use high quality renderer</string> <string name="pref_high_quality_renderer">Use high quality renderer</string>
<string name="pref_hardware_bitmap_threshold">Custom hardware bitmap threshold</string>
<string name="pref_hardware_bitmap_threshold_default">Default (%d)</string>
<string name="pref_hardware_bitmap_threshold_summary">If reader loads a blank image incrementally reduce the threshold.\nSelected: %s</string>
<string name="pref_always_decode_long_strip_with_ssiv_2">Use legacy decoder for long strip reader</string>
<string name="pref_always_decode_long_strip_with_ssiv_summary">Affects performance. Only enable if reducing bitmap threshold doesn\'t fix blank image issues</string>
<string name="pref_display_profile">Custom display profile</string> <string name="pref_display_profile">Custom display profile</string>
<string name="pref_crop_borders">Crop borders</string> <string name="pref_crop_borders">Crop borders</string>
<string name="pref_custom_brightness">Custom brightness</string> <string name="pref_custom_brightness">Custom brightness</string>