diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c51e90dd8..511cc05f9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -291,7 +291,6 @@ dependencies { } implementation(libs.image.decoder) - implementation(libs.image.decoder2) implementation(libs.webgpuviewer) implementation(libs.kim) diff --git a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt index 5bffd41a3..ecaf9e811 100644 --- a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt @@ -2,7 +2,6 @@ package eu.kanade.domain.base import android.content.Context import dev.icerock.moko.resources.StringResource -import eu.kanade.tachiyomi.util.system.GLUtil import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.i18n.MR @@ -35,18 +34,6 @@ class BasePreferences( val highQualityRenderer: Preference = preferenceStore.getBoolean("pref_high_quality_renderer_key", false) - val displayProfile: Preference = preferenceStore.getString("pref_display_profile_key", "") - - val hardwareBitmapThreshold: Preference = preferenceStore.getInt( - "pref_hardware_bitmap_threshold", - GLUtil.SAFE_TEXTURE_LIMIT, - ) - - val alwaysDecodeLongStripWithSSIV: Preference = preferenceStore.getBoolean( - "pref_always_decode_long_strip_with_ssiv", - false, - ) - val installationId: Preference = preferenceStore.getString(Preference.appStateKey("installation_id"), "") val donationCampaignShown: Preference = preferenceStore.getBoolean( diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt index d675c2f30..ec2043c6e 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt @@ -53,7 +53,7 @@ import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.DropdownMenu 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.ui.reader.viewer.ReaderPageImageView import kotlinx.coroutines.runBlocking @@ -173,7 +173,7 @@ fun MangaCoverDialog( .memoryCachePolicy(CachePolicy.DISABLED) .newDecoder(true) .target { result -> - val res = (result as ImageDecoder2.DecodeResultImage).res + val res = (result as ImageDecoder.DecodeResultImage).res val page = runBlocking(WebGpuRenderer.dispatcher) { ImagePage(res.image, res.width, res.height) }.apply { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt index d38e6f5e4..1da71e31e 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt @@ -47,7 +47,6 @@ import eu.kanade.tachiyomi.network.PREF_DOH_QUAD9 import eu.kanade.tachiyomi.network.PREF_DOH_SHECAN import eu.kanade.tachiyomi.ui.more.OnboardingScreen 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.isShizukuInstalled import eu.kanade.tachiyomi.util.system.powerManager @@ -334,15 +333,6 @@ object SettingsAdvancedScreen : SearchableSettings { basePreferences: BasePreferences, ): Preference.PreferenceGroup { 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( title = stringResource(MR.strings.pref_category_reader), preferenceItems = listOf( @@ -350,37 +340,6 @@ object SettingsAdvancedScreen : SearchableSettings { preference = basePreferences.highQualityRenderer, 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("*/*")) - }, - ), ), ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index d76f57915..96f4b7a40 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -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().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)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt new file mode 100644 index 000000000..aa2a42170 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt @@ -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() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/TachiyomiImageDecoder.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/TachiyomiImageDecoder.kt deleted file mode 100644 index b5896aa62..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/TachiyomiImageDecoder.kt +++ /dev/null @@ -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, - ) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index a3c1a7a79..0955fec46 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -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]. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt index 6a9deec6e..3dc33deb1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt @@ -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().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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt index 3ef216fd8..c392c8e63 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt @@ -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() + 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 } } } diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index e38d17a5e..e5b8eb7e0 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -32,7 +32,6 @@ dependencies { api(libs.okio) implementation(libs.image.decoder) - implementation(libs.image.decoder2) implementation(libs.unifile) implementation(libs.archive) diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/util/system/GLUtil.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/util/system/GLUtil.kt deleted file mode 100644 index f78932a68..000000000 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/util/system/GLUtil.kt +++ /dev/null @@ -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(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.. 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 diff --git a/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt b/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt index 275590d55..97885e05a 100644 --- a/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt +++ b/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt @@ -21,13 +21,11 @@ import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.get import androidx.core.graphics.green import androidx.core.graphics.red +import ca.mpreg.imagedecoder.ImageDecoder import com.hippo.unifile.UniFile -import eu.kanade.tachiyomi.util.system.GLUtil import logcat.LogPriority import okio.Buffer import okio.BufferedSource -import tachiyomi.decoder.Format -import tachiyomi.decoder.ImageDecoder import java.io.InputStream import java.util.Locale import kotlin.math.abs @@ -49,17 +47,19 @@ object ImageUtil { fun findImageType(stream: InputStream): ImageType? { return try { - when (getImageType(stream)?.format) { - Format.Avif -> ImageType.AVIF - Format.Gif -> ImageType.GIF - Format.Heif -> ImageType.HEIF - Format.Jpeg -> ImageType.JPEG - Format.Jxl -> ImageType.JXL - Format.Png -> ImageType.PNG - Format.Webp -> ImageType.WEBP + val decoder = ImageDecoder.new(stream) + when (decoder.format) { + "jpeg" -> ImageType.JPEG + "png" -> ImageType.PNG + "webp" -> ImageType.WEBP + "gif" -> ImageType.GIF + "heif" -> ImageType.HEIF + "jxl" -> ImageType.JXL + "jp2" -> ImageType.JP2 else -> null } } catch (e: Exception) { + logcat(LogPriority.ERROR) { "findImageType: ${e.message}" } null } } @@ -71,38 +71,23 @@ object ImageUtil { fun isAnimatedAndSupported(source: BufferedSource): Boolean { return try { - val type = getImageType(source.peek().inputStream()) ?: return false - // https://coil-kt.github.io/coil/getting_started/#supported-image-formats - when (type.format) { - Format.Gif -> true - // Animated WebP on Android 9+ - Format.Webp -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P - // Animated Heif on Android 11+ - Format.Heif -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + val type = findImageType(source.peek().inputStream()) ?: return false + when (type) { + ImageType.GIF -> true + ImageType.WEBP, ImageType.HEIF -> { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return false + val decoder = ImageDecoder.new(source.peek().inputStream()) + decoder.pages > 1 + } + else -> false } } catch (e: Exception) { + logcat(LogPriority.ERROR) { "isAnimatedAndSupported: ${e.message}" } 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) { AVIF("image/avif", "avif"), GIF("image/gif", "gif"), @@ -111,6 +96,8 @@ object ImageUtil { JXL("image/jxl", "jxl"), PNG("image/png", "png"), WEBP("image/webp", "webp"), + JP2("image/jp2", "jp2"), + JPX("image/jpx", "jpx"), } /** @@ -312,30 +299,23 @@ object ImageUtil { 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 */ fun chooseBackground(context: Context, imageStream: InputStream): Drawable { - val decoder = ImageDecoder.newInstance(imageStream) - val image = decoder?.decode() - decoder?.recycle() + val decoder = try { + ImageDecoder.new(imageStream) + } 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 if (image == null) return whiteColor.toDrawable() @@ -575,121 +555,6 @@ object ImageUtil { } 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7e8c12327..edb8a9251 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,8 +39,7 @@ firebase-bom = "34.17.0" firebase-crashlytics = "3.0.7" flexibleAdapter = "c8013533" google-services = "4.5.0" -image-decoder = "e03b81e18a" -imagedecoder2 = "9" +image-decoder = "9" injekt = "91edab2317" jsoup = "1.23.1" junit = "6.1.3" @@ -69,7 +68,7 @@ spotless = "8.9.0" sqldelight = "2.3.2" sqldelight-androidx-driver = "0637a964a1" stringSimilarity = "0.1.0" -subsamplingScaleImageView = "66e0db195d" +subsamplingScaleImageView = "94915e6f73" swipe = "1.3.0" tapmoc = "0.4.2" 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-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } flexibleAdapter = { module = "com.github.arkon.FlexibleAdapter:flexible-adapter", version.ref = "flexibleAdapter" } -image-decoder = { module = "com.github.mihonapp:image-decoder", version.ref = "image-decoder" } -image-decoder2 = { module = "ca.mpreg:imagedecoder", version.ref = "imagedecoder2" } +image-decoder = { module = "ca.mpreg:imagedecoder", version.ref = "image-decoder" } injekt = { module = "com.github.mihonapp:injekt", version.ref = "injekt" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } 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-sqliteDialect338 = { module = "app.cash.sqldelight:sqlite-3-38-dialect", version.ref = "sqldelight" } 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" } tapmoc-gradle = { module = "com.gradleup.tapmoc:tapmoc-gradle-plugin", version.ref = "tapmoc" } unifile = { module = "com.github.mihon:unifile", version.ref = "unifile" } diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 6e9bb354b..bf1f5580f 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -408,11 +408,6 @@ Show reading mode Briefly show current mode when reader is opened Use high quality renderer - Custom hardware bitmap threshold - Default (%d) - If reader loads a blank image incrementally reduce the threshold.\nSelected: %s - Use legacy decoder for long strip reader - Affects performance. Only enable if reducing bitmap threshold doesn\'t fix blank image issues Custom display profile Crop borders Custom brightness