Implemented local cover encryption (#881)
* Implemented local cover encryption and made coil capable of reading encrypted cover archives * add check that the file is not an image before determining that it is a zip file
This commit is contained in:
parent
282a0c4e16
commit
291734a406
@ -1,6 +1,5 @@
|
||||
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
|
||||
@ -25,7 +24,6 @@ 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
|
||||
@ -57,13 +55,8 @@ 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 {
|
||||
|
||||
@ -168,45 +161,6 @@ object SettingsSecurityScreen : SearchableSettings {
|
||||
},
|
||||
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()
|
||||
|
@ -214,7 +214,7 @@ class PreferenceModule(val application: Application) : InjektModule {
|
||||
SourcePreferences(get())
|
||||
}
|
||||
addSingletonFactory {
|
||||
SecurityPreferences(get(), application.applicationContext)
|
||||
SecurityPreferences(get())
|
||||
}
|
||||
addSingletonFactory {
|
||||
LibraryPreferences(get())
|
||||
|
@ -9,6 +9,9 @@ import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.ImageSource
|
||||
import coil.fetch.SourceResult
|
||||
import coil.request.Options
|
||||
import eu.kanade.tachiyomi.util.storage.CbzCrypto
|
||||
import net.lingala.zip4j.ZipFile
|
||||
import net.lingala.zip4j.model.FileHeader
|
||||
import okio.BufferedSource
|
||||
import tachiyomi.core.util.system.ImageUtil
|
||||
import tachiyomi.decoder.ImageDecoder
|
||||
@ -19,9 +22,24 @@ import tachiyomi.decoder.ImageDecoder
|
||||
class TachiyomiImageDecoder(private val resources: ImageSource, private val options: Options) : Decoder {
|
||||
|
||||
override suspend fun decode(): DecodeResult {
|
||||
val decoder = resources.sourceOrNull()?.use {
|
||||
ImageDecoder.newInstance(it.inputStream())
|
||||
// SY -->
|
||||
var zip4j: ZipFile? = null
|
||||
var entry: FileHeader? = null
|
||||
|
||||
if (resources.sourceOrNull()?.peek()?.use { CbzCrypto.detectCoverImageArchive(it.inputStream()) } == true) {
|
||||
if (resources.source().peek().use { ImageUtil.findImageType(it.inputStream()) == null }) {
|
||||
zip4j = ZipFile(resources.file().toFile().absolutePath)
|
||||
entry = zip4j.fileHeaders.firstOrNull { it.fileName.equals(CbzCrypto.DEFAULT_COVER_NAME, ignoreCase = true) }
|
||||
|
||||
if (zip4j.isEncrypted) zip4j.setPassword(CbzCrypto.getDecryptedPasswordCbz())
|
||||
}
|
||||
}
|
||||
val decoder = resources.sourceOrNull()?.use {
|
||||
zip4j.use { zipFile ->
|
||||
ImageDecoder.newInstance(zipFile?.getInputStream(entry) ?: it.inputStream())
|
||||
}
|
||||
}
|
||||
// SY <--
|
||||
|
||||
check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" }
|
||||
|
||||
@ -45,6 +63,9 @@ class TachiyomiImageDecoder(private val resources: ImageSource, private val opti
|
||||
|
||||
private fun isApplicable(source: BufferedSource): Boolean {
|
||||
val type = source.peek().inputStream().use {
|
||||
// SY -->
|
||||
if (CbzCrypto.detectCoverImageArchive(it)) return true
|
||||
// SY <--
|
||||
ImageUtil.findImageType(it)
|
||||
}
|
||||
return when (type) {
|
||||
|
@ -1,13 +1,11 @@
|
||||
package eu.kanade.tachiyomi.core.security
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.core.R
|
||||
import tachiyomi.core.preference.PreferenceStore
|
||||
import tachiyomi.core.preference.getEnum
|
||||
|
||||
class SecurityPreferences(
|
||||
private val preferenceStore: PreferenceStore,
|
||||
private val context: Context,
|
||||
) {
|
||||
|
||||
fun useAuthenticator() = preferenceStore.getBoolean("use_biometric_lock", false)
|
||||
@ -23,7 +21,7 @@ class SecurityPreferences(
|
||||
|
||||
fun authenticatorDays() = this.preferenceStore.getInt("biometric_days", 0x7F)
|
||||
|
||||
fun encryptDatabase() = this.preferenceStore.getBoolean("encrypt_database", !context.getDatabasePath("tachiyomi.db").exists())
|
||||
fun encryptDatabase() = this.preferenceStore.getBoolean("encrypt_database", false)
|
||||
|
||||
fun sqlPassword() = this.preferenceStore.getString("sql_password", "")
|
||||
|
||||
@ -32,9 +30,6 @@ class SecurityPreferences(
|
||||
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 <--
|
||||
|
||||
/**
|
||||
@ -56,11 +51,5 @@ class SecurityPreferences(
|
||||
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 <--
|
||||
}
|
||||
|
@ -1,10 +1,8 @@
|
||||
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
|
||||
@ -20,7 +18,7 @@ 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.io.InputStream
|
||||
import java.security.KeyStore
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Cipher
|
||||
@ -36,6 +34,7 @@ import javax.crypto.spec.IvParameterSpec
|
||||
*/
|
||||
object CbzCrypto {
|
||||
const val DATABASE_NAME = "tachiyomiEncrypted.db"
|
||||
const val DEFAULT_COVER_NAME = "cover.jpg"
|
||||
private val securityPreferences: SecurityPreferences by injectLazy()
|
||||
private val keyStore = KeyStore.getInstance(KEYSTORE).apply {
|
||||
load(null)
|
||||
@ -210,23 +209,15 @@ object CbzCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteLocalCoverCache(context: Context) {
|
||||
if (context.getExternalFilesDir(LOCAL_CACHE_DIR)?.exists() == true) {
|
||||
context.getExternalFilesDir(LOCAL_CACHE_DIR)?.deleteRecursively()
|
||||
fun detectCoverImageArchive(stream: InputStream): Boolean {
|
||||
val bytes = ByteArray(128)
|
||||
if (stream.markSupported()) {
|
||||
stream.mark(bytes.size)
|
||||
stream.read(bytes, 0, bytes.size).also { stream.reset() }
|
||||
} else {
|
||||
stream.read(bytes, 0, bytes.size)
|
||||
}
|
||||
}
|
||||
|
||||
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() }
|
||||
return String(bytes).contains(DEFAULT_COVER_NAME, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -242,7 +233,4 @@ 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 <--
|
||||
|
@ -42,6 +42,10 @@ import kotlin.math.min
|
||||
object ImageUtil {
|
||||
|
||||
fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean {
|
||||
// SY -->
|
||||
if (File(name).extension.equals("cbi", ignoreCase = true)) return true
|
||||
// SY <--
|
||||
|
||||
val contentType = try {
|
||||
URLConnection.guessContentTypeFromName(name)
|
||||
} catch (e: Exception) {
|
||||
|
@ -230,14 +230,6 @@
|
||||
<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>
|
||||
|
@ -390,13 +390,16 @@ actual class LocalSource(
|
||||
is Format.Zip -> {
|
||||
ZipFile(format.file).use { zip ->
|
||||
// SY -->
|
||||
if (zip.isEncrypted) zip.setPassword(CbzCrypto.getDecryptedPasswordCbz())
|
||||
var encrypted = false
|
||||
if (zip.isEncrypted) {
|
||||
zip.setPassword(CbzCrypto.getDecryptedPasswordCbz())
|
||||
encrypted = true
|
||||
}
|
||||
val entry = zip.fileHeaders.toList()
|
||||
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
|
||||
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { zip.getInputStream(it) } }
|
||||
entry?.let { coverManager.update(manga, zip.getInputStream(it), encrypted) }
|
||||
// SY <--
|
||||
|
||||
entry?.let { coverManager.update(manga, zip.getInputStream(it)) }
|
||||
}
|
||||
}
|
||||
is Format.Rar -> {
|
||||
|
@ -2,58 +2,40 @@ package tachiyomi.source.local.image
|
||||
|
||||
import android.content.Context
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.core.security.SecurityPreferences
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.util.storage.CbzCrypto
|
||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||
import net.lingala.zip4j.ZipFile
|
||||
import net.lingala.zip4j.model.ZipParameters
|
||||
import tachiyomi.core.util.system.ImageUtil
|
||||
import tachiyomi.source.local.io.LocalSourceFileSystem
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
private const val DEFAULT_COVER_NAME = "cover.jpg"
|
||||
|
||||
// SY -->
|
||||
private const val NO_COVER_FILE = ".nocover"
|
||||
private const val CACHE_COVER_INTERNAL = ".cacheCoverInternal"
|
||||
private const val LOCAL_CACHE_DIR = "covers/local"
|
||||
// SY <--
|
||||
private const val COVER_ARCHIVE_NAME = "cover.cbi"
|
||||
|
||||
actual class LocalCoverManager(
|
||||
private val context: Context,
|
||||
private val fileSystem: LocalSourceFileSystem,
|
||||
|
||||
// SY -->
|
||||
private val coverCacheDir: File? = context.getExternalFilesDir(LOCAL_CACHE_DIR),
|
||||
private val securityPreferences: SecurityPreferences = Injekt.get(),
|
||||
// SY <--
|
||||
|
||||
) {
|
||||
|
||||
actual fun find(mangaUrl: String): File? {
|
||||
return fileSystem.getFilesInMangaDirectory(mangaUrl)
|
||||
// Get all file whose names start with 'cover'
|
||||
// --> SY
|
||||
.filter { (it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true)) || it.name == NO_COVER_FILE || it.name == CACHE_COVER_INTERNAL }
|
||||
.filter { it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true) }
|
||||
// Get the first actual image
|
||||
.firstOrNull {
|
||||
if (it.name != NO_COVER_FILE && it.name != CACHE_COVER_INTERNAL) {
|
||||
ImageUtil.isImage(it.name) { it.inputStream() }
|
||||
} else if (it.name == NO_COVER_FILE) {
|
||||
true
|
||||
} else if (it.name == CACHE_COVER_INTERNAL) {
|
||||
return File("$coverCacheDir/${it.parentFile?.name}/$DEFAULT_COVER_NAME")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
// SY <--
|
||||
ImageUtil.isImage(it.name) { it.inputStream() } || it.name == COVER_ARCHIVE_NAME
|
||||
}
|
||||
}
|
||||
|
||||
actual fun update(
|
||||
manga: SManga,
|
||||
inputStream: InputStream,
|
||||
// SY -->
|
||||
encrypted: Boolean,
|
||||
// SY <--
|
||||
): File? {
|
||||
val directory = fileSystem.getMangaDirectory(manga.url)
|
||||
if (directory == null) {
|
||||
@ -64,48 +46,40 @@ actual class LocalCoverManager(
|
||||
var targetFile = find(manga.url)
|
||||
if (targetFile == null) {
|
||||
// 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()
|
||||
if (encrypted) {
|
||||
targetFile = File(directory.absolutePath, COVER_ARCHIVE_NAME)
|
||||
} else {
|
||||
targetFile = File(directory.absolutePath, DEFAULT_COVER_NAME)
|
||||
targetFile.createNewFile()
|
||||
}
|
||||
|
||||
if (targetFile.name == NO_COVER_FILE) return null
|
||||
if (securityPreferences.localCoverLocation().get() == SecurityPreferences.CoverCacheLocation.IN_MANGA_DIRECTORY) {
|
||||
// SY <--
|
||||
}
|
||||
|
||||
// It might not exist at this point
|
||||
targetFile.parentFile?.mkdirs()
|
||||
inputStream.use { input ->
|
||||
// SY -->
|
||||
if (encrypted) {
|
||||
val zip4j = ZipFile(targetFile)
|
||||
val zipParameters = ZipParameters()
|
||||
zip4j.setPassword(CbzCrypto.getDecryptedPasswordCbz())
|
||||
CbzCrypto.setZipParametersEncrypted(zipParameters)
|
||||
zipParameters.fileNameInZip = DEFAULT_COVER_NAME
|
||||
zip4j.addStream(input, zipParameters)
|
||||
|
||||
DiskUtil.createNoMediaFile(UniFile.fromFile(directory), context)
|
||||
|
||||
manga.thumbnail_url = zip4j.file.absolutePath
|
||||
return zip4j.file
|
||||
} else {
|
||||
// SY <--
|
||||
targetFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
DiskUtil.createNoMediaFile(UniFile.fromFile(directory), context)
|
||||
manga.thumbnail_url = targetFile.absolutePath
|
||||
return targetFile
|
||||
// SY -->
|
||||
}
|
||||
} else if (securityPreferences.localCoverLocation().get() == SecurityPreferences.CoverCacheLocation.INTERNAL) {
|
||||
// It might not exist at this point
|
||||
targetFile.parentFile?.mkdirs()
|
||||
val path = "$coverCacheDir/${targetFile.parentFile?.name}/$DEFAULT_COVER_NAME"
|
||||
val outputFile = File(path)
|
||||
|
||||
outputFile.parentFile?.mkdirs()
|
||||
outputFile.createNewFile()
|
||||
|
||||
inputStream.use { input ->
|
||||
outputFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
manga.thumbnail_url = outputFile.absolutePath
|
||||
return outputFile
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
// SY <--
|
||||
}
|
||||
}
|
||||
|
@ -8,5 +8,7 @@ expect class LocalCoverManager {
|
||||
|
||||
fun find(mangaUrl: String): File?
|
||||
|
||||
fun update(manga: SManga, inputStream: InputStream): File?
|
||||
// SY -->
|
||||
fun update(manga: SManga, inputStream: InputStream, encrypted: Boolean = false): File?
|
||||
// SY <--
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user