feat: implement more patch option types (#2015)

This commit is contained in:
Ax333l 2024-07-04 19:34:55 +02:00 committed by GitHub
parent a22158d070
commit ec0a077539
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 875 additions and 253 deletions

View File

@ -188,6 +188,9 @@ dependencies {
// Scrollbars
implementation(libs.scrollbars)
// Reorderable lists
implementation(libs.reorderable)
// Compose Icons
implementation(libs.compose.icons.fontawesome)
}

View File

@ -2,7 +2,7 @@ package app.revanced.manager.data.room
import androidx.room.TypeConverter
import app.revanced.manager.data.room.bundles.Source
import io.ktor.http.*
import app.revanced.manager.data.room.options.Option.SerializedValue
import java.io.File
class Converters {
@ -17,4 +17,10 @@ class Converters {
@TypeConverter
fun fileToString(file: File): String = file.path
@TypeConverter
fun serializedOptionFromString(value: String) = SerializedValue.fromJsonString(value)
@TypeConverter
fun serializedOptionToString(value: SerializedValue) = value.toJsonString()
}

View File

@ -3,6 +3,23 @@ package app.revanced.manager.data.room.options
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import app.revanced.manager.patcher.patch.Option
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.float
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlin.reflect.KClass
@Entity(
tableName = "options",
@ -19,5 +36,74 @@ data class Option(
@ColumnInfo(name = "patch_name") val patchName: String,
@ColumnInfo(name = "key") val key: String,
// Encoded as Json.
@ColumnInfo(name = "value") val value: String,
)
@ColumnInfo(name = "value") val value: SerializedValue,
) {
@Serializable
data class SerializedValue(val raw: JsonElement) {
fun toJsonString() = json.encodeToString(raw)
fun deserializeFor(option: Option<*>): Any? {
if (raw is JsonNull) return null
val errorMessage = "Cannot deserialize value as ${option.type}"
try {
if (option.type.endsWith("Array")) {
val elementType = option.type.removeSuffix("Array")
return raw.jsonArray.map { deserializeBasicType(elementType, it.jsonPrimitive) }
}
return deserializeBasicType(option.type, raw.jsonPrimitive)
} catch (e: IllegalArgumentException) {
throw SerializationException(errorMessage, e)
} catch (e: IllegalStateException) {
throw SerializationException(errorMessage, e)
} catch (e: kotlinx.serialization.SerializationException) {
throw SerializationException(errorMessage, e)
}
}
companion object {
private val json = Json {
// Patcher does not forbid the use of these values, so we should support them.
allowSpecialFloatingPointValues = true
}
private fun deserializeBasicType(type: String, value: JsonPrimitive) = when (type) {
"Boolean" -> value.boolean
"Int" -> value.int
"Long" -> value.long
"Float" -> value.float
"String" -> value.content.also { if (!value.isString) throw SerializationException("Expected value to be a string: $value") }
else -> throw SerializationException("Unknown type: $type")
}
fun fromJsonString(value: String) = SerializedValue(json.decodeFromString(value))
fun fromValue(value: Any?) = SerializedValue(when (value) {
null -> JsonNull
is Number -> JsonPrimitive(value)
is Boolean -> JsonPrimitive(value)
is String -> JsonPrimitive(value)
is List<*> -> buildJsonArray {
var elementClass: KClass<out Any>? = null
value.forEach {
when (it) {
null -> throw SerializationException("List elements must not be null")
is Number -> add(it)
is Boolean -> add(it)
is String -> add(it)
else -> throw SerializationException("Unknown element type: ${it::class.simpleName}")
}
if (elementClass == null) elementClass = it::class
else if (elementClass != it::class) throw SerializationException("List elements must have the same type")
}
}
else -> throw SerializationException("Unknown type: ${value::class.simpleName}")
})
}
}
class SerializationException(message: String, cause: Throwable? = null) :
Exception(message, cause)
}

View File

@ -1,18 +1,14 @@
package app.revanced.manager.domain.repository
import android.util.Log
import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.options.Option
import app.revanced.manager.data.room.options.OptionGroup
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.util.Options
import app.revanced.manager.util.tag
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.floatOrNull
import kotlinx.serialization.json.intOrNull
class PatchOptionsRepository(db: AppDatabase) {
private val dao = db.optionDao()
@ -24,19 +20,37 @@ class PatchOptionsRepository(db: AppDatabase) {
packageName = packageName
).also { dao.createOptionGroup(it) }.uid
suspend fun getOptions(packageName: String): Options {
suspend fun getOptions(
packageName: String,
bundlePatches: Map<Int, Map<String, PatchInfo>>
): Options {
val options = dao.getOptions(packageName)
// Bundle -> Patches
return buildMap<Int, MutableMap<String, MutableMap<String, Any?>>>(options.size) {
options.forEach { (sourceUid, bundlePatchOptionsList) ->
// Patches -> Patch options
this[sourceUid] = bundlePatchOptionsList.fold(mutableMapOf()) { bundlePatchOptions, option ->
val patchOptions = bundlePatchOptions.getOrPut(option.patchName, ::mutableMapOf)
this[sourceUid] =
bundlePatchOptionsList.fold(mutableMapOf()) { bundlePatchOptions, dbOption ->
val deserializedPatchOptions =
bundlePatchOptions.getOrPut(dbOption.patchName, ::mutableMapOf)
patchOptions[option.key] = deserialize(option.value)
val option =
bundlePatches[sourceUid]?.get(dbOption.patchName)?.options?.find { it.key == dbOption.key }
if (option != null) {
try {
deserializedPatchOptions[option.key] =
dbOption.value.deserializeFor(option)
} catch (e: Option.SerializationException) {
Log.w(
tag,
"Option ${dbOption.patchName}:${option.key} could not be deserialized",
e
)
}
}
bundlePatchOptions
}
bundlePatchOptions
}
}
}
}
@ -47,8 +61,12 @@ class PatchOptionsRepository(db: AppDatabase) {
groupId to bundlePatchOptions.flatMap { (patchName, patchOptions) ->
patchOptions.mapNotNull { (key, value) ->
val serialized = serialize(value)
?: return@mapNotNull null // Don't save options that we can't serialize.
val serialized = try {
Option.SerializedValue.fromValue(value)
} catch (e: Option.SerializationException) {
Log.e(tag, "Option $patchName:$key could not be serialized", e)
return@mapNotNull null
}
Option(groupId, patchName, key, serialized)
}
@ -61,29 +79,4 @@ class PatchOptionsRepository(db: AppDatabase) {
suspend fun clearOptionsForPackage(packageName: String) = dao.clearForPackage(packageName)
suspend fun clearOptionsForPatchBundle(uid: Int) = dao.clearForPatchBundle(uid)
suspend fun reset() = dao.reset()
private companion object {
fun deserialize(value: String): Any? {
val primitive = Json.decodeFromString<JsonPrimitive>(value)
return when {
primitive.isString -> primitive.content
primitive is JsonNull -> null
else -> primitive.booleanOrNull ?: primitive.intOrNull ?: primitive.floatOrNull
}
}
fun serialize(value: Any?): String? {
val primitive = when (value) {
null -> JsonNull
is String -> JsonPrimitive(value)
is Int -> JsonPrimitive(value)
is Float -> JsonPrimitive(value)
is Boolean -> JsonPrimitive(value)
else -> return null
}
return Json.encodeToString(primitive)
}
}
}

View File

@ -15,7 +15,7 @@ data class PatchInfo(
val description: String?,
val include: Boolean,
val compatiblePackages: ImmutableList<CompatiblePackage>?,
val options: ImmutableList<Option>?
val options: ImmutableList<Option<*>>?
) {
constructor(patch: Patch<*>) : this(
patch.name.orEmpty(),
@ -78,20 +78,24 @@ data class CompatiblePackage(
}
@Immutable
data class Option(
data class Option<T>(
val title: String,
val key: String,
val description: String,
val required: Boolean,
val type: String,
val default: Any?
val default: T?,
val presets: Map<String, T?>?,
val validator: (T?) -> Boolean,
) {
constructor(option: PatchOption<*>) : this(
constructor(option: PatchOption<T>) : this(
option.title ?: option.key,
option.key,
option.description.orEmpty(),
option.required,
option.valueType,
option.default,
option.values,
{ option.validator(option, it) },
)
}

View File

@ -0,0 +1,99 @@
package app.revanced.manager.ui.component
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisallowComposableCalls
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.res.stringResource
import app.revanced.manager.R
@Composable
private inline fun <T> NumberInputDialog(
current: T?,
name: String,
crossinline onSubmit: (T?) -> Unit,
crossinline validator: @DisallowComposableCalls (T) -> Boolean,
crossinline toNumberOrNull: @DisallowComposableCalls String.() -> T?
) {
var fieldValue by rememberSaveable {
mutableStateOf(current?.toString().orEmpty())
}
val numberFieldValue by remember {
derivedStateOf { fieldValue.toNumberOrNull() }
}
val validatorFailed by remember {
derivedStateOf { numberFieldValue?.let { !validator(it) } ?: false }
}
AlertDialog(
onDismissRequest = { onSubmit(null) },
title = { Text(name) },
text = {
OutlinedTextField(
value = fieldValue,
onValueChange = { fieldValue = it },
placeholder = {
Text(stringResource(R.string.dialog_input_placeholder))
},
isError = validatorFailed,
supportingText = {
if (validatorFailed) {
Text(
stringResource(R.string.input_dialog_value_invalid),
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.error
)
}
}
)
},
confirmButton = {
TextButton(
onClick = { numberFieldValue?.let(onSubmit) },
enabled = numberFieldValue != null && !validatorFailed,
) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = { onSubmit(null) }) {
Text(stringResource(R.string.cancel))
}
},
)
}
@Composable
fun IntInputDialog(
current: Int?,
name: String,
validator: (Int) -> Boolean = { true },
onSubmit: (Int?) -> Unit
) = NumberInputDialog(current, name, onSubmit, validator, String::toIntOrNull)
@Composable
fun LongInputDialog(
current: Long?,
name: String,
validator: (Long) -> Boolean = { true },
onSubmit: (Long?) -> Unit
) = NumberInputDialog(current, name, onSubmit, validator, String::toLongOrNull)
@Composable
fun FloatInputDialog(
current: Float?,
name: String,
validator: (Float) -> Boolean = { true },
onSubmit: (Float?) -> Unit
) = NumberInputDialog(current, name, onSubmit, validator, String::toFloatOrNull)

View File

@ -1,204 +1,655 @@
package app.revanced.manager.ui.component.patches
import android.app.Application
import android.os.Parcelable
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material.icons.outlined.SelectAll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisallowComposableCalls
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog as ComposeDialog
import androidx.compose.ui.window.DialogProperties
import app.revanced.manager.R
import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.patcher.patch.Option
import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.FloatInputDialog
import app.revanced.manager.ui.component.IntInputDialog
import app.revanced.manager.ui.component.LongInputDialog
import app.revanced.manager.util.isScrollingUp
import app.revanced.manager.util.mutableStateSetOf
import app.revanced.manager.util.saver.snapshotStateListSaver
import app.revanced.manager.util.saver.snapshotStateSetSaver
import app.revanced.manager.util.toast
import kotlinx.parcelize.Parcelize
import org.koin.compose.koinInject
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyColumnState
import java.io.Serializable
import kotlin.random.Random
// Composable functions do not support function references, so we have to use composable lambdas instead.
private typealias OptionImpl = @Composable (Option, Any?, (Any?) -> Unit) -> Unit
@Composable
private fun OptionListItem(
option: Option,
onClick: () -> Unit,
trailingContent: @Composable () -> Unit
private class OptionEditorScope<T : Any>(
private val editor: OptionEditor<T>,
val option: Option<T>,
val openDialog: () -> Unit,
val dismissDialog: () -> Unit,
val value: T?,
val setValue: (T?) -> Unit,
) {
ListItem(
modifier = Modifier.clickable(onClick = onClick),
headlineContent = { Text(option.title) },
supportingContent = { Text(option.description) },
trailingContent = trailingContent
)
fun submitDialog(value: T?) {
setValue(value)
dismissDialog()
}
fun clickAction() = editor.clickAction(this)
@Composable
fun ListItemTrailingContent() = editor.ListItemTrailingContent(this)
@Composable
fun Dialog() = editor.Dialog(this)
}
@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())
}
private interface OptionEditor<T : Any> {
fun clickAction(scope: OptionEditorScope<T>) = scope.openDialog()
val fs: Filesystem = koinInject()
val (contract, permissionName) = fs.permissionContract()
val permissionLauncher = rememberLauncherForActivityResult(contract = contract) {
showFileDialog = it
}
if (showFileDialog) {
PathSelectorDialog(
root = fs.externalFilesDir()
) {
showFileDialog = false
it?.let { path ->
fieldValue = path.toString()
}
@Composable
fun ListItemTrailingContent(scope: OptionEditorScope<T>) {
IconButton(onClick = { clickAction(scope) }) {
Icon(Icons.Outlined.Edit, stringResource(R.string.edit))
}
}
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(name) },
text = {
OutlinedTextField(
value = fieldValue,
onValueChange = { fieldValue = it },
placeholder = {
Text(stringResource(R.string.dialog_input_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)
}
}
)
}
}
)
},
confirmButton = {
TextButton(onClick = { onSubmit(fieldValue) }) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.cancel))
}
},
)
@Composable
fun Dialog(scope: OptionEditorScope<T>)
}
private val unknownOption: OptionImpl = { option, _, _ ->
val context = LocalContext.current
OptionListItem(
option = option,
onClick = { context.toast("Unknown type: ${option.type}") },
trailingContent = {})
}
private val optionImplementations = mapOf<String, OptionImpl>(
// These are the only two types that are currently used by the official patches
"Boolean" to { option, value, setValue ->
val current = (value as? Boolean) ?: false
OptionListItem(
option = option,
onClick = { setValue(!current) }
) {
Switch(checked = current, onCheckedChange = setValue)
}
},
"String" to { 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.edit)
)
}
}
}
private val optionEditors = mapOf(
"Boolean" to BooleanOptionEditor,
"String" to StringOptionEditor,
"Int" to IntOptionEditor,
"Long" to LongOptionEditor,
"Float" to FloatOptionEditor,
"BooleanArray" to ListOptionEditor(BooleanOptionEditor),
"StringArray" to ListOptionEditor(StringOptionEditor),
"IntArray" to ListOptionEditor(IntOptionEditor),
"LongArray" to ListOptionEditor(LongOptionEditor),
"FloatArray" to ListOptionEditor(FloatOptionEditor),
)
@Composable
fun OptionItem(option: Option, value: Any?, setValue: (Any?) -> Unit) {
val implementation = remember(option.type) {
optionImplementations.getOrDefault(
option.type,
unknownOption
private inline fun <T : Any> WithOptionEditor(
editor: OptionEditor<T>,
option: Option<T>,
value: T?,
noinline setValue: (T?) -> Unit,
crossinline onDismissDialog: @DisallowComposableCalls () -> Unit = {},
block: OptionEditorScope<T>.() -> Unit
) {
var showDialog by rememberSaveable { mutableStateOf(false) }
val scope = remember(editor, option, value, setValue) {
OptionEditorScope(
editor,
option,
openDialog = { showDialog = true },
dismissDialog = {
showDialog = false
onDismissDialog()
},
value,
setValue
)
}
implementation(option, value, setValue)
if (showDialog) scope.Dialog()
scope.block()
}
@Composable
fun <T : Any> OptionItem(option: Option<T>, value: T?, setValue: (T?) -> Unit) {
val editor = remember(option.type, option.presets) {
@Suppress("UNCHECKED_CAST")
val baseOptionEditor =
optionEditors.getOrDefault(option.type, UnknownTypeEditor) as OptionEditor<T>
if (option.type != "Boolean" && option.presets != null) PresetOptionEditor(baseOptionEditor)
else baseOptionEditor
}
WithOptionEditor(editor, option, value, setValue) {
ListItem(
modifier = Modifier.clickable(onClick = ::clickAction),
headlineContent = { Text(option.title) },
supportingContent = { Text(option.description) },
trailingContent = { ListItemTrailingContent() }
)
}
}
private object StringOptionEditor : OptionEditor<String> {
@Composable
override fun Dialog(scope: OptionEditorScope<String>) {
var showFileDialog by rememberSaveable { mutableStateOf(false) }
var fieldValue by rememberSaveable(scope.value) {
mutableStateOf(scope.value.orEmpty())
}
val validatorFailed by remember {
derivedStateOf { !scope.option.validator(fieldValue) }
}
val fs: Filesystem = koinInject()
val (contract, permissionName) = fs.permissionContract()
val permissionLauncher = rememberLauncherForActivityResult(contract = contract) {
showFileDialog = it
}
if (showFileDialog) {
PathSelectorDialog(
root = fs.externalFilesDir()
) {
showFileDialog = false
it?.let { path ->
fieldValue = path.toString()
}
}
}
AlertDialog(
onDismissRequest = scope.dismissDialog,
title = { Text(scope.option.title) },
text = {
OutlinedTextField(
value = fieldValue,
onValueChange = { fieldValue = it },
placeholder = {
Text(stringResource(R.string.dialog_input_placeholder))
},
isError = validatorFailed,
supportingText = {
if (validatorFailed) {
Text(
stringResource(R.string.input_dialog_value_invalid),
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.error
)
}
},
trailingIcon = {
var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
IconButton(
onClick = { showDropdownMenu = true }
) {
Icon(
Icons.Outlined.MoreVert,
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)
}
}
)
}
}
)
},
confirmButton = {
TextButton(
enabled = !validatorFailed,
onClick = { scope.submitDialog(fieldValue) }) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = scope.dismissDialog) {
Text(stringResource(R.string.cancel))
}
},
)
}
}
private abstract class NumberOptionEditor<T : Number> : OptionEditor<T> {
@Composable
protected abstract fun NumberDialog(
title: String,
current: T?,
validator: (T?) -> Boolean,
onSubmit: (T?) -> Unit
)
@Composable
override fun Dialog(scope: OptionEditorScope<T>) {
NumberDialog(scope.option.title, scope.value, scope.option.validator) {
if (it == null) return@NumberDialog scope.dismissDialog()
scope.submitDialog(it)
}
}
}
private object IntOptionEditor : NumberOptionEditor<Int>() {
@Composable
override fun NumberDialog(
title: String,
current: Int?,
validator: (Int?) -> Boolean,
onSubmit: (Int?) -> Unit
) = IntInputDialog(current, title, validator, onSubmit)
}
private object LongOptionEditor : NumberOptionEditor<Long>() {
@Composable
override fun NumberDialog(
title: String,
current: Long?,
validator: (Long?) -> Boolean,
onSubmit: (Long?) -> Unit
) = LongInputDialog(current, title, validator, onSubmit)
}
private object FloatOptionEditor : NumberOptionEditor<Float>() {
@Composable
override fun NumberDialog(
title: String,
current: Float?,
validator: (Float?) -> Boolean,
onSubmit: (Float?) -> Unit
) = FloatInputDialog(current, title, validator, onSubmit)
}
private object BooleanOptionEditor : OptionEditor<Boolean> {
override fun clickAction(scope: OptionEditorScope<Boolean>) {
scope.setValue(!scope.current)
}
@Composable
override fun ListItemTrailingContent(scope: OptionEditorScope<Boolean>) {
Switch(checked = scope.current, onCheckedChange = scope.setValue)
}
@Composable
override fun Dialog(scope: OptionEditorScope<Boolean>) {
}
private val OptionEditorScope<Boolean>.current get() = value ?: false
}
private object UnknownTypeEditor : OptionEditor<Any>, KoinComponent {
override fun clickAction(scope: OptionEditorScope<Any>) =
get<Application>().toast("Unknown type: ${scope.option.type}")
@Composable
override fun Dialog(scope: OptionEditorScope<Any>) {
}
}
/**
* A wrapper for [OptionEditor]s that shows selectable presets.
*
* @param innerEditor The [OptionEditor] for [T].
*/
private class PresetOptionEditor<T : Any>(private val innerEditor: OptionEditor<T>) :
OptionEditor<T> {
@Composable
override fun Dialog(scope: OptionEditorScope<T>) {
var selectedPreset by rememberSaveable(scope.value, scope.option.presets) {
val presets = scope.option.presets!!
mutableStateOf(presets.entries.find { it.value == scope.value }?.key)
}
WithOptionEditor(
innerEditor,
scope.option,
scope.value,
scope.setValue,
onDismissDialog = scope.dismissDialog
) inner@{
var hidePresetsDialog by rememberSaveable {
mutableStateOf(false)
}
if (hidePresetsDialog) return@inner
// TODO: add a divider for scrollable content
AlertDialogExtended(
onDismissRequest = scope.dismissDialog,
confirmButton = {
TextButton(
onClick = {
if (selectedPreset != null) scope.submitDialog(
scope.option.presets?.get(
selectedPreset
)
)
else {
this@inner.openDialog()
// Hide the presets dialog so it doesn't show up in the background.
hidePresetsDialog = true
}
}
) {
Text(stringResource(if (selectedPreset != null) R.string.save else R.string.continue_))
}
},
dismissButton = {
TextButton(onClick = scope.dismissDialog) {
Text(stringResource(R.string.cancel))
}
},
title = { Text(scope.option.title) },
textHorizontalPadding = PaddingValues(horizontal = 0.dp),
text = {
val presets = remember(scope.option.presets) {
scope.option.presets?.entries?.toList().orEmpty()
}
LazyColumn {
@Composable
fun Item(title: String, value: Any?, presetKey: String?) {
ListItem(
modifier = Modifier.clickable { selectedPreset = presetKey },
headlineContent = { Text(title) },
supportingContent = value?.toString()?.let { { Text(it) } },
leadingContent = {
RadioButton(
selected = selectedPreset == presetKey,
onClick = { selectedPreset = presetKey }
)
}
)
}
items(presets, key = { it.key }) {
Item(it.key, it.value, it.key)
}
item(key = null) {
Item(stringResource(R.string.option_preset_custom_value), null, null)
}
}
}
)
}
}
}
private class ListOptionEditor<T : Serializable>(private val elementEditor: OptionEditor<T>) :
OptionEditor<List<T>> {
private fun createElementOption(option: Option<List<T>>) = Option<T>(
option.title,
option.key,
option.description,
option.required,
option.type.removeSuffix("Array"),
null,
null
) { true }
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
override fun Dialog(scope: OptionEditorScope<List<T>>) {
val items =
rememberSaveable(scope.value, saver = snapshotStateListSaver()) {
// We need a key for each element in order to support dragging.
scope.value?.map(::Item)?.toMutableStateList() ?: mutableStateListOf()
}
val listIsDirty by remember {
derivedStateOf {
val current = scope.value.orEmpty()
if (current.size != items.size) return@derivedStateOf true
current.forEachIndexed { index, value ->
if (value != items[index].value) return@derivedStateOf true
}
false
}
}
val lazyListState = rememberLazyListState()
val reorderableLazyColumnState =
rememberReorderableLazyColumnState(lazyListState) { from, to ->
// Update the list
items.add(to.index, items.removeAt(from.index))
}
var deleteMode by rememberSaveable {
mutableStateOf(false)
}
val deletionTargets = rememberSaveable(saver = snapshotStateSetSaver()) {
mutableStateSetOf<Int>()
}
val back = back@{
if (deleteMode) {
deletionTargets.clear()
deleteMode = false
return@back
}
if (!listIsDirty) {
scope.dismissDialog()
return@back
}
scope.submitDialog(items.mapNotNull { it.value })
}
ComposeDialog(
onDismissRequest = back,
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true
),
) {
Scaffold(
topBar = {
AppTopBar(
title = if (deleteMode) pluralStringResource(
R.plurals.selected_count,
deletionTargets.size,
deletionTargets.size
) else scope.option.title,
onBackClick = back,
backIcon = {
if (deleteMode) {
return@AppTopBar Icon(
Icons.Filled.Close,
stringResource(R.string.cancel)
)
}
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
},
actions = {
if (deleteMode) {
IconButton(
onClick = {
if (items.size == deletionTargets.size) deletionTargets.clear()
else deletionTargets.addAll(items.map { it.key })
}
) {
Icon(
Icons.Outlined.SelectAll,
stringResource(R.string.select_deselect_all)
)
}
IconButton(
onClick = {
items.removeIf { it.key in deletionTargets }
deletionTargets.clear()
deleteMode = false
}
) {
Icon(
Icons.Outlined.Delete,
stringResource(R.string.delete)
)
}
} else {
IconButton(onClick = items::clear) {
Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
}
}
}
)
},
floatingActionButton = {
if (deleteMode) return@Scaffold
ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.add)) },
icon = { Icon(Icons.Outlined.Add, null) },
expanded = lazyListState.isScrollingUp,
onClick = { items.add(Item(null)) }
)
}
) { paddingValues ->
val elementOption = remember(scope.option) { createElementOption(scope.option) }
LazyColumn(
state = lazyListState,
modifier = Modifier
.fillMaxHeight()
.padding(paddingValues),
) {
itemsIndexed(items, key = { _, item -> item.key }) { index, item ->
val interactionSource = remember { MutableInteractionSource() }
ReorderableItem(reorderableLazyColumnState, key = item.key) {
WithOptionEditor(
elementEditor,
elementOption,
value = item.value,
setValue = { items[index] = item.copy(value = it) }
) {
ListItem(
modifier = Modifier.combinedClickable(
indication = LocalIndication.current,
interactionSource = interactionSource,
onLongClickLabel = stringResource(R.string.select),
onLongClick = {
deletionTargets.add(item.key)
deleteMode = true
},
onClick = {
if (!deleteMode) {
clickAction()
return@combinedClickable
}
if (item.key in deletionTargets) {
deletionTargets.remove(
item.key
)
deleteMode = deletionTargets.isNotEmpty()
} else deletionTargets.add(item.key)
},
),
tonalElevation = if (deleteMode && item.key in deletionTargets) 8.dp else 0.dp,
leadingContent = {
IconButton(
modifier = Modifier.draggableHandle(interactionSource = interactionSource),
onClick = {},
) {
Icon(
Icons.Filled.DragHandle,
stringResource(R.string.drag_handle)
)
}
},
headlineContent = {
if (item.value == null) return@ListItem Text(
stringResource(R.string.empty),
fontStyle = FontStyle.Italic
)
Text(item.value.toString())
},
trailingContent = {
ListItemTrailingContent()
}
)
}
}
}
}
}
}
}
@Parcelize
private data class Item<T : Serializable>(val value: T?, val key: Int = Random.nextInt()) :
Parcelable
}

