Compare commits

...

4 Commits

Author SHA1 Message Date
oSumAtrIX
27de80b65f feat: Replace bundle management screen with patches viewer directly 2026-03-12 23:15:19 +01:00
oSumAtrIX
a7c3a69f3d feat: Use a readonly patches selector screen to view patches from a specific source 2026-03-12 23:13:42 +01:00
oSumAtrIX
e3558413b7 feat: Merge initial update choices to one 2026-03-12 23:13:41 +01:00
semantic-release-bot
5eea987b82 chore: Release v1.26.0-dev.35 [skip ci]
# app [1.26.0-dev.35](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.34...v1.26.0-dev.35) (2026-03-12)

### Features

* Add support for writing downloader UI with fragments ([#2998](https://github.com/ReVanced/revanced-manager/issues/2998)) ([7471710](7471710f28))
2026-03-12 20:49:42 +00:00
16 changed files with 946 additions and 663 deletions

View File

@@ -1,3 +1,10 @@
# app [1.26.0-dev.35](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.34...v1.26.0-dev.35) (2026-03-12)
### Features
* Add support for writing downloader UI with fragments ([#2998](https://github.com/ReVanced/revanced-manager/issues/2998)) ([7471710](https://github.com/ReVanced/revanced-manager/commit/7471710f287b4febb9546a7d7cd59d654b2754e9))
# app [1.26.0-dev.34](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.33...v1.26.0-dev.34) (2026-03-10)

View File

@@ -1 +1 @@
version = 1.26.0-dev.34
version = 1.26.0-dev.35

View File

@@ -135,10 +135,20 @@ class PatchBundleRepository(
}
private suspend fun loadFromDb(): List<PatchBundleEntity> {
val all = dao.all()
val all = dao.all().toMutableList()
if (all.isEmpty()) {
dao.upsert(defaultSource)
return listOf(defaultSource)
all += defaultSource
}
// Temporary testing sources for dashboard source-management UX.
if (all.none { it.uid == testSourceOne.uid }) {
dao.upsert(testSourceOne)
all += testSourceOne
}
if (all.none { it.uid == testSourceTwo.uid }) {
dao.upsert(testSourceTwo)
all += testSourceTwo
}
return all
@@ -414,5 +424,21 @@ class PatchBundleRepository(
source = Source.API,
autoUpdate = false
)
val testSourceOne = PatchBundleEntity(
uid = 1001,
name = "Stub source A",
versionHash = null,
source = Source.Remote(io.ktor.http.Url("https://example.invalid/revanced/stub-a.json")),
autoUpdate = false
)
val testSourceTwo = PatchBundleEntity(
uid = 1002,
name = "Stub source B",
versionHash = null,
source = Source.Remote(io.ktor.http.Url("https://example.invalid/revanced/stub-b.json")),
autoUpdate = false
)
}
}

View File

@@ -27,57 +27,23 @@ import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.util.transparentListItemColors
@Composable
fun AutoUpdatesDialog(onSubmit: (Boolean, Boolean) -> Unit) {
var patchesEnabled by rememberSaveable { mutableStateOf(true) }
var managerEnabled by rememberSaveable { mutableStateOf(true) }
fun AutoUpdatesDialog(onSubmit: (Boolean) -> Unit) {
var enabled by rememberSaveable { mutableStateOf(true) }
AlertDialog(
onDismissRequest = {},
confirmButton = {
TextButton(onClick = { onSubmit(managerEnabled, patchesEnabled) }) {
Text(stringResource(R.string.save))
TextButton(onClick = { onSubmit(enabled) }) {
Text(stringResource(R.string.confirm))
}
},
dismissButton = {
TextButton(onClick = { onSubmit(false) }) {
Text(stringResource(R.string.no))
}
},
icon = { Icon(Icons.Outlined.Update, null) },
title = { Text(text = stringResource(R.string.auto_updates_dialog_title)) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = stringResource(R.string.auto_updates_dialog_description))
Column {
AutoUpdatesItem(
headline = R.string.auto_updates_dialog_manager,
icon = Icons.Outlined.Update,
checked = managerEnabled,
onCheckedChange = { managerEnabled = it }
)
HorizontalDivider()
AutoUpdatesItem(
headline = R.string.auto_updates_dialog_patches,
icon = Icons.Outlined.Source,
checked = patchesEnabled,
onCheckedChange = { patchesEnabled = it }
)
}
Text(text = stringResource(R.string.auto_updates_dialog_note))
}
}
text = { Text(text = stringResource(R.string.auto_updates_dialog_description)) }
)
}
@Composable
private fun AutoUpdatesItem(
@StringRes headline: Int,
icon: ImageVector,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) = ListItem(
leadingContent = { Icon(icon, null) },
headlineContent = { Text(stringResource(headline)) },
trailingContent = { HapticCheckbox(checked = checked, onCheckedChange = null) },
modifier = Modifier.clickable { onCheckedChange(!checked) },
colors = transparentListItemColors
)

View File

@@ -24,7 +24,8 @@ private inline fun <T> NumberInputDialog(
name: String,
crossinline onSubmit: (T?) -> Unit,
crossinline validator: @DisallowComposableCalls (T) -> Boolean,
crossinline toNumberOrNull: @DisallowComposableCalls String.() -> T?
crossinline toNumberOrNull: @DisallowComposableCalls String.() -> T?,
readOnly: Boolean = false
) {
var fieldValue by rememberSaveable {
mutableStateOf(current?.toString().orEmpty())
@@ -43,12 +44,14 @@ private inline fun <T> NumberInputDialog(
OutlinedTextField(
value = fieldValue,
onValueChange = { fieldValue = it },
enabled = !readOnly,
readOnly = readOnly,
placeholder = {
Text(stringResource(R.string.dialog_input_placeholder))
},
isError = validatorFailed,
isError = !readOnly && validatorFailed,
supportingText = {
if (validatorFailed) {
if (!readOnly && validatorFailed) {
Text(
stringResource(R.string.input_dialog_value_invalid),
modifier = Modifier.fillMaxWidth(),
@@ -59,16 +62,18 @@ private inline fun <T> NumberInputDialog(
)
},
confirmButton = {
TextButton(
onClick = { numberFieldValue?.let(onSubmit) },
enabled = numberFieldValue != null && !validatorFailed,
) {
Text(stringResource(R.string.save))
if (!readOnly) {
TextButton(
onClick = { numberFieldValue?.let(onSubmit) },
enabled = numberFieldValue != null && !validatorFailed,
) {
Text(stringResource(R.string.save))
}
}
},
dismissButton = {
TextButton(onClick = { onSubmit(null) }) {
Text(stringResource(R.string.cancel))
Text(stringResource(if (readOnly) R.string.close else R.string.cancel))
}
},
)
@@ -79,21 +84,24 @@ fun IntInputDialog(
current: Int?,
name: String,
validator: (Int) -> Boolean = { true },
onSubmit: (Int?) -> Unit
) = NumberInputDialog(current, name, onSubmit, validator, String::toIntOrNull)
onSubmit: (Int?) -> Unit,
readOnly: Boolean = false
) = NumberInputDialog(current, name, onSubmit, validator, String::toIntOrNull, readOnly)
@Composable
fun LongInputDialog(
current: Long?,
name: String,
validator: (Long) -> Boolean = { true },
onSubmit: (Long?) -> Unit
) = NumberInputDialog(current, name, onSubmit, validator, String::toLongOrNull)
onSubmit: (Long?) -> Unit,
readOnly: Boolean = false
) = NumberInputDialog(current, name, onSubmit, validator, String::toLongOrNull, readOnly)
@Composable
fun FloatInputDialog(
current: Float?,
name: String,
validator: (Float) -> Boolean = { true },
onSubmit: (Float?) -> Unit
) = NumberInputDialog(current, name, onSubmit, validator, String::toFloatOrNull)
onSubmit: (Float?) -> Unit,
readOnly: Boolean = false
) = NumberInputDialog(current, name, onSubmit, validator, String::toFloatOrNull, readOnly)

View File

@@ -18,6 +18,7 @@ fun TextInputDialog(
onDismissRequest: () -> Unit,
onConfirm: (String) -> Unit,
validator: (String) -> Boolean = String::isNotEmpty,
readOnly: Boolean = false
) {
val (value, setValue) = rememberSaveable(initial) {
mutableStateOf(initial)
@@ -29,23 +30,30 @@ fun TextInputDialog(
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
onClick = { onConfirm(value) },
enabled = valid
) {
Text(stringResource(R.string.ok))
if (!readOnly) {
TextButton(
onClick = { onConfirm(value) },
enabled = valid
) {
Text(stringResource(R.string.ok))
}
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.cancel))
Text(stringResource(if (readOnly) R.string.close else R.string.cancel))
}
},
title = {
Text(title)
},
text = {
TextField(value = value, onValueChange = setValue)
TextField(
value = value,
onValueChange = setValue,
enabled = !readOnly,
readOnly = readOnly
)
}
)
}

View File

@@ -52,8 +52,14 @@ import app.revanced.manager.ui.component.ExceptionViewerDialog
import app.revanced.manager.ui.component.FullscreenDialog
import app.revanced.manager.ui.component.TextInputDialog
import app.revanced.manager.ui.component.haptics.HapticSwitch
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
import app.revanced.manager.ui.screen.PatchesSelectorScreen
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -82,12 +88,27 @@ fun BundleInformationDialog(
}
if (viewCurrentBundlePatches) {
BundlePatchesDialog(
src = src,
onDismissRequest = {
viewCurrentBundlePatches = false
val viewModel: PatchesSelectorViewModel = koinViewModel(
key = "bundle-patches-${src.uid}",
parameters = {
parametersOf(
SelectedApplicationInfo.PatchesSelector.ViewModelParams(
app = SelectedApp.Search(packageName = "", version = null),
currentSelection = null,
options = emptyMap(),
readOnly = true,
)
)
}
)
FullscreenDialog(onDismissRequest = { viewCurrentBundlePatches = false }) {
PatchesSelectorScreen(
onSave = { _, _ -> },
onBackClick = { viewCurrentBundlePatches = false },
viewModel = viewModel
)
}
return
}
FullscreenDialog(

View File

@@ -1,276 +0,0 @@
package app.revanced.manager.ui.component.bundle
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
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.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.component.ArrowButton
import app.revanced.manager.ui.component.FullscreenDialog
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import kotlinx.coroutines.flow.mapNotNull
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BundlePatchesDialog(
onDismissRequest: () -> Unit,
src: PatchBundleSource,
) {
var showAllVersions by rememberSaveable { mutableStateOf(false) }
var showOptions by rememberSaveable { mutableStateOf(false) }
val patchBundleRepository: PatchBundleRepository = koinInject()
val patches by remember(src.uid) {
patchBundleRepository.bundleInfoFlow.mapNotNull { it[src.uid]?.patches }
}.collectAsStateWithLifecycle(emptyList())
FullscreenDialog(
onDismissRequest = onDismissRequest,
) {
Scaffold(
topBar = {
BundleTopBar(
title = stringResource(R.string.patches),
onBackClick = onDismissRequest,
backIcon = {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
},
)
},
) { paddingValues ->
LazyColumnWithScrollbar(
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(16.dp)
) {
items(patches) { patch ->
PatchItem(
patch,
showAllVersions,
onExpandVersions = { showAllVersions = !showAllVersions },
showOptions,
onExpandOptions = { showOptions = !showOptions }
)
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PatchItem(
patch: PatchInfo,
expandVersions: Boolean,
onExpandVersions: () -> Unit,
expandOptions: Boolean,
onExpandOptions: () -> Unit
) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.then(
if (patch.options.isNullOrEmpty()) Modifier else Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onExpandOptions),
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Absolute.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = patch.name,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
if (!patch.options.isNullOrEmpty()) {
ArrowButton(expanded = expandOptions, onClick = null)
}
}
patch.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium
)
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
if (patch.compatiblePackages.isNullOrEmpty()) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
PatchInfoChip(
text = "$PACKAGE_ICON ${stringResource(R.string.patches_view_any_package)}"
)
PatchInfoChip(
text = "$VERSION_ICON ${stringResource(R.string.patches_view_any_version)}"
)
}
} else {
patch.compatiblePackages.forEach { compatiblePackage ->
val packageName = compatiblePackage.packageName
val versions = compatiblePackage.versions.orEmpty().reversed()
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
PatchInfoChip(
modifier = Modifier.align(Alignment.CenterVertically),
text = "$PACKAGE_ICON $packageName"
)
if (versions.isNotEmpty()) {
if (expandVersions) {
versions.forEach { version ->
PatchInfoChip(
modifier = Modifier.align(Alignment.CenterVertically),
text = "$VERSION_ICON $version"
)
}
} else {
PatchInfoChip(
modifier = Modifier.align(Alignment.CenterVertically),
text = "$VERSION_ICON ${versions.first()}"
)
}
if (versions.size > 1) {
PatchInfoChip(
onClick = onExpandVersions,
text = if (expandVersions) stringResource(R.string.less) else "+${versions.size - 1}"
)
}
}
}
}
}
}
if (!patch.options.isNullOrEmpty()) {
AnimatedVisibility(visible = expandOptions) {
val options = patch.options
Column {
options.forEachIndexed { i, option ->
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
colors = CardColors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
disabledContainerColor = Color.Transparent,
disabledContentColor = MaterialTheme.colorScheme.onSurface
), shape = when {
options.size == 1 -> RoundedCornerShape(8.dp)
i == 0 -> RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)
i == options.lastIndex -> RoundedCornerShape(
bottomStart = 8.dp,
bottomEnd = 8.dp
)
else -> RoundedCornerShape(0.dp)
}
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = option.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = option.description,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
}
}
}
}
@Composable
fun PatchInfoChip(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
text: String
) {
val shape = RoundedCornerShape(8.0.dp)
val cardModifier = if (onClick != null) {
Modifier
.clip(shape)
.clickable(onClick = onClick)
} else {
Modifier
}
OutlinedCard(
modifier = modifier.then(cardModifier),
colors = CardColors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
disabledContainerColor = Color.Transparent,
disabledContentColor = MaterialTheme.colorScheme.onSurface
),
shape = shape,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.20f))
) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text,
overflow = TextOverflow.Ellipsis,
softWrap = false,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
const val PACKAGE_ICON = "\uD83D\uDCE6"
const val VERSION_ICON = "\uD83C\uDFAF"

View File

@@ -91,7 +91,8 @@ private class OptionEditorScope<T : Any>(
val selectionWarningEnabled: Boolean,
val showSelectionWarning: () -> Unit,
val value: T?,
val setValue: (T?) -> Unit
val setValue: (T?) -> Unit,
val readOnly: Boolean = false
) {
fun submitDialog(value: T?) {
setValue(value)
@@ -106,8 +107,13 @@ private class OptionEditorScope<T : Any>(
}
fun clickAction() {
checkSafeguard {
if (readOnly) {
// In readonly mode, bypass the safeguard and open the dialog directly
editor.clickAction(this)
} else {
checkSafeguard {
editor.clickAction(this)
}
}
}
@@ -123,7 +129,10 @@ private interface OptionEditor<T : Any> {
@Composable
fun ListItemTrailingContent(scope: OptionEditorScope<T>) {
IconButton(onClick = { scope.checkSafeguard { clickAction(scope) } }) {
IconButton(
onClick = { scope.checkSafeguard { clickAction(scope) } },
enabled = !scope.readOnly
) {
Icon(Icons.Outlined.Edit, stringResource(R.string.edit))
}
}
@@ -152,13 +161,14 @@ private inline fun <T : Any> WithOptionEditor(
value: T?,
noinline setValue: (T?) -> Unit,
selectionWarningEnabled: Boolean,
readOnly: Boolean = false,
crossinline onDismissDialog: @DisallowComposableCalls () -> Unit = {},
block: OptionEditorScope<T>.() -> Unit
) {
var showDialog by rememberSaveable { mutableStateOf(false) }
var showSelectionWarningDialog by rememberSaveable { mutableStateOf(false) }
val scope = remember(editor, option, value, setValue, selectionWarningEnabled) {
val scope = remember(editor, option, value, setValue, selectionWarningEnabled, readOnly) {
OptionEditorScope(
editor,
option,
@@ -170,7 +180,8 @@ private inline fun <T : Any> WithOptionEditor(
selectionWarningEnabled,
showSelectionWarning = { showSelectionWarningDialog = true },
value,
setValue
setValue,
readOnly = readOnly
)
}
@@ -189,7 +200,8 @@ fun <T : Any> OptionItem(
option: Option<T>,
value: T?,
setValue: (T?) -> Unit,
selectionWarningEnabled: Boolean
selectionWarningEnabled: Boolean,
readOnly: Boolean = false
) {
val editor = remember(option.type, option.presets) {
@Suppress("UNCHECKED_CAST")
@@ -202,7 +214,7 @@ fun <T : Any> OptionItem(
else baseOptionEditor
}
WithOptionEditor(editor, option, value, setValue, selectionWarningEnabled) {
WithOptionEditor(editor, option, value, setValue, selectionWarningEnabled, readOnly) {
ListItem(
modifier = Modifier.clickable(onClick = ::clickAction),
headlineContent = { Text(option.name) },
@@ -255,12 +267,14 @@ private object StringOptionEditor : OptionEditor<String> {
OutlinedTextField(
value = fieldValue,
onValueChange = { fieldValue = it },
enabled = !scope.readOnly,
readOnly = scope.readOnly,
placeholder = {
Text(stringResource(R.string.dialog_input_placeholder))
},
isError = validatorFailed,
isError = !scope.readOnly && validatorFailed,
supportingText = {
if (validatorFailed) {
if (!scope.readOnly && validatorFailed) {
Text(
stringResource(R.string.input_dialog_value_invalid),
modifier = Modifier.fillMaxWidth(),
@@ -268,51 +282,55 @@ private object StringOptionEditor : OptionEditor<String> {
)
}
},
trailingIcon = {
var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
IconButton(
onClick = { showDropdownMenu = true }
) {
Icon(
Icons.Outlined.MoreVert,
stringResource(R.string.string_option_menu_description)
)
}
trailingIcon = if (!scope.readOnly) {
{
var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
IconButton(
onClick = { showDropdownMenu = true }
) {
Icon(
Icons.Outlined.MoreVert,
stringResource(R.string.string_option_menu_description)
)
}
DropdownMenu(
expanded = showDropdownMenu,
onDismissRequest = { showDropdownMenu = false }
) {
DropdownMenuItem(
leadingIcon = {
Icon(Icons.Outlined.Folder, null)
},
text = {
Text(stringResource(R.string.path_selector))
},
onClick = {
showDropdownMenu = false
if (fs.hasStoragePermission()) {
showFileDialog = true
} else {
permissionLauncher.launch(permissionName)
DropdownMenu(
expanded = showDropdownMenu,
onDismissRequest = { showDropdownMenu = false }
) {
DropdownMenuItem(
leadingIcon = {
Icon(Icons.Outlined.Folder, null)
},
text = {
Text(stringResource(R.string.path_selector))
},
onClick = {
showDropdownMenu = false
if (fs.hasStoragePermission()) {
showFileDialog = true
} else {
permissionLauncher.launch(permissionName)
}
}
}
)
)
}
}
}
} else null
)
},
confirmButton = {
TextButton(
enabled = !validatorFailed,
onClick = { scope.submitDialog(fieldValue) }) {
Text(stringResource(R.string.save))
if (!scope.readOnly) {
TextButton(
enabled = !validatorFailed,
onClick = { scope.submitDialog(fieldValue) }) {
Text(stringResource(R.string.save))
}
}
},
dismissButton = {
TextButton(onClick = scope.dismissDialog) {
Text(stringResource(R.string.cancel))
Text(stringResource(if (scope.readOnly) R.string.close else R.string.cancel))
}
},
)
@@ -325,16 +343,17 @@ private abstract class NumberOptionEditor<T : Number> : OptionEditor<T> {
title: String,
current: T?,
validator: (T?) -> Boolean,
onSubmit: (T?) -> Unit
onSubmit: (T?) -> Unit,
readOnly: Boolean
)
@Composable
override fun Dialog(scope: OptionEditorScope<T>) {
NumberDialog(scope.option.name, scope.value, scope.option.validator) {
NumberDialog(scope.option.name, scope.value, scope.option.validator, {
if (it == null) return@NumberDialog scope.dismissDialog()
scope.submitDialog(it)
}
}, scope.readOnly)
}
}
@@ -344,8 +363,9 @@ private object IntOptionEditor : NumberOptionEditor<Int>() {
title: String,
current: Int?,
validator: (Int?) -> Boolean,
onSubmit: (Int?) -> Unit
) = IntInputDialog(current, title, validator, onSubmit)
onSubmit: (Int?) -> Unit,
readOnly: Boolean
) = IntInputDialog(current, title, validator, onSubmit, readOnly)
}
private object LongOptionEditor : NumberOptionEditor<Long>() {
@@ -354,8 +374,9 @@ private object LongOptionEditor : NumberOptionEditor<Long>() {
title: String,
current: Long?,
validator: (Long?) -> Boolean,
onSubmit: (Long?) -> Unit
) = LongInputDialog(current, title, validator, onSubmit)
onSubmit: (Long?) -> Unit,
readOnly: Boolean
) = LongInputDialog(current, title, validator, onSubmit, readOnly)
}
private object FloatOptionEditor : NumberOptionEditor<Float>() {
@@ -364,8 +385,9 @@ private object FloatOptionEditor : NumberOptionEditor<Float>() {
title: String,
current: Float?,
validator: (Float?) -> Boolean,
onSubmit: (Float?) -> Unit
) = FloatInputDialog(current, title, validator, onSubmit)
onSubmit: (Float?) -> Unit,
readOnly: Boolean
) = FloatInputDialog(current, title, validator, onSubmit, readOnly)
}
private object BooleanOptionEditor : OptionEditor<Boolean> {
@@ -381,7 +403,8 @@ private object BooleanOptionEditor : OptionEditor<Boolean> {
scope.checkSafeguard {
scope.setValue(value)
}
}
},
enabled = !scope.readOnly
)
}
@@ -422,6 +445,7 @@ private class PresetOptionEditor<T : Any>(private val innerEditor: OptionEditor<
scope.value,
scope.setValue,
scope.selectionWarningEnabled,
readOnly = scope.readOnly,
onDismissDialog = scope.dismissDialog
) inner@{
var hidePresetsDialog by rememberSaveable {
@@ -433,26 +457,28 @@ private class PresetOptionEditor<T : Any>(private val innerEditor: OptionEditor<
AlertDialogExtended(
onDismissRequest = scope.dismissDialog,
confirmButton = {
TextButton(
onClick = {
if (selectedPreset != null) scope.submitDialog(
scope.option.presets?.get(
selectedPreset
if (!scope.readOnly) {
TextButton(
onClick = {
if (selectedPreset != null) scope.submitDialog(
scope.option.presets?.get(
selectedPreset
)
)
)
else {
this@inner.openDialog()
// Hide the presets dialog so it doesn't show up in the background.
hidePresetsDialog = true
else {
this@inner.openDialog()
// Hide the presets dialog so it doesn't show up in the background.
hidePresetsDialog = true
}
}
) {
Text(stringResource(if (selectedPreset != null) R.string.save else R.string.continue_))
}
) {
Text(stringResource(if (selectedPreset != null) R.string.save else R.string.continue_))
}
},
dismissButton = {
TextButton(onClick = scope.dismissDialog) {
Text(stringResource(R.string.cancel))
Text(stringResource(if (scope.readOnly) R.string.close else R.string.cancel))
}
},
title = { Text(scope.option.name) },
@@ -466,13 +492,14 @@ private class PresetOptionEditor<T : Any>(private val innerEditor: OptionEditor<
@Composable
fun Item(title: String, value: Any?, presetKey: String?) {
ListItem(
modifier = Modifier.clickable { selectedPreset = presetKey },
modifier = if (!scope.readOnly) Modifier.clickable { selectedPreset = presetKey } else Modifier,
headlineContent = { Text(title) },
supportingContent = value?.toString()?.let { { Text(it) } },
leadingContent = {
HapticRadioButton(
selected = selectedPreset == presetKey,
onClick = { selectedPreset = presetKey }
onClick = if (!scope.readOnly) { { selectedPreset = presetKey } } else null,
enabled = !scope.readOnly
)
},
colors = transparentListItemColors
@@ -602,7 +629,7 @@ private class ListOptionEditor<T : Serializable>(private val elementEditor: Opti
stringResource(R.string.delete)
)
}
} else {
} else if (!scope.readOnly) {
IconButton(onClick = items::clear) {
Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
}
@@ -611,7 +638,7 @@ private class ListOptionEditor<T : Serializable>(private val elementEditor: Opti
)
},
floatingActionButton = {
if (deleteMode) return@Scaffold
if (deleteMode || scope.readOnly) return@Scaffold
HapticExtendedFloatingActionButton(
text = { Text(stringResource(R.string.add)) },
@@ -643,45 +670,52 @@ private class ListOptionEditor<T : Serializable>(private val elementEditor: Opti
elementOption,
value = item.value,
setValue = { items[index] = item.copy(value = it) },
selectionWarningEnabled = scope.selectionWarningEnabled
selectionWarningEnabled = scope.selectionWarningEnabled,
readOnly = scope.readOnly
) {
ListItem(
modifier = Modifier.combinedClickable(
indication = LocalIndication.current,
interactionSource = interactionSource,
onLongClickLabel = stringResource(R.string.select),
onLongClick = {
if (!deleteMode) {
deletionTargets.add(item.key)
deleteMode = true
}
},
onClick = {
if (!deleteMode) {
clickAction()
return@combinedClickable
}
modifier = if (!scope.readOnly) {
Modifier.combinedClickable(
indication = LocalIndication.current,
interactionSource = interactionSource,
onLongClickLabel = stringResource(R.string.select),
onLongClick = {
if (!deleteMode) {
deletionTargets.add(item.key)
deleteMode = true
}
},
onClick = {
if (!deleteMode) {
clickAction()
return@combinedClickable
}
if (item.key in deletionTargets) {
deletionTargets.remove(
item.key
)
deleteMode = deletionTargets.isNotEmpty()
} else deletionTargets.add(item.key)
},
),
tonalElevation = if (deleteMode && item.key in deletionTargets) 8.dp else 0.dp,
leadingContent = {
IconButton(
modifier = Modifier.draggableHandle(interactionSource = interactionSource),
onClick = {},
) {
Icon(
Icons.Filled.DragHandle,
stringResource(R.string.drag_handle)
)
}
if (item.key in deletionTargets) {
deletionTargets.remove(
item.key
)
deleteMode = deletionTargets.isNotEmpty()
} else deletionTargets.add(item.key)
},
)
} else {
Modifier.clickable(onClick = ::clickAction)
},
tonalElevation = if (deleteMode && item.key in deletionTargets) 8.dp else 0.dp,
leadingContent = if (!scope.readOnly) {
{
IconButton(
modifier = Modifier.draggableHandle(interactionSource = interactionSource),
onClick = {},
) {
Icon(
Icons.Filled.DragHandle,
stringResource(R.string.drag_handle)
)
}
}
} else null,
headlineContent = {
if (item.value == null) return@ListItem Text(
stringResource(R.string.empty),

View File

@@ -52,10 +52,14 @@ fun IntegerItem(
}
if (dialogOpen) {
IntInputDialog(current = value, name = stringResource(headline)) { new ->
dialogOpen = false
new?.let(onValueChange)
}
IntInputDialog(
current = value,
name = stringResource(headline),
onSubmit = { new ->
dialogOpen = false
new?.let(onValueChange)
}
)
}
SettingsListItem(

View File

@@ -47,6 +47,7 @@ data object SelectedApplicationInfo : ComplexParameter<SelectedApplicationInfo.V
val app: SelectedApp,
val currentSelection: PatchSelection?,
val options: @RawValue Options,
val readOnly: Boolean = false,
) : Parcelable
}

View File

@@ -25,8 +25,8 @@ import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Source
import androidx.compose.material.icons.outlined.Update
@@ -43,10 +43,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -60,6 +58,7 @@ 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.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.patcher.aapt.Aapt
import app.revanced.manager.ui.component.AlertDialogExtended
@@ -68,15 +67,20 @@ import app.revanced.manager.ui.component.AutoUpdatesDialog
import app.revanced.manager.ui.component.AvailableUpdateDialog
import app.revanced.manager.ui.component.NotificationCard
import app.revanced.manager.ui.component.ConfirmDialog
import app.revanced.manager.ui.component.bundle.BundleInformationDialog
import app.revanced.manager.ui.component.bundle.BundleTopBar
import app.revanced.manager.ui.component.bundle.ImportPatchBundleDialog
import app.revanced.manager.ui.component.haptics.HapticFloatingActionButton
import app.revanced.manager.ui.component.haptics.HapticTab
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
import app.revanced.manager.ui.viewmodel.DashboardViewModel
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
import app.revanced.manager.util.RequestInstallAppsContract
import app.revanced.manager.util.toast
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
enum class DashboardPage(
val titleResId: Int,
@@ -99,12 +103,12 @@ fun DashboardScreen(
onDownloaderClick: () -> Unit,
onAppClick: (String) -> Unit
) {
var selectedSourceCount by rememberSaveable { mutableIntStateOf(0) }
val bundlesSelectable by remember { derivedStateOf { selectedSourceCount > 0 } }
val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0)
val showNewDownloaderNotification by vm.newDownloadersAvailable.collectAsStateWithLifecycle(
false
)
val sources by vm.sources.collectAsStateWithLifecycle(emptyList())
val patchCounts by vm.patchCounts.collectAsStateWithLifecycle(emptyMap())
val showNewDownloaderNotification by vm.newDownloadersAvailable.collectAsStateWithLifecycle(false)
val usePrerelease by vm.prefs.usePatchesPrereleases.getAsState()
val androidContext = LocalContext.current
val resources = LocalResources.current
val composableScope = rememberCoroutineScope()
@@ -113,9 +117,24 @@ fun DashboardScreen(
initialPageOffsetFraction = 0f
) { DashboardPage.entries.size }
LaunchedEffect(pagerState.currentPage) {
if (pagerState.currentPage != DashboardPage.BUNDLES.ordinal) vm.cancelSourceSelection()
}
var sourceEditMode by rememberSaveable { mutableStateOf(false) }
var selectedSourceUids by rememberSaveable { mutableStateOf(setOf<Int>()) }
val selectedSourceCount by remember { derivedStateOf { selectedSourceUids.size } }
val readOnlyPatchesVm: PatchesSelectorViewModel = koinViewModel(
key = "dashboard-patches-selector",
parameters = {
parametersOf(
SelectedApplicationInfo.PatchesSelector.ViewModelParams(
app = SelectedApp.Search(packageName = "", version = null),
currentSelection = null,
options = emptyMap(),
readOnly = true,
)
)
}
)
val firstLaunch by vm.prefs.firstLaunch.getAsState()
if (firstLaunch) AutoUpdatesDialog(vm::applyAutoUpdatePrefs)
@@ -138,7 +157,6 @@ fun DashboardScreen(
var showUpdateDialog by rememberSaveable { mutableStateOf(true) }
val showManagerUpdateDialogOnLaunch by vm.prefs.showManagerUpdateDialogOnLaunch.getAsState()
val availableUpdate = vm.updatedManagerVersion
if (showUpdateDialog && showManagerUpdateDialogOnLaunch && availableUpdate != null) {
AvailableUpdateDialog(
onDismiss = { showUpdateDialog = false },
@@ -155,31 +173,53 @@ fun DashboardScreen(
if (granted) onAppSelectorClick()
}
if (showAndroid11Dialog) Android11Dialog(
onDismissRequest = {
showAndroid11Dialog = false
},
onContinue = {
installAppsPermissionLauncher.launch(androidContext.packageName)
}
onDismissRequest = { showAndroid11Dialog = false },
onContinue = { installAppsPermissionLauncher.launch(androidContext.packageName) }
)
var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) }
if (showDeleteConfirmationDialog) {
ConfirmDialog(
onDismiss = { showDeleteConfirmationDialog = false },
onConfirm = vm::deleteSources,
onConfirm = {
vm.deleteSources(selectedSourceUids)
selectedSourceUids = emptySet()
sourceEditMode = false
},
title = stringResource(R.string.delete),
description = stringResource(R.string.patches_delete_multiple_dialog_description),
icon = Icons.Outlined.Delete
)
}
var aboutSourceUid by rememberSaveable { mutableStateOf<Int?>(null) }
aboutSourceUid?.let { uid ->
val src = sources.firstOrNull { it.uid == uid }
if (src != null) {
BundleInformationDialog(
src = src,
patchCount = patchCounts[src.uid] ?: 0,
onDismissRequest = { aboutSourceUid = null },
onDeleteRequest = {
vm.deleteSources(setOf(src.uid))
aboutSourceUid = null
},
onUpdate = { vm.updateSource(src.uid) },
)
}
}
Scaffold(
topBar = {
if (bundlesSelectable) {
val onPatchesTab = pagerState.currentPage == DashboardPage.BUNDLES.ordinal
if (onPatchesTab && sourceEditMode) {
BundleTopBar(
title = stringResource(R.string.patches_selected, selectedSourceCount),
onBackClick = vm::cancelSourceSelection,
onBackClick = {
sourceEditMode = false
selectedSourceUids = emptySet()
},
backIcon = {
Icon(
imageVector = Icons.Default.Close,
@@ -188,22 +228,10 @@ fun DashboardScreen(
},
actions = {
IconButton(
onClick = {
showDeleteConfirmationDialog = true
}
onClick = { showDeleteConfirmationDialog = true },
enabled = selectedSourceUids.isNotEmpty()
) {
Icon(
Icons.Outlined.DeleteOutline,
stringResource(R.string.delete)
)
}
IconButton(
onClick = vm::updateSources
) {
Icon(
Icons.Outlined.Refresh,
stringResource(R.string.refresh)
)
Icon(Icons.Outlined.DeleteOutline, stringResource(R.string.delete))
}
}
)
@@ -212,18 +240,13 @@ fun DashboardScreen(
title = stringResource(R.string.app_name),
actions = {
if (!vm.updatedManagerVersion.isNullOrEmpty()) {
IconButton(
onClick = onUpdateClick,
) {
BadgedBox(
badge = {
Badge(modifier = Modifier.size(6.dp))
}
) {
IconButton(onClick = onUpdateClick) {
BadgedBox(badge = { Badge(modifier = Modifier.size(6.dp)) }) {
Icon(Icons.Outlined.Update, stringResource(R.string.update))
}
}
}
IconButton(onClick = onAnnouncementsClick) {
BadgedBox(
badge = {
@@ -232,12 +255,10 @@ fun DashboardScreen(
}
}
) {
Icon(
Icons.Outlined.Notifications,
stringResource(R.string.announcements)
)
Icon(Icons.Outlined.Notifications, stringResource(R.string.announcements))
}
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Outlined.Settings, stringResource(R.string.settings))
}
@@ -247,18 +268,15 @@ fun DashboardScreen(
}
},
floatingActionButton = {
val onPatchesTab = pagerState.currentPage == DashboardPage.BUNDLES.ordinal
HapticFloatingActionButton(
onClick = {
vm.cancelSourceSelection()
when (pagerState.currentPage) {
DashboardPage.DASHBOARD.ordinal -> {
if (availablePatches < 1) {
androidContext.toast(resources.getString(R.string.no_patch_found))
composableScope.launch {
pagerState.animateScrollToPage(
DashboardPage.BUNDLES.ordinal
)
pagerState.animateScrollToPage(DashboardPage.BUNDLES.ordinal)
}
return@HapticFloatingActionButton
}
@@ -271,11 +289,22 @@ fun DashboardScreen(
}
DashboardPage.BUNDLES.ordinal -> {
showAddBundleDialog = true
if (sourceEditMode) {
showAddBundleDialog = true
} else {
sourceEditMode = true
selectedSourceUids = emptySet()
}
}
}
}
) { Icon(Icons.Default.Add, stringResource(R.string.add)) }
) {
val icon = when {
onPatchesTab && !sourceEditMode -> Icons.Outlined.Edit
else -> Icons.Default.Add
}
Icon(icon, stringResource(if (onPatchesTab && !sourceEditMode) R.string.edit else R.string.add))
}
}
) { paddingValues ->
Column(Modifier.padding(paddingValues)) {
@@ -286,7 +315,13 @@ fun DashboardScreen(
DashboardPage.entries.forEachIndexed { index, page ->
HapticTab(
selected = pagerState.currentPage == index,
onClick = { composableScope.launch { pagerState.animateScrollToPage(index) } },
onClick = {
composableScope.launch { pagerState.animateScrollToPage(index) }
if (index != DashboardPage.BUNDLES.ordinal) {
sourceEditMode = false
selectedSourceUids = emptySet()
}
},
text = { Text(stringResource(page.titleResId)) },
icon = { Icon(page.icon, null) },
selectedContentColor = MaterialTheme.colorScheme.primary,
@@ -371,24 +406,46 @@ fun DashboardScreen(
modifier = Modifier.fillMaxSize(),
pageContent = { index ->
when (DashboardPage.entries[index]) {
DashboardPage.DASHBOARD -> {
InstalledAppsScreen(
onAppClick = { onAppClick(it.currentPackageName) }
)
}
DashboardPage.DASHBOARD -> InstalledAppsScreen(
onAppClick = { onAppClick(it.currentPackageName) }
)
DashboardPage.BUNDLES -> {
BackHandler {
if (bundlesSelectable) vm.cancelSourceSelection() else composableScope.launch {
pagerState.animateScrollToPage(
DashboardPage.DASHBOARD.ordinal
)
if (sourceEditMode) {
sourceEditMode = false
selectedSourceUids = emptySet()
} else composableScope.launch {
pagerState.animateScrollToPage(DashboardPage.DASHBOARD.ordinal)
}
}
BundleListScreen(
eventsFlow = vm.bundleListEventsFlow,
setSelectedSourceCount = { selectedSourceCount = it }
PatchesSelectorScreen(
onSave = { _, _ -> },
onBackClick = {
if (sourceEditMode) {
sourceEditMode = false
selectedSourceUids = emptySet()
} else composableScope.launch {
pagerState.animateScrollToPage(DashboardPage.DASHBOARD.ordinal)
}
},
viewModel = readOnlyPatchesVm,
sourceEditMode = sourceEditMode,
selectedSourceUids = selectedSourceUids,
onToggleSourceSelection = { uid ->
selectedSourceUids = if (uid in selectedSourceUids) {
selectedSourceUids - uid
} else {
selectedSourceUids + uid
}
},
onSourceMenuAction = { uid, action ->
when (action) {
SourceMenuAction.MORE -> aboutSourceUid = uid
SourceMenuAction.REFRESH -> vm.updateSource(uid)
}
}
)
}
}

View File

@@ -10,34 +10,42 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListScope
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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Deselect
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SwapHoriz
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardColors
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -45,6 +53,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SecondaryScrollableTabRow
import androidx.compose.material3.SmallFloatingActionButton
@@ -54,6 +63,7 @@ import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
@@ -65,12 +75,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.patcher.patch.Option
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.component.AppTopBar
@@ -96,14 +110,24 @@ import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
enum class SourceMenuAction { MORE, REFRESH }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, FlowPreview::class)
@Composable
fun PatchesSelectorScreen(
onSave: (PatchSelection?, Options) -> Unit,
onBackClick: () -> Unit,
viewModel: PatchesSelectorViewModel
viewModel: PatchesSelectorViewModel,
sourceEditMode: Boolean = false,
selectedSourceUids: Set<Int> = emptySet(),
onToggleSourceSelection: ((Int) -> Unit)? = null,
onCurrentSourceChanged: ((Int?) -> Unit)? = null,
onSourceMenuAction: ((uid: Int, action: SourceMenuAction) -> Unit)? = null,
) {
val context = LocalContext.current
val readOnly = viewModel.readOnly
val bundles by viewModel.bundlesFlow.collectAsStateWithLifecycle(initialValue = emptyList())
val sourceStates by viewModel.sourceStateByUidFlow.collectAsStateWithLifecycle(initialValue = emptyMap())
val pagerState = rememberPagerState(
initialPage = 0,
initialPageOffsetFraction = 0f
@@ -117,6 +141,7 @@ fun PatchesSelectorScreen(
val (searchExpanded, setSearchExpanded) = rememberSaveable {
mutableStateOf(false)
}
var selectedReadonlyPackages by rememberSaveable { mutableStateOf(setOf<String>()) }
var showBottomSheet by rememberSaveable { mutableStateOf(false) }
val showSaveButton by remember {
derivedStateOf { viewModel.selectionIsValid(bundles) }
@@ -146,6 +171,57 @@ fun PatchesSelectorScreen(
}
}
fun PatchInfo.matchesReadonlyPackageFilter(): Boolean {
if (!readOnly || selectedReadonlyPackages.isEmpty()) return true
// Universal patches apply to any package and should remain visible.
val compatiblePackageList = compatiblePackages ?: return true
return compatiblePackageList.any { it.packageName in selectedReadonlyPackages }
}
fun List<PatchInfo>.applyReadonlyPackageFilter() =
if (!readOnly) this else filter(PatchInfo::matchesReadonlyPackageFilter)
LaunchedEffect(bundles, pagerState.currentPage) {
onCurrentSourceChanged?.invoke(bundles.getOrNull(pagerState.currentPage)?.uid)
}
fun LazyListScope.sourceStateItem(uid: Int) {
when (val state = sourceStates[uid]) {
is PatchBundleSource.State.Failed -> {
item(key = "source-error-$uid") {
ListHeader(title = stringResource(R.string.patches_error))
ListItem(
headlineContent = { Text(stringResource(R.string.patches_error_description)) },
supportingContent = { Text(state.throwable.message.orEmpty()) },
colors = transparentListItemColors
)
}
}
PatchBundleSource.State.Missing -> {
item(key = "source-missing-$uid") {
ListHeader(title = stringResource(R.string.patches_missing))
ListItem(
headlineContent = { Text(stringResource(R.string.patches_not_downloaded)) },
colors = transparentListItemColors
)
}
}
else -> Unit
}
}
fun LazyListScope.emptyPatchesItem(uid: Int) {
item(key = "source-empty-$uid") {
ListItem(
headlineContent = { Text(stringResource(R.string.no_patches_selected)) },
colors = transparentListItemColors
)
}
}
pendingScopeAction?.let { action ->
val currentBundle = bundles.getOrNull(pagerState.currentPage) ?: return@let
@@ -185,87 +261,150 @@ fun PatchesSelectorScreen(
style = MaterialTheme.typography.titleMedium
)
data class PackageFilterOption(
val packageName: String,
val displayName: String,
val patchCount: Int,
)
val packageOptions = remember(currentBundle?.uid, bundles) {
val pm = context.packageManager
// Count how many patches explicitly target each package.
val packagePatchCounts = mutableMapOf<String, Int>()
currentBundle
?.patches
.orEmpty()
.forEach { patch ->
patch.compatiblePackages
?.map { it.packageName }
?.distinct()
?.forEach { packageName ->
packagePatchCounts[packageName] = (packagePatchCounts[packageName] ?: 0) + 1
}
}
packagePatchCounts.map { (packageName, count) ->
val appInfo = runCatching { pm.getApplicationInfo(packageName, 0) }.getOrNull()
val appLabel = appInfo
?.let { runCatching { pm.getApplicationLabel(it).toString() }.getOrNull() }
.orEmpty()
.ifBlank { packageName }
PackageFilterOption(
packageName = packageName,
displayName = appLabel,
patchCount = count,
)
}.sortedWith(
compareByDescending<PackageFilterOption> { it.patchCount }
.thenBy { it.displayName.lowercase() }
)
}
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
CheckedFilterChip(
selected = viewModel.filter and SHOW_INCOMPATIBLE == 0,
onClick = { viewModel.toggleFlag(SHOW_INCOMPATIBLE) },
label = { Text(stringResource(R.string.this_version)) }
)
if (!readOnly) {
CheckedFilterChip(
selected = viewModel.filter and SHOW_INCOMPATIBLE == 0,
onClick = { viewModel.toggleFlag(SHOW_INCOMPATIBLE) },
label = { Text(stringResource(R.string.this_version)) }
)
}
CheckedFilterChip(
selected = viewModel.filter and SHOW_UNIVERSAL != 0,
onClick = { viewModel.toggleFlag(SHOW_UNIVERSAL) },
label = { Text(stringResource(R.string.universal)) },
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
Text(
text = stringResource(R.string.patch_selector_sheet_actions_title),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 8.dp)
)
fun guardedAction(action: () -> Unit) {
showBottomSheet = false
if (viewModel.selectionWarningEnabled) {
showSelectionWarning = true
} else {
action()
if (readOnly) {
packageOptions.forEach { packageOption ->
CheckedFilterChip(
selected = packageOption.packageName in selectedReadonlyPackages,
onClick = {
selectedReadonlyPackages = if (packageOption.packageName in selectedReadonlyPackages) {
selectedReadonlyPackages - packageOption.packageName
} else {
selectedReadonlyPackages + packageOption.packageName
}
},
label = {
Text("${packageOption.displayName} (${packageOption.patchCount})")
}
)
}
}
}
ActionItem(
icon = Icons.Outlined.Restore,
text = stringResource(R.string.restore_default_selection),
onClick = {
guardedAction {
executeScopedAction { uid ->
viewModel.restoreDefaults(uid)
}
if (!readOnly) {
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
Text(
text = stringResource(R.string.patch_selector_sheet_actions_title),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 8.dp)
)
fun guardedAction(action: () -> Unit) {
showBottomSheet = false
if (viewModel.selectionWarningEnabled) {
showSelectionWarning = true
} else {
action()
}
}
)
ActionItem(
icon = Icons.Outlined.Deselect,
text = stringResource(R.string.deselect_all),
onClick = {
guardedAction {
executeScopedAction { uid ->
viewModel.deselectAll(bundles, uid)
}
}
}
)
ActionItem(
icon = Icons.Outlined.SwapHoriz,
text = stringResource(R.string.invert_selection),
onClick = {
guardedAction {
executeScopedAction { uid ->
viewModel.invertSelection(bundles, uid)
}
}
}
)
if (bundles.size > 1 && currentBundle != null) {
ActionItem(
icon = Icons.Outlined.Deselect,
text = stringResource(R.string.deselect_all_except, currentBundle.name),
icon = Icons.Outlined.Restore,
text = stringResource(R.string.restore_default_selection),
onClick = {
guardedAction {
viewModel.deselectAllExcept(bundles, currentBundle.uid)
executeScopedAction { uid ->
viewModel.restoreDefaults(uid)
}
}
}
)
ActionItem(
icon = Icons.Outlined.Deselect,
text = stringResource(R.string.deselect_all),
onClick = {
guardedAction {
executeScopedAction { uid ->
viewModel.deselectAll(bundles, uid)
}
}
}
)
ActionItem(
icon = Icons.Outlined.SwapHoriz,
text = stringResource(R.string.invert_selection),
onClick = {
guardedAction {
executeScopedAction { uid ->
viewModel.invertSelection(bundles, uid)
}
}
}
)
if (bundles.size > 1 && currentBundle != null) {
ActionItem(
icon = Icons.Outlined.Deselect,
text = stringResource(R.string.deselect_all_except, currentBundle.name),
onClick = {
guardedAction {
viewModel.deselectAllExcept(bundles, currentBundle.uid)
}
}
)
}
}
}
}
@@ -293,7 +432,8 @@ fun PatchesSelectorScreen(
values = viewModel.getOptions(bundle, patch),
reset = { viewModel.resetOptions(bundle, patch) },
set = { key, value -> viewModel.setOption(bundle, patch, key, value) },
selectionWarningEnabled = viewModel.selectionWarningEnabled
selectionWarningEnabled = viewModel.selectionWarningEnabled,
readOnly = readOnly
)
}
@@ -308,6 +448,7 @@ fun PatchesSelectorScreen(
patches: List<PatchInfo>,
visible: Boolean,
compatible: Boolean,
section: String,
header: (@Composable () -> Unit)? = null
) {
if (patches.isNotEmpty() && visible) {
@@ -319,17 +460,14 @@ fun PatchesSelectorScreen(
items(
items = patches,
key = { it.name },
key = { "$section:${it.name}" },
contentType = { 1 }
) { patch ->
PatchItem(
patch = patch,
onOptionsDialog = { viewModel.optionsDialog = uid to patch },
selected = compatible && viewModel.isSelected(
uid,
patch
),
onToggle = {
selected = compatible && viewModel.isSelected(uid, patch),
onToggle = if (readOnly) ({}) else ({
when {
// Open incompatible dialog if the patch is not supported
!compatible -> viewModel.openIncompatibleDialog(patch)
@@ -344,8 +482,11 @@ fun PatchesSelectorScreen(
// Toggle the patch otherwise
else -> viewModel.togglePatch(uid, patch)
}
},
compatible = compatible
}),
compatible = compatible,
readOnly = readOnly,
packageName = viewModel.packageName,
showAllPackages = readOnly
)
}
}
@@ -410,26 +551,40 @@ fun PatchesSelectorScreen(
}
}
) {
val bundle = bundles[pagerState.currentPage]
val bundle = bundles.getOrNull(pagerState.currentPage)
if (bundle == null) {
ListItem(
headlineContent = { Text(stringResource(R.string.patches_error)) },
supportingContent = { Text(stringResource(R.string.patches_error_description)) },
colors = transparentListItemColors
)
return@SearchBar
}
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize()
) {
fun List<PatchInfo>.searched() = filter {
it.name.contains(query, true)
}
sourceStateItem(bundle.uid)
fun List<PatchInfo>.searched() =
applyReadonlyPackageFilter().filter { it.name.contains(query, true) }
val compatiblePatches = bundle.compatible.searched()
val universalPatches = bundle.universal.searched()
val incompatiblePatches = bundle.incompatible.searched()
patchList(
uid = bundle.uid,
patches = bundle.compatible.searched(),
patches = compatiblePatches,
visible = true,
compatible = true
compatible = true,
section = "compatible"
)
patchList(
uid = bundle.uid,
patches = bundle.universal.searched(),
patches = universalPatches,
visible = viewModel.filter and SHOW_UNIVERSAL != 0,
compatible = true
compatible = true,
section = "universal"
) {
ListHeader(
title = stringResource(R.string.universal_patches),
@@ -438,20 +593,25 @@ fun PatchesSelectorScreen(
patchList(
uid = bundle.uid,
patches = bundle.incompatible.searched(),
patches = incompatiblePatches,
visible = viewModel.filter and SHOW_INCOMPATIBLE != 0,
compatible = viewModel.allowIncompatiblePatches
compatible = viewModel.allowIncompatiblePatches,
section = "incompatible"
) {
ListHeader(
title = stringResource(R.string.incompatible_patches),
onHelpClick = { showIncompatiblePatchesDialog = true }
)
}
if (compatiblePatches.isEmpty() && universalPatches.isEmpty() && incompatiblePatches.isEmpty()) {
emptyPatchesItem(bundle.uid)
}
}
}
},
floatingActionButton = {
if (!showSaveButton) return@Scaffold
if (readOnly || !showSaveButton) return@Scaffold
AnimatedVisibility(
visible = !searchExpanded,
@@ -519,36 +679,48 @@ fun PatchesSelectorScreen(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
) {
bundles.forEachIndexed { index, bundle ->
var showTabMenu by remember { mutableStateOf(false) }
HapticTab(
selected = pagerState.currentPage == index,
onClick = {
composableScope.launch {
pagerState.animateScrollToPage(
index
)
if (sourceEditMode && readOnly) {
onToggleSourceSelection?.invoke(bundle.uid)
} else {
composableScope.launch {
pagerState.animateScrollToPage(index)
}
}
},
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
val selectionState = viewModel.getBundleSelectionState(bundle)
val toggleableState = when (selectionState) {
true -> ToggleableState.On
false -> ToggleableState.Off
null -> ToggleableState.Indeterminate
}
HapticTriStateCheckbox(
state = toggleableState,
onClick = {
when {
viewModel.selectionWarningEnabled -> showSelectionWarning = true
selectionState == false -> viewModel.restoreDefaults(bundle.uid)
else -> viewModel.deselectAll(bundles, bundle.uid)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
if (sourceEditMode && readOnly) {
HapticCheckbox(
checked = bundle.uid in selectedSourceUids,
onCheckedChange = { onToggleSourceSelection?.invoke(bundle.uid) }
)
} else {
val selectionState = viewModel.getBundleSelectionState(bundle)
val toggleableState = when (selectionState) {
true -> ToggleableState.On
false -> ToggleableState.Off
null -> ToggleableState.Indeterminate
}
)
Spacer(modifier = Modifier.width(4.dp))
HapticTriStateCheckbox(
state = toggleableState,
onClick = if (readOnly) ({}) else ({
when {
viewModel.selectionWarningEnabled -> showSelectionWarning = true
selectionState == false -> viewModel.restoreDefaults(bundle.uid)
else -> viewModel.deselectAll(bundles, bundle.uid)
}
}),
enabled = !readOnly
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
@@ -561,6 +733,42 @@ fun PatchesSelectorScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (onSourceMenuAction != null) {
Box {
IconButton(
onClick = { showTabMenu = true },
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Outlined.MoreVert,
contentDescription = stringResource(R.string.more),
modifier = Modifier.size(18.dp)
)
}
DropdownMenu(
expanded = showTabMenu,
onDismissRequest = { showTabMenu = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.refresh)) },
leadingIcon = { Icon(Icons.Outlined.Refresh, null) },
onClick = {
showTabMenu = false
onSourceMenuAction(bundle.uid, SourceMenuAction.REFRESH)
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.more)) },
leadingIcon = { Icon(Icons.Outlined.Info, null) },
onClick = {
showTabMenu = false
onSourceMenuAction(bundle.uid, SourceMenuAction.MORE)
}
)
}
}
}
}
},
selectedContentColor = MaterialTheme.colorScheme.primary,
@@ -582,17 +790,25 @@ fun PatchesSelectorScreen(
modifier = Modifier.fillMaxSize(),
state = patchLazyListStates[index]
) {
sourceStateItem(bundle.uid)
val compatiblePatches = bundle.compatible.applyReadonlyPackageFilter()
val universalPatches = bundle.universal.applyReadonlyPackageFilter()
val incompatiblePatches = bundle.incompatible.applyReadonlyPackageFilter()
patchList(
uid = bundle.uid,
patches = bundle.compatible,
patches = compatiblePatches,
visible = true,
compatible = true
compatible = true,
section = "compatible"
)
patchList(
uid = bundle.uid,
patches = bundle.universal,
patches = universalPatches,
visible = viewModel.filter and SHOW_UNIVERSAL != 0,
compatible = true
compatible = true,
section = "universal"
) {
ListHeader(
title = stringResource(R.string.universal_patches),
@@ -600,15 +816,20 @@ fun PatchesSelectorScreen(
}
patchList(
uid = bundle.uid,
patches = bundle.incompatible,
patches = incompatiblePatches,
visible = viewModel.filter and SHOW_INCOMPATIBLE != 0,
compatible = viewModel.allowIncompatiblePatches
compatible = viewModel.allowIncompatiblePatches,
section = "incompatible"
) {
ListHeader(
title = stringResource(R.string.incompatible_patches),
onHelpClick = { showIncompatiblePatchesDialog = true }
)
}
if (compatiblePatches.isEmpty() && universalPatches.isEmpty() && incompatiblePatches.isEmpty()) {
emptyPatchesItem(bundle.uid)
}
}
}
)
@@ -630,26 +851,41 @@ private fun UniversalPatchWarningDialog(
@Composable
private fun PatchItem(
patch: PatchInfo,
onOptionsDialog: () -> Unit,
onOptionsDialog: (() -> Unit)?,
selected: Boolean,
onToggle: () -> Unit,
compatible: Boolean = true
compatible: Boolean = true,
readOnly: Boolean = false,
packageName: String = "",
showAllPackages: Boolean = false
) = ListItem(
modifier = Modifier
.let { if (!compatible) it.alpha(0.5f) else it }
.clickable(onClick = onToggle)
.let { if (!readOnly) it.clickable(onClick = onToggle) else it }
.fillMaxSize(),
leadingContent = {
HapticCheckbox(
checked = selected,
onCheckedChange = { onToggle() },
enabled = compatible
enabled = compatible && !readOnly
)
},
headlineContent = { Text(patch.name) },
supportingContent = patch.description?.let { { Text(it) } },
supportingContent = {
Column {
patch.description?.let {
Text(it)
}
// Show compatibility information
PatchCompatibilityInfo(
patch = patch,
packageName = packageName,
showAllPackages = showAllPackages
)
}
},
trailingContent = {
if (patch.options?.isNotEmpty() == true) {
if (patch.options?.isNotEmpty() == true && onOptionsDialog != null) {
IconButton(onClick = onOptionsDialog, enabled = compatible) {
Icon(Icons.Outlined.Settings, null)
}
@@ -658,6 +894,114 @@ private fun PatchItem(
colors = transparentListItemColors
)
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun PatchCompatibilityInfo(
patch: PatchInfo,
packageName: String = "",
showAllPackages: Boolean = false
) {
if (patch.compatiblePackages == null) {
// Universal patch
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 8.dp)
) {
CompatibilityChip(text = "📦 ${stringResource(R.string.patches_view_any_package)}")
CompatibilityChip(text = "🎯 ${stringResource(R.string.patches_view_any_version)}")
}
} else {
// Specific package compatibility
if (showAllPackages) {
// Show all packages (readonly mode)
patch.compatiblePackages.forEach { compatiblePackage ->
val versions = compatiblePackage.versions.orEmpty().reversed()
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(top = 8.dp)
) {
CompatibilityChip(
modifier = Modifier.align(Alignment.CenterVertically),
text = "📦 ${compatiblePackage.packageName}"
)
if (versions.isEmpty()) {
CompatibilityChip(
modifier = Modifier.align(Alignment.CenterVertically),
text = "🎯 ${stringResource(R.string.patches_view_any_version)}"
)
} else {
versions.forEach { version ->
CompatibilityChip(
modifier = Modifier.align(Alignment.CenterVertically),
text = "🎯 $version"
)
}
}
}
}
} else {
// Show only selected app's compatible versions (patching mode)
val selectedPackageCompat = patch.compatiblePackages.find { it.packageName == packageName }
if (selectedPackageCompat != null) {
val versions = selectedPackageCompat.versions.orEmpty().reversed()
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(top = 8.dp)
) {
if (versions.isEmpty()) {
CompatibilityChip(
modifier = Modifier.align(Alignment.CenterVertically),
text = "🎯 ${stringResource(R.string.patches_view_any_version)}"
)
} else {
versions.forEach { version ->
CompatibilityChip(
modifier = Modifier.align(Alignment.CenterVertically),
text = "🎯 $version"
)
}
}
}
}
}
}
}
@Composable
private fun CompatibilityChip(
modifier: Modifier = Modifier,
text: String
) {
OutlinedCard(
modifier = modifier,
colors = CardColors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
disabledContainerColor = Color.Transparent,
disabledContentColor = MaterialTheme.colorScheme.onSurface
),
shape = RoundedCornerShape(8.0.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.20f))
) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text,
overflow = TextOverflow.Ellipsis,
softWrap = false,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
fun ListHeader(
title: String,
@@ -780,7 +1124,8 @@ private fun OptionsDialog(
reset: () -> Unit,
set: (String, Any?) -> Unit,
onDismissRequest: () -> Unit,
selectionWarningEnabled: Boolean
selectionWarningEnabled: Boolean,
readOnly: Boolean
) = FullscreenDialog(onDismissRequest = onDismissRequest) {
Scaffold(
topBar = {
@@ -788,8 +1133,10 @@ private fun OptionsDialog(
title = patch.name,
onBackClick = onDismissRequest,
actions = {
IconButton(onClick = reset) {
Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
if (!readOnly) {
IconButton(onClick = reset) {
Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
}
}
}
)
@@ -809,10 +1156,9 @@ private fun OptionsDialog(
OptionItem(
option = option as Option<Any>,
value = value,
setValue = {
set(name, it)
},
selectionWarningEnabled = selectionWarningEnabled
setValue = if (readOnly) ({}) else ({ set(name, it) }),
selectionWarningEnabled = selectionWarningEnabled,
readOnly = readOnly
)
}
}

View File

@@ -15,6 +15,7 @@ import androidx.lifecycle.viewModelScope
import app.revanced.manager.R
import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
import app.revanced.manager.domain.bundles.RemotePatchBundle
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.AnnouncementRepository
import app.revanced.manager.domain.repository.DownloaderRepository
@@ -23,14 +24,11 @@ import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.util.PM
import app.revanced.manager.util.uiSafe
import kotlin.time.Clock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -46,6 +44,12 @@ class DashboardViewModel(
) : ViewModel() {
val availablePatches =
patchBundleRepository.bundleInfoFlow.map { it.values.sumOf { bundle -> bundle.patches.size } }
val sources = patchBundleRepository.sources
val patchCounts = patchBundleRepository.patchCountsFlow
val sourceStates = patchBundleRepository.sources.map { sources ->
sources.associate { src -> src.uid to src.state }
}
private val contentResolver: ContentResolver = app.contentResolver
private val powerManager = app.getSystemService<PowerManager>()!!
@@ -132,14 +136,14 @@ class DashboardViewModel(
}
}
fun applyAutoUpdatePrefs(manager: Boolean, patches: Boolean) = viewModelScope.launch {
fun applyAutoUpdatePrefs(enabled: Boolean) = viewModelScope.launch {
prefs.firstLaunch.update(false)
prefs.managerAutoUpdates.update(manager)
prefs.managerAutoUpdates.update(enabled)
if (manager) checkForManagerUpdates()
if (enabled) {
checkForManagerUpdates()
if (patches) {
with(patchBundleRepository) {
sources
.first()
@@ -160,12 +164,52 @@ class DashboardViewModel(
fun updateSources() = sendEvent(BundleListViewModel.Event.UPDATE_SELECTED)
fun deleteSources() = sendEvent(BundleListViewModel.Event.DELETE_SELECTED)
fun refreshPatchSources() = viewModelScope.launch {
patchBundleRepository.reload()
}
fun deleteSources(uids: Set<Int>) = viewModelScope.launch {
if (uids.isEmpty()) return@launch
val selected = patchBundleRepository.sources.first().filter { it.uid in uids }
patchBundleRepository.remove(*selected.toTypedArray())
}
fun updateSource(uid: Int) = viewModelScope.launch {
val source = patchBundleRepository.sources.first()
.firstOrNull { it.uid == uid } as? RemotePatchBundle ?: return@launch
patchBundleRepository.update(source, showToast = true, force = true)
}
fun toggleSourceAutoUpdate(uid: Int, enabled: Boolean) = viewModelScope.launch {
val source = patchBundleRepository.sources.first()
.firstOrNull { it.uid == uid } as? RemotePatchBundle ?: return@launch
with(patchBundleRepository) {
source.setAutoUpdate(enabled)
}
patchBundleRepository.reload()
}
fun setDefaultSourcePrerelease(enabled: Boolean) = viewModelScope.launch {
prefs.usePatchesPrereleases.update(enabled)
val source = patchBundleRepository.sources.first()
.firstOrNull { it.uid == 0 } as? RemotePatchBundle ?: return@launch
patchBundleRepository.update(source, force = true)
patchBundleRepository.reload()
}
@SuppressLint("Recycle")
fun createLocalSource(patchBundle: Uri) = viewModelScope.launch {
patchBundleRepository.createLocal { contentResolver.openInputStream(patchBundle)!! }
patchBundleRepository.reload()
}
fun createRemoteSource(apiUrl: String, autoUpdate: Boolean) = viewModelScope.launch {
patchBundleRepository.createRemote(apiUrl, autoUpdate)
patchBundleRepository.reload()
}
}

View File

@@ -37,6 +37,7 @@ 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
@@ -50,9 +51,11 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
private val app: Application = get()
private val savedStateHandle: SavedStateHandle = get()
private val prefs: PreferencesManager = get()
private val patchBundleRepository: PatchBundleRepository = get()
private val packageName = input.app.packageName
val packageName = input.app.packageName
val appVersion = input.app.version
val readOnly = input.readOnly
var selectionWarningEnabled by mutableStateOf(true)
private set
@@ -61,8 +64,43 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
val allowIncompatiblePatches =
get<PreferencesManager>().disablePatchVersionCompatCheck.getBlocking()
val bundlesFlow =
get<PatchBundleRepository>().scopedBundleInfoFlow(packageName, input.app.version)
val sourceStateByUidFlow = patchBundleRepository.sources.map { sources ->
sources.associate { src -> src.uid to src.state }
}
val bundlesFlow = if (readOnly) {
combine(
patchBundleRepository.sources,
patchBundleRepository.bundleInfoFlow
) { sources, bundleInfoMap ->
sources.map { src ->
val bundleInfo = bundleInfoMap[src.uid]
val allPatches = bundleInfo?.patches?.distinctBy(PatchInfo::name).orEmpty()
val compatible = mutableListOf<PatchInfo>()
val incompatible = mutableListOf<PatchInfo>()
val universal = mutableListOf<PatchInfo>()
allPatches.forEach { patch ->
when {
patch.compatiblePackages == null -> universal
else -> compatible
}.add(patch)
}
PatchBundleInfo.Scoped(
name = bundleInfo?.name ?: src.name,
version = bundleInfo?.version ?: src.version,
uid = src.uid,
patches = allPatches,
compatible = compatible,
incompatible = incompatible,
universal = universal
)
}
}
} else {
patchBundleRepository.scopedBundleInfoFlow(packageName, input.app.version)
}
init {
viewModelScope.launch {

View File

@@ -77,10 +77,9 @@ Second \"item\" text"</string>
<string name="legacy_import_failed">Could not import legacy settings</string>
<string name="auto_updates_dialog_title">Configure updates</string>
<string name="auto_updates_dialog_description">Do you want ReVanced Manager to periodically check for updates for the following components?</string>
<string name="auto_updates_dialog_manager">ReVanced Manager</string>
<string name="auto_updates_dialog_patches">ReVanced Patches</string>
<string name="auto_updates_dialog_note">These settings can be changed later.</string>
<string name="auto_updates_dialog_description">Do you want ReVanced Manager to periodically check for updates?
This can be changed later in the settings.</string>
<string name="announcements_filter_tag">Filter by tag</string>
<string name="announcements_show_archived">Show archived</string>