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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 970 additions and 86 deletions

View File

@ -166,6 +166,9 @@ dependencies {
implementation(androidx.paging.compose)
implementation(libs.bundles.sqlite)
// SY -->
implementation(libs.sqlcipher)
// SY <--
implementation(kotlinx.reflect)
@ -208,6 +211,9 @@ dependencies {
implementation(libs.disklrucache)
implementation(libs.unifile)
implementation(libs.junrar)
// SY -->
implementation(libs.zip4j)
// SY <--
// Preferences
implementation(libs.preferencektx)

View File

@ -118,6 +118,15 @@
# XmlUtil
-keep public enum nl.adaptivity.xmlutil.EventType { *; }
# SY -->
# SqlCipher
-keepclassmembers class net.zetetic.database.sqlcipher.SQLiteCustomFunction { *; }
-keepclassmembers class net.zetetic.database.sqlcipher.SQLiteConnection { *; }
-keepclassmembers class net.zetetic.database.sqlcipher.SQLiteGlobal { *; }
-keepclassmembers class net.zetetic.database.sqlcipher.SQLiteDebug { *; }
-keepclassmembers class net.zetetic.database.sqlcipher.SQLiteDebug$* { *; }
# SY <--
# Design library
-dontwarn com.google.android.material.**
-keep class com.google.android.material.** { *; }

View File

@ -5,6 +5,7 @@ import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import tachiyomi.core.metadata.comicinfo.ComicInfo
import tachiyomi.core.metadata.comicinfo.ComicInfoPublishingStatus
import tachiyomi.domain.chapter.model.Chapter
@ -115,6 +116,9 @@ fun getComicInfo(manga: Manga, chapter: Chapter, chapterUrl: String) = ComicInfo
publishingStatus = ComicInfo.PublishingStatusTachiyomi(
ComicInfoPublishingStatus.toComicInfoValue(manga.status),
),
// SY -->
padding = CbzCrypto.createComicInfoPadding()?.let { ComicInfo.PaddingTachiyomiSY(it) },
// SY <--
inker = null,
colorist = null,
letterer = null,

View File

@ -1,28 +1,48 @@
package eu.kanade.presentation.more.settings.screen
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.fragment.app.FragmentActivity
@ -34,10 +54,16 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.core.security.SecurityPreferences
import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate
import eu.kanade.tachiyomi.ui.category.biometric.BiometricTimesScreen
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.isAuthenticationSupported
import eu.kanade.tachiyomi.util.system.toast
import logcat.LogPriority
import tachiyomi.core.util.lang.withIOContext
import tachiyomi.core.util.system.logcat
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
object SettingsSecurityScreen : SearchableSettings {
@ -56,6 +82,10 @@ object SettingsSecurityScreen : SearchableSettings {
val useAuth by useAuthPref.collectAsState()
val scope = rememberCoroutineScope()
val isCbzPasswordSet by remember { CbzCrypto.isPasswordSetState(scope) }.collectAsState()
val passwordProtectDownloads by securityPreferences.passwordProtectDownloads().collectAsState()
return listOf(
Preference.PreferenceItem.SwitchPreference(
pref = useAuthPref,
@ -96,6 +126,92 @@ object SettingsSecurityScreen : SearchableSettings {
.associateWith { stringResource(it.titleResId) },
),
// SY -->
Preference.PreferenceItem.SwitchPreference(
title = stringResource(R.string.encrypt_database),
pref = securityPreferences.encryptDatabase(),
subtitle = stringResource(R.string.encrypt_database_subtitle),
),
Preference.PreferenceItem.SwitchPreference(
pref = securityPreferences.passwordProtectDownloads(),
title = stringResource(R.string.password_protect_downloads),
subtitle = stringResource(R.string.password_protect_downloads_summary),
enabled = isCbzPasswordSet,
),
Preference.PreferenceItem.ListPreference(
pref = securityPreferences.encryptionType(),
title = stringResource(R.string.encryption_type),
entries = SecurityPreferences.EncryptionType.values()
.associateWith { stringResource(it.titleResId) },
enabled = passwordProtectDownloads,
),
kotlin.run {
var dialogOpen by remember { mutableStateOf(false) }
if (dialogOpen) {
PasswordDialog(
onDismissRequest = { dialogOpen = false },
onReturnPassword = { password ->
dialogOpen = false
CbzCrypto.deleteKeyCbz()
securityPreferences.cbzPassword().set(CbzCrypto.encryptCbz(password.replace("\n", "")))
},
)
}
Preference.PreferenceItem.TextPreference(
title = stringResource(R.string.set_cbz_zip_password),
onClick = {
dialogOpen = true
},
)
},
Preference.PreferenceItem.TextPreference(
title = stringResource(R.string.delete_cbz_archive_password),
onClick = {
CbzCrypto.deleteKeyCbz()
securityPreferences.cbzPassword().set("")
},
enabled = isCbzPasswordSet,
),
Preference.PreferenceItem.ListPreference(
pref = securityPreferences.localCoverLocation(),
title = stringResource(R.string.save_local_manga_covers),
entries = SecurityPreferences.CoverCacheLocation.values()
.associateWith { stringResource(it.titleResId) },
enabled = passwordProtectDownloads,
onValueChanged = {
try {
withIOContext {
CbzCrypto.deleteLocalCoverCache(context)
CbzCrypto.deleteLocalCoverSystemFiles(context)
}
true
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
context.toast(e.toString(), Toast.LENGTH_SHORT).show()
false
}
},
),
Preference.PreferenceItem.TextPreference(
title = stringResource(R.string.delete_cached_local_source_covers),
subtitle = stringResource(R.string.delete_cached_local_source_covers_subtitle),
onClick = {
try {
CbzCrypto.deleteLocalCoverCache(context)
CbzCrypto.deleteLocalCoverSystemFiles(context)
context.toast(R.string.successfully_deleted_all_locally_cached_covers, Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
context.toast(R.string.something_went_wrong_deleting_your_cover_images, Toast.LENGTH_LONG).show()
}
},
enabled = produceState(false) {
withIOContext {
value = context.getExternalFilesDir("covers/local")?.absolutePath?.let { File(it).listFiles()?.isNotEmpty() } == true
}
}.value,
),
kotlin.run {
val navigator = LocalNavigator.currentOrThrow
val count by securityPreferences.authenticatorTimeRanges().collectAsState()
@ -215,6 +331,81 @@ object SettingsSecurityScreen : SearchableSettings {
},
)
}
@Composable
fun PasswordDialog(
onDismissRequest: () -> Unit,
onReturnPassword: (String) -> Unit,
) {
var password by rememberSaveable { mutableStateOf("") }
var passwordVisibility by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(text = stringResource(R.string.cbz_archive_password)) },
text = {
TextField(
value = password,
onValueChange = { password = it },
maxLines = 1,
placeholder = { Text(text = stringResource(R.string.password)) },
label = { Text(text = stringResource(R.string.password)) },
trailingIcon = {
IconButton(
onClick = {
passwordVisibility = !passwordVisibility
},
) {
Icon(
imageVector = if (passwordVisibility) {
Icons.Default.Visibility
} else {
Icons.Default.VisibilityOff
},
contentDescription = null,
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { onReturnPassword(password) },
),
modifier = Modifier.onKeyEvent {
if (it.key == Key.Enter) {
return@onKeyEvent true
}
false
},
visualTransformation = if (passwordVisibility) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
)
},
properties = DialogProperties(
usePlatformDefaultWidth = true,
),
confirmButton = {
TextButton(
onClick = {
onReturnPassword(password)
},
) {
Text(text = stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(android.R.string.cancel))
}
},
)
}
// SY <--
}

View File

@ -27,11 +27,13 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.NetworkPreferences
import eu.kanade.tachiyomi.source.AndroidSourceManager
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import eu.kanade.tachiyomi.util.system.isDevFlavor
import exh.eh.EHentaiUpdateHelper
import exh.pref.DelegateSourcePreferences
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
import kotlinx.serialization.json.Json
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.core.XmlVersion
import nl.adaptivity.xmlutil.serialization.UnknownChildHandler
@ -64,23 +66,42 @@ import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addSingleton
import uy.kohesive.injekt.api.addSingletonFactory
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
// SY -->
private const val LEGACY_DATABASE_NAME = "tachiyomi.db"
// SY <--
class AppModule(val app: Application) : InjektModule {
// SY -->
private val securityPreferences: SecurityPreferences by injectLazy()
// SY <--
override fun InjektRegistrar.registerInjectables() {
addSingleton(app)
addSingletonFactory<SqlDriver> {
// SY -->
System.loadLibrary("sqlcipher")
// SY <--
AndroidSqliteDriver(
schema = Database.Schema,
context = app,
name = "tachiyomi.db",
factory = if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// SY -->
name = if (securityPreferences.encryptDatabase().get()) {
CbzCrypto.DATABASE_NAME
} else {
LEGACY_DATABASE_NAME
},
factory = if (securityPreferences.encryptDatabase().get()) {
SupportOpenHelperFactory(CbzCrypto.getDecryptedPasswordSql())
} else if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Support database inspector in Android Studio
FrameworkSQLiteOpenHelperFactory()
} else {
RequerySQLiteOpenHelperFactory()
},
// SY <--
callback = object : AndroidSqliteDriver.Callback(Database.Schema) {
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
@ -193,7 +214,7 @@ class PreferenceModule(val application: Application) : InjektModule {
SourcePreferences(get())
}
addSingletonFactory {
SecurityPreferences(get())
SecurityPreferences(get(), application.applicationContext)
}
addSingletonFactory {
LibraryPreferences(get())

View File

@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.data.notification.NotificationHandler
import eu.kanade.tachiyomi.source.UnmeteredSource
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE
import eu.kanade.tachiyomi.util.storage.saveTo
@ -34,6 +35,8 @@ import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runBlocking
import logcat.LogPriority
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.ZipParameters
import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Response
import rx.Observable
@ -55,11 +58,7 @@ import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.BufferedOutputStream
import java.io.File
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.
@ -585,32 +584,42 @@ class Downloader(
dirname: String,
tmpDir: UniFile,
) {
val zip = mangaDir.createFile("$dirname.cbz$TMP_DIR_SUFFIX")
ZipOutputStream(BufferedOutputStream(zip.openOutputStream())).use { zipOut ->
zipOut.setMethod(ZipEntry.STORED)
// SY -->
val zip = ZipFile("${mangaDir.filePath}/$dirname.cbz$TMP_DIR_SUFFIX")
val zipParameters = ZipParameters()
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)
if (CbzCrypto.getPasswordProtectDlPref() &&
CbzCrypto.isPasswordSet()
) {
CbzCrypto.setZipParametersEncrypted(zipParameters)
zip.setPassword(CbzCrypto.getDecryptedPasswordCbz())
compressedSize = size
setSize(size)
tmpDir.filePath?.let { addPaddingToImage(File(it)) }
}
zipOut.putNextEntry(entry)
zipOut.write(data)
}
}
}
zip.renameTo("$dirname.cbz")
zip.addFiles(
tmpDir.listFiles()?.map { img -> img.filePath?.let { File(it) } },
zipParameters,
)
mangaDir.findFile("$dirname.cbz$TMP_DIR_SUFFIX")?.renameTo("$dirname.cbz")
// SY <--
tmpDir.delete()
}
// SY -->
private fun addPaddingToImage(imageDir: File) {
imageDir.listFiles()
// using ImageUtils isImage and findImageType functions causes IO errors when deleting files to set Exif Metadata
// it should be safe to assume that all files with image extensions are actual images at this point
?.filter {
it.extension.equals("jpg", true) ||
it.extension.equals("jpeg", true) ||
it.extension.equals("png", true) ||
it.extension.equals("webp", true)
}
?.forEach { ImageUtil.addPaddingToImageExif(it) }
}
// SY <--
/**
* Creates a ComicInfo.xml file inside the given directory.
*/

View File

@ -101,7 +101,13 @@ class ChapterLoader(
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
when (format) {
is Format.Directory -> DirectoryPageLoader(format.file)
is Format.Zip -> ZipPageLoader(format.file)
// SY -->
is Format.Zip -> try {
ZipPageLoader(format.file, context)
} catch (e: Throwable) {
error(context.getString(R.string.wrong_cbz_archive_password))
}
// SY <--
is Format.Rar -> try {
RarPageLoader(format.file)
} catch (e: UnsupportedRarV5Exception) {
@ -119,7 +125,13 @@ class ChapterLoader(
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
when (format) {
is Format.Directory -> DirectoryPageLoader(format.file)
is Format.Zip -> ZipPageLoader(format.file)
// SY -->
is Format.Zip -> try {
ZipPageLoader(format.file, context)
} catch (e: Throwable) {
error(context.getString(R.string.wrong_cbz_archive_password))
}
// SY <--
is Format.Rar -> try {
RarPageLoader(format.file)
} catch (e: UnsupportedRarV5Exception) {

View File

@ -49,7 +49,9 @@ class DownloadPageLoader(
}
private suspend fun getPagesFromArchive(chapterPath: UniFile): List<ReaderPage> {
val loader = ZipPageLoader(File(chapterPath.filePath!!)).also { zipPageLoader = it }
// SY -->
val loader = ZipPageLoader(File(chapterPath.filePath!!), context).also { zipPageLoader = it }
// SY <--
return loader.getPages()
}

View File

@ -1,40 +1,69 @@
package eu.kanade.tachiyomi.ui.reader.loader
import android.content.Context
import android.os.Build
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import net.lingala.zip4j.ZipFile
import tachiyomi.core.util.system.ImageUtil
import java.io.File
import java.nio.charset.StandardCharsets
import java.util.zip.ZipFile
/**
* Loader used to load a chapter from a .zip or .cbz file.
*/
class ZipPageLoader(file: File) : PageLoader() {
class ZipPageLoader(
file: File,
// SY -->
context: Context,
// SY <--
) : PageLoader() {
/**
* The zip file to load pages from.
*/
private val zip = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
ZipFile(file, StandardCharsets.ISO_8859_1)
} else {
ZipFile(file)
// SY -->
private var zip4j = ZipFile(file)
init {
if (zip4j.isEncrypted) {
if (!CbzCrypto.checkCbzPassword(zip4j, CbzCrypto.getDecryptedPasswordCbz())) {
this.recycle()
throw Exception(context.getString(R.string.wrong_cbz_archive_password))
}
}
}
private val zip: java.util.zip.ZipFile? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if (!zip4j.isEncrypted) java.util.zip.ZipFile(file, StandardCharsets.ISO_8859_1) else null
} else {
if (!zip4j.isEncrypted) java.util.zip.ZipFile(file) else null
}
// SY <--
/**
* Recycles this loader and the open zip.
*/
override fun recycle() {
super.recycle()
zip.close()
// SY -->
zip4j.close()
zip?.close()
// SY <--
}
/**
* Returns the pages found on this zip archive ordered with a natural comparator.
*/
override suspend fun getPages(): List<ReaderPage> {
// SY -->
// Part can be removed after testing that there are no bugs with zip4j on some users devices
if (zip != null) {
// SY <--
return zip.entries().asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
@ -43,8 +72,27 @@ class ZipPageLoader(file: File) : PageLoader() {
stream = { zip.getInputStream(entry) }
status = Page.State.READY
}
// SY -->
}.toList()
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
zip4j.charset = StandardCharsets.ISO_8859_1
}
.toList()
zip4j.setPassword(CbzCrypto.getDecryptedPasswordCbz())
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 ->
ReaderPage(i).apply {
stream = { zip4j.getInputStream(entry) }
status = Page.State.READY
zip4jFile = zip4j
zip4jEntry = entry
}
}.toList()
}
// SY <--
}
/**

View File

@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.ui.reader.model
import eu.kanade.tachiyomi.source.model.Page
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.FileHeader
import java.io.InputStream
open class ReaderPage(
@ -8,6 +10,9 @@ open class ReaderPage(
url: String = "",
imageUrl: String? = null,
// SY -->
/**zip4j inputStreams do not support mark() and release(), so they must be passed to ImageUtil */
var zip4jFile: ZipFile? = null,
var zip4jEntry: FileHeader? = null,
/** Value to check if this page is used to as if it was too wide */
var shiftedPage: Boolean = false,
/** Value to check if a page is can be doubled up, but can't because the next page is too wide */

View File

@ -168,7 +168,14 @@ class PagerPageHolder(
val (bais, isAnimated, background) = withIOContext {
streamFn().buffered(16).use { stream ->
// SY -->
(if (extraPage != null) streamFn2?.invoke()?.buffered(16) else null).use { stream2 ->
(
if (extraPage != null) {
streamFn2?.invoke()
?.buffered(16)
} else {
null
}
).use { stream2 ->
if (viewer.config.dualPageSplit) {
process(item.first, stream)
} else {
@ -222,7 +229,13 @@ class PagerPageHolder(
return splitInHalf(imageStream)
}
val isDoublePage = ImageUtil.isWideImage(imageStream)
val isDoublePage = ImageUtil.isWideImage(
imageStream,
// SY -->
page.zip4jFile,
page.zip4jEntry,
// SY <--
)
if (!isDoublePage) {
return imageStream
}
@ -247,7 +260,13 @@ class PagerPageHolder(
if (imageStream2 == null) {
return if (imageStream is BufferedInputStream &&
!ImageUtil.isAnimatedAndSupported(imageStream) &&
ImageUtil.isWideImage(imageStream) &&
ImageUtil.isWideImage(
imageStream,
// SY -->
page.zip4jFile,
page.zip4jEntry,
// SY <--
) &&
viewer.config.centerMarginType and PagerConfig.CenterMarginType.WIDE_PAGE_CENTER_MARGIN > 0 &&
!viewer.config.imageCropBorders
) {

View File

@ -213,7 +213,13 @@ class WebtoonPageHolder(
private fun process(imageStream: BufferedInputStream): InputStream {
if (viewer.config.dualPageSplit) {
val isDoublePage = ImageUtil.isWideImage(imageStream)
val isDoublePage = ImageUtil.isWideImage(
imageStream,
// SY -->
page?.zip4jFile,
page?.zip4jEntry,
// SY <--
)
if (isDoublePage) {
val upperSide = if (viewer.config.dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT
return ImageUtil.splitAndMerge(imageStream, upperSide)
@ -224,7 +230,13 @@ class WebtoonPageHolder(
if (page is StencilPage) {
return imageStream
}
val isStripSplitNeeded = ImageUtil.isStripSplitNeeded(imageStream)
val isStripSplitNeeded = ImageUtil.isStripSplitNeeded(
imageStream,
// SY -->
page?.zip4jFile,
page?.zip4jEntry,
// SY <--
)
if (isStripSplitNeeded) {
return onStripSplit(imageStream)
}
@ -237,7 +249,14 @@ class WebtoonPageHolder(
// If we have reached this point [page] and its stream shouldn't be null
val page = page!!
val stream = page.stream!!
val splitData = ImageUtil.getSplitDataForStream(imageStream).toMutableList()
val splitData = ImageUtil.getSplitDataForStream(
imageStream,
// SY -->
page.zip4jFile,
page.zip4jEntry,
// SY <--
).toMutableList()
val currentSplitData = splitData.removeFirst()
val newPages = splitData.map {
StencilPage(page) { ImageUtil.splitStrip(it, stream) }

View File

@ -56,6 +56,9 @@ data class ComicInfo(
val tags: Tags?,
val web: Web?,
val publishingStatus: PublishingStatusTachiyomi?,
// SY -->
val padding: PaddingTachiyomiSY?,
// SY <--
) {
@Suppress("UNUSED")
@XmlElement(false)
@ -123,6 +126,12 @@ data class ComicInfo(
@Serializable
@XmlSerialName("PublishingStatusTachiyomi", "http://www.w3.org/2001/XMLSchema", "ty")
data class PublishingStatusTachiyomi(@XmlValue(true) val value: String = "")
// SY -->
@Serializable
@XmlSerialName("PaddingTachiyomiSY", "http://www.w3.org/2001/XMLSchema", "tysy")
data class PaddingTachiyomiSY(@XmlValue(true) val value: String = "")
// SY <--
}
enum class ComicInfoPublishingStatus(

View File

@ -48,5 +48,8 @@ dependencies {
// SY -->
implementation(sylibs.xlog)
implementation(libs.zip4j)
implementation(libs.injekt.core)
implementation(libs.exifinterface)
// SY <--
}

View File

@ -1,11 +1,13 @@
package eu.kanade.tachiyomi.core.security
import android.content.Context
import eu.kanade.tachiyomi.core.R
import tachiyomi.core.preference.PreferenceStore
import tachiyomi.core.preference.getEnum
class SecurityPreferences(
private val preferenceStore: PreferenceStore,
private val context: Context,
) {
fun useAuthenticator() = preferenceStore.getBoolean("use_biometric_lock", false)
@ -20,6 +22,19 @@ class SecurityPreferences(
fun authenticatorTimeRanges() = this.preferenceStore.getStringSet("biometric_time_ranges", mutableSetOf())
fun authenticatorDays() = this.preferenceStore.getInt("biometric_days", 0x7F)
fun encryptDatabase() = this.preferenceStore.getBoolean("encrypt_database", !context.getDatabasePath("tachiyomi.db").exists())
fun sqlPassword() = this.preferenceStore.getString("sql_password", "")
fun passwordProtectDownloads() = preferenceStore.getBoolean("password_protect_downloads", false)
fun encryptionType() = this.preferenceStore.getEnum("encryption_type", EncryptionType.AES_256)
fun cbzPassword() = this.preferenceStore.getString("cbz_password", "")
fun localCoverLocation() = this.preferenceStore.getEnum("local_cover_location", CoverCacheLocation.IN_MANGA_DIRECTORY)
// SY <--
/**
@ -33,4 +48,19 @@ class SecurityPreferences(
INCOGNITO(R.string.pref_incognito_mode),
NEVER(R.string.lock_never),
}
// SY -->
enum class EncryptionType(val titleResId: Int) {
AES_256(R.string.aes_256),
AES_192(R.string.aes_192),
AES_128(R.string.aes_128),
ZIP_STANDARD(R.string.standard_zip_encryption),
}
enum class CoverCacheLocation(val titleResId: Int) {
IN_MANGA_DIRECTORY(R.string.save_in_manga_directory),
INTERNAL(R.string.save_internally),
NEVER(R.string.save_never),
}
// SY <--
}

View File

@ -0,0 +1,246 @@
package eu.kanade.tachiyomi.util.storage
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import eu.kanade.tachiyomi.core.R
import eu.kanade.tachiyomi.core.security.SecurityPreferences
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.model.ZipParameters
import net.lingala.zip4j.model.enums.AesKeyStrength
import net.lingala.zip4j.model.enums.EncryptionMethod
import tachiyomi.core.util.system.logcat
import uy.kohesive.injekt.injectLazy
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.security.KeyStore
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
// SY -->
/**
* object used to En/Decrypt and Base64 en/decode
* passwords before storing
* them in Shared Preferences
*/
object CbzCrypto {
const val DATABASE_NAME = "tachiyomiEncrypted.db"
private val securityPreferences: SecurityPreferences by injectLazy()
private val keyStore = KeyStore.getInstance(KEYSTORE).apply {
load(null)
}
private val encryptionCipherCbz
get() = Cipher.getInstance(CRYPTO_SETTINGS).apply {
init(
Cipher.ENCRYPT_MODE,
getKey(ALIAS_CBZ),
)
}
private val encryptionCipherSql
get() = Cipher.getInstance(CRYPTO_SETTINGS).apply {
init(
Cipher.ENCRYPT_MODE,
getKey(ALIAS_SQL),
)
}
private fun getDecryptCipher(iv: ByteArray, alias: String): Cipher {
return Cipher.getInstance(CRYPTO_SETTINGS).apply {
init(
Cipher.DECRYPT_MODE,
getKey(alias),
IvParameterSpec(iv),
)
}
}
private fun getKey(alias: String): SecretKey {
val loadedKey = keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry
return loadedKey?.secretKey ?: generateKey(alias)
}
private fun generateKey(alias: String): SecretKey {
return KeyGenerator.getInstance(ALGORITHM).apply {
init(
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setKeySize(KEY_SIZE)
.setBlockModes(BLOCK_MODE)
.setEncryptionPaddings(PADDING)
.setRandomizedEncryptionRequired(true)
.setUserAuthenticationRequired(false)
.build(),
)
}.generateKey()
}
private fun encrypt(password: String, cipher: Cipher): String {
val outputStream = ByteArrayOutputStream()
outputStream.use { output ->
output.write(cipher.iv)
ByteArrayInputStream(password.toByteArray()).use { input ->
val buffer = ByteArray(BUFFER_SIZE)
while (input.available() > BUFFER_SIZE) {
input.read(buffer)
output.write(cipher.update(buffer))
}
output.write(cipher.doFinal(input.readBytes()))
}
}
return Base64.encodeToString(outputStream.toByteArray(), Base64.DEFAULT)
}
private fun decrypt(encryptedPassword: String, alias: String): String {
val inputStream = Base64.decode(encryptedPassword, Base64.DEFAULT).inputStream()
return inputStream.use { input ->
val iv = ByteArray(IV_SIZE)
input.read(iv)
val cipher = getDecryptCipher(iv, alias)
ByteArrayOutputStream().use { output ->
val buffer = ByteArray(BUFFER_SIZE)
while (inputStream.available() > BUFFER_SIZE) {
inputStream.read(buffer)
output.write(cipher.update(buffer))
}
output.write(cipher.doFinal(inputStream.readBytes()))
output.toString()
}
}
}
fun deleteKeyCbz() {
keyStore.deleteEntry(ALIAS_CBZ)
generateKey(ALIAS_CBZ)
}
fun encryptCbz(password: String): String {
return encrypt(password, encryptionCipherCbz)
}
fun getDecryptedPasswordCbz(): CharArray {
return decrypt(securityPreferences.cbzPassword().get(), ALIAS_CBZ).toCharArray()
}
private fun generateAndEncryptSqlPw() {
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
val password = (1..32).map {
charPool[SecureRandom().nextInt(charPool.size)]
}.joinToString("", transform = { it.toString() })
securityPreferences.sqlPassword().set(encrypt(password, encryptionCipherSql))
}
fun getDecryptedPasswordSql(): ByteArray {
if (securityPreferences.sqlPassword().get().isBlank()) generateAndEncryptSqlPw()
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()
}
fun isPasswordSetState(scope: CoroutineScope): StateFlow<Boolean> {
return securityPreferences.cbzPassword().changes()
.map { it.isNotEmpty() }
.stateIn(scope, SharingStarted.Eagerly, false)
}
fun getPasswordProtectDlPref(): Boolean {
return securityPreferences.passwordProtectDownloads().get()
}
fun createComicInfoPadding(): String? {
return if (getPasswordProtectDlPref()) {
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
List(SecureRandom().nextInt(100) + 42) { charPool.random() }.joinToString("")
} else {
null
}
}
fun setZipParametersEncrypted(zipParameters: ZipParameters) {
zipParameters.isEncryptFiles = true
when (securityPreferences.encryptionType().get()) {
SecurityPreferences.EncryptionType.AES_256 -> {
zipParameters.encryptionMethod = EncryptionMethod.AES
zipParameters.aesKeyStrength = AesKeyStrength.KEY_STRENGTH_256
}
SecurityPreferences.EncryptionType.AES_192 -> {
zipParameters.encryptionMethod = EncryptionMethod.AES
zipParameters.aesKeyStrength = AesKeyStrength.KEY_STRENGTH_192
}
SecurityPreferences.EncryptionType.AES_128 -> {
zipParameters.encryptionMethod = EncryptionMethod.AES
zipParameters.aesKeyStrength = AesKeyStrength.KEY_STRENGTH_128
}
SecurityPreferences.EncryptionType.ZIP_STANDARD -> {
zipParameters.encryptionMethod = EncryptionMethod.ZIP_STANDARD
}
}
}
fun deleteLocalCoverCache(context: Context) {
if (context.getExternalFilesDir(LOCAL_CACHE_DIR)?.exists() == true) {
context.getExternalFilesDir(LOCAL_CACHE_DIR)?.deleteRecursively()
}
}
fun deleteLocalCoverSystemFiles(context: Context) {
val baseFolderLocation = "${context.getString(R.string.app_name)}${File.separator}local"
DiskUtil.getExternalStorages(context)
.map { File(it.absolutePath, baseFolderLocation) }
.asSequence()
.flatMap { it.listFiles().orEmpty().toList() }
.filter { it.isDirectory }
.flatMap { it.listFiles().orEmpty().toList() }
.filter { it.name == ".cacheCoverInternal" || it.name == ".nocover" }
.forEach { it.delete() }
}
}
private const val BUFFER_SIZE = 2048
private const val KEY_SIZE = 256
private const val IV_SIZE = 16
private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC
private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7
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 LOCAL_CACHE_DIR = "covers/local"
// SY <--

View File

@ -23,15 +23,20 @@ import androidx.core.graphics.createBitmap
import androidx.core.graphics.get
import androidx.core.graphics.green
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
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream
import java.net.URLConnection
import java.security.SecureRandom
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@ -122,8 +127,18 @@ object ImageUtil {
*
* @return true if the width is greater than the height
*/
fun isWideImage(imageStream: BufferedInputStream): Boolean {
val options = extractImageOptions(imageStream)
fun isWideImage(
imageStream: BufferedInputStream,
zip4jFile: ZipFile? = null,
zip4jEntry: FileHeader? = null,
): Boolean {
val options = extractImageOptions(
imageStream,
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
)
return options.outWidth > options.outHeight
}
@ -248,16 +263,45 @@ object ImageUtil {
*
* @return true if the height:width ratio is greater than 3.
*/
private fun isTallImage(imageStream: InputStream): Boolean {
val options = extractImageOptions(imageStream, resetAfterExtraction = false)
private fun isTallImage(
imageStream: InputStream,
// SY -->
zip4jFile: ZipFile? = null,
zip4jEntry: FileHeader? = null,
// SY <--
): Boolean {
val options = extractImageOptions(
imageStream,
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
resetAfterExtraction = false,
)
return (options.outHeight / options.outWidth) > 3
}
/**
* Splits tall images to improve performance of reader
*/
fun splitTallImage(tmpDir: UniFile, imageFile: UniFile, filenamePrefix: String): Boolean {
if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(imageFile.openInputStream())) {
fun splitTallImage(
tmpDir: UniFile,
imageFile: UniFile,
filenamePrefix: String,
// SY -->
zip4jFile: ZipFile? = null,
zip4jEntry: FileHeader? = null,
// SY <--
): Boolean {
if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(
imageFile.openInputStream(),
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
)
) {
return true
}
@ -267,7 +311,14 @@ object ImageUtil {
return false
}
val options = extractImageOptions(imageFile.openInputStream(), resetAfterExtraction = false).apply {
val options = extractImageOptions(
imageFile.openInputStream(),
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
resetAfterExtraction = false,
).apply {
inJustDecodeBounds = false
}
@ -313,10 +364,22 @@ object ImageUtil {
* Check whether the image is a long Strip that needs splitting
* @return true if the image is not animated and it's height is greater than image width and screen height
*/
fun isStripSplitNeeded(imageStream: BufferedInputStream): Boolean {
fun isStripSplitNeeded(
imageStream: BufferedInputStream,
// SY -->
zip4jFile: ZipFile? = null,
zip4jEntry: FileHeader? = null,
// SY <--
): Boolean {
if (isAnimatedAndSupported(imageStream)) return false
val options = extractImageOptions(imageStream)
val options = extractImageOptions(
imageStream,
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
)
val imageHeightIsBiggerThanWidth = options.outHeight > options.outWidth
val imageHeightBiggerThanScreenHeight = options.outHeight > optimalImageHeight
return imageHeightIsBiggerThanWidth && imageHeightBiggerThanScreenHeight
@ -348,8 +411,21 @@ object ImageUtil {
}
}
fun getSplitDataForStream(imageStream: InputStream): List<SplitData> {
return extractImageOptions(imageStream).splitData
fun getSplitDataForStream(
imageStream: InputStream,
// SY -->
zip4jFile: ZipFile? = null,
zip4jEntry: FileHeader? = null,
// SY <--
): List<SplitData> {
// SY -->
return extractImageOptions(
imageStream,
zip4jFile,
zip4jEntry,
).splitData
// <--
}
private val BitmapFactory.Options.splitData
@ -614,8 +690,17 @@ object ImageUtil {
*/
private fun extractImageOptions(
imageStream: InputStream,
// SY -->
zip4jFile: ZipFile? = null,
zip4jEntry: FileHeader? = null,
// 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(imageStream.available() + 1)
val imageBytes = imageStream.readBytes()
@ -625,6 +710,33 @@ 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
*/
fun addPaddingToImageExif(imageFile: File) {
try {
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
val padding = List(SecureRandom().nextInt(16384) + 16384) { charPool.random() }.joinToString("")
val exif = ExifInterface(imageFile.absolutePath)
exif.setAttribute("UserComment", padding)
exif.saveAttributes()
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
}
}
// SY <--
private fun getBitmapRegionDecoder(imageStream: InputStream): BitmapRegionDecoder? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(imageStream)

View File

@ -33,10 +33,12 @@ jsoup = "org.jsoup:jsoup:1.15.4"
disklrucache = "com.jakewharton:disklrucache:2.0.2"
unifile = "com.github.tachiyomiorg:unifile:17bec43"
junrar = "com.github.junrar:junrar:7.5.4"
zip4j = "net.lingala.zip4j:zip4j:2.11.5"
sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqlite" }
sqlite-ktx = { module = "androidx.sqlite:sqlite-ktx", version.ref = "sqlite" }
sqlite-android = "com.github.requery:sqlite-android:3.41.1"
sqlcipher = "net.zetetic:sqlcipher-android:4.5.4"
preferencektx = "androidx.preference:preference-ktx:1.2.0"
@ -48,6 +50,7 @@ coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil_version
subsamplingscaleimageview = "com.github.tachiyomiorg:subsampling-scale-image-view:c8e2650"
image-decoder = "com.github.tachiyomiorg:image-decoder:7879b45"
exifinterface = "androidx.exifinterface:exifinterface:1.3.6"
natural-comparator = "com.github.gpanther:java-nat-sort:natural-comparator-1.1"

View File

@ -220,6 +220,28 @@
<string name="thursday">Thursday</string>
<string name="friday">Friday</string>
<string name="saturday">Saturday</string>
<string name="encrypt_database">Encrypt database</string>
<string name="encrypt_database_subtitle">Requires app restart to take effect \nClearing app data and loading a backup is highly recommended</string>
<string name="set_cbz_zip_password">Set CBZ archive password</string>
<string name="password_protect_downloads">Password protect downloads</string>
<string name="password_protect_downloads_summary">Encrypts CBZ archive downloads with the given password.\nWARNING: DATA INSIDE THE ARCHIVES WILL BE LOST FOREVER IF YOU FORGET THE PASSWORD</string>
<string name="delete_cbz_archive_password">Delete CBZ archive password</string>
<string name="save_in_manga_directory">In manga directory</string>
<string name="save_internally">Internally</string>
<string name="save_never">Never (local source manga won\'t have covers)</string>
<string name="save_local_manga_covers">Save local manga covers</string>
<string name="delete_cached_local_source_covers">Delete cached local source covers</string>
<string name="delete_cached_local_source_covers_subtitle">Internally cached local source manga covers are NOT deleted automatically Please delete them here then open the local source extension to generate them again</string>
<string name="successfully_deleted_all_locally_cached_covers">Successfully deleted all locally cached covers</string>
<string name="something_went_wrong_deleting_your_cover_images">Something went wrong deleting your cover images: </string>
<string name="cbz_archive_password">CBZ archive password</string>
<string name="wrong_cbz_archive_password">Wrong CBZ archive password</string>
<string name="encryption_type">Encryption type</string>
<string name="aes_256">AES 256</string>
<string name="aes_192">AES 192</string>
<string name="aes_128">AES 128</string>
<string name="standard_zip_encryption">Standard zip encryption (fast but insecure)</string>
<!-- Reader Settings -->
<string name="page_downloading">Page Downloading</string>

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 {

View File

@ -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,12 +295,28 @@ actual class LocalSource(
}
private fun copyComicInfoFile(comicInfoFileStream: InputStream, folderPath: String?): File {
// 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) }
}
}
}
}
private fun setMangaDetailsFromComicInfoFile(stream: InputStream, manga: SManga) {
val comicInfo = AndroidXmlReader(stream, StandardCharsets.UTF_8.name()).use {
@ -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
}
}

View File

@ -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 {
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()
}
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
}
// SY <--
}
}