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