implement mihonapp/mihon#326 (#1104)

* implement mihonapp/mihon#326

Archives are now being read from channels

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>

* disable parallelisms for loading into memory

* switched to mutex

* detekt changes

* more detekt baseline changes

---------

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
This commit is contained in:
Shamicen 2024-03-16 16:59:00 +01:00 committed by GitHub
parent 45711cd394
commit 6719f22eff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 513 additions and 398 deletions

View File

@ -215,7 +215,7 @@ dependencies {
// Disk
implementation(libs.disklrucache)
implementation(libs.unifile)
implementation(libs.junrar)
implementation(libs.bundles.archive)
// SY -->
implementation(libs.zip4j)
// SY <--

View File

@ -122,6 +122,9 @@
# XmlUtil
-keep public enum nl.adaptivity.xmlutil.EventType { *; }
# Apache Commons Compress
-keep class * extends org.apache.commons.compress.archivers.zip.ZipExtraField { <init>(); }
# Firebase
-keep class com.google.firebase.installations.** { *; }
-keep interface com.google.firebase.installations.** { *; }

View File

@ -570,10 +570,14 @@ object SettingsReaderScreen : SearchableSettings {
.toMap()
.toImmutableMap(),
),
Preference.PreferenceItem.SwitchPreference(
pref = readerPreferences.cacheArchiveMangaOnDisk(),
title = stringResource(SYMR.strings.cache_archived_manga_to_disk),
subtitle = stringResource(SYMR.strings.cache_archived_manga_to_disk_subtitle),
Preference.PreferenceItem.ListPreference(
pref = readerPreferences.archiveReaderMode(),
title = stringResource(SYMR.strings.pref_archive_reader_mode),
subtitle = stringResource(SYMR.strings.pref_archive_reader_mode_summary),
entries = ReaderPreferences.archiveModeTypes
.mapIndexed { index, it -> index to stringResource(it) }
.toMap()
.toImmutableMap(),
),
),
)

View File

@ -13,7 +13,6 @@ import eu.kanade.tachiyomi.source.UnmeteredSource
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import eu.kanade.tachiyomi.util.storage.CbzCrypto.addFilesToZip
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE
import eu.kanade.tachiyomi.util.storage.saveTo
@ -46,6 +45,7 @@ import logcat.LogPriority
import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Response
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.storage.addFilesToZip
import tachiyomi.core.common.storage.extension
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.launchNow
@ -572,10 +572,6 @@ class Downloader(
tmpDir,
imageFile,
filenamePrefix,
// SY -->
zip4jFile = null,
zip4jEntry = null,
// SY <--
)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to split downloaded image" }

View File

@ -389,7 +389,6 @@ class ReaderViewModel @JvmOverloads constructor(
context = context,
downloadManager = downloadManager,
downloadProvider = downloadProvider,
tempFileManager = tempFileManager,
manga = manga,
source = source, /* SY --> */
sourceManager = sourceManager,

View File

@ -10,7 +10,6 @@ import eu.kanade.tachiyomi.source.online.all.MergedSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.storage.UniFileTempFileManager
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.manga.model.Manga
@ -28,7 +27,6 @@ class ChapterLoader(
private val context: Context,
private val downloadManager: DownloadManager,
private val downloadProvider: DownloadProvider,
private val tempFileManager: UniFileTempFileManager,
private val manga: Manga,
private val source: Source,
// SY -->
@ -121,36 +119,39 @@ class ChapterLoader(
source = source,
downloadManager = downloadManager,
downloadProvider = downloadProvider,
tempFileManager = tempFileManager,
)
source is HttpSource -> HttpPageLoader(chapter, source)
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
when (format) {
is Format.Directory -> DirectoryPageLoader(format.file)
is Format.Zip -> ZipPageLoader(tempFileManager.createTempFile(format.file))
is Format.Zip -> ZipPageLoader(format.file, context)
is Format.Rar -> try {
RarPageLoader(tempFileManager.createTempFile(format.file))
RarPageLoader(format.file)
} catch (e: UnsupportedRarV5Exception) {
error(context.stringResource(MR.strings.loader_rar5_error))
}
is Format.Epub -> EpubPageLoader(tempFileManager.createTempFile(format.file))
is Format.Epub -> EpubPageLoader(format.file, context)
}
}
else -> error(context.stringResource(MR.strings.loader_not_implemented_error))
}
}
// SY <--
isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager, downloadProvider, tempFileManager)
isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager, downloadProvider)
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
when (format) {
is Format.Directory -> DirectoryPageLoader(format.file)
is Format.Zip -> ZipPageLoader(tempFileManager.createTempFile(format.file))
// SY -->
is Format.Zip -> ZipPageLoader(format.file, context)
is Format.Rar -> try {
RarPageLoader(tempFileManager.createTempFile(format.file))
RarPageLoader(format.file)
// SY <--
} catch (e: UnsupportedRarV5Exception) {
error(context.stringResource(MR.strings.loader_rar5_error))
}
is Format.Epub -> EpubPageLoader(tempFileManager.createTempFile(format.file))
// SY -->
is Format.Epub -> EpubPageLoader(format.file, context)
// SY <--
}
}
source is HttpSource -> HttpPageLoader(chapter, source)

View File

