diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index 7d0b8c2dce..39dbbe1a92 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -32,8 +32,8 @@ import coil3.request.allowRgb565 import coil3.request.crossfade import coil3.util.DebugLogger import eu.kanade.tachiyomi.appwidget.TachiyomiWidgetManager +import eu.kanade.tachiyomi.data.coil.BufferedSourceFetcher import eu.kanade.tachiyomi.data.coil.CoilDiskCache -import eu.kanade.tachiyomi.data.coil.InputStreamFetcher import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder @@ -199,7 +199,7 @@ open class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.F add(TachiyomiImageDecoder.Factory()) add(MangaCoverFetcher.Factory(callFactoryLazy, diskCacheLazy)) add(MangaCoverKeyer()) - add(InputStreamFetcher.Factory()) + add(BufferedSourceFetcher.Factory()) } diskCache(diskCacheLazy::value) memoryCache { MemoryCache.Builder().maxSizePercent(this@App, 0.40).build() } 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 0000000000..1d119062c2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/BufferedSourceFetcher.kt @@ -0,0 +1,32 @@ +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/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 7a8b717b0d..82a6cd1907 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -781,7 +781,7 @@ class ReaderViewModel( val imageBytes2 = stream2().readBytes() val imageBitmap2 = BitmapFactory.decodeByteArray(imageBytes2, 0, imageBytes2.size) - val stream = ImageUtil.mergeBitmaps(imageBitmap, imageBitmap2, isLTR, bg) + val stream = ImageUtil.mergeBitmaps(imageBitmap, imageBitmap2, isLTR, bg).inputStream() val chapter = page1.chapter.chapter val context = Injekt.get() 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 dce4aeaeae..de4fae85ed 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 @@ -38,8 +38,7 @@ import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.GLUtil import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.animatorDurationScale -import java.io.InputStream -import java.nio.ByteBuffer +import okio.BufferedSource /** * A wrapper view for showing page image. @@ -99,13 +98,13 @@ open class ReaderPageImageView @JvmOverloads constructor( } } - fun setImage(inputStream: InputStream, isAnimated: Boolean, config: Config) { + fun setImage(source: BufferedSource, isAnimated: Boolean, config: Config) { if (isAnimated) { prepareAnimatedImageView() - setAnimatedImage(inputStream, config) + setAnimatedImage(source, config) } else { prepareNonAnimatedImageView() - setNonAnimatedImage(inputStream, config) + setNonAnimatedImage(source, config) } } @@ -225,7 +224,7 @@ open class ReaderPageImageView @JvmOverloads constructor( }, ) - val useCoilPipeline = isWebtoon && data is InputStream && !ImageUtil.isMaxTextureSizeExceeded(data) + val useCoilPipeline = isWebtoon && data is BufferedSource && !ImageUtil.isMaxTextureSizeExceeded(data) if (isWebtoon && useCoilPipeline) { val request = ImageRequest.Builder(context) @@ -252,7 +251,7 @@ open class ReaderPageImageView @JvmOverloads constructor( } else { when (data) { is BitmapDrawable -> setImage(ImageSource.bitmap(data.bitmap)) - is InputStream -> setImage(ImageSource.inputStream(data)) + is BufferedSource -> setImage(ImageSource.inputStream(data.inputStream())) else -> throw IllegalArgumentException("Not implemented for class ${data::class.simpleName}") } isVisible = true @@ -299,18 +298,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/webtoon/WebtoonPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt index d4512c1026..cd211d3903 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 @@ -28,10 +28,9 @@ import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope -import kotlinx.coroutines.suspendCancellableCoroutine +import okio.Buffer +import okio.BufferedSource import timber.log.Timber -import java.io.BufferedInputStream -import java.io.InputStream /** * Holder of the webtoon reader for a single page of a chapter. @@ -232,13 +231,11 @@ class WebtoonPageHolder( val streamFn = page?.stream ?: return - val (openStream, isAnimated) = try { + val (source, isAnimated) = try { withIOContext { - val stream = streamFn().buffered(16) - val openStream = process(stream) - - val isAnimated = ImageUtil.isAnimatedAndSupported(stream) - Pair(openStream, isAnimated) + val source = streamFn().use { process(Buffer().readFrom(it)) } + val isAnimated = ImageUtil.isAnimatedAndSupported(source) + Pair(source, isAnimated) } } catch (e: Exception) { Timber.e(e) @@ -247,7 +244,7 @@ class WebtoonPageHolder( } withUIContext { frame.setImage( - openStream, + source, isAnimated, ReaderPageImageView.Config( zoomDuration = viewer.config.doubleTapAnimDuration, @@ -258,23 +255,19 @@ class WebtoonPageHolder( ), ) } - // Suspend the coroutine to close the input stream only when the WebtoonPageHolder is recycled - suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { openStream.close() } - } } - private fun process(imageStream: BufferedInputStream): InputStream { + private fun process(imageSource: BufferedSource): BufferedSource { if (!viewer.config.splitPages) { - return imageStream + return imageSource } - val isDoublePage = ImageUtil.isWideImage(imageStream) + val isDoublePage = ImageUtil.isWideImage(imageSource) if (!isDoublePage) { - return imageStream + return imageSource } - return ImageUtil.splitAndStackBitmap(imageStream, viewer.config.invertDoublePages, viewer.hasMargins) + return ImageUtil.splitAndStackBitmap(imageSource, viewer.config.invertDoublePages, viewer.hasMargins) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt index 1834b059a0..bec4855da4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt @@ -25,12 +25,11 @@ import androidx.core.graphics.red import androidx.core.graphics.scale import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.R +import okio.Buffer +import okio.BufferedSource import tachiyomi.decoder.Format import tachiyomi.decoder.ImageDecoder import timber.log.Timber -import java.io.BufferedInputStream -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream import java.io.File import java.io.FileOutputStream import java.io.InputStream @@ -92,9 +91,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 @@ -335,7 +334,7 @@ object ImageUtil { imageBitmap: Bitmap, secondHalf: Boolean, progressCallback: ((Int) -> Unit)? = null, - ): ByteArrayInputStream { + ): BufferedSource { val height = imageBitmap.height val width = imageBitmap.width val result = Bitmap.createBitmap(width / 2, height, Bitmap.Config.ARGB_8888) @@ -343,10 +342,10 @@ object ImageUtil { progressCallback?.invoke(98) canvas.drawBitmap(imageBitmap, Rect(if (!secondHalf) 0 else width / 2, 0, if (secondHalf) width else width / 2, height), result.rect, null) progressCallback?.invoke(99) - val output = ByteArrayOutputStream() - result.compress(Bitmap.CompressFormat.JPEG, 100, output) + val output = Buffer() + result.compress(Bitmap.CompressFormat.JPEG, 100, output.outputStream()) progressCallback?.invoke(100) - return ByteArrayInputStream(output.toByteArray()) + return output } /** @@ -354,20 +353,18 @@ object ImageUtil { * * @return true if the width is greater than the height */ - fun isWideImage(imageStream: BufferedInputStream): Boolean { - val options = extractImageOptions(imageStream) - imageStream.reset() + fun isWideImage(imageSource: BufferedSource): Boolean { + val options = extractImageOptions(imageSource) return options.outWidth > options.outHeight } fun splitAndStackBitmap( - imageStream: InputStream, + imageSource: BufferedSource, rightSideOnTop: Boolean, hasMargins: Boolean, progressCallback: ((Int) -> Unit)? = null, - ): ByteArrayInputStream { - val imageBytes = imageStream.readBytes() - val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) + ): BufferedSource { + val imageBitmap = BitmapFactory.decodeStream(imageSource.inputStream()) val height = imageBitmap.height val width = imageBitmap.width @@ -411,10 +408,10 @@ object ImageUtil { null, ) progressCallback?.invoke(99) - val output = ByteArrayOutputStream() - result.compress(Bitmap.CompressFormat.JPEG, 100, output) + val output = Buffer() + result.compress(Bitmap.CompressFormat.JPEG, 100, output.outputStream()) progressCallback?.invoke(100) - return ByteArrayInputStream(output.toByteArray()) + return output } fun mergeBitmaps( @@ -425,7 +422,7 @@ object ImageUtil { hingeGap: Int = 0, context: Context? = null, progressCallback: ((Int) -> Unit)? = null, - ): ByteArrayInputStream { + ): BufferedSource { var imageBitmap = iBitmap var imageBitmap2 = iBitmap2 var height = imageBitmap.height @@ -473,10 +470,10 @@ object ImageUtil { canvas.drawBitmap(imageBitmap2, imageBitmap2.rect, bottomPart, null) progressCallback?.invoke(99) - val output = ByteArrayOutputStream() - result.compress(Bitmap.CompressFormat.JPEG, 100, output) + val output = Buffer() + result.compress(Bitmap.CompressFormat.JPEG, 100, output.outputStream()) progressCallback?.invoke(100) - return ByteArrayInputStream(output.toByteArray()) + return output } fun padSingleImage( @@ -487,7 +484,7 @@ object ImageUtil { hingeGap: Int, context: Context, progressCallback: ((Int) -> Unit)? = null, - ): ByteArrayInputStream { + ): BufferedSource { val height = imageBitmap.height val width = imageBitmap.width val isFullPageSpread = height < width @@ -523,10 +520,10 @@ object ImageUtil { canvas.drawBitmap(imageBitmap, imageBitmap.rect, upperPart, null) } progressCallback?.invoke(99) - val output = ByteArrayOutputStream() - result.compress(Bitmap.CompressFormat.JPEG, 100, output) + val output = Buffer() + result.compress(Bitmap.CompressFormat.JPEG, 100, output.outputStream()) progressCallback?.invoke(100) - return ByteArrayInputStream(output.toByteArray()) + return output } /** @@ -534,8 +531,8 @@ object ImageUtil { * * @return true if the height:width ratio is greater than 3. */ - private fun isTallImage(imageStream: InputStream): Boolean { - val options = extractImageOptions(imageStream, false) + private fun isTallImage(imageSource: BufferedSource): Boolean { + val options = extractImageOptions(imageSource) return (options.outHeight / options.outWidth) > 3 } @@ -543,15 +540,16 @@ object ImageUtil { * Splits tall images to improve performance of reader */ fun splitTallImage(imageFile: UniFile, imageFilePath: 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 = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - BitmapRegionDecoder.newInstance(imageFile.openInputStream()) + BitmapRegionDecoder.newInstance(imageSource.peek().inputStream()) } else { @Suppress("DEPRECATION") - BitmapRegionDecoder.newInstance(imageFile.openInputStream(), false) + BitmapRegionDecoder.newInstance(imageSource.peek().inputStream(), false) } if (bitmapRegionDecoder == null) { @@ -559,7 +557,7 @@ object ImageUtil { return false } - val options = extractImageOptions(imageFile.openInputStream(), resetAfterExtraction = false).apply { + val options = extractImageOptions(imageSource).apply { inJustDecodeBounds = false } val splitDataList = options.splitData @@ -773,16 +771,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(imageStream.available() + 1) - - 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 } @@ -792,8 +783,8 @@ object ImageUtil { "image/jxl" to "jxl", ) - fun isMaxTextureSizeExceeded(imageStream: InputStream): Boolean { - val opts = extractImageOptions(imageStream) + fun isMaxTextureSizeExceeded(imageSource: BufferedSource): Boolean { + val opts = extractImageOptions(imageSource) return maxOf(opts.outWidth, opts.outHeight) > GLUtil.maxTextureSize } }