Load ZIP file contents to cache (#9381)

* Extract downloaded archives to tmp folder when loading for viewing

* Generate sequence of entries from ZipInputStream instead of loading entire ZipFile

(cherry picked from commit 44619febd333f4e662cdbf149ae0741a43ebd27b)

# Conflicts:
#	app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt
This commit is contained in:
arkon 2023-04-23 11:59:58 -04:00 committed by Jobobby04
parent cf752a0d88
commit 20d35268b1
7 changed files with 94 additions and 228 deletions

View File

@ -121,7 +121,6 @@ class ChapterLoader(
} }
// SY <-- // SY <--
isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager, downloadProvider) isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager, downloadProvider)
source is HttpSource -> HttpPageLoader(chapter, source)
source is LocalSource -> source.getFormat(chapter.chapter).let { format -> source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
when (format) { when (format) {
is Format.Directory -> DirectoryPageLoader(format.file) is Format.Directory -> DirectoryPageLoader(format.file)
@ -140,6 +139,7 @@ class ChapterLoader(
is Format.Epub -> EpubPageLoader(format.file) is Format.Epub -> EpubPageLoader(format.file)
} }
} }
source is HttpSource -> HttpPageLoader(chapter, source)
source is StubSource -> error(context.getString(R.string.source_not_installed, source.toString())) source is StubSource -> error(context.getString(R.string.source_not_installed, source.toString()))
else -> error(context.getString(R.string.loader_not_implemented_error)) else -> error(context.getString(R.string.loader_not_implemented_error))
} }

View File

