feat(patch-selector): default patches selection (#1272)

This commit is contained in:
Ax333l 2023-10-01 20:56:16 +02:00 committed by GitHub
parent ca3c9af3b8
commit f78b56ef0a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 364 additions and 69 deletions

View File

@ -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()

View File

@ -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)
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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,

View File

@ -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<PreferencesManager>().allowExperimental
val bundlesFlow = get<PatchBundleRepository>().sources.flatMapLatestAndCombine(
@ -54,7 +63,7 @@ class PatchesSelectorViewModel(
val unsupported = mutableListOf<PatchInfo>()
val universal = mutableListOf<PatchInfo>()
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<Int, Set<String>> = mutableStateMapOf()
init {
viewModelScope.launch(Dispatchers.Default) { loadPreviousSelection() }
}
val hasPreviousSelection by derivedStateOf {
previousPatchesSelection.filterValues(Set<String>::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<Array<Selector>> {
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<String>()
var filter by mutableStateOf(SHOW_SUPPORTED or SHOW_UNSUPPORTED)
var filter by mutableStateOf(SHOW_SUPPORTED or SHOW_UNIVERSAL or SHOW_UNSUPPORTED)
private set
private fun getOrCreateSelection(bundle: Int) =
selectedPatches.getOrPut(bundle, ::mutableStateSetOf)
private suspend fun loadPreviousSelection() {
val selection = (input.patchesSelection ?: selectionRepository.getSelection(
packageName
)).mapValues { (_, value) -> value.toSet() }
fun isSelected(bundle: Int, patch: PatchInfo) =
selectedPatches[bundle]?.contains(patch.name) ?: false
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)
withContext(Dispatchers.Main) {
previousPatchesSelection.putAll(selection)
}
}
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 switchBaseSelectionMode() = viewModelScope.launch {
baseSelectionMode = if (baseSelectionMode == BaseSelectionMode.DEFAULT) {
BaseSelectionMode.PREVIOUS
} else {
BaseSelectionMode.DEFAULT
}
}
private fun getOrCreateSelection(bundle: Int) =
explicitPatchesSelection.getOrPut(bundle, ::mutableStateMapOf)
fun isSelected(bundle: Int, patch: PatchInfo) =
selectors.firstNotNullOf { fn -> fn(bundle, patch) }
fun togglePatch(bundle: Int, patch: PatchInfo) {
val patches = getOrCreateSelection(bundle)
hasModifiedSelection = true
patches[patch.name] = !isSelected(bundle, patch)
}
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 <K, K2, V> SnapshotStateMap<K, SnapshotStateMap<K2, V>>.getOrCreate(key: K) =
getOrPut(key, ::mutableStateMapOf)
private val optionsSaver: Saver<SnapshotStateOptions, Options> = snapshotStateMapSaver(
private val optionsSaver: Saver<SnapshotOptions, Options> = snapshotStateMapSaver(
// Patch name -> Options
valueSaver = snapshotStateMapSaver(
// Option key -> Option value
@ -188,8 +287,24 @@ class PatchesSelectorViewModel(
)
)
private val patchesSelectionSaver: Saver<SnapshotStatePatchesSelection, PatchesSelection> =
snapshotStateMapSaver(valueSaver = snapshotStateSetSaver())
private val explicitPatchesSelectionSaver: Saver<SnapshotExplicitPatchesSelection, ExplicitPatchesSelection> =
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<Int, SnapshotStateMap<String, SnapshotStateMap<String, Any?>>>
private typealias Selector = (Int, PatchInfo) -> Boolean?
private typealias ExplicitPatchesSelection = Map<Int, Map<String, Boolean>>
/**
* [PatchesSelection] but with observable collection types.
*/
private typealias SnapshotStatePatchesSelection = SnapshotStateMap<Int, SnapshotStateSet<String>>
// Versions of other types, but utilizing observable collection types instead.
private typealias SnapshotOptions = SnapshotStateMap<Int, SnapshotStateMap<String, SnapshotStateMap<String, Any?>>>
private typealias SnapshotExplicitPatchesSelection = SnapshotStateMap<Int, SnapshotStateMap<String, Boolean>>

View File

@ -143,6 +143,12 @@
<string name="unsupported_app">Unsupported app</string>
<string name="unsupported_patches">Unsupported patches</string>
<string name="universal_patches">Universal patches</string>
<string name="menu_opt_selection_mode_default">Use default selection</string>
<string name="menu_opt_selection_mode_previous">Use previous selection</string>
<string name="patch_selection_reset_toast">Patch selection and options has been reset to recommended defaults</string>
<string name="selection_warning_title">Stop using defaults?</string>
<string name="selection_warning_description">You may encounter issues when not using the default patch selection and options.</string>
<string name="selection_warning_continue_countdown">Continue (%ds)</string>
<string name="supported">Supported</string>
<string name="universal">Universal</string>
<string name="unsupported">Unsupported</string>
@ -223,6 +229,8 @@
<string name="collapse_content">collapse</string>
<string name="more">More</string>
<string name="continue_">Continue</string>
<string name="permanent_dismiss">Do not show this again</string>
<string name="donate">Donate</string>
<string name="website">Website</string>
<string name="github">GitHub</string>