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:
parent
514e061dd9
commit
88f076afd4
@ -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)
|
||||||
|
9
app/proguard-rules.pro
vendored
9
app/proguard-rules.pro
vendored
@ -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.** { *; }
|
||||||
|
@ -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,
|
||||||
|
@ -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 <--
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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())
|
||||||
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
@ -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) {
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 <--
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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 */
|
||||||
|
@ -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
|
||||||
) {
|
) {
|
||||||
|
@ -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) }
|
||||||
|
@ -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(
|
||||||
|
@ -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 <--
|
||||||
}
|
}
|
||||||
|
@ -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 <--
|
||||||
}
|
}
|
||||||
|
246
core/src/main/java/eu/kanade/tachiyomi/util/storage/CbzCrypto.kt
Normal file
246
core/src/main/java/eu/kanade/tachiyomi/util/storage/CbzCrypto.kt
Normal 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 <--
|
@ -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)
|
||||||
|
@ -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"
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 <--
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user