@ -1,61 +1,57 @@
package eu.kanade.tachiyomi.ui.reader.loader package eu.kanade.tachiyomi.ui.reader.loader
import android.app.Application
import com.github.junrar.Archive import com.github.junrar.Archive
import com.github.junrar.rarfile.FileHeader import com.github.junrar.rarfile.FileHeader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import uy.kohesive.injekt.injectLazy
import tachiyomi.core.util.system.ImageUtil
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import java.io.PipedInputStream import java.io.PipedInputStream
import java.io.PipedOutputStream import java.io.PipedOutputStream
import java.util.concurrent.Executors
/** /**
* Loader used to load a chapter from a .rar or .cbr file. * Loader used to load a chapter from a .rar or .cbr file.
*/ */
internal class RarPageLoader(file: File) : PageLoader() { internal class RarPageLoader(file: File) : PageLoader() {
private val rar = Archive(file) private val context: Application by injectLazy()
private val tmpDir = File(context.externalCacheDir, "reader_${file.hashCode()}").also {
it.deleteRecursively()
it.mkdirs()
}
/** init {
* Pool for copying compressed files to an input stream. Archive(file).use { rar ->
*/ rar.fileHeaders.asSequence()
private val pool = Executors.newFixedThreadPool(1) .filterNot { it.isDirectory }
.forEach { header ->
val pageFile = File(tmpDir, header.fileName).also { it.createNewFile() }
getStream(rar, header).use {
it.copyTo(pageFile.outputStream())
}
}
}
}
override var isLocal: Boolean = true override var isLocal: Boolean = true
override suspend fun getPages(): List<ReaderPage> { override suspend fun getPages(): List<ReaderPage> {
return rar.fileHeaders.asSequence() return DirectoryPageLoader(tmpDir).getPages()
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { rar.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.mapIndexed { i, header ->
ReaderPage(i).apply {
stream = { getStream(header) }
status = Page.State.READY
}
}
.toList()
}
override suspend fun loadPage(page: ReaderPage) {
check(!isRecycled)
} }
override fun recycle() { override fun recycle() {
super.recycle() super.recycle()
rar.close() tmpDir.deleteRecursively()
pool.shutdown()
} }
/** /**
* Returns an input stream for the given [header]. * Returns an input stream for the given [header].
*/ */
private fun getStream(header: FileHeader): InputStream { private fun getStream(rar: Archive, header: FileHeader): InputStream {
val pipeIn = PipedInputStream() val pipeIn = PipedInputStream()
val pipeOut = PipedOutputStream(pipeIn) val pipeOut = PipedOutputStream(pipeIn)
pool.execute { synchronized(this) {
try { try {
pipeOut.use { pipeOut.use {
rar.extractFile(header, it) rar.extractFile(header, it)

View File

@ -1,16 +1,17 @@
package eu.kanade.tachiyomi.ui.reader.loader package eu.kanade.tachiyomi.ui.reader.loader
import android.app.Application
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.CbzCrypto import eu.kanade.tachiyomi.util.storage.CbzCrypto
import net.lingala.zip4j.ZipFile import net.lingala.zip4j.ZipFile
import tachiyomi.core.util.system.ImageUtil import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import java.io.FileInputStream
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.zip.ZipInputStream
/** /**
* Loader used to load a chapter from a .zip or .cbz file. * Loader used to load a chapter from a .zip or .cbz file.
@ -22,85 +23,75 @@ internal class ZipPageLoader(
// SY <-- // SY <--
) : PageLoader() { ) : PageLoader() {
/** private val context: Application by injectLazy()
* The zip file to load pages from. private val tmpDir = File(context.externalCacheDir, "reader_${file.hashCode()}").also {
*/ it.deleteRecursively()
// SY --> it.mkdirs()
private var zip4j = ZipFile(file) }
// SY -->
init { init {
if (zip4j.isEncrypted) { val zip = ZipFile(file)
if (!CbzCrypto.checkCbzPassword(zip4j, CbzCrypto.getDecryptedPasswordCbz())) { if (zip.isEncrypted) {
if (!CbzCrypto.checkCbzPassword(zip, CbzCrypto.getDecryptedPasswordCbz())) {
this.recycle() this.recycle()
throw Exception(context.getString(R.string.wrong_cbz_archive_password)) throw Exception(context.getString(R.string.wrong_cbz_archive_password))
} }
unzipEncrypted(zip)
} else {
unzip(file)
}
}
private fun unzip(file: File) {
// SY <--
ZipInputStream(FileInputStream(file)).use { zipInputStream ->
generateSequence { zipInputStream.nextEntry }
.filterNot { it.isDirectory }
.forEach { entry ->
File(tmpDir, entry.name).also { it.createNewFile() }
.outputStream().use { pageOutputStream ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pageOutputStream.write(zipInputStream.readNBytes(entry.size.toInt()))
} else {
val buffer = ByteArray(2048)
var len: Int
while (
zipInputStream.read(buffer, 0, buffer.size)
.also { len = it } >= 0
) {
pageOutputStream.write(buffer, 0, len)
}
}
pageOutputStream.flush()
}
zipInputStream.closeEntry()
}
} }
} }
private val zip: java.util.zip.ZipFile? = // SY -->
private fun unzipEncrypted(zip: ZipFile) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if (!zip4j.isEncrypted) java.util.zip.ZipFile(file, StandardCharsets.ISO_8859_1) else null zip.charset = StandardCharsets.ISO_8859_1
} else {
if (!zip4j.isEncrypted) java.util.zip.ZipFile(file) else null
} }
zip.setPassword(CbzCrypto.getDecryptedPasswordCbz())
zip.fileHeaders.asSequence()
.filterNot { !it.isDirectory }
.forEach { entry ->
zip.extractFile(entry, tmpDir.absolutePath)
}
}
// SY <-- // SY <--
/** override var isLocal: Boolean = true
* Recycles this loader and the open zip.
*/
override fun recycle() {
super.recycle()
// SY -->
zip4j.close()
zip?.close()
// SY <--
}
/**
* Returns the pages found on this zip archive ordered with a natural comparator.
override suspend fun getPages(): List<ReaderPage> { override suspend fun getPages(): List<ReaderPage> {
// SY --> return DirectoryPageLoader(tmpDir).getPages()
// Part can be removed after testing that there are no bugs with zip4j on some users devices
if (zip != null) {
// SY <--
return zip.entries().asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.mapIndexed { i, entry ->
ReaderPage(i).apply {
stream = { zip.getInputStream(entry) }
status = Page.State.READY
}
// SY -->
}.toList()
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
zip4j.charset = StandardCharsets.ISO_8859_1
}
zip4j.setPassword(CbzCrypto.getDecryptedPasswordCbz())
return zip4j.fileHeaders.asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { zip4j.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.mapIndexed { i, entry ->
ReaderPage(i).apply {
stream = { zip4j.getInputStream(entry) }
status = Page.State.READY
zip4jFile = zip4j
zip4jEntry = entry
}
}.toList()
}
// SY <--
}
override suspend fun loadPage(page: ReaderPage) {
check(!isRecycled)
} }
override fun recycle() { override fun recycle() {
super.recycle() super.recycle()
zip.close() tmpDir.deleteRecursively()
} }
} }

View File

@ -1,8 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.model package eu.kanade.tachiyomi.ui.reader.model
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.FileHeader
import java.io.InputStream import java.io.InputStream
open class ReaderPage( open class ReaderPage(
@ -10,9 +8,6 @@ open class ReaderPage(
url: String = "", url: String = "",
imageUrl: String? = null, imageUrl: String? = null,
// SY --> // SY -->
/**zip4j inputStreams do not support mark() and release(), so they must be passed to ImageUtil */
var zip4jFile: ZipFile? = null,
var zip4jEntry: FileHeader? = null,
/** Value to check if this page is used to as if it was too wide */ /** Value to check if this page is used to as if it was too wide */
var shiftedPage: Boolean = false, var shiftedPage: Boolean = false,
/** Value to check if a page is can be doubled up, but can't because the next page is too wide */ /** Value to check if a page is can be doubled up, but can't because the next page is too wide */

View File

@ -229,13 +229,7 @@ class PagerPageHolder(
return splitInHalf(imageStream) return splitInHalf(imageStream)
} }
val isDoublePage = ImageUtil.isWideImage( val isDoublePage = ImageUtil.isWideImage(imageStream)
imageStream,
// SY -->
page.zip4jFile,
page.zip4jEntry,
// SY <--
)
if (!isDoublePage) { if (!isDoublePage) {
return imageStream return imageStream
} }
@ -260,13 +254,7 @@ class PagerPageHolder(
if (imageStream2 == null) { if (imageStream2 == null) {
return if (imageStream is BufferedInputStream && return if (imageStream is BufferedInputStream &&
!ImageUtil.isAnimatedAndSupported(imageStream) && !ImageUtil.isAnimatedAndSupported(imageStream) &&
ImageUtil.isWideImage( ImageUtil.isWideImage(imageStream) &&
imageStream,
// SY -->
page.zip4jFile,
page.zip4jEntry,
// SY <--
) &&
viewer.config.centerMarginType and PagerConfig.CenterMarginType.WIDE_PAGE_CENTER_MARGIN > 0 && viewer.config.centerMarginType and PagerConfig.CenterMarginType.WIDE_PAGE_CENTER_MARGIN > 0 &&
!viewer.config.imageCropBorders !viewer.config.imageCropBorders
) { ) {

View File

@ -213,13 +213,7 @@ class WebtoonPageHolder(
private fun process(imageStream: BufferedInputStream): InputStream { private fun process(imageStream: BufferedInputStream): InputStream {
if (viewer.config.dualPageSplit) { if (viewer.config.dualPageSplit) {
val isDoublePage = ImageUtil.isWideImage( val isDoublePage = ImageUtil.isWideImage(imageStream)
imageStream,
// SY -->
page?.zip4jFile,
page?.zip4jEntry,
// SY <--
)
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(imageStream, upperSide)
@ -230,13 +224,7 @@ class WebtoonPageHolder(
if (page is StencilPage) { if (page is StencilPage) {
return imageStream return imageStream
} }
val isStripSplitNeeded = ImageUtil.isStripSplitNeeded( val isStripSplitNeeded = ImageUtil.isStripSplitNeeded(imageStream)
imageStream,
// SY -->
page?.zip4jFile,
page?.zip4jEntry,
// SY <--
)
if (isStripSplitNeeded) { if (isStripSplitNeeded) {
return onStripSplit(imageStream) return onStripSplit(imageStream)
} }
@ -249,14 +237,7 @@ class WebtoonPageHolder(
// If we have reached this point [page] and its stream shouldn't be null // If we have reached this point [page] and its stream shouldn't be null
val page = page!! val page = page!!
val stream = page.stream!! val stream = page.stream!!
val splitData = ImageUtil.getSplitDataForStream( val splitData = ImageUtil.getSplitDataForStream(imageStream).toMutableList()
imageStream,
// SY -->
page.zip4jFile,
page.zip4jEntry,
// SY <--
).toMutableList()
val currentSplitData = splitData.removeFirst() val currentSplitData = splitData.removeFirst()
val newPages = splitData.map { val newPages = splitData.map {
StencilPage(page) { ImageUtil.splitStrip(it, stream) } StencilPage(page) { ImageUtil.splitStrip(it, stream) }

View File

@ -26,8 +26,6 @@ import androidx.core.graphics.red
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import logcat.LogPriority import logcat.LogPriority
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.FileHeader
import tachiyomi.decoder.Format import tachiyomi.decoder.Format
import tachiyomi.decoder.ImageDecoder import tachiyomi.decoder.ImageDecoder
import java.io.BufferedInputStream import java.io.BufferedInputStream
@ -127,17 +125,9 @@ object ImageUtil {
* *
* @return true if the width is greater than the height * @return true if the width is greater than the height
*/ */
fun isWideImage( fun isWideImage(imageStream: BufferedInputStream): Boolean {
imageStream: BufferedInputStream,
zip4jFile: ZipFile? = null,
zip4jEntry: FileHeader? = null,
): Boolean {
val options = extractImageOptions( val options = extractImageOptions(
imageStream, imageStream,
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
) )
return options.outWidth > options.outHeight return options.outWidth > options.outHeight
} }
@ -263,19 +253,9 @@ 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( private fun isTallImage(imageStream: InputStream): Boolean {
imageStream: InputStream,
// SY -->
zip4jFile: ZipFile? = null,
zip4jEntry: FileHeader? = null,
// SY <--
): Boolean {
val options = extractImageOptions( val options = extractImageOptions(
imageStream, imageStream,
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
resetAfterExtraction = false, resetAfterExtraction = false,
) )
@ -285,23 +265,8 @@ object ImageUtil {
/** /**
* Splits tall images to improve performance of reader * Splits tall images to improve performance of reader
*/ */
fun splitTallImage( fun splitTallImage(tmpDir: UniFile, imageFile: UniFile, filenamePrefix: String, ): Boolean {
tmpDir: UniFile, if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(imageFile.openInputStream())) {
imageFile: UniFile,
filenamePrefix: String,
// SY -->
zip4jFile: ZipFile? = null,
zip4jEntry: FileHeader? = null,
// SY <--
): Boolean {
if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(
imageFile.openInputStream(),
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
)
) {
return true return true
} }
@ -311,14 +276,7 @@ object ImageUtil {
return false return false
} }
val options = extractImageOptions( val options = extractImageOptions(imageFile.openInputStream(), resetAfterExtraction = false).apply {
imageFile.openInputStream(),
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
resetAfterExtraction = false,
).apply {
inJustDecodeBounds = false inJustDecodeBounds = false
} }
@ -364,22 +322,10 @@ object ImageUtil {
* Check whether the image is a long Strip that needs splitting * Check whether the image is a long Strip that needs splitting
* @return true if the image is not animated and it's height is greater than image width and screen height * @return true if the image is not animated and it's height is greater than image width and screen height
*/ */
fun isStripSplitNeeded( fun isStripSplitNeeded(imageStream: BufferedInputStream): Boolean {
imageStream: BufferedInputStream,
// SY -->
zip4jFile: ZipFile? = null,
zip4jEntry: FileHeader? = null,
// SY <--
): Boolean {
if (isAnimatedAndSupported(imageStream)) return false if (isAnimatedAndSupported(imageStream)) return false
val options = extractImageOptions( val options = extractImageOptions(imageStream)
imageStream,
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
)
val imageHeightIsBiggerThanWidth = options.outHeight > options.outWidth val imageHeightIsBiggerThanWidth = options.outHeight > options.outWidth
val imageHeightBiggerThanScreenHeight = options.outHeight > optimalImageHeight val imageHeightBiggerThanScreenHeight = options.outHeight > optimalImageHeight
return imageHeightIsBiggerThanWidth && imageHeightBiggerThanScreenHeight return imageHeightIsBiggerThanWidth && imageHeightBiggerThanScreenHeight
@ -411,21 +357,8 @@ object ImageUtil {
} }
} }
fun getSplitDataForStream( fun getSplitDataForStream(imageStream: InputStream): List<SplitData> {
imageStream: InputStream, return extractImageOptions(imageStream).splitData
// SY -->
zip4jFile: ZipFile? = null,
zip4jEntry: FileHeader? = null,
// SY <--
): List<SplitData> {
// SY -->
return extractImageOptions(
imageStream,
zip4jFile,
zip4jEntry,
).splitData
// <--
} }
private val BitmapFactory.Options.splitData private val BitmapFactory.Options.splitData
@ -690,17 +623,8 @@ object ImageUtil {
*/ */
private fun extractImageOptions( private fun extractImageOptions(
imageStream: InputStream, imageStream: InputStream,
// SY -->
zip4jFile: ZipFile? = null,
zip4jEntry: FileHeader? = null,
// SY <--
resetAfterExtraction: Boolean = true, resetAfterExtraction: Boolean = true,
): BitmapFactory.Options { ): BitmapFactory.Options {
// SY -->
// zip4j does currently not support mark() and reset()
if (zip4jFile != null && zip4jEntry != null) return extractImageOptionsZip4j(zip4jFile, zip4jEntry)
// SY <--
imageStream.mark(imageStream.available() + 1) imageStream.mark(imageStream.available() + 1)
val imageBytes = imageStream.readBytes() val imageBytes = imageStream.readBytes()
@ -711,15 +635,6 @@ object ImageUtil {
} }
// SY --> // SY -->
private fun extractImageOptionsZip4j(zip4jFile: ZipFile?, zip4jEntry: FileHeader?): BitmapFactory.Options {
zip4jFile?.getInputStream(zip4jEntry).use { imageStream ->
val imageBytes = imageStream?.readBytes()
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
imageBytes?.size?.let { BitmapFactory.decodeByteArray(imageBytes, 0, it, options) }
return options
}
}
/** /**
* Creates random exif metadata used as padding to make * Creates random exif metadata used as padding to make
* the size of files inside CBZ archives unique * the size of files inside CBZ archives unique