View File

@ -4,17 +4,11 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@ -22,6 +16,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
import app.revanced.manager.domain.manager.base.Preference
import app.revanced.manager.ui.component.IntInputDialog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -57,7 +52,7 @@ fun IntegerItem(
}
if (dialogOpen) {
IntegerItemDialog(current = value, name = headline) { new ->
IntInputDialog(current = value, name = stringResource(headline)) { new ->
dialogOpen = false
new?.let(onValueChange)
}
@ -78,44 +73,4 @@ fun IntegerItem(
}
}
)
}
@Composable
private fun IntegerItemDialog(current: Int, @StringRes name: Int, onSubmit: (Int?) -> Unit) {
var fieldValue by rememberSaveable {
mutableStateOf(current.toString())
}
val integerFieldValue by remember {
derivedStateOf {
fieldValue.toIntOrNull()
}
}
AlertDialog(
onDismissRequest = { onSubmit(null) },
title = { Text(stringResource(name)) },
text = {
OutlinedTextField(
value = fieldValue,
onValueChange = { fieldValue = it },
placeholder = {
Text(stringResource(R.string.dialog_input_placeholder))
},
)
},
confirmButton = {
TextButton(
onClick = { integerFieldValue?.let(onSubmit) },
enabled = integerFieldValue != null,
) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = { onSubmit(null) }) {
Text(stringResource(R.string.cancel))
}
},
)
}

