Merge branch 'sync-part-final' into feat/add-sync-triggers-experimental

This commit is contained in:
KaiserBh 2023-12-30 22:17:55 +11:00
commit 01f06ff1af
No known key found for this signature in database
GPG Key ID: 14D73B142042BBA9
25 changed files with 272 additions and 161 deletions

View File

@ -26,10 +26,10 @@ class BasePreferences(
fun shownOnboardingFlow() = preferenceStore.getBoolean(Preference.appStateKey("onboarding_complete"), false) fun shownOnboardingFlow() = preferenceStore.getBoolean(Preference.appStateKey("onboarding_complete"), false)
enum class ExtensionInstaller(val titleRes: StringResource) { enum class ExtensionInstaller(val titleRes: StringResource, val requiresSystemPermission: Boolean) {
LEGACY(MR.strings.ext_installer_legacy), LEGACY(MR.strings.ext_installer_legacy, true),
PACKAGEINSTALLER(MR.strings.ext_installer_packageinstaller), PACKAGEINSTALLER(MR.strings.ext_installer_packageinstaller, true),
SHIZUKU(MR.strings.ext_installer_shizuku), SHIZUKU(MR.strings.ext_installer_shizuku, false),
PRIVATE(MR.strings.ext_installer_private), PRIVATE(MR.strings.ext_installer_private, false),
} }
} }

View File

