Merge branch 'dev' into feat/redesign-selected-app-info

This commit is contained in:
Ushie
2026-04-01 21:10:33 +03:00
25 changed files with 2814 additions and 1985 deletions

View File

@@ -1,3 +1,57 @@
# app [2.6.0-dev.7](https://github.com/ReVanced/revanced-manager/compare/v2.6.0-dev.6...v2.6.0-dev.7) (2026-04-01)
### Features
* Improve patch options ([#3213](https://github.com/ReVanced/revanced-manager/issues/3213)) ([90667c8](https://github.com/ReVanced/revanced-manager/commit/90667c8c141c743b11a189e7ab3061ee58c7bc87))
# app [2.6.0-dev.6](https://github.com/ReVanced/revanced-manager/compare/v2.6.0-dev.5...v2.6.0-dev.6) (2026-04-01)
### Bug Fixes
* Missing database migration file [no ci] ([df9b44c](https://github.com/ReVanced/revanced-manager/commit/df9b44c9f4959c81e16a1665e8066bd01e97b1fe))
### Features
* Improve AppsScreen design, add app pinning and jump to top ([#3240](https://github.com/ReVanced/revanced-manager/issues/3240)) ([46720a4](https://github.com/ReVanced/revanced-manager/commit/46720a4a885ad5aa321ab855439bb30c292cb17f))
# app [2.6.0-dev.5](https://github.com/ReVanced/revanced-manager/compare/v2.6.0-dev.4...v2.6.0-dev.5) (2026-03-29)
### Bug Fixes
* Dashboard padding ([5f757b6](https://github.com/ReVanced/revanced-manager/commit/5f757b6ee1fd923afaeffeae6bde12934822c30f))
# app [2.6.0-dev.4](https://github.com/ReVanced/revanced-manager/compare/v2.6.0-dev.3...v2.6.0-dev.4) (2026-03-28)
### Bug Fixes
* Ignore RichTap vibrator hardware crash ([7856652](https://github.com/ReVanced/revanced-manager/commit/7856652506896a804a57fc085e80c6cf5c1fcba0))
# app [2.6.0-dev.3](https://github.com/ReVanced/revanced-manager/compare/v2.6.0-dev.2...v2.6.0-dev.3) (2026-03-26)
### Bug Fixes
* Preserve applied patches on patched apps when source patches is deleted ([#3227](https://github.com/ReVanced/revanced-manager/issues/3227)) ([4884fdb](https://github.com/ReVanced/revanced-manager/commit/4884fdb5545134181a8a2176c7a21911aa22be9b))
# app [2.6.0-dev.2](https://github.com/ReVanced/revanced-manager/compare/v2.6.0-dev.1...v2.6.0-dev.2) (2026-03-25)
### Bug Fixes
* Adjust font size and fix letter spacing in announcements ([e1660a7](https://github.com/ReVanced/revanced-manager/commit/e1660a7f2f94f3f50ee8bb2479888af8b760e51e))
# app [2.6.0-dev.1](https://github.com/ReVanced/revanced-manager/compare/v2.5.1...v2.6.0-dev.1) (2026-03-25)
### Features
* Better styling for announcements ([4f88357](https://github.com/ReVanced/revanced-manager/commit/4f88357d9f2aa44bcab980e2719f3d28d169de85))
## app [2.5.1](https://github.com/ReVanced/revanced-manager/compare/v2.5.0...v2.5.1) (2026-03-24)

View File

@@ -179,10 +179,6 @@ android {
}
buildTypes {
debug {
resValue("string", "app_name", "ReVanced Manager (Debug)")
}
release {
// Causes patching to not work properly, if enabled.
isMinifyEnabled = false
@@ -191,10 +187,7 @@ android {
val keystoreFile = file("keystore.jks")
if (project.hasProperty("signAsDebug") || !keystoreFile.exists()) {
resValue("string", "app_name", "ReVanced Manager (Debug signed)")
signingConfig = signingConfigs.getByName("debug")
isPseudoLocalesEnabled = true
} else {
signingConfig = signingConfigs.create("release") {
storeFile = keystoreFile

View File

@@ -1 +1 @@
version = 2.5.1
version = 2.6.0-dev.7

View File

@@ -0,0 +1,471 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "00e80220281511a934e0dee1fbd8b3c9",
"entities": [
{
"tableName": "patch_bundles",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` TEXT, `source` TEXT NOT NULL, `auto_update` INTEGER NOT NULL, `released_at` INTEGER, PRIMARY KEY(`uid`))",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "versionHash",
"columnName": "version",
"affinity": "TEXT"
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "autoUpdate",
"columnName": "auto_update",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "releasedAt",
"columnName": "released_at",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"uid"
]
}
},
{
"tableName": "patch_selections",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `patch_bundle` INTEGER NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`patch_bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "patchBundle",
"columnName": "patch_bundle",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "packageName",
"columnName": "package_name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"uid"
]
},
"indices": [
{
"name": "index_patch_selections_patch_bundle_package_name",
"unique": true,
"columnNames": [
"patch_bundle",
"package_name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_patch_selections_patch_bundle_package_name` ON `${TABLE_NAME}` (`patch_bundle`, `package_name`)"
}
],
"foreignKeys": [
{
"table": "patch_bundles",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"patch_bundle"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "selected_patches",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`selection` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, PRIMARY KEY(`selection`, `patch_name`), FOREIGN KEY(`selection`) REFERENCES `patch_selections`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "selection",
"columnName": "selection",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "patchName",
"columnName": "patch_name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"selection",
"patch_name"
]
},
"foreignKeys": [
{
"table": "patch_selections",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"selection"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "downloaded_app",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `directory` TEXT NOT NULL, `last_used` INTEGER NOT NULL, PRIMARY KEY(`package_name`, `version`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "package_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "directory",
"columnName": "directory",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastUsed",
"columnName": "last_used",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"package_name",
"version"
]
}
},
{
"tableName": "installed_app",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`current_package_name` TEXT NOT NULL, `original_package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `install_type` TEXT NOT NULL, PRIMARY KEY(`current_package_name`))",
"fields": [
{
"fieldPath": "currentPackageName",
"columnName": "current_package_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "originalPackageName",
"columnName": "original_package_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "installType",
"columnName": "install_type",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"current_package_name"
]
}
},
{
"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 )",
"fields": [
{
"fieldPath": "packageName",
"columnName": "package_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bundle",
"columnName": "bundle",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "patchName",
"columnName": "patch_name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"package_name",
"bundle",
"patch_name"
]
},
"foreignKeys": [
{
"table": "installed_app",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"package_name"
],
"referencedColumns": [
"current_package_name"
]
}
]
},
{
"tableName": "installed_patch_bundle",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `bundle_uid` INTEGER NOT NULL, `bundle_name` TEXT NOT NULL, `bundle_version` TEXT, PRIMARY KEY(`package_name`, `bundle_uid`), FOREIGN KEY(`package_name`) REFERENCES `installed_app`(`current_package_name`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "packageName",
"columnName": "package_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bundleUid",
"columnName": "bundle_uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bundleName",
"columnName": "bundle_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bundleVersion",
"columnName": "bundle_version",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"package_name",
"bundle_uid"
]
},
"foreignKeys": [
{
"table": "installed_app",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"package_name"
],
"referencedColumns": [
"current_package_name"
]
}
]
},
{
"tableName": "option_groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `patch_bundle` INTEGER NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`patch_bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "patchBundle",
"columnName": "patch_bundle",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "packageName",
"columnName": "package_name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"uid"
]
},
"indices": [
{
"name": "index_option_groups_patch_bundle_package_name",
"unique": true,
"columnNames": [
"patch_bundle",
"package_name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_option_groups_patch_bundle_package_name` ON `${TABLE_NAME}` (`patch_bundle`, `package_name`)"
}
],
"foreignKeys": [
{
"table": "patch_bundles",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"patch_bundle"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "options",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`group`, `patch_name`, `key`), FOREIGN KEY(`group`) REFERENCES `option_groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "group",
"columnName": "group",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "patchName",
"columnName": "patch_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"group",
"patch_name",
"key"
]
},
"foreignKeys": [
{
"table": "option_groups",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"group"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "downloaders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` TEXT, `source` TEXT NOT NULL, `auto_update` INTEGER NOT NULL, `released_at` INTEGER, PRIMARY KEY(`uid`))",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "versionHash",
"columnName": "version",
"affinity": "TEXT"
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "autoUpdate",
"columnName": "auto_update",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "releasedAt",
"columnName": "released_at",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"uid"
]
}
}
],
"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, '00e80220281511a934e0dee1fbd8b3c9')"
]
}
}

View File

@@ -26,7 +26,7 @@ import kotlin.random.Random
@Database(
entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class, InstalledPatchBundle::class, OptionGroup::class, Option::class, DownloaderEntity::class],
version = 4,
version = 5,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 1, to = 2),
@@ -35,7 +35,8 @@ import kotlin.random.Random
to = 3,
spec = AppDatabase.DeleteTrustedDownloaders::class
),
AutoMigration(from = 3, to = 4)
AutoMigration(from = 3, to = 4),
AutoMigration(from = 4, to = 5)
]
)
@TypeConverters(Converters::class)

View File

@@ -4,8 +4,7 @@ import android.os.Parcelable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import app.revanced.manager.data.room.bundles.PatchBundleEntity
import kotlinx.parcelize.Parcelize
@Parcelize
@@ -18,15 +17,8 @@ import kotlinx.parcelize.Parcelize
parentColumns = ["current_package_name"],
childColumns = ["package_name"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
PatchBundleEntity::class,
parentColumns = ["uid"],
childColumns = ["bundle"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["bundle"], unique = false)]
]
)
data class AppliedPatch(
@ColumnInfo(name = "package_name") val packageName: String,

View File

@@ -37,4 +37,6 @@ class PreferencesManager(
val showDeveloperSettings = booleanPreference("show_developer_settings", context.isDebuggable)
val allowMeteredNetworks = booleanPreference("allow_metered_networks", false)
val pinnedApps = stringSetPreference("pinned_apps", emptySet())
}

View File

@@ -5,21 +5,27 @@ import androidx.compose.animation.expandIn
import androidx.compose.animation.shrinkOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.SelectableChipColors
import androidx.compose.material3.SelectableChipElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat
import app.revanced.manager.util.withHapticFeedback
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CheckedFilterChip(
selected: Boolean,
@@ -34,30 +40,33 @@ fun CheckedFilterChip(
border: BorderStroke? = FilterChipDefaults.filterChipBorder(enabled, selected),
interactionSource: MutableInteractionSource? = null
) {
FilterChip(
selected = selected,
onClick = onClick.withHapticFeedback(HapticFeedbackConstantsCompat.CONFIRM),
label = label,
modifier = modifier,
enabled = enabled,
leadingIcon = {
AnimatedVisibility(
visible = selected,
enter = expandIn(expandFrom = Alignment.CenterStart),
exit = shrinkOut(shrinkTowards = Alignment.CenterStart)
) {
Icon(
modifier = Modifier.size(FilterChipDefaults.IconSize),
imageVector = Icons.Filled.Done,
contentDescription = null,
)
}
},
trailingIcon = trailingIcon,
shape = shape,
colors = colors,
elevation = elevation,
border = border,
interactionSource = interactionSource
)
// Override default Material3 minimum touch target size to nothing
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
FilterChip(
selected = selected,
onClick = onClick.withHapticFeedback(HapticFeedbackConstantsCompat.CONFIRM),
label = label,
modifier = modifier.height(32.dp),
enabled = enabled,
leadingIcon = {
AnimatedVisibility(
visible = selected,
enter = expandIn(expandFrom = Alignment.CenterStart),
exit = shrinkOut(shrinkTowards = Alignment.CenterStart)
) {
Icon(
modifier = Modifier.size(FilterChipDefaults.IconSize),
imageVector = Icons.Filled.Done,
contentDescription = null,
)
}
},
trailingIcon = trailingIcon,
shape = shape,
colors = colors,
elevation = elevation,
border = border,
interactionSource = interactionSource
)
}
}

View File

@@ -1,108 +1,111 @@
package app.revanced.manager.ui.component
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
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
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private inline fun <T> NumberInputDialog(
current: T?,
name: String,
unit: 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,
suffix = unit?.let { { Text(it) } },
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,
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = { onSubmit(null) }, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.cancel))
}
},
)
}
@Composable
fun IntInputDialog(
current: Int?,
name: String,
unit: String? = null,
validator: (Int) -> Boolean = { true },
onSubmit: (Int?) -> Unit
) = NumberInputDialog(current, name, unit, onSubmit, validator, String::toIntOrNull)
@Composable
fun LongInputDialog(
current: Long?,
name: String,
unit: String? = null,
validator: (Long) -> Boolean = { true },
onSubmit: (Long?) -> Unit
) = NumberInputDialog(current, name, unit, onSubmit, validator, String::toLongOrNull)
@Composable
fun FloatInputDialog(
current: Float?,
name: String,
unit: String? = null,
validator: (Float) -> Boolean = { true },
onSubmit: (Float?) -> Unit
package app.revanced.manager.ui.component
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
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 androidx.compose.ui.text.input.KeyboardType
import app.revanced.manager.R
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private inline fun <T> NumberInputDialog(
current: T?,
name: String,
unit: 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 },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
placeholder = {
Text(stringResource(R.string.dialog_input_placeholder))
},
isError = validatorFailed,
suffix = unit?.let { { Text(it) } },
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,
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = { onSubmit(null) }, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.cancel))
}
},
)
}
@Composable
fun IntInputDialog(
current: Int?,
name: String,
unit: String? = null,
validator: (Int) -> Boolean = { true },
onSubmit: (Int?) -> Unit
) = NumberInputDialog(current, name, unit, onSubmit, validator, String::toIntOrNull)
@Composable
fun LongInputDialog(
current: Long?,
name: String,
unit: String? = null,
validator: (Long) -> Boolean = { true },
onSubmit: (Long?) -> Unit
) = NumberInputDialog(current, name, unit, onSubmit, validator, String::toLongOrNull)
@Composable
fun FloatInputDialog(
current: Float?,
name: String,
unit: String? = null,
validator: (Float) -> Boolean = { true },
onSubmit: (Float?) -> Unit
) = NumberInputDialog(current, name, unit, onSubmit, validator, String::toFloatOrNull)

