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
implementation(libs.disklrucache)
implementation(libs.unifile)
implementation(libs.bundles.archive)
// SY -->
implementation(libs.zip4j)
// SY <--
// Preferences
implementation(libs.preferencektx)

View File

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

View File

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

View File

@ -44,10 +44,10 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import logcat.LogPriority
import mihon.core.common.archive.ZipWriter
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
@ -65,12 +65,8 @@ import tachiyomi.domain.track.interactor.GetTracks
import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.BufferedOutputStream
import java.io.File
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.
@ -619,70 +615,19 @@ class Downloader(
tmpDir: UniFile,
) {
// SY -->
if (CbzCrypto.getPasswordProtectDlPref() && CbzCrypto.isPasswordSet()) {
archiveEncryptedChapter(mangaDir, dirname, tmpDir)
return
}
val encrypt = CbzCrypto.getPasswordProtectDlPref() && CbzCrypto.isPasswordSet()
// SY <--
val zip = mangaDir.createFile("$dirname.cbz$TMP_DIR_SUFFIX")!!
ZipOutputStream(BufferedOutputStream(zip.openOutputStream())).use { zipOut ->
zipOut.setMethod(ZipEntry.STORED)
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)
}
ZipWriter(context, zip, /* SY --> */ encrypt /* SY <-- */).use { writer ->
tmpDir.listFiles()?.forEach { file ->
writer.write(file)
}
}
zip.renameTo("$dirname.cbz")
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.
*/

View File

