diff --git a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt index af23735e9..24ad9a1b7 100644 --- a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt @@ -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), } } diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt index 3b63e86fd..c9c83ab45 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt @@ -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(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) }) { diff --git a/app/src/main/java/eu/kanade/presentation/more/onboarding/PermissionStep.kt b/app/src/main/java/eu/kanade/presentation/more/onboarding/PermissionStep.kt index e7e3ec598..79e45159f 100644 --- a/app/src/main/java/eu/kanade/presentation/more/onboarding/PermissionStep.kt +++ b/app/src/main/java/eu/kanade/presentation/more/onboarding/PermissionStep.kt @@ -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(), diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt index 921fd4ae4..4a486b0b4 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt @@ -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 = { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/CreateBackupScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/CreateBackupScreen.kt index 9811a3cce..cf673b28d 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/CreateBackupScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/data/CreateBackupScreen.kt @@ -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(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 = 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, -) diff --git a/app/src/main/java/eu/kanade/presentation/util/Permissions.kt b/app/src/main/java/eu/kanade/presentation/util/Permissions.kt new file mode 100644 index 000000000..e3f2df2c8 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/util/Permissions.kt @@ -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 +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateFlags.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateFlags.kt deleted file mode 100644 index 25c843a0d..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateFlags.kt +++ /dev/null @@ -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 -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateJob.kt index c2f1e5ded..ae546aa8f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreateJob.kt @@ -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() .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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreator.kt index 2ec832e3e..1b39d879e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupCreator.kt @@ -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 { - if (options and BACKUP_CATEGORY != BACKUP_CATEGORY) return emptyList() + suspend fun backupCategories(options: BackupOptions): List { + if (!options.categories) return emptyList() return categoriesBackupCreator.backupCategories() } - suspend fun backupMangas(mangas: List, flags: Int): List { - return mangaBackupCreator.backupMangas(mangas, flags) + suspend fun backupMangas(mangas: List, options: BackupOptions): List { + return mangaBackupCreator.backupMangas(mangas, options) } fun backupSources(mangas: List): List { return sourcesBackupCreator.backupSources(mangas) } - fun backupAppPreferences(flags: Int): List { - if (flags and BACKUP_APP_PREFS != BACKUP_APP_PREFS) return emptyList() + fun backupAppPreferences(options: BackupOptions): List { + if (!options.appSettings) return emptyList() - return preferenceBackupCreator.backupAppPreferences() + return preferenceBackupCreator.backupAppPreferences(includePrivatePreferences = options.privateSettings) } - fun backupSourcePreferences(flags: Int): List { - if (flags and BACKUP_SOURCE_PREFS != BACKUP_SOURCE_PREFS) return emptyList() + fun backupSourcePreferences(options: BackupOptions): List { + if (!options.sourceSettings) return emptyList() - return preferenceBackupCreator.backupSourcePreferences() + return preferenceBackupCreator.backupSourcePreferences(includePrivatePreferences = options.privateSettings) } companion object { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupOptions.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupOptions.kt new file mode 100644 index 000000000..13e1cf480 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/BackupOptions.kt @@ -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, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt index 67182ba83..1c03f7fce 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt @@ -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, flags: Int): List { + suspend fun backupMangas(mangas: List, options: BackupOptions): List { 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 -> diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/PreferenceBackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/PreferenceBackupCreator.kt index c75612de9..cc1a7157f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/PreferenceBackupCreator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/PreferenceBackupCreator.kt @@ -22,26 +22,27 @@ class PreferenceBackupCreator( private val preferenceStore: PreferenceStore = Injekt.get(), ) { - fun backupAppPreferences(): List { + fun backupAppPreferences(includePrivatePreferences: Boolean): List { return preferenceStore.getAll().toBackupPreferences() + .withPrivatePreferences(includePrivatePreferences) } - fun backupSourcePreferences(): List { + fun backupSourcePreferences(includePrivatePreferences: Boolean): List { return sourceManager.getCatalogueSources() .filterIsInstance() .map { BackupSourcePreferences( it.preferenceKey(), - it.sourcePreferences().all.toBackupPreferences(), + it.sourcePreferences().all.toBackupPreferences() + .withPrivatePreferences(includePrivatePreferences), ) } } @Suppress("UNCHECKED_CAST") private fun Map.toBackupPreferences(): List { - 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.withPrivatePreferences(include: Boolean) = + if (include) { + this + } else { + this.filter { !Preference.isPrivate(it.key) } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestoreJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestoreJob.kt index f1b3a28c9..470efcd83 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestoreJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestoreJob.kt @@ -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() .addTag(TAG) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/RestoreOptions.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/RestoreOptions.kt index d9e124a81..bd5bface9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/RestoreOptions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/RestoreOptions.kt @@ -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], - ) - } -} +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt index c40d78157..eccf66cd6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt @@ -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)" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsScreenModel.kt index 13374b28d..f6ed811a7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/extension/ExtensionsScreenModel.kt @@ -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(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() diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/lang/BooleanArrayExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/BooleanArrayExtensions.kt new file mode 100644 index 000000000..f7b577889 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/BooleanArrayExtensions.kt @@ -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.asBooleanArray(): BooleanArray { + return this::class.declaredMemberProperties + .filterIsInstance>() + .map { it.get(this) } + .toBooleanArray() +} + +inline fun BooleanArray.asDataClass(): T { + val properties = T::class.declaredMemberProperties.filterIsInstance>() + require(properties.size == this.size) { "Boolean array size does not match data class property count" } + return T::class.primaryConstructor!!.call(*this.toTypedArray()) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt index 05c0971fd..96560e59a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt @@ -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) +} diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index 9fb2052fc..01680f105 100644 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -184,8 +184,6 @@ Select a folder where %1$s will store chapter downloads, backups, and more.\n\nA dedicated folder is recommended.\n\nSelected folder: %2$s Select a folder A folder must be selected - Required - Optional Install apps permission To install source extensions. Notification permission @@ -334,6 +332,7 @@ Age rating 18+ Sources from this extension may contain NSFW (18+) content + Permissions are needed to install extensions. Tap here to grant. Installing extension… Installer Legacy @@ -510,6 +509,7 @@ What do you want to backup? App settings Source settings + Include sensitive settings (e.g., tracker login tokens) Creating backup Backup failed Storage permissions not granted diff --git a/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt b/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt index ac396fadf..3a079953b 100644 --- a/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt +++ b/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt @@ -473,4 +473,4 @@ abstract class HttpSource : CatalogueSource { override fun getFilterList() = FilterList() } -class LicensedMangaChaptersException : Exception("Licensed - No chapters to show") +class LicensedMangaChaptersException : RuntimeException()