Libarchive refactor (#1249)

* Refactor archive support with libarchive

* Refactor archive support with libarchive

* Revert string resource changs

* Only mark archive formats as supported

Comic book archives should not be compressed.

* Fixup

* Remove epub from archive format list

* Move to mihon package

* Format

* Cleanup

Co-authored-by: Shamicen <84282253+Shamicen@users.noreply.github.com>
(cherry picked from commit 239c38982c4fd55d4d86b37fd9c3c51c3b47d098)

* handle incorrect passwords

* lint

* fixed broken encryption detection + small tweaks

* Add safeguard to prevent ArchiveInputStream from being closed twice (#967)

* fix: Add safeguard to prevent ArchiveInputStream from being closed twice

* detekt

* lint: Make detekt happy

---------

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

(cherry picked from commit e620665dda9eb5cc39f09e6087ea4f60a3cbe150)

* fixed ArchiveReaderMode CACHE_TO_DISK

* Added some missing SY --> comments

---------

Co-authored-by: FooIbar <118464521+fooibar@users.noreply.github.com>
Co-authored-by: Ahmad Ansori Palembani <46041660+null2264@users.noreply.github.com>
This commit is contained in:
Shamicen 2024-08-18 02:25:25 +02:00 committed by GitHub
parent 71f2daf8f3
commit 95c834581b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 477 additions and 749 deletions

View File

@ -212,10 +212,6 @@ dependencies {
// Disk // Disk
implementation(libs.disklrucache) implementation(libs.disklrucache)
implementation(libs.unifile) implementation(libs.unifile)
implementation(libs.bundles.archive)
// SY -->
implementation(libs.zip4j)
// SY <--
// Preferences // Preferences
implementation(libs.preferencektx) implementation(libs.preferencektx)

View File

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

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.data.coil package eu.kanade.tachiyomi.data.coil
import android.app.Application
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Build import android.os.Build
import coil3.ImageLoader import coil3.ImageLoader
@ -11,37 +12,37 @@ import coil3.decode.ImageSource
import coil3.fetch.SourceFetchResult import coil3.fetch.SourceFetchResult
import coil3.request.Options import coil3.request.Options
import coil3.request.bitmapConfig import coil3.request.bitmapConfig
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.storage.CbzCrypto import eu.kanade.tachiyomi.util.storage.CbzCrypto
import eu.kanade.tachiyomi.util.storage.CbzCrypto.getCoverStream
import eu.kanade.tachiyomi.util.system.GLUtil import eu.kanade.tachiyomi.util.system.GLUtil
import net.lingala.zip4j.ZipFile import mihon.core.common.archive.archiveReader
import net.lingala.zip4j.model.FileHeader
import okio.BufferedSource import okio.BufferedSource
import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.decoder.ImageDecoder import tachiyomi.decoder.ImageDecoder
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.BufferedInputStream
/** /**
* A [Decoder] that uses built-in [ImageDecoder] to decode images that is not supported by the system. * A [Decoder] that uses built-in [ImageDecoder] to decode images that is not supported by the system.
*/ */
class TachiyomiImageDecoder(private val resources: ImageSource, private val options: Options) : Decoder { class TachiyomiImageDecoder(private val resources: ImageSource, private val options: Options) : Decoder {
private val context = Injekt.get<Application>()
override suspend fun decode(): DecodeResult { override suspend fun decode(): DecodeResult {
// SY --> // SY -->
var zip4j: ZipFile? = null var coverStream: BufferedInputStream? = null
var entry: FileHeader? = null
if (resources.sourceOrNull()?.peek()?.use { CbzCrypto.detectCoverImageArchive(it.inputStream()) } == true) { if (resources.sourceOrNull()?.peek()?.use { CbzCrypto.detectCoverImageArchive(it.inputStream()) } == true) {
if (resources.source().peek().use { ImageUtil.findImageType(it.inputStream()) == null }) { if (resources.source().peek().use { ImageUtil.findImageType(it.inputStream()) == null }) {
zip4j = ZipFile(resources.file().toFile().absolutePath) coverStream = UniFile.fromFile(resources.file().toFile())
entry = zip4j.fileHeaders.firstOrNull { ?.archiveReader(context = context)
it.fileName.equals(CbzCrypto.DEFAULT_COVER_NAME, ignoreCase = true) ?.getCoverStream()
}
if (zip4j.isEncrypted) zip4j.setPassword(CbzCrypto.getDecryptedPasswordCbz())
} }
} }
val decoder = resources.sourceOrNull()?.use { val decoder = resources.sourceOrNull()?.use {
zip4j.use { zipFile -> coverStream.use { coverStream ->
ImageDecoder.newInstance(zipFile?.getInputStream(entry) ?: it.inputStream(), options.cropBorders, displayProfile) ImageDecoder.newInstance(coverStream ?: it.inputStream(), options.cropBorders, displayProfile)
} }
} }
// SY <-- // SY <--

View File

@ -44,10 +44,10 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
import logcat.LogPriority import logcat.LogPriority
import mihon.core.common.archive.ZipWriter
import nl.adaptivity.xmlutil.serialization.XML import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Response import okhttp3.Response
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.storage.addFilesToZip
import tachiyomi.core.common.storage.extension import tachiyomi.core.common.storage.extension
import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.launchNow import tachiyomi.core.common.util.lang.launchNow
@ -65,12 +65,8 @@ import tachiyomi.domain.track.interactor.GetTracks
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.BufferedOutputStream
import java.io.File import java.io.File
import java.util.Locale import java.util.Locale
import java.util.zip.CRC32
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/** /**
* This class is the one in charge of downloading chapters. * This class is the one in charge of downloading chapters.
@ -619,70 +615,19 @@ class Downloader(
tmpDir: UniFile, tmpDir: UniFile,
) { ) {
// SY --> // SY -->
if (CbzCrypto.getPasswordProtectDlPref() && CbzCrypto.isPasswordSet()) { val encrypt = CbzCrypto.getPasswordProtectDlPref() && CbzCrypto.isPasswordSet()
archiveEncryptedChapter(mangaDir, dirname, tmpDir)
return
}
// SY <-- // SY <--
val zip = mangaDir.createFile("$dirname.cbz$TMP_DIR_SUFFIX")!! val zip = mangaDir.createFile("$dirname.cbz$TMP_DIR_SUFFIX")!!
ZipOutputStream(BufferedOutputStream(zip.openOutputStream())).use { zipOut -> ZipWriter(context, zip, /* SY --> */ encrypt /* SY <-- */).use { writer ->
zipOut.setMethod(ZipEntry.STORED) tmpDir.listFiles()?.forEach { file ->
writer.write(file)
tmpDir.listFiles()?.forEach { img ->
img.openInputStream().use { input ->
val data = input.readBytes()
val size = img.length()
val entry = ZipEntry(img.name).apply {
val crc = CRC32().apply {
update(data)
}
setCrc(crc.value)
compressedSize = size
setSize(size)
}
zipOut.putNextEntry(entry)
zipOut.write(data)
}
} }
} }
zip.renameTo("$dirname.cbz") zip.renameTo("$dirname.cbz")
tmpDir.delete() tmpDir.delete()
} }
// SY -->
private fun archiveEncryptedChapter(
mangaDir: UniFile,
dirname: String,
tmpDir: UniFile,
) {
tmpDir.filePath?.let { addPaddingToImage(File(it)) }
tmpDir.listFiles()?.toList()?.let { files ->
mangaDir.createFile("$dirname.cbz$TMP_DIR_SUFFIX")
?.addFilesToZip(files, CbzCrypto.getDecryptedPasswordCbz())
}
mangaDir.findFile("$dirname.cbz$TMP_DIR_SUFFIX")?.renameTo("$dirname.cbz")
tmpDir.delete()
}
private fun addPaddingToImage(imageDir: File) {
imageDir.listFiles()
// using ImageUtils isImage and findImageType functions causes IO errors when deleting files to set Exif Metadata
// it should be safe to assume that all files with image extensions are actual images at this point
?.filter {
it.extension.equals("jpg", true) ||
it.extension.equals("jpeg", true) ||
it.extension.equals("png", true) ||
it.extension.equals("webp", true)
}
?.forEach { ImageUtil.addPaddingToImageExif(it) }
}
// SY <--
/** /**
* Creates a ComicInfo.xml file inside the given directory. * Creates a ComicInfo.xml file inside the given directory.
*/ */

View File

@ -1,9 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.loader package eu.kanade.tachiyomi.ui.reader.loader
import android.app.Application import android.app.Application
import android.os.Build
import com.github.junrar.Archive
import com.github.junrar.rarfile.FileHeader
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
@ -16,80 +13,69 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import tachiyomi.core.common.storage.UniFileTempFileManager import mihon.core.common.archive.ArchiveReader
import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.ImageUtil
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import java.io.InputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.util.concurrent.Executors
/** /**
* Loader used to load a chapter from a .rar or .cbr file. * Loader used to load a chapter from an archive file.
*/ */
internal class RarPageLoader(file: UniFile) : PageLoader() { internal class ArchivePageLoader(private val reader: ArchiveReader) : PageLoader() {
// SY --> // SY -->
private val tempFileManager: UniFileTempFileManager by injectLazy() private val mutex = Mutex()
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 context: Application by injectLazy()
private val readerPreferences: ReaderPreferences by injectLazy() private val readerPreferences: ReaderPreferences by injectLazy()
private val tmpDir = File(context.externalCacheDir, "reader_${file.hashCode()}").also { private val tmpDir = File(context.externalCacheDir, "reader_${reader.archiveHashCode}").also {
it.deleteRecursively() it.deleteRecursively()
} }
init { init {
reader.wrongPassword?.let { wrongPassword ->
if (wrongPassword) {
error("Incorrect archive password")
}
}
if (readerPreferences.archiveReaderMode().get() == ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) { if (readerPreferences.archiveReaderMode().get() == ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) {
tmpDir.mkdirs() tmpDir.mkdirs()
rar.fileHeaders.asSequence() reader.useEntries { entries ->
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { rar.getInputStream(it) } } entries
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } .filter { it.isFile && ImageUtil.isImage(it.name) { reader.getInputStream(it.name)!! } }
.forEach { header -> .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
File(tmpDir, header.fileName.substringAfterLast("/")) .forEach { entry ->
.also { it.createNewFile() } File(tmpDir, entry.name.substringAfterLast("/"))
.outputStream() .also { it.createNewFile() }
.use { output -> .outputStream()
rar.getInputStream(header).use { input -> .use { output ->
input.copyTo(output) reader.getInputStream(entry.name)?.use { input ->
input.copyTo(output)
}
} }
} }
} }
} }
} }
// SY <-- // SY <--
override var isLocal: Boolean = true override var isLocal: Boolean = true
/** override suspend fun getPages(): List<ReaderPage> = reader.useEntries { entries ->
* Pool for copying compressed files to an input stream.
*/
private val pool = Executors.newFixedThreadPool(1)
override suspend fun getPages(): List<ReaderPage> {
// SY --> // SY -->
if (readerPreferences.archiveReaderMode().get() == ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) { if (readerPreferences.archiveReaderMode().get() == ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) {
return DirectoryPageLoader(UniFile.fromFile(tmpDir)!!).getPages() return DirectoryPageLoader(UniFile.fromFile(tmpDir)!!).getPages()
} }
val mutex = Mutex()
// SY <-- // SY <--
return rar.fileHeaders.asSequence() entries
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { rar.getInputStream(it) } } .filter { it.isFile && ImageUtil.isImage(it.name) { reader.getInputStream(it.name)!! } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.mapIndexed { i, header -> .mapIndexed { i, entry ->
// SY --> // SY -->
val imageBytesDeferred: Deferred<ByteArray>? = val imageBytesDeferred: Deferred<ByteArray>? =
when (readerPreferences.archiveReaderMode().get()) { when (readerPreferences.archiveReaderMode().get()) {
ReaderPreferences.ArchiveReaderMode.LOAD_INTO_MEMORY -> { ReaderPreferences.ArchiveReaderMode.LOAD_INTO_MEMORY -> {
CoroutineScope(Dispatchers.IO).async { CoroutineScope(Dispatchers.IO).async {
mutex.withLock { mutex.withLock {
getStream(header).buffered().use { stream -> reader.getInputStream(entry.name)!!.buffered().use { stream ->
stream.readBytes() stream.readBytes()
} }
} }
@ -98,12 +84,11 @@ internal class RarPageLoader(file: UniFile) : PageLoader() {
else -> null else -> null
} }
val imageBytes by lazy { runBlocking { imageBytesDeferred?.await() } } val imageBytes by lazy { runBlocking { imageBytesDeferred?.await() } }
// SY <-- // SY <--
ReaderPage(i).apply { ReaderPage(i).apply {
// SY --> // SY -->
stream = { imageBytes?.copyOf()?.inputStream() ?: getStream(header) } stream = { imageBytes?.copyOf()?.inputStream() ?: reader.getInputStream(entry.name)!! }
// SY <-- // SY <--
status = Page.State.READY status = Page.State.READY
} }
@ -117,27 +102,9 @@ internal class RarPageLoader(file: UniFile) : PageLoader() {
override fun recycle() { override fun recycle() {
super.recycle() super.recycle()
rar.close() reader.close()
// SY --> // SY -->
tmpDir.deleteRecursively() tmpDir.deleteRecursively()
// SY <-- // SY <--
pool.shutdown()
}
/**
* Returns an input stream for the given [header].
*/
private fun getStream(header: FileHeader): InputStream {
val pipeIn = PipedInputStream()
val pipeOut = PipedOutputStream(pipeIn)
pool.execute {
try {
pipeOut.use {
rar.extractFile(header, it)
}
} catch (e: Exception) {
}
}
return pipeIn
} }
} }

View File

@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.loader package eu.kanade.tachiyomi.ui.reader.loader
import android.content.Context import android.content.Context
import com.github.junrar.exception.UnsupportedRarV5Exception
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadProvider import eu.kanade.tachiyomi.data.download.DownloadProvider
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
@ -9,6 +8,7 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.all.MergedSource import eu.kanade.tachiyomi.source.online.all.MergedSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import mihon.core.common.archive.archiveReader
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
@ -124,34 +124,26 @@ class ChapterLoader(
source is LocalSource -> source.getFormat(chapter.chapter).let { format -> source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
when (format) { when (format) {
is Format.Directory -> DirectoryPageLoader(format.file) is Format.Directory -> DirectoryPageLoader(format.file)
is Format.Zip -> ZipPageLoader(format.file, context) is Format.Archive -> ArchivePageLoader(format.file.archiveReader(context))
is Format.Rar -> try { is Format.Epub -> EpubPageLoader(format.file.archiveReader(context))
RarPageLoader(format.file)
} catch (e: UnsupportedRarV5Exception) {
error(context.stringResource(MR.strings.loader_rar5_error))
}
is Format.Epub -> EpubPageLoader(format.file, context)
} }
} }
else -> error(context.stringResource(MR.strings.loader_not_implemented_error)) else -> error(context.stringResource(MR.strings.loader_not_implemented_error))
} }
} }
// SY <-- // SY <--
isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager, downloadProvider) isDownloaded -> DownloadPageLoader(
chapter,
manga,
source,
downloadManager,
downloadProvider,
)
source is LocalSource -> source.getFormat(chapter.chapter).let { format -> source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
when (format) { when (format) {
is Format.Directory -> DirectoryPageLoader(format.file) is Format.Directory -> DirectoryPageLoader(format.file)
// SY --> is Format.Archive -> ArchivePageLoader(format.file.archiveReader(context))
is Format.Zip -> ZipPageLoader(format.file, context) is Format.Epub -> EpubPageLoader(format.file.archiveReader(context))
is Format.Rar -> try {
RarPageLoader(format.file)
// SY <--
} catch (e: UnsupportedRarV5Exception) {
error(context.stringResource(MR.strings.loader_rar5_error))
}
// SY -->
is Format.Epub -> EpubPageLoader(format.file, context)
// SY <--
} }
} }
source is HttpSource -> HttpPageLoader(chapter, source) source is HttpSource -> HttpPageLoader(chapter, source)

View File

@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import mihon.core.common.archive.archiveReader
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@ -26,7 +27,7 @@ internal class DownloadPageLoader(
private val context: Application by injectLazy() private val context: Application by injectLazy()
private var zipPageLoader: ZipPageLoader? = null private var archivePageLoader: ArchivePageLoader? = null
override var isLocal: Boolean = true override var isLocal: Boolean = true
@ -42,13 +43,11 @@ internal class DownloadPageLoader(
override fun recycle() { override fun recycle() {
super.recycle() super.recycle()
zipPageLoader?.recycle() archivePageLoader?.recycle()
} }
private suspend fun getPagesFromArchive(file: UniFile): List<ReaderPage> { private suspend fun getPagesFromArchive(file: UniFile): List<ReaderPage> {
// SY --> val loader = ArchivePageLoader(file.archiveReader(context)).also { archivePageLoader = it }
val loader = ZipPageLoader(file, context).also { zipPageLoader = it }
// SY <--
return loader.getPages() return loader.getPages()
} }
@ -64,6 +63,6 @@ internal class DownloadPageLoader(
} }
override suspend fun loadPage(page: ReaderPage) { override suspend fun loadPage(page: ReaderPage) {
zipPageLoader?.loadPage(page) archivePageLoader?.loadPage(page)
} }
} }

View File

@ -1,26 +1,23 @@
package eu.kanade.tachiyomi.ui.reader.loader 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.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.storage.EpubFile import eu.kanade.tachiyomi.util.storage.EpubFile
import mihon.core.common.archive.ArchiveReader
/** /**
* Loader used to load a chapter from a .epub file. * Loader used to load a chapter from a .epub file.
*/ */
// SY --> internal class EpubPageLoader(reader: ArchiveReader) : PageLoader() {
internal class EpubPageLoader(file: UniFile, context: Context) : PageLoader() {
private val epub = EpubFile(file, context) private val epub = EpubFile(reader)
// SY <--
override var isLocal: Boolean = true override var isLocal: Boolean = true
override suspend fun getPages(): List<ReaderPage> { override suspend fun getPages(): List<ReaderPage> {
return epub.getImagesFromPages() return epub.getImagesFromPages()
.mapIndexed { i, path -> .mapIndexed { i, path ->
val streamFn = { epub.getInputStream(epub.getEntry(path)!!) } val streamFn = { epub.getInputStream(path)!! }
ReaderPage(i).apply { ReaderPage(i).apply {
stream = streamFn stream = streamFn
status = Page.State.READY status = Page.State.READY

View File

@ -1,168 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.content.Context
import android.os.Build
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import 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.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: UniFile, context: Context) : PageLoader() {
// SY -->
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 apacheZip: ZipFile? = if (!file.isEncryptedZip() && Build.VERSION.SDK_INT > Build.VERSION_CODES.N) {
ZipFile.Builder()
.setSeekableByteChannel(channel)
.get()
} else {
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 {
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()
apacheZip?.close()
// SY -->
zip4j?.close()
tmpDir.deleteRecursively()
}
override var isLocal: Boolean = true
override suspend fun getPages(): List<ReaderPage> {
if (readerPreferences.archiveReaderMode().get() == ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) {
return DirectoryPageLoader(UniFile.fromFile(tmpDir)!!).getPages()
}
return if (apacheZip == null) {
loadZip4j()
} else {
loadApacheZip(apacheZip)
}
}
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 = { imageBytes?.copyOf()?.inputStream() ?: zip4j.getInputStream(entry) }
status = Page.State.READY
}
}.toList()
}
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 = { imageBytes?.copyOf()?.inputStream() ?: zip.getInputStream(entry) }
status = Page.State.READY
}
}.toList()
}
// SY <--
/**
* No additional action required to load the page
*/
override suspend fun loadPage(page: ReaderPage) {
check(!isRecycled)
}
}

View File

@ -558,6 +558,7 @@
<ID>LongParameterList:UpdatesRepositoryImpl.kt$UpdatesRepositoryImpl$( mangaId: Long, mangaTitle: String, chapterId: Long, chapterName: String, scanlator: String?, read: Boolean, bookmark: Boolean, lastPageRead: Long, sourceId: Long, favorite: Boolean, thumbnailUrl: String?, coverLastModified: Long, dateUpload: Long, dateFetch: Long, )</ID> <ID>LongParameterList:UpdatesRepositoryImpl.kt$UpdatesRepositoryImpl$( mangaId: Long, mangaTitle: String, chapterId: Long, chapterName: String, scanlator: String?, read: Boolean, bookmark: Boolean, lastPageRead: Long, sourceId: Long, favorite: Boolean, thumbnailUrl: String?, coverLastModified: Long, dateUpload: Long, dateFetch: Long, )</ID>
<ID>LongParameterList:UpdatesUiItem.kt$( 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>LongParameterList:UpdatesUiItem.kt$( 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>LongParameterList:WebtoonRecyclerView.kt$WebtoonRecyclerView$( fromRate: Float, toRate: Float, fromX: Float, toX: Float, fromY: Float, toY: Float, )</ID> <ID>LongParameterList:WebtoonRecyclerView.kt$WebtoonRecyclerView$( fromRate: Float, toRate: Float, fromX: Float, toX: Float, fromY: Float, toY: Float, )</ID>
<ID>LoopWithTooManyJumpStatements:ArchiveReader.kt$ArchiveReader$while</ID>
<ID>LoopWithTooManyJumpStatements:DownloadStore.kt$DownloadStore$for</ID> <ID>LoopWithTooManyJumpStatements:DownloadStore.kt$DownloadStore$for</ID>
<ID>LoopWithTooManyJumpStatements:EHentaiUpdateWorker.kt$EHentaiUpdateWorker$for</ID> <ID>LoopWithTooManyJumpStatements:EHentaiUpdateWorker.kt$EHentaiUpdateWorker$for</ID>
<ID>LoopWithTooManyJumpStatements:ImageUtil.kt$ImageUtil$for</ID> <ID>LoopWithTooManyJumpStatements:ImageUtil.kt$ImageUtil$for</ID>
@ -1510,6 +1511,7 @@
<ID>NestedBlockDepth:Anilist.kt$Anilist$override suspend fun update(track: Track, didReadChapter: Boolean): Track</ID> <ID>NestedBlockDepth:Anilist.kt$Anilist$override suspend fun update(track: Track, didReadChapter: Boolean): Track</ID>
<ID>NestedBlockDepth:ApiMangaParser.kt$ApiMangaParser$fun parseIntoMetadata( metadata: MangaDexSearchMetadata, mangaDto: MangaDto, simpleChapters: List&lt;String&gt;, statistics: StatisticsMangaDto?, )</ID> <ID>NestedBlockDepth:ApiMangaParser.kt$ApiMangaParser$fun parseIntoMetadata( metadata: MangaDexSearchMetadata, mangaDto: MangaDto, simpleChapters: List&lt;String&gt;, statistics: StatisticsMangaDto?, )</ID>
<ID>NestedBlockDepth:AppLanguageScreen.kt$AppLanguageScreen$private fun getLangs(context: Context): ImmutableList&lt;Language&gt;</ID> <ID>NestedBlockDepth:AppLanguageScreen.kt$AppLanguageScreen$private fun getLangs(context: Context): ImmutableList&lt;Language&gt;</ID>
<ID>NestedBlockDepth:ArchiveReader.kt$ArchiveReader$private fun isPasswordIncorrect(): Boolean?</ID>
<ID>NestedBlockDepth:BackupRestorer.kt$BackupRestorer$private fun writeErrorLog(): File</ID> <ID>NestedBlockDepth:BackupRestorer.kt$BackupRestorer$private fun writeErrorLog(): File</ID>
<ID>NestedBlockDepth:BrowseSourceScreenModel.kt$BrowseSourceScreenModel$fun searchGenre(genreName: String)</ID> <ID>NestedBlockDepth:BrowseSourceScreenModel.kt$BrowseSourceScreenModel$fun searchGenre(genreName: String)</ID>
<ID>NestedBlockDepth:ChapterLoader.kt$ChapterLoader$private fun getPageLoader(chapter: ReaderChapter): PageLoader</ID> <ID>NestedBlockDepth:ChapterLoader.kt$ChapterLoader$private fun getPageLoader(chapter: ReaderChapter): PageLoader</ID>

View File

@ -36,7 +36,7 @@ dependencies {
implementation(libs.image.decoder) implementation(libs.image.decoder)
implementation(libs.unifile) implementation(libs.unifile)
implementation(libs.bundles.archive) implementation(libs.libarchive)
api(kotlinx.coroutines.core) api(kotlinx.coroutines.core)
api(kotlinx.serialization.json) api(kotlinx.serialization.json)
@ -56,7 +56,6 @@ dependencies {
// SY --> // SY -->
implementation(sylibs.xlog) implementation(sylibs.xlog)
implementation(libs.zip4j)
implementation(libs.injekt.core) implementation(libs.injekt.core)
implementation(sylibs.exifinterface) implementation(sylibs.exifinterface)
// SY <-- // SY <--

View File

@ -56,7 +56,6 @@ class SecurityPreferences(
// SY --> // SY -->
enum class EncryptionType(val titleRes: StringResource) { enum class EncryptionType(val titleRes: StringResource) {
AES_256(SYMR.strings.aes_256), AES_256(SYMR.strings.aes_256),
AES_192(SYMR.strings.aes_192),
AES_128(SYMR.strings.aes_128), AES_128(SYMR.strings.aes_128),
ZIP_STANDARD(SYMR.strings.standard_zip_encryption), ZIP_STANDARD(SYMR.strings.standard_zip_encryption),
} }

View File

@ -9,14 +9,13 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import net.lingala.zip4j.model.ZipParameters import mihon.core.common.archive.ArchiveReader
import net.lingala.zip4j.model.enums.AesKeyStrength import tachiyomi.core.common.util.system.ImageUtil
import net.lingala.zip4j.model.enums.EncryptionMethod
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.BufferedInputStream
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import java.nio.ByteBuffer
import java.nio.CharBuffer import java.nio.CharBuffer
import java.security.KeyStore import java.security.KeyStore
import java.security.SecureRandom import java.security.SecureRandom
@ -33,7 +32,7 @@ import javax.crypto.spec.IvParameterSpec
*/ */
object CbzCrypto { object CbzCrypto {
const val DATABASE_NAME = "tachiyomiEncrypted.db" const val DATABASE_NAME = "tachiyomiEncrypted.db"
const val DEFAULT_COVER_NAME = "cover.jpg" private const val DEFAULT_COVER_NAME = "cover.jpg"
private val securityPreferences: SecurityPreferences by injectLazy() private val securityPreferences: SecurityPreferences by injectLazy()
private val keyStore = KeyStore.getInstance(Keystore).apply { private val keyStore = KeyStore.getInstance(Keystore).apply {
load(null) load(null)
@ -129,15 +128,11 @@ object CbzCrypto {
return encrypt(password.toByteArray(), encryptionCipherCbz) return encrypt(password.toByteArray(), encryptionCipherCbz)
} }
fun getDecryptedPasswordCbz(): CharArray { fun getDecryptedPasswordCbz(): ByteArray {
val encryptedPassword = securityPreferences.cbzPassword().get() val encryptedPassword = securityPreferences.cbzPassword().get()
if (encryptedPassword.isBlank()) error("This archive is encrypted please set a password") if (encryptedPassword.isBlank()) error("This archive is encrypted please set a password")
val cbzBytes = decrypt(encryptedPassword, AliasCbz) return decrypt(encryptedPassword, AliasCbz)
return Charsets.UTF_8.decode(ByteBuffer.wrap(cbzBytes)).array()
.also {
cbzBytes.fill('#'.code.toByte())
}
} }
private fun generateAndEncryptSqlPw() { private fun generateAndEncryptSqlPw() {
@ -185,27 +180,12 @@ object CbzCrypto {
} }
} }
fun setZipParametersEncrypted(zipParameters: ZipParameters) { fun getPreferredEncryptionAlgo(): ByteArray =
zipParameters.isEncryptFiles = true
when (securityPreferences.encryptionType().get()) { when (securityPreferences.encryptionType().get()) {
SecurityPreferences.EncryptionType.AES_256 -> { SecurityPreferences.EncryptionType.AES_256 -> "zip:encryption=aes256".toByteArray()
zipParameters.encryptionMethod = EncryptionMethod.AES SecurityPreferences.EncryptionType.AES_128 -> "zip:encryption=aes128".toByteArray()
zipParameters.aesKeyStrength = AesKeyStrength.KEY_STRENGTH_256 SecurityPreferences.EncryptionType.ZIP_STANDARD -> "zip:encryption=zipcrypt".toByteArray()
}
SecurityPreferences.EncryptionType.AES_192 -> {
zipParameters.encryptionMethod = EncryptionMethod.AES
zipParameters.aesKeyStrength = AesKeyStrength.KEY_STRENGTH_192
}
SecurityPreferences.EncryptionType.AES_128 -> {
zipParameters.encryptionMethod = EncryptionMethod.AES
zipParameters.aesKeyStrength = AesKeyStrength.KEY_STRENGTH_128
}
SecurityPreferences.EncryptionType.ZIP_STANDARD -> {
zipParameters.encryptionMethod = EncryptionMethod.ZIP_STANDARD
}
} }
}
fun detectCoverImageArchive(stream: InputStream): Boolean { fun detectCoverImageArchive(stream: InputStream): Boolean {
val bytes = ByteArray(128) val bytes = ByteArray(128)
@ -217,6 +197,15 @@ object CbzCrypto {
} }
return String(bytes).contains(DEFAULT_COVER_NAME, ignoreCase = true) return String(bytes).contains(DEFAULT_COVER_NAME, ignoreCase = true)
} }
fun ArchiveReader.getCoverStream(): BufferedInputStream? {
this.getInputStream(DEFAULT_COVER_NAME)?.let { stream ->
if (ImageUtil.isImage(DEFAULT_COVER_NAME) { stream }) {
return this.getInputStream(DEFAULT_COVER_NAME)?.buffered()
}
}
return null
}
} }
private const val BufferSize = 2048 private const val BufferSize = 2048

View File

@ -1,15 +1,8 @@
package eu.kanade.tachiyomi.util.storage package eu.kanade.tachiyomi.util.storage
import android.content.Context import mihon.core.common.archive.ArchiveReader
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.Jsoup
import org.jsoup.nodes.Document 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.Closeable
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
@ -17,45 +10,18 @@ import java.io.InputStream
/** /**
* Wrapper over ZipFile to load files in epub format. * Wrapper over ZipFile to load files in epub format.
*/ */
// SY --> class EpubFile(private val reader: ArchiveReader) : Closeable by reader {
class EpubFile(file: UniFile, context: Context) : Closeable {
private val tempFileManager: UniFileTempFileManager by injectLazy()
/**
* Zip file of this epub.
*/
private val zip = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
ZipFile.Builder().setFile(tempFileManager.createTempFile(file)).get()
} else {
ZipFile.Builder().setSeekableByteChannel(file.openReadOnlyChannel(context)).get()
}
// SY <--
/** /**
* Path separator used by this epub. * Path separator used by this epub.
*/ */
private val pathSeparator = getPathSeparator() private val pathSeparator = getPathSeparator()
/**
* Closes the underlying zip file.
*/
override fun close() {
zip.close()
}
/** /**
* Returns an input stream for reading the contents of the specified zip file entry. * Returns an input stream for reading the contents of the specified zip file entry.
*/ */
fun getInputStream(entry: ZipArchiveEntry): InputStream { fun getInputStream(entryName: String): InputStream? {
return zip.getInputStream(entry) return reader.getInputStream(entryName)
}
/**
* Returns the zip file entry for the specified name, or null if not found.
*/
fun getEntry(name: String): ZipArchiveEntry? {
return zip.getEntry(name)
} }
/** /**
@ -72,9 +38,9 @@ class EpubFile(file: UniFile, context: Context) : Closeable {
* Returns the path to the package document. * Returns the path to the package document.
*/ */
fun getPackageHref(): String { fun getPackageHref(): String {
val meta = zip.getEntry(resolveZipPath("META-INF", "container.xml")) val meta = getInputStream(resolveZipPath("META-INF", "container.xml"))
if (meta != null) { if (meta != null) {
val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") } val metaDoc = meta.use { Jsoup.parse(it, null, "") }
val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path") val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path")
if (path != null) { if (path != null) {
return path return path
@ -87,8 +53,7 @@ class EpubFile(file: UniFile, context: Context) : Closeable {
* Returns the package document where all the files are listed. * Returns the package document where all the files are listed.
*/ */
fun getPackageDocument(ref: String): Document { fun getPackageDocument(ref: String): Document {
val entry = zip.getEntry(ref) return getInputStream(ref)!!.use { Jsoup.parse(it, null, "") }
return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
} }
/** /**
@ -111,8 +76,7 @@ class EpubFile(file: UniFile, context: Context) : Closeable {
val basePath = getParentDirectory(packageHref) val basePath = getParentDirectory(packageHref)
pages.forEach { page -> pages.forEach { page ->
val entryPath = resolveZipPath(basePath, page) val entryPath = resolveZipPath(basePath, page)
val entry = zip.getEntry(entryPath) val document = getInputStream(entryPath)!!.use { Jsoup.parse(it, null, "") }
val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
val imageBasePath = getParentDirectory(entryPath) val imageBasePath = getParentDirectory(entryPath)
document.allElements.forEach { document.allElements.forEach {
@ -130,8 +94,9 @@ class EpubFile(file: UniFile, context: Context) : Closeable {
* Returns the path separator used by the epub file. * Returns the path separator used by the epub file.
*/ */
private fun getPathSeparator(): String { private fun getPathSeparator(): String {
val meta = zip.getEntry("META-INF\\container.xml") val meta = getInputStream("META-INF\\container.xml")
return if (meta != null) { return if (meta != null) {
meta.close()
"\\" "\\"
} else { } else {
"/" "/"

View File

@ -0,0 +1,7 @@
package mihon.core.common.archive
class ArchiveEntry(
val name: String,
val isFile: Boolean,
val isEncrypted: Boolean,
)

View File

@ -0,0 +1,84 @@
package mihon.core.common.archive
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import me.zhanghai.android.libarchive.Archive
import me.zhanghai.android.libarchive.ArchiveEntry
import me.zhanghai.android.libarchive.ArchiveException
import java.io.InputStream
import java.nio.ByteBuffer
import kotlin.concurrent.Volatile
class ArchiveInputStream(
buffer: Long,
size: Long,
// SY -->
encrypted: Boolean,
// SY <--
) : InputStream() {
private val lock = Any()
@Volatile
private var isClosed = false
private val archive = Archive.readNew()
init {
try {
// SY -->
if (encrypted) {
Archive.readAddPassphrase(archive, CbzCrypto.getDecryptedPasswordCbz())
}
// SY <--
Archive.setCharset(archive, Charsets.UTF_8.name().toByteArray())
Archive.readSupportFilterAll(archive)
Archive.readSupportFormatAll(archive)
Archive.readOpenMemoryUnsafe(archive, buffer, size)
} catch (e: ArchiveException) {
close()
throw e
}
}
private val oneByteBuffer = ByteBuffer.allocateDirect(1)
override fun read(): Int {
read(oneByteBuffer)
return if (oneByteBuffer.hasRemaining()) oneByteBuffer.get().toUByte().toInt() else -1
}
override fun read(b: ByteArray, off: Int, len: Int): Int {
val buffer = ByteBuffer.wrap(b, off, len)
read(buffer)
return if (buffer.hasRemaining()) buffer.remaining() else -1
}
private fun read(buffer: ByteBuffer) {
buffer.clear()
Archive.readData(archive, buffer)
buffer.flip()
}
override fun close() {
synchronized(lock) {
if (isClosed) return
isClosed = true
}
Archive.readFree(archive)
}
fun getNextEntry() = Archive.readNextHeader(archive).takeUnless { it == 0L }?.let { entry ->
val name = ArchiveEntry.pathnameUtf8(entry) ?: ArchiveEntry.pathname(entry)?.decodeToString() ?: return null
val isFile = ArchiveEntry.filetype(entry) == ArchiveEntry.AE_IFREG
// SY -->
val isEncrypted = ArchiveEntry.isEncrypted(entry)
// SY <--
ArchiveEntry(
name,
isFile,
// SY -->
isEncrypted
// SY <--
)
}
}

View File

@ -0,0 +1,94 @@
package mihon.core.common.archive
import android.content.Context
import android.os.ParcelFileDescriptor
import android.system.Os
import android.system.OsConstants
import com.hippo.unifile.UniFile
import me.zhanghai.android.libarchive.ArchiveException
import tachiyomi.core.common.storage.openFileDescriptor
import java.io.Closeable
import java.io.InputStream
class ArchiveReader(pfd: ParcelFileDescriptor) : Closeable {
val size = pfd.statSize
val address = Os.mmap(0, size, OsConstants.PROT_READ, OsConstants.MAP_PRIVATE, pfd.fileDescriptor, 0)
// SY -->
var encrypted: Boolean = false
private set
var wrongPassword: Boolean? = null
private set
val archiveHashCode = pfd.hashCode()
init {
checkEncryptionStatus()
}
// SY <--
inline fun <T> useEntries(block: (Sequence<ArchiveEntry>) -> T): T = ArchiveInputStream(
address,
size,
// SY -->
encrypted,
// SY <--
).use { block(generateSequence { it.getNextEntry() }) }
fun getInputStream(entryName: String): InputStream? {
val archive = ArchiveInputStream(address, size, /* SY --> */ encrypted /* SY <-- */)
try {
while (true) {
val entry = archive.getNextEntry() ?: break
if (entry.name == entryName) {
return archive
}
}
} catch (e: ArchiveException) {
archive.close()
throw e
}
archive.close()
return null
}
// SY -->
private fun checkEncryptionStatus() {
val archive = ArchiveInputStream(address, size, false)
try {
while (true) {
val entry = archive.getNextEntry() ?: break
if (entry.isEncrypted) {
encrypted = true
isPasswordIncorrect(entry.name)
break
}
}
} catch (e: ArchiveException) {
archive.close()
throw e
}
archive.close()
}
private fun isPasswordIncorrect(entryName: String) {
try {
getInputStream(entryName).use { stream ->
stream!!.read()
}
} catch (e: ArchiveException) {
if (e.message == "Incorrect passphrase") {
wrongPassword = true
return
}
throw e
}
wrongPassword = false
}
// SY <--
override fun close() {
Os.munmap(address, size)
}
}
fun UniFile.archiveReader(context: Context) = openFileDescriptor(context, "r").use { ArchiveReader(it) }

View File

@ -0,0 +1,119 @@
package mihon.core.common.archive
import android.content.Context
import android.system.Os
import android.system.StructStat
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import me.zhanghai.android.libarchive.Archive
import me.zhanghai.android.libarchive.ArchiveEntry
import me.zhanghai.android.libarchive.ArchiveEntry.AE_IFREG
import me.zhanghai.android.libarchive.ArchiveException
import tachiyomi.core.common.storage.openFileDescriptor
import java.io.Closeable
import java.nio.ByteBuffer
class ZipWriter(
val context: Context,
file: UniFile,
// SY -->
encrypt: Boolean = false,
// SY <--
) : Closeable {
private val pfd = file.openFileDescriptor(context, "wt")
private val archive = Archive.writeNew()
private val entry = ArchiveEntry.new2(archive)
private val buffer = ByteBuffer.allocateDirect(
// SY -->
BUFFER_SIZE
// SY <--
)
init {
try {
Archive.setCharset(archive, Charsets.UTF_8.name().toByteArray())
Archive.writeSetFormatZip(archive)
Archive.writeZipSetCompressionStore(archive)
// SY -->
if (encrypt) {
Archive.writeSetOptions(archive, CbzCrypto.getPreferredEncryptionAlgo())
Archive.writeSetPassphrase(archive, CbzCrypto.getDecryptedPasswordCbz())
}
// SY <--
Archive.writeOpenFd(archive, pfd.fd)
} catch (e: ArchiveException) {
close()
throw e
}
}
fun write(file: UniFile) {
file.openFileDescriptor(context, "r").use {
val fd = it.fileDescriptor
ArchiveEntry.clear(entry)
ArchiveEntry.setPathnameUtf8(entry, file.name)
val stat = Os.fstat(fd)
ArchiveEntry.setStat(entry, stat.toArchiveStat())
Archive.writeHeader(archive, entry)
while (true) {
buffer.clear()
Os.read(fd, buffer)
if (buffer.position() == 0) break
buffer.flip()
Archive.writeData(archive, buffer)
}
Archive.writeFinishEntry(archive)
}
}
// SY -->
fun write(fileData: ByteArray, fileName: String) {
ArchiveEntry.clear(entry)
ArchiveEntry.setPathnameUtf8(entry, fileName)
ArchiveEntry.setSize(entry, fileData.size.toLong())
ArchiveEntry.setFiletype(entry, AE_IFREG)
Archive.writeHeader(archive, entry)
var position = 0
while (position < fileData.size) {
val lengthToRead = minOf(BUFFER_SIZE, fileData.size - position)
buffer.clear()
buffer.put(fileData, position, lengthToRead)
buffer.flip()
Archive.writeData(archive, buffer)
position += lengthToRead
}
Archive.writeFinishEntry(archive)
}
// SY <--
override fun close() {
ArchiveEntry.free(entry)
Archive.writeFree(archive)
pfd.close()
}
// SY -->
companion object {
private const val BUFFER_SIZE = 8192
}
// SY <--
}
private fun StructStat.toArchiveStat() = ArchiveEntry.StructStat().apply {
stDev = st_dev
stMode = st_mode
stNlink = st_nlink.toInt()
stUid = st_uid
stGid = st_gid
stRdev = st_rdev
stSize = st_size
stBlksize = st_blksize
stBlocks = st_blocks
stAtim = timespec(st_atime)
stMtim = timespec(st_mtime)
stCtim = timespec(st_ctime)
stIno = st_ino
}
private fun timespec(tvSec: Long) = ArchiveEntry.StructTimespec().also { it.tvSec = tvSec }

View File

@ -3,19 +3,6 @@ package tachiyomi.core.common.storage
import android.content.Context import android.content.Context
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import com.hippo.unifile.UniFile 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? val UniFile.extension: String?
get() = name?.substringAfterLast('.') get() = name?.substringAfterLast('.')
@ -26,200 +13,5 @@ val UniFile.nameWithoutExtension: String?
val UniFile.displayablePath: String val UniFile.displayablePath: String
get() = filePath ?: uri.toString() get() = filePath ?: uri.toString()
fun UniFile.openReadOnlyChannel(context: Context): FileChannel { fun UniFile.openFileDescriptor(context: Context, mode: String): ParcelFileDescriptor =
return ParcelFileDescriptor.AutoCloseInputStream(context.contentResolver.openFileDescriptor(uri, "r")).channel context.contentResolver.openFileDescriptor(uri, mode) ?: error("Failed to open file descriptor: $displayablePath")
// 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

@ -32,9 +32,7 @@ jsoup = "org.jsoup:jsoup:1.18.1"
disklrucache = "com.jakewharton:disklrucache:2.0.2" disklrucache = "com.jakewharton:disklrucache:2.0.2"
unifile = "com.github.tachiyomiorg:unifile:e0def6b3dc" unifile = "com.github.tachiyomiorg:unifile:e0def6b3dc"
common-compress = "org.apache.commons:commons-compress:1.26.2" libarchive = "me.zhanghai.android.libarchive:library:1.1.0"
junrar = "com.github.junrar:junrar:7.5.5"
zip4j = "net.lingala.zip4j:zip4j:2.11.5"
sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqlite" } sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqlite" }
sqlite-ktx = { module = "androidx.sqlite:sqlite-ktx", version.ref = "sqlite" } sqlite-ktx = { module = "androidx.sqlite:sqlite-ktx", version.ref = "sqlite" }
@ -105,7 +103,6 @@ detekt-rules-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatt
detekt-rules-compose = { module = "io.nlopez.compose.rules:detekt", version.ref = "detektCompose" } detekt-rules-compose = { module = "io.nlopez.compose.rules:detekt", version.ref = "detektCompose" }
[bundles] [bundles]
archive = ["common-compress", "junrar"]
okhttp = ["okhttp-core", "okhttp-logging", "okhttp-brotli", "okhttp-dnsoverhttps"] okhttp = ["okhttp-core", "okhttp-logging", "okhttp-brotli", "okhttp-dnsoverhttps"]
js-engine = ["quickjs-android"] js-engine = ["quickjs-android"]
sqlite = ["sqlite-framework", "sqlite-ktx", "sqlite-android"] sqlite = ["sqlite-framework", "sqlite-ktx", "sqlite-android"]

View File

@ -279,7 +279,6 @@
<string name="wrong_cbz_archive_password">Wrong CBZ archive password</string> <string name="wrong_cbz_archive_password">Wrong CBZ archive password</string>
<string name="encryption_type">Encryption type</string> <string name="encryption_type">Encryption type</string>
<string name="aes_256">AES 256</string> <string name="aes_256">AES 256</string>
<string name="aes_192">AES 192</string>
<string name="aes_128">AES 128</string> <string name="aes_128">AES 128</string>
<string name="standard_zip_encryption">Standard zip encryption (fast but insecure)</string> <string name="standard_zip_encryption">Standard zip encryption (fast but insecure)</string>

View File

@ -793,7 +793,6 @@
<string name="transition_pages_error">Failed to load pages: %1$s</string> <string name="transition_pages_error">Failed to load pages: %1$s</string>
<string name="page_list_empty_error">No pages found</string> <string name="page_list_empty_error">No pages found</string>
<string name="loader_not_implemented_error">Source not found</string> <string name="loader_not_implemented_error">Source not found</string>
<string name="loader_rar5_error">RARv5 format is not supported</string>
<!-- Updates --> <!-- Updates -->
<string name="updating_library">Updating library</string> <string name="updating_library">Updating library</string>

View File

@ -15,10 +15,6 @@ kotlin {
// SY <-- // SY <--
implementation(libs.unifile) implementation(libs.unifile)
implementation(libs.bundles.archive)
// SY -->
implementation(libs.zip4j)
// SY <--
} }
} }
val androidMain by getting { val androidMain by getting {

View File

@ -1,7 +1,6 @@
package tachiyomi.source.local package tachiyomi.source.local
import android.content.Context import android.content.Context
import android.os.Build
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
@ -12,22 +11,18 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import eu.kanade.tachiyomi.util.storage.EpubFile import eu.kanade.tachiyomi.util.storage.EpubFile
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.decodeFromStream
import logcat.LogPriority import logcat.LogPriority
import mihon.core.common.archive.ZipWriter
import mihon.core.common.archive.archiveReader
import nl.adaptivity.xmlutil.AndroidXmlReader import nl.adaptivity.xmlutil.AndroidXmlReader
import nl.adaptivity.xmlutil.serialization.XML import nl.adaptivity.xmlutil.serialization.XML
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.storage.UniFileTempFileManager
import tachiyomi.core.common.storage.addStreamToZip
import tachiyomi.core.common.storage.extension 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.storage.nameWithoutExtension
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.ImageUtil
@ -51,7 +46,6 @@ import uy.kohesive.injekt.injectLazy
import java.io.InputStream import java.io.InputStream
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.days
import com.github.junrar.Archive as JunrarArchive
import tachiyomi.domain.source.model.Source as DomainSource import tachiyomi.domain.source.model.Source as DomainSource
actual class LocalSource( actual class LocalSource(
@ -65,7 +59,6 @@ actual class LocalSource(
private val json: Json by injectLazy() private val json: Json by injectLazy()
private val xml: XML by injectLazy() private val xml: XML by injectLazy()
private val tempFileManager: UniFileTempFileManager by injectLazy()
private val POPULAR_FILTERS = FilterList(OrderBy.Popular(context)) private val POPULAR_FILTERS = FilterList(OrderBy.Popular(context))
private val LATEST_FILTERS = FilterList(OrderBy.Latest(context)) private val LATEST_FILTERS = FilterList(OrderBy.Latest(context))
@ -161,13 +154,14 @@ actual class LocalSource(
val mangaDirFiles = fileSystem.getFilesInMangaDirectory(manga.url) val mangaDirFiles = fileSystem.getFilesInMangaDirectory(manga.url)
val existingFile = mangaDirFiles val existingFile = mangaDirFiles
.firstOrNull { it.name == COMIC_INFO_FILE } .firstOrNull { it.name == COMIC_INFO_FILE }
val comicInfoArchiveFile = mangaDirFiles val comicInfoArchiveFile = mangaDirFiles.firstOrNull { it.name == COMIC_INFO_ARCHIVE }
.firstOrNull { it.name == COMIC_INFO_ARCHIVE } val comicInfoArchiveReader = comicInfoArchiveFile?.archiveReader(context)
val existingComicInfo = (existingFile?.openInputStream() ?: comicInfoArchiveFile?.getZipInputStream(COMIC_INFO_FILE))?.use { val existingComicInfo =
AndroidXmlReader(it, StandardCharsets.UTF_8.name()).use { (existingFile?.openInputStream() ?: comicInfoArchiveReader?.getInputStream(COMIC_INFO_FILE))?.use {
xml.decodeFromReader<ComicInfo>(it) AndroidXmlReader(it, StandardCharsets.UTF_8.name()).use { xmlReader ->
xml.decodeFromReader<ComicInfo>(xmlReader)
}
} }
}
val newComicInfo = if (existingComicInfo != null) { val newComicInfo = if (existingComicInfo != null) {
manga.run { manga.run {
existingComicInfo.copy( existingComicInfo.copy(
@ -188,8 +182,9 @@ actual class LocalSource(
fileSystem.getMangaDirectory(manga.url)?.let { fileSystem.getMangaDirectory(manga.url)?.let {
copyComicInfoFile( copyComicInfoFile(
xml.encodeToString(ComicInfo.serializer(), newComicInfo).byteInputStream(), xml.encodeToString(ComicInfo.serializer(), newComicInfo).byteInputStream(),
it it,
) comicInfoArchiveReader?.encrypted ?: false
)
} }
} }
// SY <-- // SY <--
@ -202,8 +197,8 @@ actual class LocalSource(
// Augment manga details based on metadata files // Augment manga details based on metadata files
try { try {
val mangaDir by lazy { fileSystem.getMangaDirectory(manga.url) } val mangaDir = fileSystem.getMangaDirectory(manga.url) ?: error("${manga.url} is not a valid directory")
val mangaDirFiles = fileSystem.getFilesInMangaDirectory(manga.url) val mangaDirFiles = mangaDir.listFiles().orEmpty()
val comicInfoFile = mangaDirFiles val comicInfoFile = mangaDirFiles
.firstOrNull { it.name == COMIC_INFO_FILE } .firstOrNull { it.name == COMIC_INFO_FILE }
@ -226,7 +221,7 @@ actual class LocalSource(
comicInfoArchiveFile != null -> { comicInfoArchiveFile != null -> {
noXmlFile?.delete() noXmlFile?.delete()
comicInfoArchiveFile.getZipInputStream(COMIC_INFO_FILE) comicInfoArchiveFile.archiveReader(context).getInputStream(COMIC_INFO_FILE)
?.let { setMangaDetailsFromComicInfoFile(it, manga) } ?.let { setMangaDetailsFromComicInfoFile(it, manga) }
} }
@ -246,7 +241,7 @@ actual class LocalSource(
// Replace with ComicInfo.xml file // Replace with ComicInfo.xml file
val comicInfo = manga.getComicInfo() val comicInfo = manga.getComicInfo()
mangaDir mangaDir
?.createFile(COMIC_INFO_FILE) .createFile(COMIC_INFO_FILE)
?.openOutputStream() ?.openOutputStream()
?.use { ?.use {
val comicInfoString = xml.encodeToString(ComicInfo.serializer(), comicInfo) val comicInfoString = xml.encodeToString(ComicInfo.serializer(), comicInfo)
@ -257,21 +252,20 @@ actual class LocalSource(
// Copy ComicInfo.xml from chapter archive to top level if found // Copy ComicInfo.xml from chapter archive to top level if found
noXmlFile == null -> { noXmlFile == null -> {
val chapterArchives = mangaDirFiles val chapterArchives = mangaDirFiles.filter(Archive::isSupported)
.filter(Archive::isSupported)
.toList()
val copiedFile = mangaDir?.let { copyComicInfoFileFromArchive(chapterArchives, it) } val copiedFile = copyComicInfoFileFromArchive(chapterArchives, mangaDir)
// SY --> // SY -->
if (copiedFile != null && copiedFile.name != COMIC_INFO_ARCHIVE) { if (copiedFile != null && copiedFile.name != COMIC_INFO_ARCHIVE) {
setMangaDetailsFromComicInfoFile(copiedFile.openInputStream(), manga) setMangaDetailsFromComicInfoFile(copiedFile.openInputStream(), manga)
} else if (copiedFile != null && copiedFile.name == COMIC_INFO_ARCHIVE) { } else if (copiedFile != null && copiedFile.name == COMIC_INFO_ARCHIVE) {
copiedFile.getZipInputStream(COMIC_INFO_FILE)?.let { setMangaDetailsFromComicInfoFile(it, manga) } copiedFile.archiveReader(context).getInputStream(COMIC_INFO_FILE)
?.let { setMangaDetailsFromComicInfoFile(it, manga) }
} // SY <-- } // SY <--
else { else {
// Avoid re-scanning // Avoid re-scanning
mangaDir?.createFile(".noxml") mangaDir.createFile(".noxml")
} }
} }
} }
@ -284,44 +278,31 @@ actual class LocalSource(
private fun copyComicInfoFileFromArchive(chapterArchives: List<UniFile>, folder: UniFile): UniFile? { private fun copyComicInfoFileFromArchive(chapterArchives: List<UniFile>, folder: UniFile): UniFile? {
for (chapter in chapterArchives) { for (chapter in chapterArchives) {
when (Format.valueOf(chapter)) { chapter.archiveReader(context).use { reader ->
is Format.Zip -> { reader.getInputStream(COMIC_INFO_FILE)?.use { stream ->
// SY --> return copyComicInfoFile(stream, folder, /* SY --> */ reader.encrypted /* SY <-- */)
chapter.getZipInputStream(COMIC_INFO_FILE)?.buffered().use { stream ->
return stream?.let { copyComicInfoFile(it, folder) }
}
} }
is Format.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)
}
}
}
}
else -> {}
} }
} }
return null return null
} }
private fun copyComicInfoFile(comicInfoFileStream: InputStream, folder: UniFile): UniFile? { private fun copyComicInfoFile(
comicInfoFileStream: InputStream,
folder: UniFile,
// SY --> // SY -->
if ( encrypt: Boolean,
CbzCrypto.getPasswordProtectDlPref() && // SY <--
CbzCrypto.isPasswordSet() ): UniFile? {
) { // SY -->
val comicInfoArchive = folder.createFile(COMIC_INFO_ARCHIVE) if (encrypt) {
comicInfoArchive?.addStreamToZip(comicInfoFileStream, COMIC_INFO_FILE, CbzCrypto.getDecryptedPasswordCbz()) val comicInfoArchiveFile = folder.createFile(COMIC_INFO_ARCHIVE)
comicInfoArchiveFile?.let { archive ->
return comicInfoArchive ZipWriter(context, archive, encrypt = true).use { writer ->
writer.write(comicInfoFileStream.use { it.readBytes() }, COMIC_INFO_FILE)
}
}
return comicInfoArchiveFile
} else { } else {
// SY <-- // SY <--
return folder.createFile(COMIC_INFO_FILE)?.apply { return folder.createFile(COMIC_INFO_FILE)?.apply {
@ -344,7 +325,7 @@ actual class LocalSource(
override suspend fun getChapterList(manga: SManga): List<SChapter> = withIOContext { override suspend fun getChapterList(manga: SManga): List<SChapter> = withIOContext {
val chapters = fileSystem.getFilesInMangaDirectory(manga.url) val chapters = fileSystem.getFilesInMangaDirectory(manga.url)
// Only keep supported formats // Only keep supported formats
.filter { it.isDirectory || Archive.isSupported(it) } .filter { it.isDirectory || Archive.isSupported(it) || it.extension.equals("epub", true) }
.map { chapterFile -> .map { chapterFile ->
SChapter.create().apply { SChapter.create().apply {
url = "${manga.url}/${chapterFile.name}" url = "${manga.url}/${chapterFile.name}"
@ -360,7 +341,7 @@ actual class LocalSource(
val format = Format.valueOf(chapterFile) val format = Format.valueOf(chapterFile)
if (format is Format.Epub) { if (format is Format.Epub) {
EpubFile(format.file, context).use { epub -> EpubFile(format.file.archiveReader(context)).use { epub ->
epub.fillMetadata(manga, this) epub.fillMetadata(manga, this)
} }
} }
@ -418,43 +399,25 @@ actual class LocalSource(
entry?.let { coverManager.update(manga, it.openInputStream()) } entry?.let { coverManager.update(manga, it.openInputStream()) }
} }
is Format.Zip -> { is Format.Archive -> {
// SY --> format.file.archiveReader(context).use { reader ->
format.file.getCoverStreamFromZip()?.let { inputStream -> val entry = reader.useEntries { entries ->
coverManager.update( entries
manga, .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
inputStream, .find { it.isFile && ImageUtil.isImage(it.name) { reader.getInputStream(it.name)!! } }
format.file.isEncryptedZip() }
)
}
}
is Format.Rar -> {
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) } }
entry?.let { coverManager.update(manga, archive.getInputStream(it)) } entry?.let { coverManager.update(manga, reader.getInputStream(it.name)!!, reader.encrypted) }
} }
} }
is Format.Epub -> { is Format.Epub -> {
// SY --> EpubFile(format.file.archiveReader(context)).use { epub ->
EpubFile(format.file, context).use { epub -> val entry = epub.getImagesFromPages().firstOrNull()
// 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) { } catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Error updating cover for ${manga.title}" } logcat(LogPriority.ERROR, e) { "Error updating cover for ${manga.title}" }
null null

View File

@ -3,9 +3,8 @@ package tachiyomi.source.local.image
import android.content.Context import android.content.Context
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import tachiyomi.core.common.storage.addStreamToZip
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import mihon.core.common.archive.ZipWriter
import tachiyomi.core.common.storage.nameWithoutExtension import tachiyomi.core.common.storage.nameWithoutExtension
import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.source.local.io.LocalSourceFileSystem import tachiyomi.source.local.io.LocalSourceFileSystem
@ -58,7 +57,9 @@ actual class LocalCoverManager(
inputStream.use { input -> inputStream.use { input ->
// SY --> // SY -->
if (encrypted) { if (encrypted) {
targetFile.addStreamToZip(inputStream, DEFAULT_COVER_NAME, CbzCrypto.getDecryptedPasswordCbz()) ZipWriter(context, targetFile, encrypt = true ).use { writer ->
writer.write(inputStream.readBytes(), DEFAULT_COVER_NAME)
}
DiskUtil.createNoMediaFile(directory, context) DiskUtil.createNoMediaFile(directory, context)
manga.thumbnail_url = targetFile.uri.toString() manga.thumbnail_url = targetFile.uri.toString()

View File

@ -5,9 +5,9 @@ import tachiyomi.core.common.storage.extension
object Archive { object Archive {
private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub") private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "7z", "cb7", "tar", "cbt")
fun isSupported(file: UniFile): Boolean { fun isSupported(file: UniFile): Boolean {
return file.extension in SUPPORTED_ARCHIVE_TYPES return file.extension?.lowercase() in SUPPORTED_ARCHIVE_TYPES
} }
} }

View File

@ -2,25 +2,22 @@ package tachiyomi.source.local.io
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import tachiyomi.core.common.storage.extension import tachiyomi.core.common.storage.extension
import tachiyomi.source.local.io.Archive.isSupported as isArchiveSupported
sealed interface Format { sealed interface Format {
data class Directory(val file: UniFile) : Format data class Directory(val file: UniFile) : Format
data class Zip(val file: UniFile) : Format data class Archive(val file: UniFile) : Format
data class Rar(val file: UniFile) : Format
data class Epub(val file: UniFile) : Format data class Epub(val file: UniFile) : Format
class UnknownFormatException : Exception() class UnknownFormatException : Exception()
companion object { companion object {
fun valueOf(file: UniFile) = with(file) { fun valueOf(file: UniFile) = when {
when { file.isDirectory -> Directory(file)
isDirectory -> Directory(this) file.extension.equals("epub", true) -> Epub(file)
extension.equals("zip", true) || extension.equals("cbz", true) -> Zip(this) isArchiveSupported(file) -> Archive(file)
extension.equals("rar", true) || extension.equals("cbr", true) -> Rar(this) else -> throw UnknownFormatException()
extension.equals("epub", true) -> Epub(this)
else -> throw UnknownFormatException()
}
} }
} }
} }