From b152e3881bffd9050a8a0ed4030823886e3fe04f Mon Sep 17 00:00:00 2001 From: FooIbar <118464521+FooIbar@users.noreply.github.com> Date: Sat, 20 Apr 2024 12:52:40 +0800 Subject: [PATCH] Use Okio instead of `java.io` for image processing (#691) --- app/src/main/java/eu/kanade/tachiyomi/App.kt | 2 + .../data/coil/BufferedSourceFetcher.kt | 38 +++++++++ .../ui/reader/viewer/ReaderPageImageView.kt | 26 +++--- .../ui/reader/viewer/pager/PagerPageHolder.kt | 81 +++++++++---------- .../viewer/webtoon/WebtoonPageHolder.kt | 39 ++++----- .../core/common/util/system/ImageUtil.kt | 73 +++++++---------- 6 files changed, 132 insertions(+), 127 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/coil/BufferedSourceFetcher.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index 12170d8a5..558ad213e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -27,6 +27,7 @@ import eu.kanade.domain.ui.UiPreferences import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode 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.MangaCoverFetcher import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer import eu.kanade.tachiyomi.data.coil.MangaKeyer @@ -162,6 +163,7 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor add(MangaCoverFetcher.MangaCoverFactory(callFactoryLazy)) add(MangaKeyer()) add(MangaCoverKeyer()) + add(BufferedSourceFetcher.Factory()) } crossfade((300 * this@App.animatorDurationScale).toInt()) allowRgb565(DeviceUtil.isLowRamDevice(this@App)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/BufferedSourceFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/BufferedSourceFetcher.kt new file mode 100644 index 000000000..4bee925ed --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/BufferedSourceFetcher.kt @@ -0,0 +1,38 @@ +package eu.kanade.tachiyomi.data.coil + +import coil3.ImageLoader +import coil3.decode.DataSource +import coil3.decode.ImageSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.SourceFetchResult +import coil3.request.Options +import okio.BufferedSource + +class BufferedSourceFetcher( + private val data: BufferedSource, + private val options: Options, +) : Fetcher { + + override suspend fun fetch(): FetchResult { + return SourceFetchResult( + source = ImageSource( + source = data, + fileSystem = options.fileSystem, + ), + mimeType = null, + dataSource = DataSource.MEMORY, + ) + } + + class Factory : Fetcher.Factory { + + override fun create( + data: BufferedSource, + options: Options, + imageLoader: ImageLoader, + ): Fetcher { + return BufferedSourceFetcher(data, options) + } + } +} 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 2cd0f2840..0c83d97ea 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 @@ -33,8 +33,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonSubsamplingImageView import eu.kanade.tachiyomi.util.system.GLUtil import eu.kanade.tachiyomi.util.system.animatorDurationScale import eu.kanade.tachiyomi.util.view.isVisibleOnScreen -import java.io.InputStream -import java.nio.ByteBuffer +import okio.BufferedSource /** * A wrapper view for showing page image. @@ -146,14 +145,14 @@ open class ReaderPageImageView @JvmOverloads constructor( } } - fun setImage(inputStream: InputStream, isAnimated: Boolean, config: Config) { + fun setImage(source: BufferedSource, isAnimated: Boolean, config: Config) { this.config = config if (isAnimated) { prepareAnimatedImageView() - setAnimatedImage(inputStream, config) + setAnimatedImage(source, config) } else { prepareNonAnimatedImageView() - setNonAnimatedImage(inputStream, config) + setNonAnimatedImage(source, config) } } @@ -262,7 +261,7 @@ open class ReaderPageImageView @JvmOverloads constructor( } private fun setNonAnimatedImage( - image: Any, + data: Any, config: Config, ) = (pageView as? SubsamplingScaleImageView)?.apply { setDoubleTapZoomDuration(config.zoomDuration.getSystemScaledDuration()) @@ -283,10 +282,10 @@ open class ReaderPageImageView @JvmOverloads constructor( }, ) - when (image) { - is BitmapDrawable -> setImage(ImageSource.bitmap(image.bitmap)) - is InputStream -> setImage(ImageSource.inputStream(image)) - else -> throw IllegalArgumentException("Not implemented for class ${image::class.simpleName}") + when (data) { + is BitmapDrawable -> setImage(ImageSource.bitmap(data.bitmap)) + is BufferedSource -> setImage(ImageSource.inputStream(data.inputStream())) + else -> throw IllegalArgumentException("Not implemented for class ${data::class.simpleName}") } isVisible = true } @@ -331,18 +330,13 @@ open class ReaderPageImageView @JvmOverloads constructor( } private fun setAnimatedImage( - image: Any, + data: Any, config: Config, ) = (pageView as? AppCompatImageView)?.apply { if (this is PhotoView) { setZoomTransitionDuration(config.zoomDuration.getSystemScaledDuration()) } - val data = when (image) { - is Drawable -> image - is InputStream -> ByteBuffer.wrap(image.readBytes()) - else -> throw IllegalArgumentException("Not implemented for class ${image::class.simpleName}") - } val request = ImageRequest.Builder(context) .data(data) .memoryCachePolicy(CachePolicy.DISABLED) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt index 7b6d1dd6a..71d499c38 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt @@ -18,14 +18,13 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import logcat.LogPriority +import okio.Buffer +import okio.BufferedSource import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.logcat -import java.io.BufferedInputStream -import java.io.ByteArrayInputStream -import java.io.InputStream /** * View of the ViewPager that contains a page of a chapter. @@ -139,38 +138,30 @@ class PagerPageHolder( val streamFn = page.stream ?: return try { - val (bais, isAnimated, background) = withIOContext { - streamFn().buffered(16).use { stream -> - process(item, stream).use { itemStream -> - val bais = ByteArrayInputStream(itemStream.readBytes()) - val isAnimated = ImageUtil.isAnimatedAndSupported(bais) - bais.reset() - val background = if (!isAnimated && viewer.config.automaticBackground) { - ImageUtil.chooseBackground(context, bais) - } else { - null - } - bais.reset() - Triple(bais, isAnimated, background) - } + val (source, isAnimated, background) = withIOContext { + val source = streamFn().use { process(item, Buffer().readFrom(it)) } + val isAnimated = ImageUtil.isAnimatedAndSupported(source) + val background = if (!isAnimated && viewer.config.automaticBackground) { + ImageUtil.chooseBackground(context, source.peek().inputStream()) + } else { + null } + Triple(source, isAnimated, background) } withUIContext { - bais.use { - setImage( - it, - isAnimated, - Config( - zoomDuration = viewer.config.doubleTapAnimDuration, - minimumScaleType = viewer.config.imageScaleType, - cropBorders = viewer.config.imageCropBorders, - zoomStartPosition = viewer.config.imageZoomType, - landscapeZoom = viewer.config.landscapeZoom, - ), - ) - if (!isAnimated) { - pageBackground = background - } + setImage( + source, + isAnimated, + Config( + zoomDuration = viewer.config.doubleTapAnimDuration, + minimumScaleType = viewer.config.imageScaleType, + cropBorders = viewer.config.imageCropBorders, + zoomStartPosition = viewer.config.imageZoomType, + landscapeZoom = viewer.config.landscapeZoom, + ), + ) + if (!isAnimated) { + pageBackground = background } removeErrorLayout() } @@ -182,40 +173,40 @@ class PagerPageHolder( } } - private fun process(page: ReaderPage, imageStream: BufferedInputStream): InputStream { + private fun process(page: ReaderPage, imageSource: BufferedSource): BufferedSource { if (viewer.config.dualPageRotateToFit) { - return rotateDualPage(imageStream) + return rotateDualPage(imageSource) } if (!viewer.config.dualPageSplit) { - return imageStream + return imageSource } if (page is InsertPage) { - return splitInHalf(imageStream) + return splitInHalf(imageSource) } - val isDoublePage = ImageUtil.isWideImage(imageStream) + val isDoublePage = ImageUtil.isWideImage(imageSource) if (!isDoublePage) { - return imageStream + return imageSource } onPageSplit(page) - return splitInHalf(imageStream) + return splitInHalf(imageSource) } - private fun rotateDualPage(imageStream: BufferedInputStream): InputStream { - val isDoublePage = ImageUtil.isWideImage(imageStream) + private fun rotateDualPage(imageSource: BufferedSource): BufferedSource { + val isDoublePage = ImageUtil.isWideImage(imageSource) return if (isDoublePage) { val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f - ImageUtil.rotateImage(imageStream, rotation) + ImageUtil.rotateImage(imageSource, rotation) } else { - imageStream + imageSource } } - private fun splitInHalf(imageStream: InputStream): InputStream { + private fun splitInHalf(imageSource: BufferedSource): BufferedSource { var side = when { viewer is L2RPagerViewer && page is InsertPage -> ImageUtil.Side.RIGHT viewer !is L2RPagerViewer && page is InsertPage -> ImageUtil.Side.LEFT @@ -231,7 +222,7 @@ class PagerPageHolder( } } - return ImageUtil.splitInHalf(imageStream, side) + return ImageUtil.splitInHalf(imageSource, side) } private fun onPageSplit(page: ReaderPage) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt index b501725f7..488db7bb6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt @@ -22,15 +22,14 @@ import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope -import kotlinx.coroutines.suspendCancellableCoroutine import logcat.LogPriority +import okio.Buffer +import okio.BufferedSource import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.logcat -import java.io.BufferedInputStream -import java.io.InputStream /** * Holder of the webtoon reader for a single page of a chapter. @@ -188,16 +187,14 @@ class WebtoonPageHolder( val streamFn = page?.stream ?: return try { - val (openStream, isAnimated) = withIOContext { - val stream = streamFn().buffered(16) - val openStream = process(stream) - - val isAnimated = ImageUtil.isAnimatedAndSupported(stream) - Pair(openStream, isAnimated) + val (source, isAnimated) = withIOContext { + val source = streamFn().use { process(Buffer().readFrom(it)) } + val isAnimated = ImageUtil.isAnimatedAndSupported(source) + Pair(source, isAnimated) } withUIContext { frame.setImage( - openStream, + source, isAnimated, ReaderPageImageView.Config( zoomDuration = viewer.config.doubleTapAnimDuration, @@ -207,10 +204,6 @@ class WebtoonPageHolder( ) removeErrorLayout() } - // Suspend the coroutine to close the input stream only when the WebtoonPageHolder is recycled - suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { openStream.close() } - } } catch (e: Throwable) { logcat(LogPriority.ERROR, e) withUIContext { @@ -219,29 +212,29 @@ class WebtoonPageHolder( } } - private fun process(imageStream: BufferedInputStream): InputStream { + private fun process(imageSource: BufferedSource): BufferedSource { if (viewer.config.dualPageRotateToFit) { - return rotateDualPage(imageStream) + return rotateDualPage(imageSource) } if (viewer.config.dualPageSplit) { - val isDoublePage = ImageUtil.isWideImage(imageStream) + val isDoublePage = ImageUtil.isWideImage(imageSource) if (isDoublePage) { val upperSide = if (viewer.config.dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT - return ImageUtil.splitAndMerge(imageStream, upperSide) + return ImageUtil.splitAndMerge(imageSource, upperSide) } } - return imageStream + return imageSource } - private fun rotateDualPage(imageStream: BufferedInputStream): InputStream { - val isDoublePage = ImageUtil.isWideImage(imageStream) + private fun rotateDualPage(imageSource: BufferedSource): BufferedSource { + val isDoublePage = ImageUtil.isWideImage(imageSource) return if (isDoublePage) { val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f - ImageUtil.rotateImage(imageStream, rotation) + ImageUtil.rotateImage(imageSource, rotation) } else { - imageStream + imageSource } } 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 03a2f2d60..f5e9a8098 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 @@ -24,11 +24,10 @@ import androidx.core.graphics.green import androidx.core.graphics.red import com.hippo.unifile.UniFile import logcat.LogPriority +import okio.Buffer +import okio.BufferedSource import tachiyomi.decoder.Format import tachiyomi.decoder.ImageDecoder -import java.io.BufferedInputStream -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream import java.io.InputStream import java.net.URLConnection import java.util.Locale @@ -76,9 +75,9 @@ object ImageUtil { ?: "jpg" } - fun isAnimatedAndSupported(stream: InputStream): Boolean { + fun isAnimatedAndSupported(source: BufferedSource): Boolean { return try { - val type = getImageType(stream) ?: return false + 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 @@ -125,18 +124,16 @@ object ImageUtil { * * @return true if the width is greater than the height */ - fun isWideImage(imageStream: BufferedInputStream): Boolean { - val options = extractImageOptions(imageStream) + fun isWideImage(imageSource: BufferedSource): Boolean { + val options = extractImageOptions(imageSource) return options.outWidth > options.outHeight } /** - * Extract the 'side' part from imageStream and return it as InputStream. + * Extract the 'side' part from [BufferedSource] and return it as [BufferedSource]. */ - fun splitInHalf(imageStream: InputStream, side: Side): InputStream { - val imageBytes = imageStream.readBytes() - - val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) + fun splitInHalf(imageSource: BufferedSource, side: Side): BufferedSource { + val imageBitmap = BitmapFactory.decodeStream(imageSource.inputStream()) val height = imageBitmap.height val width = imageBitmap.width @@ -150,22 +147,20 @@ object ImageUtil { half.applyCanvas { drawBitmap(imageBitmap, part, singlePage, null) } - val output = ByteArrayOutputStream() - half.compress(Bitmap.CompressFormat.JPEG, 100, output) + val output = Buffer() + half.compress(Bitmap.CompressFormat.JPEG, 100, output.outputStream()) - return ByteArrayInputStream(output.toByteArray()) + return output } - fun rotateImage(imageStream: InputStream, degrees: Float): InputStream { - val imageBytes = imageStream.readBytes() - - val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) + fun rotateImage(imageSource: BufferedSource, degrees: Float): BufferedSource { + val imageBitmap = BitmapFactory.decodeStream(imageSource.inputStream()) val rotated = rotateBitMap(imageBitmap, degrees) - val output = ByteArrayOutputStream() - rotated.compress(Bitmap.CompressFormat.JPEG, 100, output) + val output = Buffer() + rotated.compress(Bitmap.CompressFormat.JPEG, 100, output.outputStream()) - return ByteArrayInputStream(output.toByteArray()) + return output } private fun rotateBitMap(bitmap: Bitmap, degrees: Float): Bitmap { @@ -176,10 +171,8 @@ object ImageUtil { /** * Split the image into left and right parts, then merge them into a new image. */ - fun splitAndMerge(imageStream: InputStream, upperSide: Side): InputStream { - val imageBytes = imageStream.readBytes() - - val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) + fun splitAndMerge(imageSource: BufferedSource, upperSide: Side): BufferedSource { + val imageBitmap = BitmapFactory.decodeStream(imageSource.inputStream()) val height = imageBitmap.height val width = imageBitmap.width @@ -201,9 +194,9 @@ object ImageUtil { drawBitmap(imageBitmap, leftPart, bottomPart, null) } - val output = ByteArrayOutputStream() - result.compress(Bitmap.CompressFormat.JPEG, 100, output) - return ByteArrayInputStream(output.toByteArray()) + val output = Buffer() + result.compress(Bitmap.CompressFormat.JPEG, 100, output.outputStream()) + return output } enum class Side { @@ -216,8 +209,8 @@ object ImageUtil { * * @return true if the height:width ratio is greater than 3. */ - private fun isTallImage(imageStream: InputStream): Boolean { - val options = extractImageOptions(imageStream, resetAfterExtraction = false) + private fun isTallImage(imageSource: BufferedSource): Boolean { + val options = extractImageOptions(imageSource) return (options.outHeight / options.outWidth) > 3 } @@ -225,17 +218,18 @@ object ImageUtil { * Splits tall images to improve performance of reader */ fun splitTallImage(tmpDir: UniFile, imageFile: UniFile, filenamePrefix: String): Boolean { - if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(imageFile.openInputStream())) { + val imageSource = imageFile.openInputStream().use { Buffer().readFrom(it) } + if (isAnimatedAndSupported(imageSource) || !isTallImage(imageSource)) { return true } - val bitmapRegionDecoder = getBitmapRegionDecoder(imageFile.openInputStream()) + val bitmapRegionDecoder = getBitmapRegionDecoder(imageSource.peek().inputStream()) if (bitmapRegionDecoder == null) { logcat { "Failed to create new instance of BitmapRegionDecoder" } return false } - val options = extractImageOptions(imageFile.openInputStream(), resetAfterExtraction = false).apply { + val options = extractImageOptions(imageSource).apply { inJustDecodeBounds = false } @@ -548,16 +542,9 @@ object ImageUtil { /** * Used to check an image's dimensions without loading it in the memory. */ - private fun extractImageOptions( - imageStream: InputStream, - resetAfterExtraction: Boolean = true, - ): BitmapFactory.Options { - imageStream.mark(Int.MAX_VALUE) - - val imageBytes = imageStream.readBytes() + private fun extractImageOptions(imageSource: BufferedSource): BitmapFactory.Options { val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options) - if (resetAfterExtraction) imageStream.reset() + BitmapFactory.decodeStream(imageSource.peek().inputStream(), null, options) return options }