View File

@@ -0,0 +1,42 @@
package app.revanced.manager.ui.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import com.eygraber.compose.placeholder.placeholder
@Composable
fun SurfaceChip(
text: String? = null
) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.height(24.dp)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(horizontal = 6.dp)
) {
Text(
text = text ?: stringResource(R.string.loading),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.placeholder(
visible = text == null,
color = MaterialTheme.colorScheme.inverseOnSurface,
shape = MaterialTheme.shapes.extraSmall
)
)
}
}
}

View File

@@ -1,55 +1,62 @@
package app.revanced.manager.ui.component
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun TextInputDialog(
initial: String,
title: String,
onDismissRequest: () -> Unit,
onConfirm: (String) -> Unit,
validator: (String) -> Boolean = String::isNotEmpty,
) {
val (value, setValue) = rememberSaveable(initial) {
mutableStateOf(initial)
}
val valid = remember(value, validator) {
validator(value)
}
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
onClick = { onConfirm(value) },
enabled = valid,
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.ok))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.cancel))
}
},
title = {
Text(title)
},
text = {
TextField(value = value, onValueChange = setValue)
}
)
package app.revanced.manager.ui.component
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun TextInputDialog(
initial: String,
title: String,
placeholder: String? = null,
onDismissRequest: () -> Unit,
onConfirm: (String) -> Unit,
validator: (String) -> Boolean = String::isNotEmpty,
trailingIcon: @Composable ((value: String, onValueChange: (String) -> Unit) -> Unit)? = null,
) {
val (value, setValue) = rememberSaveable(initial) {
mutableStateOf(initial)
}
val valid = remember(value, validator) {
validator(value)
}
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
onClick = { onConfirm(value) },
enabled = valid,
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.ok))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.cancel))
}
},
title = {
Text(title)
},
text = {
TextField(
value = value,
onValueChange = setValue,
placeholder = placeholder?.let { { Text(placeholder) } },
trailingIcon = trailingIcon?.let { { it(value, setValue) } }
)
}
)
}

View File