View File

@ -54,6 +54,7 @@ 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.Option
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.Countdown
@ -519,7 +520,8 @@ fun OptionsDialog(
val value =
if (values == null || !values.contains(key)) option.default else values[key]
OptionItem(option = option, value = value, setValue = { set(key, it) })
@Suppress("UNCHECKED_CAST")
OptionItem(option = option as Option<Any>, value = value, setValue = { set(key, it) })
}
}
}

View File

@ -1,5 +1,6 @@
package app.revanced.manager.ui.viewmodel
import android.app.Application
import android.content.pm.PackageInfo
import android.os.Parcelable
import androidx.compose.runtime.MutableState
@ -22,6 +23,7 @@ import app.revanced.manager.util.Options
import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
@ -31,10 +33,12 @@ import org.koin.core.component.get
@OptIn(SavedStateHandleSaveableApi::class)
class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
val bundlesRepo: PatchBundleRepository = get()
private val bundleRepository: PatchBundleRepository = get()
private val selectionRepository: PatchSelectionRepository = get()
private val optionsRepository: PatchOptionsRepository = get()
private val pm: PM = get()
private val savedStateHandle: SavedStateHandle = get()
private val app: Application = get()
val prefs: PreferencesManager = get()
private val persistConfiguration = input.patches == null
@ -62,8 +66,15 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
viewModelScope.launch {
if (!persistConfiguration) return@launch // TODO: save options for patched apps.
val packageName = selectedApp.packageName // Accessing this from another thread may cause crashes.
state.value = withContext(Dispatchers.Default) { optionsRepository.getOptions(packageName) }
val packageName =
selectedApp.packageName // Accessing this from another thread may cause crashes.
state.value = withContext(Dispatchers.Default) {
val bundlePatches = bundleRepository.bundles.first()
.mapValues { (_, bundle) -> bundle.patches.associateBy { it.name } }
optionsRepository.getOptions(packageName, bundlePatches)
}
}
state