@ -10,7 +10,6 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import tachiyomi.core.common.storage.UniFileTempFileManager
import tachiyomi.domain.manga.model.Manga
import uy.kohesive.injekt.injectLazy
@ -23,7 +22,6 @@ internal class DownloadPageLoader(
private val source: Source,
private val downloadManager: DownloadManager,
private val downloadProvider: DownloadProvider,
private val tempFileManager: UniFileTempFileManager,
) : PageLoader() {
private val context: Application by injectLazy()
@ -48,7 +46,9 @@ internal class DownloadPageLoader(
}
private suspend fun getPagesFromArchive(file: UniFile): List<ReaderPage> {
val loader = ZipPageLoader(tempFileManager.createTempFile(file)).also { zipPageLoader = it }
// SY -->
val loader = ZipPageLoader(file, context).also { zipPageLoader = it }
// SY <--
return loader.getPages()
}

View File

@ -1,16 +1,19 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.content.Context
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.storage.EpubFile
import java.io.File
/**
* Loader used to load a chapter from a .epub file.
*/
internal class EpubPageLoader(file: File) : PageLoader() {
// SY -->
internal class EpubPageLoader(file: UniFile, context: Context) : PageLoader() {
private val epub = EpubFile(file)
private val epub = EpubFile(file, context)
// SY <--
override var isLocal: Boolean = true

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.app.Application
import android.os.Build
import com.github.junrar.Archive
import com.github.junrar.rarfile.FileHeader
import com.hippo.unifile.UniFile
@ -8,6 +9,14 @@ 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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import tachiyomi.core.common.storage.UniFileTempFileManager
import tachiyomi.core.common.util.system.ImageUtil
import uy.kohesive.injekt.injectLazy
import java.io.File
@ -19,11 +28,17 @@ import java.util.concurrent.Executors
/**
* Loader used to load a chapter from a .rar or .cbr file.
*/
internal class RarPageLoader(file: File) : PageLoader() {
private val rar = Archive(file)
internal class RarPageLoader(file: UniFile) : PageLoader() {
// SY -->
private val tempFileManager: UniFileTempFileManager by injectLazy()
private val rar = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
Archive(tempFileManager.createTempFile(file))
} else {
Archive(file.openInputStream())
}
private val context: Application by injectLazy()
private val readerPreferences: ReaderPreferences by injectLazy()
private val tmpDir = File(context.externalCacheDir, "reader_${file.hashCode()}").also {
@ -31,17 +46,18 @@ internal class RarPageLoader(file: File) : PageLoader() {
}
init {
if (readerPreferences.cacheArchiveMangaOnDisk().get()) {
if (readerPreferences.archiveReaderMode().get() == ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) {
tmpDir.mkdirs()
Archive(file).use { rar ->
rar.fileHeaders.asSequence()
.filterNot { it.isDirectory }
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { rar.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.forEach { header ->
val pageOutputStream = File(tmpDir, header.fileName.substringAfterLast("/"))
File(tmpDir, header.fileName.substringAfterLast("/"))
.also { it.createNewFile() }
.outputStream()
getStream(header).use {
it.copyTo(pageOutputStream)
.use { output ->
rar.getInputStream(header).use { input ->
input.copyTo(output)
}
}
}
@ -58,16 +74,37 @@ internal class RarPageLoader(file: File) : PageLoader() {
override suspend fun getPages(): List<ReaderPage> {
// SY -->
if (readerPreferences.cacheArchiveMangaOnDisk().get()) {
if (readerPreferences.archiveReaderMode().get() == ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) {
return DirectoryPageLoader(UniFile.fromFile(tmpDir)!!).getPages()
}
val mutex = Mutex()
// SY <--
return rar.fileHeaders.asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { rar.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.mapIndexed { i, header ->
// SY -->
val imageBytesDeferred: Deferred<ByteArray>? =
when (readerPreferences.archiveReaderMode().get()) {
ReaderPreferences.ArchiveReaderMode.LOAD_INTO_MEMORY -> {
CoroutineScope(Dispatchers.IO).async {
mutex.withLock {
getStream(header).buffered().use { stream ->
stream.readBytes()
}
}
}
}
else -> null
}
val imageBytes by lazy { runBlocking { imageBytesDeferred?.await() } }
// SY <--
ReaderPage(i).apply {
stream = { getStream(header) }
// SY -->
stream = { imageBytes?.copyOf()?.inputStream() ?: getStream(header) }
// SY <--
status = Page.State.READY
}
}

View File

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.app.Application
import android.content.Context
import android.os.Build
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.model.Page
@ -8,106 +8,154 @@ 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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.apache.commons.compress.archivers.zip.ZipFile
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.storage.UniFileTempFileManager
import tachiyomi.core.common.storage.isEncryptedZip
import tachiyomi.core.common.storage.openReadOnlyChannel
import tachiyomi.core.common.storage.testCbzPassword
import tachiyomi.core.common.storage.unzip
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.i18n.sy.SYMR
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.nio.charset.StandardCharsets
import java.util.zip.ZipFile
import java.nio.channels.SeekableByteChannel
import net.lingala.zip4j.ZipFile as Zip4jFile
/**
* Loader used to load a chapter from a .zip or .cbz file.
*/
internal class ZipPageLoader(file: File) : PageLoader() {
internal class ZipPageLoader(file: UniFile, context: Context) : PageLoader() {
// SY -->
private val context: Application by injectLazy()
private val channel: SeekableByteChannel = file.openReadOnlyChannel(context)
private val tempFileManager: UniFileTempFileManager by injectLazy()
private val readerPreferences: ReaderPreferences by injectLazy()
private val tmpDir = File(context.externalCacheDir, "reader_${file.hashCode()}").also {
it.deleteRecursively()
}
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
private val apacheZip: ZipFile? = if (!file.isEncryptedZip() && Build.VERSION.SDK_INT > Build.VERSION_CODES.N) {
ZipFile(channel)
} else {
if (!zip4j.isEncrypted) ZipFile(file) else null
null
}
private val tmpFile =
if (
apacheZip == null &&
readerPreferences.archiveReaderMode().get() != ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK
) {
tempFileManager.createTempFile(file)
} else {
null
}
private val zip4j =
if (apacheZip == null && tmpFile != null) {
Zip4jFile(tmpFile)
} else {
null
}
init {
Zip4jFile(file).use { zip ->
if (zip.isEncrypted) {
if (!CbzCrypto.checkCbzPassword(zip, CbzCrypto.getDecryptedPasswordCbz())) {
if (file.isEncryptedZip()) {
if (!file.testCbzPassword()) {
this.recycle()
throw IllegalStateException(context.stringResource(SYMR.strings.wrong_cbz_archive_password))
}
zip4j.setPassword(CbzCrypto.getDecryptedPasswordCbz())
if (readerPreferences.cacheArchiveMangaOnDisk().get()) {
unzip()
}
} else {
if (readerPreferences.cacheArchiveMangaOnDisk().get()) {
unzip()
}
zip4j?.setPassword(CbzCrypto.getDecryptedPasswordCbz())
}
if (readerPreferences.archiveReaderMode().get() == ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) {
file.unzip(tmpDir, onlyCopyImages = true)
}
}
// SY <--
override fun recycle() {
super.recycle()
zip?.close()
apacheZip?.close()
// SY -->
zip4j.close()
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> {
if (readerPreferences.cacheArchiveMangaOnDisk().get()) {
if (readerPreferences.archiveReaderMode().get() == ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) {
return DirectoryPageLoader(UniFile.fromFile(tmpDir)!!).getPages()
}
if (zip == null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
zip4j.charset = StandardCharsets.ISO_8859_1
return if (apacheZip == null) {
loadZip4j()
} else {
loadApacheZip(apacheZip)
}
}
return zip4j.fileHeaders.asSequence()
private fun loadZip4j(): List<ReaderPage> {
val mutex = Mutex()
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 ->
val imageBytesDeferred: Deferred<ByteArray>? =
when (readerPreferences.archiveReaderMode().get()) {
ReaderPreferences.ArchiveReaderMode.LOAD_INTO_MEMORY -> {
CoroutineScope(Dispatchers.IO).async {
mutex.withLock {
zip4j.getInputStream(entry).buffered().use { stream ->
stream.readBytes()
}
}
}
}
else -> null
}
val imageBytes by lazy { runBlocking { imageBytesDeferred?.await() } }
ReaderPage(i).apply {
stream = { zip4j.getInputStream(entry) }
stream = { imageBytes?.copyOf()?.inputStream() ?: zip4j.getInputStream(entry) }
status = Page.State.READY
zip4jFile = zip4j
zip4jEntry = entry
}
}.toList()
} else {
// SY <--
return zip.entries().asSequence()
}
private fun loadApacheZip(zip: ZipFile): List<ReaderPage> {
val mutex = Mutex()
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 ->
val imageBytesDeferred: Deferred<ByteArray>? =
when (readerPreferences.archiveReaderMode().get()) {
ReaderPreferences.ArchiveReaderMode.LOAD_INTO_MEMORY -> {
CoroutineScope(Dispatchers.IO).async {
mutex.withLock {
zip.getInputStream(entry).buffered().use { stream ->
stream.readBytes()
}
}
}
}
else -> null
}
val imageBytes by lazy { runBlocking { imageBytesDeferred?.await() } }
ReaderPage(i).apply {
stream = { zip.getInputStream(entry) }
stream = { imageBytes?.copyOf()?.inputStream() ?: zip.getInputStream(entry) }
status = Page.State.READY
}
}.toList()
}
}
// SY <--
/**
* No additional action required to load the page

View File

@ -1,8 +1,6 @@
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(
@ -10,9 +8,6 @@ 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

@ -180,7 +180,7 @@ class ReaderPreferences(
fun centerMarginType() = preferenceStore.getInt("center_margin_type", PagerConfig.CenterMarginType.NONE)
fun cacheArchiveMangaOnDisk() = preferenceStore.getBoolean("cache_archive_manga_on_disk", false)
fun archiveReaderMode() = preferenceStore.getInt("archive_reader_mode", ArchiveReaderMode.LOAD_FROM_FILE)
fun markReadDupe() = preferenceStore.getBoolean("mark_read_dupe", false)
// SY <--
@ -203,6 +203,12 @@ class ReaderPreferences(
LOWEST(47),
}
object ArchiveReaderMode {
const val LOAD_FROM_FILE = 0
const val LOAD_INTO_MEMORY = 1
const val CACHE_TO_DISK = 2
}
companion object {
const val WEBTOON_PADDING_MIN = 0
const val WEBTOON_PADDING_MAX = 25
@ -264,6 +270,12 @@ class ReaderPreferences(
SYMR.strings.center_margin_wide_page,
SYMR.strings.center_margin_double_and_wide_page,
)
val archiveModeTypes = listOf(
SYMR.strings.archive_mode_load_from_file,
SYMR.strings.archive_mode_load_into_memory,
SYMR.strings.archive_mode_cache_to_disk
)
// SY <--
}
}

View File

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

View File

@ -224,13 +224,7 @@ class WebtoonPageHolder(
}
if (viewer.config.dualPageSplit) {
val isDoublePage = ImageUtil.isWideImage(
imageStream,
// SY -->
page?.zip4jFile,
page?.zip4jEntry,
// SY <--
)
val isDoublePage = ImageUtil.isWideImage(imageStream)
if (isDoublePage) {
val upperSide = if (viewer.config.dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT
return ImageUtil.splitAndMerge(imageStream, upperSide)
@ -241,13 +235,7 @@ class WebtoonPageHolder(
}
private fun rotateDualPage(imageStream: BufferedInputStream): InputStream {
val isDoublePage = ImageUtil.isWideImage(
imageStream,
// SY -->
page?.zip4jFile,
page?.zip4jEntry,
// SY <--
)
val isDoublePage = ImageUtil.isWideImage(imageStream)
return if (isDoublePage) {
val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f
ImageUtil.rotateImage(imageStream, rotation)

View File

@ -656,6 +656,12 @@ object EXHMigrations {
remove(Preference.appStateKey("trusted_signatures"))
}
}
if (oldVersion under 66) {
val cacheImagesToDisk = prefs.getBoolean("cache_archive_manga_on_disk", false)
if (cacheImagesToDisk) {
readerPreferences.archiveReaderMode().set(ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK)
}
}
if (oldVersion under 66) {
if (prefs.getBoolean(Preference.privateKey("encrypt_database"), false)) {

View File

@ -187,7 +187,6 @@ class InterceptActivity : BaseActivity() {
lifecycleScope.launchIO {
loadGalleryEnd(gallery, sources[index])
}
}
.show()
} else {

View File

@ -172,7 +172,7 @@
<ID>ComplexCondition:LibraryUpdateJob.kt$LibraryUpdateJob$group == LibraryGroup.BY_DEFAULT || groupLibraryUpdateType == GroupLibraryMode.GLOBAL || (groupLibraryUpdateType == GroupLibraryMode.ALL_BUT_UNGROUPED &amp;&amp; group == LibraryGroup.UNGROUPED)</ID>
<ID>ComplexCondition:MangaRestorer.kt$MangaRestorer$customTitle != null || customArtist != null || customAuthor != null || customThumbnailUrl != null || customDescription != null || customGenre != null || customStatus != 0</ID>
<ID>ComplexCondition:MangaScreenModel.kt$MangaScreenModel$(selectedItem.selected &amp;&amp; selected) || (!selectedItem.selected &amp;&amp; !selected)</ID>
<ID>ComplexCondition:PagerPageHolder.kt$PagerPageHolder$imageStream is BufferedInputStream &amp;&amp; !ImageUtil.isAnimatedAndSupported(imageStream) &amp;&amp; ImageUtil.isWideImage( imageStream, // SY --&gt; page.zip4jFile, page.zip4jEntry, // SY &lt;-- ) &amp;&amp; viewer.config.centerMarginType and PagerConfig.CenterMarginType.WIDE_PAGE_CENTER_MARGIN &gt; 0 &amp;&amp; !viewer.config.imageCropBorders</ID>
<ID>ComplexCondition:PagerPageHolder.kt$PagerPageHolder$imageStream is BufferedInputStream &amp;&amp; !ImageUtil.isAnimatedAndSupported(imageStream) &amp;&amp; ImageUtil.isWideImage(imageStream) &amp;&amp; viewer.config.centerMarginType and PagerConfig.CenterMarginType.WIDE_PAGE_CENTER_MARGIN &gt; 0 &amp;&amp; !viewer.config.imageCropBorders</ID>
<ID>ComplexCondition:PagerViewerAdapter.kt$PagerViewerAdapter$items[itemIndex]?.fullPage == true &amp;&amp; itemIndex &gt; 0 &amp;&amp; items[itemIndex - 1] != null &amp;&amp; (itemIndex - 1) % 2 == 0</ID>
<ID>ComplexCondition:ReaderActivity.kt$ReaderActivity$readerPreferences.useAutoWebtoon().get() &amp;&amp; (manga?.readingMode?.toInt() ?: ReadingMode.DEFAULT.flagValue) == ReadingMode.DEFAULT.flagValue &amp;&amp; defaultReaderType != null &amp;&amp; defaultReaderType == ReadingMode.WEBTOON.flagValue</ID>
<ID>ComplexCondition:ReaderNavigationOverlayView.kt$ReaderNavigationOverlayView$isVisible || (!showOnStart &amp;&amp; firstLaunch) || navigation is DisabledNavigation</ID>
@ -539,8 +539,8 @@
<ID>LongMethod:UpdatesQuery.kt$UpdatesQuery$override fun &lt;R&gt; execute(mapper: (SqlCursor) -&gt; QueryResult&lt;R&gt;): QueryResult&lt;R&gt;</ID>
<ID>LongMethod:UpdatesUiItem.kt$internal fun LazyListScope.updatesUiItems( uiModels: List&lt;UpdatesUiModel&gt;, selectionMode: Boolean, // SY --&gt; preserveReadingPosition: Boolean, // SY &lt;-- onUpdateSelected: (UpdatesItem, Boolean, Boolean, Boolean) -&gt; Unit, onClickCover: (UpdatesItem) -&gt; Unit, onClickUpdate: (UpdatesItem) -&gt; Unit, onDownloadChapter: (List&lt;UpdatesItem&gt;, ChapterDownloadAction) -&gt; Unit, )</ID>
<ID>LongMethod:WebtoonRecyclerView.kt$WebtoonRecyclerView.Detector$override fun onTouchEvent(ev: MotionEvent): Boolean</ID>
<ID>LongParameterList:ChapterLoader.kt$ChapterLoader$( private val context: Context, private val downloadManager: DownloadManager, private val downloadProvider: DownloadProvider, private val tempFileManager: UniFileTempFileManager, private val manga: Manga, private val source: Source, // SY --&gt; private val sourceManager: SourceManager, private val readerPrefs: ReaderPreferences, private val mergedReferences: List&lt;MergedMangaReference&gt;, private val mergedManga: Map&lt;Long, Manga&gt;, // SY &lt;-- )</ID>
<ID>LongParameterList:ChapterMapper.kt$ChapterMapper$( id: Long, mangaId: Long, url: String, name: String, scanlator: String?, read: Boolean, bookmark: Boolean, lastPageRead: Long, chapterNumber: Double, sourceOrder: Long, dateFetch: Long, dateUpload: Long, lastModifiedAt: Long, )</ID>
<ID>LongParameterList:ChapterLoader.kt$ChapterLoader$( private val context: Context, private val downloadManager: DownloadManager, private val downloadProvider: DownloadProvider, private val manga: Manga, private val source: Source, // SY --&gt; private val sourceManager: SourceManager, private val readerPrefs: ReaderPreferences, private val mergedReferences: List&lt;MergedMangaReference&gt;, private val mergedManga: Map&lt;Long, Manga&gt;, // SY &lt;-- )</ID>
<ID>LongParameterList:ChapterMapper.kt$ChapterMapper$( id: Long, mangaId: Long, url: String, name: String, scanlator: String?, read: Boolean, bookmark: Boolean, lastPageRead: Long, chapterNumber: Double, sourceOrder: Long, dateFetch: Long, dateUpload: Long, lastModifiedAt: Long, version: Long, @Suppress("UNUSED_PARAMETER") isSyncing: Long, )</ID>
<ID>LongParameterList:Chip.kt$ChipColors$( private val containerColor: Color, private val labelColor: Color, private val leadingIconContentColor: Color, private val trailingIconContentColor: Color, private val disabledContainerColor: Color, private val disabledLabelColor: Color, private val disabledLeadingIconContentColor: Color, private val disabledTrailingIconContentColor: Color, )</ID>
<ID>LongParameterList:EXHMigrations.kt$EXHMigrations$( context: Context, preferenceStore: PreferenceStore, basePreferences: BasePreferences, uiPreferences: UiPreferences, networkPreferences: NetworkPreferences, sourcePreferences: SourcePreferences, securityPreferences: SecurityPreferences, libraryPreferences: LibraryPreferences, readerPreferences: ReaderPreferences, backupPreferences: BackupPreferences, trackerManager: TrackerManager, pagePreviewCache: PagePreviewCache, )</ID>
<ID>LongParameterList:FavoritesEntryRepositoryImpl.kt$FavoritesEntryRepositoryImpl$( gid: String, token: String, title: String, category: Long, otherGid: String?, otherToken: String?, )</ID>
@ -806,6 +806,7 @@
<ID>MagicNumber:EXHMigrations.kt$EXHMigrations$59</ID>
<ID>MagicNumber:EXHMigrations.kt$EXHMigrations$6</ID>
<ID>MagicNumber:EXHMigrations.kt$EXHMigrations$60</ID>
<ID>MagicNumber:EXHMigrations.kt$EXHMigrations$66</ID>
<ID>MagicNumber:EXHMigrations.kt$EXHMigrations$6907</ID>
<ID>MagicNumber:EXHMigrations.kt$EXHMigrations$6907L</ID>
<ID>MagicNumber:EXHMigrations.kt$EXHMigrations$6909L</ID>
@ -1630,6 +1631,7 @@
<ID>NestedBlockDepth:SearchEngine.kt$SearchEngine$fun queryToSql(q: List&lt;QueryComponent&gt;): Pair&lt;String, List&lt;String&gt;&gt;</ID>
<ID>NestedBlockDepth:SmartSearchEngine.kt$SmartSearchEngine$private fun removeTextInBrackets(text: String, readForward: Boolean): String</ID>
<ID>NestedBlockDepth:SyncChaptersWithSource.kt$SyncChaptersWithSource$suspend fun await( rawSourceChapters: List&lt;SChapter&gt;, manga: Manga, source: Source, manualFetch: Boolean = false, fetchWindow: Pair&lt;Long, Long&gt; = Pair(0, 0), ): List&lt;Chapter&gt;</ID>
<ID>NestedBlockDepth:UniFileExtensions.kt$fun UniFile.unzip(destination: File, onlyCopyImages: Boolean = false)</ID>
<ID>NestedBlockDepth:UniFileTempFileManager.kt$UniFileTempFileManager$fun createTempFile(file: UniFile): File</ID>
<ID>NestedBlockDepth:WebtoonRecyclerView.kt$WebtoonRecyclerView.Detector$override fun onTouchEvent(ev: MotionEvent): Boolean</ID>
<ID>NestedBlockDepth:WebtoonViewer.kt$WebtoonViewer$fun scrollDown()</ID>
@ -2105,6 +2107,7 @@
<ID>TopLevelPropertyNaming:CbzCrypto.kt$private const val CRYPTO_SETTINGS = "$ALGORITHM/$BLOCK_MODE/$PADDING"</ID>
<ID>TopLevelPropertyNaming:CbzCrypto.kt$private const val IV_SIZE = 16</ID>
<ID>TopLevelPropertyNaming:CbzCrypto.kt$private const val KEY_SIZE = 256</ID>
<ID>TopLevelPropertyNaming:CbzCrypto.kt$private const val SQL_PASSWORD_LENGTH = 32</ID>
<ID>TopLevelPropertyNaming:ChapterCache.kt$/** Application cache version. */ private const val PARAMETER_APP_VERSION = 1</ID>
<ID>TopLevelPropertyNaming:ChapterCache.kt$/** The maximum number of bytes this cache should use to store. */ private const val PARAMETER_CACHE_SIZE = 100L * 1024 * 1024</ID>
<ID>TopLevelPropertyNaming:ChapterCache.kt$/** The number of values per cache entry. Must be positive. */ private const val PARAMETER_VALUE_COUNT = 1</ID>

View File

@ -36,6 +36,7 @@ dependencies {
implementation(libs.image.decoder)
implementation(libs.unifile)
implementation(libs.bundles.archive)
api(kotlinx.coroutines.core)
api(kotlinx.serialization.json)

View File

@ -3,25 +3,15 @@ package eu.kanade.tachiyomi.util.storage
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.core.security.SecurityPreferences
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import logcat.LogPriority
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.exception.ZipException
import net.lingala.zip4j.io.inputstream.ZipInputStream
import net.lingala.zip4j.io.outputstream.ZipOutputStream
import net.lingala.zip4j.model.LocalFileHeader
import net.lingala.zip4j.model.ZipParameters
import net.lingala.zip4j.model.enums.AesKeyStrength
import net.lingala.zip4j.model.enums.EncryptionMethod
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.injectLazy
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
@ -136,12 +126,15 @@ object CbzCrypto {
}
fun getDecryptedPasswordCbz(): CharArray {
return decrypt(securityPreferences.cbzPassword().get(), ALIAS_CBZ).toCharArray()
val encryptedPassword = securityPreferences.cbzPassword().get()
if (encryptedPassword.isBlank()) error("This archive is encrypted please set a password")
return decrypt(encryptedPassword, ALIAS_CBZ).toCharArray()
}
private fun generateAndEncryptSqlPw() {
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
val password = (1..32).map {
val password = (1..SQL_PASSWORD_LENGTH).map {
charPool[SecureRandom().nextInt(charPool.size)]
}.joinToString("", transform = { it.toString() })
securityPreferences.sqlPassword().set(encrypt(password, encryptionCipherSql))
@ -152,27 +145,6 @@ object CbzCrypto {
return decrypt(securityPreferences.sqlPassword().get(), ALIAS_SQL).toByteArray()
}
/**
* Function that returns true when the supplied password
* can Successfully decrypt the supplied zip archive
* not very elegant but this is the solution recommended by the maintainer for checking passwords
* a real password check will likely be implemented in the future though
*/
fun checkCbzPassword(zip4j: ZipFile, password: CharArray): Boolean {
try {
zip4j.setPassword(password)
zip4j.use { zip ->
zip.getInputStream(zip.fileHeaders.firstOrNull())
}
return true
} catch (e: Exception) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${zip4j.file.name} in: ${zip4j.file.parentFile?.name}"
}
}
return false
}
fun isPasswordSet(): Boolean {
return securityPreferences.cbzPassword().get().isNotEmpty()
}
@ -228,133 +200,6 @@ object CbzCrypto {
}
return String(bytes).contains(DEFAULT_COVER_NAME, ignoreCase = true)
}
fun UniFile.isEncryptedZip(): Boolean {
return try {
val stream = ZipInputStream(this.openInputStream())
stream.nextEntry
stream.close()
false
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
true
} else throw zipException
}
}
fun UniFile.testCbzPassword(): Boolean {
return try {
val stream = ZipInputStream(this.openInputStream())
stream.setPassword(getDecryptedPasswordCbz())
stream.nextEntry
stream.close()
true
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
false
} else throw zipException
}
}
fun UniFile.addStreamToZip(inputStream: InputStream, filename: String, password: CharArray? = null) {
val zipOutputStream =
if (password != null) ZipOutputStream(this.openOutputStream(), password)
else ZipOutputStream(this.openOutputStream())
val zipParameters = ZipParameters()
zipParameters.fileNameInZip = filename
if (password != null) setZipParametersEncrypted(zipParameters)
zipOutputStream.putNextEntry(zipParameters)
zipOutputStream.use { output ->
inputStream.use { input ->
input.copyTo(output)
}
}
}
fun UniFile.addFilesToZip(files: List<UniFile>, password: CharArray? = null) {
val zipOutputStream =
if (password != null) ZipOutputStream(this.openOutputStream(), password)
else ZipOutputStream(this.openOutputStream())
files.forEach {
val zipParameters = ZipParameters()
if (password != null) setZipParametersEncrypted(zipParameters)
zipParameters.fileNameInZip = it.name
zipOutputStream.putNextEntry(zipParameters)
it.openInputStream().use { input ->
input.copyTo(zipOutputStream)
}
zipOutputStream.closeEntry()
}
zipOutputStream.close()
}
fun UniFile.getZipInputStream(filename: String): InputStream? {
val zipInputStream = ZipInputStream(this.openInputStream())
var fileHeader: LocalFileHeader?
if (this.isEncryptedZip()) zipInputStream.setPassword(getDecryptedPasswordCbz())
try {
while (run {
fileHeader = zipInputStream.nextEntry
fileHeader != null
}) {
if (fileHeader?.fileName == filename) return zipInputStream
}
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${this.name} in: ${this.parentFile?.name}"
}
} else throw zipException
}
return null
}
fun UniFile.getCoverStreamFromZip(): InputStream? {
val zipInputStream = ZipInputStream(this.openInputStream())
var fileHeader: LocalFileHeader?
val fileHeaderList: MutableList<LocalFileHeader?> = mutableListOf()
if (this.isEncryptedZip()) zipInputStream.setPassword(getDecryptedPasswordCbz())
try {
while (run {
fileHeader = zipInputStream.nextEntry
fileHeader != null
}) {
fileHeaderList.add(fileHeader)
}
var coverHeader = fileHeaderList
.mapNotNull { it }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) }
val coverStream = coverHeader?.fileName?.let { this.getZipInputStream(it) }
if (coverStream != null) {
if (!ImageUtil.isImage(coverHeader?.fileName) { coverStream }) coverHeader = null
}
return coverHeader?.fileName?.let { getZipInputStream(it) }
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${this.name} in: ${this.parentFile?.name}"
}
return null
} else throw zipException
}
}
}
private const val BUFFER_SIZE = 2048
@ -369,4 +214,6 @@ private const val CRYPTO_SETTINGS = "$ALGORITHM/$BLOCK_MODE/$PADDING"
private const val KEYSTORE = "AndroidKeyStore"
private const val ALIAS_CBZ = "cbzPw"
private const val ALIAS_SQL = "sqlPw"
private const val SQL_PASSWORD_LENGTH = 32
// SY <--

View File

@ -1,22 +1,36 @@
package eu.kanade.tachiyomi.util.storage
import android.content.Context
import android.os.Build
import com.hippo.unifile.UniFile
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
import org.apache.commons.compress.archivers.zip.ZipFile
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import tachiyomi.core.common.storage.UniFileTempFileManager
import tachiyomi.core.common.storage.openReadOnlyChannel
import uy.kohesive.injekt.injectLazy
import java.io.Closeable
import java.io.File
import java.io.InputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
/**
* Wrapper over ZipFile to load files in epub format.
*/
class EpubFile(file: File) : Closeable {
// SY -->
class EpubFile(file: UniFile, context: Context) : Closeable {
private val tempFileManager: UniFileTempFileManager by injectLazy()
/**
* Zip file of this epub.
*/
private val zip = ZipFile(file)
private val zip = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
ZipFile(tempFileManager.createTempFile(file))
} else {
ZipFile(file.openReadOnlyChannel(context))
}
// SY <--
/**
* Path separator used by this epub.
@ -33,14 +47,14 @@ class EpubFile(file: File) : Closeable {
/**
* Returns an input stream for reading the contents of the specified zip file entry.
*/
fun getInputStream(entry: ZipEntry): InputStream {
fun getInputStream(entry: ZipArchiveEntry): InputStream {
return zip.getInputStream(entry)
}
/**
* Returns the zip file entry for the specified name, or null if not found.
*/
fun getEntry(name: String): ZipEntry? {
fun getEntry(name: String): ZipArchiveEntry? {
return zip.getEntry(name)
}

View File

@ -1,6 +1,21 @@
package tachiyomi.core.common.storage
import android.content.Context
import android.os.ParcelFileDescriptor
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import logcat.LogPriority
import net.lingala.zip4j.exception.ZipException
import net.lingala.zip4j.io.inputstream.ZipInputStream
import net.lingala.zip4j.io.outputstream.ZipOutputStream
import net.lingala.zip4j.model.LocalFileHeader
import net.lingala.zip4j.model.ZipParameters
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat
import java.io.File
import java.io.InputStream
import java.nio.channels.FileChannel
val UniFile.extension: String?
get() = name?.substringAfterLast('.')
@ -10,3 +25,201 @@ val UniFile.nameWithoutExtension: String?
val UniFile.displayablePath: String
get() = filePath ?: uri.toString()
fun UniFile.openReadOnlyChannel(context: Context): FileChannel {
return ParcelFileDescriptor.AutoCloseInputStream(context.contentResolver.openFileDescriptor(uri, "r")).channel
// SY -->
}
fun UniFile.isEncryptedZip(): Boolean {
return try {
val stream = ZipInputStream(this.openInputStream())
stream.nextEntry
stream.close()
false
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
true
} else {
throw zipException
}
}
}
fun UniFile.testCbzPassword(): Boolean {
return try {
val stream = ZipInputStream(this.openInputStream())
stream.setPassword(CbzCrypto.getDecryptedPasswordCbz())
stream.nextEntry
stream.close()
true
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
false
} else {
throw zipException
}
}
}
fun UniFile.addStreamToZip(inputStream: InputStream, filename: String, password: CharArray? = null) {
val zipOutputStream =
if (password != null) {
ZipOutputStream(this.openOutputStream(), password)
} else {
ZipOutputStream(this.openOutputStream())
}
val zipParameters = ZipParameters()
zipParameters.fileNameInZip = filename
if (password != null) CbzCrypto.setZipParametersEncrypted(zipParameters)
zipOutputStream.putNextEntry(zipParameters)
zipOutputStream.use { output ->
inputStream.use { input ->
input.copyTo(output)
}
}
}
/**
* Unzips encrypted or unencrypted zip files using zip4j.
* The caller is responsible to ensure, that the file this is called from is a zip archive
*/
fun UniFile.unzip(destination: File, onlyCopyImages: Boolean = false) {
destination.mkdirs()
if (!destination.isDirectory) return
val zipInputStream = ZipInputStream(this.openInputStream())
var fileHeader: LocalFileHeader?
if (this.isEncryptedZip()) {
zipInputStream.setPassword(CbzCrypto.getDecryptedPasswordCbz())
}
try {
while (
run {
fileHeader = zipInputStream.nextEntry
fileHeader != null
}
) {
val tmpFile = File("${destination.absolutePath}/${fileHeader!!.fileName}")
if (onlyCopyImages) {
if (!fileHeader!!.isDirectory && ImageUtil.isImage(fileHeader!!.fileName)) {
tmpFile.createNewFile()
tmpFile.outputStream().buffered().use { tmpOut ->
zipInputStream.buffered().copyTo(tmpOut)
}
}
} else {
if (!fileHeader!!.isDirectory && ImageUtil.isImage(fileHeader!!.fileName)) {
tmpFile.createNewFile()
tmpFile
.outputStream()
.buffered()
.use { zipInputStream.buffered().copyTo(it) }
}
}
}
zipInputStream.close()
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${this.name} in: ${this.parentFile?.name}"
}
} else {
throw zipException
}
}
}
fun UniFile.addFilesToZip(files: List<UniFile>, password: CharArray? = null) {
val zipOutputStream =
if (password != null) {
ZipOutputStream(this.openOutputStream(), password)
} else {
ZipOutputStream(this.openOutputStream())
}
files.forEach {
val zipParameters = ZipParameters()
if (password != null) CbzCrypto.setZipParametersEncrypted(zipParameters)
zipParameters.fileNameInZip = it.name
zipOutputStream.putNextEntry(zipParameters)
it.openInputStream().use { input ->
input.copyTo(zipOutputStream)
}
zipOutputStream.closeEntry()
}
zipOutputStream.close()
}
fun UniFile.getZipInputStream(filename: String): InputStream? {
val zipInputStream = ZipInputStream(this.openInputStream())
var fileHeader: LocalFileHeader?
if (this.isEncryptedZip()) zipInputStream.setPassword(CbzCrypto.getDecryptedPasswordCbz())
try {
while (
run {
fileHeader = zipInputStream.nextEntry
fileHeader != null
}
) {
if (fileHeader?.fileName == filename) return zipInputStream
}
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${this.name} in: ${this.parentFile?.name}"
}
} else {
throw zipException
}
}
return null
}
fun UniFile.getCoverStreamFromZip(): InputStream? {
val zipInputStream = ZipInputStream(this.openInputStream())
var fileHeader: LocalFileHeader?
val fileHeaderList: MutableList<LocalFileHeader?> = mutableListOf()
if (this.isEncryptedZip()) zipInputStream.setPassword(CbzCrypto.getDecryptedPasswordCbz())
try {
while (
run {
fileHeader = zipInputStream.nextEntry
fileHeader != null
}
) {
fileHeaderList.add(fileHeader)
}
var coverHeader = fileHeaderList
.mapNotNull { it }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) }
val coverStream = coverHeader?.fileName?.let { this.getZipInputStream(it) }
if (coverStream != null) {
if (!ImageUtil.isImage(coverHeader?.fileName) { coverStream }) coverHeader = null
}
return coverHeader?.fileName?.let { getZipInputStream(it) }
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${this.name} in: ${this.parentFile?.name}"
}
return null
} else {
throw zipException
}
}
}
// SY <--

View File

@ -26,8 +26,6 @@ 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
@ -133,20 +131,8 @@ object ImageUtil {
*
* @return true if the width is greater than the height
*/
fun isWideImage(
imageStream: BufferedInputStream,
// SY -->
zip4jFile: ZipFile?,
zip4jEntry: FileHeader?,
// SY <--
): Boolean {
val options = extractImageOptions(
imageStream,
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
)
fun isWideImage(imageStream: BufferedInputStream): Boolean {
val options = extractImageOptions(imageStream)
return options.outWidth > options.outHeight
}
@ -271,19 +257,9 @@ object ImageUtil {
*
* @return true if the height:width ratio is greater than 3.
*/
private fun isTallImage(
imageStream: InputStream,
// SY -->
zip4jFile: ZipFile?,
zip4jEntry: FileHeader?,
// SY <--
): Boolean {
private fun isTallImage(imageStream: InputStream): Boolean {
val options = extractImageOptions(
imageStream,
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
resetAfterExtraction = false,
)
@ -297,18 +273,9 @@ object ImageUtil {
tmpDir: UniFile,
imageFile: UniFile,
filenamePrefix: String,
// SY -->
zip4jFile: ZipFile?,
zip4jEntry: FileHeader?,
// SY <--
): Boolean {
if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(
imageFile.openInputStream(),
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
)
if (isAnimatedAndSupported(imageFile.openInputStream()) ||
!isTallImage(imageFile.openInputStream())
) {
return true
}
@ -321,10 +288,6 @@ object ImageUtil {
val options = extractImageOptions(
imageFile.openInputStream(),
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
resetAfterExtraction = false,
).apply {
inJustDecodeBounds = false
@ -641,17 +604,8 @@ 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(Int.MAX_VALUE)
val imageBytes = imageStream.readBytes()
@ -661,16 +615,6 @@ object ImageUtil {
return options
}
// 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

@ -33,6 +33,7 @@ jsoup = "org.jsoup:jsoup:1.17.2"
disklrucache = "com.jakewharton:disklrucache:2.0.2"
unifile = "com.github.tachiyomiorg:unifile:7c257e1c64"
common-compress = "org.apache.commons:commons-compress:1.26.0"
junrar = "com.github.junrar:junrar:7.5.5"
zip4j = "net.lingala.zip4j:zip4j:2.11.5"
@ -111,6 +112,7 @@ google-api-client-oauth = "com.google.oauth-client:google-oauth-client:1.34.1"
[bundles]
acra = ["acra-http", "acra-scheduler"]
archive = ["common-compress", "junrar"]
okhttp = ["okhttp-core", "okhttp-logging", "okhttp-brotli", "okhttp-dnsoverhttps"]
js-engine = ["quickjs-android"]
sqlite = ["sqlite-framework", "sqlite-ktx", "sqlite-android"]

View File

@ -324,9 +324,13 @@
<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>
<!-- Archive reader mode -->
<string name="archive_mode_load_from_file">Load from file</string>
<string name="archive_mode_load_into_memory">Load into memory</string>
<string name="archive_mode_cache_to_disk">Copy to disk</string>
<string name="pref_archive_reader_mode">Archive reader mode</string>
<string name="pref_archive_reader_mode_summary">The way in which images inside archives, such as CBZ or CBR, are being loaded</string>
<!-- Entry Page -->
<!-- Entry Info -->

View File

@ -15,7 +15,7 @@ kotlin {
// SY <--
implementation(libs.unifile)
implementation(libs.junrar)
implementation(libs.bundles.archive)
// SY -->
implementation(libs.zip4j)
// SY <--

View File

@ -1,6 +1,7 @@
package tachiyomi.source.local
import android.content.Context
import android.os.Build
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source
@ -11,10 +12,6 @@ import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import eu.kanade.tachiyomi.util.storage.CbzCrypto.addStreamToZip
import eu.kanade.tachiyomi.util.storage.CbzCrypto.getCoverStreamFromZip
import eu.kanade.tachiyomi.util.storage.CbzCrypto.getZipInputStream
import eu.kanade.tachiyomi.util.storage.CbzCrypto.isEncryptedZip
import eu.kanade.tachiyomi.util.storage.EpubFile
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@ -31,7 +28,11 @@ import tachiyomi.core.metadata.comicinfo.copyFromComicInfo
import tachiyomi.core.metadata.comicinfo.getComicInfo
import tachiyomi.core.metadata.tachiyomi.MangaDetails
import tachiyomi.core.common.storage.UniFileTempFileManager
import tachiyomi.core.common.storage.addStreamToZip
import tachiyomi.core.common.storage.extension
import tachiyomi.core.common.storage.getCoverStreamFromZip
import tachiyomi.core.common.storage.getZipInputStream
import tachiyomi.core.common.storage.isEncryptedZip
import tachiyomi.core.common.storage.nameWithoutExtension
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.ImageUtil
@ -268,7 +269,13 @@ actual class LocalSource(
}
}
is Format.Rar -> {
JunrarArchive(tempFileManager.createTempFile(chapter)).use { rar ->
val archive = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
JunrarArchive(tempFileManager.createTempFile(chapter))
} else {
JunrarArchive(chapter.openInputStream())
}
archive.use { rar ->
rar.fileHeaders.firstOrNull { it.fileName == COMIC_INFO_FILE }?.let { comicInfoFile ->
rar.getInputStream(comicInfoFile).buffered().use { stream ->
return copyComicInfoFile(stream, folder)
@ -330,7 +337,7 @@ actual class LocalSource(
val format = Format.valueOf(chapterFile)
if (format is Format.Epub) {
EpubFile(tempFileManager.createTempFile(format.file)).use { epub ->
EpubFile(format.file, context).use { epub ->
epub.fillMetadata(manga, this)
}
}
@ -397,10 +404,15 @@ actual class LocalSource(
format.file.isEncryptedZip()
)
}
// SY <--
}
is Format.Rar -> {
JunrarArchive(tempFileManager.createTempFile(format.file)).use { archive ->
val rarArchive = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
JunrarArchive(tempFileManager.createTempFile(format.file))
} else {
JunrarArchive(format.file.openInputStream())
}
rarArchive.use { archive ->
// SY <--
val entry = archive.fileHeaders
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
@ -409,7 +421,9 @@ actual class LocalSource(
}
}
is Format.Epub -> {
EpubFile(tempFileManager.createTempFile(format.file)).use { epub ->
// SY -->
EpubFile(format.file, context).use { epub ->
// SY <--
val entry = epub.getImagesFromPages()
.firstOrNull()
?.let { epub.getEntry(it) }

View File

@ -4,7 +4,7 @@ import android.content.Context
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import eu.kanade.tachiyomi.util.storage.CbzCrypto.addStreamToZip
import tachiyomi.core.common.storage.addStreamToZip
import eu.kanade.tachiyomi.util.storage.DiskUtil
import tachiyomi.core.common.storage.nameWithoutExtension
import tachiyomi.core.common.util.system.ImageUtil