diff --git a/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt b/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt index 2e288d9793..630c5d66e8 100644 --- a/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt +++ b/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt @@ -35,6 +35,9 @@ abstract class SelectionDao { @Query("DELETE FROM patch_selections WHERE patch_bundle = :uid") abstract suspend fun clearForPatchBundle(uid: Int) + @Query("DELETE FROM patch_selections WHERE package_name = :packageName") + abstract suspend fun clearForPackage(packageName: String) + @Query("DELETE FROM patch_selections") abstract suspend fun reset() diff --git a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt index 34d7af60e0..5c08ba179c 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt @@ -21,4 +21,7 @@ class PreferencesManager( val showAutoUpdatesDialog = booleanPreference("show_auto_updates_dialog", true) val managerAutoUpdates = booleanPreference("manager_auto_updates", false) + + val disableSelectionWarning = booleanPreference("disable_selection_warning", false) + val enableSelectionWarningCountdown = booleanPreference("enable_selection_warning_countdown", true) } diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchSelectionRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchSelectionRepository.kt index cade429164..c34e5efd6b 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/PatchSelectionRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchSelectionRepository.kt @@ -25,6 +25,10 @@ class PatchSelectionRepository(db: AppDatabase) { ) }) + suspend fun clearSelection(packageName: String) { + dao.clearForPackage(packageName) + } + suspend fun reset() = dao.reset() suspend fun export(bundleUid: Int): SerializedSelection = dao.exportSelection(bundleUid) diff --git a/app/src/main/java/app/revanced/manager/ui/component/Countdown.kt b/app/src/main/java/app/revanced/manager/ui/component/Countdown.kt new file mode 100644 index 0000000000..0fb23c2728 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/Countdown.kt @@ -0,0 +1,26 @@ +package app.revanced.manager.ui.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import kotlinx.coroutines.delay + +@Composable +fun Countdown(start: Int, content: @Composable (Int) -> Unit) { + var timer by rememberSaveable(start) { + mutableStateOf(start) + } + LaunchedEffect(timer) { + if (timer == 0) { + return@LaunchedEffect + } + + delay(1000L) + timer -= 1 + } + + content(timer) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index 0d02ea2587..36d12f23c8 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -16,11 +16,15 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.outlined.HelpOutline +import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Restore import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.WarningAmber import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip @@ -35,26 +39,36 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R +import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.ui.component.AppTopBar +import app.revanced.manager.ui.component.Countdown import app.revanced.manager.ui.component.patches.OptionItem import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel +import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.BaseSelectionMode import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_SUPPORTED import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNSUPPORTED import app.revanced.manager.util.Options import app.revanced.manager.util.PatchesSelection import kotlinx.coroutines.launch +import org.koin.compose.rememberKoinInject @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -85,12 +99,49 @@ fun PatchesSelectorScreen( ) } + vm.pendingSelectionAction?.let { + SelectionWarningDialog( + onCancel = vm::dismissSelectionWarning, + onConfirm = vm::confirmSelectionWarning + ) + } + Scaffold( topBar = { AppTopBar( title = stringResource(R.string.select_patches), onBackClick = onBackClick, actions = { + IconButton(onClick = vm::reset) { + Icon(Icons.Outlined.Restore, stringResource(R.string.reset)) + } + + var dropdownActive by rememberSaveable { + mutableStateOf(false) + } + // This part should probably be changed + IconButton(onClick = { dropdownActive = true }) { + Icon(Icons.Outlined.MoreVert, stringResource(R.string.more)) + DropdownMenu( + expanded = dropdownActive, + onDismissRequest = { dropdownActive = false } + ) { + DropdownMenuItem( + text = { + val id = + if (vm.baseSelectionMode == BaseSelectionMode.DEFAULT) + R.string.menu_opt_selection_mode_previous else R.string.menu_opt_selection_mode_default + + Text(stringResource(id)) + }, + onClick = { + dropdownActive = false + vm.switchBaseSelectionMode() + }, + enabled = vm.hasPreviousSelection + ) + } + } IconButton(onClick = { }) { Icon(Icons.Outlined.Search, stringResource(R.string.search)) } @@ -102,9 +153,11 @@ fun PatchesSelectorScreen( text = { Text(stringResource(R.string.patch)) }, icon = { Icon(Icons.Default.Build, null) }, onClick = { + // TODO: only allow this if all required options have been set. composableScope.launch { - // TODO: only allow this if all required options have been set. - onPatchClick(vm.getAndSaveSelection(), vm.getOptions()) + val selection = vm.getSelection() + vm.saveSelection(selection).join() + onPatchClick(selection, vm.getOptions()) } } ) @@ -206,7 +259,15 @@ fun PatchesSelectorScreen( bundle.uid, patch ), - onToggle = { vm.togglePatch(bundle.uid, patch) }, + onToggle = { + if (vm.selectionWarningEnabled) { + vm.pendingSelectionAction = { + vm.togglePatch(bundle.uid, patch) + } + } else { + vm.togglePatch(bundle.uid, patch) + } + }, supported = supported ) } @@ -246,6 +307,84 @@ fun PatchesSelectorScreen( } } +@Composable +fun SelectionWarningDialog( + onCancel: () -> Unit, + onConfirm: (Boolean) -> Unit +) { + val prefs: PreferencesManager = rememberKoinInject() + var dismissPermanently by rememberSaveable { + mutableStateOf(false) + } + + AlertDialog( + onDismissRequest = onCancel, + confirmButton = { + val enableCountdown by prefs.enableSelectionWarningCountdown.getAsState() + + Countdown(start = if (enableCountdown) 3 else 0) { timer -> + LaunchedEffect(timer) { + if (timer == 0) prefs.enableSelectionWarningCountdown.update(false) + } + + TextButton( + onClick = { onConfirm(dismissPermanently) }, + enabled = timer == 0 + ) { + val text = + if (timer == 0) stringResource(R.string.continue_) else stringResource( + R.string.selection_warning_continue_countdown, timer + ) + Text(text, color = MaterialTheme.colorScheme.error) + } + } + }, + dismissButton = { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.cancel)) + } + }, + icon = { + Icon(Icons.Outlined.WarningAmber, null) + }, + title = { + Text( + text = stringResource(R.string.selection_warning_title), + style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center), + color = MaterialTheme.colorScheme.onSurface, + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.Start + ) { + Text( + text = stringResource(R.string.selection_warning_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.clickable { + dismissPermanently = !dismissPermanently + } + ) { + Checkbox( + checked = dismissPermanently, + onCheckedChange = { + dismissPermanently = it + } + ) + Text(stringResource(R.string.permanent_dismiss)) + } + } + } + ) +} + @Composable fun PatchItem( patch: PatchInfo, diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt index b5c6b67065..44cead31ba 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt @@ -1,18 +1,21 @@ package app.revanced.manager.ui.viewmodel +import android.app.Application import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi import androidx.lifecycle.viewmodel.compose.saveable +import app.revanced.manager.R import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.PatchSelectionRepository import app.revanced.manager.domain.repository.PatchBundleRepository @@ -20,12 +23,9 @@ import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.ui.destination.Destination import app.revanced.manager.util.Options import app.revanced.manager.util.PatchesSelection -import app.revanced.manager.util.SnapshotStateSet import app.revanced.manager.util.flatMapLatestAndCombine -import app.revanced.manager.util.mutableStateSetOf import app.revanced.manager.util.saver.snapshotStateMapSaver -import app.revanced.manager.util.saver.snapshotStateSetSaver -import app.revanced.manager.util.toMutableStateSet +import app.revanced.manager.util.toast import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -39,8 +39,17 @@ import org.koin.core.component.get class PatchesSelectorViewModel( val input: Destination.PatchesSelector ) : ViewModel(), KoinComponent { + private val app: Application = get() private val selectionRepository: PatchSelectionRepository = get() private val savedStateHandle: SavedStateHandle = get() + private val prefs: PreferencesManager = get() + + private val packageName = input.selectedApp.packageName + + var pendingSelectionAction by mutableStateOf<(() -> Unit)?>(null) + + var selectionWarningEnabled by mutableStateOf(true) + private set val allowExperimental = get().allowExperimental val bundlesFlow = get().sources.flatMapLatestAndCombine( @@ -54,7 +63,7 @@ class PatchesSelectorViewModel( val unsupported = mutableListOf() val universal = mutableListOf() - bundle.patches.filter { it.compatibleWith(input.selectedApp.packageName) }.forEach { + bundle.patches.filter { it.compatibleWith(packageName) }.forEach { val targetList = when { it.compatiblePackages == null -> universal it.supportsVersion(input.selectedApp.version) -> supported @@ -68,38 +77,72 @@ class PatchesSelectorViewModel( } } - private val selectedPatches: SnapshotStatePatchesSelection by savedStateHandle.saveable( - saver = patchesSelectionSaver, - init = { - val map: SnapshotStatePatchesSelection = mutableStateMapOf() - viewModelScope.launch(Dispatchers.Default) { - val bundles = bundlesFlow.first() - val filteredSelection = - (input.patchesSelection - ?: selectionRepository.getSelection(input.selectedApp.packageName)) - .mapValues { (uid, patches) -> - // Filter out patches that don't exist. - val filteredPatches = bundles.singleOrNull { it.uid == uid } - ?.let { bundle -> - val allPatches = bundle.all.map { it.name } - patches.filter { allPatches.contains(it) } - } - ?: patches - - filteredPatches.toMutableStateSet() - } - - withContext(Dispatchers.Main) { - map.putAll(filteredSelection) - } + init { + viewModelScope.launch { + if (prefs.disableSelectionWarning.get()) { + selectionWarningEnabled = false + return@launch } - return@saveable map - }) - private val patchOptions: SnapshotStateOptions by savedStateHandle.saveable( + + val experimental = allowExperimental.get() + fun BundleInfo.hasDefaultPatches(): Boolean { + return if (experimental) { + all.asSequence() + } else { + sequence { + yieldAll(supported) + yieldAll(universal) + } + }.any { it.include } + } + + // Don't show the warning if there are no default patches. + selectionWarningEnabled = bundlesFlow.first().any(BundleInfo::hasDefaultPatches) + } + } + + var baseSelectionMode by mutableStateOf(BaseSelectionMode.DEFAULT) + private set + + private val previousPatchesSelection: SnapshotStateMap> = mutableStateMapOf() + + init { + viewModelScope.launch(Dispatchers.Default) { loadPreviousSelection() } + } + + val hasPreviousSelection by derivedStateOf { + previousPatchesSelection.filterValues(Set::isNotEmpty).isNotEmpty() + } + + private var hasModifiedSelection = false + + private val explicitPatchesSelection: SnapshotExplicitPatchesSelection by savedStateHandle.saveable( + saver = explicitPatchesSelectionSaver, + init = ::mutableStateMapOf + ) + + private val patchOptions: SnapshotOptions by savedStateHandle.saveable( saver = optionsSaver, init = ::mutableStateMapOf ) + private val selectors by derivedStateOf> { + arrayOf( + // Patches that were explicitly selected + { bundle, patch -> + explicitPatchesSelection[bundle]?.get(patch.name) + }, + // The fallback selection. + when (baseSelectionMode) { + BaseSelectionMode.DEFAULT -> ({ _, patch -> patch.include }) + + BaseSelectionMode.PREVIOUS -> ({ bundle, patch -> + previousPatchesSelection[bundle]?.contains(patch.name) ?: false + }) + } + ) + } + /** * Show the patch options dialog for this patch. */ @@ -107,35 +150,91 @@ class PatchesSelectorViewModel( val compatibleVersions = mutableStateListOf() - var filter by mutableStateOf(SHOW_SUPPORTED or SHOW_UNSUPPORTED) + var filter by mutableStateOf(SHOW_SUPPORTED or SHOW_UNIVERSAL or SHOW_UNSUPPORTED) private set + private suspend fun loadPreviousSelection() { + val selection = (input.patchesSelection ?: selectionRepository.getSelection( + packageName + )).mapValues { (_, value) -> value.toSet() } + + withContext(Dispatchers.Main) { + previousPatchesSelection.putAll(selection) + } + } + + fun switchBaseSelectionMode() = viewModelScope.launch { + baseSelectionMode = if (baseSelectionMode == BaseSelectionMode.DEFAULT) { + BaseSelectionMode.PREVIOUS + } else { + BaseSelectionMode.DEFAULT + } + } + private fun getOrCreateSelection(bundle: Int) = - selectedPatches.getOrPut(bundle, ::mutableStateSetOf) + explicitPatchesSelection.getOrPut(bundle, ::mutableStateMapOf) fun isSelected(bundle: Int, patch: PatchInfo) = - selectedPatches[bundle]?.contains(patch.name) ?: false + selectors.firstNotNullOf { fn -> fn(bundle, patch) } fun togglePatch(bundle: Int, patch: PatchInfo) { - val name = patch.name val patches = getOrCreateSelection(bundle) - if (patches.contains(name)) patches.remove(name) else patches.add(name) + hasModifiedSelection = true + patches[patch.name] = !isSelected(bundle, patch) } - suspend fun getAndSaveSelection(): PatchesSelection = - selectedPatches.also { - withContext(Dispatchers.Default) { - selectionRepository.updateSelection(input.selectedApp.packageName, it) - } - }.mapValues { it.value.toMutableSet() }.apply { - if (allowExperimental.get()) { - return@apply + fun confirmSelectionWarning(dismissPermanently: Boolean) { + selectionWarningEnabled = false + + pendingSelectionAction?.invoke() + pendingSelectionAction = null + + if (!dismissPermanently) return + + viewModelScope.launch { + prefs.disableSelectionWarning.update(true) + } + } + + fun dismissSelectionWarning() { + pendingSelectionAction = null + } + + fun reset() { + patchOptions.clear() + baseSelectionMode = BaseSelectionMode.DEFAULT + explicitPatchesSelection.clear() + hasModifiedSelection = false + app.toast(app.getString(R.string.patch_selection_reset_toast)) + } + + suspend fun getSelection(): PatchesSelection { + val bundles = bundlesFlow.first() + val removeUnsupported = !allowExperimental.get() + + return bundles.associate { bundle -> + val included = + bundle.all.filter { isSelected(bundle.uid, it) }.map { it.name }.toMutableSet() + + if (removeUnsupported) { + val unsupported = bundle.unsupported.map { it.name }.toSet() + included.removeAll(unsupported) } - // Filter out unsupported patches that may have gotten selected through the database if the setting is not enabled. - bundlesFlow.first().forEach { - this[it.uid]?.removeAll(it.unsupported.map { patch -> patch.name }.toSet()) + bundle.uid to included + } + } + + suspend fun saveSelection(selection: PatchesSelection) = + viewModelScope.launch(Dispatchers.Default) { + when { + hasModifiedSelection -> selectionRepository.updateSelection(packageName, selection) + baseSelectionMode == BaseSelectionMode.DEFAULT -> selectionRepository.clearSelection( + packageName + ) + + else -> {} } } @@ -180,7 +279,7 @@ class PatchesSelectorViewModel( private fun SnapshotStateMap>.getOrCreate(key: K) = getOrPut(key, ::mutableStateMapOf) - private val optionsSaver: Saver = snapshotStateMapSaver( + private val optionsSaver: Saver = snapshotStateMapSaver( // Patch name -> Options valueSaver = snapshotStateMapSaver( // Option key -> Option value @@ -188,8 +287,24 @@ class PatchesSelectorViewModel( ) ) - private val patchesSelectionSaver: Saver = - snapshotStateMapSaver(valueSaver = snapshotStateSetSaver()) + private val explicitPatchesSelectionSaver: Saver = + snapshotStateMapSaver(valueSaver = snapshotStateMapSaver()) + } + + /** + * An enum for controlling the behavior of the selector. + */ + enum class BaseSelectionMode { + /** + * Selection is determined by the [PatchInfo.include] field. + */ + DEFAULT, + + /** + * Selection is determined by what the user selected previously. + * Any patch that is not part of the previous selection will be deselected. + */ + PREVIOUS } data class BundleInfo( @@ -202,12 +317,9 @@ class PatchesSelectorViewModel( ) } -/** - * [Options] but with observable collection types. - */ -private typealias SnapshotStateOptions = SnapshotStateMap>> +private typealias Selector = (Int, PatchInfo) -> Boolean? +private typealias ExplicitPatchesSelection = Map> -/** - * [PatchesSelection] but with observable collection types. - */ -private typealias SnapshotStatePatchesSelection = SnapshotStateMap> \ No newline at end of file +// Versions of other types, but utilizing observable collection types instead. +private typealias SnapshotOptions = SnapshotStateMap>> +private typealias SnapshotExplicitPatchesSelection = SnapshotStateMap> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ec2f613233..58c4b940a0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -143,6 +143,12 @@ Unsupported app Unsupported patches Universal patches + Use default selection + Use previous selection + Patch selection and options has been reset to recommended defaults + Stop using defaults? + You may encounter issues when not using the default patch selection and options. + Continue (%ds) Supported Universal Unsupported @@ -223,6 +229,8 @@ collapse More + Continue + Do not show this again Donate Website GitHub