feat: patch options UI (#80)

This commit is contained in:
Ax333l 2023-08-12 10:41:22 +02:00 committed by GitHub
parent 3f059d7748
commit 7aea9473de
6 changed files with 260 additions and 113 deletions

View file

@ -36,6 +36,13 @@ fun AppScaffold(
fun AppTopBar(
title: String,
onBackClick: (() -> Unit)? = null,
backIcon: @Composable (() -> Unit) = @Composable {
Icon(
imageVector = Icons.Default.ArrowBack, contentDescription = stringResource(
R.string.back
)
)
},
actions: @Composable (RowScope.() -> Unit) = {},
scrollBehavior: TopAppBarScrollBehavior? = null
) {
@ -47,10 +54,7 @@ fun AppTopBar(
navigationIcon = {
if (onBackClick != null) {
IconButton(onClick = onBackClick) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = stringResource(R.string.back)
)
backIcon()
}
}
},

View file

@ -1,38 +1,71 @@
package app.revanced.manager.ui.component.patches
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material3.Button
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.ListItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
import app.revanced.manager.data.platform.FileSystem
import app.revanced.manager.patcher.patch.Option
import app.revanced.manager.util.toast
import app.revanced.patcher.patch.PatchOption
import org.koin.compose.rememberKoinInject
/**
* [Composable] functions do not support function references, so we have to use composable lambdas instead.
*/
private typealias OptionField = @Composable (Any?, (Any?) -> Unit) -> Unit
// Composable functions do not support function references, so we have to use composable lambdas instead.
private typealias OptionImpl = @Composable (Option, Any?, (Any?) -> Unit) -> Unit
private val StringField: OptionField = { value, setValue ->
val fs: FileSystem = rememberKoinInject()
@Composable
private fun OptionListItem(
option: Option,
onClick: () -> Unit,
trailingContent: @Composable () -> Unit
) {
ListItem(
modifier = Modifier.clickable(onClick = onClick),
headlineContent = { Text(option.title) },
supportingContent = { Text(option.description) },
trailingContent = trailingContent
)
}
@Composable
private fun StringOptionDialog(
name: String,
value: String?,
onSubmit: (String) -> Unit,
onDismissRequest: () -> Unit
) {
var showFileDialog by rememberSaveable { mutableStateOf(false) }
var fieldValue by rememberSaveable(value) {
mutableStateOf(value.orEmpty())
}
val fs: FileSystem = rememberKoinInject()
val (contract, permissionName) = fs.permissionContract()
val permissionLauncher = rememberLauncherForActivityResult(contract = contract) {
showFileDialog = it
}
val current = value as? String
if (showFileDialog) {
PathSelectorDialog(
@ -40,45 +73,133 @@ private val StringField: OptionField = { value, setValue ->
) {
showFileDialog = false
it?.let { path ->
setValue(path.toString())
fieldValue = path.toString()
}
}
}
Column {
TextField(value = current ?: "", onValueChange = setValue)
Button(onClick = {
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(name) },
text = {
OutlinedTextField(
value = fieldValue,
onValueChange = { fieldValue = it },
placeholder = {
Text(stringResource(R.string.string_option_placeholder))
},
trailingIcon = {
var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
IconButton(
onClick = { showDropdownMenu = true }
) {
Icon(
Icons.Outlined.MoreVert,
contentDescription = stringResource(R.string.string_option_menu_description)
)
}
DropdownMenu(
expanded = showDropdownMenu,
onDismissRequest = { showDropdownMenu = false }
) {
DropdownMenuItem(
leadingIcon = {
Icon(Icons.Outlined.Folder, null)
},
text = {
Text(stringResource(R.string.path_selector))
},
onClick = {
showDropdownMenu = false
if (fs.hasStoragePermission()) {
showFileDialog = true
} else {
permissionLauncher.launch(permissionName)
}
}) {
Icon(Icons.Filled.FileOpen, null)
Text("Select file or folder")
}
)
}
}
)
},
confirmButton = {
TextButton(onClick = { onSubmit(fieldValue) }) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.cancel))
}
},
)
}
private val StringOption: OptionImpl = { option, value, setValue ->
var showInputDialog by rememberSaveable { mutableStateOf(false) }
fun showInputDialog() {
showInputDialog = true
}
fun dismissInputDialog() {
showInputDialog = false
}
if (showInputDialog) {
StringOptionDialog(
name = option.title,
value = value as? String,
onSubmit = {
dismissInputDialog()
setValue(it)
},
onDismissRequest = ::dismissInputDialog
)
}
OptionListItem(
option = option,
onClick = ::showInputDialog
) {
IconButton(onClick = ::showInputDialog) {
Icon(
Icons.Outlined.Edit,
contentDescription = stringResource(R.string.string_option_icon_description)
)
}
}
}
private val BooleanField: OptionField = { value, setValue ->
val current = value as? Boolean
Switch(checked = current ?: false, onCheckedChange = setValue)
private val BooleanOption: OptionImpl = { option, value, setValue ->
val current = (value as? Boolean) ?: false
OptionListItem(
option = option,
onClick = { setValue(!current) }
) {
Switch(checked = current, onCheckedChange = setValue)
}
}
private val UnknownField: OptionField = { _, _ ->
Text("This type has not been implemented")
private val UnknownOption: OptionImpl = { option, _, _ ->
val context = LocalContext.current
OptionListItem(
option = option,
onClick = { context.toast("Unknown type: ${option.type.name}") },
trailingContent = {})
}
@Composable
fun OptionField(option: Option, value: Any?, setValue: (Any?) -> Unit) {
fun OptionItem(option: Option, value: Any?, setValue: (Any?) -> Unit) {
val implementation = remember(option.type) {
when (option.type) {
// These are the only two types that are currently used by the official patches.
PatchOption.StringOption::class.java -> StringField
PatchOption.BooleanOption::class.java -> BooleanField
else -> UnknownField
PatchOption.StringOption::class.java -> StringOption
PatchOption.BooleanOption::class.java -> BooleanOption
else -> UnknownOption
}
}
implementation(value, setValue)
implementation(option, value, setValue)
}

View file

@ -2,18 +2,20 @@ package app.revanced.manager.ui.component.patches
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.DocumentScanner
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.InsertDriveFile
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.ListItem
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -21,15 +23,18 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.util.saver.PathSaver
import java.nio.file.Path
import kotlin.io.path.absolutePathString
import kotlin.io.path.isDirectory
import kotlin.io.path.isRegularFile
import kotlin.io.path.isReadable
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.name
@ -40,14 +45,8 @@ fun PathSelectorDialog(root: Path, onSelect: (Path?) -> Unit) {
val notAtRootDir = remember(currentDirectory) {
currentDirectory != root
}
val everything = remember(currentDirectory) {
currentDirectory.listDirectoryEntries()
}
val directories = remember(everything) {
everything.filter { it.isDirectory() }
}
val files = remember(everything) {
everything.filter { it.isRegularFile() }
val (directories, files) = remember(currentDirectory) {
currentDirectory.listDirectoryEntries().filter(Path::isReadable).partition(Path::isDirectory)
}
Dialog(
@ -60,51 +59,78 @@ fun PathSelectorDialog(root: Path, onSelect: (Path?) -> Unit) {
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.select_file),
onBackClick = { onSelect(null) }
)
title = stringResource(R.string.path_selector),
onBackClick = { onSelect(null) },
backIcon = {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
}
)
},
) { paddingValues ->
BackHandler(enabled = notAtRootDir) {
currentDirectory = currentDirectory.parent
}
Column(
modifier = Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())
LazyColumn(
modifier = Modifier.padding(paddingValues)
) {
Text(text = currentDirectory.toString())
Row(
modifier = Modifier.clickable { onSelect(currentDirectory) }
) {
Text("(Use this directory)")
item(key = "current") {
PathItem(
onClick = { onSelect(currentDirectory) },
icon = Icons.Outlined.Folder,
name = currentDirectory.toString()
)
}
if (notAtRootDir) {
Row(
modifier = Modifier.clickable { currentDirectory = currentDirectory.parent }
) {
Text("Previous directory")
item(key = "parent") {
PathItem(
onClick = { currentDirectory = currentDirectory.parent },
icon = Icons.Outlined.ArrowBack,
name = stringResource(R.string.path_selector_parent_dir)
)
}
}
directories.forEach {
Row(
modifier = Modifier.clickable { currentDirectory = it }
) {
Icon(Icons.Filled.Folder, null)
Text(text = it.name)
if (directories.isNotEmpty()) {
item(key = "dirs_header") {
GroupHeader(title = stringResource(R.string.path_selector_dirs))
}
}
files.forEach {
Row(
modifier = Modifier.clickable { onSelect(it) }
) {
Icon(Icons.Filled.FileOpen, null)
Text(text = it.name)
items(directories, key = { it.absolutePathString() }) {
PathItem(
onClick = { currentDirectory = it },
icon = Icons.Outlined.Folder,
name = it.name
)
}
if (files.isNotEmpty()) {
item(key = "files_header") {
GroupHeader(title = stringResource(R.string.path_selector_files))
}
}
items(files, key = { it.absolutePathString() }) {
PathItem(
onClick = { onSelect(it) },
icon = Icons.Outlined.InsertDriveFile,
name = it.name
)
}
}
}
}
}
@Composable
private fun PathItem(
onClick: () -> Unit,
icon: ImageVector,
name: String
) {
ListItem(
modifier = Modifier.clickable(onClick = onClick),
headlineContent = { Text(name) },
leadingContent = { Icon(icon, contentDescription = null) }
)
}

View file

@ -13,15 +13,13 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.Restore
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
@ -49,7 +47,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.patches.OptionField
import app.revanced.manager.ui.component.patches.OptionItem
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_SUPPORTED
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL
@ -82,8 +80,8 @@ fun PatchesSelectorScreen(
onDismissRequest = vm::dismissDialogs,
patch = patch,
values = vm.getOptions(bundle, patch),
set = { key, value -> vm.setOption(bundle, patch, key, value) },
unset = { vm.unsetOption(bundle, patch, it) }
reset = { vm.resetOptions(bundle, patch) },
set = { key, value -> vm.setOption(bundle, patch, key, value) }
)
}
@ -336,7 +334,7 @@ fun UnsupportedDialog(
fun OptionsDialog(
patch: PatchInfo,
values: Map<String, Any?>?,
unset: (String) -> Unit,
reset: () -> Unit,
set: (String, Any?) -> Unit,
onDismissRequest: () -> Unit,
) = Dialog(
@ -350,36 +348,26 @@ fun OptionsDialog(
topBar = {
AppTopBar(
title = patch.name,
onBackClick = onDismissRequest
onBackClick = onDismissRequest,
actions = {
IconButton(onClick = reset) {
Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())
LazyColumn(
modifier = Modifier.padding(paddingValues)
) {
patch.options?.forEach {
ListItem(
headlineContent = { Text(it.title) },
supportingContent = { Text(it.description) },
overlineContent = {
Button(onClick = { unset(it.key) }) {
Text("reset")
}
},
trailingContent = {
val key = it.key
if (patch.options == null) return@LazyColumn
items(patch.options, key = { it.key }) { option ->
val key = option.key
val value =
if (values == null || !values.contains(key)) it.defaultValue else values[key]
if (values == null || !values.contains(key)) option.defaultValue else values[key]
OptionField(option = it, value = value, setValue = { set(key, it) })
}
)
}
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.apply))
OptionItem(option = option, value = value, setValue = { set(key, it) })
}
}
}

View file

@ -145,8 +145,8 @@ class PatchesSelectorViewModel(
patchOptions.getOrCreate(bundle).getOrCreate(patch.name)[key] = value
}
fun unsetOption(bundle: Int, patch: PatchInfo, key: String) {
patchOptions[bundle]?.get(patch.name)?.remove(key)
fun resetOptions(bundle: Int, patch: PatchInfo) {
patchOptions[bundle]?.remove(patch.name)
}
fun dismissDialogs() {

View file

@ -90,6 +90,7 @@
<string name="options">Options</string>
<string name="ok">OK</string>
<string name="reset">Reset</string>
<string name="patch">Patch</string>
<string name="select_from_storage">Select from storage</string>
<string name="search">Search</string>
@ -156,7 +157,14 @@
<string name="error_occurred">An error occurred</string>
<string name="already_downloaded">Already downloaded</string>
<string name="select_file">Select file</string>
<string name="string_option_icon_description">Edit</string>
<string name="string_option_menu_description">More options</string>
<string name="string_option_placeholder">Value</string>
<string name="path_selector">Select from storage</string>
<string name="path_selector_parent_dir">Previous directory</string>
<string name="path_selector_dirs">Directories</string>
<string name="path_selector_files">Files</string>
<string name="show_password_field">Show password</string>
<string name="hide_password_field">Hide password</string>