@ -1,6 +1,7 @@
package eu.kanade.presentation.browse package eu.kanade.presentation.browse
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -42,12 +43,15 @@ import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.presentation.browse.components.BaseBrowseItem import eu.kanade.presentation.browse.components.BaseBrowseItem
import eu.kanade.presentation.browse.components.ExtensionIcon import eu.kanade.presentation.browse.components.ExtensionIcon
import eu.kanade.presentation.components.WarningBanner
import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreenModel import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreenModel
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.FastScrollLazyColumn import tachiyomi.presentation.core.components.FastScrollLazyColumn
import tachiyomi.presentation.core.components.material.PullRefresh import tachiyomi.presentation.core.components.material.PullRefresh
@ -127,11 +131,24 @@ private fun ExtensionContent(
onOpenExtension: (Extension.Installed) -> Unit, onOpenExtension: (Extension.Installed) -> Unit,
onClickUpdateAll: () -> Unit, onClickUpdateAll: () -> Unit,
) { ) {
val context = LocalContext.current
var trustState by remember { mutableStateOf<Extension.Untrusted?>(null) } var trustState by remember { mutableStateOf<Extension.Untrusted?>(null) }
val installGranted = rememberRequestPackageInstallsPermissionState()
FastScrollLazyColumn( FastScrollLazyColumn(
contentPadding = contentPadding + topSmallPaddingValues, contentPadding = contentPadding + topSmallPaddingValues,
) { ) {
if (!installGranted && state.installer?.requiresSystemPermission == true) {
item {
WarningBanner(
textRes = MR.strings.ext_permission_install_apps_warning,
modifier = Modifier.clickable {
context.launchRequestPackageInstallsPermission()
},
)
}
}
state.items.forEach { (header, items) -> state.items.forEach { (header, items) ->
item( item(
contentType = "header", contentType = "header",
@ -384,6 +401,13 @@ private fun ExtensionItemActions(
installStep == InstallStep.Idle -> { installStep == InstallStep.Idle -> {
when (extension) { when (extension) {
is Extension.Installed -> { is Extension.Installed -> {
IconButton(onClick = { onClickItemAction(extension) }) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(MR.strings.action_settings),
)
}
if (extension.hasUpdate) { if (extension.hasUpdate) {
IconButton(onClick = { onClickItemAction(extension) }) { IconButton(onClick = { onClickItemAction(extension) }) {
Icon( Icon(
@ -392,13 +416,6 @@ private fun ExtensionItemActions(
) )
} }
} }
IconButton(onClick = { onClickItemAction(extension) }) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(MR.strings.action_settings),
)
}
} }
is Extension.Untrusted -> { is Extension.Untrusted -> {
IconButton(onClick = { onClickItemAction(extension) }) { IconButton(onClick = { onClickItemAction(extension) }) {

View File

@ -11,8 +11,6 @@ import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
@ -35,33 +33,29 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.secondaryItemAlpha import tachiyomi.presentation.core.util.secondaryItemAlpha
internal class PermissionStep : OnboardingStep { internal class PermissionStep : OnboardingStep {
private var installGranted by mutableStateOf(false)
private var notificationGranted by mutableStateOf(false) private var notificationGranted by mutableStateOf(false)
private var batteryGranted by mutableStateOf(false) private var batteryGranted by mutableStateOf(false)
override val isComplete: Boolean override val isComplete: Boolean = true
get() = installGranted
@Composable @Composable
override fun Content() { override fun Content() {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val installGranted = rememberRequestPackageInstallsPermissionState()
DisposableEffect(lifecycleOwner.lifecycle) { DisposableEffect(lifecycleOwner.lifecycle) {
val observer = object : DefaultLifecycleObserver { val observer = object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) { override fun onResume(owner: LifecycleOwner) {
installGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.canRequestPackageInstalls()
} else {
@Suppress("DEPRECATION")
Settings.Secure.getInt(context.contentResolver, Settings.Secure.INSTALL_NON_MARKET_APPS) != 0
}
notificationGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { notificationGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
@ -78,31 +72,16 @@ internal class PermissionStep : OnboardingStep {
} }
} }
Column( Column {
modifier = Modifier.padding(vertical = 16.dp),
) {
SectionHeader(stringResource(MR.strings.onboarding_permission_type_required))
PermissionItem( PermissionItem(
title = stringResource(MR.strings.onboarding_permission_install_apps), title = stringResource(MR.strings.onboarding_permission_install_apps),
subtitle = stringResource(MR.strings.onboarding_permission_install_apps_description), subtitle = stringResource(MR.strings.onboarding_permission_install_apps_description),
granted = installGranted, granted = installGranted,
onButtonClick = { onButtonClick = {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.launchRequestPackageInstallsPermission()
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
}
} else {
Intent(Settings.ACTION_SECURITY_SETTINGS)
}
context.startActivity(intent)
}, },
) )
Spacer(modifier = Modifier.height(16.dp))
SectionHeader(stringResource(MR.strings.onboarding_permission_type_optional))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permissionRequester = rememberLauncherForActivityResult( val permissionRequester = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(), contract = ActivityResultContracts.RequestPermission(),

View File

@ -45,6 +45,7 @@ import eu.kanade.tachiyomi.network.PREF_DOH_QUAD9
import eu.kanade.tachiyomi.network.PREF_DOH_SHECAN import eu.kanade.tachiyomi.network.PREF_DOH_SHECAN
import eu.kanade.tachiyomi.ui.more.OnboardingScreen import eu.kanade.tachiyomi.ui.more.OnboardingScreen
import eu.kanade.tachiyomi.util.CrashLogUtil import eu.kanade.tachiyomi.util.CrashLogUtil
import eu.kanade.tachiyomi.util.system.isDevFlavor
import eu.kanade.tachiyomi.util.system.isPreviewBuildType import eu.kanade.tachiyomi.util.system.isPreviewBuildType
import eu.kanade.tachiyomi.util.system.isReleaseBuildType import eu.kanade.tachiyomi.util.system.isReleaseBuildType
import eu.kanade.tachiyomi.util.system.isShizukuInstalled import eu.kanade.tachiyomi.util.system.isShizukuInstalled
@ -370,6 +371,14 @@ object SettingsAdvancedScreen : SearchableSettings {
pref = extensionInstallerPref, pref = extensionInstallerPref,
title = stringResource(MR.strings.ext_installer_pref), title = stringResource(MR.strings.ext_installer_pref),
entries = extensionInstallerPref.entries entries = extensionInstallerPref.entries
.filter {
// TODO: allow private option in stable versions once URL handling is more fleshed out
if (isPreviewBuildType || isDevFlavor) {
true
} else {
it != BasePreferences.ExtensionInstaller.PRIVATE
}
}
.associateWith { stringResource(it.titleRes) } .associateWith { stringResource(it.titleRes) }
.toImmutableMap(), .toImmutableMap(),
onValueChanged = { onValueChanged = {

View File

@ -29,16 +29,11 @@ import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.WarningBanner import eu.kanade.presentation.components.WarningBanner
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags
import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob
import eu.kanade.tachiyomi.data.backup.create.BackupCreator import eu.kanade.tachiyomi.data.backup.create.BackupCreator
import eu.kanade.tachiyomi.data.backup.create.BackupOptions
import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.PersistentSet
import kotlinx.collections.immutable.minus
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.plus
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.LabeledCheckbox import tachiyomi.presentation.core.components.LabeledCheckbox
@ -101,13 +96,13 @@ class CreateBackupScreen : Screen() {
modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium), modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium),
) )
} }
BackupChoices.forEach { (k, v) -> BackupOptions.entries.forEach { option ->
item { item {
LabeledCheckbox( LabeledCheckbox(
label = stringResource(v), label = stringResource(option.label),
checked = state.flags.contains(k), checked = option.getter(state.options),
onCheckedChange = { onCheckedChange = {
model.toggleFlag(k) model.toggle(option.setter, it)
}, },
modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium), modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium),
) )
@ -145,37 +140,20 @@ class CreateBackupScreen : Screen() {
private class CreateBackupScreenModel : StateScreenModel<CreateBackupScreenModel.State>(State()) { private class CreateBackupScreenModel : StateScreenModel<CreateBackupScreenModel.State>(State()) {
fun toggleFlag(flag: Int) { fun toggle(setter: (BackupOptions, Boolean) -> BackupOptions, enabled: Boolean) {
mutableState.update { mutableState.update {
if (it.flags.contains(flag)) { it.copy(
it.copy(flags = it.flags - flag) options = setter(it.options, enabled),
} else { )
it.copy(flags = it.flags + flag)
}
} }
} }
fun createBackup(context: Context, uri: Uri) { fun createBackup(context: Context, uri: Uri) {
val flags = state.value.flags.fold(initial = 0, operation = { a, b -> a or b }) BackupCreateJob.startNow(context, uri, state.value.options)
BackupCreateJob.startNow(context, uri, flags)
} }
@Immutable @Immutable
data class State( data class State(
val flags: PersistentSet<Int> = persistentSetOf( val options: BackupOptions = BackupOptions(),
BackupCreateFlags.BACKUP_CATEGORY,
BackupCreateFlags.BACKUP_CHAPTER,
BackupCreateFlags.BACKUP_TRACK,
BackupCreateFlags.BACKUP_HISTORY,
),
) )
} }
private val BackupChoices = persistentMapOf(
BackupCreateFlags.BACKUP_CATEGORY to MR.strings.categories,
BackupCreateFlags.BACKUP_CHAPTER to MR.strings.chapters,
BackupCreateFlags.BACKUP_TRACK to MR.strings.track,
BackupCreateFlags.BACKUP_HISTORY to MR.strings.history,
BackupCreateFlags.BACKUP_APP_PREFS to MR.strings.app_settings,
BackupCreateFlags.BACKUP_SOURCE_PREFS to MR.strings.source_settings,
)

View File

@ -0,0 +1,41 @@
package eu.kanade.presentation.util
import android.os.Build
import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
@Composable
fun rememberRequestPackageInstallsPermissionState(): Boolean {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var installGranted by remember { mutableStateOf(false) }
DisposableEffect(lifecycleOwner.lifecycle) {
val observer = object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
installGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.canRequestPackageInstalls()
} else {
@Suppress("DEPRECATION")
Settings.Secure.getInt(context.contentResolver, Settings.Secure.INSTALL_NON_MARKET_APPS) != 0
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
return installGranted
}

View File

@ -1,17 +0,0 @@
package eu.kanade.tachiyomi.data.backup.create
internal object BackupCreateFlags {
const val BACKUP_CATEGORY = 0x1
const val BACKUP_CHAPTER = 0x2
const val BACKUP_HISTORY = 0x4
const val BACKUP_TRACK = 0x8
const val BACKUP_APP_PREFS = 0x10
const val BACKUP_SOURCE_PREFS = 0x20
const val AutomaticDefaults = BACKUP_CATEGORY or
BACKUP_CHAPTER or
BACKUP_HISTORY or
BACKUP_TRACK or
BACKUP_APP_PREFS or
BACKUP_SOURCE_PREFS
}

View File

@ -19,6 +19,8 @@ import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.backup.BackupNotifier import eu.kanade.tachiyomi.data.backup.BackupNotifier
import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob import eu.kanade.tachiyomi.data.backup.restore.BackupRestoreJob
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.lang.asBooleanArray
import eu.kanade.tachiyomi.util.lang.asDataClass
import eu.kanade.tachiyomi.util.system.cancelNotification import eu.kanade.tachiyomi.util.system.cancelNotification
import eu.kanade.tachiyomi.util.system.isRunning import eu.kanade.tachiyomi.util.system.isRunning
import eu.kanade.tachiyomi.util.system.setForegroundSafely import eu.kanade.tachiyomi.util.system.setForegroundSafely
@ -47,10 +49,11 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete
setForegroundSafely() setForegroundSafely()
val flags = inputData.getInt(BACKUP_FLAGS_KEY, BackupCreateFlags.AutomaticDefaults) val options: BackupOptions = inputData.getBooleanArray(OPTIONS_KEY)?.asDataClass()
?: BackupOptions()
return try { return try {
val location = BackupCreator(context, isAutoBackup).backup(uri, flags) val location = BackupCreator(context, isAutoBackup).backup(uri, options)
if (!isAutoBackup) { if (!isAutoBackup) {
notifier.showBackupComplete(UniFile.fromUri(context, location.toUri())!!) notifier.showBackupComplete(UniFile.fromUri(context, location.toUri())!!)
} }
@ -112,11 +115,11 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete
} }
} }
fun startNow(context: Context, uri: Uri, flags: Int) { fun startNow(context: Context, uri: Uri, options: BackupOptions) {
val inputData = workDataOf( val inputData = workDataOf(
IS_AUTO_BACKUP_KEY to false, IS_AUTO_BACKUP_KEY to false,
LOCATION_URI_KEY to uri.toString(), LOCATION_URI_KEY to uri.toString(),
BACKUP_FLAGS_KEY to flags, OPTIONS_KEY to options.asBooleanArray(),
) )
val request = OneTimeWorkRequestBuilder<BackupCreateJob>() val request = OneTimeWorkRequestBuilder<BackupCreateJob>()
.addTag(TAG_MANUAL) .addTag(TAG_MANUAL)
@ -132,4 +135,4 @@ private const val TAG_MANUAL = "$TAG_AUTO:manual"
private const val IS_AUTO_BACKUP_KEY = "is_auto_backup" // Boolean private const val IS_AUTO_BACKUP_KEY = "is_auto_backup" // Boolean
private const val LOCATION_URI_KEY = "location_uri" // String private const val LOCATION_URI_KEY = "location_uri" // String
private const val BACKUP_FLAGS_KEY = "backup_flags" // Int private const val OPTIONS_KEY = "options" // BooleanArray

View File

@ -5,9 +5,6 @@ import android.net.Uri
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.backup.BackupFileValidator import eu.kanade.tachiyomi.data.backup.BackupFileValidator
import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags.BACKUP_APP_PREFS
import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags.BACKUP_CATEGORY
import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags.BACKUP_SOURCE_PREFS
import eu.kanade.tachiyomi.data.backup.create.creators.CategoriesBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.CategoriesBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.PreferenceBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.PreferenceBackupCreator
@ -52,7 +49,7 @@ class BackupCreator(
private val sourcesBackupCreator: SourcesBackupCreator = SourcesBackupCreator(), private val sourcesBackupCreator: SourcesBackupCreator = SourcesBackupCreator(),
) { ) {
suspend fun backup(uri: Uri, flags: Int): String { suspend fun backup(uri: Uri, options: BackupOptions): String {
var file: UniFile? = null var file: UniFile? = null
try { try {
file = ( file = (
@ -73,19 +70,18 @@ class BackupCreator(
UniFile.fromUri(context, uri) UniFile.fromUri(context, uri)
} }
) )
?: throw Exception(context.stringResource(MR.strings.create_backup_file_error))
if (!file.isFile) { if (file == null || !file.isFile) {
throw IllegalStateException("Failed to get handle on a backup file") throw IllegalStateException(context.stringResource(MR.strings.create_backup_file_error))
} }
val databaseManga = getFavorites.await() val databaseManga = getFavorites.await()
val backup = Backup( val backup = Backup(
backupManga = backupMangas(databaseManga, flags), backupManga = backupMangas(databaseManga, options),
backupCategories = backupCategories(flags), backupCategories = backupCategories(options),
backupSources = backupSources(databaseManga), backupSources = backupSources(databaseManga),
backupPreferences = backupAppPreferences(flags), backupPreferences = backupAppPreferences(options),
backupSourcePreferences = backupSourcePreferences(flags), backupSourcePreferences = backupSourcePreferences(options),
) )
val byteArray = parser.encodeToByteArray(BackupSerializer, backup) val byteArray = parser.encodeToByteArray(BackupSerializer, backup)
@ -118,30 +114,30 @@ class BackupCreator(
} }
} }
suspend fun backupCategories(options: Int): List<BackupCategory> { suspend fun backupCategories(options: BackupOptions): List<BackupCategory> {
if (options and BACKUP_CATEGORY != BACKUP_CATEGORY) return emptyList() if (!options.categories) return emptyList()
return categoriesBackupCreator.backupCategories() return categoriesBackupCreator.backupCategories()
} }
suspend fun backupMangas(mangas: List<Manga>, flags: Int): List<BackupManga> { suspend fun backupMangas(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
return mangaBackupCreator.backupMangas(mangas, flags) return mangaBackupCreator.backupMangas(mangas, options)
} }
fun backupSources(mangas: List<Manga>): List<BackupSource> { fun backupSources(mangas: List<Manga>): List<BackupSource> {
return sourcesBackupCreator.backupSources(mangas) return sourcesBackupCreator.backupSources(mangas)
} }
fun backupAppPreferences(flags: Int): List<BackupPreference> { fun backupAppPreferences(options: BackupOptions): List<BackupPreference> {
if (flags and BACKUP_APP_PREFS != BACKUP_APP_PREFS) return emptyList() if (!options.appSettings) return emptyList()
return preferenceBackupCreator.backupAppPreferences() return preferenceBackupCreator.backupAppPreferences(includePrivatePreferences = options.privateSettings)
} }
fun backupSourcePreferences(flags: Int): List<BackupSourcePreferences> { fun backupSourcePreferences(options: BackupOptions): List<BackupSourcePreferences> {
if (flags and BACKUP_SOURCE_PREFS != BACKUP_SOURCE_PREFS) return emptyList() if (!options.sourceSettings) return emptyList()
return preferenceBackupCreator.backupSourcePreferences() return preferenceBackupCreator.backupSourcePreferences(includePrivatePreferences = options.privateSettings)
} }
companion object { companion object {

View File

@ -0,0 +1,63 @@
package eu.kanade.tachiyomi.data.backup.create
import dev.icerock.moko.resources.StringResource
import kotlinx.collections.immutable.persistentListOf
import tachiyomi.i18n.MR
data class BackupOptions(
val libraryEntries: Boolean = true,
val categories: Boolean = true,
val chapters: Boolean = true,
val tracking: Boolean = true,
val history: Boolean = true,
val appSettings: Boolean = true,
val sourceSettings: Boolean = true,
val privateSettings: Boolean = false,
) {
companion object {
val entries = persistentListOf(
Entry(
label = MR.strings.categories,
getter = BackupOptions::categories,
setter = { options, enabled -> options.copy(categories = enabled) },
),
Entry(
label = MR.strings.chapters,
getter = BackupOptions::chapters,
setter = { options, enabled -> options.copy(chapters = enabled) },
),
Entry(
label = MR.strings.track,
getter = BackupOptions::tracking,
setter = { options, enabled -> options.copy(tracking = enabled) },
),
Entry(
label = MR.strings.history,
getter = BackupOptions::history,
setter = { options, enabled -> options.copy(history = enabled) },
),
Entry(
label = MR.strings.app_settings,
getter = BackupOptions::appSettings,
setter = { options, enabled -> options.copy(appSettings = enabled) },
),
Entry(
label = MR.strings.source_settings,
getter = BackupOptions::sourceSettings,
setter = { options, enabled -> options.copy(sourceSettings = enabled) },
),
Entry(
label = MR.strings.private_settings,
getter = BackupOptions::privateSettings,
setter = { options, enabled -> options.copy(privateSettings = enabled) },
),
)
}
data class Entry(
val label: StringResource,
val getter: (BackupOptions) -> Boolean,
val setter: (BackupOptions, Boolean) -> BackupOptions,
)
}

View File

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.data.backup.create.creators package eu.kanade.tachiyomi.data.backup.create.creators
import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags import eu.kanade.tachiyomi.data.backup.create.BackupOptions
import eu.kanade.tachiyomi.data.backup.models.BackupChapter import eu.kanade.tachiyomi.data.backup.models.BackupChapter
import eu.kanade.tachiyomi.data.backup.models.BackupHistory import eu.kanade.tachiyomi.data.backup.models.BackupHistory
import eu.kanade.tachiyomi.data.backup.models.BackupManga import eu.kanade.tachiyomi.data.backup.models.BackupManga
@ -20,18 +20,17 @@ class MangaBackupCreator(
private val getHistory: GetHistory = Injekt.get(), private val getHistory: GetHistory = Injekt.get(),
) { ) {
suspend fun backupMangas(mangas: List<Manga>, flags: Int): List<BackupManga> { suspend fun backupMangas(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
return mangas.map { return mangas.map {
backupManga(it, flags) backupManga(it, options)
} }
} }
private suspend fun backupManga(manga: Manga, options: Int): BackupManga { private suspend fun backupManga(manga: Manga, options: BackupOptions): BackupManga {
// Entry for this manga // Entry for this manga
val mangaObject = manga.toBackupManga() val mangaObject = manga.toBackupManga()
// Check if user wants chapter information in backup if (options.chapters) {
if (options and BackupCreateFlags.BACKUP_CHAPTER == BackupCreateFlags.BACKUP_CHAPTER) {
// Backup all the chapters // Backup all the chapters
handler.awaitList { handler.awaitList {
chaptersQueries.getChaptersByMangaId( chaptersQueries.getChaptersByMangaId(
@ -44,8 +43,7 @@ class MangaBackupCreator(
?.let { mangaObject.chapters = it } ?.let { mangaObject.chapters = it }
} }
// Check if user wants category information in backup if (options.categories) {
if (options and BackupCreateFlags.BACKUP_CATEGORY == BackupCreateFlags.BACKUP_CATEGORY) {
// Backup categories for this manga // Backup categories for this manga
val categoriesForManga = getCategories.await(manga.id) val categoriesForManga = getCategories.await(manga.id)
if (categoriesForManga.isNotEmpty()) { if (categoriesForManga.isNotEmpty()) {
@ -53,16 +51,14 @@ class MangaBackupCreator(
} }
} }
// Check if user wants track information in backup if (options.tracking) {
if (options and BackupCreateFlags.BACKUP_TRACK == BackupCreateFlags.BACKUP_TRACK) {
val tracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id, backupTrackMapper) } val tracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id, backupTrackMapper) }
if (tracks.isNotEmpty()) { if (tracks.isNotEmpty()) {
mangaObject.tracking = tracks mangaObject.tracking = tracks
} }
} }
// Check if user wants history information in backup if (options.history) {
if (options and BackupCreateFlags.BACKUP_HISTORY == BackupCreateFlags.BACKUP_HISTORY) {
val historyByMangaId = getHistory.await(manga.id) val historyByMangaId = getHistory.await(manga.id)
if (historyByMangaId.isNotEmpty()) { if (historyByMangaId.isNotEmpty()) {
val history = historyByMangaId.map { history -> val history = historyByMangaId.map { history ->

View File

@ -22,26 +22,27 @@ class PreferenceBackupCreator(
private val preferenceStore: PreferenceStore = Injekt.get(), private val preferenceStore: PreferenceStore = Injekt.get(),
) { ) {
fun backupAppPreferences(): List<BackupPreference> { fun backupAppPreferences(includePrivatePreferences: Boolean): List<BackupPreference> {
return preferenceStore.getAll().toBackupPreferences() return preferenceStore.getAll().toBackupPreferences()
.withPrivatePreferences(includePrivatePreferences)
} }
fun backupSourcePreferences(): List<BackupSourcePreferences> { fun backupSourcePreferences(includePrivatePreferences: Boolean): List<BackupSourcePreferences> {
return sourceManager.getCatalogueSources() return sourceManager.getCatalogueSources()
.filterIsInstance<ConfigurableSource>() .filterIsInstance<ConfigurableSource>()
.map { .map {
BackupSourcePreferences( BackupSourcePreferences(
it.preferenceKey(), it.preferenceKey(),
it.sourcePreferences().all.toBackupPreferences(), it.sourcePreferences().all.toBackupPreferences()
.withPrivatePreferences(includePrivatePreferences),
) )
} }
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun Map<String, *>.toBackupPreferences(): List<BackupPreference> { private fun Map<String, *>.toBackupPreferences(): List<BackupPreference> {
return this.filterKeys { return this
!Preference.isPrivate(it) && !Preference.isAppState(it) .filterKeys { !Preference.isAppState(it) }
}
.mapNotNull { (key, value) -> .mapNotNull { (key, value) ->
when (value) { when (value) {
is Int -> BackupPreference(key, IntPreferenceValue(value)) is Int -> BackupPreference(key, IntPreferenceValue(value))
@ -56,4 +57,11 @@ class PreferenceBackupCreator(
} }
} }
} }
private fun List<BackupPreference>.withPrivatePreferences(include: Boolean) =
if (include) {
this
} else {
this.filter { !Preference.isPrivate(it.key) }
}
} }

View File

@ -13,6 +13,8 @@ import androidx.work.WorkerParameters
import androidx.work.workDataOf import androidx.work.workDataOf
import eu.kanade.tachiyomi.data.backup.BackupNotifier import eu.kanade.tachiyomi.data.backup.BackupNotifier
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.lang.asBooleanArray
import eu.kanade.tachiyomi.util.lang.asDataClass
import eu.kanade.tachiyomi.util.system.cancelNotification import eu.kanade.tachiyomi.util.system.cancelNotification
import eu.kanade.tachiyomi.util.system.isRunning import eu.kanade.tachiyomi.util.system.isRunning
import eu.kanade.tachiyomi.util.system.setForegroundSafely import eu.kanade.tachiyomi.util.system.setForegroundSafely
@ -30,8 +32,7 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val uri = inputData.getString(LOCATION_URI_KEY)?.toUri() val uri = inputData.getString(LOCATION_URI_KEY)?.toUri()
val options = inputData.getBooleanArray(OPTIONS_KEY) val options: RestoreOptions? = inputData.getBooleanArray(OPTIONS_KEY)?.asDataClass()
?.let { RestoreOptions.fromBooleanArray(it) }
if (uri == null || options == null) { if (uri == null || options == null) {
return Result.failure() return Result.failure()
@ -84,7 +85,7 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
val inputData = workDataOf( val inputData = workDataOf(
LOCATION_URI_KEY to uri.toString(), LOCATION_URI_KEY to uri.toString(),
SYNC_KEY to sync, SYNC_KEY to sync,
OPTIONS_KEY to options.toBooleanArray(), OPTIONS_KEY to options.asBooleanArray(),
) )
val request = OneTimeWorkRequestBuilder<BackupRestoreJob>() val request = OneTimeWorkRequestBuilder<BackupRestoreJob>()
.addTag(TAG) .addTag(TAG)

View File

@ -4,14 +4,4 @@ data class RestoreOptions(
val appSettings: Boolean = true, val appSettings: Boolean = true,
val sourceSettings: Boolean = true, val sourceSettings: Boolean = true,
val library: Boolean = true, val library: Boolean = true,
) { )
fun toBooleanArray() = booleanArrayOf(appSettings, sourceSettings, library)
companion object {
fun fromBooleanArray(booleanArray: BooleanArray) = RestoreOptions(
appSettings = booleanArray[0],
sourceSettings = booleanArray[1],
library = booleanArray[2],
)
}
}

View File

@ -2,8 +2,8 @@ package eu.kanade.tachiyomi.data.sync
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import eu.kanade.tachiyomi.data.backup.create.BackupCreateFlags
import eu.kanade.tachiyomi.data.backup.create.BackupCreator import eu.kanade.tachiyomi.data.backup.create.BackupCreator
import eu.kanade.tachiyomi.data.backup.create.BackupOptions
import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupChapter import eu.kanade.tachiyomi.data.backup.models.BackupChapter
import eu.kanade.tachiyomi.data.backup.models.BackupManga import eu.kanade.tachiyomi.data.backup.models.BackupManga
@ -72,12 +72,22 @@ class SyncManager(
*/ */
suspend fun syncData() { suspend fun syncData() {
val databaseManga = getAllMangaFromDB() val databaseManga = getAllMangaFromDB()
val backupOptions = BackupOptions(
libraryEntries = true,
categories = true,
chapters = true,
tracking = true,
history = true,
appSettings = true,
sourceSettings = true,
privateSettings = true,
)
val backup = Backup( val backup = Backup(
backupManga = backupCreator.backupMangas(databaseManga, BackupCreateFlags.AutomaticDefaults), backupManga = backupCreator.backupMangas(databaseManga, backupOptions),
backupCategories = backupCreator.backupCategories(BackupCreateFlags.AutomaticDefaults), backupCategories = backupCreator.backupCategories(backupOptions),
backupSources = backupCreator.backupSources(databaseManga), backupSources = backupCreator.backupSources(databaseManga),
backupPreferences = backupCreator.backupAppPreferences(BackupCreateFlags.AutomaticDefaults), backupPreferences = backupCreator.backupAppPreferences(backupOptions),
backupSourcePreferences = backupCreator.backupSourcePreferences(BackupCreateFlags.AutomaticDefaults), backupSourcePreferences = backupCreator.backupSourcePreferences(backupOptions),
) )
// Create the SyncData object // Create the SyncData object

View File

@ -120,7 +120,7 @@ abstract class SyncService(
remote.copy(chapters = mergedChapters) remote.copy(chapters = mergedChapters)
} }
} }
else -> null // This case occurs if both are null, which shouldn't happen but is handled for completeness. else -> null
} }
} }
} }

View File

@ -304,7 +304,7 @@ internal object ExtensionLoader {
when (val obj = Class.forName(it, false, classLoader).getDeclaredConstructor().newInstance()) { when (val obj = Class.forName(it, false, classLoader).getDeclaredConstructor().newInstance()) {
is Source -> listOf(obj) is Source -> listOf(obj)
is SourceFactory -> obj.createSources() is SourceFactory -> obj.createSources()
else -> throw Exception("Unknown source class type! ${obj.javaClass}") else -> throw Exception("Unknown source class type: ${obj.javaClass}")
} }
} catch (e: Throwable) { } catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Extension load error: $extName ($it)" } logcat(LogPriority.ERROR, e) { "Extension load error: $extName ($it)" }

View File

@ -5,6 +5,7 @@ import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.extension.interactor.GetExtensionsByType import eu.kanade.domain.extension.interactor.GetExtensionsByType
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.presentation.components.SEARCH_DEBOUNCE_MILLIS import eu.kanade.presentation.components.SEARCH_DEBOUNCE_MILLIS
@ -34,6 +35,7 @@ import kotlin.time.Duration.Companion.seconds
class ExtensionsScreenModel( class ExtensionsScreenModel(
preferences: SourcePreferences = Injekt.get(), preferences: SourcePreferences = Injekt.get(),
basePreferences: BasePreferences = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(), private val extensionManager: ExtensionManager = Injekt.get(),
private val getExtensions: GetExtensionsByType = Injekt.get(), private val getExtensions: GetExtensionsByType = Injekt.get(),
) : StateScreenModel<ExtensionsScreenModel.State>(State()) { ) : StateScreenModel<ExtensionsScreenModel.State>(State()) {
@ -124,6 +126,10 @@ class ExtensionsScreenModel(
preferences.extensionUpdatesCount().changes() preferences.extensionUpdatesCount().changes()
.onEach { mutableState.update { state -> state.copy(updates = it) } } .onEach { mutableState.update { state -> state.copy(updates = it) } }
.launchIn(screenModelScope) .launchIn(screenModelScope)
basePreferences.extensionInstaller().changes()
.onEach { mutableState.update { state -> state.copy(installer = it) } }
.launchIn(screenModelScope)
} }
fun search(query: String?) { fun search(query: String?) {
@ -199,6 +205,7 @@ class ExtensionsScreenModel(
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val items: ItemGroups = mutableMapOf(), val items: ItemGroups = mutableMapOf(),
val updates: Int = 0, val updates: Int = 0,
val installer: BasePreferences.ExtensionInstaller? = null,
val searchQuery: String? = null, val searchQuery: String? = null,
) { ) {
val isEmpty = items.isEmpty() val isEmpty = items.isEmpty()

View File

@ -0,0 +1,18 @@
package eu.kanade.tachiyomi.util.lang
import kotlin.reflect.KProperty1
import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.full.primaryConstructor
fun <T : Any> T.asBooleanArray(): BooleanArray {
return this::class.declaredMemberProperties
.filterIsInstance<KProperty1<T, Boolean>>()
.map { it.get(this) }
.toBooleanArray()
}
inline fun <reified T : Any> BooleanArray.asDataClass(): T {
val properties = T::class.declaredMemberProperties.filterIsInstance<KProperty1<T, Boolean>>()
require(properties.size == this.size) { "Boolean array size does not match data class property count" }
return T::class.primaryConstructor!!.call(*this.toTypedArray())
}

View File

@ -9,6 +9,7 @@ import android.content.res.Configuration
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.PowerManager import android.os.PowerManager
import android.provider.Settings
import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.net.toUri import androidx.core.net.toUri
@ -167,3 +168,14 @@ fun Context.isInstalledFromFDroid(): Boolean {
// F-Droid builds typically disable the updater // F-Droid builds typically disable the updater
(!BuildConfig.INCLUDE_UPDATER && !isDevFlavor) (!BuildConfig.INCLUDE_UPDATER && !isDevFlavor)
} }
fun Context.launchRequestPackageInstallsPermission() {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:$packageName")
}
} else {
Intent(Settings.ACTION_SECURITY_SETTINGS)
}
startActivity(intent)
}

View File

@ -75,7 +75,7 @@ WHERE _id IN :chapterIds;
insert: insert:
INSERT INTO chapters(manga_id, url, name, scanlator, read, bookmark, last_page_read, chapter_number, source_order, date_fetch, date_upload, last_modified_at) INSERT INTO chapters(manga_id, url, name, scanlator, read, bookmark, last_page_read, chapter_number, source_order, date_fetch, date_upload, last_modified_at)
VALUES (:mangaId, :url, :name, :scanlator, :read, :bookmark, :lastPageRead, :chapterNumber, :sourceOrder, :dateFetch, :dateUpload, strftime('%s', 'now')); VALUES (:mangaId, :url, :name, :scanlator, :read, :bookmark, :lastPageRead, :chapterNumber, :sourceOrder, :dateFetch, :dateUpload, 0);
update: update:
UPDATE chapters UPDATE chapters

View File

@ -117,7 +117,7 @@ AND source IN :sourceIds;
insert: insert:
INSERT INTO mangas(source, url, artist, author, description, genre, title, status, thumbnail_url, favorite, last_update, next_update, initialized, viewer, chapter_flags, cover_last_modified, date_added, update_strategy, calculate_interval, last_modified_at) INSERT INTO mangas(source, url, artist, author, description, genre, title, status, thumbnail_url, favorite, last_update, next_update, initialized, viewer, chapter_flags, cover_last_modified, date_added, update_strategy, calculate_interval, last_modified_at)
VALUES (:source, :url, :artist, :author, :description, :genre, :title, :status, :thumbnailUrl, :favorite, :lastUpdate, :nextUpdate, :initialized, :viewerFlags, :chapterFlags, :coverLastModified, :dateAdded, :updateStrategy, :calculateInterval, strftime('%s', 'now')); VALUES (:source, :url, :artist, :author, :description, :genre, :title, :status, :thumbnailUrl, :favorite, :lastUpdate, :nextUpdate, :initialized, :viewerFlags, :chapterFlags, :coverLastModified, :dateAdded, :updateStrategy, :calculateInterval, 0);
update: update:
UPDATE mangas SET UPDATE mangas SET

View File

@ -20,7 +20,7 @@ END;
insert: insert:
INSERT INTO mangas_categories(manga_id, category_id, last_modified_at) INSERT INTO mangas_categories(manga_id, category_id, last_modified_at)
VALUES (:mangaId, :categoryId, strftime('%s', 'now')); VALUES (:mangaId, :categoryId, 0);
deleteMangaCategoryByMangaId: deleteMangaCategoryByMangaId:
DELETE FROM mangas_categories DELETE FROM mangas_categories

View File

@ -184,8 +184,6 @@
<string name="onboarding_storage_info">Select a folder where %1$s will store chapter downloads, backups, and more.\n\nA dedicated folder is recommended.\n\nSelected folder: %2$s</string> <string name="onboarding_storage_info">Select a folder where %1$s will store chapter downloads, backups, and more.\n\nA dedicated folder is recommended.\n\nSelected folder: %2$s</string>
<string name="onboarding_storage_action_select">Select a folder</string> <string name="onboarding_storage_action_select">Select a folder</string>
<string name="onboarding_storage_selection_required">A folder must be selected</string> <string name="onboarding_storage_selection_required">A folder must be selected</string>
<string name="onboarding_permission_type_required">Required</string>
<string name="onboarding_permission_type_optional">Optional</string>
<string name="onboarding_permission_install_apps">Install apps permission</string> <string name="onboarding_permission_install_apps">Install apps permission</string>
<string name="onboarding_permission_install_apps_description">To install source extensions.</string> <string name="onboarding_permission_install_apps_description">To install source extensions.</string>
<string name="onboarding_permission_notifications">Notification permission</string> <string name="onboarding_permission_notifications">Notification permission</string>
@ -334,6 +332,7 @@
<string name="ext_info_age_rating">Age rating</string> <string name="ext_info_age_rating">Age rating</string>
<string name="ext_nsfw_short">18+</string> <string name="ext_nsfw_short">18+</string>
<string name="ext_nsfw_warning">Sources from this extension may contain NSFW (18+) content</string> <string name="ext_nsfw_warning">Sources from this extension may contain NSFW (18+) content</string>
<string name="ext_permission_install_apps_warning">Permissions are needed to install extensions. Tap here to grant.</string>
<string name="ext_install_service_notif">Installing extension…</string> <string name="ext_install_service_notif">Installing extension…</string>
<string name="ext_installer_pref">Installer</string> <string name="ext_installer_pref">Installer</string>
<string name="ext_installer_legacy">Legacy</string> <string name="ext_installer_legacy">Legacy</string>
@ -510,6 +509,7 @@
<string name="backup_choice">What do you want to backup?</string> <string name="backup_choice">What do you want to backup?</string>
<string name="app_settings">App settings</string> <string name="app_settings">App settings</string>
<string name="source_settings">Source settings</string> <string name="source_settings">Source settings</string>
<string name="private_settings">Include sensitive settings (e.g., tracker login tokens)</string>
<string name="creating_backup">Creating backup</string> <string name="creating_backup">Creating backup</string>
<string name="creating_backup_error">Backup failed</string> <string name="creating_backup_error">Backup failed</string>
<string name="missing_storage_permission">Storage permissions not granted</string> <string name="missing_storage_permission">Storage permissions not granted</string>

View File

@ -473,4 +473,4 @@ abstract class HttpSource : CatalogueSource {
override fun getFilterList() = FilterList() override fun getFilterList() = FilterList()
} }
class LicensedMangaChaptersException : Exception("Licensed - No chapters to show") class LicensedMangaChaptersException : RuntimeException()