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:
parent
71f2daf8f3
commit
95c834581b
@ -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)
|
||||||
|
3
app/proguard-rules.pro
vendored
3
app/proguard-rules.pro
vendored
@ -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.** { *; }
|
||||||
|
@ -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 <--
|
||||||
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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)
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -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<UpdatesUiModel>, selectionMode: Boolean, // SY --> preserveReadingPosition: Boolean, // SY <-- onUpdateSelected: (UpdatesItem, Boolean, Boolean, Boolean) -> Unit, onClickCover: (UpdatesItem) -> Unit, onClickUpdate: (UpdatesItem) -> Unit, onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit, )</ID>
|
<ID>LongParameterList:UpdatesUiItem.kt$( uiModels: List<UpdatesUiModel>, selectionMode: Boolean, // SY --> preserveReadingPosition: Boolean, // SY <-- onUpdateSelected: (UpdatesItem, Boolean, Boolean, Boolean) -> Unit, onClickCover: (UpdatesItem) -> Unit, onClickUpdate: (UpdatesItem) -> Unit, onDownloadChapter: (List<UpdatesItem>, ChapterDownloadAction) -> Unit, )</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<String>, statistics: StatisticsMangaDto?, )</ID>
|
<ID>NestedBlockDepth:ApiMangaParser.kt$ApiMangaParser$fun parseIntoMetadata( metadata: MangaDexSearchMetadata, mangaDto: MangaDto, simpleChapters: List<String>, statistics: StatisticsMangaDto?, )</ID>
|
||||||
<ID>NestedBlockDepth:AppLanguageScreen.kt$AppLanguageScreen$private fun getLangs(context: Context): ImmutableList<Language></ID>
|
<ID>NestedBlockDepth:AppLanguageScreen.kt$AppLanguageScreen$private fun getLangs(context: Context): ImmutableList<Language></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>
|
||||||
|
@ -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 <--
|
||||||
|
@ -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),
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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 {
|
||||||
"/"
|
"/"
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
package mihon.core.common.archive
|
||||||
|
|
||||||
|
class ArchiveEntry(
|
||||||
|
val name: String,
|
||||||
|
val isFile: Boolean,
|
||||||
|
val isEncrypted: Boolean,
|
||||||
|
)
|
@ -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 <--
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -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) }
|
@ -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 }
|
@ -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 <--
|
|
||||||
|
@ -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"]
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user