diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt index e3953a555..437e9b230 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt @@ -27,10 +27,7 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.reader.domain.ReaderColorFilter -import org.koitharu.kotatsu.utils.ext.assistedViewModels -import org.koitharu.kotatsu.utils.ext.enqueueWith -import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat -import org.koitharu.kotatsu.utils.ext.referer +import org.koitharu.kotatsu.utils.ext.* @AndroidEntryPoint class ColorFilterConfigActivity : @@ -117,6 +114,7 @@ class ColorFilterConfigActivity : .data(preview.url) .referer(preview.referer) .scale(Scale.FILL) + .decodeRegion() .error(R.drawable.ic_error_placeholder) .size(ViewSizeResolver(binding.imageViewBefore)) .allowRgb565(false) diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt index e310ee2ca..212d12963 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt @@ -14,6 +14,7 @@ import org.koitharu.kotatsu.databinding.ItemPageThumbBinding import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail +import org.koitharu.kotatsu.utils.ext.decodeRegion import org.koitharu.kotatsu.utils.ext.isLowRamDevice import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.setTextColorAttr @@ -30,7 +31,7 @@ fun pageThumbnailAD( val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width) val thumbSize = Size( width = gridWidth, - height = (gridWidth * 13f / 18f).toInt(), + height = (gridWidth / 13f * 18f).toInt(), ) suspend fun loadPageThumbnail(item: PageThumbnail): Drawable? = withContext(Dispatchers.Default) { @@ -52,6 +53,7 @@ fun pageThumbnailAD( ImageRequest.Builder(context) .data(file) .size(thumbSize) + .decodeRegion() .allowRgb565(isLowRamDevice(context)) .build(), ).drawable diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt index 0950a69a4..914947ba2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt @@ -13,6 +13,7 @@ import com.google.android.material.progressindicator.BaseProgressIndicator import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.utils.image.RegionBitmapDecoder import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener fun ImageView.newImageRequest(url: Any?): ImageRequest.Builder? { @@ -66,6 +67,10 @@ fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRe return listener(ImageRequestIndicatorListener(indicator)) } +fun ImageRequest.Builder.decodeRegion(): ImageRequest.Builder { + return decoderFactory(RegionBitmapDecoder.Factory()) +} + @Suppress("SpellCheckingInspection") fun ImageRequest.Builder.crossfade(context: Context?): ImageRequest.Builder { if (context == null) { diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/image/RegionBitmapDecoder.kt b/app/src/main/java/org/koitharu/kotatsu/utils/image/RegionBitmapDecoder.kt new file mode 100644 index 000000000..891ae70fd --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/image/RegionBitmapDecoder.kt @@ -0,0 +1,168 @@ +package org.koitharu.kotatsu.utils.image + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.BitmapRegionDecoder +import android.graphics.Rect +import android.os.Build +import androidx.core.graphics.drawable.toDrawable +import coil.ImageLoader +import coil.decode.DecodeResult +import coil.decode.DecodeUtils +import coil.decode.Decoder +import coil.decode.ImageSource +import coil.fetch.SourceResult +import coil.request.Options +import coil.size.* +import kotlin.math.roundToInt +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit + +class RegionBitmapDecoder( + private val source: ImageSource, + private val options: Options, + private val parallelismLock: Semaphore, +) : Decoder { + + override suspend fun decode() = parallelismLock.withPermit { + runInterruptible { BitmapFactory.Options().decode() } + } + + private fun BitmapFactory.Options.decode(): DecodeResult { + val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + BitmapRegionDecoder.newInstance(source.source().inputStream()) + } else { + @Suppress("DEPRECATION") + BitmapRegionDecoder.newInstance(source.source().inputStream(), false) + } + checkNotNull(regionDecoder) + try { + val rect = configureScale(regionDecoder.width, regionDecoder.height) + configureConfig() + val bitmap = regionDecoder.decodeRegion(rect, this) + bitmap.density = options.context.resources.displayMetrics.densityDpi + return DecodeResult( + drawable = bitmap.toDrawable(options.context.resources), + isSampled = true, + ) + } finally { + regionDecoder.recycle() + } + } + + private fun BitmapFactory.Options.configureConfig() { + var config = options.config + + inMutable = false + + if (Build.VERSION.SDK_INT >= 26 && options.colorSpace != null) { + inPreferredColorSpace = options.colorSpace + } + inPremultiplied = options.premultipliedAlpha + + // Decode the image as RGB_565 as an optimization if allowed. + if (options.allowRgb565 && config == Bitmap.Config.ARGB_8888 && outMimeType == "image/jpeg") { + config = Bitmap.Config.RGB_565 + } + + // High color depth images must be decoded as either RGBA_F16 or HARDWARE. + if (Build.VERSION.SDK_INT >= 26 && outConfig == Bitmap.Config.RGBA_F16 && config != Bitmap.Config.HARDWARE) { + config = Bitmap.Config.RGBA_F16 + } + + inPreferredConfig = config + } + + /** Compute and set the scaling properties for [BitmapFactory.Options]. */ + private fun BitmapFactory.Options.configureScale(srcWidth: Int, srcHeight: Int): Rect { + val dstWidth = options.size.widthPx(options.scale) { srcWidth } + val dstHeight = options.size.heightPx(options.scale) { srcHeight } + + val srcRatio = srcWidth / srcHeight.toDouble() + val dstRatio = dstWidth / dstHeight.toDouble() + val rect = if (srcRatio < dstRatio) { + // probably manga + Rect(0, 0, srcWidth, (srcWidth / dstRatio).toInt()) + } else { + Rect(0, 0, (srcHeight / dstRatio).toInt(), srcHeight) + } + rect.offsetTo( + (srcWidth - rect.width()) / 2, + (srcHeight - rect.height()) / 2, + ) + + // Calculate the image's sample size. + inSampleSize = DecodeUtils.calculateInSampleSize( + srcWidth = rect.width(), + srcHeight = rect.height(), + dstWidth = dstWidth, + dstHeight = dstHeight, + scale = options.scale, + ) + + // Calculate the image's density scaling multiple. + var scale = DecodeUtils.computeSizeMultiplier( + srcWidth = rect.width() / inSampleSize.toDouble(), + srcHeight = rect.height() / inSampleSize.toDouble(), + dstWidth = dstWidth.toDouble(), + dstHeight = dstHeight.toDouble(), + scale = options.scale, + ) + + // Only upscale the image if the options require an exact size. + if (options.allowInexactSize) { + scale = scale.coerceAtMost(1.0) + } + + inScaled = scale != 1.0 + if (inScaled) { + if (scale > 1) { + // Upscale + inDensity = (Int.MAX_VALUE / scale).roundToInt() + inTargetDensity = Int.MAX_VALUE + } else { + // Downscale + inDensity = Int.MAX_VALUE + inTargetDensity = (Int.MAX_VALUE * scale).roundToInt() + } + } + return rect + } + + class Factory( + maxParallelism: Int = DEFAULT_MAX_PARALLELISM, + ) : Decoder.Factory { + + @Suppress("NEWER_VERSION_IN_SINCE_KOTLIN") + @SinceKotlin("999.9") // Only public in Java. + constructor() : this() + + private val parallelismLock = Semaphore(maxParallelism) + + override fun create(result: SourceResult, options: Options, imageLoader: ImageLoader): Decoder { + return RegionBitmapDecoder(result.source, options, parallelismLock) + } + + override fun equals(other: Any?) = other is Factory + + override fun hashCode() = javaClass.hashCode() + } +} + +private const val DEFAULT_MAX_PARALLELISM = 4 + +private inline fun Size.widthPx(scale: Scale, original: () -> Int): Int { + return if (isOriginal) original() else width.toPx(scale) +} + +private inline fun Size.heightPx(scale: Scale, original: () -> Int): Int { + return if (isOriginal) original() else height.toPx(scale) +} + +private fun Dimension.toPx(scale: Scale) = pxOrElse { + when (scale) { + Scale.FILL -> Int.MIN_VALUE + Scale.FIT -> Int.MAX_VALUE + } +} diff --git a/app/src/main/res/layout/sheet_reader_config.xml b/app/src/main/res/layout/sheet_reader_config.xml index c31c5f5c5..d99bf50ea 100644 --- a/app/src/main/res/layout/sheet_reader_config.xml +++ b/app/src/main/res/layout/sheet_reader_config.xml @@ -123,6 +123,7 @@ android:singleLine="true" android:text="@string/automatic_scroll" android:textAppearance="?attr/textAppearanceButton" + android:textColor="@color/list_item_text_color" app:drawableStartCompat="@drawable/ic_timer" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@id/slider_timer"