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)
enum class ExtensionInstaller(val titleRes: StringResource) {
LEGACY(MR.strings.ext_installer_legacy),
PACKAGEINSTALLER(MR.strings.ext_installer_packageinstaller),
SHIZUKU(MR.strings.ext_installer_shizuku),
PRIVATE(MR.strings.ext_installer_private),
enum class ExtensionInstaller(val titleRes: StringResource, val requiresSystemPermission: Boolean) {
LEGACY(MR.strings.ext_installer_legacy, true),
PACKAGEINSTALLER(MR.strings.ext_installer_packageinstaller, true),
SHIZUKU(MR.strings.ext_installer_shizuku, false),
PRIVATE(MR.strings.ext_installer_private, false),
}
}

View File

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

View File

@ -11,8 +11,6 @@ import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.material.icons.Icons
import androidx.compose.material.icons.filled.Check
@ -35,33 +33,29 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.secondaryItemAlpha
internal class PermissionStep : OnboardingStep {
private var installGranted by mutableStateOf(false)
private var notificationGranted by mutableStateOf(false)
private var batteryGranted by mutableStateOf(false)
override val isComplete: Boolean
get() = installGranted
override val isComplete: Boolean = true
@Composable
override fun Content() {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val installGranted = rememberRequestPackageInstallsPermissionState()
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
}
notificationGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
@ -78,31 +72,16 @@ internal class PermissionStep : OnboardingStep {
}
}
Column(
modifier = Modifier.padding(vertical = 16.dp),
) {
SectionHeader(stringResource(MR.strings.onboarding_permission_type_required))
Column {
PermissionItem(
title = stringResource(MR.strings.onboarding_permission_install_apps),
subtitle = stringResource(MR.strings.onboarding_permission_install_apps_description),
granted = installGranted,
onButtonClick = {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
}
} else {
Intent(Settings.ACTION_SECURITY_SETTINGS)
}
context.startActivity(intent)
context.launchRequestPackageInstallsPermission()
},
)
Spacer(modifier = Modifier.height(16.dp))
SectionHeader(stringResource(MR.strings.onboarding_permission_type_optional))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permissionRequester = rememberLauncherForActivityResult(
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.ui.more.OnboardingScreen
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.isReleaseBuildType
import eu.kanade.tachiyomi.util.system.isShizukuInstalled
@ -370,6 +371,14 @@ object SettingsAdvancedScreen : SearchableSettings {
pref = extensionInstallerPref,
title = stringResource(MR.strings.ext_installer_pref),
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) }
.toImmutableMap(),
onValueChanged = {

View File

@ -29,16 +29,11 @@ import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.WarningBanner
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.BackupCreator
import eu.kanade.tachiyomi.data.backup.create.BackupOptions
import eu.kanade.tachiyomi.util.system.DeviceUtil
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 tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.LabeledCheckbox
@ -101,13 +96,13 @@ class CreateBackupScreen : Screen() {
modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium),
)
}
BackupChoices.forEach { (k, v) ->
BackupOptions.entries.forEach { option ->
item {
LabeledCheckbox(
label = stringResource(v),
checked = state.flags.contains(k),
label = stringResource(option.label),
checked = option.getter(state.options),
onCheckedChange = {
model.toggleFlag(k)
model.toggle(option.setter, it)
},
modifier = Modifier.padding(horizontal = MaterialTheme.padding.medium),
)
@ -145,37 +140,20 @@ class CreateBackupScreen : Screen() {
private class CreateBackupScreenModel : StateScreenModel<CreateBackupScreenModel.State>(State()) {
fun toggleFlag(flag: Int) {
fun toggle(setter: (BackupOptions, Boolean) -> BackupOptions, enabled: Boolean) {
mutableState.update {
if (it.flags.contains(flag)) {
it.copy(flags = it.flags - flag)
} else {
it.copy(flags = it.flags + flag)
}
it.copy(
options = setter(it.options, enabled),
)
}
}
fun createBackup(context: Context, uri: Uri) {
val flags = state.value.flags.fold(initial = 0, operation = { a, b -> a or b })
BackupCreateJob.startNow(context, uri, flags)
BackupCreateJob.startNow(context, uri, state.value.options)
}
@Immutable
data class State(
val flags: PersistentSet<Int> = persistentSetOf(
BackupCreateFlags.BACKUP_CATEGORY,
BackupCreateFlags.BACKUP_CHAPTER,
BackupCreateFlags.BACKUP_TRACK,
BackupCreateFlags.BACKUP_HISTORY,
),
val options: BackupOptions = BackupOptions(),
)
}
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.restore.BackupRestoreJob
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.isRunning
import eu.kanade.tachiyomi.util.system.setForegroundSafely
@ -47,10 +49,11 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete
setForegroundSafely()
val flags = inputData.getInt(BACKUP_FLAGS_KEY, BackupCreateFlags.AutomaticDefaults)
val options: BackupOptions = inputData.getBooleanArray(OPTIONS_KEY)?.asDataClass()
?: BackupOptions()
return try {
val location = BackupCreator(context, isAutoBackup).backup(uri, flags)
val location = BackupCreator(context, isAutoBackup).backup(uri, options)
if (!isAutoBackup) {
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(
IS_AUTO_BACKUP_KEY to false,
LOCATION_URI_KEY to uri.toString(),
BACKUP_FLAGS_KEY to flags,
OPTIONS_KEY to options.asBooleanArray(),
)
val request = OneTimeWorkRequestBuilder<BackupCreateJob>()
.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 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 eu.kanade.tachiyomi.BuildConfig
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.MangaBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.PreferenceBackupCreator
@ -52,7 +49,7 @@ class BackupCreator(
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
try {
file = (
@ -73,19 +70,18 @@ class BackupCreator(
UniFile.fromUri(context, uri)
}
)
?: throw Exception(context.stringResource(MR.strings.create_backup_file_error))
if (!file.isFile) {
throw IllegalStateException("Failed to get handle on a backup file")
if (file == null || !file.isFile) {
throw IllegalStateException(context.stringResource(MR.strings.create_backup_file_error))
}
val databaseManga = getFavorites.await()
val backup = Backup(
backupManga = backupMangas(databaseManga, flags),
backupCategories = backupCategories(flags),
backupManga = backupMangas(databaseManga, options),
backupCategories = backupCategories(options),
backupSources = backupSources(databaseManga),
backupPreferences = backupAppPreferences(flags),
backupSourcePreferences = backupSourcePreferences(flags),
backupPreferences = backupAppPreferences(options),
backupSourcePreferences = backupSourcePreferences(options),
)
val byteArray = parser.encodeToByteArray(BackupSerializer, backup)
@ -118,30 +114,30 @@ class BackupCreator(
}
}
suspend fun backupCategories(options: Int): List<BackupCategory> {
if (options and BACKUP_CATEGORY != BACKUP_CATEGORY) return emptyList()
suspend fun backupCategories(options: BackupOptions): List<BackupCategory> {
if (!options.categories) return emptyList()
return categoriesBackupCreator.backupCategories()
}
suspend fun backupMangas(mangas: List<Manga>, flags: Int): List<BackupManga> {
return mangaBackupCreator.backupMangas(mangas, flags)
suspend fun backupMangas(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
return mangaBackupCreator.backupMangas(mangas, options)
}
fun backupSources(mangas: List<Manga>): List<BackupSource> {
return sourcesBackupCreator.backupSources(mangas)
}
fun backupAppPreferences(flags: Int): List<BackupPreference> {
if (flags and BACKUP_APP_PREFS != BACKUP_APP_PREFS) return emptyList()
fun backupAppPreferences(options: BackupOptions): List<BackupPreference> {
if (!options.appSettings) return emptyList()
return preferenceBackupCreator.backupAppPreferences()
return preferenceBackupCreator.backupAppPreferences(includePrivatePreferences = options.privateSettings)
}
fun backupSourcePreferences(flags: Int): List<BackupSourcePreferences> {
if (flags and BACKUP_SOURCE_PREFS != BACKUP_SOURCE_PREFS) return emptyList()
fun backupSourcePreferences(options: BackupOptions): List<BackupSourcePreferences> {
if (!options.sourceSettings) return emptyList()
return preferenceBackupCreator.backupSourcePreferences()
return preferenceBackupCreator.backupSourcePreferences(includePrivatePreferences = options.privateSettings)
}
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
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.BackupHistory
import eu.kanade.tachiyomi.data.backup.models.BackupManga
@ -20,18 +20,17 @@ class MangaBackupCreator(
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 {
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
val mangaObject = manga.toBackupManga()
// Check if user wants chapter information in backup
if (options and BackupCreateFlags.BACKUP_CHAPTER == BackupCreateFlags.BACKUP_CHAPTER) {
if (options.chapters) {
// Backup all the chapters
handler.awaitList {
chaptersQueries.getChaptersByMangaId(
@ -44,8 +43,7 @@ class MangaBackupCreator(
?.let { mangaObject.chapters = it }
}
// Check if user wants category information in backup
if (options and BackupCreateFlags.BACKUP_CATEGORY == BackupCreateFlags.BACKUP_CATEGORY) {
if (options.categories) {
// Backup categories for this manga
val categoriesForManga = getCategories.await(manga.id)
if (categoriesForManga.isNotEmpty()) {
@ -53,16 +51,14 @@ class MangaBackupCreator(
}
}
// Check if user wants track information in backup
if (options and BackupCreateFlags.BACKUP_TRACK == BackupCreateFlags.BACKUP_TRACK) {
if (options.tracking) {
val tracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id, backupTrackMapper) }
if (tracks.isNotEmpty()) {
mangaObject.tracking = tracks
}
}
// Check if user wants history information in backup
if (options and BackupCreateFlags.BACKUP_HISTORY == BackupCreateFlags.BACKUP_HISTORY) {
if (options.history) {
val historyByMangaId = getHistory.await(manga.id)
if (historyByMangaId.isNotEmpty()) {
val history = historyByMangaId.map { history ->

View File

@ -22,26 +22,27 @@ class PreferenceBackupCreator(
private val preferenceStore: PreferenceStore = Injekt.get(),
) {
fun backupAppPreferences(): List<BackupPreference> {
fun backupAppPreferences(includePrivatePreferences: Boolean): List<BackupPreference> {
return preferenceStore.getAll().toBackupPreferences()
.withPrivatePreferences(includePrivatePreferences)
}
fun backupSourcePreferences(): List<BackupSourcePreferences> {
fun backupSourcePreferences(includePrivatePreferences: Boolean): List<BackupSourcePreferences> {
return sourceManager.getCatalogueSources()
.filterIsInstance<ConfigurableSource>()
.map {
BackupSourcePreferences(
it.preferenceKey(),
it.sourcePreferences().all.toBackupPreferences(),
it.sourcePreferences().all.toBackupPreferences()
.withPrivatePreferences(includePrivatePreferences),
)
}
}
@Suppress("UNCHECKED_CAST")
private fun Map<String, *>.toBackupPreferences(): List<BackupPreference> {
return this.filterKeys {
!Preference.isPrivate(it) && !Preference.isAppState(it)
}
return this
.filterKeys { !Preference.isAppState(it) }
.mapNotNull { (key, value) ->
when (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 eu.kanade.tachiyomi.data.backup.BackupNotifier
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.isRunning
import eu.kanade.tachiyomi.util.system.setForegroundSafely
@ -30,8 +32,7 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
override suspend fun doWork(): Result {
val uri = inputData.getString(LOCATION_URI_KEY)?.toUri()
val options = inputData.getBooleanArray(OPTIONS_KEY)
?.let { RestoreOptions.fromBooleanArray(it) }
val options: RestoreOptions? = inputData.getBooleanArray(OPTIONS_KEY)?.asDataClass()
if (uri == null || options == null) {
return Result.failure()
@ -84,7 +85,7 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet
val inputData = workDataOf(
LOCATION_URI_KEY to uri.toString(),
SYNC_KEY to sync,
OPTIONS_KEY to options.toBooleanArray(),
OPTIONS_KEY to options.asBooleanArray(),
)
val request = OneTimeWorkRequestBuilder<BackupRestoreJob>()
.addTag(TAG)

View File

@ -4,14 +4,4 @@ data class RestoreOptions(
val appSettings: Boolean = true,
val sourceSettings: 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.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.BackupOptions
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupChapter
import eu.kanade.tachiyomi.data.backup.models.BackupManga
@ -72,12 +72,22 @@ class SyncManager(
*/
suspend fun syncData() {
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(
backupManga = backupCreator.backupMangas(databaseManga, BackupCreateFlags.AutomaticDefaults),
backupCategories = backupCreator.backupCategories(BackupCreateFlags.AutomaticDefaults),
backupManga = backupCreator.backupMangas(databaseManga, backupOptions),
backupCategories = backupCreator.backupCategories(backupOptions),
backupSources = backupCreator.backupSources(databaseManga),
backupPreferences = backupCreator.backupAppPreferences(BackupCreateFlags.AutomaticDefaults),
backupSourcePreferences = backupCreator.backupSourcePreferences(BackupCreateFlags.AutomaticDefaults),
backupPreferences = backupCreator.backupAppPreferences(backupOptions),
backupSourcePreferences = backupCreator.backupSourcePreferences(backupOptions),
)
// Create the SyncData object

View File

@ -120,7 +120,7 @@ abstract class SyncService(
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()) {
is Source -> listOf(obj)
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) {
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.screenModelScope
import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.extension.interactor.GetExtensionsByType
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.presentation.components.SEARCH_DEBOUNCE_MILLIS
@ -34,6 +35,7 @@ import kotlin.time.Duration.Companion.seconds
class ExtensionsScreenModel(
preferences: SourcePreferences = Injekt.get(),
basePreferences: BasePreferences = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(),
private val getExtensions: GetExtensionsByType = Injekt.get(),
) : StateScreenModel<ExtensionsScreenModel.State>(State()) {
@ -124,6 +126,10 @@ class ExtensionsScreenModel(
preferences.extensionUpdatesCount().changes()
.onEach { mutableState.update { state -> state.copy(updates = it) } }
.launchIn(screenModelScope)
basePreferences.extensionInstaller().changes()
.onEach { mutableState.update { state -> state.copy(installer = it) } }
.launchIn(screenModelScope)
}
fun search(query: String?) {
@ -199,6 +205,7 @@ class ExtensionsScreenModel(
val isRefreshing: Boolean = false,
val items: ItemGroups = mutableMapOf(),
val updates: Int = 0,
val installer: BasePreferences.ExtensionInstaller? = null,
val searchQuery: String? = null,
) {
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.os.Build
import android.os.PowerManager
import android.provider.Settings
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.content.getSystemService
import androidx.core.net.toUri
@ -167,3 +168,14 @@ fun Context.isInstalledFromFDroid(): Boolean {
// F-Droid builds typically disable the updater
(!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 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 chapters

View File

@ -117,7 +117,7 @@ AND source IN :sourceIds;
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)
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 mangas SET

View File

@ -20,7 +20,7 @@ END;
insert:
INSERT INTO mangas_categories(manga_id, category_id, last_modified_at)
VALUES (:mangaId, :categoryId, strftime('%s', 'now'));
VALUES (:mangaId, :categoryId, 0);
deleteMangaCategoryByMangaId:
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_action_select">Select a folder</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_description">To install source extensions.</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_nsfw_short">18+</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_installer_pref">Installer</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="app_settings">App 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_error">Backup failed</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()
}
class LicensedMangaChaptersException : Exception("Licensed - No chapters to show")
class LicensedMangaChaptersException : RuntimeException()