mirror of
https://github.com/revanced/revanced-manager.git
synced 2025-05-28 20:30:13 +02:00
Merge branch 'compose-dev' of https://github.com/ReVanced/revanced-manager into fix/minor-issues
This commit is contained in:
commit
b26fe30861
@ -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 @@
|
|||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"database": {
|
"database": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"identityHash": "802fa2fda94b930bf0ebb85d195f1022",
|
"identityHash": "c0c780e55e10c9b095c004733c846b67",
|
||||||
"entities": [
|
"entities": [
|
||||||
{
|
{
|
||||||
"tableName": "patch_bundles",
|
"tableName": "patch_bundles",
|
||||||
@ -231,7 +231,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"tableName": "applied_patch",
|
"tableName": "applied_patch",
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `bundle` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, PRIMARY KEY(`package_name`, `bundle`, `patch_name`), FOREIGN KEY(`package_name`) REFERENCES `installed_app`(`current_package_name`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `bundle` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, PRIMARY KEY(`package_name`, `bundle`, `patch_name`), FOREIGN KEY(`package_name`) REFERENCES `installed_app`(`current_package_name`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"fieldPath": "packageName",
|
"fieldPath": "packageName",
|
||||||
@ -285,7 +285,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"table": "patch_bundles",
|
"table": "patch_bundles",
|
||||||
"onDelete": "NO ACTION",
|
"onDelete": "CASCADE",
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"columns": [
|
"columns": [
|
||||||
"bundle"
|
"bundle"
|
||||||
@ -407,7 +407,7 @@
|
|||||||
"views": [],
|
"views": [],
|
||||||
"setupQueries": [
|
"setupQueries": [
|
||||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '802fa2fda94b930bf0ebb85d195f1022')"
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c0c780e55e10c9b095c004733c846b67')"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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()
|
||||||
}
|
}
|
@ -22,7 +22,8 @@ import kotlinx.parcelize.Parcelize
|
|||||||
ForeignKey(
|
ForeignKey(
|
||||||
PatchBundleEntity::class,
|
PatchBundleEntity::class,
|
||||||
parentColumns = ["uid"],
|
parentColumns = ["uid"],
|
||||||
childColumns = ["bundle"]
|
childColumns = ["bundle"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
indices = [Index(value = ["bundle"], unique = false)]
|
indices = [Index(value = ["bundle"], unique = false)]
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
@ -49,15 +49,17 @@ class KeystoreManager(app: Application, private val prefs: PreferencesManager) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
keystorePath.outputStream().use {
|
withContext(Dispatchers.IO) {
|
||||||
ks.store(it, null)
|
keystorePath.outputStream().use {
|
||||||
|
ks.store(it, null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePrefs(DEFAULT, DEFAULT)
|
updatePrefs(DEFAULT, DEFAULT)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun import(cn: String, pass: String, keystore: InputStream): Boolean {
|
suspend fun import(cn: String, pass: String, keystore: InputStream): Boolean {
|
||||||
val keystoreData = keystore.readBytes()
|
val keystoreData = withContext(Dispatchers.IO) { keystore.readBytes() }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val ks = ApkSigner.readKeyStore(ByteArrayInputStream(keystoreData), null)
|
val ks = ApkSigner.readKeyStore(ByteArrayInputStream(keystoreData), null)
|
||||||
|
@ -137,7 +137,7 @@ class PatchBundleRepository(
|
|||||||
private fun addBundle(patchBundle: PatchBundleSource) =
|
private fun addBundle(patchBundle: PatchBundleSource) =
|
||||||
_sources.update { it.toMutableMap().apply { put(patchBundle.uid, patchBundle) } }
|
_sources.update { it.toMutableMap().apply { put(patchBundle.uid, patchBundle) } }
|
||||||
|
|
||||||
suspend fun createLocal(patches: InputStream, integrations: InputStream?) {
|
suspend fun createLocal(patches: InputStream, integrations: InputStream?) = withContext(Dispatchers.Default) {
|
||||||
val uid = persistenceRepo.create("", SourceInfo.Local).uid
|
val uid = persistenceRepo.create("", SourceInfo.Local).uid
|
||||||
val bundle = LocalPatchBundle("", uid, directoryOf(uid))
|
val bundle = LocalPatchBundle("", uid, directoryOf(uid))
|
||||||
|
|
||||||
@ -145,7 +145,7 @@ class PatchBundleRepository(
|
|||||||
addBundle(bundle)
|
addBundle(bundle)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createRemote(url: String, autoUpdate: Boolean) {
|
suspend fun createRemote(url: String, autoUpdate: Boolean) = withContext(Dispatchers.Default) {
|
||||||
val entity = persistenceRepo.create("", SourceInfo.from(url), autoUpdate)
|
val entity = persistenceRepo.create("", SourceInfo.from(url), autoUpdate)
|
||||||
addBundle(entity.load())
|
addBundle(entity.load())
|
||||||
}
|
}
|
||||||
|
@ -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,21 +1,30 @@
|
|||||||
package app.revanced.manager.ui.component.bundle
|
package app.revanced.manager.ui.component.bundle
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
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.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
|
||||||
import androidx.compose.material.icons.outlined.DeleteOutline
|
import androidx.compose.material.icons.outlined.DeleteOutline
|
||||||
|
import androidx.compose.material.icons.outlined.Share
|
||||||
import androidx.compose.material.icons.outlined.Update
|
import androidx.compose.material.icons.outlined.Update
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
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.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
@ -26,7 +35,7 @@ import app.revanced.manager.domain.bundles.PatchBundleSource
|
|||||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
|
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
|
||||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
|
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
|
||||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
|
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
|
||||||
import kotlinx.coroutines.flow.map
|
import app.revanced.manager.ui.component.ColumnWithScrollbar
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@ -35,17 +44,18 @@ fun BundleInformationDialog(
|
|||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onDeleteRequest: () -> Unit,
|
onDeleteRequest: () -> Unit,
|
||||||
bundle: PatchBundleSource,
|
bundle: PatchBundleSource,
|
||||||
onRefreshButton: () -> Unit,
|
onUpdate: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val composableScope = rememberCoroutineScope()
|
val composableScope = rememberCoroutineScope()
|
||||||
var viewCurrentBundlePatches by remember { mutableStateOf(false) }
|
var viewCurrentBundlePatches by remember { mutableStateOf(false) }
|
||||||
val isLocal = bundle is LocalPatchBundle
|
val isLocal = bundle is LocalPatchBundle
|
||||||
val patchCount by remember(bundle) {
|
val state by bundle.state.collectAsStateWithLifecycle()
|
||||||
bundle.state.map { it.patchBundleOrNull()?.patches?.size ?: 0 }
|
|
||||||
}.collectAsStateWithLifecycle(0)
|
|
||||||
val props by remember(bundle) {
|
val props by remember(bundle) {
|
||||||
bundle.propsFlow()
|
bundle.propsFlow()
|
||||||
}.collectAsStateWithLifecycle(null)
|
}.collectAsStateWithLifecycle(null)
|
||||||
|
val patchCount = remember(state) {
|
||||||
|
state.patchBundleOrNull()?.patches?.size ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
if (viewCurrentBundlePatches) {
|
if (viewCurrentBundlePatches) {
|
||||||
BundlePatchesDialog(
|
BundlePatchesDialog(
|
||||||
@ -70,7 +80,7 @@ fun BundleInformationDialog(
|
|||||||
BundleTopBar(
|
BundleTopBar(
|
||||||
title = bundleName,
|
title = bundleName,
|
||||||
onBackClick = onDismissRequest,
|
onBackClick = onDismissRequest,
|
||||||
onBackIcon = {
|
backIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = stringResource(R.string.back)
|
contentDescription = stringResource(R.string.back)
|
||||||
@ -86,7 +96,7 @@ fun BundleInformationDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!isLocal) {
|
if (!isLocal) {
|
||||||
IconButton(onClick = onRefreshButton) {
|
IconButton(onClick = onUpdate) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Update,
|
Icons.Outlined.Update,
|
||||||
stringResource(R.string.refresh)
|
stringResource(R.string.refresh)
|
||||||
@ -114,7 +124,95 @@ fun BundleInformationDialog(
|
|||||||
onPatchesClick = {
|
onPatchesClick = {
|
||||||
viewCurrentBundlePatches = true
|
viewCurrentBundlePatches = true
|
||||||
},
|
},
|
||||||
|
extraFields = {
|
||||||
|
(state as? PatchBundleSource.State.Failed)?.throwable?.let {
|
||||||
|
var showDialog by rememberSaveable {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
if (showDialog) BundleErrorViewerDialog(
|
||||||
|
onDismiss = { showDialog = false },
|
||||||
|
text = remember(it) { it.stackTraceToString() }
|
||||||
|
)
|
||||||
|
|
||||||
|
BundleListItem(
|
||||||
|
headlineText = stringResource(R.string.bundle_error),
|
||||||
|
supportingText = stringResource(R.string.bundle_error_description),
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Outlined.ArrowRight,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable { showDialog = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state is PatchBundleSource.State.Missing && !isLocal) {
|
||||||
|
BundleListItem(
|
||||||
|
headlineText = stringResource(R.string.bundle_error),
|
||||||
|
supportingText = stringResource(R.string.bundle_not_downloaded),
|
||||||
|
modifier = Modifier.clickable(onClick = onUpdate)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun BundleErrorViewerDialog(onDismiss: () -> Unit, text: String) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
dismissOnBackPress = true
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
BundleTopBar(
|
||||||
|
title = stringResource(R.string.bundle_error),
|
||||||
|
onBackClick = onDismiss,
|
||||||
|
backIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.back)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
val sendIntent: Intent = Intent().apply {
|
||||||
|
action = Intent.ACTION_SEND
|
||||||
|
putExtra(
|
||||||
|
Intent.EXTRA_TEXT,
|
||||||
|
text
|
||||||
|
)
|
||||||
|
type = "text/plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||||
|
context.startActivity(shareIntent)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Share,
|
||||||
|
contentDescription = stringResource(R.string.share)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
ColumnWithScrollbar(
|
||||||
|
modifier = Modifier.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
Text(text, modifier = Modifier.horizontalScroll(rememberScrollState()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -57,7 +57,7 @@ fun BundleItem(
|
|||||||
onDelete()
|
onDelete()
|
||||||
},
|
},
|
||||||
bundle = bundle,
|
bundle = bundle,
|
||||||
onRefreshButton = onUpdate,
|
onUpdate = onUpdate,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,7 +50,7 @@ fun BundlePatchesDialog(
|
|||||||
BundleTopBar(
|
BundleTopBar(
|
||||||
title = stringResource(R.string.bundle_patches),
|
title = stringResource(R.string.bundle_patches),
|
||||||
onBackClick = onDismissRequest,
|
onBackClick = onDismissRequest,
|
||||||
onBackIcon = {
|
backIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = stringResource(R.string.back)
|
contentDescription = stringResource(R.string.back)
|
||||||
|
@ -19,7 +19,7 @@ fun BundleTopBar(
|
|||||||
onBackClick: (() -> Unit)? = null,
|
onBackClick: (() -> Unit)? = null,
|
||||||
actions: @Composable (RowScope.() -> Unit) = {},
|
actions: @Composable (RowScope.() -> Unit) = {},
|
||||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||||
onBackIcon: @Composable () -> Unit,
|
backIcon: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
|
val containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
|
||||||
|
|
||||||
@ -34,7 +34,7 @@ fun BundleTopBar(
|
|||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
if (onBackClick != null) {
|
if (onBackClick != null) {
|
||||||
IconButton(onClick = onBackClick) {
|
IconButton(onClick = onBackClick) {
|
||||||
onBackIcon()
|
backIcon()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -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))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
@ -113,7 +113,7 @@ fun DashboardScreen(
|
|||||||
BundleTopBar(
|
BundleTopBar(
|
||||||
title = stringResource(R.string.bundles_selected, vm.selectedSources.size),
|
title = stringResource(R.string.bundles_selected, vm.selectedSources.size),
|
||||||
onBackClick = vm::cancelSourceSelection,
|
onBackClick = vm::cancelSourceSelection,
|
||||||
onBackIcon = {
|
backIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = stringResource(R.string.back)
|
contentDescription = stringResource(R.string.back)
|
||||||
|
@ -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) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,8 @@ package app.revanced.manager.ui.screen.settings
|
|||||||
|
|
||||||
import android.app.ActivityManager
|
import android.app.ActivityManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -87,6 +89,15 @@ fun AdvancedSettingsScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val exportDebugLogsLauncher =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) {
|
||||||
|
it?.let(vm::exportDebugLogs)
|
||||||
|
}
|
||||||
|
SettingsListItem(
|
||||||
|
headlineContent = stringResource(R.string.debug_logs_export),
|
||||||
|
modifier = Modifier.clickable { exportDebugLogsLauncher.launch(vm.debugLogFileName) }
|
||||||
|
)
|
||||||
|
|
||||||
GroupHeader(stringResource(R.string.patcher))
|
GroupHeader(stringResource(R.string.patcher))
|
||||||
BooleanItem(
|
BooleanItem(
|
||||||
preference = vm.prefs.useProcessRuntime,
|
preference = vm.prefs.useProcessRuntime,
|
||||||
|
@ -1,21 +1,41 @@
|
|||||||
package app.revanced.manager.ui.viewmodel
|
package app.revanced.manager.ui.viewmodel
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
|
import app.revanced.manager.domain.bundles.RemotePatchBundle
|
||||||
import app.revanced.manager.domain.manager.PreferencesManager
|
import app.revanced.manager.domain.manager.PreferencesManager
|
||||||
import app.revanced.manager.domain.repository.PatchBundleRepository
|
import app.revanced.manager.domain.repository.PatchBundleRepository
|
||||||
import app.revanced.manager.domain.bundles.RemotePatchBundle
|
import app.revanced.manager.util.tag
|
||||||
|
import app.revanced.manager.util.toast
|
||||||
import app.revanced.manager.util.uiSafe
|
import app.revanced.manager.util.uiSafe
|
||||||
|
import com.github.pgreze.process.Redirect
|
||||||
|
import com.github.pgreze.process.process
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
class AdvancedSettingsViewModel(
|
class AdvancedSettingsViewModel(
|
||||||
val prefs: PreferencesManager,
|
val prefs: PreferencesManager,
|
||||||
private val app: Application,
|
private val app: Application,
|
||||||
private val patchBundleRepository: PatchBundleRepository
|
private val patchBundleRepository: PatchBundleRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
val debugLogFileName: String
|
||||||
|
get() {
|
||||||
|
val time = DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(LocalDateTime.now())
|
||||||
|
|
||||||
|
return "revanced-manager_logcat_$time"
|
||||||
|
}
|
||||||
|
|
||||||
fun setApiUrl(value: String) = viewModelScope.launch(Dispatchers.Default) {
|
fun setApiUrl(value: String) = viewModelScope.launch(Dispatchers.Default) {
|
||||||
if (value == prefs.api.get()) return@launch
|
if (value == prefs.api.get()) return@launch
|
||||||
|
|
||||||
@ -32,4 +52,31 @@ class AdvancedSettingsViewModel(
|
|||||||
fun resetBundles() = viewModelScope.launch {
|
fun resetBundles() = viewModelScope.launch {
|
||||||
patchBundleRepository.reset()
|
patchBundleRepository.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun exportDebugLogs(target: Uri) = viewModelScope.launch {
|
||||||
|
val exitCode = try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
app.contentResolver.openOutputStream(target)!!.bufferedWriter().use { writer ->
|
||||||
|
val consumer = Redirect.Consume { flow ->
|
||||||
|
flow.onEach {
|
||||||
|
writer.write(it)
|
||||||
|
}.flowOn(Dispatchers.IO).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
process("logcat", "-d", stdout = consumer).resultCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Got exception while exporting logs", e)
|
||||||
|
app.toast(app.getString(R.string.debug_logs_export_failed))
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exitCode == 0)
|
||||||
|
app.toast(app.getString(R.string.debug_logs_export_success))
|
||||||
|
else
|
||||||
|
app.toast(app.getString(R.string.debug_logs_export_read_failed, exitCode))
|
||||||
|
}
|
||||||
}
|
}
|
@ -55,14 +55,17 @@ class ImportExportViewModel(
|
|||||||
|
|
||||||
fun resetOptionsForPackage(packageName: String) = viewModelScope.launch {
|
fun resetOptionsForPackage(packageName: String) = viewModelScope.launch {
|
||||||
optionsRepository.clearOptionsForPackage(packageName)
|
optionsRepository.clearOptionsForPackage(packageName)
|
||||||
|
app.toast(app.getString(R.string.patch_options_reset_toast))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearOptionsForBundle(patchBundle: PatchBundleSource) = viewModelScope.launch {
|
fun clearOptionsForBundle(patchBundle: PatchBundleSource) = viewModelScope.launch {
|
||||||
optionsRepository.clearOptionsForPatchBundle(patchBundle.uid)
|
optionsRepository.clearOptionsForPatchBundle(patchBundle.uid)
|
||||||
|
app.toast(app.getString(R.string.patch_options_reset_toast))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetOptions() = viewModelScope.launch {
|
fun resetOptions() = viewModelScope.launch {
|
||||||
optionsRepository.reset()
|
optionsRepository.reset()
|
||||||
|
app.toast(app.getString(R.string.patch_options_reset_toast))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startKeystoreImport(content: Uri) = viewModelScope.launch {
|
fun startKeystoreImport(content: Uri) = viewModelScope.launch {
|
||||||
@ -98,6 +101,7 @@ class ImportExportViewModel(
|
|||||||
private suspend fun tryKeystoreImport(cn: String, pass: String, path: Path): Boolean {
|
private suspend fun tryKeystoreImport(cn: String, pass: String, path: Path): Boolean {
|
||||||
path.inputStream().use { stream ->
|
path.inputStream().use { stream ->
|
||||||
if (keystoreManager.import(cn, pass, stream)) {
|
if (keystoreManager.import(cn, pass, stream)) {
|
||||||
|
app.toast(app.getString(R.string.import_keystore_success))
|
||||||
cancelKeystoreImport()
|
cancelKeystoreImport()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -116,6 +120,7 @@ class ImportExportViewModel(
|
|||||||
|
|
||||||
fun exportKeystore(target: Uri) = viewModelScope.launch {
|
fun exportKeystore(target: Uri) = viewModelScope.launch {
|
||||||
keystoreManager.export(contentResolver.openOutputStream(target)!!)
|
keystoreManager.export(contentResolver.openOutputStream(target)!!)
|
||||||
|
app.toast(app.getString(R.string.export_keystore_success))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun regenerateKeystore() = viewModelScope.launch {
|
fun regenerateKeystore() = viewModelScope.launch {
|
||||||
@ -123,8 +128,9 @@ class ImportExportViewModel(
|
|||||||
app.toast(app.getString(R.string.regenerate_keystore_success))
|
app.toast(app.getString(R.string.regenerate_keystore_success))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetSelection() = viewModelScope.launch(Dispatchers.Default) {
|
fun resetSelection() = viewModelScope.launch {
|
||||||
selectionRepository.reset()
|
withContext(Dispatchers.Default) { selectionRepository.reset() }
|
||||||
|
app.toast(app.getString(R.string.reset_patch_selection_success))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun executeSelectionAction(target: Uri) = viewModelScope.launch {
|
fun executeSelectionAction(target: Uri) = viewModelScope.launch {
|
||||||
@ -173,6 +179,7 @@ class ImportExportViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectionRepository.import(bundleUid, selection)
|
selectionRepository.import(bundleUid, selection)
|
||||||
|
app.toast(app.getString(R.string.import_patch_selection_success))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,6 +198,7 @@ class ImportExportViewModel(
|
|||||||
Json.Default.encodeToStream(selection, it)
|
Json.Default.encodeToStream(selection, it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
app.toast(app.getString(R.string.export_patch_selection_success))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>
|
@ -25,6 +25,8 @@
|
|||||||
|
|
||||||
<string name="bundle_missing">Missing</string>
|
<string name="bundle_missing">Missing</string>
|
||||||
<string name="bundle_error">Error</string>
|
<string name="bundle_error">Error</string>
|
||||||
|
<string name="bundle_error_description">Bundle could not be loaded. Click to view the error</string>
|
||||||
|
<string name="bundle_not_downloaded">Bundle has not been downloaded. Click here to download it</string>
|
||||||
<string name="bundle_name_default">Default</string>
|
<string name="bundle_name_default">Default</string>
|
||||||
<string name="bundle_name_fallback">Unnamed</string>
|
<string name="bundle_name_fallback">Unnamed</string>
|
||||||
|
|
||||||
@ -81,20 +83,25 @@
|
|||||||
<string name="import_keystore_dialog_password_field">Password</string>
|
<string name="import_keystore_dialog_password_field">Password</string>
|
||||||
<string name="import_keystore_dialog_button">Import</string>
|
<string name="import_keystore_dialog_button">Import</string>
|
||||||
<string name="import_keystore_wrong_credentials">Wrong keystore credentials</string>
|
<string name="import_keystore_wrong_credentials">Wrong keystore credentials</string>
|
||||||
|
<string name="import_keystore_success">Imported keystore</string>
|
||||||
<string name="export_keystore">Export keystore</string>
|
<string name="export_keystore">Export keystore</string>
|
||||||
<string name="export_keystore_description">Export the current keystore</string>
|
<string name="export_keystore_description">Export the current keystore</string>
|
||||||
<string name="export_keystore_unavailable">No keystore to export</string>
|
<string name="export_keystore_unavailable">No keystore to export</string>
|
||||||
|
<string name="export_keystore_success">Exported keystore</string>
|
||||||
<string name="regenerate_keystore">Regenerate keystore</string>
|
<string name="regenerate_keystore">Regenerate keystore</string>
|
||||||
<string name="regenerate_keystore_description">Generate a new keystore</string>
|
<string name="regenerate_keystore_description">Generate a new keystore</string>
|
||||||
<string name="regenerate_keystore_success">The keystore has been successfully replaced</string>
|
<string name="regenerate_keystore_success">The keystore has been successfully replaced</string>
|
||||||
<string name="import_patch_selection">Import patch selection</string>
|
<string name="import_patch_selection">Import patch selection</string>
|
||||||
<string name="import_patch_selection_description">Import patch selection from a JSON file</string>
|
<string name="import_patch_selection_description">Import patch selection from a JSON file</string>
|
||||||
<string name="import_patch_selection_fail">Could not import patch selection: %s</string>
|
<string name="import_patch_selection_fail">Could not import patch selection: %s</string>
|
||||||
|
<string name="import_patch_selection_success">Imported patch selection</string>
|
||||||
<string name="export_patch_selection">Export patch selection</string>
|
<string name="export_patch_selection">Export patch selection</string>
|
||||||
<string name="export_patch_selection_description">Export patch selection from a JSON file</string>
|
<string name="export_patch_selection_description">Export patch selection to a JSON file</string>
|
||||||
<string name="export_patch_selection_fail">Could not export patch selection: %s</string>
|
<string name="export_patch_selection_fail">Could not export patch selection: %s</string>
|
||||||
|
<string name="export_patch_selection_success">Exported patch selection</string>
|
||||||
<string name="reset_patch_selection">Reset patch selection</string>
|
<string name="reset_patch_selection">Reset patch selection</string>
|
||||||
<string name="reset_patch_selection_description">Reset the stored patch selection</string>
|
<string name="reset_patch_selection_description">Reset the stored patch selection</string>
|
||||||
|
<string name="reset_patch_selection_success">Patch selection has been reset</string>
|
||||||
<string name="patch_options_reset_package">Reset patch options for app</string>
|
<string name="patch_options_reset_package">Reset patch options for app</string>
|
||||||
<string name="patch_options_reset_package_description">Resets patch options for a single app</string>
|
<string name="patch_options_reset_package_description">Resets patch options for a single app</string>
|
||||||
<string name="patch_options_reset_bundle">Resets patch options for bundle</string>
|
<string name="patch_options_reset_bundle">Resets patch options for bundle</string>
|
||||||
@ -116,6 +123,7 @@
|
|||||||
<string name="edit">Edit</string>
|
<string name="edit">Edit</string>
|
||||||
<string name="dialog_input_placeholder">Value</string>
|
<string name="dialog_input_placeholder">Value</string>
|
||||||
<string name="reset">Reset</string>
|
<string name="reset">Reset</string>
|
||||||
|
<string name="share">Share</string>
|
||||||
<string name="patch">Patch</string>
|
<string name="patch">Patch</string>
|
||||||
<string name="select_from_storage">Select from storage</string>
|
<string name="select_from_storage">Select from storage</string>
|
||||||
<string name="select_from_storage_description">Select an APK file from storage using file picker</string>
|
<string name="select_from_storage_description">Select an APK file from storage using file picker</string>
|
||||||
@ -137,6 +145,10 @@
|
|||||||
<string name="process_runtime_description">This is faster and allows Patcher to use more memory.</string>
|
<string name="process_runtime_description">This is faster and allows Patcher to use more memory.</string>
|
||||||
<string name="process_runtime_memory_limit">Patcher process memory limit</string>
|
<string name="process_runtime_memory_limit">Patcher process memory limit</string>
|
||||||
<string name="process_runtime_memory_limit_description">The max amount of memory that the Patcher process can use (in megabytes)</string>
|
<string name="process_runtime_memory_limit_description">The max amount of memory that the Patcher process can use (in megabytes)</string>
|
||||||
|
<string name="debug_logs_export">Export debug logs</string>
|
||||||
|
<string name="debug_logs_export_read_failed">Failed to read logs (exit code %d)</string>
|
||||||
|
<string name="debug_logs_export_failed">Failed to export logs</string>
|
||||||
|
<string name="debug_logs_export_success">Exported logs</string>
|
||||||
<string name="api_url">API URL</string>
|
<string name="api_url">API URL</string>
|
||||||
<string name="api_url_dialog_title">Set custom API URL</string>
|
<string name="api_url_dialog_title">Set custom API URL</string>
|
||||||
<string name="api_url_dialog_description">You may have issues with features when using a custom API URL.</string>
|
<string name="api_url_dialog_description">You may have issues with features when using a custom API URL.</string>
|
||||||
@ -227,6 +239,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>
|
||||||
@ -267,6 +280,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>
|
||||||
@ -312,6 +326,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>
|
||||||
@ -320,6 +335,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>
|
||||||
@ -332,6 +348,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">Add new bundle</string>
|
<string name="select_bundle_type_dialog_title">Add new bundle</string>
|
||||||
<string name="select_bundle_type_dialog_description">Add a new bundle from a URL or storage</string>
|
<string name="select_bundle_type_dialog_description">Add a new bundle from a URL or storage</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