@ -1,9 +1,6 @@
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
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
@ -16,80 +13,69 @@ 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 mihon.core.common.archive.ArchiveReader
import tachiyomi.core.common.util.system.ImageUtil
import uy.kohesive.injekt.injectLazy
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 -->
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 mutex = Mutex()
private val context: Application 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()
}
init {
reader.wrongPassword?.let { wrongPassword ->
if (wrongPassword) {
error("Incorrect archive password")
}
}
if (readerPreferences.archiveReaderMode().get() == ReaderPreferences.ArchiveReaderMode.CACHE_TO_DISK) {
tmpDir.mkdirs()
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)
reader.useEntries { entries ->
entries
.filter { it.isFile && ImageUtil.isImage(it.name) { reader.getInputStream(it.name)!! } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.forEach { entry ->
File(tmpDir, entry.name.substringAfterLast("/"))
.also { it.createNewFile() }
.outputStream()
.use { output ->
reader.getInputStream(entry.name)?.use { input ->
input.copyTo(output)
}
}
}
}
}
}
}
}
// SY <--
override var isLocal: Boolean = true
/**
* Pool for copying compressed files to an input stream.
*/
private val pool = Executors.newFixedThreadPool(1)
override suspend fun getPages(): List<ReaderPage> {
override suspend fun getPages(): List<ReaderPage> = reader.useEntries { entries ->
// SY -->
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 ->
entries
.filter { it.isFile && ImageUtil.isImage(it.name) { reader.getInputStream(it.name)!! } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.mapIndexed { i, entry ->
// SY -->
val imageBytesDeferred: Deferred<ByteArray>? =
when (readerPreferences.archiveReaderMode().get()) {
ReaderPreferences.ArchiveReaderMode.LOAD_INTO_MEMORY -> {
CoroutineScope(Dispatchers.IO).async {
mutex.withLock {
getStream(header).buffered().use { stream ->
reader.getInputStream(entry.name)!!.buffered().use { stream ->
stream.readBytes()
}
}
@ -98,12 +84,11 @@ internal class RarPageLoader(file: UniFile) : PageLoader() {
else -> null
}
val imageBytes by lazy { runBlocking { imageBytesDeferred?.await() } }
// SY <--
ReaderPage(i).apply {
// SY -->
stream = { imageBytes?.copyOf()?.inputStream() ?: getStream(header) }
stream = { imageBytes?.copyOf()?.inputStream() ?: reader.getInputStream(entry.name)!! }
// SY <--
status = Page.State.READY
}
@ -117,27 +102,9 @@ internal class RarPageLoader(file: UniFile) : PageLoader() {
override fun recycle() {
super.recycle()
rar.close()
reader.close()
// SY -->
tmpDir.deleteRecursively()
// 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
import android.content.Context
import com.github.junrar.exception.UnsupportedRarV5Exception
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadProvider
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.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import mihon.core.common.archive.archiveReader
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat
@ -124,34 +124,26 @@ class ChapterLoader(
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
when (format) {
is Format.Directory -> DirectoryPageLoader(format.file)
is Format.Zip -> ZipPageLoader(format.file, context)
is Format.Rar -> try {
RarPageLoader(format.file)
} catch (e: UnsupportedRarV5Exception) {
error(context.stringResource(MR.strings.loader_rar5_error))
}
is Format.Epub -> EpubPageLoader(format.file, context)
is Format.Archive -> ArchivePageLoader(format.file.archiveReader(context))
is Format.Epub -> EpubPageLoader(format.file.archiveReader(context))
}
}
else -> error(context.stringResource(MR.strings.loader_not_implemented_error))
}
}
// SY <--
isDownloaded -> DownloadPageLoader(chapter, manga, source, downloadManager, downloadProvider)
isDownloaded -> DownloadPageLoader(
chapter,
manga,
source,
downloadManager,
downloadProvider,
)
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
when (format) {
is Format.Directory -> DirectoryPageLoader(format.file)
// SY -->
is Format.Zip -> ZipPageLoader(format.file, 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 <--
is Format.Archive -> ArchivePageLoader(format.file.archiveReader(context))
is Format.Epub -> EpubPageLoader(format.file.archiveReader(context))
}
}
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.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import mihon.core.common.archive.archiveReader
import tachiyomi.domain.manga.model.Manga
import uy.kohesive.injekt.injectLazy
@ -26,7 +27,7 @@ internal class DownloadPageLoader(
private val context: Application by injectLazy()
private var zipPageLoader: ZipPageLoader? = null
private var archivePageLoader: ArchivePageLoader? = null
override var isLocal: Boolean = true
@ -42,13 +43,11 @@ internal class DownloadPageLoader(
override fun recycle() {
super.recycle()
zipPageLoader?.recycle()
archivePageLoader?.recycle()
}
private suspend fun getPagesFromArchive(file: UniFile): List<ReaderPage> {
// SY -->
val loader = ZipPageLoader(file, context).also { zipPageLoader = it }
// SY <--
val loader = ArchivePageLoader(file.archiveReader(context)).also { archivePageLoader = it }
return loader.getPages()
}
@ -64,6 +63,6 @@ internal class DownloadPageLoader(
}
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
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 mihon.core.common.archive.ArchiveReader
/**
* Loader used to load a chapter from a .epub file.
*/
// SY -->
internal class EpubPageLoader(file: UniFile, context: Context) : PageLoader() {
internal class EpubPageLoader(reader: ArchiveReader) : PageLoader() {
private val epub = EpubFile(file, context)
// SY <--
private val epub = EpubFile(reader)
override var isLocal: Boolean = true
override suspend fun getPages(): List<ReaderPage> {
return epub.getImagesFromPages()
.mapIndexed { i, path ->
val streamFn = { epub.getInputStream(epub.getEntry(path)!!) }
val streamFn = { epub.getInputStream(path)!! }
ReaderPage(i).apply {
stream = streamFn
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: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>LoopWithTooManyJumpStatements:ArchiveReader.kt$ArchiveReader$while</ID>
<ID>LoopWithTooManyJumpStatements:DownloadStore.kt$DownloadStore$for</ID>
<ID>LoopWithTooManyJumpStatements:EHentaiUpdateWorker.kt$EHentaiUpdateWorker$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: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:ArchiveReader.kt$ArchiveReader$private fun isPasswordIncorrect(): Boolean?</ID>
<ID>NestedBlockDepth:BackupRestorer.kt$BackupRestorer$private fun writeErrorLog(): File</ID>
<ID>NestedBlockDepth:BrowseSourceScreenModel.kt$BrowseSourceScreenModel$fun searchGenre(genreName: String)</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.unifile)
implementation(libs.bundles.archive)
implementation(libs.libarchive)
api(kotlinx.coroutines.core)
api(kotlinx.serialization.json)
@ -56,7 +56,6 @@ dependencies {
// SY -->
implementation(sylibs.xlog)
implementation(libs.zip4j)
implementation(libs.injekt.core)
implementation(sylibs.exifinterface)
// SY <--

View File

@ -56,7 +56,6 @@ class SecurityPreferences(
// SY -->
enum class EncryptionType(val titleRes: StringResource) {
AES_256(SYMR.strings.aes_256),
AES_192(SYMR.strings.aes_192),
AES_128(SYMR.strings.aes_128),
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.map
import kotlinx.coroutines.flow.stateIn
import net.lingala.zip4j.model.ZipParameters
import net.lingala.zip4j.model.enums.AesKeyStrength
import net.lingala.zip4j.model.enums.EncryptionMethod
import mihon.core.common.archive.ArchiveReader
import tachiyomi.core.common.util.system.ImageUtil
import uy.kohesive.injekt.injectLazy
import java.io.BufferedInputStream
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.nio.ByteBuffer
import java.nio.CharBuffer
import java.security.KeyStore
import java.security.SecureRandom
@ -33,7 +32,7 @@ import javax.crypto.spec.IvParameterSpec
*/
object CbzCrypto {
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 keyStore = KeyStore.getInstance(Keystore).apply {
load(null)
@ -129,15 +128,11 @@ object CbzCrypto {
return encrypt(password.toByteArray(), encryptionCipherCbz)
}
fun getDecryptedPasswordCbz(): CharArray {
fun getDecryptedPasswordCbz(): ByteArray {
val encryptedPassword = securityPreferences.cbzPassword().get()
if (encryptedPassword.isBlank()) error("This archive is encrypted please set a password")
val cbzBytes = decrypt(encryptedPassword, AliasCbz)
return Charsets.UTF_8.decode(ByteBuffer.wrap(cbzBytes)).array()
.also {
cbzBytes.fill('#'.code.toByte())
}
return decrypt(encryptedPassword, AliasCbz)
}
private fun generateAndEncryptSqlPw() {
@ -185,27 +180,12 @@ object CbzCrypto {
}
}
fun setZipParametersEncrypted(zipParameters: ZipParameters) {
zipParameters.isEncryptFiles = true
fun getPreferredEncryptionAlgo(): ByteArray =
when (securityPreferences.encryptionType().get()) {
SecurityPreferences.EncryptionType.AES_256 -> {
zipParameters.encryptionMethod = EncryptionMethod.AES
zipParameters.aesKeyStrength = AesKeyStrength.KEY_STRENGTH_256
}
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
}
SecurityPreferences.EncryptionType.AES_256 -> "zip:encryption=aes256".toByteArray()
SecurityPreferences.EncryptionType.AES_128 -> "zip:encryption=aes128".toByteArray()
SecurityPreferences.EncryptionType.ZIP_STANDARD -> "zip:encryption=zipcrypt".toByteArray()
}
}
fun detectCoverImageArchive(stream: InputStream): Boolean {
val bytes = ByteArray(128)
@ -217,6 +197,15 @@ object CbzCrypto {
}
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

View File

@ -1,15 +1,8 @@
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 mihon.core.common.archive.ArchiveReader
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
@ -17,45 +10,18 @@ import java.io.InputStream
/**
* Wrapper over ZipFile to load files in epub format.
*/
// SY -->
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 <--
class EpubFile(private val reader: ArchiveReader) : Closeable by reader {
/**
* Path separator used by this epub.
*/
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.
*/
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): ZipArchiveEntry? {
return zip.getEntry(name)
fun getInputStream(entryName: String): InputStream? {
return reader.getInputStream(entryName)
}
/**
@ -72,9 +38,9 @@ class EpubFile(file: UniFile, context: Context) : Closeable {
* Returns the path to the package document.
*/
fun getPackageHref(): String {
val meta = zip.getEntry(resolveZipPath("META-INF", "container.xml"))
val meta = getInputStream(resolveZipPath("META-INF", "container.xml"))
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")
if (path != null) {
return path
@ -87,8 +53,7 @@ class EpubFile(file: UniFile, context: Context) : Closeable {
* Returns the package document where all the files are listed.
*/
fun getPackageDocument(ref: String): Document {
val entry = zip.getEntry(ref)
return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
return getInputStream(ref)!!.use { Jsoup.parse(it, null, "") }
}
/**
@ -111,8 +76,7 @@ class EpubFile(file: UniFile, context: Context) : Closeable {
val basePath = getParentDirectory(packageHref)
pages.forEach { page ->
val entryPath = resolveZipPath(basePath, page)
val entry = zip.getEntry(entryPath)
val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
val document = getInputStream(entryPath)!!.use { Jsoup.parse(it, null, "") }
val imageBasePath = getParentDirectory(entryPath)
document.allElements.forEach {
@ -130,8 +94,9 @@ class EpubFile(file: UniFile, context: Context) : Closeable {
* Returns the path separator used by the epub file.
*/
private fun getPathSeparator(): String {
val meta = zip.getEntry("META-INF\\container.xml")
val meta = getInputStream("META-INF\\container.xml")
return if (meta != null) {
meta.close()
"\\"
} 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.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('.')
@ -26,200 +13,5 @@ val UniFile.nameWithoutExtension: String?
val UniFile.displayablePath: String
get() = filePath ?: uri.toString()
fun UniFile.openReadOnlyChannel(context: Context): FileChannel {
return ParcelFileDescriptor.AutoCloseInputStream(context.contentResolver.openFileDescriptor(uri, "r")).channel
// SY -->
}
fun UniFile.isEncryptedZip(): Boolean {
return try {
val stream = ZipInputStream(this.openInputStream())
stream.nextEntry
stream.close()
false
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
true
} else {
throw zipException
}
}
}
fun UniFile.testCbzPassword(): Boolean {
return try {
val stream = ZipInputStream(this.openInputStream())
stream.setPassword(CbzCrypto.getDecryptedPasswordCbz())
stream.nextEntry
stream.close()
true
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
false
} else {
throw zipException
}
}
}
fun UniFile.addStreamToZip(inputStream: InputStream, filename: String, password: CharArray? = null) {
val zipOutputStream =
if (password != null) {
ZipOutputStream(this.openOutputStream(), password)
} else {
ZipOutputStream(this.openOutputStream())
}
val zipParameters = ZipParameters()
zipParameters.fileNameInZip = filename
if (password != null) CbzCrypto.setZipParametersEncrypted(zipParameters)
zipOutputStream.putNextEntry(zipParameters)
zipOutputStream.use { output ->
inputStream.use { input ->
input.copyTo(output)
}
}
}
/**
* Unzips encrypted or unencrypted zip files using zip4j.
* The caller is responsible to ensure, that the file this is called from is a zip archive
*/
fun UniFile.unzip(destination: File, onlyCopyImages: Boolean = false) {
destination.mkdirs()
if (!destination.isDirectory) return
val zipInputStream = ZipInputStream(this.openInputStream())
var fileHeader: LocalFileHeader?
if (this.isEncryptedZip()) {
zipInputStream.setPassword(CbzCrypto.getDecryptedPasswordCbz())
}
try {
while (
run {
fileHeader = zipInputStream.nextEntry
fileHeader != null
}
) {
val tmpFile = File("${destination.absolutePath}/${fileHeader!!.fileName}")
if (onlyCopyImages) {
if (!fileHeader!!.isDirectory && ImageUtil.isImage(fileHeader!!.fileName)) {
tmpFile.createNewFile()
tmpFile.outputStream().buffered().use { tmpOut ->
zipInputStream.buffered().copyTo(tmpOut)
}
}
} else {
if (!fileHeader!!.isDirectory && ImageUtil.isImage(fileHeader!!.fileName)) {
tmpFile.createNewFile()
tmpFile
.outputStream()
.buffered()
.use { zipInputStream.buffered().copyTo(it) }
}
}
}
zipInputStream.close()
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${this.name} in: ${this.parentFile?.name}"
}
} else {
throw zipException
}
}
}
fun UniFile.addFilesToZip(files: List<UniFile>, password: CharArray? = null) {
val zipOutputStream =
if (password != null) {
ZipOutputStream(this.openOutputStream(), password)
} else {
ZipOutputStream(this.openOutputStream())
}
files.forEach {
val zipParameters = ZipParameters()
if (password != null) CbzCrypto.setZipParametersEncrypted(zipParameters)
zipParameters.fileNameInZip = it.name
zipOutputStream.putNextEntry(zipParameters)
it.openInputStream().use { input ->
input.copyTo(zipOutputStream)
}
zipOutputStream.closeEntry()
}
zipOutputStream.close()
}
fun UniFile.getZipInputStream(filename: String): InputStream? {
val zipInputStream = ZipInputStream(this.openInputStream())
var fileHeader: LocalFileHeader?
if (this.isEncryptedZip()) zipInputStream.setPassword(CbzCrypto.getDecryptedPasswordCbz())
try {
while (
run {
fileHeader = zipInputStream.nextEntry
fileHeader != null
}
) {
if (fileHeader?.fileName == filename) return zipInputStream
}
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${this.name} in: ${this.parentFile?.name}"
}
} else {
throw zipException
}
}
return null
}
fun UniFile.getCoverStreamFromZip(): InputStream? {
val zipInputStream = ZipInputStream(this.openInputStream())
var fileHeader: LocalFileHeader?
val fileHeaderList: MutableList<LocalFileHeader?> = mutableListOf()
if (this.isEncryptedZip()) zipInputStream.setPassword(CbzCrypto.getDecryptedPasswordCbz())
try {
while (
run {
fileHeader = zipInputStream.nextEntry
fileHeader != null
}
) {
fileHeaderList.add(fileHeader)
}
var coverHeader = fileHeaderList
.mapNotNull { it }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) }
val coverStream = coverHeader?.fileName?.let { this.getZipInputStream(it) }
if (coverStream != null) {
if (!ImageUtil.isImage(coverHeader?.fileName) { coverStream }) coverHeader = null
}
return coverHeader?.fileName?.let { getZipInputStream(it) }
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${this.name} in: ${this.parentFile?.name}"
}
return null
} else {
throw zipException
}
}
}
// SY <--
fun UniFile.openFileDescriptor(context: Context, mode: String): ParcelFileDescriptor =
context.contentResolver.openFileDescriptor(uri, mode) ?: error("Failed to open file descriptor: $displayablePath")

View File

@ -32,9 +32,7 @@ jsoup = "org.jsoup:jsoup:1.18.1"
disklrucache = "com.jakewharton:disklrucache:2.0.2"
unifile = "com.github.tachiyomiorg:unifile:e0def6b3dc"
common-compress = "org.apache.commons:commons-compress:1.26.2"
junrar = "com.github.junrar:junrar:7.5.5"
zip4j = "net.lingala.zip4j:zip4j:2.11.5"
libarchive = "me.zhanghai.android.libarchive:library:1.1.0"
sqlite-framework = { module = "androidx.sqlite:sqlite-framework", 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" }
[bundles]
archive = ["common-compress", "junrar"]
okhttp = ["okhttp-core", "okhttp-logging", "okhttp-brotli", "okhttp-dnsoverhttps"]
js-engine = ["quickjs-android"]
sqlite = ["sqlite-framework", "sqlite-ktx", "sqlite-android"]

View File

@ -279,7 +279,6 @@
<string name="wrong_cbz_archive_password">Wrong CBZ archive password</string>
<string name="encryption_type">Encryption type</string>
<string name="aes_256">AES 256</string>
<string name="aes_192">AES 192</string>
<string name="aes_128">AES 128</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="page_list_empty_error">No pages found</string>
<string name="loader_not_implemented_error">Source not found</string>
<string name="loader_rar5_error">RARv5 format is not supported</string>
<!-- Updates -->
<string name="updating_library">Updating library</string>

View File

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

View File

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

View File

@ -3,9 +3,8 @@ package tachiyomi.source.local.image
import android.content.Context
import com.hippo.unifile.UniFile
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 mihon.core.common.archive.ZipWriter
import tachiyomi.core.common.storage.nameWithoutExtension
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.source.local.io.LocalSourceFileSystem
@ -58,7 +57,9 @@ actual class LocalCoverManager(
inputStream.use { input ->
// SY -->
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)
manga.thumbnail_url = targetFile.uri.toString()

View File

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