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:
parent
be6bbb8e9b
commit
6071acd3df
@ -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),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
@ -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" }
|
||||
}
|
||||
|
@ -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 <--
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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 */
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
) {
|
||||
|
@ -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) }
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user