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

View File

@ -118,6 +118,15 @@
# XmlUtil # XmlUtil
-keep public enum nl.adaptivity.xmlutil.EventType { *; } -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 # Design library
-dontwarn com.google.android.material.** -dontwarn com.google.android.material.**
-keep class 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.source.model.SManga
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType 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.ComicInfo
import tachiyomi.core.metadata.comicinfo.ComicInfoPublishingStatus import tachiyomi.core.metadata.comicinfo.ComicInfoPublishingStatus
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
@ -115,6 +116,9 @@ fun getComicInfo(manga: Manga, chapter: Chapter, chapterUrl: String) = ComicInfo
publishingStatus = ComicInfo.PublishingStatusTachiyomi( publishingStatus = ComicInfo.PublishingStatusTachiyomi(
ComicInfoPublishingStatus.toComicInfoValue(manga.status), ComicInfoPublishingStatus.toComicInfoValue(manga.status),
), ),
// SY -->
padding = CbzCrypto.createComicInfoPadding()?.let { ComicInfo.PaddingTachiyomiSY(it) },
// SY <--
inker = null, inker = null,
colorist = null, colorist = null,
letterer = null, letterer = null,

View File

@ -1,28 +1,48 @@
package eu.kanade.presentation.more.settings.screen package eu.kanade.presentation.more.settings.screen
import android.widget.Toast
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn 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.AlertDialog
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember 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.setValue
import androidx.compose.runtime.toMutableStateList import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource 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.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.fragment.app.FragmentActivity 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.core.security.SecurityPreferences
import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate
import eu.kanade.tachiyomi.ui.category.biometric.BiometricTimesScreen 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.authenticate
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.isAuthenticationSupported 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.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.File
object SettingsSecurityScreen : SearchableSettings { object SettingsSecurityScreen : SearchableSettings {
@ -56,6 +82,10 @@ object SettingsSecurityScreen : SearchableSettings {
val useAuth by useAuthPref.collectAsState() val useAuth by useAuthPref.collectAsState()
val scope = rememberCoroutineScope()
val isCbzPasswordSet by remember { CbzCrypto.isPasswordSetState(scope) }.collectAsState()
val passwordProtectDownloads by securityPreferences.passwordProtectDownloads().collectAsState()
return listOf( return listOf(
Preference.PreferenceItem.SwitchPreference( Preference.PreferenceItem.SwitchPreference(
pref = useAuthPref, pref = useAuthPref,
@ -96,6 +126,92 @@ object SettingsSecurityScreen : SearchableSettings {
.associateWith { stringResource(it.titleResId) }, .associateWith { stringResource(it.titleResId) },
), ),
// SY --> // 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 { kotlin.run {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val count by securityPreferences.authenticatorTimeRanges().collectAsState() 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 <-- // SY <--
} }

View File

@ -27,11 +27,13 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.NetworkPreferences import eu.kanade.tachiyomi.network.NetworkPreferences
import eu.kanade.tachiyomi.source.AndroidSourceManager import eu.kanade.tachiyomi.source.AndroidSourceManager
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import eu.kanade.tachiyomi.util.system.isDevFlavor import eu.kanade.tachiyomi.util.system.isDevFlavor
import exh.eh.EHentaiUpdateHelper import exh.eh.EHentaiUpdateHelper
import exh.pref.DelegateSourcePreferences import exh.pref.DelegateSourcePreferences
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
import nl.adaptivity.xmlutil.XmlDeclMode import nl.adaptivity.xmlutil.XmlDeclMode
import nl.adaptivity.xmlutil.core.XmlVersion import nl.adaptivity.xmlutil.core.XmlVersion
import nl.adaptivity.xmlutil.serialization.UnknownChildHandler 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.addSingleton
import uy.kohesive.injekt.api.addSingletonFactory import uy.kohesive.injekt.api.addSingletonFactory
import uy.kohesive.injekt.api.get 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 { class AppModule(val app: Application) : InjektModule {
// SY -->
private val securityPreferences: SecurityPreferences by injectLazy()
// SY <--
override fun InjektRegistrar.registerInjectables() { override fun InjektRegistrar.registerInjectables() {
addSingleton(app) addSingleton(app)
addSingletonFactory<SqlDriver> { addSingletonFactory<SqlDriver> {
// SY -->
System.loadLibrary("sqlcipher")
// SY <--
AndroidSqliteDriver( AndroidSqliteDriver(
schema = Database.Schema, schema = Database.Schema,
context = app, context = app,
name = "tachiyomi.db", // SY -->
factory = if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 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 // Support database inspector in Android Studio
FrameworkSQLiteOpenHelperFactory() FrameworkSQLiteOpenHelperFactory()
} else { } else {
RequerySQLiteOpenHelperFactory() RequerySQLiteOpenHelperFactory()
}, },
// SY <--
callback = object : AndroidSqliteDriver.Callback(Database.Schema) { callback = object : AndroidSqliteDriver.Callback(Database.Schema) {
override fun onOpen(db: SupportSQLiteDatabase) { override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db) super.onOpen(db)
@ -193,7 +214,7 @@ class PreferenceModule(val application: Application) : InjektModule {
SourcePreferences(get()) SourcePreferences(get())
} }
addSingletonFactory { addSingletonFactory {
SecurityPreferences(get()) SecurityPreferences(get(), application.applicationContext)
} }
addSingletonFactory { addSingletonFactory {
LibraryPreferences(get()) 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.UnmeteredSource
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource 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
import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE
import eu.kanade.tachiyomi.util.storage.saveTo import eu.kanade.tachiyomi.util.storage.saveTo
@ -34,6 +35,8 @@ import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import logcat.LogPriority import logcat.LogPriority
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.ZipParameters
import nl.adaptivity.xmlutil.serialization.XML import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Response import okhttp3.Response
import rx.Observable import rx.Observable
@ -55,11 +58,7 @@ import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.source.service.SourceManager
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.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.
@ -585,32 +584,42 @@ class Downloader(
dirname: String, dirname: String,
tmpDir: UniFile, tmpDir: UniFile,
) { ) {
val zip = mangaDir.createFile("$dirname.cbz$TMP_DIR_SUFFIX") // SY -->
ZipOutputStream(BufferedOutputStream(zip.openOutputStream())).use { zipOut -> val zip = ZipFile("${mangaDir.filePath}/$dirname.cbz$TMP_DIR_SUFFIX")
zipOut.setMethod(ZipEntry.STORED) val zipParameters = ZipParameters()
tmpDir.listFiles()?.forEach { img -> if (CbzCrypto.getPasswordProtectDlPref() &&
img.openInputStream().use { input -> CbzCrypto.isPasswordSet()
val data = input.readBytes() ) {
val size = img.length() CbzCrypto.setZipParametersEncrypted(zipParameters)
val entry = ZipEntry(img.name).apply { zip.setPassword(CbzCrypto.getDecryptedPasswordCbz())
val crc = CRC32().apply {
update(data)
}
setCrc(crc.value)
compressedSize = size tmpDir.filePath?.let { addPaddingToImage(File(it)) }
setSize(size)
} }
zipOut.putNextEntry(entry) zip.addFiles(
zipOut.write(data) tmpDir.listFiles()?.map { img -> img.filePath?.let { File(it) } },
} zipParameters,
} )
} mangaDir.findFile("$dirname.cbz$TMP_DIR_SUFFIX")?.renameTo("$dirname.cbz")
zip.renameTo("$dirname.cbz") // SY <--
tmpDir.delete() 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. * 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 -> 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) // 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 { is Format.Rar -> try {
RarPageLoader(format.file) RarPageLoader(format.file)
} catch (e: UnsupportedRarV5Exception) { } catch (e: UnsupportedRarV5Exception) {
@ -119,7 +125,13 @@ 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) // 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 { is Format.Rar -> try {
RarPageLoader(format.file) RarPageLoader(format.file)
} catch (e: UnsupportedRarV5Exception) { } catch (e: UnsupportedRarV5Exception) {

View File

@ -49,7 +49,9 @@ class DownloadPageLoader(
} }
private suspend fun getPagesFromArchive(chapterPath: UniFile): List<ReaderPage> { 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() return loader.getPages()
} }

View File

@ -1,40 +1,69 @@
package eu.kanade.tachiyomi.ui.reader.loader package eu.kanade.tachiyomi.ui.reader.loader
import android.content.Context
import android.os.Build import android.os.Build
import eu.kanade.tachiyomi.R
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.lang.compareToCaseInsensitiveNaturalOrder 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 tachiyomi.core.util.system.ImageUtil
import java.io.File import java.io.File
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.zip.ZipFile
/** /**
* Loader used to load a chapter from a .zip or .cbz file. * 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. * The zip file to load pages from.
*/ */
private val zip = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // SY -->
ZipFile(file, StandardCharsets.ISO_8859_1) private var zip4j = ZipFile(file)
} else {
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. * Recycles this loader and the open zip.
*/ */
override fun recycle() { override fun recycle() {
super.recycle() super.recycle()
zip.close() // SY -->
zip4j.close()
zip?.close()
// SY <--
} }
/** /**
* Returns the pages found on this zip archive ordered with a natural comparator. * Returns the pages found on this zip archive ordered with a natural comparator.
*/ */
override suspend fun getPages(): List<ReaderPage> { 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() return zip.entries().asSequence()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } .filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
@ -43,8 +72,27 @@ class ZipPageLoader(file: File) : PageLoader() {
stream = { zip.getInputStream(entry) } stream = { zip.getInputStream(entry) }
status = Page.State.READY 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 package eu.kanade.tachiyomi.ui.reader.model
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.FileHeader
import java.io.InputStream import java.io.InputStream
open class ReaderPage( open class ReaderPage(
@ -8,6 +10,9 @@ open class ReaderPage(
url: String = "", url: String = "",
imageUrl: String? = null, imageUrl: String? = null,
// SY --> // 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 */ /** Value to check if this page is used to as if it was too wide */
var shiftedPage: Boolean = false, 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 */ /** 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 { val (bais, isAnimated, background) = withIOContext {
streamFn().buffered(16).use { stream -> streamFn().buffered(16).use { stream ->
// SY --> // 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) { if (viewer.config.dualPageSplit) {
process(item.first, stream) process(item.first, stream)
} else { } else {
@ -222,7 +229,13 @@ class PagerPageHolder(
return splitInHalf(imageStream) return splitInHalf(imageStream)
} }
val isDoublePage = ImageUtil.isWideImage(imageStream) val isDoublePage = ImageUtil.isWideImage(
imageStream,
// SY -->
page.zip4jFile,
page.zip4jEntry,
// SY <--
)
if (!isDoublePage) { if (!isDoublePage) {
return imageStream return imageStream
} }
@ -247,7 +260,13 @@ class PagerPageHolder(
if (imageStream2 == null) { if (imageStream2 == null) {
return if (imageStream is BufferedInputStream && return if (imageStream is BufferedInputStream &&
!ImageUtil.isAnimatedAndSupported(imageStream) && !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.centerMarginType and PagerConfig.CenterMarginType.WIDE_PAGE_CENTER_MARGIN > 0 &&
!viewer.config.imageCropBorders !viewer.config.imageCropBorders
) { ) {

View File

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

View File

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

View File

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

View File

@ -1,11 +1,13 @@
package eu.kanade.tachiyomi.core.security package eu.kanade.tachiyomi.core.security
import android.content.Context
import eu.kanade.tachiyomi.core.R import eu.kanade.tachiyomi.core.R
import tachiyomi.core.preference.PreferenceStore import tachiyomi.core.preference.PreferenceStore
import tachiyomi.core.preference.getEnum import tachiyomi.core.preference.getEnum
class SecurityPreferences( class SecurityPreferences(
private val preferenceStore: PreferenceStore, private val preferenceStore: PreferenceStore,
private val context: Context,
) { ) {
fun useAuthenticator() = preferenceStore.getBoolean("use_biometric_lock", false) fun useAuthenticator() = preferenceStore.getBoolean("use_biometric_lock", false)
@ -20,6 +22,19 @@ class SecurityPreferences(
fun authenticatorTimeRanges() = this.preferenceStore.getStringSet("biometric_time_ranges", mutableSetOf()) fun authenticatorTimeRanges() = this.preferenceStore.getStringSet("biometric_time_ranges", mutableSetOf())
fun authenticatorDays() = this.preferenceStore.getInt("biometric_days", 0x7F) 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 <-- // SY <--
/** /**
@ -33,4 +48,19 @@ class SecurityPreferences(
INCOGNITO(R.string.pref_incognito_mode), INCOGNITO(R.string.pref_incognito_mode),
NEVER(R.string.lock_never), 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.get
import androidx.core.graphics.green import androidx.core.graphics.green
import androidx.core.graphics.red import androidx.core.graphics.red
import androidx.exifinterface.media.ExifInterface
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import logcat.LogPriority import logcat.LogPriority
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.FileHeader
import tachiyomi.decoder.Format import tachiyomi.decoder.Format
import tachiyomi.decoder.ImageDecoder import tachiyomi.decoder.ImageDecoder
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream import java.io.InputStream
import java.net.URLConnection import java.net.URLConnection
import java.security.SecureRandom
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@ -122,8 +127,18 @@ object ImageUtil {
* *
* @return true if the width is greater than the height * @return true if the width is greater than the height
*/ */
fun isWideImage(imageStream: BufferedInputStream): Boolean { fun isWideImage(
val options = extractImageOptions(imageStream) imageStream: BufferedInputStream,
zip4jFile: ZipFile? = null,
zip4jEntry: FileHeader? = null,
): Boolean {
val options = extractImageOptions(
imageStream,
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
)
return options.outWidth > options.outHeight return options.outWidth > options.outHeight
} }
@ -248,16 +263,45 @@ object ImageUtil {
* *
* @return true if the height:width ratio is greater than 3. * @return true if the height:width ratio is greater than 3.
*/ */
private fun isTallImage(imageStream: InputStream): Boolean { private fun isTallImage(
val options = extractImageOptions(imageStream, resetAfterExtraction = false) 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 return (options.outHeight / options.outWidth) > 3
} }
/** /**
* Splits tall images to improve performance of reader * Splits tall images to improve performance of reader
*/ */
fun splitTallImage(tmpDir: UniFile, imageFile: UniFile, filenamePrefix: String): Boolean { fun splitTallImage(
if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(imageFile.openInputStream())) { 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 return true
} }
@ -267,7 +311,14 @@ object ImageUtil {
return false return false
} }
val options = extractImageOptions(imageFile.openInputStream(), resetAfterExtraction = false).apply { val options = extractImageOptions(
imageFile.openInputStream(),
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
resetAfterExtraction = false,
).apply {
inJustDecodeBounds = false inJustDecodeBounds = false
} }
@ -313,10 +364,22 @@ object ImageUtil {
* Check whether the image is a long Strip that needs splitting * 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 * @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 if (isAnimatedAndSupported(imageStream)) return false
val options = extractImageOptions(imageStream) val options = extractImageOptions(
imageStream,
// SY -->
zip4jFile,
zip4jEntry,
// SY <--
)
val imageHeightIsBiggerThanWidth = options.outHeight > options.outWidth val imageHeightIsBiggerThanWidth = options.outHeight > options.outWidth
val imageHeightBiggerThanScreenHeight = options.outHeight > optimalImageHeight val imageHeightBiggerThanScreenHeight = options.outHeight > optimalImageHeight
return imageHeightIsBiggerThanWidth && imageHeightBiggerThanScreenHeight return imageHeightIsBiggerThanWidth && imageHeightBiggerThanScreenHeight
@ -348,8 +411,21 @@ object ImageUtil {
} }
} }
fun getSplitDataForStream(imageStream: InputStream): List<SplitData> { fun getSplitDataForStream(
return extractImageOptions(imageStream).splitData imageStream: InputStream,
// SY -->
zip4jFile: ZipFile? = null,
zip4jEntry: FileHeader? = null,
// SY <--
): List<SplitData> {
// SY -->
return extractImageOptions(
imageStream,
zip4jFile,
zip4jEntry,
).splitData
// <--
} }
private val BitmapFactory.Options.splitData private val BitmapFactory.Options.splitData
@ -614,8 +690,17 @@ object ImageUtil {
*/ */
private fun extractImageOptions( private fun extractImageOptions(
imageStream: InputStream, imageStream: InputStream,
// SY -->
zip4jFile: ZipFile? = null,
zip4jEntry: FileHeader? = null,
// SY <--
resetAfterExtraction: Boolean = true, resetAfterExtraction: Boolean = true,
): BitmapFactory.Options { ): 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) imageStream.mark(imageStream.available() + 1)
val imageBytes = imageStream.readBytes() val imageBytes = imageStream.readBytes()
@ -625,6 +710,33 @@ object ImageUtil {
return options 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? { private fun getBitmapRegionDecoder(imageStream: InputStream): BitmapRegionDecoder? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(imageStream) BitmapRegionDecoder.newInstance(imageStream)

View File

@ -33,10 +33,12 @@ jsoup = "org.jsoup:jsoup:1.15.4"
disklrucache = "com.jakewharton:disklrucache:2.0.2" disklrucache = "com.jakewharton:disklrucache:2.0.2"
unifile = "com.github.tachiyomiorg:unifile:17bec43" unifile = "com.github.tachiyomiorg:unifile:17bec43"
junrar = "com.github.junrar:junrar:7.5.4" 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-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" }
sqlite-android = "com.github.requery:sqlite-android:3.41.1" 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" 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" subsamplingscaleimageview = "com.github.tachiyomiorg:subsampling-scale-image-view:c8e2650"
image-decoder = "com.github.tachiyomiorg:image-decoder:7879b45" 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" 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="thursday">Thursday</string>
<string name="friday">Friday</string> <string name="friday">Friday</string>
<string name="saturday">Saturday</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 --> <!-- Reader Settings -->
<string name="page_downloading">Page Downloading</string> <string name="page_downloading">Page Downloading</string>

View File

@ -11,6 +11,9 @@ kotlin {
implementation(project(":source-api")) implementation(project(":source-api"))
implementation(libs.unifile) implementation(libs.unifile)
implementation(libs.junrar) implementation(libs.junrar)
// SY -->
implementation(libs.zip4j)
// SY <--
} }
} }
val androidMain by getting { 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.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.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream import kotlinx.serialization.json.encodeToStream
import logcat.LogPriority import logcat.LogPriority
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.ZipParameters
import nl.adaptivity.xmlutil.AndroidXmlReader import nl.adaptivity.xmlutil.AndroidXmlReader
import nl.adaptivity.xmlutil.serialization.XML import nl.adaptivity.xmlutil.serialization.XML
import rx.Observable import rx.Observable
@ -39,7 +42,6 @@ import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.InputStream import java.io.InputStream
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.zip.ZipFile
import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.days
import com.github.junrar.Archive as JunrarArchive import com.github.junrar.Archive as JunrarArchive
import tachiyomi.domain.source.model.Source as DomainSource import tachiyomi.domain.source.model.Source as DomainSource
@ -187,6 +189,10 @@ actual class LocalSource(
.firstOrNull { it.name == ".noxml" } .firstOrNull { it.name == ".noxml" }
val legacyJsonDetailsFile = mangaDirFiles val legacyJsonDetailsFile = mangaDirFiles
.firstOrNull { it.extension == "json" } .firstOrNull { it.extension == "json" }
// SY -->
val comicInfoArchiveFile = mangaDirFiles
.firstOrNull { it.name == COMIC_INFO_ARCHIVE }
// SY <--
when { when {
// Top level ComicInfo.xml // Top level ComicInfo.xml
@ -194,6 +200,18 @@ actual class LocalSource(
noXmlFile?.delete() noXmlFile?.delete()
setMangaDetailsFromComicInfoFile(comicInfoFile.inputStream(), manga) 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 // TODO: automatically convert these to ComicInfo.xml
legacyJsonDetailsFile != null -> { legacyJsonDetailsFile != null -> {
@ -217,9 +235,17 @@ actual class LocalSource(
val folderPath = mangaDir?.absolutePath val folderPath = mangaDir?.absolutePath
val copiedFile = copyComicInfoFileFromArchive(chapterArchives, folderPath) val copiedFile = copyComicInfoFileFromArchive(chapterArchives, folderPath)
if (copiedFile != null) { // SY -->
if (copiedFile != null && copiedFile.name != COMIC_INFO_ARCHIVE) {
setMangaDetailsFromComicInfoFile(copiedFile.inputStream(), manga) 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 // Avoid re-scanning
File("$folderPath/.noxml").createNewFile() File("$folderPath/.noxml").createNewFile()
} }
@ -237,7 +263,16 @@ actual class LocalSource(
when (Format.valueOf(chapter)) { when (Format.valueOf(chapter)) {
is Format.Zip -> { is Format.Zip -> {
ZipFile(chapter).use { zip: ZipFile -> 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 -> zip.getInputStream(comicInfoFile).buffered().use { stream ->
return copyComicInfoFile(stream, folderPath) return copyComicInfoFile(stream, folderPath)
} }
@ -260,12 +295,28 @@ actual class LocalSource(
} }
private fun copyComicInfoFile(comicInfoFileStream: InputStream, folderPath: String?): File { 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 { return File("$folderPath/$COMIC_INFO_FILE").apply {
outputStream().use { outputStream -> outputStream().use { outputStream ->
comicInfoFileStream.use { it.copyTo(outputStream) } comicInfoFileStream.use { it.copyTo(outputStream) }
} }
} }
} }
}
private fun setMangaDetailsFromComicInfoFile(stream: InputStream, manga: SManga) { private fun setMangaDetailsFromComicInfoFile(stream: InputStream, manga: SManga) {
val comicInfo = AndroidXmlReader(stream, StandardCharsets.UTF_8.name()).use { val comicInfo = AndroidXmlReader(stream, StandardCharsets.UTF_8.name()).use {
@ -338,9 +389,12 @@ actual class LocalSource(
} }
is Format.Zip -> { is Format.Zip -> {
ZipFile(format.file).use { zip -> ZipFile(format.file).use { zip ->
val entry = zip.entries().toList() // SY -->
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } if (zip.isEncrypted) zip.setPassword(CbzCrypto.getDecryptedPasswordCbz())
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } 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)) } entry?.let { coverManager.update(manga, zip.getInputStream(it)) }
} }
@ -374,6 +428,10 @@ actual class LocalSource(
const val ID = 0L const val ID = 0L
const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/" 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 private val LATEST_THRESHOLD = 7.days.inWholeMilliseconds
} }
} }

View File

@ -2,27 +2,52 @@ 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.core.security.SecurityPreferences
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import tachiyomi.core.util.system.ImageUtil import tachiyomi.core.util.system.ImageUtil
import tachiyomi.source.local.io.LocalSourceFileSystem import tachiyomi.source.local.io.LocalSourceFileSystem
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
private const val DEFAULT_COVER_NAME = "cover.jpg" 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( actual class LocalCoverManager(
private val context: Context, private val context: Context,
private val fileSystem: LocalSourceFileSystem, 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? { actual fun find(mangaUrl: String): File? {
return fileSystem.getFilesInMangaDirectory(mangaUrl) return fileSystem.getFilesInMangaDirectory(mangaUrl)
// Get all file whose names start with 'cover' // 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 // Get the first actual image
.firstOrNull { .firstOrNull {
if (it.name != NO_COVER_FILE && it.name != CACHE_COVER_INTERNAL) {
ImageUtil.isImage(it.name) { it.inputStream() } 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) var targetFile = find(manga.url)
if (targetFile == null) { 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() 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 // It might not exist at this point
targetFile.parentFile?.mkdirs() targetFile.parentFile?.mkdirs()
inputStream.use { input -> inputStream.use { input ->
targetFile.outputStream().use { output -> targetFile.outputStream().use { output ->
input.copyTo(output) input.copyTo(output)
} }
}
DiskUtil.createNoMediaFile(UniFile.fromFile(directory), context) DiskUtil.createNoMediaFile(UniFile.fromFile(directory), context)
manga.thumbnail_url = targetFile.absolutePath manga.thumbnail_url = targetFile.absolutePath
return targetFile 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 <--
} }
} }