mirror of
https://github.com/revanced/revanced-manager.git
synced 2025-05-02 23:04:25 +02:00
feat: implement more patch option types (#2015)
This commit is contained in:
parent
a22158d070
commit
ec0a077539
@ -188,6 +188,9 @@ dependencies {
|
|||||||
// Scrollbars
|
// Scrollbars
|
||||||
implementation(libs.scrollbars)
|
implementation(libs.scrollbars)
|
||||||
|
|
||||||
|
// Reorderable lists
|
||||||
|
implementation(libs.reorderable)
|
||||||
|
|
||||||
// Compose Icons
|
// Compose Icons
|
||||||
implementation(libs.compose.icons.fontawesome)
|
implementation(libs.compose.icons.fontawesome)
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@ package app.revanced.manager.data.room
|
|||||||
|
|
||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
import app.revanced.manager.data.room.bundles.Source
|
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
|
import java.io.File
|
||||||
|
|
||||||
class Converters {
|
class Converters {
|
||||||
@ -17,4 +17,10 @@ class Converters {
|
|||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun fileToString(file: File): String = file.path
|
fun fileToString(file: File): String = file.path
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun serializedOptionFromString(value: String) = SerializedValue.fromJsonString(value)
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun serializedOptionToString(value: SerializedValue) = value.toJsonString()
|
||||||
}
|
}
|
@ -3,6 +3,23 @@ package app.revanced.manager.data.room.options
|
|||||||
import androidx.room.ColumnInfo
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
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(
|
@Entity(
|
||||||
tableName = "options",
|
tableName = "options",
|
||||||
@ -19,5 +36,74 @@ data class Option(
|
|||||||
@ColumnInfo(name = "patch_name") val patchName: String,
|
@ColumnInfo(name = "patch_name") val patchName: String,
|
||||||
@ColumnInfo(name = "key") val key: String,
|
@ColumnInfo(name = "key") val key: String,
|
||||||
// Encoded as Json.
|
// 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)
|
||||||
|
}
|
||||||
|
@ -1,18 +1,14 @@
|
|||||||
package app.revanced.manager.domain.repository
|
package app.revanced.manager.domain.repository
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import app.revanced.manager.data.room.AppDatabase
|
import app.revanced.manager.data.room.AppDatabase
|
||||||
import app.revanced.manager.data.room.options.Option
|
import app.revanced.manager.data.room.options.Option
|
||||||
import app.revanced.manager.data.room.options.OptionGroup
|
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.Options
|
||||||
|
import app.revanced.manager.util.tag
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
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) {
|
class PatchOptionsRepository(db: AppDatabase) {
|
||||||
private val dao = db.optionDao()
|
private val dao = db.optionDao()
|
||||||
@ -24,19 +20,37 @@ class PatchOptionsRepository(db: AppDatabase) {
|
|||||||
packageName = packageName
|
packageName = packageName
|
||||||
).also { dao.createOptionGroup(it) }.uid
|
).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)
|
val options = dao.getOptions(packageName)
|
||||||
// Bundle -> Patches
|
// Bundle -> Patches
|
||||||
return buildMap<Int, MutableMap<String, MutableMap<String, Any?>>>(options.size) {
|
return buildMap<Int, MutableMap<String, MutableMap<String, Any?>>>(options.size) {
|
||||||
options.forEach { (sourceUid, bundlePatchOptionsList) ->
|
options.forEach { (sourceUid, bundlePatchOptionsList) ->
|
||||||
// Patches -> Patch options
|
// Patches -> Patch options
|
||||||
this[sourceUid] = bundlePatchOptionsList.fold(mutableMapOf()) { bundlePatchOptions, option ->
|
this[sourceUid] =
|
||||||
val patchOptions = bundlePatchOptions.getOrPut(option.patchName, ::mutableMapOf)
|
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) ->
|
groupId to bundlePatchOptions.flatMap { (patchName, patchOptions) ->
|
||||||
patchOptions.mapNotNull { (key, value) ->
|
patchOptions.mapNotNull { (key, value) ->
|
||||||
val serialized = serialize(value)
|
val serialized = try {
|
||||||
?: return@mapNotNull null // Don't save options that we can't serialize.
|
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)
|
Option(groupId, patchName, key, serialized)
|
||||||
}
|
}
|
||||||
@ -61,29 +79,4 @@ class PatchOptionsRepository(db: AppDatabase) {
|
|||||||
suspend fun clearOptionsForPackage(packageName: String) = dao.clearForPackage(packageName)
|
suspend fun clearOptionsForPackage(packageName: String) = dao.clearForPackage(packageName)
|
||||||
suspend fun clearOptionsForPatchBundle(uid: Int) = dao.clearForPatchBundle(uid)
|
suspend fun clearOptionsForPatchBundle(uid: Int) = dao.clearForPatchBundle(uid)
|
||||||
suspend fun reset() = dao.reset()
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -15,7 +15,7 @@ data class PatchInfo(
|
|||||||
val description: String?,
|
val description: String?,
|
||||||
val include: Boolean,
|
val include: Boolean,
|
||||||
val compatiblePackages: ImmutableList<CompatiblePackage>?,
|
val compatiblePackages: ImmutableList<CompatiblePackage>?,
|
||||||
val options: ImmutableList<Option>?
|
val options: ImmutableList<Option<*>>?
|
||||||
) {
|
) {
|
||||||
constructor(patch: Patch<*>) : this(
|
constructor(patch: Patch<*>) : this(
|
||||||
patch.name.orEmpty(),
|
patch.name.orEmpty(),
|
||||||
@ -78,20 +78,24 @@ data class CompatiblePackage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class Option(
|
data class Option<T>(
|
||||||
val title: String,
|
val title: String,
|
||||||
val key: String,
|
val key: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
val required: Boolean,
|
val required: Boolean,
|
||||||
val type: String,
|
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.title ?: option.key,
|
||||||
option.key,
|
option.key,
|
||||||
option.description.orEmpty(),
|
option.description.orEmpty(),
|
||||||
option.required,
|
option.required,
|
||||||
option.valueType,
|
option.valueType,
|
||||||
option.default,
|
option.default,
|
||||||
|
option.values,
|
||||||
|
{ option.validator(option, it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -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)
|
@ -1,204 +1,655 @@
|
|||||||
package app.revanced.manager.ui.component.patches
|
package app.revanced.manager.ui.component.patches
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Parcelable
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.LocalIndication
|
||||||
import androidx.compose.foundation.clickable
|
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.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.Edit
|
||||||
import androidx.compose.material.icons.outlined.Folder
|
import androidx.compose.material.icons.outlined.Folder
|
||||||
import androidx.compose.material.icons.outlined.MoreVert
|
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.AlertDialog
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisallowComposableCalls
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.toMutableStateList
|
||||||
import androidx.compose.ui.Modifier
|
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.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.R
|
||||||
import app.revanced.manager.data.platform.Filesystem
|
import app.revanced.manager.data.platform.Filesystem
|
||||||
import app.revanced.manager.patcher.patch.Option
|
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 app.revanced.manager.util.toast
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.koin.compose.koinInject
|
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 class OptionEditorScope<T : Any>(
|
||||||
private typealias OptionImpl = @Composable (Option, Any?, (Any?) -> Unit) -> Unit
|
private val editor: OptionEditor<T>,
|
||||||
|
val option: Option<T>,
|
||||||
@Composable
|
val openDialog: () -> Unit,
|
||||||
private fun OptionListItem(
|
val dismissDialog: () -> Unit,
|
||||||
option: Option,
|
val value: T?,
|
||||||
onClick: () -> Unit,
|
val setValue: (T?) -> Unit,
|
||||||
trailingContent: @Composable () -> Unit
|
|
||||||
) {
|
) {
|
||||||
ListItem(
|
fun submitDialog(value: T?) {
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
setValue(value)
|
||||||
headlineContent = { Text(option.title) },
|
dismissDialog()
|
||||||
supportingContent = { Text(option.description) },
|
}
|
||||||
trailingContent = trailingContent
|
|
||||||
)
|
fun clickAction() = editor.clickAction(this)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ListItemTrailingContent() = editor.ListItemTrailingContent(this)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Dialog() = editor.Dialog(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
private interface OptionEditor<T : Any> {
|
||||||
private fun StringOptionDialog(
|
fun clickAction(scope: OptionEditorScope<T>) = scope.openDialog()
|
||||||
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 = koinInject()
|
@Composable
|
||||||
val (contract, permissionName) = fs.permissionContract()
|
fun ListItemTrailingContent(scope: OptionEditorScope<T>) {
|
||||||
val permissionLauncher = rememberLauncherForActivityResult(contract = contract) {
|
IconButton(onClick = { clickAction(scope) }) {
|
||||||
showFileDialog = it
|
Icon(Icons.Outlined.Edit, stringResource(R.string.edit))
|
||||||
}
|
|
||||||
|
|
||||||
if (showFileDialog) {
|
|
||||||
PathSelectorDialog(
|
|
||||||
root = fs.externalFilesDir()
|
|
||||||
) {
|
|
||||||
showFileDialog = false
|
|
||||||
it?.let { path ->
|
|
||||||
fieldValue = path.toString()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AlertDialog(
|
@Composable
|
||||||
onDismissRequest = onDismissRequest,
|
fun Dialog(scope: OptionEditorScope<T>)
|
||||||
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))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val unknownOption: OptionImpl = { option, _, _ ->
|
private val optionEditors = mapOf(
|
||||||
val context = LocalContext.current
|
"Boolean" to BooleanOptionEditor,
|
||||||
OptionListItem(
|
"String" to StringOptionEditor,
|
||||||
option = option,
|
"Int" to IntOptionEditor,
|
||||||
onClick = { context.toast("Unknown type: ${option.type}") },
|
"Long" to LongOptionEditor,
|
||||||
trailingContent = {})
|
"Float" to FloatOptionEditor,
|
||||||
}
|
"BooleanArray" to ListOptionEditor(BooleanOptionEditor),
|
||||||
|
"StringArray" to ListOptionEditor(StringOptionEditor),
|
||||||
private val optionImplementations = mapOf<String, OptionImpl>(
|
"IntArray" to ListOptionEditor(IntOptionEditor),
|
||||||
// These are the only two types that are currently used by the official patches
|
"LongArray" to ListOptionEditor(LongOptionEditor),
|
||||||
"Boolean" to { option, value, setValue ->
|
"FloatArray" to ListOptionEditor(FloatOptionEditor),
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OptionItem(option: Option, value: Any?, setValue: (Any?) -> Unit) {
|
private inline fun <T : Any> WithOptionEditor(
|
||||||
val implementation = remember(option.type) {
|
editor: OptionEditor<T>,
|
||||||
optionImplementations.getOrDefault(
|
option: Option<T>,
|
||||||
option.type,
|
value: T?,
|
||||||
unknownOption
|
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
|
||||||
}
|
}
|
@ -4,17 +4,11 @@ import androidx.annotation.StringRes
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Edit
|
import androidx.compose.material.icons.outlined.Edit
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
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.Composable
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@ -22,6 +16,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
import app.revanced.manager.domain.manager.base.Preference
|
import app.revanced.manager.domain.manager.base.Preference
|
||||||
|
import app.revanced.manager.ui.component.IntInputDialog
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@ -57,7 +52,7 @@ fun IntegerItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
IntegerItemDialog(current = value, name = headline) { new ->
|
IntInputDialog(current = value, name = stringResource(headline)) { new ->
|
||||||
dialogOpen = false
|
dialogOpen = false
|
||||||
new?.let(onValueChange)
|
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))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
@ -54,6 +54,7 @@ import androidx.compose.ui.window.DialogProperties
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
import app.revanced.manager.domain.manager.PreferencesManager
|
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.patcher.patch.PatchInfo
|
||||||
import app.revanced.manager.ui.component.AppTopBar
|
import app.revanced.manager.ui.component.AppTopBar
|
||||||
import app.revanced.manager.ui.component.Countdown
|
import app.revanced.manager.ui.component.Countdown
|
||||||
@ -519,7 +520,8 @@ fun OptionsDialog(
|
|||||||
val value =
|
val value =
|
||||||
if (values == null || !values.contains(key)) option.default else values[key]
|
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) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package app.revanced.manager.ui.viewmodel
|
package app.revanced.manager.ui.viewmodel
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.runtime.MutableState
|
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.PM
|
||||||
import app.revanced.manager.util.PatchSelection
|
import app.revanced.manager.util.PatchSelection
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
@ -31,10 +33,12 @@ import org.koin.core.component.get
|
|||||||
@OptIn(SavedStateHandleSaveableApi::class)
|
@OptIn(SavedStateHandleSaveableApi::class)
|
||||||
class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
|
class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
|
||||||
val bundlesRepo: PatchBundleRepository = get()
|
val bundlesRepo: PatchBundleRepository = get()
|
||||||
|
private val bundleRepository: PatchBundleRepository = get()
|
||||||
private val selectionRepository: PatchSelectionRepository = get()
|
private val selectionRepository: PatchSelectionRepository = get()
|
||||||
private val optionsRepository: PatchOptionsRepository = get()
|
private val optionsRepository: PatchOptionsRepository = get()
|
||||||
private val pm: PM = get()
|
private val pm: PM = get()
|
||||||
private val savedStateHandle: SavedStateHandle = get()
|
private val savedStateHandle: SavedStateHandle = get()
|
||||||
|
private val app: Application = get()
|
||||||
val prefs: PreferencesManager = get()
|
val prefs: PreferencesManager = get()
|
||||||
|
|
||||||
private val persistConfiguration = input.patches == null
|
private val persistConfiguration = input.patches == null
|
||||||
@ -62,8 +66,15 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (!persistConfiguration) return@launch // TODO: save options for patched apps.
|
if (!persistConfiguration) return@launch // TODO: save options for patched apps.
|
||||||
|
|
||||||
val packageName = selectedApp.packageName // Accessing this from another thread may cause crashes.
|
val packageName =
|
||||||
state.value = withContext(Dispatchers.Default) { optionsRepository.getOptions(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
|
state
|
||||||
|
@ -8,4 +8,7 @@
|
|||||||
<item quantity="one">Executed %d patch</item>
|
<item quantity="one">Executed %d patch</item>
|
||||||
<item quantity="other">Executed %d patches</item>
|
<item quantity="other">Executed %d patches</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<plurals name="selected_count">
|
||||||
|
<item quantity="other">%d selected</item>
|
||||||
|
</plurals>
|
||||||
</resources>
|
</resources>
|
@ -236,6 +236,7 @@
|
|||||||
<string name="patch_selector_sheet_filter_compat_title">Compatibility</string>
|
<string name="patch_selector_sheet_filter_compat_title">Compatibility</string>
|
||||||
|
|
||||||
<string name="string_option_menu_description">More options</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">Select from storage</string>
|
||||||
<string name="path_selector_parent_dir">Previous directory</string>
|
<string name="path_selector_parent_dir">Previous directory</string>
|
||||||
@ -276,6 +277,7 @@
|
|||||||
|
|
||||||
<string name="expand_content">expand</string>
|
<string name="expand_content">expand</string>
|
||||||
<string name="collapse_content">collapse</string>
|
<string name="collapse_content">collapse</string>
|
||||||
|
<string name="drag_handle">reorder</string>
|
||||||
|
|
||||||
<string name="more">More</string>
|
<string name="more">More</string>
|
||||||
<string name="continue_">Continue</string>
|
<string name="continue_">Continue</string>
|
||||||
@ -321,6 +323,7 @@
|
|||||||
<string name="cancel">Cancel</string>
|
<string name="cancel">Cancel</string>
|
||||||
<string name="save">Save</string>
|
<string name="save">Save</string>
|
||||||
<string name="update">Update</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="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="no_changelogs_found">No changelogs found</string>
|
||||||
<string name="just_now">Just now</string>
|
<string name="just_now">Just now</string>
|
||||||
@ -329,6 +332,7 @@
|
|||||||
<string name="days_ago">%sd ago</string>
|
<string name="days_ago">%sd ago</string>
|
||||||
<string name="invalid_date">Invalid date</string>
|
<string name="invalid_date">Invalid date</string>
|
||||||
<string name="disable_battery_optimization">Disable battery optimization</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="failed_to_check_updates">Failed to check for updates: %s</string>
|
||||||
<string name="no_update_available">No update available</string>
|
<string name="no_update_available">No update available</string>
|
||||||
@ -341,6 +345,7 @@
|
|||||||
<string name="download_update_confirmation">Download update?</string>
|
<string name="download_update_confirmation">Download update?</string>
|
||||||
<string name="no_contributors_found">No contributors found</string>
|
<string name="no_contributors_found">No contributors found</string>
|
||||||
<string name="select">Select</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_title">Select bundle type</string>
|
||||||
<string name="select_bundle_type_dialog_description">Select the type that is right for you.</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>
|
<string name="local_bundle_description">Import local files from your storage, does not automatically update</string>
|
||||||
|
@ -11,6 +11,7 @@ work-runtime = "2.9.0"
|
|||||||
compose-bom = "2024.03.00"
|
compose-bom = "2024.03.00"
|
||||||
accompanist = "0.34.0"
|
accompanist = "0.34.0"
|
||||||
placeholder = "1.1.2"
|
placeholder = "1.1.2"
|
||||||
|
reorderable = "1.5.2"
|
||||||
serialization = "1.6.3"
|
serialization = "1.6.3"
|
||||||
collection = "0.3.7"
|
collection = "0.3.7"
|
||||||
room-version = "2.6.1"
|
room-version = "2.6.1"
|
||||||
@ -120,6 +121,9 @@ libsu-nio = { group = "com.github.topjohnwu.libsu", name = "nio", version.ref =
|
|||||||
# Scrollbars
|
# Scrollbars
|
||||||
scrollbars = { group = "com.github.GIGAMOLE", name = "ComposeScrollbars", 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
|
# Compose Icons
|
||||||
# switch to br.com.devsrsouza.compose.icons after DevSrSouza/compose-icons#30 is merged
|
# 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" }
|
compose-icons-fontawesome = { group = "com.github.BenjaminHalko.compose-icons", name = "font-awesome", version.ref = "compose-icons" }
|
||||||
|
Loading…
x
Reference in New Issue
Block a user