View File

@ -8,4 +8,7 @@
<item quantity="one">Executed %d patch</item>
<item quantity="other">Executed %d patches</item>
</plurals>
<plurals name="selected_count">
<item quantity="other">%d selected</item>
</plurals>
</resources>

View File

@ -236,6 +236,7 @@
<string name="patch_selector_sheet_filter_compat_title">Compatibility</string>
<string name="string_option_menu_description">More options</string>
<string name="option_preset_custom_value">Custom value</string>
<string name="path_selector">Select from storage</string>
<string name="path_selector_parent_dir">Previous directory</string>
@ -276,6 +277,7 @@
<string name="expand_content">expand</string>
<string name="collapse_content">collapse</string>
<string name="drag_handle">reorder</string>
<string name="more">More</string>
<string name="continue_">Continue</string>
@ -321,6 +323,7 @@
<string name="cancel">Cancel</string>
<string name="save">Save</string>
<string name="update">Update</string>
<string name="empty">Empty</string>
<string name="installing_message">Tap on <b>Update</b> when prompted. \n ReVanced Manager will close when updating.</string>
<string name="no_changelogs_found">No changelogs found</string>
<string name="just_now">Just now</string>
@ -329,6 +332,7 @@
<string name="days_ago">%sd ago</string>
<string name="invalid_date">Invalid date</string>
<string name="disable_battery_optimization">Disable battery optimization</string>
<string name="input_dialog_value_invalid">Invalid value</string>
<string name="failed_to_check_updates">Failed to check for updates: %s</string>
<string name="no_update_available">No update available</string>
@ -341,6 +345,7 @@
<string name="download_update_confirmation">Download update?</string>
<string name="no_contributors_found">No contributors found</string>
<string name="select">Select</string>
<string name="select_deselect_all">Select or deselect all</string>
<string name="select_bundle_type_dialog_title">Select bundle type</string>
<string name="select_bundle_type_dialog_description">Select the type that is right for you.</string>
<string name="local_bundle_description">Import local files from your storage, does not automatically update</string>

View File

@ -11,6 +11,7 @@ work-runtime = "2.9.0"
compose-bom = "2024.03.00"
accompanist = "0.34.0"
placeholder = "1.1.2"
reorderable = "1.5.2"
serialization = "1.6.3"
collection = "0.3.7"
room-version = "2.6.1"
@ -120,6 +121,9 @@ libsu-nio = { group = "com.github.topjohnwu.libsu", name = "nio", version.ref =
# Scrollbars
scrollbars = { group = "com.github.GIGAMOLE", name = "ComposeScrollbars", version.ref = "scrollbars" }
# Reorderable lists
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
# Compose Icons
# switch to br.com.devsrsouza.compose.icons after DevSrSouza/compose-icons#30 is merged
compose-icons-fontawesome = { group = "com.github.BenjaminHalko.compose-icons", name = "font-awesome", version.ref = "compose-icons" }