Encrypted CBZ archives (#846)

* Initial Implementation of encrypted CBZ archives

* changed a preference key to correct Syntax, changed a function name and changed ComicInfo padding length

* Update app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSecurityScreen.kt

Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>

* Update app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSecurityScreen.kt

Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>

* add necessary imports

* fix indentation after merge conflict

* Update app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSecurityScreen.kt

Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>

* fix indentation and add imports

* collect preferences as states

* test if password is correct in ZipPageLoader

* added withIOContext to function call

* added encryption type preference

* implemented database encryption

* added proguard rules for sqlcipher and generate padding length with SecureRandom

---------

Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>
This commit is contained in:
Shamicen
2023-05-06 17:06:54 +02:00
committed by GitHub
parent 514e061dd9
commit 88f076afd4
22 changed files with 970 additions and 86 deletions
+3
View File
@@ -11,6 +11,9 @@ kotlin {
implementation(project(":source-api"))
implementation(libs.unifile)
implementation(libs.junrar)
// SY -->
implementation(libs.zip4j)
// SY <--
}
}
val androidMain by getting {
@@ -9,12 +9,15 @@ import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import eu.kanade.tachiyomi.util.storage.EpubFile
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream
import logcat.LogPriority
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.ZipParameters
import nl.adaptivity.xmlutil.AndroidXmlReader
import nl.adaptivity.xmlutil.serialization.XML
import rx.Observable
@@ -39,7 +42,6 @@ import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.nio.charset.StandardCharsets
import java.util.zip.ZipFile
import kotlin.time.Duration.Companion.days
import com.github.junrar.Archive as JunrarArchive
import tachiyomi.domain.source.model.Source as DomainSource
@@ -187,6 +189,10 @@ actual class LocalSource(
.firstOrNull { it.name == ".noxml" }
val legacyJsonDetailsFile = mangaDirFiles
.firstOrNull { it.extension == "json" }
// SY -->
val comicInfoArchiveFile = mangaDirFiles
.firstOrNull { it.name == COMIC_INFO_ARCHIVE }
// SY <--
when {
// Top level ComicInfo.xml
@@ -194,6 +200,18 @@ actual class LocalSource(
noXmlFile?.delete()
setMangaDetailsFromComicInfoFile(comicInfoFile.inputStream(), manga)
}
// SY -->
comicInfoArchiveFile != null -> {
val comicInfoArchive = ZipFile(comicInfoArchiveFile)
noXmlFile?.delete()
if (CbzCrypto.checkCbzPassword(comicInfoArchive, CbzCrypto.getDecryptedPasswordCbz())) {
comicInfoArchive.setPassword(CbzCrypto.getDecryptedPasswordCbz())
val comicInfoEntry = comicInfoArchive.fileHeaders.firstOrNull { it.fileName == COMIC_INFO_FILE }
setMangaDetailsFromComicInfoFile(comicInfoArchive.getInputStream(comicInfoEntry), manga)
}
}
// SY <--
// TODO: automatically convert these to ComicInfo.xml
legacyJsonDetailsFile != null -> {
@@ -217,9 +235,17 @@ actual class LocalSource(
val folderPath = mangaDir?.absolutePath
val copiedFile = copyComicInfoFileFromArchive(chapterArchives, folderPath)
if (copiedFile != null) {
// SY -->
if (copiedFile != null && copiedFile.name != COMIC_INFO_ARCHIVE) {
setMangaDetailsFromComicInfoFile(copiedFile.inputStream(), manga)
} else {
} else if (copiedFile != null && copiedFile.name == COMIC_INFO_ARCHIVE) {
val comicInfoArchive = ZipFile(copiedFile)
comicInfoArchive.setPassword(CbzCrypto.getDecryptedPasswordCbz())
val comicInfoEntry = comicInfoArchive.fileHeaders.firstOrNull { it.fileName == COMIC_INFO_FILE }
setMangaDetailsFromComicInfoFile(comicInfoArchive.getInputStream(comicInfoEntry), manga)
} // SY <--
else {
// Avoid re-scanning
File("$folderPath/.noxml").createNewFile()
}
@@ -237,7 +263,16 @@ actual class LocalSource(
when (Format.valueOf(chapter)) {
is Format.Zip -> {
ZipFile(chapter).use { zip: ZipFile ->
zip.getEntry(COMIC_INFO_FILE)?.let { comicInfoFile ->
// SY -->
if (zip.isEncrypted && !CbzCrypto.checkCbzPassword(zip, CbzCrypto.getDecryptedPasswordCbz())
) {
return null
} else if (zip.isEncrypted && CbzCrypto.checkCbzPassword(zip, CbzCrypto.getDecryptedPasswordCbz())
) {
zip.setPassword(CbzCrypto.getDecryptedPasswordCbz())
}
zip.getFileHeader(COMIC_INFO_FILE)?.let { comicInfoFile ->
// SY <--
zip.getInputStream(comicInfoFile).buffered().use { stream ->
return copyComicInfoFile(stream, folderPath)
}
@@ -260,9 +295,25 @@ actual class LocalSource(
}
private fun copyComicInfoFile(comicInfoFileStream: InputStream, folderPath: String?): File {
return File("$folderPath/$COMIC_INFO_FILE").apply {
outputStream().use { outputStream ->
comicInfoFileStream.use { it.copyTo(outputStream) }
// SY -->
if (
CbzCrypto.getPasswordProtectDlPref() &&
CbzCrypto.isPasswordSet()
) {
val zipParameters = ZipParameters()
CbzCrypto.setZipParametersEncrypted(zipParameters)
zipParameters.fileNameInZip = COMIC_INFO_FILE
val zipEncrypted = ZipFile("$folderPath/$COMIC_INFO_ARCHIVE")
zipEncrypted.setPassword(CbzCrypto.getDecryptedPasswordCbz())
zipEncrypted.addStream(comicInfoFileStream, zipParameters)
return zipEncrypted.file
} else {
// SY <--
return File("$folderPath/$COMIC_INFO_FILE").apply {
outputStream().use { outputStream ->
comicInfoFileStream.use { it.copyTo(outputStream) }
}
}
}
}
@@ -338,9 +389,12 @@ actual class LocalSource(
}
is Format.Zip -> {
ZipFile(format.file).use { zip ->
val entry = zip.entries().toList()
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
// SY -->
if (zip.isEncrypted) zip.setPassword(CbzCrypto.getDecryptedPasswordCbz())
val entry = zip.fileHeaders.toList()
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { zip.getInputStream(it) } }
// SY <--
entry?.let { coverManager.update(manga, zip.getInputStream(it)) }
}
@@ -374,6 +428,10 @@ actual class LocalSource(
const val ID = 0L
const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/"
// SY -->
const val COMIC_INFO_ARCHIVE = "ComicInfo.cbm"
// SY <--
private val LATEST_THRESHOLD = 7.days.inWholeMilliseconds
}
}
@@ -2,27 +2,52 @@ package tachiyomi.source.local.image
import android.content.Context
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.core.security.SecurityPreferences
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.storage.DiskUtil
import tachiyomi.core.util.system.ImageUtil
import tachiyomi.source.local.io.LocalSourceFileSystem
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import java.io.InputStream
private const val DEFAULT_COVER_NAME = "cover.jpg"
// SY -->
private const val NO_COVER_FILE = ".nocover"
private const val CACHE_COVER_INTERNAL = ".cacheCoverInternal"
private const val LOCAL_CACHE_DIR = "covers/local"
// SY <--
actual class LocalCoverManager(
private val context: Context,
private val fileSystem: LocalSourceFileSystem,
// SY -->
private val coverCacheDir: File? = context.getExternalFilesDir(LOCAL_CACHE_DIR),
private val securityPreferences: SecurityPreferences = Injekt.get(),
// SY <--
) {
actual fun find(mangaUrl: String): File? {
return fileSystem.getFilesInMangaDirectory(mangaUrl)
// Get all file whose names start with 'cover'
.filter { it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true) }
// --> SY
.filter { (it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true)) || it.name == NO_COVER_FILE || it.name == CACHE_COVER_INTERNAL }
// Get the first actual image
.firstOrNull {
ImageUtil.isImage(it.name) { it.inputStream() }
if (it.name != NO_COVER_FILE && it.name != CACHE_COVER_INTERNAL) {
ImageUtil.isImage(it.name) { it.inputStream() }
} else if (it.name == NO_COVER_FILE) {
true
} else if (it.name == CACHE_COVER_INTERNAL) {
return File("$coverCacheDir/${it.parentFile?.name}/$DEFAULT_COVER_NAME")
} else {
false
}
// SY <--
}
}
@@ -38,21 +63,49 @@ actual class LocalCoverManager(
var targetFile = find(manga.url)
if (targetFile == null) {
targetFile = File(directory.absolutePath, DEFAULT_COVER_NAME)
// SY -->
targetFile = when (securityPreferences.localCoverLocation().get()) {
SecurityPreferences.CoverCacheLocation.INTERNAL -> File(directory.absolutePath, CACHE_COVER_INTERNAL)
SecurityPreferences.CoverCacheLocation.NEVER -> File(directory.absolutePath, NO_COVER_FILE)
SecurityPreferences.CoverCacheLocation.IN_MANGA_DIRECTORY -> File(directory.absolutePath, DEFAULT_COVER_NAME)
}
if (targetFile.parentFile?.parentFile?.name != "local") targetFile.parentFile?.mkdirs()
targetFile.createNewFile()
}
// It might not exist at this point
targetFile.parentFile?.mkdirs()
inputStream.use { input ->
targetFile.outputStream().use { output ->
input.copyTo(output)
if (targetFile.name == NO_COVER_FILE) return null
if (securityPreferences.localCoverLocation().get() == SecurityPreferences.CoverCacheLocation.IN_MANGA_DIRECTORY) {
// SY <--
// It might not exist at this point
targetFile.parentFile?.mkdirs()
inputStream.use { input ->
targetFile.outputStream().use { output ->
input.copyTo(output)
}
DiskUtil.createNoMediaFile(UniFile.fromFile(directory), context)
manga.thumbnail_url = targetFile.absolutePath
return targetFile
// SY -->
}
} else if (securityPreferences.localCoverLocation().get() == SecurityPreferences.CoverCacheLocation.INTERNAL) {
// It might not exist at this point
targetFile.parentFile?.mkdirs()
val path = "$coverCacheDir/${targetFile.parentFile?.name}/$DEFAULT_COVER_NAME"
val outputFile = File(path)
outputFile.parentFile?.mkdirs()
outputFile.createNewFile()
inputStream.use { input ->
outputFile.outputStream().use { output ->
input.copyTo(output)
}
}
manga.thumbnail_url = outputFile.absolutePath
return outputFile
} else {
return null
}
DiskUtil.createNoMediaFile(UniFile.fromFile(directory), context)
manga.thumbnail_url = targetFile.absolutePath
return targetFile
// SY <--
}
}