Merge branch 'compose-dev' of https://github.com/ReVanced/revanced-manager into fix/minor-issues

This commit is contained in:
Ushie 2024-07-04 23:34:22 +03:00
commit b26fe30861
No known key found for this signature in database
GPG key ID: B3AAD18842E34632
25 changed files with 1080 additions and 279 deletions

View file

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

View file

@ -2,7 +2,7 @@
"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')"
]
}
}

View file

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

View file

@ -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)]

View file

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

View file

@ -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)

View file

@ -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())
}

View file

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

View file

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

View file

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

View file

@ -1,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()))
}
}
}
}

View file

@ -57,7 +57,7 @@ fun BundleItem(
onDelete()
},
bundle = bundle,
onRefreshButton = onUpdate,
onUpdate = onUpdate,
)
}

View file

@ -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)

View file

@ -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()
}
}
},

View file

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

View file

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

View file

@ -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)

View file

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

View file

@ -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,

View file

@ -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))
}
}

View file

@ -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))
}
}

View file

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

View file

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

View file

@ -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>

View file

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