Use Okio instead of java.io for image processing (#691)

This commit is contained in:
FooIbar 2024-04-20 12:52:40 +08:00 committed by GitHub
parent f27ca3b1b2
commit b152e3881b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 132 additions and 127 deletions

View file

@ -27,6 +27,7 @@ import eu.kanade.domain.ui.UiPreferences
import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode
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.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
@ -162,6 +163,7 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
add(MangaCoverFetcher.MangaCoverFactory(callFactoryLazy)) add(MangaCoverFetcher.MangaCoverFactory(callFactoryLazy))
add(MangaKeyer()) add(MangaKeyer())
add(MangaCoverKeyer()) add(MangaCoverKeyer())
add(BufferedSourceFetcher.Factory())
} }
crossfade((300 * this@App.animatorDurationScale).toInt()) crossfade((300 * this@App.animatorDurationScale).toInt())
allowRgb565(DeviceUtil.isLowRamDevice(this@App)) allowRgb565(DeviceUtil.isLowRamDevice(this@App))

View file

@ -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<BufferedSource> {
override fun create(
data: BufferedSource,
options: Options,
imageLoader: ImageLoader,
): Fetcher {
return BufferedSourceFetcher(data, options)
}
}
}

View file

@ -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.GLUtil
import eu.kanade.tachiyomi.util.system.animatorDurationScale import eu.kanade.tachiyomi.util.system.animatorDurationScale
import eu.kanade.tachiyomi.util.view.isVisibleOnScreen import eu.kanade.tachiyomi.util.view.isVisibleOnScreen
import java.io.InputStream import okio.BufferedSource
import java.nio.ByteBuffer
/** /**
* A wrapper view for showing page image. * 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 this.config = config
if (isAnimated) { if (isAnimated) {
prepareAnimatedImageView() prepareAnimatedImageView()
setAnimatedImage(inputStream, config) setAnimatedImage(source, config)
} else { } else {
prepareNonAnimatedImageView() prepareNonAnimatedImageView()
setNonAnimatedImage(inputStream, config) setNonAnimatedImage(source, config)
} }
} }
@ -262,7 +261,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
} }
private fun setNonAnimatedImage( private fun setNonAnimatedImage(
image: Any, data: Any,
config: Config, config: Config,
) = (pageView as? SubsamplingScaleImageView)?.apply { ) = (pageView as? SubsamplingScaleImageView)?.apply {
setDoubleTapZoomDuration(config.zoomDuration.getSystemScaledDuration()) setDoubleTapZoomDuration(config.zoomDuration.getSystemScaledDuration())
@ -283,10 +282,10 @@ open class ReaderPageImageView @JvmOverloads constructor(
}, },
) )
when (image) { when (data) {
is BitmapDrawable -> setImage(ImageSource.bitmap(image.bitmap)) is BitmapDrawable -> setImage(ImageSource.bitmap(data.bitmap))
is InputStream -> setImage(ImageSource.inputStream(image)) is BufferedSource -> setImage(ImageSource.inputStream(data.inputStream()))
else -> throw IllegalArgumentException("Not implemented for class ${image::class.simpleName}") else -> throw IllegalArgumentException("Not implemented for class ${data::class.simpleName}")
} }
isVisible = true isVisible = true
} }
@ -331,18 +330,13 @@ open class ReaderPageImageView @JvmOverloads constructor(
} }
private fun setAnimatedImage( private fun setAnimatedImage(
image: Any, data: Any,
config: Config, config: Config,
) = (pageView as? AppCompatImageView)?.apply { ) = (pageView as? AppCompatImageView)?.apply {
if (this is PhotoView) { if (this is PhotoView) {
setZoomTransitionDuration(config.zoomDuration.getSystemScaledDuration()) 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) val request = ImageRequest.Builder(context)
.data(data) .data(data)
.memoryCachePolicy(CachePolicy.DISABLED) .memoryCachePolicy(CachePolicy.DISABLED)

View file

@ -18,14 +18,13 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
import logcat.LogPriority import logcat.LogPriority
import okio.Buffer
import okio.BufferedSource
import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat 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. * View of the ViewPager that contains a page of a chapter.
@ -139,38 +138,30 @@ class PagerPageHolder(
val streamFn = page.stream ?: return val streamFn = page.stream ?: return
try { try {
val (bais, isAnimated, background) = withIOContext { val (source, isAnimated, background) = withIOContext {
streamFn().buffered(16).use { stream -> val source = streamFn().use { process(item, Buffer().readFrom(it)) }
process(item, stream).use { itemStream -> val isAnimated = ImageUtil.isAnimatedAndSupported(source)
val bais = ByteArrayInputStream(itemStream.readBytes()) val background = if (!isAnimated && viewer.config.automaticBackground) {
val isAnimated = ImageUtil.isAnimatedAndSupported(bais) ImageUtil.chooseBackground(context, source.peek().inputStream())
bais.reset() } else {
val background = if (!isAnimated && viewer.config.automaticBackground) { null
ImageUtil.chooseBackground(context, bais)
} else {
null
}
bais.reset()
Triple(bais, isAnimated, background)
}
} }
Triple(source, isAnimated, background)
} }
withUIContext { withUIContext {
bais.use { setImage(
setImage( source,
it, isAnimated,
isAnimated, Config(
Config( zoomDuration = viewer.config.doubleTapAnimDuration,
zoomDuration = viewer.config.doubleTapAnimDuration, minimumScaleType = viewer.config.imageScaleType,
minimumScaleType = viewer.config.imageScaleType, cropBorders = viewer.config.imageCropBorders,
cropBorders = viewer.config.imageCropBorders, zoomStartPosition = viewer.config.imageZoomType,
zoomStartPosition = viewer.config.imageZoomType, landscapeZoom = viewer.config.landscapeZoom,
landscapeZoom = viewer.config.landscapeZoom, ),
), )
) if (!isAnimated) {
if (!isAnimated) { pageBackground = background
pageBackground = background
}
} }
removeErrorLayout() 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) { if (viewer.config.dualPageRotateToFit) {
return rotateDualPage(imageStream) return rotateDualPage(imageSource)
} }
if (!viewer.config.dualPageSplit) { if (!viewer.config.dualPageSplit) {
return imageStream return imageSource
} }
if (page is InsertPage) { if (page is InsertPage) {
return splitInHalf(imageStream) return splitInHalf(imageSource)
} }
val isDoublePage = ImageUtil.isWideImage(imageStream) val isDoublePage = ImageUtil.isWideImage(imageSource)
if (!isDoublePage) { if (!isDoublePage) {
return imageStream return imageSource
} }
onPageSplit(page) onPageSplit(page)
return splitInHalf(imageStream) return splitInHalf(imageSource)
} }
private fun rotateDualPage(imageStream: BufferedInputStream): InputStream { private fun rotateDualPage(imageSource: BufferedSource): BufferedSource {
val isDoublePage = ImageUtil.isWideImage(imageStream) val isDoublePage = ImageUtil.isWideImage(imageSource)
return if (isDoublePage) { return if (isDoublePage) {
val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f
ImageUtil.rotateImage(imageStream, rotation) ImageUtil.rotateImage(imageSource, rotation)
} else { } else {
imageStream imageSource
} }
} }
private fun splitInHalf(imageStream: InputStream): InputStream { private fun splitInHalf(imageSource: BufferedSource): BufferedSource {
var side = when { var side = when {
viewer is L2RPagerViewer && page is InsertPage -> ImageUtil.Side.RIGHT viewer is L2RPagerViewer && page is InsertPage -> ImageUtil.Side.RIGHT
viewer !is L2RPagerViewer && page is InsertPage -> ImageUtil.Side.LEFT 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) { private fun onPageSplit(page: ReaderPage) {

View file

@ -22,15 +22,14 @@ import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.suspendCancellableCoroutine
import logcat.LogPriority import logcat.LogPriority
import okio.Buffer
import okio.BufferedSource
import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat 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. * Holder of the webtoon reader for a single page of a chapter.
@ -188,16 +187,14 @@ class WebtoonPageHolder(
val streamFn = page?.stream ?: return val streamFn = page?.stream ?: return
try { try {
val (openStream, isAnimated) = withIOContext { val (source, isAnimated) = withIOContext {
val stream = streamFn().buffered(16) val source = streamFn().use { process(Buffer().readFrom(it)) }
val openStream = process(stream) val isAnimated = ImageUtil.isAnimatedAndSupported(source)
Pair(source, isAnimated)
val isAnimated = ImageUtil.isAnimatedAndSupported(stream)
Pair(openStream, isAnimated)
} }
withUIContext { withUIContext {
frame.setImage( frame.setImage(
openStream, source,
isAnimated, isAnimated,
ReaderPageImageView.Config( ReaderPageImageView.Config(
zoomDuration = viewer.config.doubleTapAnimDuration, zoomDuration = viewer.config.doubleTapAnimDuration,
@ -207,10 +204,6 @@ class WebtoonPageHolder(
) )
removeErrorLayout() removeErrorLayout()
} }
// Suspend the coroutine to close the input stream only when the WebtoonPageHolder is recycled
suspendCancellableCoroutine<Nothing> { continuation ->
continuation.invokeOnCancellation { openStream.close() }
}
} catch (e: Throwable) { } catch (e: Throwable) {
logcat(LogPriority.ERROR, e) logcat(LogPriority.ERROR, e)
withUIContext { withUIContext {
@ -219,29 +212,29 @@ class WebtoonPageHolder(
} }
} }
private fun process(imageStream: BufferedInputStream): InputStream { private fun process(imageSource: BufferedSource): BufferedSource {
if (viewer.config.dualPageRotateToFit) { if (viewer.config.dualPageRotateToFit) {
return rotateDualPage(imageStream) return rotateDualPage(imageSource)
} }
if (viewer.config.dualPageSplit) { if (viewer.config.dualPageSplit) {
val isDoublePage = ImageUtil.isWideImage(imageStream) val isDoublePage = ImageUtil.isWideImage(imageSource)
if (isDoublePage) { if (isDoublePage) {
val upperSide = if (viewer.config.dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT 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 { private fun rotateDualPage(imageSource: BufferedSource): BufferedSource {
val isDoublePage = ImageUtil.isWideImage(imageStream) val isDoublePage = ImageUtil.isWideImage(imageSource)
return if (isDoublePage) { return if (isDoublePage) {
val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f
ImageUtil.rotateImage(imageStream, rotation) ImageUtil.rotateImage(imageSource, rotation)
} else { } else {
imageStream imageSource
} }
} }

View file

@ -24,11 +24,10 @@ import androidx.core.graphics.green
import androidx.core.graphics.red import androidx.core.graphics.red
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import logcat.LogPriority import logcat.LogPriority
import okio.Buffer
import okio.BufferedSource
import tachiyomi.decoder.Format import tachiyomi.decoder.Format
import tachiyomi.decoder.ImageDecoder import tachiyomi.decoder.ImageDecoder
import java.io.BufferedInputStream
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import java.net.URLConnection import java.net.URLConnection
import java.util.Locale import java.util.Locale
@ -76,9 +75,9 @@ object ImageUtil {
?: "jpg" ?: "jpg"
} }
fun isAnimatedAndSupported(stream: InputStream): Boolean { fun isAnimatedAndSupported(source: BufferedSource): Boolean {
return try { 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 // https://coil-kt.github.io/coil/getting_started/#supported-image-formats
when (type.format) { when (type.format) {
Format.Gif -> true Format.Gif -> true
@ -125,18 +124,16 @@ object ImageUtil {
* *
* @return true if the width is greater than the height * @return true if the width is greater than the height
*/ */
fun isWideImage(imageStream: BufferedInputStream): Boolean { fun isWideImage(imageSource: BufferedSource): Boolean {
val options = extractImageOptions(imageStream) val options = extractImageOptions(imageSource)
return options.outWidth > options.outHeight 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 { fun splitInHalf(imageSource: BufferedSource, side: Side): BufferedSource {
val imageBytes = imageStream.readBytes() val imageBitmap = BitmapFactory.decodeStream(imageSource.inputStream())
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
val height = imageBitmap.height val height = imageBitmap.height
val width = imageBitmap.width val width = imageBitmap.width
@ -150,22 +147,20 @@ object ImageUtil {
half.applyCanvas { half.applyCanvas {
drawBitmap(imageBitmap, part, singlePage, null) drawBitmap(imageBitmap, part, singlePage, null)
} }
val output = ByteArrayOutputStream() val output = Buffer()
half.compress(Bitmap.CompressFormat.JPEG, 100, output) half.compress(Bitmap.CompressFormat.JPEG, 100, output.outputStream())
return ByteArrayInputStream(output.toByteArray()) return output
} }
fun rotateImage(imageStream: InputStream, degrees: Float): InputStream { fun rotateImage(imageSource: BufferedSource, degrees: Float): BufferedSource {
val imageBytes = imageStream.readBytes() val imageBitmap = BitmapFactory.decodeStream(imageSource.inputStream())
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
val rotated = rotateBitMap(imageBitmap, degrees) val rotated = rotateBitMap(imageBitmap, degrees)
val output = ByteArrayOutputStream() val output = Buffer()
rotated.compress(Bitmap.CompressFormat.JPEG, 100, output) rotated.compress(Bitmap.CompressFormat.JPEG, 100, output.outputStream())
return ByteArrayInputStream(output.toByteArray()) return output
} }
private fun rotateBitMap(bitmap: Bitmap, degrees: Float): Bitmap { 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. * Split the image into left and right parts, then merge them into a new image.
*/ */
fun splitAndMerge(imageStream: InputStream, upperSide: Side): InputStream { fun splitAndMerge(imageSource: BufferedSource, upperSide: Side): BufferedSource {
val imageBytes = imageStream.readBytes() val imageBitmap = BitmapFactory.decodeStream(imageSource.inputStream())
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
val height = imageBitmap.height val height = imageBitmap.height
val width = imageBitmap.width val width = imageBitmap.width
@ -201,9 +194,9 @@ object ImageUtil {
drawBitmap(imageBitmap, leftPart, bottomPart, null) drawBitmap(imageBitmap, leftPart, bottomPart, null)
} }
val output = ByteArrayOutputStream() val output = Buffer()
result.compress(Bitmap.CompressFormat.JPEG, 100, output) result.compress(Bitmap.CompressFormat.JPEG, 100, output.outputStream())
return ByteArrayInputStream(output.toByteArray()) return output
} }
enum class Side { enum class Side {
@ -216,8 +209,8 @@ object ImageUtil {
* *
* @return true if the height:width ratio is greater than 3. * @return true if the height:width ratio is greater than 3.
*/ */
private fun isTallImage(imageStream: InputStream): Boolean { private fun isTallImage(imageSource: BufferedSource): Boolean {
val options = extractImageOptions(imageStream, resetAfterExtraction = false) val options = extractImageOptions(imageSource)
return (options.outHeight / options.outWidth) > 3 return (options.outHeight / options.outWidth) > 3
} }
@ -225,17 +218,18 @@ object ImageUtil {
* Splits tall images to improve performance of reader * Splits tall images to improve performance of reader
*/ */
fun splitTallImage(tmpDir: UniFile, imageFile: UniFile, filenamePrefix: String): Boolean { 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 return true
} }
val bitmapRegionDecoder = getBitmapRegionDecoder(imageFile.openInputStream()) val bitmapRegionDecoder = getBitmapRegionDecoder(imageSource.peek().inputStream())
if (bitmapRegionDecoder == null) { if (bitmapRegionDecoder == null) {
logcat { "Failed to create new instance of BitmapRegionDecoder" } logcat { "Failed to create new instance of BitmapRegionDecoder" }
return false return false
} }
val options = extractImageOptions(imageFile.openInputStream(), resetAfterExtraction = false).apply { val options = extractImageOptions(imageSource).apply {
inJustDecodeBounds = false inJustDecodeBounds = false
} }
@ -548,16 +542,9 @@ object ImageUtil {
/** /**
* Used to check an image's dimensions without loading it in the memory. * Used to check an image's dimensions without loading it in the memory.
*/ */
private fun extractImageOptions( private fun extractImageOptions(imageSource: BufferedSource): BitmapFactory.Options {
imageStream: InputStream,
resetAfterExtraction: Boolean = true,
): BitmapFactory.Options {
imageStream.mark(Int.MAX_VALUE)
val imageBytes = imageStream.readBytes()
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options) BitmapFactory.decodeStream(imageSource.peek().inputStream(), null, options)
if (resetAfterExtraction) imageStream.reset()
return options return options
} }