Make comic book archive caching optional instead of rolling it back completely (#883)

* Make comic book archive caching optional and fix cbz archives not opening for some users

* corrected placement of // SY <--
This commit is contained in:
Shamicen 2023-05-13 04:52:17 +02:00 committed by GitHub
parent be6bbb8e9b
commit 6071acd3df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 278 additions and 36 deletions

View File

@ -545,6 +545,11 @@ object SettingsReaderScreen : SearchableSettings {
3 to stringResource(R.string.center_margin_double_and_wide_page),
),
),
Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.cacheArchiveMangaOnDisk(),
title = stringResource(R.string.cache_archived_manga_to_disk),
subtitle = stringResource(R.string.cache_archived_manga_to_disk_subtitle),
),
),
)
}

View File

@ -522,7 +522,15 @@ class Downloader(
// If the original page was previously split, then skip
if (imageFile.name.orEmpty().startsWith("${filenamePrefix}__")) return
ImageUtil.splitTallImage(tmpDir, imageFile, filenamePrefix)
ImageUtil.splitTallImage(
tmpDir,
imageFile,
filenamePrefix,
// SY -->
zip4jFile = null,
zip4jEntry = null,
// SY <--
)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to split downloaded image" }
}

View File

@ -1,11 +1,14 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.app.Application
import com.github.junrar.Archive
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.setting.ReaderPreferences
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import tachiyomi.core.util.system.ImageUtil
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.InputStream
import java.io.PipedInputStream
@ -18,9 +21,40 @@ internal class RarPageLoader(file: File) : PageLoader() {
private val rar = Archive(file)
// SY -->
private val context: Application by injectLazy()
private val readerPreferences: ReaderPreferences by injectLazy()
private val tmpDir = File(context.externalCacheDir, "reader_${file.hashCode()}").also {
it.deleteRecursively()
}
init {
if (readerPreferences.cacheArchiveMangaOnDisk().get()) {
tmpDir.mkdirs()
Archive(file).use { rar ->
rar.fileHeaders.asSequence()
.filterNot { it.isDirectory }
.forEach { header ->
val pageOutputStream = File(tmpDir, header.fileName.substringAfterLast("/"))
.also { it.createNewFile() }
.outputStream()
getStream(rar, header).use {
it.copyTo(pageOutputStream)
}
}
}
}
}
// SY <--
override var isLocal: Boolean = true
override suspend fun getPages(): List<ReaderPage> {
// SY -->
if (readerPreferences.cacheArchiveMangaOnDisk().get()) {
return DirectoryPageLoader(tmpDir).getPages()
}
// SY <--
return rar.fileHeaders.asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { rar.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
@ -40,6 +74,9 @@ internal class RarPageLoader(file: File) : PageLoader() {
override fun recycle() {
super.recycle()
rar.close()
// SY -->
tmpDir.deleteRecursively()
// SY <--
}
/**

View File

@ -3,13 +3,17 @@ package eu.kanade.tachiyomi.ui.reader.loader
import android.app.Application
import android.os.Build
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.setting.ReaderPreferences
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import net.lingala.zip4j.ZipFile
import tachiyomi.core.util.system.ImageUtil
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.nio.charset.StandardCharsets
import java.util.zip.ZipFile
import net.lingala.zip4j.ZipFile as Zip4jFile
/**
* Loader used to load a chapter from a .zip or .cbz file.
@ -18,44 +22,99 @@ internal class ZipPageLoader(file: File) : PageLoader() {
// SY -->
private val context: Application by injectLazy()
private val readerPreferences: ReaderPreferences by injectLazy()
private val tmpDir = File(context.externalCacheDir, "reader_${file.hashCode()}").also {
it.deleteRecursively()
it.mkdirs()
}
private val zip4j: Zip4jFile = Zip4jFile(file)
private val zip: ZipFile? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if (!zip4j.isEncrypted) ZipFile(file, StandardCharsets.ISO_8859_1) else null
} else {
if (!zip4j.isEncrypted) ZipFile(file) else null
}
init {
ZipFile(file).use { zip ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
zip4j.charset = StandardCharsets.ISO_8859_1
}
Zip4jFile(file).use { zip ->
if (zip.isEncrypted) {
if (!CbzCrypto.checkCbzPassword(zip, CbzCrypto.getDecryptedPasswordCbz())) {
this.recycle()
throw IllegalStateException(context.getString(R.string.wrong_cbz_archive_password))
}
unzip(zip, CbzCrypto.getDecryptedPasswordCbz())
zip4j.setPassword(CbzCrypto.getDecryptedPasswordCbz())
if (readerPreferences.cacheArchiveMangaOnDisk().get()) {
unzip()
}
} else {
unzip(zip)
if (readerPreferences.cacheArchiveMangaOnDisk().get()) {
unzip()
}
}
}
}
private fun unzip(zip: ZipFile, password: CharArray? = null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
zip.charset = StandardCharsets.ISO_8859_1
}
if (password != null) {
zip.setPassword(password)
}
zip.fileHeaders.asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { zip.getInputStream(it) } }
.forEach { entry ->
zip.extractFile(entry, tmpDir.absolutePath)
}
}
// SY <--
override fun recycle() {
super.recycle()
zip?.close()
// SY -->
zip4j.close()
tmpDir.deleteRecursively()
}
private fun unzip() {
tmpDir.mkdirs()
zip4j.fileHeaders.asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { zip4j.getInputStream(it) } }
.forEach { entry ->
zip4j.extractFile(entry, tmpDir.absolutePath)
}
}
override var isLocal: Boolean = true
override suspend fun getPages(): List<ReaderPage> {
return DirectoryPageLoader(tmpDir).getPages()
if (readerPreferences.cacheArchiveMangaOnDisk().get()) {
return DirectoryPageLoader(tmpDir).getPages()
}
if (zip == null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
zip4j.charset = StandardCharsets.ISO_8859_1
}
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()
} else {
// 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
}
}.toList()
}
}
/**
* No additional action required to load the page
*/
override suspend fun loadPage(page: ReaderPage) {
check(!isRecycled)
}
}

View File

@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.ui.reader.model
import eu.kanade.tachiyomi.source.model.Page
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.FileHeader
import java.io.InputStream
open class ReaderPage(
@ -8,6 +10,9 @@ open class ReaderPage(
url: String = "",
imageUrl: String? = null,
// 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 */
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 */

View File

@ -166,6 +166,8 @@ class ReaderPreferences(
fun invertDoublePages() = preferenceStore.getBoolean("invert_double_pages", false)
fun centerMarginType() = preferenceStore.getInt("center_margin_type", PagerConfig.CenterMarginType.NONE)
fun cacheArchiveMangaOnDisk() = preferenceStore.getBoolean("cache_archive_manga_on_disk", false)
// SY <--
enum class TappingInvertMode(val shouldInvertHorizontal: Boolean = false, val shouldInvertVertical: Boolean = false) {

View File

@ -229,7 +229,13 @@ class PagerPageHolder(
return splitInHalf(imageStream)
}
val isDoublePage = ImageUtil.isWideImage(imageStream)
val isDoublePage = ImageUtil.isWideImage(
imageStream,
// SY -->
page.zip4jFile,
page.zip4jEntry,
// SY <--
)
if (!isDoublePage) {
return imageStream
}
@ -240,7 +246,13 @@ class PagerPageHolder(
}
private fun rotateDualPage(imageStream: BufferedInputStream): InputStream {
val isDoublePage = ImageUtil.isWideImage(imageStream)
val isDoublePage = ImageUtil.isWideImage(
imageStream,
// SY -->
page.zip4jFile,
page.zip4jEntry,
// SY <--
)
return if (isDoublePage) {
val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f
ImageUtil.rotateImage(imageStream, rotation)
@ -254,7 +266,13 @@ class PagerPageHolder(
if (imageStream2 == null) {
return if (imageStream is BufferedInputStream &&
!ImageUtil.isAnimatedAndSupported(imageStream) &&
ImageUtil.isWideImage(imageStream) &&
ImageUtil.isWideImage(
imageStream,
// SY -->
page.zip4jFile,
page.zip4jEntry,
// SY <--
) &&
viewer.config.centerMarginType and PagerConfig.CenterMarginType.WIDE_PAGE_CENTER_MARGIN > 0 &&
!viewer.config.imageCropBorders
) {

View File

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

View File

@ -26,6 +26,8 @@ import androidx.core.graphics.red
import androidx.exifinterface.media.ExifInterface
import com.hippo.unifile.UniFile
import logcat.LogPriority
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.FileHeader
import tachiyomi.decoder.Format
import tachiyomi.decoder.ImageDecoder
import java.io.BufferedInputStream
@ -129,9 +131,19 @@ object ImageUtil {
*
* @return true if the width is greater than the height
*/
fun isWideImage(imageStream: BufferedInputStream): Boolean {
fun isWideImage(
imageStream: BufferedInputStream,
// SY -->
zip4jFile: ZipFile?,
zip4jEntry: FileHeader?,
// SY <--
): Boolean {
val options = extractImageOptions(
imageStream,
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
)
return options.outWidth > options.outHeight
}
@ -257,9 +269,19 @@ object ImageUtil {
*
* @return true if the height:width ratio is greater than 3.
*/
private fun isTallImage(imageStream: InputStream): Boolean {
private fun isTallImage(
imageStream: InputStream,
// SY -->
zip4jFile: ZipFile?,
zip4jEntry: FileHeader?,
// SY <--
): Boolean {
val options = extractImageOptions(
imageStream,
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
resetAfterExtraction = false,
)
@ -269,8 +291,23 @@ 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())) {
fun splitTallImage(
tmpDir: UniFile,
imageFile: UniFile,
filenamePrefix: String,
// SY -->
zip4jFile: ZipFile?,
zip4jEntry: FileHeader?,
// SY <--
): Boolean {
if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(
imageFile.openInputStream(),
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
)
) {
return true
}
@ -280,7 +317,14 @@ object ImageUtil {
return false
}
val options = extractImageOptions(imageFile.openInputStream(), resetAfterExtraction = false).apply {
val options = extractImageOptions(
imageFile.openInputStream(),
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
resetAfterExtraction = false,
).apply {
inJustDecodeBounds = false
}
@ -326,10 +370,22 @@ object ImageUtil {
* 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
*/
fun isStripSplitNeeded(imageStream: BufferedInputStream): Boolean {
fun isStripSplitNeeded(
imageStream: BufferedInputStream,
// SY -->
zip4jFile: ZipFile?,
zip4jEntry: FileHeader?,
// SY <--
): Boolean {
if (isAnimatedAndSupported(imageStream)) return false
val options = extractImageOptions(imageStream)
val options = extractImageOptions(
imageStream,
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
)
val imageHeightIsBiggerThanWidth = options.outHeight > options.outWidth
val imageHeightBiggerThanScreenHeight = options.outHeight > optimalImageHeight
return imageHeightIsBiggerThanWidth && imageHeightBiggerThanScreenHeight
@ -361,8 +417,20 @@ object ImageUtil {
}
}
fun getSplitDataForStream(imageStream: InputStream): List<SplitData> {
return extractImageOptions(imageStream).splitData
fun getSplitDataForStream(
imageStream: InputStream,
// SY -->
zip4jFile: ZipFile?,
zip4jEntry: FileHeader?,
// SY <--
): List<SplitData> {
return extractImageOptions(
imageStream,
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
).splitData
}
private val BitmapFactory.Options.splitData
@ -627,8 +695,17 @@ object ImageUtil {
*/
private fun extractImageOptions(
imageStream: InputStream,
// SY -->
zip4jFile: ZipFile?,
zip4jEntry: FileHeader?,
// SY <--
resetAfterExtraction: Boolean = true,
): 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)
val imageBytes = imageStream.readBytes()
@ -639,6 +716,15 @@ object ImageUtil {
}
// 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
* the size of files inside CBZ archives unique

View File

@ -334,6 +334,10 @@
<string name="pref_center_margin">Center margin type</string>
<string name="pref_center_margin_summary">Insert spacer to accommodate deadspace on foldable devices.</string>
<!-- Cache archived manga to disk -->
<string name="cache_archived_manga_to_disk">Cache images inside CBZ/CBR archives on disk</string>
<string name="cache_archived_manga_to_disk_subtitle">Temporarily copy images inside comic book archives to disk while reading \nMay improve reader performance</string>
<!-- Entry Page -->
<!-- Entry Info -->
<string name="az_recommends">See Recommendations</string>