diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 44e9ac9ee..88b4ec650 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 309e4a4d1..ec00cf28a 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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.** { *; } diff --git a/app/src/main/java/eu/kanade/domain/manga/model/Manga.kt b/app/src/main/java/eu/kanade/domain/manga/model/Manga.kt index 7f81f7a8b..68550ba20 100644 --- a/app/src/main/java/eu/kanade/domain/manga/model/Manga.kt +++ b/app/src/main/java/eu/kanade/domain/manga/model/Manga.kt @@ -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, diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSecurityScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSecurityScreen.kt index 2c9586b67..cfe76a930 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSecurityScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSecurityScreen.kt @@ -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 <-- } diff --git a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt index 2da136a1f..398ff1098 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt @@ -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 { + // 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()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt index 1d15b7494..6d2d49f63 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt @@ -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) - } - zipOut.putNextEntry(entry) - zipOut.write(data) - } - } + tmpDir.filePath?.let { addPaddingToImage(File(it)) } } - 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. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt index 59730b846..b2a40112b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt @@ -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) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt index a3ff97358..7223f0b4f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt @@ -49,7 +49,9 @@ class DownloadPageLoader( } private suspend fun getPagesFromArchive(chapterPath: UniFile): List { - val loader = ZipPageLoader(File(chapterPath.filePath!!)).also { zipPageLoader = it } + // SY --> + val loader = ZipPageLoader(File(chapterPath.filePath!!), context).also { zipPageLoader = it } + // SY <-- return loader.getPages() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt index 5022fb45e..196bf142b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ZipPageLoader.kt @@ -1,50 +1,98 @@ 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 { - return zip.entries().asSequence() - .filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } - .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } - .mapIndexed { i, entry -> - ReaderPage(i).apply { - stream = { zip.getInputStream(entry) } - status = Page.State.READY - } + // 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) } + .mapIndexed { i, entry -> + ReaderPage(i).apply { + 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 <-- } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt index 0a6f2d414..f8d205e65 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt @@ -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 */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt index 2fa56b82a..51522945d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt @@ -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 ) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt index b72a011da..13570c945 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt @@ -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) } diff --git a/core-metadata/src/main/java/tachiyomi/core/metadata/comicinfo/ComicInfo.kt b/core-metadata/src/main/java/tachiyomi/core/metadata/comicinfo/ComicInfo.kt index 80c0e9d4c..a19c3775f 100644 --- a/core-metadata/src/main/java/tachiyomi/core/metadata/comicinfo/ComicInfo.kt +++ b/core-metadata/src/main/java/tachiyomi/core/metadata/comicinfo/ComicInfo.kt @@ -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( diff --git a/core/build.gradle.kts b/core/build.gradle.kts index c70d6690e..c0fdeb2ed 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -48,5 +48,8 @@ dependencies { // SY --> implementation(sylibs.xlog) + implementation(libs.zip4j) + implementation(libs.injekt.core) + implementation(libs.exifinterface) // SY <-- } diff --git a/core/src/main/java/eu/kanade/tachiyomi/core/security/SecurityPreferences.kt b/core/src/main/java/eu/kanade/tachiyomi/core/security/SecurityPreferences.kt index 80eff0f4f..0e4e50aa4 100644 --- a/core/src/main/java/eu/kanade/tachiyomi/core/security/SecurityPreferences.kt +++ b/core/src/main/java/eu/kanade/tachiyomi/core/security/SecurityPreferences.kt @@ -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 <-- } diff --git a/core/src/main/java/eu/kanade/tachiyomi/util/storage/CbzCrypto.kt b/core/src/main/java/eu/kanade/tachiyomi/util/storage/CbzCrypto.kt new file mode 100644 index 000000000..6b35fa172 --- /dev/null +++ b/core/src/main/java/eu/kanade/tachiyomi/util/storage/CbzCrypto.kt @@ -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 = ('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 { + 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 = ('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 <-- diff --git a/core/src/main/java/tachiyomi/core/util/system/ImageUtil.kt b/core/src/main/java/tachiyomi/core/util/system/ImageUtil.kt index 40828c5a2..fffbf8c1e 100644 --- a/core/src/main/java/tachiyomi/core/util/system/ImageUtil.kt +++ b/core/src/main/java/tachiyomi/core/util/system/ImageUtil.kt @@ -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 { - return extractImageOptions(imageStream).splitData + fun getSplitDataForStream( + imageStream: InputStream, + // SY --> + zip4jFile: ZipFile? = null, + zip4jEntry: FileHeader? = null, + // SY <-- + + ): List { + // 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 = ('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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eaa5a9688..92d913d6f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" diff --git a/i18n/src/main/res/values/strings_sy.xml b/i18n/src/main/res/values/strings_sy.xml index e7bde3226..f4aaca94c 100644 --- a/i18n/src/main/res/values/strings_sy.xml +++ b/i18n/src/main/res/values/strings_sy.xml @@ -220,6 +220,28 @@ Thursday Friday Saturday + Encrypt database + Requires app restart to take effect \nClearing app data and loading a backup is highly recommended + Set CBZ archive password + Password protect downloads + Encrypts CBZ archive downloads with the given password.\nWARNING: DATA INSIDE THE ARCHIVES WILL BE LOST FOREVER IF YOU FORGET THE PASSWORD + Delete CBZ archive password + In manga directory + Internally + Never (local source manga won\'t have covers) + Save local manga covers + Delete cached local source covers + Internally cached local source manga covers are NOT deleted automatically Please delete them here then open the local source extension to generate them again + Successfully deleted all locally cached covers + Something went wrong deleting your cover images: + CBZ archive password + Wrong CBZ archive password + Encryption type + AES 256 + AES 192 + AES 128 + Standard zip encryption (fast but insecure) + Page Downloading diff --git a/source-local/build.gradle.kts b/source-local/build.gradle.kts index 0f6ad1fc2..eb724b5ac 100644 --- a/source-local/build.gradle.kts +++ b/source-local/build.gradle.kts @@ -11,6 +11,9 @@ kotlin { implementation(project(":source-api")) implementation(libs.unifile) implementation(libs.junrar) + // SY --> + implementation(libs.zip4j) + // SY <-- } } val androidMain by getting { diff --git a/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt b/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt index 85421aeb0..b299a8a67 100755 --- a/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt +++ b/source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalSource.kt @@ -9,12 +9,15 @@ import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder +import eu.kanade.tachiyomi.util.storage.CbzCrypto import eu.kanade.tachiyomi.util.storage.EpubFile import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.encodeToStream import logcat.LogPriority +import net.lingala.zip4j.ZipFile +import net.lingala.zip4j.model.ZipParameters import nl.adaptivity.xmlutil.AndroidXmlReader import nl.adaptivity.xmlutil.serialization.XML import rx.Observable @@ -39,7 +42,6 @@ import java.io.File import java.io.FileInputStream import java.io.InputStream import java.nio.charset.StandardCharsets -import java.util.zip.ZipFile import kotlin.time.Duration.Companion.days import com.github.junrar.Archive as JunrarArchive import tachiyomi.domain.source.model.Source as DomainSource @@ -187,6 +189,10 @@ actual class LocalSource( .firstOrNull { it.name == ".noxml" } val legacyJsonDetailsFile = mangaDirFiles .firstOrNull { it.extension == "json" } + // SY --> + val comicInfoArchiveFile = mangaDirFiles + .firstOrNull { it.name == COMIC_INFO_ARCHIVE } + // SY <-- when { // Top level ComicInfo.xml @@ -194,6 +200,18 @@ actual class LocalSource( noXmlFile?.delete() setMangaDetailsFromComicInfoFile(comicInfoFile.inputStream(), manga) } + // SY --> + comicInfoArchiveFile != null -> { + val comicInfoArchive = ZipFile(comicInfoArchiveFile) + noXmlFile?.delete() + + if (CbzCrypto.checkCbzPassword(comicInfoArchive, CbzCrypto.getDecryptedPasswordCbz())) { + comicInfoArchive.setPassword(CbzCrypto.getDecryptedPasswordCbz()) + val comicInfoEntry = comicInfoArchive.fileHeaders.firstOrNull { it.fileName == COMIC_INFO_FILE } + setMangaDetailsFromComicInfoFile(comicInfoArchive.getInputStream(comicInfoEntry), manga) + } + } + // SY <-- // TODO: automatically convert these to ComicInfo.xml legacyJsonDetailsFile != null -> { @@ -217,9 +235,17 @@ actual class LocalSource( val folderPath = mangaDir?.absolutePath val copiedFile = copyComicInfoFileFromArchive(chapterArchives, folderPath) - if (copiedFile != null) { + // SY --> + if (copiedFile != null && copiedFile.name != COMIC_INFO_ARCHIVE) { setMangaDetailsFromComicInfoFile(copiedFile.inputStream(), manga) - } else { + } else if (copiedFile != null && copiedFile.name == COMIC_INFO_ARCHIVE) { + val comicInfoArchive = ZipFile(copiedFile) + comicInfoArchive.setPassword(CbzCrypto.getDecryptedPasswordCbz()) + val comicInfoEntry = comicInfoArchive.fileHeaders.firstOrNull { it.fileName == COMIC_INFO_FILE } + + setMangaDetailsFromComicInfoFile(comicInfoArchive.getInputStream(comicInfoEntry), manga) + } // SY <-- + else { // Avoid re-scanning File("$folderPath/.noxml").createNewFile() } @@ -237,7 +263,16 @@ actual class LocalSource( when (Format.valueOf(chapter)) { is Format.Zip -> { ZipFile(chapter).use { zip: ZipFile -> - zip.getEntry(COMIC_INFO_FILE)?.let { comicInfoFile -> + // SY --> + if (zip.isEncrypted && !CbzCrypto.checkCbzPassword(zip, CbzCrypto.getDecryptedPasswordCbz()) + ) { + return null + } else if (zip.isEncrypted && CbzCrypto.checkCbzPassword(zip, CbzCrypto.getDecryptedPasswordCbz()) + ) { + zip.setPassword(CbzCrypto.getDecryptedPasswordCbz()) + } + zip.getFileHeader(COMIC_INFO_FILE)?.let { comicInfoFile -> + // SY <-- zip.getInputStream(comicInfoFile).buffered().use { stream -> return copyComicInfoFile(stream, folderPath) } @@ -260,9 +295,25 @@ actual class LocalSource( } private fun copyComicInfoFile(comicInfoFileStream: InputStream, folderPath: String?): File { - return File("$folderPath/$COMIC_INFO_FILE").apply { - outputStream().use { outputStream -> - comicInfoFileStream.use { it.copyTo(outputStream) } + // SY --> + if ( + CbzCrypto.getPasswordProtectDlPref() && + CbzCrypto.isPasswordSet() + ) { + val zipParameters = ZipParameters() + CbzCrypto.setZipParametersEncrypted(zipParameters) + zipParameters.fileNameInZip = COMIC_INFO_FILE + + val zipEncrypted = ZipFile("$folderPath/$COMIC_INFO_ARCHIVE") + zipEncrypted.setPassword(CbzCrypto.getDecryptedPasswordCbz()) + zipEncrypted.addStream(comicInfoFileStream, zipParameters) + return zipEncrypted.file + } else { + // SY <-- + return File("$folderPath/$COMIC_INFO_FILE").apply { + outputStream().use { outputStream -> + comicInfoFileStream.use { it.copyTo(outputStream) } + } } } } @@ -338,9 +389,12 @@ actual class LocalSource( } is Format.Zip -> { ZipFile(format.file).use { zip -> - val entry = zip.entries().toList() - .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } - .find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } + // SY --> + if (zip.isEncrypted) zip.setPassword(CbzCrypto.getDecryptedPasswordCbz()) + val entry = zip.fileHeaders.toList() + .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } + .find { !it.isDirectory && ImageUtil.isImage(it.fileName) { zip.getInputStream(it) } } + // SY <-- entry?.let { coverManager.update(manga, zip.getInputStream(it)) } } @@ -374,6 +428,10 @@ actual class LocalSource( const val ID = 0L const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/" + // SY --> + const val COMIC_INFO_ARCHIVE = "ComicInfo.cbm" + // SY <-- + private val LATEST_THRESHOLD = 7.days.inWholeMilliseconds } } diff --git a/source-local/src/androidMain/kotlin/tachiyomi/source/local/image/LocalCoverManager.kt b/source-local/src/androidMain/kotlin/tachiyomi/source/local/image/LocalCoverManager.kt index e5aaf5dd7..32c3ce6c4 100644 --- a/source-local/src/androidMain/kotlin/tachiyomi/source/local/image/LocalCoverManager.kt +++ b/source-local/src/androidMain/kotlin/tachiyomi/source/local/image/LocalCoverManager.kt @@ -2,27 +2,52 @@ package tachiyomi.source.local.image import android.content.Context import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.core.security.SecurityPreferences import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.util.storage.DiskUtil import tachiyomi.core.util.system.ImageUtil import tachiyomi.source.local.io.LocalSourceFileSystem +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import java.io.File import java.io.InputStream private const val DEFAULT_COVER_NAME = "cover.jpg" +// SY --> +private const val NO_COVER_FILE = ".nocover" +private const val CACHE_COVER_INTERNAL = ".cacheCoverInternal" +private const val LOCAL_CACHE_DIR = "covers/local" +// SY <-- + actual class LocalCoverManager( private val context: Context, private val fileSystem: LocalSourceFileSystem, + + // SY --> + private val coverCacheDir: File? = context.getExternalFilesDir(LOCAL_CACHE_DIR), + private val securityPreferences: SecurityPreferences = Injekt.get(), + // SY <-- + ) { actual fun find(mangaUrl: String): File? { return fileSystem.getFilesInMangaDirectory(mangaUrl) // Get all file whose names start with 'cover' - .filter { it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true) } + // --> SY + .filter { (it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true)) || it.name == NO_COVER_FILE || it.name == CACHE_COVER_INTERNAL } // Get the first actual image .firstOrNull { - ImageUtil.isImage(it.name) { it.inputStream() } + if (it.name != NO_COVER_FILE && it.name != CACHE_COVER_INTERNAL) { + ImageUtil.isImage(it.name) { it.inputStream() } + } else if (it.name == NO_COVER_FILE) { + true + } else if (it.name == CACHE_COVER_INTERNAL) { + return File("$coverCacheDir/${it.parentFile?.name}/$DEFAULT_COVER_NAME") + } else { + false + } + // SY <-- } } @@ -38,21 +63,49 @@ actual class LocalCoverManager( var targetFile = find(manga.url) if (targetFile == null) { - targetFile = File(directory.absolutePath, DEFAULT_COVER_NAME) + // SY --> + targetFile = when (securityPreferences.localCoverLocation().get()) { + SecurityPreferences.CoverCacheLocation.INTERNAL -> File(directory.absolutePath, CACHE_COVER_INTERNAL) + SecurityPreferences.CoverCacheLocation.NEVER -> File(directory.absolutePath, NO_COVER_FILE) + SecurityPreferences.CoverCacheLocation.IN_MANGA_DIRECTORY -> File(directory.absolutePath, DEFAULT_COVER_NAME) + } + if (targetFile.parentFile?.parentFile?.name != "local") targetFile.parentFile?.mkdirs() targetFile.createNewFile() } - // It might not exist at this point - targetFile.parentFile?.mkdirs() - inputStream.use { input -> - targetFile.outputStream().use { output -> - input.copyTo(output) + if (targetFile.name == NO_COVER_FILE) return null + if (securityPreferences.localCoverLocation().get() == SecurityPreferences.CoverCacheLocation.IN_MANGA_DIRECTORY) { + // SY <-- + // It might not exist at this point + targetFile.parentFile?.mkdirs() + inputStream.use { input -> + targetFile.outputStream().use { output -> + input.copyTo(output) + } + DiskUtil.createNoMediaFile(UniFile.fromFile(directory), context) + manga.thumbnail_url = targetFile.absolutePath + return targetFile + // SY --> } + } else if (securityPreferences.localCoverLocation().get() == SecurityPreferences.CoverCacheLocation.INTERNAL) { + // It might not exist at this point + targetFile.parentFile?.mkdirs() + val path = "$coverCacheDir/${targetFile.parentFile?.name}/$DEFAULT_COVER_NAME" + val outputFile = File(path) + + outputFile.parentFile?.mkdirs() + outputFile.createNewFile() + + inputStream.use { input -> + outputFile.outputStream().use { output -> + input.copyTo(output) + } + } + manga.thumbnail_url = outputFile.absolutePath + return outputFile + } else { + return null } - - DiskUtil.createNoMediaFile(UniFile.fromFile(directory), context) - - manga.thumbnail_url = targetFile.absolutePath - return targetFile + // SY <-- } }