implement mihonapp/mihon#326 (#1104)

* implement mihonapp/mihon#326

Archives are now being read from channels

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

* disable parallelisms for loading into memory

* switched to mutex

* detekt changes

* more detekt baseline changes

---------

Co-authored-by: FooIbar <118464521+FooIbar@users.noreply.github.com>
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
This commit is contained in:
Shamicen
2024-03-16 16:59:00 +01:00
committed by GitHub
parent 45711cd394
commit 6719f22eff
27 changed files with 513 additions and 398 deletions
+1
View File
@@ -36,6 +36,7 @@ dependencies {
implementation(libs.image.decoder)
implementation(libs.unifile)
implementation(libs.bundles.archive)
api(kotlinx.coroutines.core)
api(kotlinx.serialization.json)
@@ -3,25 +3,15 @@ package eu.kanade.tachiyomi.util.storage
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.core.security.SecurityPreferences
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import logcat.LogPriority
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.exception.ZipException
import net.lingala.zip4j.io.inputstream.ZipInputStream
import net.lingala.zip4j.io.outputstream.ZipOutputStream
import net.lingala.zip4j.model.LocalFileHeader
import net.lingala.zip4j.model.ZipParameters
import net.lingala.zip4j.model.enums.AesKeyStrength
import net.lingala.zip4j.model.enums.EncryptionMethod
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.injectLazy
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
@@ -136,12 +126,15 @@ object CbzCrypto {
}
fun getDecryptedPasswordCbz(): CharArray {
return decrypt(securityPreferences.cbzPassword().get(), ALIAS_CBZ).toCharArray()
val encryptedPassword = securityPreferences.cbzPassword().get()
if (encryptedPassword.isBlank()) error("This archive is encrypted please set a password")
return decrypt(encryptedPassword, ALIAS_CBZ).toCharArray()
}
private fun generateAndEncryptSqlPw() {
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
val password = (1..32).map {
val password = (1..SQL_PASSWORD_LENGTH).map {
charPool[SecureRandom().nextInt(charPool.size)]
}.joinToString("", transform = { it.toString() })
securityPreferences.sqlPassword().set(encrypt(password, encryptionCipherSql))
@@ -152,27 +145,6 @@ object CbzCrypto {
return decrypt(securityPreferences.sqlPassword().get(), ALIAS_SQL).toByteArray()
}
/**
* Function that returns true when the supplied password
* can Successfully decrypt the supplied zip archive
* not very elegant but this is the solution recommended by the maintainer for checking passwords
* a real password check will likely be implemented in the future though
*/
fun checkCbzPassword(zip4j: ZipFile, password: CharArray): Boolean {
try {
zip4j.setPassword(password)
zip4j.use { zip ->
zip.getInputStream(zip.fileHeaders.firstOrNull())
}
return true
} catch (e: Exception) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${zip4j.file.name} in: ${zip4j.file.parentFile?.name}"
}
}
return false
}
fun isPasswordSet(): Boolean {
return securityPreferences.cbzPassword().get().isNotEmpty()
}
@@ -228,133 +200,6 @@ object CbzCrypto {
}
return String(bytes).contains(DEFAULT_COVER_NAME, ignoreCase = true)
}
fun UniFile.isEncryptedZip(): Boolean {
return try {
val stream = ZipInputStream(this.openInputStream())
stream.nextEntry
stream.close()
false
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
true
} else throw zipException
}
}
fun UniFile.testCbzPassword(): Boolean {
return try {
val stream = ZipInputStream(this.openInputStream())
stream.setPassword(getDecryptedPasswordCbz())
stream.nextEntry
stream.close()
true
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
false
} else throw zipException
}
}
fun UniFile.addStreamToZip(inputStream: InputStream, filename: String, password: CharArray? = null) {
val zipOutputStream =
if (password != null) ZipOutputStream(this.openOutputStream(), password)
else ZipOutputStream(this.openOutputStream())
val zipParameters = ZipParameters()
zipParameters.fileNameInZip = filename
if (password != null) setZipParametersEncrypted(zipParameters)
zipOutputStream.putNextEntry(zipParameters)
zipOutputStream.use { output ->
inputStream.use { input ->
input.copyTo(output)
}
}
}
fun UniFile.addFilesToZip(files: List<UniFile>, password: CharArray? = null) {
val zipOutputStream =
if (password != null) ZipOutputStream(this.openOutputStream(), password)
else ZipOutputStream(this.openOutputStream())
files.forEach {
val zipParameters = ZipParameters()
if (password != null) setZipParametersEncrypted(zipParameters)
zipParameters.fileNameInZip = it.name
zipOutputStream.putNextEntry(zipParameters)
it.openInputStream().use { input ->
input.copyTo(zipOutputStream)
}
zipOutputStream.closeEntry()
}
zipOutputStream.close()
}
fun UniFile.getZipInputStream(filename: String): InputStream? {
val zipInputStream = ZipInputStream(this.openInputStream())
var fileHeader: LocalFileHeader?
if (this.isEncryptedZip()) zipInputStream.setPassword(getDecryptedPasswordCbz())
try {
while (run {
fileHeader = zipInputStream.nextEntry
fileHeader != null
}) {
if (fileHeader?.fileName == filename) return zipInputStream
}
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${this.name} in: ${this.parentFile?.name}"
}
} else throw zipException
}
return null
}
fun UniFile.getCoverStreamFromZip(): InputStream? {
val zipInputStream = ZipInputStream(this.openInputStream())
var fileHeader: LocalFileHeader?
val fileHeaderList: MutableList<LocalFileHeader?> = mutableListOf()
if (this.isEncryptedZip()) zipInputStream.setPassword(getDecryptedPasswordCbz())
try {
while (run {
fileHeader = zipInputStream.nextEntry
fileHeader != null
}) {
fileHeaderList.add(fileHeader)
}
var coverHeader = fileHeaderList
.mapNotNull { it }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) }
val coverStream = coverHeader?.fileName?.let { this.getZipInputStream(it) }
if (coverStream != null) {
if (!ImageUtil.isImage(coverHeader?.fileName) { coverStream }) coverHeader = null
}
return coverHeader?.fileName?.let { getZipInputStream(it) }
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${this.name} in: ${this.parentFile?.name}"
}
return null
} else throw zipException
}
}
}
private const val BUFFER_SIZE = 2048
@@ -369,4 +214,6 @@ private const val CRYPTO_SETTINGS = "$ALGORITHM/$BLOCK_MODE/$PADDING"
private const val KEYSTORE = "AndroidKeyStore"
private const val ALIAS_CBZ = "cbzPw"
private const val ALIAS_SQL = "sqlPw"
private const val SQL_PASSWORD_LENGTH = 32
// SY <--
@@ -1,22 +1,36 @@
package eu.kanade.tachiyomi.util.storage
import android.content.Context
import android.os.Build
import com.hippo.unifile.UniFile
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
import org.apache.commons.compress.archivers.zip.ZipFile
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import tachiyomi.core.common.storage.UniFileTempFileManager
import tachiyomi.core.common.storage.openReadOnlyChannel
import uy.kohesive.injekt.injectLazy
import java.io.Closeable
import java.io.File
import java.io.InputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
/**
* Wrapper over ZipFile to load files in epub format.
*/
class EpubFile(file: File) : Closeable {
// SY -->
class EpubFile(file: UniFile, context: Context) : Closeable {
private val tempFileManager: UniFileTempFileManager by injectLazy()
/**
* Zip file of this epub.
*/
private val zip = ZipFile(file)
private val zip = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
ZipFile(tempFileManager.createTempFile(file))
} else {
ZipFile(file.openReadOnlyChannel(context))
}
// SY <--
/**
* Path separator used by this epub.
@@ -33,14 +47,14 @@ class EpubFile(file: File) : Closeable {
/**
* Returns an input stream for reading the contents of the specified zip file entry.
*/
fun getInputStream(entry: ZipEntry): InputStream {
fun getInputStream(entry: ZipArchiveEntry): InputStream {
return zip.getInputStream(entry)
}
/**
* Returns the zip file entry for the specified name, or null if not found.
*/
fun getEntry(name: String): ZipEntry? {
fun getEntry(name: String): ZipArchiveEntry? {
return zip.getEntry(name)
}
@@ -1,6 +1,21 @@
package tachiyomi.core.common.storage
import android.content.Context
import android.os.ParcelFileDescriptor
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import logcat.LogPriority
import net.lingala.zip4j.exception.ZipException
import net.lingala.zip4j.io.inputstream.ZipInputStream
import net.lingala.zip4j.io.outputstream.ZipOutputStream
import net.lingala.zip4j.model.LocalFileHeader
import net.lingala.zip4j.model.ZipParameters
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat
import java.io.File
import java.io.InputStream
import java.nio.channels.FileChannel
val UniFile.extension: String?
get() = name?.substringAfterLast('.')
@@ -10,3 +25,201 @@ val UniFile.nameWithoutExtension: String?
val UniFile.displayablePath: String
get() = filePath ?: uri.toString()
fun UniFile.openReadOnlyChannel(context: Context): FileChannel {
return ParcelFileDescriptor.AutoCloseInputStream(context.contentResolver.openFileDescriptor(uri, "r")).channel
// SY -->
}
fun UniFile.isEncryptedZip(): Boolean {
return try {
val stream = ZipInputStream(this.openInputStream())
stream.nextEntry
stream.close()
false
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
true
} else {
throw zipException
}
}
}
fun UniFile.testCbzPassword(): Boolean {
return try {
val stream = ZipInputStream(this.openInputStream())
stream.setPassword(CbzCrypto.getDecryptedPasswordCbz())
stream.nextEntry
stream.close()
true
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
false
} else {
throw zipException
}
}
}
fun UniFile.addStreamToZip(inputStream: InputStream, filename: String, password: CharArray? = null) {
val zipOutputStream =
if (password != null) {
ZipOutputStream(this.openOutputStream(), password)
} else {
ZipOutputStream(this.openOutputStream())
}
val zipParameters = ZipParameters()
zipParameters.fileNameInZip = filename
if (password != null) CbzCrypto.setZipParametersEncrypted(zipParameters)
zipOutputStream.putNextEntry(zipParameters)
zipOutputStream.use { output ->
inputStream.use { input ->
input.copyTo(output)
}
}
}
/**
* Unzips encrypted or unencrypted zip files using zip4j.
* The caller is responsible to ensure, that the file this is called from is a zip archive
*/
fun UniFile.unzip(destination: File, onlyCopyImages: Boolean = false) {
destination.mkdirs()
if (!destination.isDirectory) return
val zipInputStream = ZipInputStream(this.openInputStream())
var fileHeader: LocalFileHeader?
if (this.isEncryptedZip()) {
zipInputStream.setPassword(CbzCrypto.getDecryptedPasswordCbz())
}
try {
while (
run {
fileHeader = zipInputStream.nextEntry
fileHeader != null
}
) {
val tmpFile = File("${destination.absolutePath}/${fileHeader!!.fileName}")
if (onlyCopyImages) {
if (!fileHeader!!.isDirectory && ImageUtil.isImage(fileHeader!!.fileName)) {
tmpFile.createNewFile()
tmpFile.outputStream().buffered().use { tmpOut ->
zipInputStream.buffered().copyTo(tmpOut)
}
}
} else {
if (!fileHeader!!.isDirectory && ImageUtil.isImage(fileHeader!!.fileName)) {
tmpFile.createNewFile()
tmpFile
.outputStream()
.buffered()
.use { zipInputStream.buffered().copyTo(it) }
}
}
}
zipInputStream.close()
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${this.name} in: ${this.parentFile?.name}"
}
} else {
throw zipException
}
}
}
fun UniFile.addFilesToZip(files: List<UniFile>, password: CharArray? = null) {
val zipOutputStream =
if (password != null) {
ZipOutputStream(this.openOutputStream(), password)
} else {
ZipOutputStream(this.openOutputStream())
}
files.forEach {
val zipParameters = ZipParameters()
if (password != null) CbzCrypto.setZipParametersEncrypted(zipParameters)
zipParameters.fileNameInZip = it.name
zipOutputStream.putNextEntry(zipParameters)
it.openInputStream().use { input ->
input.copyTo(zipOutputStream)
}
zipOutputStream.closeEntry()
}
zipOutputStream.close()
}
fun UniFile.getZipInputStream(filename: String): InputStream? {
val zipInputStream = ZipInputStream(this.openInputStream())
var fileHeader: LocalFileHeader?
if (this.isEncryptedZip()) zipInputStream.setPassword(CbzCrypto.getDecryptedPasswordCbz())
try {
while (
run {
fileHeader = zipInputStream.nextEntry
fileHeader != null
}
) {
if (fileHeader?.fileName == filename) return zipInputStream
}
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${this.name} in: ${this.parentFile?.name}"
}
} else {
throw zipException
}
}
return null
}
fun UniFile.getCoverStreamFromZip(): InputStream? {
val zipInputStream = ZipInputStream(this.openInputStream())
var fileHeader: LocalFileHeader?
val fileHeaderList: MutableList<LocalFileHeader?> = mutableListOf()
if (this.isEncryptedZip()) zipInputStream.setPassword(CbzCrypto.getDecryptedPasswordCbz())
try {
while (
run {
fileHeader = zipInputStream.nextEntry
fileHeader != null
}
) {
fileHeaderList.add(fileHeader)
}
var coverHeader = fileHeaderList
.mapNotNull { it }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) }
val coverStream = coverHeader?.fileName?.let { this.getZipInputStream(it) }
if (coverStream != null) {
if (!ImageUtil.isImage(coverHeader?.fileName) { coverStream }) coverHeader = null
}
return coverHeader?.fileName?.let { getZipInputStream(it) }
} catch (zipException: ZipException) {
if (zipException.type == ZipException.Type.WRONG_PASSWORD) {
logcat(LogPriority.WARN) {
"Wrong CBZ archive password for: ${this.name} in: ${this.parentFile?.name}"
}
return null
} else {
throw zipException
}
}
}
// SY <--
@@ -26,8 +26,6 @@ import androidx.core.graphics.red
import androidx.exifinterface.media.ExifInterface
import com.hippo.unifile.UniFile
import logcat.LogPriority
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.FileHeader
import tachiyomi.decoder.Format
import tachiyomi.decoder.ImageDecoder
import java.io.BufferedInputStream
@@ -133,20 +131,8 @@ object ImageUtil {
*
* @return true if the width is greater than the height
*/
fun isWideImage(
imageStream: BufferedInputStream,
// SY -->
zip4jFile: ZipFile?,
zip4jEntry: FileHeader?,
// SY <--
): Boolean {
val options = extractImageOptions(
imageStream,
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
)
fun isWideImage(imageStream: BufferedInputStream): Boolean {
val options = extractImageOptions(imageStream)
return options.outWidth > options.outHeight
}
@@ -271,19 +257,9 @@ object ImageUtil {
*
* @return true if the height:width ratio is greater than 3.
*/
private fun isTallImage(
imageStream: InputStream,
// SY -->
zip4jFile: ZipFile?,
zip4jEntry: FileHeader?,
// SY <--
): Boolean {
private fun isTallImage(imageStream: InputStream): Boolean {
val options = extractImageOptions(
imageStream,
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
resetAfterExtraction = false,
)
@@ -297,18 +273,9 @@ object ImageUtil {
tmpDir: UniFile,
imageFile: UniFile,
filenamePrefix: String,
// SY -->
zip4jFile: ZipFile?,
zip4jEntry: FileHeader?,
// SY <--
): Boolean {
if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(
imageFile.openInputStream(),
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
)
if (isAnimatedAndSupported(imageFile.openInputStream()) ||
!isTallImage(imageFile.openInputStream())
) {
return true
}
@@ -321,10 +288,6 @@ object ImageUtil {
val options = extractImageOptions(
imageFile.openInputStream(),
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
resetAfterExtraction = false,
).apply {
inJustDecodeBounds = false
@@ -641,17 +604,8 @@ object ImageUtil {
*/
private fun extractImageOptions(
imageStream: InputStream,
// SY -->
zip4jFile: ZipFile?,
zip4jEntry: FileHeader?,
// SY <--
resetAfterExtraction: Boolean = true,
): BitmapFactory.Options {
// SY -->
// zip4j does currently not support mark() and reset()
if (zip4jFile != null && zip4jEntry != null) return extractImageOptionsZip4j(zip4jFile, zip4jEntry)
// SY <--
imageStream.mark(Int.MAX_VALUE)
val imageBytes = imageStream.readBytes()
@@ -661,16 +615,6 @@ object ImageUtil {
return options
}
// SY -->
private fun extractImageOptionsZip4j(zip4jFile: ZipFile?, zip4jEntry: FileHeader?): BitmapFactory.Options {
zip4jFile?.getInputStream(zip4jEntry).use { imageStream ->
val imageBytes = imageStream?.readBytes()
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
imageBytes?.size?.let { BitmapFactory.decodeByteArray(imageBytes, 0, it, options) }
return options
}
}
/**
* Creates random exif metadata used as padding to make
* the size of files inside CBZ archives unique