@@ -1,74 +1,134 @@
package app.revanced.manager.ui.component.patches
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
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.FullscreenDialog
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.TooltipIconButton
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun OptionsDialog(
patch: PatchInfo,
values: Map<String, Any?>?,
reset: () -> Unit,
set: (String, Any?) -> Unit,
onDismissRequest: () -> Unit,
selectionWarningEnabled: Boolean,
readOnly: Boolean
) = FullscreenDialog(onDismissRequest = onDismissRequest) {
Scaffold(
topBar = {
AppTopBar(
title = patch.name,
onBackClick = onDismissRequest,
actions = {
if (!readOnly) {
TooltipIconButton(
onClick = reset,
tooltip = stringResource(R.string.reset)
) {
Icon(Icons.Filled.Restore, stringResource(R.string.reset))
}
}
}
)
}
) { paddingValues ->
LazyColumnWithScrollbar(
modifier = Modifier.padding(paddingValues)
) {
if (patch.options == null) return@LazyColumnWithScrollbar
items(patch.options, key = { it.name }) { option ->
val name = option.name
val value =
if (values == null || !values.contains(name)) option.default else values[name]
@Suppress("UNCHECKED_CAST")
OptionItem(
option = option as Option<Any>,
value = value,
setValue = {
set(name, it)
},
selectionWarningEnabled = selectionWarningEnabled,
readOnly = readOnly
)
}
}
}
}
package app.revanced.manager.ui.component.patches
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
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.FullscreenDialog
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.TooltipIconButton
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun OptionsDialog(
patch: PatchInfo,
values: Map<String, Any?>?,
reset: () -> Unit,
resetOption: (Option<*>) -> Unit,
set: (String, Any?) -> Unit,
onDismissRequest: () -> Unit,
selectionWarningEnabled: Boolean,
readOnly: Boolean
) {
val invalidOptions = remember { mutableStateMapOf<Option<*>, Boolean>() }
var showDialog by remember { mutableStateOf(false) }
if (showDialog) {
DiscardInvalidDialog(
onDismiss = { showDialog = false },
onConfirm = {
showDialog = false
onDismissRequest()
}
)
}
val onBackClick = {
if (invalidOptions.values.any { it }) {
showDialog = true
} else {
onDismissRequest()
}
}
FullscreenDialog(onDismissRequest = onBackClick) {
Scaffold(
topBar = {
AppTopBar(
title = patch.name,
onBackClick = onBackClick,
actions = {
if (!readOnly) {
TooltipIconButton(
onClick = reset,
tooltip = stringResource(R.string.reset)
) {
Icon(Icons.Filled.Restore, stringResource(R.string.reset))
}
}
}
)
}
) { paddingValues ->
LazyColumnWithScrollbar(
modifier = Modifier
.padding(paddingValues)
.imePadding()
) {
patch.options ?: return@LazyColumnWithScrollbar
items(patch.options, key = { it.name }) { option ->
val name = option.name
val usingDefault = values == null || name !in values
val value = if (usingDefault) option.default else values[name]
@Suppress("UNCHECKED_CAST")
OptionItem(
option = option as Option<Any>,
value = value,
setValue = { set(name, it) },
isDefault = usingDefault,
reset = { resetOption(option) },
selectionWarningEnabled = selectionWarningEnabled,
readOnly = readOnly,
setInvalid = { invalidOptions[option] = it }
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun DiscardInvalidDialog(
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
onDismissRequest = { onDismiss() },
title = { Text(stringResource(R.string.discard_changes_dialog_title)) },
text = { Text(stringResource(R.string.patch_options_discard_invalid_description)) },
confirmButton = {
TextButton(onClick = { onConfirm() }, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.discard_changes))
}
},
dismissButton = {
TextButton(onClick = { onDismiss() }, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.cancel))
}
},
)
}

View File

@@ -142,7 +142,7 @@ fun PatchesFilterBottomSheet(
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
packageName?.let {
CheckedFilterChip(
@@ -169,7 +169,7 @@ fun PatchesFilterBottomSheet(
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
packagePatchCounts
.toList()

View File

@@ -49,7 +49,8 @@ fun AnnouncementScreen(
scrollState.canScrollBackward || scrollState.canScrollForward
}
)
val textColor = MaterialTheme.colorScheme.onSurface
val headerTextColor = MaterialTheme.colorScheme.onSurface
val textColor = MaterialTheme.colorScheme.onSurfaceVariant
val linkColor = MaterialTheme.colorScheme.primary
Scaffold(
@@ -120,13 +121,35 @@ fun AnnouncementScreen(
val webView = it.children.first() as WebView
@Language("HTML")
val style = """
<html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
line-height: 150%;
color: ${textColor.toCss()};
}
ul, ol {
padding-inline-start: 12px;
}
strong, b {
font-weight: 600;
color: ${headerTextColor.toCss()};
}
h1, h2, h3, h4, h5, h6 {
font-weight: 475;
line-height: 133%;
color: ${headerTextColor.toCss()};
}
h1 {
font-size: 2.25em;
}
h2 {
font-size: 1.75em;
}
h3 {
font-size: 1.5em;
}
a {
color: ${linkColor.toCss()};
}

View File

@@ -4,8 +4,10 @@ import android.content.ActivityNotFoundException
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
@@ -14,10 +16,16 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
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.Apps
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -27,12 +35,16 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
@@ -45,21 +57,27 @@ import app.revanced.manager.ui.component.AppLabel
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.component.SearchBar
import app.revanced.manager.ui.component.SurfaceChip
import app.revanced.manager.ui.component.TooltipIconButton
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.AppsViewModel
import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.AppInfo
import app.revanced.manager.util.EventEffect
import app.revanced.manager.util.toast
import app.revanced.manager.util.transparentListItemColors
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
@Composable
fun AppsScreen(
onAppClick: (InstalledApp) -> Unit,
onPatchableAppClick: (String) -> Unit,
onStorageSelect: (SelectedApp.Local) -> Unit,
lazyListState: LazyListState = rememberLazyListState(),
searchLazyListState: LazyListState = rememberLazyListState(),
onSearchExpandedChange: (Boolean) -> Unit = {},
viewModel: AppsViewModel = koinViewModel()
) {
val context = LocalContext.current
@@ -74,226 +92,142 @@ fun AppsScreen(
val installedApps by viewModel.installedApps.collectAsStateWithLifecycle()
val patchableApps by viewModel.patchableApps.collectAsStateWithLifecycle()
val suggestedVersions by viewModel.suggestedVersions.collectAsStateWithLifecycle()
val pinnedApps by viewModel.pinnedApps.collectAsStateWithLifecycle()
fun patchedPackageNames(apps: List<InstalledApp>?): Set<String> =
apps
?.flatMap { listOf(it.currentPackageName, it.originalPackageName) }
?.toSet()
val patchedPackageNames =
installedApps?.flatMap { listOf(it.currentPackageName, it.originalPackageName) }?.toSet()
.orEmpty()
fun InstalledApp.matchesQuery(query: String): Boolean {
if (query.isBlank()) return true
val packageInfo = viewModel.packageInfoMap[currentPackageName]
return currentPackageName.contains(query, ignoreCase = true) ||
originalPackageName.contains(query, ignoreCase = true) ||
viewModel.loadLabel(packageInfo).contains(query, ignoreCase = true)
return currentPackageName.contains(
query, ignoreCase = true
) || originalPackageName.contains(query, ignoreCase = true) || viewModel.loadLabel(
packageInfo
).contains(query, ignoreCase = true)
}
fun patchableMatchesQuery(packageName: String, label: String?, query: String): Boolean {
if (query.isBlank()) return true
return packageName.contains(query, ignoreCase = true) ||
label?.contains(query, ignoreCase = true) == true
return packageName.contains(query, ignoreCase = true) || label?.contains(
query, ignoreCase = true
) == true
}
var searchExpanded by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(searchExpanded) {
onSearchExpandedChange(searchExpanded)
}
val filterText by viewModel.filterText.collectAsStateWithLifecycle()
val TITLE_HORIZONTAL = 16.dp
val TITLE_VERTICAL = 8.dp
Scaffold(
topBar = {
Box(modifier = Modifier.padding(horizontal = if (searchExpanded) 0.dp else 16.dp)) {
SearchBar(
query = filterText,
onQueryChange = viewModel::setFilterText,
expanded = searchExpanded,
onExpandedChange = { searchExpanded = it },
placeholder = { Text(stringResource(R.string.search_apps)) },
windowInsets = WindowInsets(0, 0, 0, 0),
leadingIcon = {
Scaffold(topBar = {
Box(modifier = Modifier.padding(horizontal = if (searchExpanded) 0.dp else 16.dp)) {
SearchBar(
query = filterText,
onQueryChange = viewModel::setFilterText,
expanded = searchExpanded,
onExpandedChange = { searchExpanded = it },
placeholder = { Text(stringResource(R.string.search_apps)) },
windowInsets = WindowInsets(0, 0, 0, 0),
leadingIcon = {
TooltipIconButton(
onClick = {
if (searchExpanded) {
searchExpanded = false
viewModel.setFilterText("")
}
},
tooltip = if (searchExpanded) stringResource(R.string.back) else stringResource(
R.string.search
),
) { _ ->
Crossfade(
targetState = searchExpanded, label = "SearchIcon"
) { expanded ->
Icon(
imageVector = if (expanded) Icons.AutoMirrored.Filled.ArrowBack else Icons.Outlined.Search,
contentDescription = if (expanded) stringResource(R.string.back) else stringResource(
R.string.search
)
)
}
}
},
trailingIcon = {
if (searchExpanded && filterText.isNotEmpty()) {
TooltipIconButton(
onClick = {
if (searchExpanded) {
searchExpanded = false
viewModel.setFilterText("")
}
},
tooltip = if (searchExpanded) stringResource(R.string.back) else stringResource(R.string.search),
) { _ ->
Crossfade(
targetState = searchExpanded,
label = "SearchIcon"
) { expanded ->
Icon(
imageVector = if (expanded) Icons.AutoMirrored.Filled.ArrowBack else Icons.Outlined.Search,
contentDescription = if (expanded) stringResource(R.string.back) else stringResource(R.string.search)
)
}
onClick = { viewModel.setFilterText("") },
tooltip = stringResource(R.string.clear),
) { contentDescription ->
Icon(
imageVector = Icons.Filled.Close,
contentDescription = contentDescription
)
}
},
trailingIcon = {
if (searchExpanded && filterText.isNotEmpty()) {
TooltipIconButton(
onClick = { viewModel.setFilterText("") },
tooltip = stringResource(R.string.clear),
) { contentDescription ->
Icon(
imageVector = Icons.Filled.Close,
contentDescription = contentDescription
)
}
}
},
}
},
) {
val query = filterText.trim()
val patched = installedApps
val patchable = patchableApps
val filteredPatchedApps = patched?.filter { it.matchesQuery(query) }.orEmpty()
val filteredPatchableApps = patchable?.filter { app ->
app.packageName !in patchedPackageNames && patchableMatchesQuery(
packageName = app.packageName,
label = viewModel.loadLabel(app.packageInfo),
query = query
)
}.orEmpty()
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(MaterialTheme.colorScheme.surface)
) {
val query = filterText.trim()
val patched = installedApps
val patchable = patchableApps
val patchedPkgNames = patchedPackageNames(patched)
val filteredPatchedApps = patched
?.filter { it.matchesQuery(query) }
.orEmpty()
val filteredPatchableApps = patchable
?.filter { app ->
app.packageName !in patchedPkgNames &&
patchableMatchesQuery(
packageName = app.packageName,
label = viewModel.loadLabel(app.packageInfo),
query = query
)
if (patched == null || patchable == null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
LoadingIndicator()
}
.orEmpty()
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(MaterialTheme.colorScheme.surface)
) {
if (patched == null || patchable == null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
LoadingIndicator()
}
} else {
LazyColumnWithScrollbar(modifier = Modifier.fillMaxSize()) {
if (filteredPatchedApps.isNotEmpty()) {
item(key = "SEARCH_HEADER_PATCHED") {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TITLE_HORIZONTAL, vertical = TITLE_VERTICAL),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.patched_apps_section_title),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelLarge,
)
}
}
}
items(
items = filteredPatchedApps,
key = { "SEARCH_PATCHED-${it.currentPackageName}" },
contentType = { "SEARCH_PATCHED" }
) { installedApp ->
val packageInfo = viewModel.packageInfoMap[installedApp.currentPackageName]
ListItem(
modifier = Modifier.clickable {
searchExpanded = false
viewModel.setFilterText("")
onAppClick(installedApp)
},
leadingContent = {
AppIcon(
packageInfo = packageInfo,
contentDescription = null,
modifier = Modifier.size(36.dp)
)
},
headlineContent = {
AppLabel(packageInfo, defaultText = installedApp.currentPackageName)
},
supportingContent = {
Text(installedApp.currentPackageName)
},
colors = transparentListItemColors
)
}
if (filteredPatchableApps.isNotEmpty()) {
item(key = "SEARCH_HEADER_PATCHABLE") {
Text(
text = stringResource(R.string.patchable_apps_section_title),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TITLE_HORIZONTAL, vertical = TITLE_VERTICAL)
)
}
}
items(
items = filteredPatchableApps,
key = { "SEARCH_PATCHABLE-${it.packageName}" },
contentType = { "SEARCH_PATCHABLE" }
) { app ->
ListItem(
modifier = Modifier.clickable {
searchExpanded = false
viewModel.setFilterText("")
onPatchableAppClick(app.packageName)
},
leadingContent = {
AppIcon(
packageInfo = app.packageInfo,
contentDescription = null,
modifier = Modifier.size(36.dp)
)
},
headlineContent = {
AppLabel(app.packageInfo, defaultText = app.packageName)
},
supportingContent = app.patches?.let { patchCount ->
{
Text(
pluralStringResource(
R.plurals.patch_count,
patchCount,
patchCount
)
)
}
},
trailingContent = if (app.packageInfo == null) {
{ Text(stringResource(R.string.not_installed)) }
} else null,
colors = transparentListItemColors
)
}
}
} else {
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize(), state = searchLazyListState
) {
appItems(
items = buildAppList(
filteredPatchedApps, filteredPatchableApps, pinnedApps
),
viewModel = viewModel,
suggestedVersions = suggestedVersions,
onPatchedClick = {
searchExpanded = false
viewModel.setFilterText(""); onAppClick(it)
},
onPatchableClick = {
searchExpanded = false
viewModel.setFilterText(""); onPatchableAppClick(it)
})
}
}
}
}
}
) { paddingValues ->
}) { paddingValues ->
if (searchExpanded) return@Scaffold
LazyColumnWithScrollbar(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(top = TITLE_VERTICAL),
.padding(top = paddingValues.calculateTopPadding())
.padding(top = 8.dp),
state = lazyListState,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
@@ -303,8 +237,7 @@ fun AppsScreen(
if (patched == null || patchable == null) {
item(key = "LOADING") {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center
modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center
) {
LoadingIndicator()
}
@@ -312,75 +245,21 @@ fun AppsScreen(
return@LazyColumnWithScrollbar
}
val patchedPackageNames = patchedPackageNames(patched)
val visiblePatchableApps = patchable.filter { it.packageName !in patchedPackageNames }
if (patched.isNotEmpty()) {
item(key = "HEADER_PATCHED") {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TITLE_HORIZONTAL, vertical = TITLE_VERTICAL),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.patched_apps_section_title),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelLarge,
)
}
}
items(
items = patched,
key = { "PATCHED-${it.currentPackageName}" },
contentType = { "PATCHED" },
) { installedApp ->
val packageInfo = viewModel.packageInfoMap[installedApp.currentPackageName]
ListItem(
modifier = Modifier.clickable { onAppClick(installedApp) },
leadingContent = {
AppIcon(
packageInfo,
contentDescription = null,
modifier = Modifier.size(36.dp)
)
},
headlineContent = { AppLabel(packageInfo, defaultText = null) },
supportingContent = { Text(installedApp.currentPackageName) },
colors = transparentListItemColors
)
}
}
if (patched.isNotEmpty()) {
item(key = "HEADER_PATCHABLE") {
Text(
text = stringResource(R.string.patchable_apps_section_title),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TITLE_HORIZONTAL, vertical = TITLE_VERTICAL)
)
}
}
val allPatchableApps = patchable.filter { it.packageName !in patchedPackageNames }
item(key = "PATCHABLE_STORAGE") {
ListItem(
modifier = Modifier.clickable { try {
pickApkLauncher.launch(APK_MIMETYPE)
} catch (_: ActivityNotFoundException) {
context.toast(R.string.no_file_picker_found)
} },
modifier = Modifier.clickable {
try {
pickApkLauncher.launch(APK_MIMETYPE)
} catch (_: ActivityNotFoundException) {
context.toast(R.string.no_file_picker_found)
}
},
leadingContent = {
Box(Modifier.size(36.dp), Alignment.Center) {
Icon(
Icons.Default.Storage,
null,
modifier = Modifier.size(24.dp)
Icons.Default.Storage, null, modifier = Modifier.size(24.dp)
)
}
},
@@ -392,43 +271,184 @@ fun AppsScreen(
)
}
items(
items = visiblePatchableApps,
key = { "PATCHABLE-${it.packageName}" },
contentType = { "PATCHABLE" },
) { app ->
ListItem(
modifier = Modifier.clickable { onPatchableAppClick(app.packageName) },
leadingContent = {
AppIcon(
packageInfo = app.packageInfo,
contentDescription = null,
modifier = Modifier.size(36.dp)
)
},
headlineContent = {
AppLabel(
app.packageInfo,
defaultText = app.packageName
)
},
supportingContent = app.patches?.let { patchCount ->
{
Text(
pluralStringResource(
R.plurals.patch_count,
patchCount,
patchCount
)
)
}
},
trailingContent = if (app.packageInfo == null) {
{ Text(stringResource(R.string.not_installed)) }
} else null,
colors = transparentListItemColors
)
}
appItems(
items = buildAppList(patched, allPatchableApps, pinnedApps),
viewModel = viewModel,
suggestedVersions = suggestedVersions,
onPatchedClick = onAppClick,
onPatchableClick = onPatchableAppClick
)
}
}
}
private sealed interface AppListItem {
data class Patched(val app: InstalledApp) : AppListItem
data class Patchable(val app: AppInfo) : AppListItem
data object PinnedHeader : AppListItem
data object AvailableHeader : AppListItem
}
private fun buildAppList(
patchedApps: List<InstalledApp>, patchableApps: List<AppInfo>, pinnedApps: Set<String>
): List<AppListItem> = buildList {
val (pinnedPatched, unpinnedPatched) = patchedApps.partition { it.currentPackageName in pinnedApps }
val (pinnedPatchable, unpinnedPatchable) = patchableApps.partition { it.packageName in pinnedApps }
if (pinnedPatched.isNotEmpty() || pinnedPatchable.isNotEmpty()) {
add(AppListItem.PinnedHeader)
pinnedPatched.mapTo(this) { AppListItem.Patched(it) }
pinnedPatchable.mapTo(this) { AppListItem.Patchable(it) }
if (unpinnedPatched.isNotEmpty() || unpinnedPatchable.isNotEmpty()) {
add(AppListItem.AvailableHeader)
}
}
unpinnedPatched.mapTo(this) { AppListItem.Patched(it) }
unpinnedPatchable.mapTo(this) { AppListItem.Patchable(it) }
}
@OptIn(ExperimentalFoundationApi::class)
private fun LazyListScope.appItems(
items: List<AppListItem>,
viewModel: AppsViewModel,
suggestedVersions: Map<String, String?>,
onPatchedClick: (InstalledApp) -> Unit,
onPatchableClick: (String) -> Unit
) {
items(
items = items,
key = {
when (it) {
is AppListItem.Patched -> "PATCHED-${it.app.currentPackageName}"
is AppListItem.Patchable -> "PATCHABLE-${it.app.packageName}"
AppListItem.PinnedHeader -> "HEADER_PINNED"
AppListItem.AvailableHeader -> "HEADER_AVAILABLE"
}
},
contentType = { it::class.simpleName }
) { item ->
when (item) {
is AppListItem.PinnedHeader -> SectionHeader(
icon = Icons.Default.PushPin,
title = stringResource(R.string.pinned_apps_section_title),
modifier = Modifier.animateItem()
)
is AppListItem.AvailableHeader -> SectionHeader(
icon = Icons.Default.Apps,
title = stringResource(R.string.available_apps_section_title),
modifier = Modifier.animateItem()
)
is AppListItem.Patched -> AppItem(
modifier = Modifier.animateItem(),
onClick = { onPatchedClick(item.app) },
onLongClick = { viewModel.togglePinned(item.app.currentPackageName) },
packageName = item.app.currentPackageName,
packageInfo = viewModel.packageInfoMap[item.app.currentPackageName],
isPatched = true,
)
is AppListItem.Patchable -> AppItem(
modifier = Modifier.animateItem(),
onClick = { onPatchableClick(item.app.packageName) },
onLongClick = { viewModel.togglePinned(item.app.packageName) },
packageInfo = item.app.packageInfo,
packageName = item.app.packageName,
patchCount = item.app.patches,
suggestedVersion = suggestedVersions[item.app.packageName]
?: stringResource(R.string.any_version),
)
}
}
}
@Composable
private fun SectionHeader(
icon: ImageVector, title: String, modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.size(18.dp)
.rotate(45f),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = title,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelLarge,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun AppItem(
modifier: Modifier = Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit,
packageName: String,
packageInfo: android.content.pm.PackageInfo?,
isPatched: Boolean = false,
patchCount: Int? = null,
suggestedVersion: String? = null,
) {
ListItem(
modifier = modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick),
leadingContent = {
AppIcon(
packageInfo = packageInfo,
contentDescription = null,
modifier = Modifier.size(36.dp)
)
},
headlineContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isPatched) {
Icon(
imageVector = Icons.Default.AutoAwesome,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
}
AppLabel(packageInfo, defaultText = packageName)
}
},
supportingContent = {
patchCount?.let {
Row(
modifier = Modifier.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
if (patchCount > 0) SurfaceChip(
pluralStringResource(
R.plurals.patch_count,
patchCount,
patchCount
)
)
if (suggestedVersion != null) SurfaceChip(suggestedVersion)
}
} ?: Text(packageName)
},
trailingContent = if (packageInfo == null) {
{ Text(stringResource(R.string.not_installed)) }
} else null,
colors = transparentListItemColors
)
}

View File

@@ -5,6 +5,7 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -26,11 +27,13 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Update
@@ -153,6 +156,10 @@ fun DashboardScreen(
initialPageOffsetFraction = 0f
) { DashboardPage.entries.size }
val appsLazyListState = rememberLazyListState()
val appsSearchLazyListState = rememberLazyListState()
var appsSearchExpanded by rememberSaveable { mutableStateOf(false) }
val dashboardPatchesParams = remember {
SelectedApplicationInfo.PatchesSelector.ViewModelParams(
app = SelectedApp.Search("", null),
@@ -389,12 +396,24 @@ fun DashboardScreen(
},
containerColor = Color.Transparent,
floatingActionButton = {
val currentScrollState =
if (appsSearchExpanded) appsSearchLazyListState else appsLazyListState
val showBackToTop by remember(currentScrollState) {
derivedStateOf { currentScrollState.firstVisibleItemIndex > 0 }
}
DashboardFab(
pagerState = pagerState,
patchesSourceEditMode = patchesSourceEditMode,
onEnablePatchesSourceEditMode = { patchesSourceEditMode = true },
onAddBundleClick = {
showAddBundleDialog = true
},
showScrollToTop = showBackToTop,
onScrollToTop = {
composableScope.launch {
currentScrollState.animateScrollToItem(0)
}
}
)
}
@@ -474,7 +493,10 @@ fun DashboardScreen(
AppsScreen(
onAppClick = { onAppClick(it.currentPackageName) },
onPatchableAppClick = ::onPatchableSelection,
onStorageSelect = { selectedApp -> onStorageSelection(selectedApp) }
onStorageSelect = { selectedApp -> onStorageSelection(selectedApp)},
lazyListState = appsLazyListState,
searchLazyListState = appsSearchLazyListState,
onSearchExpandedChange = { appsSearchExpanded = it }
)
}
@@ -519,9 +541,15 @@ private fun DashboardFab(
pagerState: PagerState,
patchesSourceEditMode: Boolean,
onEnablePatchesSourceEditMode: () -> Unit,
onAddBundleClick: () -> Unit
onAddBundleClick: () -> Unit,
showScrollToTop: Boolean,
onScrollToTop: () -> Unit
) {
val fabState = when (pagerState.currentPage) {
DashboardPage.DASHBOARD.ordinal -> {
if (showScrollToTop) DashboardFabState.ScrollToTop else DashboardFabState.Hidden
}
DashboardPage.BUNDLES.ordinal -> {
if (patchesSourceEditMode) DashboardFabState.AddBundles else DashboardFabState.EditBundles
}
@@ -529,46 +557,69 @@ private fun DashboardFab(
else -> DashboardFabState.Hidden
}
if (fabState == DashboardFabState.Hidden) return
HapticExtendedFloatingActionButton(
onClick = if (fabState == DashboardFabState.AddBundles) onAddBundleClick else onEnablePatchesSourceEditMode,
tooltip = stringResource(
if (fabState == DashboardFabState.AddBundles) R.string.fab_add_patches else R.string.edit
),
expanded = fabState == DashboardFabState.AddBundles,
icon = {
AnimatedContent(
targetState = fabState,
transitionSpec = {
(fadeIn(animationSpec = tween(durationMillis = 180, delayMillis = 60)) +
scaleIn(animationSpec = tween(durationMillis = 180, delayMillis = 60), initialScale = 0.85f)) togetherWith
(fadeOut(animationSpec = tween(durationMillis = 90)) +
scaleOut(animationSpec = tween(durationMillis = 90), targetScale = 0.85f))
},
label = "dashboard_fab_icon_transition"
) { state ->
when (state) {
DashboardFabState.EditBundles -> {
Icon(Icons.Outlined.Edit, contentDescription = stringResource(R.string.edit))
}
DashboardFabState.AddBundles -> {
Icon(Icons.Default.Add, contentDescription = null)
}
DashboardFabState.Hidden -> Unit
AnimatedVisibility(
visible = fabState != DashboardFabState.Hidden, enter = fadeIn() + scaleIn(), exit = fadeOut() + scaleOut()
) {
HapticExtendedFloatingActionButton(
onClick = {
when (fabState) {
DashboardFabState.AddBundles -> onAddBundleClick()
DashboardFabState.EditBundles -> onEnablePatchesSourceEditMode()
DashboardFabState.ScrollToTop -> onScrollToTop()
else -> {}
}
}
},
text = { Text(stringResource(R.string.fab_add_patches)) }
)
},
tooltip = stringResource(
if (fabState == DashboardFabState.AddBundles) R.string.fab_add_patches else R.string.edit
),
expanded = fabState == DashboardFabState.AddBundles,
icon = {
AnimatedContent(
targetState = fabState,
transitionSpec = {
(fadeIn(animationSpec = tween(durationMillis = 180, delayMillis = 60)) +
scaleIn(
animationSpec = tween(durationMillis = 180, delayMillis = 60),
initialScale = 0.85f
)) togetherWith
(fadeOut(animationSpec = tween(durationMillis = 90)) +
scaleOut(
animationSpec = tween(durationMillis = 90),
targetScale = 0.85f
))
},
label = "dashboard_fab_icon_transition"
) { state ->
when (state) {
DashboardFabState.EditBundles -> {
Icon(
Icons.Outlined.Edit,
contentDescription = stringResource(R.string.edit)
)
}
DashboardFabState.AddBundles -> {
Icon(Icons.Default.Add, contentDescription = null)
}
DashboardFabState.ScrollToTop -> {
Icon(Icons.Filled.KeyboardArrowUp, contentDescription = null)
}
else -> {}
}
}
},
text = { Text(stringResource(R.string.fab_add_patches)) }
)
}
}
private enum class DashboardFabState {
Hidden,
EditBundles,
AddBundles,
ScrollToTop
}
@Composable

View File

@@ -8,6 +8,8 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -269,8 +271,14 @@ fun PatcherScreen(
expandedCategory = category
}
val patcherProgress by animateFloatAsState(
targetValue = viewModel.progress,
animationSpec = tween(),
label = "patcherProgress"
)
LinearWavyProgressIndicator(
progress = { viewModel.progress },
progress = { patcherProgress },
modifier = Modifier.fillMaxWidth()
)

View File

@@ -221,6 +221,7 @@ fun PatchesSelectorScreen(
patch = dialog.patch,
values = viewModel.getOptions(dialog.bundle, dialog.patch),
reset = { viewModel.resetOptions(dialog.bundle, dialog.patch) },
resetOption = { viewModel.resetOption(dialog.bundle, dialog.patch, it) },
set = { key, value ->
viewModel.setOption(
dialog.bundle,
@@ -230,7 +231,7 @@ fun PatchesSelectorScreen(
)
},
selectionWarningEnabled = viewModel.selectionWarningEnabled,
readOnly = readOnly
readOnly = readOnly,
)
}
@@ -357,12 +358,7 @@ fun PatchesSelectorScreen(
expanded = searchExpanded,
onExpandedChange = setSearchExpanded,
placeholder = { Text(stringResource(R.string.search_patches)) },
windowInsets = if (readOnly) WindowInsets(
0,
0,
0,
0
) else WindowInsets.systemBars,
windowInsets = if (readOnly) WindowInsets(top = 0, bottom = 0) else WindowInsets.systemBars,
leadingIcon = {
TooltipIconButton(
onClick = {
@@ -479,10 +475,16 @@ fun PatchesSelectorScreen(
) { paddingValues ->
if (searchExpanded) return@Scaffold
val appliedPadding = if (!readOnly) {
paddingValues
} else {
PaddingValues(top = paddingValues.calculateTopPadding())
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(appliedPadding)
) {
Spacer(
modifier = Modifier

View File

@@ -1,168 +1,170 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoFixHigh
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SecondaryScrollableTabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.patcher.patch.Option
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.requiredOptionsSet
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.haptics.HapticTab
import app.revanced.manager.ui.component.patches.OptionItem
import app.revanced.manager.ui.component.patches.PatchesListHeader
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.isScrollingUp
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RequiredOptionsScreen(
onContinue: (PatchSelection?, Options) -> Unit,
onBackClick: () -> Unit,
vm: PatchesSelectorViewModel
) {
val list by vm.requiredOptsPatches.collectAsStateWithLifecycle(emptyList())
val pagerState = rememberPagerState(
initialPage = 0,
initialPageOffsetFraction = 0f
) {
list.size
}
val patchLazyListStates = remember(list) { List(list.size, ::LazyListState) }
val bundles by vm.bundlesFlow.collectAsStateWithLifecycle(emptyList())
val showContinueButton by remember {
derivedStateOf {
bundles.requiredOptionsSet(
allowIncompatible = vm.allowIncompatiblePatches,
isSelected = { bundle, patch -> vm.isSelected(bundle.uid, patch) },
optionsForPatch = { bundle, patch -> vm.getOptions(bundle.uid, patch) }
)
}
}
val composableScope = rememberCoroutineScope()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.required_options_screen),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
)
},
floatingActionButton = {
if (!showContinueButton) return@Scaffold
HapticExtendedFloatingActionButton(
text = { Text(stringResource(R.string.patch)) },
icon = {
Icon(
Icons.Default.AutoFixHigh,
stringResource(R.string.patch)
)
},
expanded = patchLazyListStates.getOrNull(pagerState.currentPage)?.isScrollingUp
?: true,
onClick = {
onContinue(vm.getCustomSelection(), vm.getOptions())
}
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
Column(
Modifier
.fillMaxSize()
.padding(paddingValues)
) {
if (list.isEmpty()) return@Column
else if (list.size > 1) SecondaryScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
) {
list.forEachIndexed { index, (bundle, _) ->
HapticTab(
selected = pagerState.currentPage == index,
onClick = {
composableScope.launch {
pagerState.animateScrollToPage(
index
)
}
},
text = { Text(bundle.name) },
selectedContentColor = MaterialTheme.colorScheme.primary,
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
HorizontalPager(
state = pagerState,
userScrollEnabled = true,
pageContent = { index ->
// Avoid crashing if the lists have not been fully initialized yet.
if (index > list.lastIndex || list.size != patchLazyListStates.size) return@HorizontalPager
val (bundle, patches) = list[index]
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize(),
state = patchLazyListStates[index]
) {
items(patches, key = { it.name }) {
PatchesListHeader(it.name)
val values = vm.getOptions(bundle.uid, it)
it.options?.forEach { option ->
val name = option.name
val value =
if (values == null || name !in values) option.default else values[name]
@Suppress("UNCHECKED_CAST")
OptionItem(
option = option as Option<Any>,
value = value,
setValue = { new ->
vm.setOption(bundle.uid, it, name, new)
},
selectionWarningEnabled = vm.selectionWarningEnabled
)
}
}
}
}
)
}
}
package app.revanced.manager.ui.screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoFixHigh
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SecondaryScrollableTabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.patcher.patch.Option
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.requiredOptionsSet
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.haptics.HapticTab
import app.revanced.manager.ui.component.patches.OptionItem
import app.revanced.manager.ui.component.patches.PatchesListHeader
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.isScrollingUp
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RequiredOptionsScreen(
onContinue: (PatchSelection?, Options) -> Unit,
onBackClick: () -> Unit,
vm: PatchesSelectorViewModel
) {
val list by vm.requiredOptsPatches.collectAsStateWithLifecycle(emptyList())
val pagerState = rememberPagerState(
initialPage = 0,
initialPageOffsetFraction = 0f
) {
list.size
}
val patchLazyListStates = remember(list) { List(list.size, ::LazyListState) }
val bundles by vm.bundlesFlow.collectAsStateWithLifecycle(emptyList())
val showContinueButton by remember {
derivedStateOf {
bundles.requiredOptionsSet(
allowIncompatible = vm.allowIncompatiblePatches,
isSelected = { bundle, patch -> vm.isSelected(bundle.uid, patch) },
optionsForPatch = { bundle, patch -> vm.getOptions(bundle.uid, patch) }
)
}
}
val composableScope = rememberCoroutineScope()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.required_options_screen),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
)
},
floatingActionButton = {
if (!showContinueButton) return@Scaffold
HapticExtendedFloatingActionButton(
text = { Text(stringResource(R.string.patch)) },
icon = {
Icon(
Icons.Default.AutoFixHigh,
stringResource(R.string.patch)
)
},
expanded = patchLazyListStates.getOrNull(pagerState.currentPage)?.isScrollingUp
?: true,
onClick = {
onContinue(vm.getCustomSelection(), vm.getOptions())
}
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
Column(
Modifier
.fillMaxSize()
.padding(paddingValues)
) {
if (list.isEmpty()) return@Column
else if (list.size > 1) SecondaryScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
) {
list.forEachIndexed { index, (bundle, _) ->
HapticTab(
selected = pagerState.currentPage == index,
onClick = {
composableScope.launch {
pagerState.animateScrollToPage(
index
)
}
},
text = { Text(bundle.name) },
selectedContentColor = MaterialTheme.colorScheme.primary,
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
HorizontalPager(
state = pagerState,
userScrollEnabled = true,
pageContent = { index ->
// Avoid crashing if the lists have not been fully initialized yet.
if (index > list.lastIndex || list.size != patchLazyListStates.size) return@HorizontalPager
val (bundle, patches) = list[index]
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize(),
state = patchLazyListStates[index]
) {
items(patches, key = { it.name }) {
PatchesListHeader(it.name)
val values = vm.getOptions(bundle.uid, it)
it.options?.forEach { option ->
val name = option.name
val usingDefault = values == null || name !in values
val value = if (usingDefault) option.default else values[name]
@Suppress("UNCHECKED_CAST")
OptionItem(
option = option as Option<Any>,
value = value,
setValue = { new -> vm.setOption(bundle.uid, it, name, new) },
isDefault = usingDefault,
reset = { vm.resetOption(bundle.uid, it, option) },
selectionWarningEnabled = vm.selectionWarningEnabled,
// Invalid options won't be saved, so we don't need to handle any states here
setInvalid = {}
)
}
}
}
}
)
}
}
}

View File

@@ -1,9 +1,10 @@
package app.revanced.manager.ui.screen
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
@@ -28,6 +29,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
@@ -113,10 +115,15 @@ fun UpdateScreen(
modifier = Modifier.padding(paddingValues),
) {
if (vm.state == State.DOWNLOADING) {
val updaterProgress by animateFloatAsState(
targetValue = vm.downloadProgress,
animationSpec = tween(),
label = "updaterProgress"
)
LinearWavyProgressIndicator(
progress = { vm.downloadProgress },
progress = { updaterProgress },
modifier = Modifier
.padding(top = paddingValues.calculateTopPadding())
.fillMaxWidth(),
)
}

View File

@@ -16,6 +16,8 @@ import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.domain.installer.RootServiceException
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.PM
import app.revanced.manager.util.toast
@@ -39,6 +41,8 @@ class AppsViewModel(
private val installedAppsRepository: InstalledAppRepository,
private val pm: PM,
private val rootInstaller: RootInstaller,
private val patchBundleRepository: PatchBundleRepository,
private val prefs: PreferencesManager,
fs: Filesystem,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
@@ -56,6 +60,30 @@ class AppsViewModel(
initialValue = null,
)
val suggestedVersions = patchBundleRepository.suggestedVersions.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyMap(),
)
val pinnedApps = prefs.pinnedApps.flow.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = prefs.pinnedApps.getBlocking(),
)
fun togglePinned(packageName: String) {
viewModelScope.launch {
prefs.edit {
if (packageName in prefs.pinnedApps.value) {
prefs.pinnedApps -= packageName
} else {
prefs.pinnedApps += packageName
}
}
}
}
val installedApps = installedAppsRepository.getAll().stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),

View File

@@ -1,396 +1,403 @@
package app.revanced.manager.ui.viewmodel
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import app.revanced.manager.R
import app.revanced.manager.domain.sources.PatchBundleSource
import app.revanced.manager.domain.sources.Source.State
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull
import app.revanced.manager.domain.sources.Extensions.version
import app.revanced.manager.patcher.patch.PatchBundleInfo
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.toPatchSelection
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.saver.Nullable
import app.revanced.manager.util.saver.nullableSaver
import app.revanced.manager.util.saver.persistentMapSaver
import app.revanced.manager.util.saver.persistentSetSaver
import app.revanced.manager.util.saver.snapshotStateMapSaver
import app.revanced.manager.util.toast
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.PersistentSet
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentMap
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
@OptIn(SavedStateHandleSaveableApi::class)
class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.ViewModelParams) :
ViewModel(), KoinComponent {
private val app: Application = get()
private val savedStateHandle: SavedStateHandle = get()
private val prefs: PreferencesManager = get()
private val bundleRepository: PatchBundleRepository = get()
val readOnly = input.readOnly
private val browseAllBundles = input.browseAllBundles
val packageName = input.app.packageName
val appVersion = input.app.version
var selectionWarningEnabled by mutableStateOf(true)
private set
var universalPatchWarningEnabled by mutableStateOf(true)
private set
val allowIncompatiblePatches =
get<PreferencesManager>().disablePatchVersionCompatCheck.getBlocking() || appVersion == null
val bundlesFlow = if (browseAllBundles) {
combine(bundleRepository.sources, bundleRepository.bundleInfoFlow) { sources, bundles ->
mergeSourcesWithBundleInfo(
sources,
bundles.mapValues { (_, bundle) -> bundle.asReadonlyScoped() }
)
}
} else {
combine(
bundleRepository.sources,
bundleRepository.scopedBundleInfoFlow(packageName, input.app.version)
) { sources, bundles ->
mergeSourcesWithBundleInfo(
sources,
bundles.associateBy(PatchBundleInfo.Scoped::uid)
)
}
}
val bundleLoadIssuesFlow = bundleRepository.sources.map { sources ->
sources.mapNotNull { source ->
val messageId = when {
source.error != null -> R.string.patches_error_description
source.state is State.Missing -> R.string.patches_not_downloaded
else -> null
} ?: return@mapNotNull null
source.uid to messageId
}.toMap()
}
init {
viewModelScope.launch {
if (readOnly) {
universalPatchWarningEnabled = false
selectionWarningEnabled = false
return@launch
}
if (prefs.disableUniversalPatchCheck.get()) {
universalPatchWarningEnabled = false
}
if (prefs.disableSelectionWarning.get()) {
selectionWarningEnabled = false
return@launch
}
fun PatchBundleInfo.Scoped.hasDefaultPatches() =
patchSequence(allowIncompatiblePatches).any { it.include }
// Don't show the warning if there are no default patches.
selectionWarningEnabled = bundlesFlow.first().any(PatchBundleInfo.Scoped::hasDefaultPatches)
}
}
private var hasModifiedSelection = false
var customPatchSelection: PersistentPatchSelection? by savedStateHandle.saveable(
key = "selection",
stateSaver = selectionSaver,
) {
mutableStateOf(input.currentSelection?.toPersistentPatchSelection())
}
private val patchOptions: PersistentOptions by savedStateHandle.saveable(
saver = optionsSaver,
) {
// Convert Options to PersistentOptions
input.options.mapValuesTo(mutableStateMapOf()) { (_, allPatches) ->
allPatches.mapValues { (_, options) -> options.toPersistentMap() }.toPersistentMap()
}
}
/**
* Active dialog or bottom sheet state. Only one can be shown at a time.
*/
var activeDialog by mutableStateOf<DialogState?>(null)
private set
var filter by mutableIntStateOf(SHOW_UNIVERSAL)
private set
private val defaultPatchSelection = bundlesFlow.map { bundles ->
bundles.toPatchSelection(allowIncompatiblePatches) { _, patch -> patch.include }
.toPersistentPatchSelection()
}
val defaultSelectionCount = defaultPatchSelection.map { selection ->
selection.values.sumOf { it.size }
}
// This is for the required options screen.
private val requiredOptsPatchesDeferred = viewModelScope.async(start = CoroutineStart.LAZY) {
bundlesFlow.first().map { bundle ->
bundle to bundle.patchSequence(allowIncompatiblePatches).filter { patch ->
val opts by lazy {
getOptions(bundle.uid, patch).orEmpty()
}
isSelected(
bundle.uid,
patch
) && patch.options?.any { it.required && it.default == null && it.name !in opts } ?: false
}.toList()
}.filter { (_, patches) -> patches.isNotEmpty() }
}
val requiredOptsPatches = flow { emit(requiredOptsPatchesDeferred.await()) }
fun selectionIsValid(bundles: List<PatchBundleInfo.Scoped>) = !readOnly && bundles.any { bundle ->
bundle.patchSequence(allowIncompatiblePatches).any { patch ->
isSelected(bundle.uid, patch)
}
}
fun isSelected(bundle: Int, patch: PatchInfo) = customPatchSelection?.let { selection ->
selection[bundle]?.contains(patch.name) == true
} ?: patch.include
fun togglePatch(bundle: Int, patch: PatchInfo) = viewModelScope.launch {
hasModifiedSelection = true
val selection = customPatchSelection ?: defaultPatchSelection.first()
val newPatches = selection[bundle]?.let { patches ->
if (patch.name in patches)
patches.remove(patch.name)
else
patches.add(patch.name)
} ?: persistentSetOf(patch.name)
customPatchSelection = selection.put(bundle, newPatches)
}
fun reset() {
patchOptions.clear()
customPatchSelection = null
hasModifiedSelection = false
app.toast(app.getString(R.string.patch_selection_reset_toast))
}
fun getCustomSelection(): PatchSelection? {
// Convert persistent collections to standard hash collections because persistent collections are not parcelable.
return customPatchSelection?.mapValues { (_, v) -> v.toSet() }
}
fun getOptions(): Options {
// Convert the collection for the same reasons as in getCustomSelection()
return patchOptions.mapValues { (_, allPatches) -> allPatches.mapValues { (_, options) -> options.toMap() } }
}
fun getOptions(bundle: Int, patch: PatchInfo) = patchOptions[bundle]?.get(patch.name)
fun setOption(bundle: Int, patch: PatchInfo, name: String, value: Any?) {
// All patches
val patchesToOpts = patchOptions.getOrElse(bundle, ::persistentMapOf)
// The key-value options of an individual patch
val patchToOpts = patchesToOpts
.getOrElse(patch.name, ::persistentMapOf)
.put(name, value)
patchOptions[bundle] = patchesToOpts.put(patch.name, patchToOpts)
}
fun resetOptions(bundle: Int, patch: PatchInfo) {
app.toast(app.getString(R.string.patch_options_reset_toast))
patchOptions[bundle] = patchOptions[bundle]?.remove(patch.name) ?: return
}
fun dismissDialogs() {
activeDialog = null
}
fun openIncompatibleDialog(incompatiblePatch: PatchInfo) {
val versions = incompatiblePatch.compatiblePackages
?.find { it.packageName == packageName }?.versions.orEmpty()
activeDialog = DialogState.IncompatiblePatch(versions)
}
fun openOptionsDialog(bundle: Int, patch: PatchInfo) {
activeDialog = DialogState.Options(bundle, patch)
}
fun showSelectionWarning() {
activeDialog = DialogState.SelectionWarning
}
fun showUniversalPatchWarning() {
activeDialog = DialogState.UniversalPatchWarning
}
fun showIncompatiblePatchesInfo() {
activeDialog = DialogState.IncompatiblePatchesInfo
}
fun toggleFlag(flag: Int) {
filter = filter xor flag
}
fun getBundleSelectionState(bundle: PatchBundleInfo.Scoped): Boolean? {
val patches = bundle.patchSequence(allowIncompatiblePatches).toList()
if (patches.isEmpty()) return false
val selectedCount = patches.count { isSelected(bundle.uid, it) }
return when (selectedCount) {
patches.size -> true
0 -> false
else -> null
}
}
private suspend fun currentSelection(): PersistentPatchSelection =
customPatchSelection ?: defaultPatchSelection.first()
private suspend fun updateSelection(
update: (PersistentPatchSelection) -> PersistentPatchSelection
) {
hasModifiedSelection = true
customPatchSelection = update(currentSelection())
}
fun deselectAll(bundles: List<PatchBundleInfo.Scoped>, bundleUid: Int?) = viewModelScope.launch {
updateSelection { selection ->
bundles.fold(selection) { acc, bundle ->
if (bundleUid != null && bundle.uid != bundleUid) return@fold acc
acc.put(bundle.uid, persistentSetOf())
}
}
}
fun invertSelection(bundles: List<PatchBundleInfo.Scoped>, bundleUid: Int?) = viewModelScope.launch {
updateSelection { selection ->
bundles.fold(selection) { acc, bundle ->
if (bundleUid != null && bundle.uid != bundleUid) return@fold acc
val currentSelected = acc[bundle.uid] ?: persistentSetOf()
val inverted = bundle.patchSequence(allowIncompatiblePatches)
.filter { it.name !in currentSelected }
.map { it.name }
.toPersistentSet()
acc.put(bundle.uid, inverted)
}
}
}
fun restoreDefaults(bundleUid: Int?) = viewModelScope.launch {
if (bundleUid == null) {
customPatchSelection = null
hasModifiedSelection = false
return@launch
}
val defaults = defaultPatchSelection.first()
updateSelection { selection ->
selection.put(bundleUid, defaults[bundleUid] ?: persistentSetOf())
}
}
fun deselectAllExcept(bundles: List<PatchBundleInfo.Scoped>, keepBundleUid: Int) = viewModelScope.launch {
updateSelection { selection ->
bundles.fold(selection) { acc, bundle ->
if (bundle.uid == keepBundleUid) return@fold acc
acc.put(bundle.uid, persistentSetOf())
}
}
}
companion object {
const val SHOW_INCOMPATIBLE = 1 // 2^0
const val SHOW_UNIVERSAL = 2 // 2^1
private val optionsSaver: Saver<PersistentOptions, Options> = snapshotStateMapSaver(
// Patch name -> Options
valueSaver = persistentMapSaver(
// Option key -> Option value
valueSaver = persistentMapSaver()
)
)
private val selectionSaver: Saver<PersistentPatchSelection?, Nullable<PatchSelection>> =
nullableSaver(persistentMapSaver(valueSaver = persistentSetSaver()))
}
sealed interface DialogState {
data class Options(val bundle: Int, val patch: PatchInfo) : DialogState
data class IncompatiblePatch(val compatibleVersions: Set<String>) : DialogState
data object IncompatiblePatchesInfo : DialogState
data object SelectionWarning : DialogState
data object UniversalPatchWarning : DialogState
}
private fun mergeSourcesWithBundleInfo(
sources: List<PatchBundleSource>,
scopedBundleInfoByUid: Map<Int, PatchBundleInfo.Scoped>
) = sources.map { source ->
scopedBundleInfoByUid[source.uid] ?: source.emptyScopedBundleInfo()
}
}
// Versions of other types, but utilizing persistent/observable collection types.
private typealias PersistentOptions = SnapshotStateMap<Int, PersistentMap<String, PersistentMap<String, Any?>>>
private typealias PersistentPatchSelection = PersistentMap<Int, PersistentSet<String>>
private fun PatchSelection.toPersistentPatchSelection(): PersistentPatchSelection =
mapValues { (_, v) -> v.toPersistentSet() }.toPersistentMap()
private fun PatchBundleInfo.Global.asReadonlyScoped() = PatchBundleInfo.Scoped(
name = name,
version = version,
releasedAt = releasedAt,
uid = uid,
patches = patches,
compatible = patches,
incompatible = emptyList(),
universal = emptyList()
)
private fun PatchBundleSource.emptyScopedBundleInfo() = PatchBundleInfo.Scoped(
name = name,
version = version,
releasedAt = (this.asRemoteOrNull)?.releasedAt,
uid = uid,
patches = emptyList(),
compatible = emptyList(),
incompatible = emptyList(),
universal = emptyList()
)
package app.revanced.manager.ui.viewmodel
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import app.revanced.manager.R
import app.revanced.manager.domain.sources.PatchBundleSource
import app.revanced.manager.domain.sources.Source.State
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.sources.Extensions.asRemoteOrNull
import app.revanced.manager.domain.sources.Extensions.version
import app.revanced.manager.patcher.patch.Option
import app.revanced.manager.patcher.patch.PatchBundleInfo
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.toPatchSelection
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.saver.Nullable
import app.revanced.manager.util.saver.nullableSaver
import app.revanced.manager.util.saver.persistentMapSaver
import app.revanced.manager.util.saver.persistentSetSaver
import app.revanced.manager.util.saver.snapshotStateMapSaver
import app.revanced.manager.util.toast
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.PersistentSet
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentMap
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
@OptIn(SavedStateHandleSaveableApi::class)
class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.ViewModelParams) :
ViewModel(), KoinComponent {
private val app: Application = get()
private val savedStateHandle: SavedStateHandle = get()
private val prefs: PreferencesManager = get()
private val bundleRepository: PatchBundleRepository = get()
val readOnly = input.readOnly
private val browseAllBundles = input.browseAllBundles
val packageName = input.app.packageName
val appVersion = input.app.version
var selectionWarningEnabled by mutableStateOf(true)
private set
var universalPatchWarningEnabled by mutableStateOf(true)
private set
val allowIncompatiblePatches =
get<PreferencesManager>().disablePatchVersionCompatCheck.getBlocking() || appVersion == null
val bundlesFlow = if (browseAllBundles) {
combine(bundleRepository.sources, bundleRepository.bundleInfoFlow) { sources, bundles ->
mergeSourcesWithBundleInfo(
sources,
bundles.mapValues { (_, bundle) -> bundle.asReadonlyScoped() }
)
}
} else {
combine(
bundleRepository.sources,
bundleRepository.scopedBundleInfoFlow(packageName, input.app.version)
) { sources, bundles ->
mergeSourcesWithBundleInfo(
sources,
bundles.associateBy(PatchBundleInfo.Scoped::uid)
)
}
}
val bundleLoadIssuesFlow = bundleRepository.sources.map { sources ->
sources.mapNotNull { source ->
val messageId = when {
source.error != null -> R.string.patches_error_description
source.state is State.Missing -> R.string.patches_not_downloaded
else -> null
} ?: return@mapNotNull null
source.uid to messageId
}.toMap()
}
init {
viewModelScope.launch {
if (readOnly) {
universalPatchWarningEnabled = false
selectionWarningEnabled = false
return@launch
}
if (prefs.disableUniversalPatchCheck.get()) {
universalPatchWarningEnabled = false
}
if (prefs.disableSelectionWarning.get()) {
selectionWarningEnabled = false
return@launch
}
fun PatchBundleInfo.Scoped.hasDefaultPatches() =
patchSequence(allowIncompatiblePatches).any { it.include }
// Don't show the warning if there are no default patches.
selectionWarningEnabled = bundlesFlow.first().any(PatchBundleInfo.Scoped::hasDefaultPatches)
}
}
private var hasModifiedSelection = false
var customPatchSelection: PersistentPatchSelection? by savedStateHandle.saveable(
key = "selection",
stateSaver = selectionSaver,
) {
mutableStateOf(input.currentSelection?.toPersistentPatchSelection())
}
private val patchOptions: PersistentOptions by savedStateHandle.saveable(
saver = optionsSaver,
) {
// Convert Options to PersistentOptions
input.options.mapValuesTo(mutableStateMapOf()) { (_, allPatches) ->
allPatches.mapValues { (_, options) -> options.toPersistentMap() }.toPersistentMap()
}
}
/**
* Active dialog or bottom sheet state. Only one can be shown at a time.
*/
var activeDialog by mutableStateOf<DialogState?>(null)
private set
var filter by mutableIntStateOf(SHOW_UNIVERSAL)
private set
private val defaultPatchSelection = bundlesFlow.map { bundles ->
bundles.toPatchSelection(allowIncompatiblePatches) { _, patch -> patch.include }
.toPersistentPatchSelection()
}
val defaultSelectionCount = defaultPatchSelection.map { selection ->
selection.values.sumOf { it.size }
}
// This is for the required options screen.
private val requiredOptsPatchesDeferred = viewModelScope.async(start = CoroutineStart.LAZY) {
bundlesFlow.first().map { bundle ->
bundle to bundle.patchSequence(allowIncompatiblePatches).filter { patch ->
val opts by lazy {
getOptions(bundle.uid, patch).orEmpty()
}
isSelected(
bundle.uid,
patch
) && patch.options?.any { it.required && it.default == null && it.name !in opts } ?: false
}.toList()
}.filter { (_, patches) -> patches.isNotEmpty() }
}
val requiredOptsPatches = flow { emit(requiredOptsPatchesDeferred.await()) }
fun selectionIsValid(bundles: List<PatchBundleInfo.Scoped>) = !readOnly && bundles.any { bundle ->
bundle.patchSequence(allowIncompatiblePatches).any { patch ->
isSelected(bundle.uid, patch)
}
}
fun isSelected(bundle: Int, patch: PatchInfo) = customPatchSelection?.let { selection ->
selection[bundle]?.contains(patch.name) == true
} ?: patch.include
fun togglePatch(bundle: Int, patch: PatchInfo) = viewModelScope.launch {
hasModifiedSelection = true
val selection = customPatchSelection ?: defaultPatchSelection.first()
val newPatches = selection[bundle]?.let { patches ->
if (patch.name in patches)
patches.remove(patch.name)
else
patches.add(patch.name)
} ?: persistentSetOf(patch.name)
customPatchSelection = selection.put(bundle, newPatches)
}
fun reset() {
patchOptions.clear()
customPatchSelection = null
hasModifiedSelection = false
app.toast(app.getString(R.string.patch_selection_reset_toast))
}
fun getCustomSelection(): PatchSelection? {
// Convert persistent collections to standard hash collections because persistent collections are not parcelable.
return customPatchSelection?.mapValues { (_, v) -> v.toSet() }
}
fun getOptions(): Options {
// Convert the collection for the same reasons as in getCustomSelection()
return patchOptions.mapValues { (_, allPatches) -> allPatches.mapValues { (_, options) -> options.toMap() } }
}
fun getOptions(bundle: Int, patch: PatchInfo) = patchOptions[bundle]?.get(patch.name)
fun setOption(bundle: Int, patch: PatchInfo, name: String, value: Any?) {
// All patches
val patchesToOpts = patchOptions.getOrElse(bundle, ::persistentMapOf)
// The key-value options of an individual patch
val patchToOpts = patchesToOpts
.getOrElse(patch.name, ::persistentMapOf)
.put(name, value)
patchOptions[bundle] = patchesToOpts.put(patch.name, patchToOpts)
}
fun resetOptions(bundle: Int, patch: PatchInfo) {
app.toast(app.getString(R.string.patch_options_reset_toast))
patchOptions[bundle] = patchOptions[bundle]?.remove(patch.name) ?: return
}
fun resetOption(bundle: Int, patch: PatchInfo, option: Option<*>) {
val bundlesToPatches = patchOptions[bundle] ?: return
val patchesToOpts = bundlesToPatches[patch.name] ?: return
patchOptions[bundle] = bundlesToPatches.put(patch.name, patchesToOpts.remove(option.name))
}
fun dismissDialogs() {
activeDialog = null
}
fun openIncompatibleDialog(incompatiblePatch: PatchInfo) {
val versions = incompatiblePatch.compatiblePackages
?.find { it.packageName == packageName }?.versions.orEmpty()
activeDialog = DialogState.IncompatiblePatch(versions)
}
fun openOptionsDialog(bundle: Int, patch: PatchInfo) {
activeDialog = DialogState.Options(bundle, patch)
}
fun showSelectionWarning() {
activeDialog = DialogState.SelectionWarning
}
fun showUniversalPatchWarning() {
activeDialog = DialogState.UniversalPatchWarning
}
fun showIncompatiblePatchesInfo() {
activeDialog = DialogState.IncompatiblePatchesInfo
}
fun toggleFlag(flag: Int) {
filter = filter xor flag
}
fun getBundleSelectionState(bundle: PatchBundleInfo.Scoped): Boolean? {
val patches = bundle.patchSequence(allowIncompatiblePatches).toList()
if (patches.isEmpty()) return false
val selectedCount = patches.count { isSelected(bundle.uid, it) }
return when (selectedCount) {
patches.size -> true
0 -> false
else -> null
}
}
private suspend fun currentSelection(): PersistentPatchSelection =
customPatchSelection ?: defaultPatchSelection.first()
private suspend fun updateSelection(
update: (PersistentPatchSelection) -> PersistentPatchSelection
) {
hasModifiedSelection = true
customPatchSelection = update(currentSelection())
}
fun deselectAll(bundles: List<PatchBundleInfo.Scoped>, bundleUid: Int?) = viewModelScope.launch {
updateSelection { selection ->
bundles.fold(selection) { acc, bundle ->
if (bundleUid != null && bundle.uid != bundleUid) return@fold acc
acc.put(bundle.uid, persistentSetOf())
}
}
}
fun invertSelection(bundles: List<PatchBundleInfo.Scoped>, bundleUid: Int?) = viewModelScope.launch {
updateSelection { selection ->
bundles.fold(selection) { acc, bundle ->
if (bundleUid != null && bundle.uid != bundleUid) return@fold acc
val currentSelected = acc[bundle.uid] ?: persistentSetOf()
val inverted = bundle.patchSequence(allowIncompatiblePatches)
.filter { it.name !in currentSelected }
.map { it.name }
.toPersistentSet()
acc.put(bundle.uid, inverted)
}
}
}
fun restoreDefaults(bundleUid: Int?) = viewModelScope.launch {
if (bundleUid == null) {
customPatchSelection = null
hasModifiedSelection = false
return@launch
}
val defaults = defaultPatchSelection.first()
updateSelection { selection ->
selection.put(bundleUid, defaults[bundleUid] ?: persistentSetOf())
}
}
fun deselectAllExcept(bundles: List<PatchBundleInfo.Scoped>, keepBundleUid: Int) = viewModelScope.launch {
updateSelection { selection ->
bundles.fold(selection) { acc, bundle ->
if (bundle.uid == keepBundleUid) return@fold acc
acc.put(bundle.uid, persistentSetOf())
}
}
}
companion object {
const val SHOW_INCOMPATIBLE = 1 // 2^0
const val SHOW_UNIVERSAL = 2 // 2^1
private val optionsSaver: Saver<PersistentOptions, Options> = snapshotStateMapSaver(
// Patch name -> Options
valueSaver = persistentMapSaver(
// Option key -> Option value
valueSaver = persistentMapSaver()
)
)
private val selectionSaver: Saver<PersistentPatchSelection?, Nullable<PatchSelection>> =
nullableSaver(persistentMapSaver(valueSaver = persistentSetSaver()))
}
sealed interface DialogState {
data class Options(val bundle: Int, val patch: PatchInfo) : DialogState
data class IncompatiblePatch(val compatibleVersions: Set<String>) : DialogState
data object IncompatiblePatchesInfo : DialogState
data object SelectionWarning : DialogState
data object UniversalPatchWarning : DialogState
}
private fun mergeSourcesWithBundleInfo(
sources: List<PatchBundleSource>,
scopedBundleInfoByUid: Map<Int, PatchBundleInfo.Scoped>
) = sources.map { source ->
scopedBundleInfoByUid[source.uid] ?: source.emptyScopedBundleInfo()
}
}
// Versions of other types, but utilizing persistent/observable collection types.
private typealias PersistentOptions = SnapshotStateMap<Int, PersistentMap<String, PersistentMap<String, Any?>>>
private typealias PersistentPatchSelection = PersistentMap<Int, PersistentSet<String>>
private fun PatchSelection.toPersistentPatchSelection(): PersistentPatchSelection =
mapValues { (_, v) -> v.toPersistentSet() }.toPersistentMap()
private fun PatchBundleInfo.Global.asReadonlyScoped() = PatchBundleInfo.Scoped(
name = name,
version = version,
releasedAt = releasedAt,
uid = uid,
patches = patches,
compatible = patches,
incompatible = emptyList(),
universal = emptyList()
)
private fun PatchBundleSource.emptyScopedBundleInfo() = PatchBundleInfo.Scoped(
name = name,
version = version,
releasedAt = (this.asRemoteOrNull)?.releasedAt,
uid = uid,
patches = emptyList(),
compatible = emptyList(),
incompatible = emptyList(),
universal = emptyList()
)

View File

@@ -275,7 +275,13 @@ val ScrollState.isScrollingUp: Boolean @Composable get() = this.isScrollingUp().
fun <R> (() -> R).withHapticFeedback(constant: Int): () -> R {
val view = LocalView.current
return {
view.performHapticFeedback(constant)
try {
view.performHapticFeedback(constant)
} catch (e: NullPointerException) {
// Note, some devices using RichTap hardware reported crashing, what??
// https://github.com/ReVanced/revanced-manager/issues/3224
Log.e("RVM-Util/Vibration", "Vibration failed with NPE, what?", e)
}
this()
}
}
@@ -285,7 +291,13 @@ fun <R> (() -> R).withHapticFeedback(constant: Int): () -> R {
fun <T, R> ((T) -> R).withHapticFeedback(constant: Int): (T) -> R {
val view = LocalView.current
return {
view.performHapticFeedback(constant)
try {
view.performHapticFeedback(constant)
} catch (e: NullPointerException) {
// Note, some devices using RichTap hardware reported crashing, what??
// https://github.com/ReVanced/revanced-manager/issues/3224
Log.e("RVM-Util/Vibration", "Vibration failed with NPE, what?", e)
}
this(it)
}
}

View File

@@ -94,7 +94,7 @@ Second \"item\" text"</string>
<string name="patch_selector_item_description">%d patches selected</string>
<string name="patch_selection_changed_warning">Patch selection has been changed</string>
<string name="no_patches_selected">No patches selected</string>
<string name="network_unavailable_warning">Your device is not connected to the internet. Downloading will fail later.</string>
<string name="network_metered_warning">You are currently on a metered connection. Data charges from your service provider may apply.</string>
<string name="apk_source_selector_item">Select APK source</string>
@@ -233,6 +233,8 @@ You wont be able to update apps that were signed with the previous keystore.<
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="edit">Edit</string>
<string name="discard_changes">Discard changes</string>
<string name="discard_changes_dialog_title">Discard changes?</string>
<string name="dialog_input_placeholder">Value</string>
<string name="reset">Reset</string>
<string name="share">Share</string>
@@ -309,6 +311,8 @@ You wont be able to update apps that were signed with the previous keystore.<
<string name="no_patched_apps_description">You dont have any patched apps yet. Start by patching your first app!</string>
<string name="patched_apps_section_title">Patched apps</string>
<string name="patchable_apps_section_title">Apps that can be patched</string>
<string name="pinned_apps_section_title">Pinned apps</string>
<string name="available_apps_section_title">Available apps</string>
<string name="no_patches_found">No patches found</string>
<string name="no_patches_description">You dont have any patches yet. Add patches by tapping the button below!</string>
<string name="tap_on_patches">Tap on the patches to get more information about them</string>
@@ -317,6 +321,22 @@ You wont be able to update apps that were signed with the previous keystore.<
<string name="universal_patches">Universal patches</string>
<string name="patch_selection_reset_toast">Patch selection and options have been reset to recommended defaults</string>
<string name="patch_options_reset_toast">Patch options have been reset</string>
<string name="patch_options_discard_invalid_description">Invalid options will reset to the last saved value.</string>
<string name="patch_options_set_default">Use default value</string>
<string name="patch_options_value_null">Not specified</string>
<string name="patch_options_value_invalid">Invalid value</string>
<string name="patch_options_value_boolean_true">Enabled</string>
<string name="patch_options_value_boolean_false">Disabled</string>
<string name="patch_options_value_required">This option is required</string>
<string name="patch_options_value_required_list_empty">This list is required, but you havent added any items.</string>
<string name="patch_options_using_default_value">Using default value</string>
<string name="patch_options_value_list_element">Value</string>
<string name="patch_options_value_list_invalid_dialog_title">Cant save option</string>
<string name="patch_options_value_list_invalid_dialog_description">This list doesnt follow the required format.</string>
<string name="patch_options_value_list_element_count">%d values</string>
<string name="patch_options_no_default_items">No default items for this option</string>
<string name="patch_options_value_required_list_empty_save_warning_title">Save without items?</string>
<string name="patch_options_value_required_list_empty_save_warning_description">This option is required, and usually needs at least one item for the patch to have an effect.</string>
<string name="non_suggested_version_warning_title">Non suggested version</string>
<string name="non_suggested_version_warning_description">The version of the app you selected doesnt match the suggested version: %s
@@ -511,7 +531,6 @@ Its only compatible with these versions: %2$s</string>
<string name="days_ago">%sd ago</string>
<string name="invalid_date">Invalid date</string>
<string name="input_dialog_value_invalid">Invalid value</string>
<string name="option_required">This option is required</string>
<string name="required_options_screen">Required options</string>
<string name="failed_to_check_updates">Couldnt check for updates: %s</string>
@@ -566,7 +585,6 @@ Tap them for more details.</string>
<string name="incompatible_patch">Incompatible patch</string>
<string name="any_version">Any</string>
<string name="never_show_again">Dont show this again</string>
<string name="open_downloaders">Open downloaders</string>
<string name="show_manager_update_dialog_on_launch">Show update message on launch</string>
<string name="show_manager_update_dialog_on_launch_description">Get notified when you open the app and a new update is available</string>
<string name="failed_to_import_keystore">Couldnt import keystore</string>
@@ -579,8 +597,11 @@ Tap them for more details.</string>
<string name="sideeffect_restart">Restart the app to see changes</string>
<string name="sideeffect_no_network">Youre offline. Check your internet connection.</string>
<string name="open_downloaders">Open downloaders</string>
<string name="network_unavailable_warning">Your device is not connected to the internet. Downloading will fail later.</string>
<string name="source_selector_category_downloaded">Previously downloaded</string>
<plurals name="patch_count">
<item quantity="one">%d patch</item>
<item quantity="other">%d patches</item>