diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt index 2c54710a5..58a48b25a 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsReaderScreen.kt @@ -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), + ), ), ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt index 53fc6971e..018946373 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt @@ -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" } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt index 056319d4e..2c8bfc9f9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/RarPageLoader.kt @@ -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 { + // 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 <-- } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt index 36df3cc53..6cda0d1dd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt @@ -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 { - 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) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt index 0a6f2d414..20b99d9cb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt @@ -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 */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt index 809a726e4..5da1bf4bc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt @@ -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) { 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 6f643562d..bf032e47f 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 @@ -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 ) { 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 b72a011da..8b6d0348d 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 @@ -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) } diff --git a/core/src/main/java/tachiyomi/core/util/system/ImageUtil.kt b/core/src/main/java/tachiyomi/core/util/system/ImageUtil.kt index 4c781b986..2d49df4f0 100644 --- a/core/src/main/java/tachiyomi/core/util/system/ImageUtil.kt +++ b/core/src/main/java/tachiyomi/core/util/system/ImageUtil.kt @@ -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 { - return extractImageOptions(imageStream).splitData + fun getSplitDataForStream( + imageStream: InputStream, + // SY --> + zip4jFile: ZipFile?, + zip4jEntry: FileHeader?, + // SY <-- + ): List { + 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 diff --git a/i18n/src/main/res/values/strings_sy.xml b/i18n/src/main/res/values/strings_sy.xml index f5b8bbb4f..27de0c002 100644 --- a/i18n/src/main/res/values/strings_sy.xml +++ b/i18n/src/main/res/values/strings_sy.xml @@ -334,6 +334,10 @@ Center margin type Insert spacer to accommodate deadspace on foldable devices. + + Cache images inside CBZ/CBR archives on disk + Temporarily copy images inside comic book archives to disk while reading \nMay improve reader performance + See Recommendations