From 6719f22effd2aaaef9bd8038df7fd0616274c68b Mon Sep 17 00:00:00 2001 From: Shamicen <84282253+Shamicen@users.noreply.github.com> Date: Sat, 16 Mar 2024 16:59:00 +0100 Subject: [PATCH] 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> --- app/build.gradle.kts | 2 +- app/proguard-rules.pro | 3 + .../settings/screen/SettingsReaderScreen.kt | 12 +- .../tachiyomi/data/download/Downloader.kt | 6 +- .../tachiyomi/ui/reader/ReaderViewModel.kt | 1 - .../ui/reader/loader/ChapterLoader.kt | 21 +- .../ui/reader/loader/DownloadPageLoader.kt | 6 +- .../ui/reader/loader/EpubPageLoader.kt | 9 +- .../ui/reader/loader/RarPageLoader.kt | 71 ++++-- .../ui/reader/loader/ZipPageLoader.kt | 172 +++++++++----- .../tachiyomi/ui/reader/model/ReaderPage.kt | 5 - .../ui/reader/setting/ReaderPreferences.kt | 16 +- .../ui/reader/viewer/pager/PagerPageHolder.kt | 24 +- .../viewer/webtoon/WebtoonPageHolder.kt | 16 +- app/src/main/java/exh/EXHMigrations.kt | 6 + .../exh/ui/intercept/InterceptActivity.kt | 1 - config/detekt/baseline.xml | 9 +- core/common/build.gradle.kts | 1 + .../tachiyomi/util/storage/CbzCrypto.kt | 167 +------------- .../kanade/tachiyomi/util/storage/EpubFile.kt | 26 ++- .../core/common/storage/UniFileExtensions.kt | 213 ++++++++++++++++++ .../core/common/util/system/ImageUtil.kt | 66 +----- gradle/libs.versions.toml | 2 + .../commonMain/resources/MR/base/strings.xml | 10 +- source-local/build.gradle.kts | 2 +- .../tachiyomi/source/local/LocalSource.kt | 42 ++-- .../source/local/image/LocalCoverManager.kt | 2 +- 27 files changed, 513 insertions(+), 398 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b0742a3fc..932d41962 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -215,7 +215,7 @@ dependencies { // Disk implementation(libs.disklrucache) implementation(libs.unifile) - implementation(libs.junrar) + implementation(libs.bundles.archive) // SY --> implementation(libs.zip4j) // SY <-- diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f4445d017..94692d207 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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 { (); } + # Firebase -keep class com.google.firebase.installations.** { *; } -keep interface com.google.firebase.installations.** { *; } 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 3acc668c3..49788eb7a 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 @@ -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(), ), ), ) 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 577adbdeb..90ef63638 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 @@ -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" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 52ca44179..38ffb1ace 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -389,7 +389,6 @@ class ReaderViewModel @JvmOverloads constructor( context = context, downloadManager = downloadManager, downloadProvider = downloadProvider, - tempFileManager = tempFileManager, manga = manga, source = source, /* SY --> */ sourceManager = sourceManager, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt index 0778e75b8..c05563f63 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt @@ -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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt index d0af290f2..ff70fec7f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt @@ -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 { - val loader = ZipPageLoader(tempFileManager.createTempFile(file)).also { zipPageLoader = it } + // SY --> + val loader = ZipPageLoader(file, context).also { zipPageLoader = it } + // SY <-- return loader.getPages() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt index 324af51bf..3a4d7388c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt @@ -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 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 9ca277f78..04c357a0e 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,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,20 +46,21 @@ 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 } - .forEach { header -> - val pageOutputStream = File(tmpDir, header.fileName.substringAfterLast("/")) - .also { it.createNewFile() } - .outputStream() - getStream(header).use { - it.copyTo(pageOutputStream) + rar.fileHeaders.asSequence() + .filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { rar.getInputStream(it) } } + .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } + .forEach { header -> + File(tmpDir, header.fileName.substringAfterLast("/")) + .also { it.createNewFile() } + .outputStream() + .use { output -> + rar.getInputStream(header).use { input -> + input.copyTo(output) + } } - } - } + } } } // SY <-- @@ -58,16 +74,37 @@ internal class RarPageLoader(file: File) : PageLoader() { override suspend fun getPages(): List { // 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? = + 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 } } 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 6b5b6ec01..049018ffb 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 @@ -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,107 +8,155 @@ 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 { + null + } + + private val tmpFile = + if ( + apacheZip == null && + readerPreferences.archiveReaderMode().get() != ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK + ) { + tempFileManager.createTempFile(file) } else { - if (!zip4j.isEncrypted) ZipFile(file) else null + 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())) { - 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() - } + if (file.isEncryptedZip()) { + if (!file.testCbzPassword()) { + this.recycle() + throw IllegalStateException(context.stringResource(SYMR.strings.wrong_cbz_archive_password)) } + 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 { - 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 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() + return if (apacheZip == null) { + loadZip4j() } 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() + loadApacheZip(apacheZip) } } + private fun loadZip4j(): List { + 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? = + 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 = { imageBytes?.copyOf()?.inputStream() ?: zip4j.getInputStream(entry) } + status = Page.State.READY + } + }.toList() + } + + private fun loadApacheZip(zip: ZipFile): List { + 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? = + 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 = { imageBytes?.copyOf()?.inputStream() ?: zip.getInputStream(entry) } + status = Page.State.READY + } + }.toList() + } + // SY <-- + /** * No additional action required to load the page */ 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 20b99d9cb..0a6f2d414 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,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 */ 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 9203ed1b0..149027acc 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 @@ -180,9 +180,9 @@ 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) + fun markReadDupe() = preferenceStore.getBoolean("mark_read_dupe", false) // SY <-- enum class TappingInvertMode( @@ -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 <-- } } 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 9cb629736..4f5636779 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 @@ -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 ) { 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 2dcf62723..a192f7853 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 @@ -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) diff --git a/app/src/main/java/exh/EXHMigrations.kt b/app/src/main/java/exh/EXHMigrations.kt index fe3e513cc..f1e57fbb3 100644 --- a/app/src/main/java/exh/EXHMigrations.kt +++ b/app/src/main/java/exh/EXHMigrations.kt @@ -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)) { diff --git a/app/src/main/java/exh/ui/intercept/InterceptActivity.kt b/app/src/main/java/exh/ui/intercept/InterceptActivity.kt index ba6c2d145..7effc5d81 100755 --- a/app/src/main/java/exh/ui/intercept/InterceptActivity.kt +++ b/app/src/main/java/exh/ui/intercept/InterceptActivity.kt @@ -187,7 +187,6 @@ class InterceptActivity : BaseActivity() { lifecycleScope.launchIO { loadGalleryEnd(gallery, sources[index]) } - } .show() } else { diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml index 9f521cda8..f185da8d9 100644 --- a/config/detekt/baseline.xml +++ b/config/detekt/baseline.xml @@ -172,7 +172,7 @@ ComplexCondition:LibraryUpdateJob.kt$LibraryUpdateJob$group == LibraryGroup.BY_DEFAULT || groupLibraryUpdateType == GroupLibraryMode.GLOBAL || (groupLibraryUpdateType == GroupLibraryMode.ALL_BUT_UNGROUPED && group == LibraryGroup.UNGROUPED) ComplexCondition:MangaRestorer.kt$MangaRestorer$customTitle != null || customArtist != null || customAuthor != null || customThumbnailUrl != null || customDescription != null || customGenre != null || customStatus != 0 ComplexCondition:MangaScreenModel.kt$MangaScreenModel$(selectedItem.selected && selected) || (!selectedItem.selected && !selected) - ComplexCondition:PagerPageHolder.kt$PagerPageHolder$imageStream is BufferedInputStream && !ImageUtil.isAnimatedAndSupported(imageStream) && ImageUtil.isWideImage( imageStream, // SY --> page.zip4jFile, page.zip4jEntry, // SY <-- ) && viewer.config.centerMarginType and PagerConfig.CenterMarginType.WIDE_PAGE_CENTER_MARGIN > 0 && !viewer.config.imageCropBorders + ComplexCondition:PagerPageHolder.kt$PagerPageHolder$imageStream is BufferedInputStream && !ImageUtil.isAnimatedAndSupported(imageStream) && ImageUtil.isWideImage(imageStream) && viewer.config.centerMarginType and PagerConfig.CenterMarginType.WIDE_PAGE_CENTER_MARGIN > 0 && !viewer.config.imageCropBorders ComplexCondition:PagerViewerAdapter.kt$PagerViewerAdapter$items[itemIndex]?.fullPage == true && itemIndex > 0 && items[itemIndex - 1] != null && (itemIndex - 1) % 2 == 0 ComplexCondition:ReaderActivity.kt$ReaderActivity$readerPreferences.useAutoWebtoon().get() && (manga?.readingMode?.toInt() ?: ReadingMode.DEFAULT.flagValue) == ReadingMode.DEFAULT.flagValue && defaultReaderType != null && defaultReaderType == ReadingMode.WEBTOON.flagValue ComplexCondition:ReaderNavigationOverlayView.kt$ReaderNavigationOverlayView$isVisible || (!showOnStart && firstLaunch) || navigation is DisabledNavigation @@ -539,8 +539,8 @@ LongMethod:UpdatesQuery.kt$UpdatesQuery$override fun <R> execute(mapper: (SqlCursor) -> QueryResult<R>): QueryResult<R> LongMethod:UpdatesUiItem.kt$internal fun LazyListScope.updatesUiItems( uiModels: List<UpdatesUiModel>, selectionMode: Boolean, // SY --> preserveReadingPosition: Boolean, // SY <-- onUpdateSelected: (UpdatesItem, Boolean, Boolean, Boolean) -> Unit, onClickCover: (UpdatesItem) -> Unit, onClickUpdate: (UpdatesItem) -> Unit, onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit, ) LongMethod:WebtoonRecyclerView.kt$WebtoonRecyclerView.Detector$override fun onTouchEvent(ev: MotionEvent): Boolean - 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 --> private val sourceManager: SourceManager, private val readerPrefs: ReaderPreferences, private val mergedReferences: List<MergedMangaReference>, private val mergedManga: Map<Long, Manga>, // SY <-- ) - 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, ) + 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 --> private val sourceManager: SourceManager, private val readerPrefs: ReaderPreferences, private val mergedReferences: List<MergedMangaReference>, private val mergedManga: Map<Long, Manga>, // SY <-- ) + 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, ) 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, ) 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, ) LongParameterList:FavoritesEntryRepositoryImpl.kt$FavoritesEntryRepositoryImpl$( gid: String, token: String, title: String, category: Long, otherGid: String?, otherToken: String?, ) @@ -806,6 +806,7 @@ MagicNumber:EXHMigrations.kt$EXHMigrations$59 MagicNumber:EXHMigrations.kt$EXHMigrations$6 MagicNumber:EXHMigrations.kt$EXHMigrations$60 + MagicNumber:EXHMigrations.kt$EXHMigrations$66 MagicNumber:EXHMigrations.kt$EXHMigrations$6907 MagicNumber:EXHMigrations.kt$EXHMigrations$6907L MagicNumber:EXHMigrations.kt$EXHMigrations$6909L @@ -1630,6 +1631,7 @@ NestedBlockDepth:SearchEngine.kt$SearchEngine$fun queryToSql(q: List<QueryComponent>): Pair<String, List<String>> NestedBlockDepth:SmartSearchEngine.kt$SmartSearchEngine$private fun removeTextInBrackets(text: String, readForward: Boolean): String NestedBlockDepth:SyncChaptersWithSource.kt$SyncChaptersWithSource$suspend fun await( rawSourceChapters: List<SChapter>, manga: Manga, source: Source, manualFetch: Boolean = false, fetchWindow: Pair<Long, Long> = Pair(0, 0), ): List<Chapter> + NestedBlockDepth:UniFileExtensions.kt$fun UniFile.unzip(destination: File, onlyCopyImages: Boolean = false) NestedBlockDepth:UniFileTempFileManager.kt$UniFileTempFileManager$fun createTempFile(file: UniFile): File NestedBlockDepth:WebtoonRecyclerView.kt$WebtoonRecyclerView.Detector$override fun onTouchEvent(ev: MotionEvent): Boolean NestedBlockDepth:WebtoonViewer.kt$WebtoonViewer$fun scrollDown() @@ -2105,6 +2107,7 @@ TopLevelPropertyNaming:CbzCrypto.kt$private const val CRYPTO_SETTINGS = "$ALGORITHM/$BLOCK_MODE/$PADDING" TopLevelPropertyNaming:CbzCrypto.kt$private const val IV_SIZE = 16 TopLevelPropertyNaming:CbzCrypto.kt$private const val KEY_SIZE = 256 + TopLevelPropertyNaming:CbzCrypto.kt$private const val SQL_PASSWORD_LENGTH = 32 TopLevelPropertyNaming:ChapterCache.kt$/** Application cache version. */ private const val PARAMETER_APP_VERSION = 1 TopLevelPropertyNaming:ChapterCache.kt$/** The maximum number of bytes this cache should use to store. */ private const val PARAMETER_CACHE_SIZE = 100L * 1024 * 1024 TopLevelPropertyNaming:ChapterCache.kt$/** The number of values per cache entry. Must be positive. */ private const val PARAMETER_VALUE_COUNT = 1 diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 9b23bda71..d3245202e 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(libs.image.decoder) implementation(libs.unifile) + implementation(libs.bundles.archive) api(kotlinx.coroutines.core) api(kotlinx.serialization.json) diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/util/storage/CbzCrypto.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/util/storage/CbzCrypto.kt index c1f442c99..1f8a3f503 100644 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/util/storage/CbzCrypto.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/util/storage/CbzCrypto.kt @@ -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 = ('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, 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 = 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 <-- diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/util/storage/EpubFile.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/util/storage/EpubFile.kt index a00ee69e7..8f2e1dcda 100644 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/util/storage/EpubFile.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/util/storage/EpubFile.kt @@ -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) } diff --git a/core/common/src/main/kotlin/tachiyomi/core/common/storage/UniFileExtensions.kt b/core/common/src/main/kotlin/tachiyomi/core/common/storage/UniFileExtensions.kt index 8bbd9b3a7..f99ad117c 100644 --- a/core/common/src/main/kotlin/tachiyomi/core/common/storage/UniFileExtensions.kt +++ b/core/common/src/main/kotlin/tachiyomi/core/common/storage/UniFileExtensions.kt @@ -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, 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 = 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 <-- diff --git a/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt b/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt index 6f207afc8..053a54b41 100644 --- a/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt +++ b/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec8557f6a..a3a8ff728 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"] diff --git a/i18n-sy/src/commonMain/resources/MR/base/strings.xml b/i18n-sy/src/commonMain/resources/MR/base/strings.xml index 2afb1a3a7..d2c499689 100644 --- a/i18n-sy/src/commonMain/resources/MR/base/strings.xml +++ b/i18n-sy/src/commonMain/resources/MR/base/strings.xml @@ -324,9 +324,13 @@ 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 + + Load from file + Load into memory + Copy to disk + Archive reader mode + The way in which images inside archives, such as CBZ or CBR, are being loaded + diff --git a/source-local/build.gradle.kts b/source-local/build.gradle.kts index 727d2a9c3..9409b5207 100644 --- a/source-local/build.gradle.kts +++ b/source-local/build.gradle.kts @@ -15,7 +15,7 @@ kotlin { // SY <-- implementation(libs.unifile) - implementation(libs.junrar) + implementation(libs.bundles.archive) // SY --> implementation(libs.zip4j) // SY <-- diff --git a/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt b/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt index 6036ab6e3..2859a3d1b 100755 --- a/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt +++ b/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt @@ -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,15 +421,17 @@ actual class LocalSource( } } is Format.Epub -> { - EpubFile(tempFileManager.createTempFile(format.file)).use { epub -> - val entry = epub.getImagesFromPages() - .firstOrNull() - ?.let { epub.getEntry(it) } + // SY --> + EpubFile(format.file, context).use { epub -> + // SY <-- + val entry = epub.getImagesFromPages() + .firstOrNull() + ?.let { epub.getEntry(it) } - entry?.let { coverManager.update(manga, epub.getInputStream(it)) } + entry?.let { coverManager.update(manga, epub.getInputStream(it)) } + } } } - } } catch (e: Throwable) { logcat(LogPriority.ERROR, e) { "Error updating cover for ${manga.title}" } null diff --git a/source-local/src/androidMain/kotlin/tachiyomi/source/local/image/LocalCoverManager.kt b/source-local/src/androidMain/kotlin/tachiyomi/source/local/image/LocalCoverManager.kt index 0738ce1c4..390e901dc 100644 --- a/source-local/src/androidMain/kotlin/tachiyomi/source/local/image/LocalCoverManager.kt +++ b/source-local/src/androidMain/kotlin/tachiyomi/source/local/image/LocalCoverManager.kt @@ -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