mirror of
https://github.com/ReVanced/revanced-manager.git
synced 2024-11-10 01:01:56 +01:00
Merge branch 'compose-dev' of https://github.com/ReVanced/revanced-manager into fix/minor-issues
This commit is contained in:
commit
b26fe30861
25 changed files with 1080 additions and 279 deletions
|
@ -188,6 +188,9 @@ dependencies {
|
|||
// Scrollbars
|
||||
implementation(libs.scrollbars)
|
||||
|
||||
// Reorderable lists
|
||||
implementation(libs.reorderable)
|
||||
|
||||
// Compose Icons
|
||||
implementation(libs.compose.icons.fontawesome)
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "802fa2fda94b930bf0ebb85d195f1022",
|
||||
"identityHash": "c0c780e55e10c9b095c004733c846b67",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "patch_bundles",
|
||||
|
@ -231,7 +231,7 @@
|
|||
},
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
|
@ -285,7 +285,7 @@
|
|||
},
|
||||
{
|
||||
"table": "patch_bundles",
|
||||
"onDelete": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"bundle"
|
||||
|
@ -407,7 +407,7 @@
|
|||
"views": [],
|
||||
"setupQueries": [
|
||||
"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 app.revanced.manager.data.room.bundles.Source
|
||||
import io.ktor.http.*
|
||||
import app.revanced.manager.data.room.options.Option.SerializedValue
|
||||
import java.io.File
|
||||
|
||||
class Converters {
|
||||
|
@ -17,4 +17,10 @@ class Converters {
|
|||
|
||||
@TypeConverter
|
||||
fun fileToString(file: File): String = file.path
|
||||
|
||||
@TypeConverter
|
||||
fun serializedOptionFromString(value: String) = SerializedValue.fromJsonString(value)
|
||||
|
||||
@TypeConverter
|
||||
fun serializedOptionToString(value: SerializedValue) = value.toJsonString()
|
||||
}
|
|
@ -22,7 +22,8 @@ import kotlinx.parcelize.Parcelize
|
|||
ForeignKey(
|
||||
PatchBundleEntity::class,
|
||||
parentColumns = ["uid"],
|
||||
childColumns = ["bundle"]
|
||||
childColumns = ["bundle"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [Index(value = ["bundle"], unique = false)]
|
||||
|
|
|
@ -3,6 +3,23 @@ package app.revanced.manager.data.room.options
|
|||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import app.revanced.manager.patcher.patch.Option
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.float
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
@Entity(
|
||||
tableName = "options",
|
||||
|
@ -19,5 +36,74 @@ data class Option(
|
|||
@ColumnInfo(name = "patch_name") val patchName: String,
|
||||
@ColumnInfo(name = "key") val key: String,
|
||||
// Encoded as Json.
|
||||
@ColumnInfo(name = "value") val value: String,
|
||||
)
|
||||
@ColumnInfo(name = "value") val value: SerializedValue,
|
||||
) {
|
||||
@Serializable
|
||||
data class SerializedValue(val raw: JsonElement) {
|
||||
fun toJsonString() = json.encodeToString(raw)
|
||||
fun deserializeFor(option: Option<*>): Any? {
|
||||
if (raw is JsonNull) return null
|
||||
|
||||
val errorMessage = "Cannot deserialize value as ${option.type}"
|
||||
try {
|
||||
if (option.type.endsWith("Array")) {
|
||||
val elementType = option.type.removeSuffix("Array")
|
||||
return raw.jsonArray.map { deserializeBasicType(elementType, it.jsonPrimitive) }
|
||||
}
|
||||
|
||||
return deserializeBasicType(option.type, raw.jsonPrimitive)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw SerializationException(errorMessage, e)
|
||||
} catch (e: IllegalStateException) {
|
||||
throw SerializationException(errorMessage, e)
|
||||
} catch (e: kotlinx.serialization.SerializationException) {
|
||||
throw SerializationException(errorMessage, e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val json = Json {
|
||||
// Patcher does not forbid the use of these values, so we should support them.
|
||||
allowSpecialFloatingPointValues = true
|
||||
}
|
||||
|
||||
private fun deserializeBasicType(type: String, value: JsonPrimitive) = when (type) {
|
||||
"Boolean" -> value.boolean
|
||||
"Int" -> value.int
|
||||
"Long" -> value.long
|
||||
"Float" -> value.float
|
||||
"String" -> value.content.also { if (!value.isString) throw SerializationException("Expected value to be a string: $value") }
|
||||
else -> throw SerializationException("Unknown type: $type")
|
||||
}
|
||||
|
||||
fun fromJsonString(value: String) = SerializedValue(json.decodeFromString(value))
|
||||
fun fromValue(value: Any?) = SerializedValue(when (value) {
|
||||
null -> JsonNull
|
||||
is Number -> JsonPrimitive(value)
|
||||
is Boolean -> JsonPrimitive(value)
|
||||
is String -> JsonPrimitive(value)
|
||||
is List<*> -> buildJsonArray {
|
||||
var elementClass: KClass<out Any>? = null
|
||||
|
||||
value.forEach {
|
||||
when (it) {
|
||||
null -> throw SerializationException("List elements must not be null")
|
||||
is Number -> add(it)
|
||||
is Boolean -> add(it)
|
||||
is String -> add(it)
|
||||
else -> throw SerializationException("Unknown element type: ${it::class.simpleName}")
|
||||
}
|
||||
|
||||
if (elementClass == null) elementClass = it::class
|
||||
else if (elementClass != it::class) throw SerializationException("List elements must have the same type")
|
||||
}
|
||||
}
|
||||
|
||||
else -> throw SerializationException("Unknown type: ${value::class.simpleName}")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class SerializationException(message: String, cause: Throwable? = null) :
|
||||
Exception(message, cause)
|
||||
}
|
||||
|
|
|
@ -49,15 +49,17 @@ class KeystoreManager(app: Application, private val prefs: PreferencesManager) {
|
|||
)
|
||||
)
|
||||
)
|
||||
keystorePath.outputStream().use {
|
||||
ks.store(it, null)
|
||||
withContext(Dispatchers.IO) {
|
||||
keystorePath.outputStream().use {
|
||||
ks.store(it, null)
|
||||
}
|
||||
}
|
||||
|
||||
updatePrefs(DEFAULT, DEFAULT)
|
||||
}
|
||||
|
||||
suspend fun import(cn: String, pass: String, keystore: InputStream): Boolean {
|
||||
val keystoreData = keystore.readBytes()
|
||||
val keystoreData = withContext(Dispatchers.IO) { keystore.readBytes() }
|
||||
|
||||
try {
|
||||
val ks = ApkSigner.readKeyStore(ByteArrayInputStream(keystoreData), null)
|
||||
|
|
|
@ -137,7 +137,7 @@ class PatchBundleRepository(
|
|||
private fun addBundle(patchBundle: PatchBundleSource) =
|
||||
_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 bundle = LocalPatchBundle("", uid, directoryOf(uid))
|
||||
|
||||
|
@ -145,7 +145,7 @@ class PatchBundleRepository(
|
|||
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)
|
||||
addBundle(entity.load())
|
||||
}
|
||||
|
|
|
@ -1,18 +1,14 @@
|
|||
package app.revanced.manager.domain.repository
|
||||
|
||||
import android.util.Log
|
||||
import app.revanced.manager.data.room.AppDatabase
|
||||
import app.revanced.manager.data.room.options.Option
|
||||
import app.revanced.manager.data.room.options.OptionGroup
|
||||
import app.revanced.manager.patcher.patch.PatchInfo
|
||||
import app.revanced.manager.util.Options
|
||||
import app.revanced.manager.util.tag
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.floatOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
|
||||
class PatchOptionsRepository(db: AppDatabase) {
|
||||
private val dao = db.optionDao()
|
||||
|
@ -24,19 +20,37 @@ class PatchOptionsRepository(db: AppDatabase) {
|
|||
packageName = packageName
|
||||
).also { dao.createOptionGroup(it) }.uid
|
||||
|
||||
suspend fun getOptions(packageName: String): Options {
|
||||
suspend fun getOptions(
|
||||
packageName: String,
|
||||
bundlePatches: Map<Int, Map<String, PatchInfo>>
|
||||
): Options {
|
||||
val options = dao.getOptions(packageName)
|
||||
// Bundle -> Patches
|
||||
return buildMap<Int, MutableMap<String, MutableMap<String, Any?>>>(options.size) {
|
||||
options.forEach { (sourceUid, bundlePatchOptionsList) ->
|
||||
// Patches -> Patch options
|
||||
this[sourceUid] = bundlePatchOptionsList.fold(mutableMapOf()) { bundlePatchOptions, option ->
|
||||
val patchOptions = bundlePatchOptions.getOrPut(option.patchName, ::mutableMapOf)
|
||||
this[sourceUid] =
|
||||
bundlePatchOptionsList.fold(mutableMapOf()) { bundlePatchOptions, dbOption ->
|
||||
val deserializedPatchOptions =
|
||||
bundlePatchOptions.getOrPut(dbOption.patchName, ::mutableMapOf)
|
||||
|
||||
patchOptions[option.key] = deserialize(option.value)
|
||||
val option =
|
||||
bundlePatches[sourceUid]?.get(dbOption.patchName)?.options?.find { it.key == dbOption.key }
|
||||
if (option != null) {
|
||||
try {
|
||||
deserializedPatchOptions[option.key] =
|
||||
dbOption.value.deserializeFor(option)
|
||||
} catch (e: Option.SerializationException) {
|
||||
Log.w(
|
||||
tag,
|
||||
"Option ${dbOption.patchName}:${option.key} could not be deserialized",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
bundlePatchOptions
|
||||
}
|
||||
bundlePatchOptions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -47,8 +61,12 @@ class PatchOptionsRepository(db: AppDatabase) {
|
|||
|
||||
groupId to bundlePatchOptions.flatMap { (patchName, patchOptions) ->
|
||||
patchOptions.mapNotNull { (key, value) ->
|
||||
val serialized = serialize(value)
|
||||
?: return@mapNotNull null // Don't save options that we can't serialize.
|
||||
val serialized = try {
|
||||
Option.SerializedValue.fromValue(value)
|
||||
} catch (e: Option.SerializationException) {
|
||||
Log.e(tag, "Option $patchName:$key could not be serialized", e)
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
Option(groupId, patchName, key, serialized)
|
||||
}
|
||||
|
@ -61,29 +79,4 @@ class PatchOptionsRepository(db: AppDatabase) {
|
|||
suspend fun clearOptionsForPackage(packageName: String) = dao.clearForPackage(packageName)
|
||||
suspend fun clearOptionsForPatchBundle(uid: Int) = dao.clearForPatchBundle(uid)
|
||||
suspend fun reset() = dao.reset()
|
||||
|
||||
private companion object {
|
||||
fun deserialize(value: String): Any? {
|
||||
val primitive = Json.decodeFromString<JsonPrimitive>(value)
|
||||
|
||||
return when {
|
||||
primitive.isString -> primitive.content
|
||||
primitive is JsonNull -> null
|
||||
else -> primitive.booleanOrNull ?: primitive.intOrNull ?: primitive.floatOrNull
|
||||
}
|
||||
}
|
||||
|
||||
fun serialize(value: Any?): String? {
|
||||
val primitive = when (value) {
|
||||
null -> JsonNull
|
||||
is String -> JsonPrimitive(value)
|
||||
is Int -> JsonPrimitive(value)
|
||||
is Float -> JsonPrimitive(value)
|
||||
is Boolean -> JsonPrimitive(value)
|
||||
else -> return null
|
||||
}
|
||||
|
||||
return Json.encodeToString(primitive)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,7 +15,7 @@ data class PatchInfo(
|
|||
val description: String?,
|
||||
val include: Boolean,
|
||||
val compatiblePackages: ImmutableList<CompatiblePackage>?,
|
||||
val options: ImmutableList<Option>?
|
||||
val options: ImmutableList<Option<*>>?
|
||||
) {
|
||||
constructor(patch: Patch<*>) : this(
|
||||
patch.name.orEmpty(),
|
||||
|
@ -78,20 +78,24 @@ data class CompatiblePackage(
|
|||
}
|
||||
|
||||
@Immutable
|
||||
data class Option(
|
||||
data class Option<T>(
|
||||
val title: String,
|
||||
val key: String,
|
||||
val description: String,
|
||||
val required: Boolean,
|
||||
val type: String,
|
||||
val default: Any?
|
||||
val default: T?,
|
||||
val presets: Map<String, T?>?,
|
||||
val validator: (T?) -> Boolean,
|
||||
) {
|
||||
constructor(option: PatchOption<*>) : this(
|
||||
constructor(option: PatchOption<T>) : this(
|
||||
option.title ?: option.key,
|
||||
option.key,
|
||||
option.description.orEmpty(),
|
||||
option.required,
|
||||
option.valueType,
|
||||
option.default,
|
||||
option.values,
|
||||
{ option.validator(option, it) },
|
||||
)
|
||||
}
|
|
@ -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
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.Share
|
||||
import androidx.compose.material.icons.outlined.Update
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.window.Dialog
|
||||
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.isDefault
|
||||
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
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
@ -35,17 +44,18 @@ fun BundleInformationDialog(
|
|||
onDismissRequest: () -> Unit,
|
||||
onDeleteRequest: () -> Unit,
|
||||
bundle: PatchBundleSource,
|
||||
onRefreshButton: () -> Unit,
|
||||
onUpdate: () -> Unit,
|
||||
) {
|
||||
val composableScope = rememberCoroutineScope()
|
||||
var viewCurrentBundlePatches by remember { mutableStateOf(false) }
|
||||
val isLocal = bundle is LocalPatchBundle
|
||||
val patchCount by remember(bundle) {
|
||||
bundle.state.map { it.patchBundleOrNull()?.patches?.size ?: 0 }
|
||||
}.collectAsStateWithLifecycle(0)
|
||||
val state by bundle.state.collectAsStateWithLifecycle()
|
||||
val props by remember(bundle) {
|
||||
bundle.propsFlow()
|
||||
}.collectAsStateWithLifecycle(null)
|
||||
val patchCount = remember(state) {
|
||||
state.patchBundleOrNull()?.patches?.size ?: 0
|
||||
}
|
||||
|
||||
if (viewCurrentBundlePatches) {
|
||||
BundlePatchesDialog(
|
||||
|
@ -70,7 +80,7 @@ fun BundleInformationDialog(
|
|||
BundleTopBar(
|
||||
title = bundleName,
|
||||
onBackClick = onDismissRequest,
|
||||
onBackIcon = {
|
||||
backIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
|
@ -86,7 +96,7 @@ fun BundleInformationDialog(
|
|||
}
|
||||
}
|
||||
if (!isLocal) {
|
||||
IconButton(onClick = onRefreshButton) {
|
||||
IconButton(onClick = onUpdate) {
|
||||
Icon(
|
||||
Icons.Outlined.Update,
|
||||
stringResource(R.string.refresh)
|
||||
|
@ -114,7 +124,95 @@ fun BundleInformationDialog(
|
|||
onPatchesClick = {
|
||||
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()
|
||||
},
|
||||
bundle = bundle,
|
||||
onRefreshButton = onUpdate,
|
||||
onUpdate = onUpdate,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -50,7 +50,7 @@ fun BundlePatchesDialog(
|
|||
BundleTopBar(
|
||||
title = stringResource(R.string.bundle_patches),
|
||||
onBackClick = onDismissRequest,
|
||||
onBackIcon = {
|
||||
backIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
|
|
|
@ -19,7 +19,7 @@ fun BundleTopBar(
|
|||
onBackClick: (() -> Unit)? = null,
|
||||
actions: @Composable (RowScope.() -> Unit) = {},
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
onBackIcon: @Composable () -> Unit,
|
||||
backIcon: @Composable () -> Unit,
|
||||
) {
|
||||
val containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
|
||||
|
||||
|
@ -34,7 +34,7 @@ fun BundleTopBar(
|
|||
navigationIcon = {
|
||||
if (onBackClick != null) {
|
||||
IconButton(onClick = onBackClick) {
|
||||
onBackIcon()
|
||||
backIcon()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1,204 +1,655 @@
|
|||
package app.revanced.manager.ui.component.patches
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Parcelable
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.DragHandle
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material.icons.outlined.Folder
|
||||
import androidx.compose.material.icons.outlined.MoreVert
|
||||
import androidx.compose.material.icons.outlined.Restore
|
||||
import androidx.compose.material.icons.outlined.SelectAll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisallowComposableCalls
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog as ComposeDialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.data.platform.Filesystem
|
||||
import app.revanced.manager.patcher.patch.Option
|
||||
import app.revanced.manager.ui.component.AlertDialogExtended
|
||||
import app.revanced.manager.ui.component.AppTopBar
|
||||
import app.revanced.manager.ui.component.FloatInputDialog
|
||||
import app.revanced.manager.ui.component.IntInputDialog
|
||||
import app.revanced.manager.ui.component.LongInputDialog
|
||||
import app.revanced.manager.util.isScrollingUp
|
||||
import app.revanced.manager.util.mutableStateSetOf
|
||||
import app.revanced.manager.util.saver.snapshotStateListSaver
|
||||
import app.revanced.manager.util.saver.snapshotStateSetSaver
|
||||
import app.revanced.manager.util.toast
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import sh.calvin.reorderable.ReorderableItem
|
||||
import sh.calvin.reorderable.rememberReorderableLazyColumnState
|
||||
import java.io.Serializable
|
||||
import kotlin.random.Random
|
||||
|
||||
// Composable functions do not support function references, so we have to use composable lambdas instead.
|
||||
private typealias OptionImpl = @Composable (Option, Any?, (Any?) -> Unit) -> Unit
|
||||
|
||||
@Composable
|
||||
private fun OptionListItem(
|
||||
option: Option,
|
||||
onClick: () -> Unit,
|
||||
trailingContent: @Composable () -> Unit
|
||||
private class OptionEditorScope<T : Any>(
|
||||
private val editor: OptionEditor<T>,
|
||||
val option: Option<T>,
|
||||
val openDialog: () -> Unit,
|
||||
val dismissDialog: () -> Unit,
|
||||
val value: T?,
|
||||
val setValue: (T?) -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
headlineContent = { Text(option.title) },
|
||||
supportingContent = { Text(option.description) },
|
||||
trailingContent = trailingContent
|
||||
)
|
||||
fun submitDialog(value: T?) {
|
||||
setValue(value)
|
||||
dismissDialog()
|
||||
}
|
||||
|
||||
fun clickAction() = editor.clickAction(this)
|
||||
|
||||
@Composable
|
||||
fun ListItemTrailingContent() = editor.ListItemTrailingContent(this)
|
||||
|
||||
@Composable
|
||||
fun Dialog() = editor.Dialog(this)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StringOptionDialog(
|
||||
name: String,
|
||||
value: String?,
|
||||
onSubmit: (String) -> Unit,
|
||||
onDismissRequest: () -> Unit
|
||||
) {
|
||||
var showFileDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var fieldValue by rememberSaveable(value) {
|
||||
mutableStateOf(value.orEmpty())
|
||||
}
|
||||
private interface OptionEditor<T : Any> {
|
||||
fun clickAction(scope: OptionEditorScope<T>) = scope.openDialog()
|
||||
|
||||
val fs: Filesystem = koinInject()
|
||||
val (contract, permissionName) = fs.permissionContract()
|
||||
val permissionLauncher = rememberLauncherForActivityResult(contract = contract) {
|
||||
showFileDialog = it
|
||||
}
|
||||
|
||||
if (showFileDialog) {
|
||||
PathSelectorDialog(
|
||||
root = fs.externalFilesDir()
|
||||
) {
|
||||
showFileDialog = false
|
||||
it?.let { path ->
|
||||
fieldValue = path.toString()
|
||||
}
|
||||
@Composable
|
||||
fun ListItemTrailingContent(scope: OptionEditorScope<T>) {
|
||||
IconButton(onClick = { clickAction(scope) }) {
|
||||
Icon(Icons.Outlined.Edit, stringResource(R.string.edit))
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = { Text(name) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = fieldValue,
|
||||
onValueChange = { fieldValue = it },
|
||||
placeholder = {
|
||||
Text(stringResource(R.string.dialog_input_placeholder))
|
||||
},
|
||||
trailingIcon = {
|
||||
var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
|
||||
IconButton(
|
||||
onClick = { showDropdownMenu = true }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.MoreVert,
|
||||
contentDescription = stringResource(R.string.string_option_menu_description)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showDropdownMenu,
|
||||
onDismissRequest = { showDropdownMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(Icons.Outlined.Folder, null)
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.path_selector))
|
||||
},
|
||||
onClick = {
|
||||
showDropdownMenu = false
|
||||
if (fs.hasStoragePermission()) {
|
||||
showFileDialog = true
|
||||
} else {
|
||||
permissionLauncher.launch(permissionName)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSubmit(fieldValue) }) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
@Composable
|
||||
fun Dialog(scope: OptionEditorScope<T>)
|
||||
}
|
||||
|
||||
private val unknownOption: OptionImpl = { option, _, _ ->
|
||||
val context = LocalContext.current
|
||||
OptionListItem(
|
||||
option = option,
|
||||
onClick = { context.toast("Unknown type: ${option.type}") },
|
||||
trailingContent = {})
|
||||
}
|
||||
|
||||
private val optionImplementations = mapOf<String, OptionImpl>(
|
||||
// These are the only two types that are currently used by the official patches
|
||||
"Boolean" to { option, value, setValue ->
|
||||
val current = (value as? Boolean) ?: false
|
||||
|
||||
OptionListItem(
|
||||
option = option,
|
||||
onClick = { setValue(!current) }
|
||||
) {
|
||||
Switch(checked = current, onCheckedChange = setValue)
|
||||
}
|
||||
},
|
||||
"String" to { option, value, setValue ->
|
||||
var showInputDialog by rememberSaveable { mutableStateOf(false) }
|
||||
fun showInputDialog() {
|
||||
showInputDialog = true
|
||||
}
|
||||
|
||||
fun dismissInputDialog() {
|
||||
showInputDialog = false
|
||||
}
|
||||
|
||||
if (showInputDialog) {
|
||||
StringOptionDialog(
|
||||
name = option.title,
|
||||
value = value as? String,
|
||||
onSubmit = {
|
||||
dismissInputDialog()
|
||||
setValue(it)
|
||||
},
|
||||
onDismissRequest = ::dismissInputDialog
|
||||
)
|
||||
}
|
||||
|
||||
OptionListItem(
|
||||
option = option,
|
||||
onClick = ::showInputDialog
|
||||
) {
|
||||
IconButton(onClick = ::showInputDialog) {
|
||||
Icon(
|
||||
Icons.Outlined.Edit,
|
||||
contentDescription = stringResource(R.string.edit)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val optionEditors = mapOf(
|
||||
"Boolean" to BooleanOptionEditor,
|
||||
"String" to StringOptionEditor,
|
||||
"Int" to IntOptionEditor,
|
||||
"Long" to LongOptionEditor,
|
||||
"Float" to FloatOptionEditor,
|
||||
"BooleanArray" to ListOptionEditor(BooleanOptionEditor),
|
||||
"StringArray" to ListOptionEditor(StringOptionEditor),
|
||||
"IntArray" to ListOptionEditor(IntOptionEditor),
|
||||
"LongArray" to ListOptionEditor(LongOptionEditor),
|
||||
"FloatArray" to ListOptionEditor(FloatOptionEditor),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun OptionItem(option: Option, value: Any?, setValue: (Any?) -> Unit) {
|
||||
val implementation = remember(option.type) {
|
||||
optionImplementations.getOrDefault(
|
||||
option.type,
|
||||
unknownOption
|
||||
private inline fun <T : Any> WithOptionEditor(
|
||||
editor: OptionEditor<T>,
|
||||
option: Option<T>,
|
||||
value: T?,
|
||||
noinline setValue: (T?) -> Unit,
|
||||
crossinline onDismissDialog: @DisallowComposableCalls () -> Unit = {},
|
||||
block: OptionEditorScope<T>.() -> Unit
|
||||
) {
|
||||
var showDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val scope = remember(editor, option, value, setValue) {
|
||||
OptionEditorScope(
|
||||
editor,
|
||||
option,
|
||||
openDialog = { showDialog = true },
|
||||
dismissDialog = {
|
||||
showDialog = false
|
||||
onDismissDialog()
|
||||
},
|
||||
value,
|
||||
setValue
|
||||
)
|
||||
}
|
||||
|
||||
implementation(option, value, setValue)
|
||||
if (showDialog) scope.Dialog()
|
||||
|
||||
scope.block()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T : Any> OptionItem(option: Option<T>, value: T?, setValue: (T?) -> Unit) {
|
||||
val editor = remember(option.type, option.presets) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val baseOptionEditor =
|
||||
optionEditors.getOrDefault(option.type, UnknownTypeEditor) as OptionEditor<T>
|
||||
|
||||
if (option.type != "Boolean" && option.presets != null) PresetOptionEditor(baseOptionEditor)
|
||||
else baseOptionEditor
|
||||
}
|
||||
|
||||
WithOptionEditor(editor, option, value, setValue) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = ::clickAction),
|
||||
headlineContent = { Text(option.title) },
|
||||
supportingContent = { Text(option.description) },
|
||||
trailingContent = { ListItemTrailingContent() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private object StringOptionEditor : OptionEditor<String> {
|
||||
@Composable
|
||||
override fun Dialog(scope: OptionEditorScope<String>) {
|
||||
var showFileDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var fieldValue by rememberSaveable(scope.value) {
|
||||
mutableStateOf(scope.value.orEmpty())
|
||||
}
|
||||
val validatorFailed by remember {
|
||||
derivedStateOf { !scope.option.validator(fieldValue) }
|
||||
}
|
||||
|
||||
val fs: Filesystem = koinInject()
|
||||
val (contract, permissionName) = fs.permissionContract()
|
||||
val permissionLauncher = rememberLauncherForActivityResult(contract = contract) {
|
||||
showFileDialog = it
|
||||
}
|
||||
|
||||
if (showFileDialog) {
|
||||
PathSelectorDialog(
|
||||
root = fs.externalFilesDir()
|
||||
) {
|
||||
showFileDialog = false
|
||||
it?.let { path ->
|
||||
fieldValue = path.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = scope.dismissDialog,
|
||||
title = { Text(scope.option.title) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = fieldValue,
|
||||
onValueChange = { fieldValue = it },
|
||||
placeholder = {
|
||||
Text(stringResource(R.string.dialog_input_placeholder))
|
||||
},
|
||||
isError = validatorFailed,
|
||||
supportingText = {
|
||||
if (validatorFailed) {
|
||||
Text(
|
||||
stringResource(R.string.input_dialog_value_invalid),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
|
||||
IconButton(
|
||||
onClick = { showDropdownMenu = true }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.MoreVert,
|
||||
stringResource(R.string.string_option_menu_description)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showDropdownMenu,
|
||||
onDismissRequest = { showDropdownMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(Icons.Outlined.Folder, null)
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.path_selector))
|
||||
},
|
||||
onClick = {
|
||||
showDropdownMenu = false
|
||||
if (fs.hasStoragePermission()) {
|
||||
showFileDialog = true
|
||||
} else {
|
||||
permissionLauncher.launch(permissionName)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = !validatorFailed,
|
||||
onClick = { scope.submitDialog(fieldValue) }) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = scope.dismissDialog) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private abstract class NumberOptionEditor<T : Number> : OptionEditor<T> {
|
||||
@Composable
|
||||
protected abstract fun NumberDialog(
|
||||
title: String,
|
||||
current: T?,
|
||||
validator: (T?) -> Boolean,
|
||||
onSubmit: (T?) -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun Dialog(scope: OptionEditorScope<T>) {
|
||||
NumberDialog(scope.option.title, scope.value, scope.option.validator) {
|
||||
if (it == null) return@NumberDialog scope.dismissDialog()
|
||||
|
||||
scope.submitDialog(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object IntOptionEditor : NumberOptionEditor<Int>() {
|
||||
@Composable
|
||||
override fun NumberDialog(
|
||||
title: String,
|
||||
current: Int?,
|
||||
validator: (Int?) -> Boolean,
|
||||
onSubmit: (Int?) -> Unit
|
||||
) = IntInputDialog(current, title, validator, onSubmit)
|
||||
}
|
||||
|
||||
private object LongOptionEditor : NumberOptionEditor<Long>() {
|
||||
@Composable
|
||||
override fun NumberDialog(
|
||||
title: String,
|
||||
current: Long?,
|
||||
validator: (Long?) -> Boolean,
|
||||
onSubmit: (Long?) -> Unit
|
||||
) = LongInputDialog(current, title, validator, onSubmit)
|
||||
}
|
||||
|
||||
private object FloatOptionEditor : NumberOptionEditor<Float>() {
|
||||
@Composable
|
||||
override fun NumberDialog(
|
||||
title: String,
|
||||
current: Float?,
|
||||
validator: (Float?) -> Boolean,
|
||||
onSubmit: (Float?) -> Unit
|
||||
) = FloatInputDialog(current, title, validator, onSubmit)
|
||||
}
|
||||
|
||||
private object BooleanOptionEditor : OptionEditor<Boolean> {
|
||||
override fun clickAction(scope: OptionEditorScope<Boolean>) {
|
||||
scope.setValue(!scope.current)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun ListItemTrailingContent(scope: OptionEditorScope<Boolean>) {
|
||||
Switch(checked = scope.current, onCheckedChange = scope.setValue)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Dialog(scope: OptionEditorScope<Boolean>) {
|
||||
}
|
||||
|
||||
private val OptionEditorScope<Boolean>.current get() = value ?: false
|
||||
}
|
||||
|
||||
private object UnknownTypeEditor : OptionEditor<Any>, KoinComponent {
|
||||
override fun clickAction(scope: OptionEditorScope<Any>) =
|
||||
get<Application>().toast("Unknown type: ${scope.option.type}")
|
||||
|
||||
@Composable
|
||||
override fun Dialog(scope: OptionEditorScope<Any>) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper for [OptionEditor]s that shows selectable presets.
|
||||
*
|
||||
* @param innerEditor The [OptionEditor] for [T].
|
||||
*/
|
||||
private class PresetOptionEditor<T : Any>(private val innerEditor: OptionEditor<T>) :
|
||||
OptionEditor<T> {
|
||||
@Composable
|
||||
override fun Dialog(scope: OptionEditorScope<T>) {
|
||||
var selectedPreset by rememberSaveable(scope.value, scope.option.presets) {
|
||||
val presets = scope.option.presets!!
|
||||
|
||||
mutableStateOf(presets.entries.find { it.value == scope.value }?.key)
|
||||
}
|
||||
|
||||
WithOptionEditor(
|
||||
innerEditor,
|
||||
scope.option,
|
||||
scope.value,
|
||||
scope.setValue,
|
||||
onDismissDialog = scope.dismissDialog
|
||||
) inner@{
|
||||
var hidePresetsDialog by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
if (hidePresetsDialog) return@inner
|
||||
|
||||
// TODO: add a divider for scrollable content
|
||||
AlertDialogExtended(
|
||||
onDismissRequest = scope.dismissDialog,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (selectedPreset != null) scope.submitDialog(
|
||||
scope.option.presets?.get(
|
||||
selectedPreset
|
||||
)
|
||||
)
|
||||
else {
|
||||
this@inner.openDialog()
|
||||
// Hide the presets dialog so it doesn't show up in the background.
|
||||
hidePresetsDialog = true
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(if (selectedPreset != null) R.string.save else R.string.continue_))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = scope.dismissDialog) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
title = { Text(scope.option.title) },
|
||||
textHorizontalPadding = PaddingValues(horizontal = 0.dp),
|
||||
text = {
|
||||
val presets = remember(scope.option.presets) {
|
||||
scope.option.presets?.entries?.toList().orEmpty()
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
@Composable
|
||||
fun Item(title: String, value: Any?, presetKey: String?) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { selectedPreset = presetKey },
|
||||
headlineContent = { Text(title) },
|
||||
supportingContent = value?.toString()?.let { { Text(it) } },
|
||||
leadingContent = {
|
||||
RadioButton(
|
||||
selected = selectedPreset == presetKey,
|
||||
onClick = { selectedPreset = presetKey }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
items(presets, key = { it.key }) {
|
||||
Item(it.key, it.value, it.key)
|
||||
}
|
||||
|
||||
item(key = null) {
|
||||
Item(stringResource(R.string.option_preset_custom_value), null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ListOptionEditor<T : Serializable>(private val elementEditor: OptionEditor<T>) :
|
||||
OptionEditor<List<T>> {
|
||||
private fun createElementOption(option: Option<List<T>>) = Option<T>(
|
||||
option.title,
|
||||
option.key,
|
||||
option.description,
|
||||
option.required,
|
||||
option.type.removeSuffix("Array"),
|
||||
null,
|
||||
null
|
||||
) { true }
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
override fun Dialog(scope: OptionEditorScope<List<T>>) {
|
||||
val items =
|
||||
rememberSaveable(scope.value, saver = snapshotStateListSaver()) {
|
||||
// We need a key for each element in order to support dragging.
|
||||
scope.value?.map(::Item)?.toMutableStateList() ?: mutableStateListOf()
|
||||
}
|
||||
val listIsDirty by remember {
|
||||
derivedStateOf {
|
||||
val current = scope.value.orEmpty()
|
||||
if (current.size != items.size) return@derivedStateOf true
|
||||
|
||||
current.forEachIndexed { index, value ->
|
||||
if (value != items[index].value) return@derivedStateOf true
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
val reorderableLazyColumnState =
|
||||
rememberReorderableLazyColumnState(lazyListState) { from, to ->
|
||||
// Update the list
|
||||
items.add(to.index, items.removeAt(from.index))
|
||||
}
|
||||
|
||||
var deleteMode by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val deletionTargets = rememberSaveable(saver = snapshotStateSetSaver()) {
|
||||
mutableStateSetOf<Int>()
|
||||
}
|
||||
|
||||
val back = back@{
|
||||
if (deleteMode) {
|
||||
deletionTargets.clear()
|
||||
deleteMode = false
|
||||
return@back
|
||||
}
|
||||
|
||||
if (!listIsDirty) {
|
||||
scope.dismissDialog()
|
||||
return@back
|
||||
}
|
||||
|
||||
scope.submitDialog(items.mapNotNull { it.value })
|
||||
}
|
||||
|
||||
ComposeDialog(
|
||||
onDismissRequest = back,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnBackPress = true
|
||||
),
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = if (deleteMode) pluralStringResource(
|
||||
R.plurals.selected_count,
|
||||
deletionTargets.size,
|
||||
deletionTargets.size
|
||||
) else scope.option.title,
|
||||
onBackClick = back,
|
||||
backIcon = {
|
||||
if (deleteMode) {
|
||||
return@AppTopBar Icon(
|
||||
Icons.Filled.Close,
|
||||
stringResource(R.string.cancel)
|
||||
)
|
||||
}
|
||||
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
|
||||
},
|
||||
actions = {
|
||||
if (deleteMode) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (items.size == deletionTargets.size) deletionTargets.clear()
|
||||
else deletionTargets.addAll(items.map { it.key })
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.SelectAll,
|
||||
stringResource(R.string.select_deselect_all)
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
items.removeIf { it.key in deletionTargets }
|
||||
deletionTargets.clear()
|
||||
deleteMode = false
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Delete,
|
||||
stringResource(R.string.delete)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = items::clear) {
|
||||
Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (deleteMode) return@Scaffold
|
||||
|
||||
ExtendedFloatingActionButton(
|
||||
text = { Text(stringResource(R.string.add)) },
|
||||
icon = { Icon(Icons.Outlined.Add, null) },
|
||||
expanded = lazyListState.isScrollingUp,
|
||||
onClick = { items.add(Item(null)) }
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
val elementOption = remember(scope.option) { createElementOption(scope.option) }
|
||||
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
itemsIndexed(items, key = { _, item -> item.key }) { index, item ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
ReorderableItem(reorderableLazyColumnState, key = item.key) {
|
||||
WithOptionEditor(
|
||||
elementEditor,
|
||||
elementOption,
|
||||
value = item.value,
|
||||
setValue = { items[index] = item.copy(value = it) }
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.combinedClickable(
|
||||
indication = LocalIndication.current,
|
||||
interactionSource = interactionSource,
|
||||
onLongClickLabel = stringResource(R.string.select),
|
||||
onLongClick = {
|
||||
deletionTargets.add(item.key)
|
||||
deleteMode = true
|
||||
},
|
||||
onClick = {
|
||||
if (!deleteMode) {
|
||||
clickAction()
|
||||
return@combinedClickable
|
||||
}
|
||||
|
||||
if (item.key in deletionTargets) {
|
||||
deletionTargets.remove(
|
||||
item.key
|
||||
)
|
||||
deleteMode = deletionTargets.isNotEmpty()
|
||||
} else deletionTargets.add(item.key)
|
||||
},
|
||||
),
|
||||
tonalElevation = if (deleteMode && item.key in deletionTargets) 8.dp else 0.dp,
|
||||
leadingContent = {
|
||||
IconButton(
|
||||
modifier = Modifier.draggableHandle(interactionSource = interactionSource),
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.DragHandle,
|
||||
stringResource(R.string.drag_handle)
|
||||
)
|
||||
}
|
||||
},
|
||||
headlineContent = {
|
||||
if (item.value == null) return@ListItem Text(
|
||||
stringResource(R.string.empty),
|
||||
fontStyle = FontStyle.Italic
|
||||
)
|
||||
|
||||
Text(item.value.toString())
|
||||
},
|
||||
trailingContent = {
|
||||
ListItemTrailingContent()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
private data class Item<T : Serializable>(val value: T?, val key: Int = Random.nextInt()) :
|
||||
Parcelable
|
||||
}
|
|
@ -4,17 +4,11 @@ import androidx.annotation.StringRes
|
|||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
|
@ -22,6 +16,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.res.stringResource
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.domain.manager.base.Preference
|
||||
import app.revanced.manager.ui.component.IntInputDialog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
@ -57,7 +52,7 @@ fun IntegerItem(
|
|||
}
|
||||
|
||||
if (dialogOpen) {
|
||||
IntegerItemDialog(current = value, name = headline) { new ->
|
||||
IntInputDialog(current = value, name = stringResource(headline)) { new ->
|
||||
dialogOpen = false
|
||||
new?.let(onValueChange)
|
||||
}
|
||||
|
@ -78,44 +73,4 @@ fun IntegerItem(
|
|||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IntegerItemDialog(current: Int, @StringRes name: Int, onSubmit: (Int?) -> Unit) {
|
||||
var fieldValue by rememberSaveable {
|
||||
mutableStateOf(current.toString())
|
||||
}
|
||||
|
||||
val integerFieldValue by remember {
|
||||
derivedStateOf {
|
||||
fieldValue.toIntOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { onSubmit(null) },
|
||||
title = { Text(stringResource(name)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = fieldValue,
|
||||
onValueChange = { fieldValue = it },
|
||||
placeholder = {
|
||||
Text(stringResource(R.string.dialog_input_placeholder))
|
||||
},
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { integerFieldValue?.let(onSubmit) },
|
||||
enabled = integerFieldValue != null,
|
||||
) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { onSubmit(null) }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
|
@ -113,7 +113,7 @@ fun DashboardScreen(
|
|||
BundleTopBar(
|
||||
title = stringResource(R.string.bundles_selected, vm.selectedSources.size),
|
||||
onBackClick = vm::cancelSourceSelection,
|
||||
onBackIcon = {
|
||||
backIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
|
|
|
@ -54,6 +54,7 @@ import androidx.compose.ui.window.DialogProperties
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.domain.manager.PreferencesManager
|
||||
import app.revanced.manager.patcher.patch.Option
|
||||
import app.revanced.manager.patcher.patch.PatchInfo
|
||||
import app.revanced.manager.ui.component.AppTopBar
|
||||
import app.revanced.manager.ui.component.Countdown
|
||||
|
@ -519,7 +520,8 @@ fun OptionsDialog(
|
|||
val value =
|
||||
if (values == null || !values.contains(key)) option.default else values[key]
|
||||
|
||||
OptionItem(option = option, value = value, setValue = { set(key, it) })
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
OptionItem(option = option as Option<Any>, value = value, setValue = { set(key, it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ package app.revanced.manager.ui.screen.settings
|
|||
|
||||
import android.app.ActivityManager
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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))
|
||||
BooleanItem(
|
||||
preference = vm.prefs.useProcessRuntime,
|
||||
|
|
|
@ -1,21 +1,41 @@
|
|||
package app.revanced.manager.ui.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.domain.bundles.RemotePatchBundle
|
||||
import app.revanced.manager.domain.manager.PreferencesManager
|
||||
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 com.github.pgreze.process.Redirect
|
||||
import com.github.pgreze.process.process
|
||||
import kotlinx.coroutines.CancellationException
|
||||
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.withContext
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class AdvancedSettingsViewModel(
|
||||
val prefs: PreferencesManager,
|
||||
private val app: Application,
|
||||
private val patchBundleRepository: PatchBundleRepository
|
||||
) : 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) {
|
||||
if (value == prefs.api.get()) return@launch
|
||||
|
||||
|
@ -32,4 +52,31 @@ class AdvancedSettingsViewModel(
|
|||
fun resetBundles() = viewModelScope.launch {
|
||||
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 {
|
||||
optionsRepository.clearOptionsForPackage(packageName)
|
||||
app.toast(app.getString(R.string.patch_options_reset_toast))
|
||||
}
|
||||
|
||||
fun clearOptionsForBundle(patchBundle: PatchBundleSource) = viewModelScope.launch {
|
||||
optionsRepository.clearOptionsForPatchBundle(patchBundle.uid)
|
||||
app.toast(app.getString(R.string.patch_options_reset_toast))
|
||||
}
|
||||
|
||||
fun resetOptions() = viewModelScope.launch {
|
||||
optionsRepository.reset()
|
||||
app.toast(app.getString(R.string.patch_options_reset_toast))
|
||||
}
|
||||
|
||||
fun startKeystoreImport(content: Uri) = viewModelScope.launch {
|
||||
|
@ -98,6 +101,7 @@ class ImportExportViewModel(
|
|||
private suspend fun tryKeystoreImport(cn: String, pass: String, path: Path): Boolean {
|
||||
path.inputStream().use { stream ->
|
||||
if (keystoreManager.import(cn, pass, stream)) {
|
||||
app.toast(app.getString(R.string.import_keystore_success))
|
||||
cancelKeystoreImport()
|
||||
return true
|
||||
}
|
||||
|
@ -116,6 +120,7 @@ class ImportExportViewModel(
|
|||
|
||||
fun exportKeystore(target: Uri) = viewModelScope.launch {
|
||||
keystoreManager.export(contentResolver.openOutputStream(target)!!)
|
||||
app.toast(app.getString(R.string.export_keystore_success))
|
||||
}
|
||||
|
||||
fun regenerateKeystore() = viewModelScope.launch {
|
||||
|
@ -123,8 +128,9 @@ class ImportExportViewModel(
|
|||
app.toast(app.getString(R.string.regenerate_keystore_success))
|
||||
}
|
||||
|
||||
fun resetSelection() = viewModelScope.launch(Dispatchers.Default) {
|
||||
selectionRepository.reset()
|
||||
fun resetSelection() = viewModelScope.launch {
|
||||
withContext(Dispatchers.Default) { selectionRepository.reset() }
|
||||
app.toast(app.getString(R.string.reset_patch_selection_success))
|
||||
}
|
||||
|
||||
fun executeSelectionAction(target: Uri) = viewModelScope.launch {
|
||||
|
@ -173,6 +179,7 @@ class ImportExportViewModel(
|
|||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
app.toast(app.getString(R.string.export_patch_selection_success))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package app.revanced.manager.ui.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.MutableState
|
||||
|
@ -22,6 +23,7 @@ import app.revanced.manager.util.Options
|
|||
import app.revanced.manager.util.PM
|
||||
import app.revanced.manager.util.PatchSelection
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
@ -31,10 +33,12 @@ import org.koin.core.component.get
|
|||
@OptIn(SavedStateHandleSaveableApi::class)
|
||||
class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
|
||||
val bundlesRepo: PatchBundleRepository = get()
|
||||
private val bundleRepository: PatchBundleRepository = get()
|
||||
private val selectionRepository: PatchSelectionRepository = get()
|
||||
private val optionsRepository: PatchOptionsRepository = get()
|
||||
private val pm: PM = get()
|
||||
private val savedStateHandle: SavedStateHandle = get()
|
||||
private val app: Application = get()
|
||||
val prefs: PreferencesManager = get()
|
||||
|
||||
private val persistConfiguration = input.patches == null
|
||||
|
@ -62,8 +66,15 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
|
|||
viewModelScope.launch {
|
||||
if (!persistConfiguration) return@launch // TODO: save options for patched apps.
|
||||
|
||||
val packageName = selectedApp.packageName // Accessing this from another thread may cause crashes.
|
||||
state.value = withContext(Dispatchers.Default) { optionsRepository.getOptions(packageName) }
|
||||
val packageName =
|
||||
selectedApp.packageName // Accessing this from another thread may cause crashes.
|
||||
|
||||
state.value = withContext(Dispatchers.Default) {
|
||||
val bundlePatches = bundleRepository.bundles.first()
|
||||
.mapValues { (_, bundle) -> bundle.patches.associateBy { it.name } }
|
||||
|
||||
optionsRepository.getOptions(packageName, bundlePatches)
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
|
|
|
@ -8,4 +8,7 @@
|
|||
<item quantity="one">Executed %d patch</item>
|
||||
<item quantity="other">Executed %d patches</item>
|
||||
</plurals>
|
||||
<plurals name="selected_count">
|
||||
<item quantity="other">%d selected</item>
|
||||
</plurals>
|
||||
</resources>
|
|
@ -25,6 +25,8 @@
|
|||
|
||||
<string name="bundle_missing">Missing</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_fallback">Unnamed</string>
|
||||
|
||||
|
@ -81,20 +83,25 @@
|
|||
<string name="import_keystore_dialog_password_field">Password</string>
|
||||
<string name="import_keystore_dialog_button">Import</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_description">Export the current keystore</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_description">Generate a new keystore</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_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_success">Imported 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_success">Exported 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_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_description">Resets patch options for a single app</string>
|
||||
<string name="patch_options_reset_bundle">Resets patch options for bundle</string>
|
||||
|
@ -116,6 +123,7 @@
|
|||
<string name="edit">Edit</string>
|
||||
<string name="dialog_input_placeholder">Value</string>
|
||||
<string name="reset">Reset</string>
|
||||
<string name="share">Share</string>
|
||||
<string name="patch">Patch</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>
|
||||
|
@ -137,6 +145,10 @@
|
|||
<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_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_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>
|
||||
|
@ -227,6 +239,7 @@
|
|||
<string name="patch_selector_sheet_filter_compat_title">Compatibility</string>
|
||||
|
||||
<string name="string_option_menu_description">More options</string>
|
||||
<string name="option_preset_custom_value">Custom value</string>
|
||||
|
||||
<string name="path_selector">Select from storage</string>
|
||||
<string name="path_selector_parent_dir">Previous directory</string>
|
||||
|
@ -267,6 +280,7 @@
|
|||
|
||||
<string name="expand_content">expand</string>
|
||||
<string name="collapse_content">collapse</string>
|
||||
<string name="drag_handle">reorder</string>
|
||||
|
||||
<string name="more">More</string>
|
||||
<string name="continue_">Continue</string>
|
||||
|
@ -312,6 +326,7 @@
|
|||
<string name="cancel">Cancel</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="update">Update</string>
|
||||
<string name="empty">Empty</string>
|
||||
<string name="installing_message">Tap on <b>Update</b> when prompted. \n ReVanced Manager will close when updating.</string>
|
||||
<string name="no_changelogs_found">No changelogs found</string>
|
||||
<string name="just_now">Just now</string>
|
||||
|
@ -320,6 +335,7 @@
|
|||
<string name="days_ago">%sd ago</string>
|
||||
<string name="invalid_date">Invalid date</string>
|
||||
<string name="disable_battery_optimization">Disable battery optimization</string>
|
||||
<string name="input_dialog_value_invalid">Invalid value</string>
|
||||
|
||||
<string name="failed_to_check_updates">Failed to check for updates: %s</string>
|
||||
<string name="no_update_available">No update available</string>
|
||||
|
@ -332,6 +348,7 @@
|
|||
<string name="download_update_confirmation">Download update?</string>
|
||||
<string name="no_contributors_found">No contributors found</string>
|
||||
<string name="select">Select</string>
|
||||
<string name="select_deselect_all">Select or deselect all</string>
|
||||
<string name="select_bundle_type_dialog_title">Add new bundle</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>
|
||||
|
|
|
@ -11,6 +11,7 @@ work-runtime = "2.9.0"
|
|||
compose-bom = "2024.03.00"
|
||||
accompanist = "0.34.0"
|
||||
placeholder = "1.1.2"
|
||||
reorderable = "1.5.2"
|
||||
serialization = "1.6.3"
|
||||
collection = "0.3.7"
|
||||
room-version = "2.6.1"
|
||||
|
@ -120,6 +121,9 @@ libsu-nio = { group = "com.github.topjohnwu.libsu", name = "nio", version.ref =
|
|||
# Scrollbars
|
||||
scrollbars = { group = "com.github.GIGAMOLE", name = "ComposeScrollbars", version.ref = "scrollbars" }
|
||||
|
||||
# Reorderable lists
|
||||
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
|
||||
|
||||
# Compose Icons
|
||||
# switch to br.com.devsrsouza.compose.icons after DevSrSouza/compose-icons#30 is merged
|
||||
compose-icons-fontawesome = { group = "com.github.BenjaminHalko.compose-icons", name = "font-awesome", version.ref = "compose-icons" }
|
||||
|
|
Loading…
Reference in a new issue