feat: selected app info page (#1395)

This commit is contained in:
Ax333l 2023-10-19 21:44:50 +02:00 committed by GitHub
parent 7ba00cafd9
commit c3af6acb2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 779 additions and 309 deletions

View File

@ -19,16 +19,17 @@ import androidx.compose.runtime.setValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import app.revanced.manager.ui.component.AutoUpdatesDialog
import app.revanced.manager.ui.destination.Destination
import app.revanced.manager.ui.screen.AppInfoScreen
import app.revanced.manager.ui.screen.InstalledAppInfoScreen
import app.revanced.manager.ui.screen.AppSelectorScreen
import app.revanced.manager.ui.screen.DashboardScreen
import app.revanced.manager.ui.screen.InstallerScreen
import app.revanced.manager.ui.screen.PatchesSelectorScreen
import app.revanced.manager.ui.screen.SelectedAppInfoScreen
import app.revanced.manager.ui.screen.SettingsScreen
import app.revanced.manager.ui.screen.VersionSelectorScreen
import app.revanced.manager.ui.theme.ReVancedManagerTheme
import app.revanced.manager.ui.theme.Theme
import app.revanced.manager.ui.viewmodel.MainViewModel
import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast
import dev.olshevski.navigation.reimagined.AnimatedNavHost
@ -37,9 +38,9 @@ import dev.olshevski.navigation.reimagined.navigate
import dev.olshevski.navigation.reimagined.pop
import dev.olshevski.navigation.reimagined.popUpTo
import dev.olshevski.navigation.reimagined.rememberNavController
import org.koin.androidx.compose.getViewModel
import org.koin.androidx.compose.getViewModel as getComposeViewModel
import org.koin.androidx.viewmodel.ext.android.getViewModel as getAndroidViewModel
import org.koin.core.parameter.parametersOf
import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel
class MainActivity : ComponentActivity() {
@ExperimentalAnimationApi
@ -48,7 +49,7 @@ class MainActivity : ComponentActivity() {
installSplashScreen()
val vm: MainViewModel = getActivityViewModel()
val vm: MainViewModel = getAndroidViewModel()
setContent {
val theme by vm.prefs.theme.getAsState()
@ -102,7 +103,7 @@ class MainActivity : ComponentActivity() {
}
legacyActivityState = LegacyActivity.LAUNCHED
} else if (legacyActivityState == LegacyActivity.FAILED){
} else if (legacyActivityState == LegacyActivity.FAILED) {
AutoUpdatesDialog(vm::applyAutoUpdatePrefs)
}
}
@ -114,15 +115,26 @@ class MainActivity : ComponentActivity() {
is Destination.Dashboard -> DashboardScreen(
onSettingsClick = { navController.navigate(Destination.Settings) },
onAppSelectorClick = { navController.navigate(Destination.AppSelector) },
onAppClick = { installedApp -> navController.navigate(Destination.ApplicationInfo(installedApp)) }
onAppClick = { installedApp ->
navController.navigate(
Destination.InstalledApplicationInfo(
installedApp
)
)
}
)
is Destination.ApplicationInfo -> AppInfoScreen(
is Destination.InstalledApplicationInfo -> InstalledAppInfoScreen(
onPatchClick = { packageName, patchesSelection ->
navController.navigate(Destination.VersionSelector(packageName, patchesSelection))
navController.navigate(
Destination.VersionSelector(
packageName,
patchesSelection
)
)
},
onBackClick = { navController.pop() },
viewModel = getViewModel { parametersOf(destination.installedApp) }
viewModel = getComposeViewModel { parametersOf(destination.installedApp) }
)
is Destination.Settings -> SettingsScreen(
@ -131,7 +143,13 @@ class MainActivity : ComponentActivity() {
is Destination.AppSelector -> AppSelectorScreen(
onAppClick = { navController.navigate(Destination.VersionSelector(it)) },
onStorageClick = { navController.navigate(Destination.PatchesSelector(it)) },
onStorageClick = {
navController.navigate(
Destination.SelectedApplicationInfo(
it
)
)
},
onBackClick = { navController.pop() }
)
@ -139,32 +157,42 @@ class MainActivity : ComponentActivity() {
onBackClick = { navController.pop() },
onAppClick = { selectedApp ->
navController.navigate(
Destination.PatchesSelector(
Destination.SelectedApplicationInfo(
selectedApp,
destination.patchesSelection,
)
)
},
viewModel = getComposeViewModel {
parametersOf(
destination.packageName,
destination.patchesSelection
)
}
)
is Destination.SelectedApplicationInfo -> SelectedAppInfoScreen(
onPatchClick = { app, patches, options ->
navController.navigate(
Destination.Installer(
app, patches, options
)
)
},
onBackClick = navController::pop,
vm = getComposeViewModel {
parametersOf(
SelectedAppInfoViewModel.Params(
destination.selectedApp,
destination.patchesSelection
)
)
},
viewModel = getViewModel { parametersOf(destination.packageName, destination.patchesSelection) }
)
is Destination.PatchesSelector -> PatchesSelectorScreen(
onBackClick = { navController.pop() },
onPatchClick = { patches, options ->
navController.navigate(
Destination.Installer(
destination.selectedApp,
patches,
options
)
)
},
vm = getViewModel { parametersOf(destination) }
}
)
is Destination.Installer -> InstallerScreen(
onBackClick = { navController.popUpTo { it is Destination.Dashboard } },
vm = getViewModel { parametersOf(destination) }
vm = getComposeViewModel { parametersOf(destination) }
)
}
}

View File

@ -7,6 +7,7 @@ import org.koin.dsl.module
val viewModelModule = module {
viewModelOf(::MainViewModel)
viewModelOf(::DashboardViewModel)
viewModelOf(::SelectedAppInfoViewModel)
viewModelOf(::PatchesSelectorViewModel)
viewModelOf(::SettingsViewModel)
viewModelOf(::AdvancedSettingsViewModel)
@ -19,5 +20,5 @@ val viewModelModule = module {
viewModelOf(::ContributorViewModel)
viewModelOf(::DownloadsViewModel)
viewModelOf(::InstalledAppsViewModel)
viewModelOf(::AppInfoViewModel)
viewModelOf(::InstalledAppInfoViewModel)
}

View File

@ -0,0 +1,39 @@
package app.revanced.manager.ui.component
import android.content.pm.PackageInfo
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun AppInfo(appInfo: PackageInfo?, placeholderLabel: String? = null, extraContent: @Composable () -> Unit = {}) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
AppIcon(
appInfo,
contentDescription = null,
modifier = Modifier
.size(100.dp)
.padding(bottom = 5.dp)
)
AppLabel(
appInfo,
modifier = Modifier.padding(top = 16.dp),
style = MaterialTheme.typography.titleLarge,
defaultText = placeholderLabel
)
extraContent()
}
}

View File

@ -14,7 +14,7 @@ sealed interface Destination : Parcelable {
object Dashboard : Destination
@Parcelize
data class ApplicationInfo(val installedApp: InstalledApp) : Destination
data class InstalledApplicationInfo(val installedApp: InstalledApp) : Destination
@Parcelize
object AppSelector : Destination
@ -26,7 +26,7 @@ sealed interface Destination : Parcelable {
data class VersionSelector(val packageName: String, val patchesSelection: PatchesSelection? = null) : Destination
@Parcelize
data class PatchesSelector(val selectedApp: SelectedApp, val patchesSelection: PatchesSelection? = null) : Destination
data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchesSelection: PatchesSelection? = null) : Destination
@Parcelize
data class Installer(val selectedApp: SelectedApp, val selectedPatches: PatchesSelection, val options: @RawValue Options) : Destination

View File

@ -0,0 +1,19 @@
package app.revanced.manager.ui.destination
import android.os.Parcelable
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchesSelection
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue
sealed interface SelectedAppInfoDestination : Parcelable {
@Parcelize
data object Main : SelectedAppInfoDestination
@Parcelize
data class PatchesSelector(val app: SelectedApp, val currentSelection: PatchesSelection?, val options: @RawValue Options) : SelectedAppInfoDestination
@Parcelize
data object VersionSelector: SelectedAppInfoDestination
}

View File

@ -0,0 +1,82 @@
package app.revanced.manager.ui.model
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.util.PatchesSelection
import app.revanced.manager.util.flatMapLatestAndCombine
import kotlinx.coroutines.flow.map
/**
* A data class that contains patch bundle metadata for use by UI code.
*/
data class BundleInfo(
val name: String,
val uid: Int,
val supported: List<PatchInfo>,
val unsupported: List<PatchInfo>,
val universal: List<PatchInfo>
) {
val all = sequence {
yieldAll(supported)
yieldAll(unsupported)
yieldAll(universal)
}
val patchCount get() = supported.size + unsupported.size + universal.size
fun patchSequence(allowUnsupported: Boolean) = if (allowUnsupported) {
all
} else {
sequence {
yieldAll(supported)
yieldAll(universal)
}
}
companion object Extensions {
inline fun Iterable<BundleInfo>.toPatchSelection(allowUnsupported: Boolean, condition: (Int, PatchInfo) -> Boolean): PatchesSelection = this.associate { bundle ->
val patches =
bundle.patchSequence(allowUnsupported)
.mapNotNullTo(mutableSetOf()) { patch ->
patch.name.takeIf {
condition(
bundle.uid,
patch
)
}
}
bundle.uid to patches
}
fun PatchBundleRepository.bundleInfoFlow(packageName: String, version: String) =
sources.flatMapLatestAndCombine(
combiner = { it.filterNotNull() }
) { source ->
// Regenerate bundle information whenever this source updates.
source.state.map { state ->
val bundle = state.patchBundleOrNull() ?: return@map null
val supported = mutableListOf<PatchInfo>()
val unsupported = mutableListOf<PatchInfo>()
val universal = mutableListOf<PatchInfo>()
bundle.patches.filter { it.compatibleWith(packageName) }.forEach {
val targetList = when {
it.compatiblePackages == null -> universal
it.supportsVersion(
packageName,
version
) -> supported
else -> unsupported
}
targetList.add(it)
}
BundleInfo(source.name, source.uid, supported, unsupported, universal)
}
}
}
}

View File

@ -41,18 +41,19 @@ import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.ui.component.AppIcon
import app.revanced.manager.ui.component.AppInfo
import app.revanced.manager.ui.component.AppLabel
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.SegmentedButton
import app.revanced.manager.ui.viewmodel.AppInfoViewModel
import app.revanced.manager.ui.viewmodel.InstalledAppInfoViewModel
import app.revanced.manager.util.PatchesSelection
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppInfoScreen(
fun InstalledAppInfoScreen(
onPatchClick: (packageName: String, patchesSelection: PatchesSelection) -> Unit,
onBackClick: () -> Unit,
viewModel: AppInfoViewModel
viewModel: InstalledAppInfoViewModel
) {
SideEffect {
viewModel.onBackClick = onBackClick
@ -80,27 +81,8 @@ fun AppInfoScreen(
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
AppIcon(
viewModel.appInfo,
contentDescription = null,
modifier = Modifier
.size(100.dp)
.padding(bottom = 5.dp)
)
AppLabel(
viewModel.appInfo,
style = MaterialTheme.typography.titleLarge,
defaultText = null
)
Text(viewModel.installedApp.version, style = MaterialTheme.typography.bodySmall)
AppInfo(viewModel.appInfo) {
Text(viewModel.installedApp.version, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium)
if (viewModel.installedApp.installType == InstallType.ROOT) {
Text(

View File

@ -15,16 +15,15 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.HelpOutline
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip
@ -42,6 +41,7 @@ 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.mutableStateOf
import androidx.compose.runtime.remember
@ -57,7 +57,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import app.revanced.manager.R
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.patcher.patch.PatchInfo
@ -65,7 +64,6 @@ import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.Countdown
import app.revanced.manager.ui.component.patches.OptionItem
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.BaseSelectionMode
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_SUPPORTED
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNSUPPORTED
@ -77,7 +75,7 @@ import org.koin.compose.rememberKoinInject
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun PatchesSelectorScreen(
onPatchClick: (PatchesSelection, Options) -> Unit,
onSave: (PatchesSelection?, Options) -> Unit,
onBackClick: () -> Unit,
vm: PatchesSelectorViewModel
) {
@ -93,10 +91,10 @@ fun PatchesSelectorScreen(
mutableStateOf(null)
}
var showBottomSheet by rememberSaveable { mutableStateOf(false) }
var showPatchButton by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
showPatchButton = vm.isSelectionNotEmpty()
val showPatchButton by remember {
derivedStateOf { vm.selectionIsValid(bundles) }
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = {
@ -140,39 +138,12 @@ fun PatchesSelectorScreen(
)
}
}
Divider()
ListItem(
modifier = Modifier
.fillMaxWidth()
.clickable(
enabled = vm.hasPreviousSelection,
onClick = vm::switchBaseSelectionMode
),
leadingContent = {
Checkbox(
checked = vm.baseSelectionMode == BaseSelectionMode.PREVIOUS,
onCheckedChange = {
vm.switchBaseSelectionMode()
},
enabled = vm.hasPreviousSelection
)
},
headlineContent = {
Text(
"Use previous selection",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
)
}
}
if (vm.compatibleVersions.isNotEmpty())
UnsupportedDialog(
appVersion = vm.input.selectedApp.version,
appVersion = vm.appVersion,
supportedVersions = vm.compatibleVersions,
onDismissRequest = vm::dismissDialogs
)
@ -194,8 +165,6 @@ fun PatchesSelectorScreen(
)
}
val allowExperimental by vm.allowExperimental.getAsState()
fun LazyListScope.patchList(
uid: Int,
patches: List<PatchInfo>,
@ -227,17 +196,10 @@ fun PatchesSelectorScreen(
if (vm.selectionWarningEnabled) {
vm.pendingSelectionAction = {
vm.togglePatch(uid, patch)
vm.viewModelScope.launch {
showPatchButton = vm.isSelectionNotEmpty()
}
}
} else {
vm.togglePatch(uid, patch)
vm.viewModelScope.launch {
showPatchButton = vm.isSelectionNotEmpty()
}
}
},
supported = supported
)
@ -292,7 +254,7 @@ fun PatchesSelectorScreen(
)
}
if (!allowExperimental) return@LazyColumn
if (!vm.allowExperimental) return@LazyColumn
patchList(
uid = bundle.uid,
patches = bundle.unsupported.searched(),
@ -332,22 +294,19 @@ fun PatchesSelectorScreen(
)
},
floatingActionButton = {
if(showPatchButton) {
ExtendedFloatingActionButton(
text = {
Text(stringResource(R.string.patch))
},
icon = { Icon(Icons.Default.Build, null) },
onClick = {
// TODO: only allow this if all required options have been set.
composableScope.launch {
val selection = vm.getSelection()
vm.saveSelection(selection).join()
onPatchClick(selection, vm.getOptions())
}
if (!showPatchButton) return@Scaffold
ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.save)) },
icon = { Icon(Icons.Outlined.Save, null) },
onClick = {
// TODO: only allow this if all required options have been set.
composableScope.launch {
vm.saveSelection()
onSave(vm.getCustomSelection(), vm.getOptions())
}
)
}
}
)
}
) { paddingValues ->
Column(
@ -407,7 +366,7 @@ fun PatchesSelectorScreen(
uid = bundle.uid,
patches = bundle.unsupported,
filterFlag = SHOW_UNSUPPORTED,
supported = allowExperimental
supported = vm.allowExperimental
) {
ListHeader(
title = stringResource(R.string.unsupported_patches),

View File

@ -0,0 +1,218 @@
package app.revanced.manager.ui.screen
import android.content.pm.PackageInfo
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowRight
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
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.ui.component.AppInfo
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.destination.SelectedAppInfoDestination
import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchesSelection
import dev.olshevski.navigation.reimagined.AnimatedNavHost
import dev.olshevski.navigation.reimagined.NavBackHandler
import dev.olshevski.navigation.reimagined.navigate
import dev.olshevski.navigation.reimagined.pop
import dev.olshevski.navigation.reimagined.rememberNavController
import org.koin.androidx.compose.getViewModel
import org.koin.core.parameter.parametersOf
@Composable
fun SelectedAppInfoScreen(
onPatchClick: (SelectedApp, PatchesSelection, Options) -> Unit,
onBackClick: () -> Unit,
vm: SelectedAppInfoViewModel
) {
val bundles by remember(vm.selectedApp.packageName, vm.selectedApp.version) {
vm.bundlesRepo.bundleInfoFlow(vm.selectedApp.packageName, vm.selectedApp.version)
}.collectAsStateWithLifecycle(initialValue = emptyList())
val allowExperimental by vm.prefs.allowExperimental.getAsState()
val patches by remember {
derivedStateOf {
vm.getPatches(bundles, allowExperimental)
}
}
val selectedPatchCount by remember {
derivedStateOf {
patches.values.sumOf { it.size }
}
}
val availablePatchCount by remember {
derivedStateOf {
bundles.sumOf { it.patchCount }
}
}
val navController =
rememberNavController<SelectedAppInfoDestination>(startDestination = SelectedAppInfoDestination.Main)
NavBackHandler(controller = navController)
AnimatedNavHost(controller = navController) { destination ->
when (destination) {
is SelectedAppInfoDestination.Main -> SelectedAppInfoScreen(
onPatchClick = {
onPatchClick(
vm.selectedApp,
patches,
vm.patchOptions
)
},
onPatchSelectorClick = {
navController.navigate(
SelectedAppInfoDestination.PatchesSelector(
vm.selectedApp,
vm.getCustomPatches(
bundles,
allowExperimental
),
vm.patchOptions
)
)
},
onVersionSelectorClick = {
navController.navigate(SelectedAppInfoDestination.VersionSelector)
},
onBackClick = onBackClick,
availablePatchCount = availablePatchCount,
selectedPatchCount = selectedPatchCount,
packageName = vm.selectedApp.packageName,
version = vm.selectedApp.version,
packageInfo = vm.selectedAppInfo,
)
is SelectedAppInfoDestination.VersionSelector -> VersionSelectorScreen(
onBackClick = navController::pop,
onAppClick = {
vm.setSelectedApp(it)
navController.pop()
},
viewModel = getViewModel { parametersOf(vm.selectedApp.packageName) }
)
is SelectedAppInfoDestination.PatchesSelector -> PatchesSelectorScreen(
onSave = { patches, options ->
vm.setCustomPatches(patches)
vm.patchOptions = options
navController.pop()
},
onBackClick = navController::pop,
vm = getViewModel {
parametersOf(
PatchesSelectorViewModel.Params(
destination.app,
destination.currentSelection,
destination.options
)
)
}
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SelectedAppInfoScreen(
onPatchClick: () -> Unit,
onPatchSelectorClick: () -> Unit,
onVersionSelectorClick: () -> Unit,
onBackClick: () -> Unit,
availablePatchCount: Int,
selectedPatchCount: Int,
packageName: String,
version: String,
packageInfo: PackageInfo?,
) {
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.app_info),
onBackClick = onBackClick
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
AppInfo(packageInfo, placeholderLabel = packageName) {
Text(
stringResource(R.string.selected_app_meta, version, availablePatchCount),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
PageItem(R.string.patch, stringResource(R.string.patch_item_description), onPatchClick)
Text(
stringResource(R.string.advanced),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
)
PageItem(
R.string.patch_selector_item,
stringResource(R.string.patch_selector_item_description, selectedPatchCount),
onPatchSelectorClick
)
PageItem(
R.string.version_selector_item,
stringResource(R.string.version_selector_item_description, version),
onVersionSelectorClick
)
}
}
}
@Composable
private fun PageItem(@StringRes title: Int, description: String, onClick: () -> Unit) {
ListItem(
modifier = Modifier
.clickable(onClick = onClick)
.padding(start = 8.dp),
headlineContent = {
Text(
stringResource(title),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleLarge
)
},
supportingContent = {
Text(
description,
color = MaterialTheme.colorScheme.outline,
style = MaterialTheme.typography.bodyMedium
)
},
trailingContent = {
Icon(Icons.Outlined.ArrowRight, null)
}
)
}

View File

@ -30,7 +30,7 @@ import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class AppInfoViewModel(
class InstalledAppInfoViewModel(
val installedApp: InstalledApp
) : ViewModel(), KoinComponent {
private val app: Application by inject()

View File

@ -2,8 +2,8 @@ package app.revanced.manager.ui.viewmodel
import android.app.Application
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
@ -20,66 +20,46 @@ import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.destination.Destination
import app.revanced.manager.ui.model.BundleInfo
import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow
import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchesSelection
import app.revanced.manager.util.flatMapLatestAndCombine
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.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import kotlinx.collections.immutable.*
import kotlinx.coroutines.withContext
import java.util.Optional
@Stable
@OptIn(SavedStateHandleSaveableApi::class)
class PatchesSelectorViewModel(
val input: Destination.PatchesSelector
) : ViewModel(), KoinComponent {
class PatchesSelectorViewModel(input: Params) : ViewModel(), KoinComponent {
private val app: Application = get()
private val selectionRepository: PatchSelectionRepository = get()
private val savedStateHandle: SavedStateHandle = get()
private val prefs: PreferencesManager = get()
private val packageName = input.selectedApp.packageName
private val packageName = input.app.packageName
val appVersion = input.app.version
var pendingSelectionAction by mutableStateOf<(() -> Unit)?>(null)
// TODO: this should be hoisted to the parent screen
var selectionWarningEnabled by mutableStateOf(true)
private set
val allowExperimental = get<PreferencesManager>().allowExperimental
val bundlesFlow = get<PatchBundleRepository>().sources.flatMapLatestAndCombine(
combiner = { it.filterNotNull() }
) { source ->
// Regenerate bundle information whenever this source updates.
source.state.map { state ->
val bundle = state.patchBundleOrNull() ?: return@map null
val supported = mutableListOf<PatchInfo>()
val unsupported = mutableListOf<PatchInfo>()
val universal = mutableListOf<PatchInfo>()
bundle.patches.filter { it.compatibleWith(packageName) }.forEach {
val targetList = when {
it.compatiblePackages == null -> universal
it.supportsVersion(
input.selectedApp.packageName,
input.selectedApp.version
) -> supported
else -> unsupported
}
targetList.add(it)
}
BundleInfo(source.name, source.uid, bundle.patches, supported, unsupported, universal)
}
}
val allowExperimental = get<PreferencesManager>().allowExperimental.getBlocking()
val bundlesFlow =
get<PatchBundleRepository>().bundleInfoFlow(packageName, input.app.version)
init {
viewModelScope.launch {
@ -88,63 +68,28 @@ class PatchesSelectorViewModel(
return@launch
}
val experimental = allowExperimental.get()
fun BundleInfo.hasDefaultPatches(): Boolean {
return if (experimental) {
all.asSequence()
} else {
sequence {
yieldAll(supported)
yieldAll(universal)
}
}.any { it.include }
}
fun BundleInfo.hasDefaultPatches() = patchSequence(allowExperimental).any { it.include }
// Don't show the warning if there are no default patches.
selectionWarningEnabled = bundlesFlow.first().any(BundleInfo::hasDefaultPatches)
}
}
var baseSelectionMode by mutableStateOf(BaseSelectionMode.DEFAULT)
private set
private val previousPatchesSelection: SnapshotStateMap<Int, Set<String>> = mutableStateMapOf()
init {
viewModelScope.launch(Dispatchers.Default) { loadPreviousSelection() }
}
val hasPreviousSelection by derivedStateOf {
previousPatchesSelection.filterValues(Set<String>::isNotEmpty).isNotEmpty()
}
private var hasModifiedSelection = false
private var customPatchesSelection: PersistentPatchesSelection? by savedStateHandle.saveable(
key = "selection",
stateSaver = patchesSaver,
) {
mutableStateOf(input.currentSelection?.toPersistentPatchesSelection())
}
private val explicitPatchesSelection: SnapshotExplicitPatchesSelection by savedStateHandle.saveable(
saver = explicitPatchesSelectionSaver,
init = ::mutableStateMapOf
)
private val patchOptions: SnapshotOptions by savedStateHandle.saveable(
private val patchOptions: PersistentOptions by savedStateHandle.saveable(
saver = optionsSaver,
init = ::mutableStateMapOf
)
private val selectors by derivedStateOf<Array<Selector>> {
arrayOf(
// Patches that were explicitly selected
{ bundle, patch ->
explicitPatchesSelection[bundle]?.get(patch.name)
},
// The fallback selection.
when (baseSelectionMode) {
BaseSelectionMode.DEFAULT -> ({ _, patch -> patch.include })
BaseSelectionMode.PREVIOUS -> ({ bundle, patch ->
previousPatchesSelection[bundle]?.contains(patch.name) ?: false
})
}
)
) {
// Convert Options to PersistentOptions
input.options.mapValuesTo(mutableStateMapOf()) { (_, allPatches) ->
allPatches.mapValues { (_, options) -> options.toPersistentMap() }.toPersistentMap()
}
}
/**
@ -154,52 +99,39 @@ class PatchesSelectorViewModel(
val compatibleVersions = mutableStateListOf<String>()
var filter by mutableStateOf(SHOW_SUPPORTED or SHOW_UNIVERSAL or SHOW_UNSUPPORTED)
var filter by mutableIntStateOf(SHOW_SUPPORTED or SHOW_UNIVERSAL or SHOW_UNSUPPORTED)
private set
private suspend fun loadPreviousSelection() {
val selection = (input.patchesSelection ?: selectionRepository.getSelection(
packageName
)).mapValues { (_, value) -> value.toSet() }
private suspend fun generateDefaultSelection(): PersistentPatchesSelection {
val bundles = bundlesFlow.first()
val generatedSelection =
bundles.toPatchSelection(allowExperimental) { _, patch -> patch.include }
withContext(Dispatchers.Main) {
previousPatchesSelection.putAll(selection)
return generatedSelection.toPersistentPatchesSelection()
}
fun selectionIsValid(bundles: List<BundleInfo>) = bundles.any { bundle ->
bundle.patchSequence(allowExperimental).any { patch ->
isSelected(bundle.uid, patch)
}
}
fun switchBaseSelectionMode() = viewModelScope.launch {
baseSelectionMode = if (baseSelectionMode == BaseSelectionMode.DEFAULT) {
BaseSelectionMode.PREVIOUS
} else {
BaseSelectionMode.DEFAULT
}
}
private suspend fun patchesAvailable(bundle: BundleInfo): List<PatchInfo> {
val patches = (bundle.supported + bundle.universal).toMutableList()
val removeUnsupported = !allowExperimental.get()
if (!removeUnsupported) patches += bundle.unsupported
return patches
}
suspend fun isSelectionNotEmpty() =
bundlesFlow.first().any { bundle ->
patchesAvailable(bundle).any { patch ->
isSelected(bundle.uid, patch)
}
}
private fun getOrCreateSelection(bundle: Int) =
explicitPatchesSelection.getOrPut(bundle, ::mutableStateMapOf)
fun isSelected(bundle: Int, patch: PatchInfo) =
selectors.firstNotNullOf { fn -> fn(bundle, patch) }
fun togglePatch(bundle: Int, patch: PatchInfo) {
val patches = getOrCreateSelection(bundle)
fun isSelected(bundle: Int, patch: PatchInfo) = customPatchesSelection?.let { selection ->
selection[bundle]?.contains(patch.name) ?: false
} ?: patch.include
fun togglePatch(bundle: Int, patch: PatchInfo) = viewModelScope.launch {
hasModifiedSelection = true
patches[patch.name] = !isSelected(bundle, patch)
val selection = customPatchesSelection ?: generateDefaultSelection()
val newPatches = selection[bundle]?.let { patches ->
if (patch.name in patches)
patches.remove(patch.name)
else
patches.add(patch.name)
} ?: persistentSetOf(patch.name)
customPatchesSelection = selection.put(bundle, newPatches)
}
fun confirmSelectionWarning(dismissPermanently: Boolean) {
@ -221,46 +153,39 @@ class PatchesSelectorViewModel(
fun reset() {
patchOptions.clear()
baseSelectionMode = BaseSelectionMode.DEFAULT
explicitPatchesSelection.clear()
customPatchesSelection = null
hasModifiedSelection = false
app.toast(app.getString(R.string.patch_selection_reset_toast))
}
suspend fun getSelection(): PatchesSelection {
val bundles = bundlesFlow.first()
val removeUnsupported = !allowExperimental.get()
fun getCustomSelection(): PatchesSelection? {
// Convert persistent collections to standard hash collections because persistent collections are not parcelable.
return bundles.associate { bundle ->
val included =
bundle.all.filter { isSelected(bundle.uid, it) }.map { it.name }.toMutableSet()
if (removeUnsupported) {
val unsupported = bundle.unsupported.map { it.name }.toSet()
included.removeAll(unsupported)
}
bundle.uid to included
}
return customPatchesSelection?.mapValues { (_, v) -> v.toSet() }
}
suspend fun saveSelection(selection: PatchesSelection) =
viewModelScope.launch(Dispatchers.Default) {
when {
hasModifiedSelection -> selectionRepository.updateSelection(packageName, selection)
baseSelectionMode == BaseSelectionMode.DEFAULT -> selectionRepository.clearSelection(
packageName
)
fun getOptions(): Options {
// Convert the collection for the same reasons as in getCustomSelection()
else -> {}
}
}
return patchOptions.mapValues { (_, allPatches) -> allPatches.mapValues { (_, options) -> options.toMap() } }
}
suspend fun saveSelection() = withContext(Dispatchers.Default) {
customPatchesSelection?.let { selectionRepository.updateSelection(packageName, it) }
?: selectionRepository.clearSelection(packageName)
}
fun getOptions(): Options = patchOptions
fun getOptions(bundle: Int, patch: PatchInfo) = patchOptions[bundle]?.get(patch.name)
fun setOption(bundle: Int, patch: PatchInfo, key: String, value: Any?) {
patchOptions.getOrCreate(bundle).getOrCreate(patch.name)[key] = value
// All patches
val patchesToOpts = patchOptions.getOrElse(bundle, ::persistentMapOf)
// The key-value options of an individual patch
val patchToOpts = patchesToOpts
.getOrElse(patch.name, ::persistentMapOf)
.put(key, value)
patchOptions[bundle] = patchesToOpts.put(patch.name, patchToOpts)
}
fun resetOptions(bundle: Int, patch: PatchInfo) {
@ -274,7 +199,7 @@ class PatchesSelectorViewModel(
fun openUnsupportedDialog(unsupportedPatches: List<PatchInfo>) {
compatibleVersions.addAll(unsupportedPatches.flatMap { patch ->
patch.compatiblePackages?.find { it.packageName == input.selectedApp.packageName }?.versions.orEmpty()
patch.compatiblePackages?.find { it.packageName == packageName }?.versions.orEmpty()
})
}
@ -287,50 +212,28 @@ class PatchesSelectorViewModel(
const val SHOW_UNIVERSAL = 2 // 2^1
const val SHOW_UNSUPPORTED = 4 // 2^2
private fun <K, K2, V> SnapshotStateMap<K, SnapshotStateMap<K2, V>>.getOrCreate(key: K) =
getOrPut(key, ::mutableStateMapOf)
private val optionsSaver: Saver<SnapshotOptions, Options> = snapshotStateMapSaver(
private val optionsSaver: Saver<PersistentOptions, Options> = snapshotStateMapSaver(
// Patch name -> Options
valueSaver = snapshotStateMapSaver(
valueSaver = persistentMapSaver(
// Option key -> Option value
valueSaver = snapshotStateMapSaver()
valueSaver = persistentMapSaver()
)
)
private val explicitPatchesSelectionSaver: Saver<SnapshotExplicitPatchesSelection, ExplicitPatchesSelection> =
snapshotStateMapSaver(valueSaver = snapshotStateMapSaver())
private val patchesSaver: Saver<PersistentPatchesSelection?, Optional<PatchesSelection>> =
nullableSaver(persistentMapSaver(valueSaver = persistentSetSaver()))
}
/**
* An enum for controlling the behavior of the selector.
*/
enum class BaseSelectionMode {
/**
* Selection is determined by the [PatchInfo.include] field.
*/
DEFAULT,
/**
* Selection is determined by what the user selected previously.
* Any patch that is not part of the previous selection will be deselected.
*/
PREVIOUS
}
data class BundleInfo(
val name: String,
val uid: Int,
val all: List<PatchInfo>,
val supported: List<PatchInfo>,
val unsupported: List<PatchInfo>,
val universal: List<PatchInfo>
data class Params(
val app: SelectedApp,
val currentSelection: PatchesSelection?,
val options: Options,
)
}
private typealias Selector = (Int, PatchInfo) -> Boolean?
private typealias ExplicitPatchesSelection = Map<Int, Map<String, Boolean>>
// Versions of other types, but utilizing persistent/observable collection types.
private typealias PersistentOptions = SnapshotStateMap<Int, PersistentMap<String, PersistentMap<String, Any?>>>
private typealias PersistentPatchesSelection = PersistentMap<Int, PersistentSet<String>>
// Versions of other types, but utilizing observable collection types instead.
private typealias SnapshotOptions = SnapshotStateMap<Int, SnapshotStateMap<String, SnapshotStateMap<String, Any?>>>
private typealias SnapshotExplicitPatchesSelection = SnapshotStateMap<Int, SnapshotStateMap<String, Boolean>>
private fun PatchesSelection.toPersistentPatchesSelection(): PersistentPatchesSelection =
mapValues { (_, v) -> v.toPersistentSet() }.toPersistentMap()

View File

@ -0,0 +1,128 @@
package app.revanced.manager.ui.viewmodel
import android.content.pm.PackageInfo
import android.os.Parcelable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
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.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.ui.model.BundleInfo
import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options
import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchesSelection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
@OptIn(SavedStateHandleSaveableApi::class)
class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
val bundlesRepo: PatchBundleRepository = get()
private val selectionRepository: PatchSelectionRepository = get()
private val pm: PM = get()
private val savedStateHandle: SavedStateHandle = get()
val prefs: PreferencesManager = get()
var selectedApp by savedStateHandle.saveable {
mutableStateOf(input.app)
}
private set
var selectedAppInfo: PackageInfo? by mutableStateOf(null)
private set
init {
invalidateSelectedAppInfo()
}
var patchOptions: Options by savedStateHandle.saveable {
mutableStateOf(emptyMap())
}
private var selectionState by savedStateHandle.saveable {
if (input.patches != null) {
return@saveable mutableStateOf(SelectionState.Customized(input.patches))
}
val selection: MutableState<SelectionState> = mutableStateOf(SelectionState.Default)
// Get previous selection (if present).
viewModelScope.launch {
val previous = selectionRepository.getSelection(selectedApp.packageName)
if (previous.values.sumOf { it.size } == 0) {
return@launch
}
selection.value = SelectionState.Customized(previous)
}
selection
}
fun setSelectedApp(new: SelectedApp) {
selectedApp = new
invalidateSelectedAppInfo()
}
private fun invalidateSelectedAppInfo() = viewModelScope.launch {
val info = when (val app = selectedApp) {
is SelectedApp.Download -> null
is SelectedApp.Local -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.file) }
is SelectedApp.Installed -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.packageName) }
}
selectedAppInfo = info
}
fun getPatches(bundles: List<BundleInfo>, allowUnsupported: Boolean) =
selectionState.patches(bundles, allowUnsupported)
fun getCustomPatches(
bundles: List<BundleInfo>,
allowUnsupported: Boolean
): PatchesSelection? =
(selectionState as? SelectionState.Customized)?.patches(bundles, allowUnsupported)
fun setCustomPatches(selection: PatchesSelection?) {
selectionState = selection?.let(SelectionState::Customized) ?: SelectionState.Default
}
data class Params(
val app: SelectedApp,
val patches: PatchesSelection?,
)
}
private sealed interface SelectionState : Parcelable {
fun patches(bundles: List<BundleInfo>, allowUnsupported: Boolean): PatchesSelection
@Parcelize
data class Customized(val patchesSelection: PatchesSelection) : SelectionState {
override fun patches(bundles: List<BundleInfo>, allowUnsupported: Boolean) =
bundles.toPatchSelection(
allowUnsupported
) { uid, patch ->
patchesSelection[uid]?.contains(patch.name) ?: false
}
}
@Parcelize
data object Default : SelectionState {
override fun patches(bundles: List<BundleInfo>, allowUnsupported: Boolean) =
bundles.toPatchSelection(allowUnsupported) { _, patch -> patch.include }
}
}

View File

@ -98,7 +98,18 @@ class PM(
null
}
fun getPackageInfo(file: File): PackageInfo? = app.packageManager.getPackageArchiveInfo(file.absolutePath, 0)
fun getPackageInfo(file: File): PackageInfo? {
val path = file.absolutePath
val pkgInfo = app.packageManager.getPackageArchiveInfo(path, 0) ?: return null
// This is needed in order to load label and icon.
pkgInfo.applicationInfo.apply {
sourceDir = path
publicSourceDir = path
}
return pkgInfo
}
fun PackageInfo.label() = this.applicationInfo.loadLabel(app.packageManager).toString()

View File

@ -0,0 +1,22 @@
package app.revanced.manager.util.saver
import androidx.compose.runtime.saveable.Saver
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
/**
* Creates a saver that can save nullable versions of types that have custom savers.
*/
fun <Original : Any, Saveable : Any> nullableSaver(baseSaver: Saver<Original, Saveable>): Saver<Original?, Optional<Saveable>> =
Saver(
save = { value ->
with(baseSaver) {
save(value ?: return@Saver Optional.empty())
}?.let {
Optional.of(it)
}
},
restore = {
it.getOrNull()?.let(baseSaver::restore)
}
)

View File

@ -0,0 +1,69 @@
package app.revanced.manager.util.saver
import androidx.compose.runtime.saveable.Saver
import kotlinx.collections.immutable.*
/**
* Create a [Saver] for [PersistentList]s.
*/
fun <T> persistentListSaver() = Saver<PersistentList<T>, List<T>>(
save = {
it.toList()
},
restore = {
it.toPersistentList()
}
)
/**
* Create a [Saver] for [PersistentSet]s.
*/
fun <T> persistentSetSaver() = Saver<PersistentSet<T>, Set<T>>(
save = {
it.toSet()
},
restore = {
it.toPersistentSet()
}
)
/**
* Create a [Saver] for [PersistentMap]s.
*/
fun <K, V> persistentMapSaver() = Saver<PersistentMap<K, V>, Map<K, V>>(
save = {
it.toMap()
},
restore = {
it.toPersistentMap()
}
)
/**
* Create a saver for [PersistentMap]s with a custom [Saver] used for the values.
* Null values will not be saved by this [Saver].
*
* @param valueSaver The [Saver] used for the values of the [Map].
*/
fun <K, Original, Saveable : Any> persistentMapSaver(
valueSaver: Saver<Original, Saveable>
) = Saver<PersistentMap<K, Original>, Map<K, Saveable>>(
save = {
buildMap {
it.forEach { (key, value) ->
with(valueSaver) {
save(value)?.let {
this@buildMap[key] = it
}
}
}
}
},
restore = {
buildMap {
it.forEach { (key, value) ->
this[key] = valueSaver.restore(value) ?: return@forEach
}
}.toPersistentMap()
}
)

View File

@ -25,6 +25,15 @@
<string name="bundle_missing">Missing</string>
<string name="bundle_error">Error</string>
<string name="selected_app_meta">%1s • %2d available patches</string>
<string name="patch_item_description">Start patching the application</string>
<string name="patch_selector_item">Patch selection and options</string>
<string name="patch_selector_item_description">%d patches selected</string>
<string name="version_selector_item">Change version</string>
<string name="version_selector_item_description">%s selected</string>
<string name="legacy_import_failed">Could not import legacy settings</string>