Add local APK to version and source selector

This commit is contained in:
Robert
2026-01-08 15:30:16 +01:00
parent 9fc2b4fdef
commit b28e9a15be
10 changed files with 200 additions and 82 deletions

View File

@@ -156,11 +156,11 @@ private fun ReVancedManager() {
SelectedAppInfo.ViewModelParams(packageName)
)
},
onStorageSelect = { packageName, localFile ->
onStorageSelect = { packageName, localPath ->
navController.navigateComplex(
SelectedAppInfo,
SelectedAppInfo.ViewModelParams(
packageName, localFile
packageName, localPath
)
)
},
@@ -233,23 +233,25 @@ private fun ReVancedManager() {
)
)
},
onVersionClick = { packageName, patchSelection, selectedVersion ->
onVersionClick = { packageName, patchSelection, selectedVersion, local ->
navController.navigateComplex(
SelectedAppInfo.VersionSelector,
SelectedAppInfo.VersionSelector.ViewModelParams(
packageName,
patchSelection,
selectedVersion,
local,
)
)
},
onSourceClick = { packageName, version, selectedSource ->
onSourceClick = { packageName, version, selectedSource, local ->
navController.navigateComplex(
SelectedAppInfo.SourceSelector,
SelectedAppInfo.SourceSelector.ViewModelParams(
packageName,
version,
selectedSource,
local,
)
)
},

View File

@@ -8,7 +8,6 @@ import app.revanced.manager.util.PatchSelection
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue
import kotlinx.serialization.Serializable
import java.io.File
interface ComplexParameter<T : Parcelable>
@@ -29,7 +28,7 @@ data object SelectedAppInfo : ComplexParameter<SelectedAppInfo.ViewModelParams>
@Parcelize
data class ViewModelParams(
val packageName: String,
val localFile: File? = null,
val localPath: String? = null,
val patches: PatchSelection? = null
) : Parcelable
@@ -54,6 +53,7 @@ data object SelectedAppInfo : ComplexParameter<SelectedAppInfo.ViewModelParams>
val packageName: String,
val patchSelection: PatchSelection,
val selectedVersion: SelectedVersion,
val localPath: String? = null,
) : Parcelable
}
@@ -64,6 +64,7 @@ data object SelectedAppInfo : ComplexParameter<SelectedAppInfo.ViewModelParams>
val packageName: String,
val version: String?,
val selectedSource: SelectedSource,
val localPath: String? = null,
) : Parcelable
}

View File

@@ -49,13 +49,12 @@ import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.EventEffect
import app.revanced.manager.util.transparentListItemColors
import org.koin.androidx.compose.koinViewModel
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSelectorScreen(
onSelect: (packageName: String) -> Unit,
onStorageSelect: (packageName: String, File) -> Unit,
onStorageSelect: (packageName: String, path: String) -> Unit,
onBackClick: () -> Unit,
vm: AppSelectorViewModel = koinViewModel()
) {

View File

@@ -53,8 +53,8 @@ fun SelectedAppInfoScreen(
onPatchSelectorClick: (packageName: String, version: String?, PatchSelection?, Options) -> Unit,
onRequiredOptions: (packageName: String, version: String?, PatchSelection?, Options) -> Unit,
onPatchClick: () -> Unit,
onVersionClick: (packageName: String, patchSelection: PatchSelection, selectedVersion: SelectedVersion) -> Unit,
onSourceClick: (packageName: String, version: String?, SelectedSource) -> Unit,
onVersionClick: (packageName: String, patchSelection: PatchSelection, selectedVersion: SelectedVersion, localPath: String?) -> Unit,
onSourceClick: (packageName: String, version: String?, SelectedSource, localPath: String?) -> Unit,
onBackClick: () -> Unit,
vm: SelectedAppInfoViewModel
) {
@@ -173,6 +173,7 @@ fun SelectedAppInfoScreen(
packageName,
fullPatchSelection,
selectedVersion,
vm.localPath,
)
},
)
@@ -193,7 +194,12 @@ fun SelectedAppInfoScreen(
PageItem(
R.string.apk_source_selector_item,
sourceDescription,
onClick = { onSourceClick(packageName, resolvedVersion, selectedSource) },
onClick = { onSourceClick(
packageName,
resolvedVersion,
selectedSource,
vm.localPath,
) },
)
error?.let {

View File

@@ -1,7 +1,6 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Save
@@ -14,13 +13,13 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.network.downloader.DownloaderPluginState
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.model.SelectedSource
import app.revanced.manager.ui.viewmodel.SourceSelectorViewModel
@@ -34,14 +33,9 @@ fun SourceSelectorScreen(
onSave: (source: SelectedSource) -> Unit,
viewModel: SourceSelectorViewModel,
) {
val context = LocalContext.current
val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(emptyList())
val plugins by viewModel.plugins.collectAsStateWithLifecycle(emptyList())
val version = viewModel.input.version
fun matchesVersion(appVersion: String) = version == null || version == appVersion
Scaffold(
topBar = {
AppTopBar(
@@ -51,13 +45,13 @@ fun SourceSelectorScreen(
},
floatingActionButton = {
HapticExtendedFloatingActionButton(
text = { Text("Save") },
text = { Text(stringResource(R.string.save)) },
icon = { Icon(Icons.Outlined.Save, null) },
onClick = { onSave(viewModel.selectedSource) },
)
}
) { paddingValues ->
LazyColumn (
LazyColumnWithScrollbar (
contentPadding = paddingValues,
) {
item {
@@ -76,58 +70,63 @@ fun SourceSelectorScreen(
)
}
viewModel.installedVersion?.let { installedVersion ->
viewModel.localApp?.let { option ->
item {
HorizontalDivider()
SourceOption(
isSelected = viewModel.selectedSource == SelectedSource.Installed,
onSelect = { viewModel.selectSource(SelectedSource.Installed) },
headlineContent = { Text(installedVersion) },
overlineContent = { Text("Installed") },
enabled = matchesVersion(installedVersion)
sourceOption = option,
isSelected = viewModel.selectedSource == option.source,
onSelect = viewModel::selectSource,
)
}
}
viewModel.installedSource?.let { option ->
item {
HorizontalDivider()
SourceOption(
sourceOption = option,
isSelected = viewModel.selectedSource == option.source,
onSelect = viewModel::selectSource,
)
}
}
if (downloadedApps.isNotEmpty()) item { HorizontalDivider() }
items(downloadedApps, key = { it.version }) { app ->
items(downloadedApps, key = { it.key }) { option ->
SourceOption(
isSelected = (viewModel.selectedSource as? SelectedSource.Downloaded)?.version == app.version,
onSelect = { viewModel.selectDownloadedApp(app) },
headlineContent = { Text(app.version) },
overlineContent = { Text("Downloaded") },
enabled = matchesVersion(app.version)
sourceOption = option,
isSelected = viewModel.selectedSource == option.source,
onSelect = viewModel::selectSource,
)
}
if (plugins.isNotEmpty()) item { HorizontalDivider() }
items(plugins, key = { it.first }) {
val packageInfo = remember {
viewModel.getPackageInfo(it.first)
}
val label = remember {
packageInfo?.applicationInfo?.loadLabel(context.packageManager).toString()
}
items(plugins, key = { it.key }) { option ->
SourceOption(
isSelected = viewModel.selectedSource == SelectedSource.Plugin(it.first),
onSelect = { viewModel.selectSource(SelectedSource.Plugin(it.first)) },
headlineContent = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) },
overlineContent = { Text("Plugin") },
enabled = it.second is DownloaderPluginState.Loaded,
supportingContent = (it.second as? DownloaderPluginState.Untrusted)?.let { {
Text("Not trusted")
} }
sourceOption = option,
isSelected = viewModel.selectedSource == option.source,
onSelect = viewModel::selectSource,
)
}
}
}
}
@Composable
private fun SourceOption(
sourceOption: SourceSelectorViewModel.SourceOption,
isSelected: Boolean,
onSelect: (SelectedSource) -> Unit,
) = SourceOption(
isSelected = isSelected,
onSelect = { onSelect(sourceOption.source) },
overlineContent = sourceOption.category?.let {{ Text(it) }},
headlineContent = { Text(sourceOption.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = sourceOption.disableReason?.let {{ Text(it.message) }},
enabled = sourceOption.disableReason == null,
)
@Composable
private fun SourceOption(
isSelected: Boolean,

View File

@@ -37,6 +37,7 @@ fun VersionSelectorScreen(
) {
val versions by viewModel.availableVersions.collectAsStateWithLifecycle(emptyList())
val downloadedVersions by viewModel.downloadedVersions.collectAsStateWithLifecycle(emptyList())
val localVersion by viewModel.localVersion.collectAsStateWithLifecycle(null)
Scaffold(
topBar = {
@@ -75,8 +76,10 @@ fun VersionSelectorScreen(
items(versions, key = { it.first.version }) { version ->
val isDownloaded = downloadedVersions.contains(version.first.version)
val isInstalled = viewModel.installedAppVersion == version.first.version
val isLocal = localVersion == version.first.version
val overlineText = when {
isLocal -> "Local"
isDownloaded && isInstalled -> "Downloaded, Installed"
isDownloaded -> "Downloaded"
isInstalled -> "Installed"

View File

@@ -38,7 +38,7 @@ class AppSelectorViewModel(
}
val appList = pm.appList
private val storageSelectionChannel = Channel<Pair<String, File>>()
private val storageSelectionChannel = Channel<Pair<String, String>>()
val storageSelectionFlow = storageSelectionChannel.receiveAsFlow()
val suggestedAppVersions = patchBundleRepository.suggestedVersions.flowOn(Dispatchers.Default)
@@ -73,7 +73,7 @@ class AppSelectorViewModel(
Files.copy(stream, toPath())
pm.getPackageInfo(this)?.let { packageInfo ->
Pair(packageInfo.packageName, this)
Pair(packageInfo.packageName, path)
}
}
}

View File

@@ -7,7 +7,6 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -47,6 +46,7 @@ import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import java.io.File
@OptIn(SavedStateHandleSaveableApi::class, PluginHostApi::class)
class SelectedAppInfoViewModel(
@@ -63,6 +63,7 @@ class SelectedAppInfoViewModel(
private val prefs: PreferencesManager = get()
val plugins = pluginsRepository.loadedPluginsFlow
val packageName = input.packageName
val localPath = input.localPath
private val persistConfiguration = input.patches == null
@@ -261,6 +262,17 @@ class SelectedAppInfoViewModel(
init {
invalidateSelectedAppInfo()
input.localPath?.let { local ->
viewModelScope.launch {
val packageInfo = pm.getPackageInfo(File(local))
_selectedVersion.value = SelectedVersion.Specific(
packageInfo?.versionName ?: return@launch
)
_selectedSource.value = SelectedSource.Local(local)
}
}
// Get the previous selection if customization is enabled.
viewModelScope.launch {
if (prefs.disableSelectionWarning.get()) {

View File

@@ -1,13 +1,14 @@
package app.revanced.manager.ui.viewmodel
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.data.room.apps.downloaded.DownloadedApp
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.DownloaderPluginRepository
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.network.downloader.DownloaderPluginState
import app.revanced.manager.ui.model.SelectedSource
import app.revanced.manager.ui.model.navigation.SelectedAppInfo
@@ -16,23 +17,17 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import java.io.File
class SourceSelectorViewModel(
val input: SelectedAppInfo.SourceSelector.ViewModelParams
) : ViewModel(), KoinComponent {
private val app: Application = get()
private val downloadedAppRepository: DownloadedAppRepository = get()
private val pluginRepository: DownloaderPluginRepository = get()
private val installedAppRepository: InstalledAppRepository = get()
private val pm: PM = get()
val downloadedApps = downloadedAppRepository.get(input.packageName)
.map { it.sortedByDescending { app -> app.version } }
val plugins = pluginRepository.pluginStates.map { plugins ->
plugins.toList().sortedByDescending { it.second is DownloaderPluginState.Loaded }
}
fun getPackageInfo(packageName: String) = pm.getPackageInfo(packageName)
var selectedSource by mutableStateOf(input.selectedSource)
private set
@@ -40,21 +35,102 @@ class SourceSelectorViewModel(
selectedSource = source
}
fun selectDownloadedApp(app: DownloadedApp) {
val file = downloadedAppRepository.getApkFileForApp(app)
var localApp by mutableStateOf<SourceOption?>(null)
private set
selectedSource = SelectedSource.Downloaded(file.path, app.version)
val downloadedApps = downloadedAppRepository.get(input.packageName)
.map { apps ->
apps.sortedByDescending { app -> app.version }
.map {
SourceOption(
source = SelectedSource.Downloaded(
path = downloadedAppRepository.getApkFileForApp(it).path,
version = it.version
),
title = it.version,
category = "Downloaded",
key = it.version,
disableReason = if (input.version != null && it.version != input.version) {
DisableReason.VERSION_NOT_MATCHING
} else null
)
}
}
val plugins = pluginRepository.pluginStates.map { plugins ->
plugins.toList().sortedByDescending { it.second is DownloaderPluginState.Loaded }
.map {
val packageInfo = pm.getPackageInfo(it.first)
val label = packageInfo?.applicationInfo?.loadLabel(app.packageManager)
?.toString()
SourceOption(
source = SelectedSource.Plugin(it.first),
title = label ?: it.first,
category = "Plugin",
key = it.first,
disableReason = when (it.second) {
is DownloaderPluginState.Loaded -> null
is DownloaderPluginState.Untrusted -> DisableReason.NOT_TRUSTED
is DownloaderPluginState.Failed -> DisableReason.FAILED_TO_LOAD
}
)
}
}
var installedVersion by mutableStateOf<String?>(null)
fun getPackageInfo(packageName: String) = pm.getPackageInfo(packageName)
var installedSource by mutableStateOf<SourceOption?>(null)
private set
init {
viewModelScope.launch {
val packageInfo = pm.getPackageInfo(input.packageName)
val packageInfo = pm.getPackageInfo(input.packageName) ?: return@launch
installedVersion = packageInfo?.versionName
val installedApp = installedAppRepository.get(input.packageName)
installedSource = SourceOption(
source = SelectedSource.Installed,
title = packageInfo.versionName.toString(),
category = "Installed",
key = input.packageName,
disableReason = when {
installedApp != null -> DisableReason.ALREADY_PATCHED
input.version != null && packageInfo.versionName != input.version -> DisableReason.VERSION_NOT_MATCHING
else -> null
}
)
}
input.localPath?.let { local ->
viewModelScope.launch {
val packageInfo = pm.getPackageInfo(File(local))
?: return@launch
localApp = SourceOption(
source = SelectedSource.Local(local),
title = packageInfo.versionName.toString(),
category = "Local",
key = "local",
disableReason = if (input.version != null && packageInfo.versionName != input.version) {
DisableReason.VERSION_NOT_MATCHING
} else null
)
}
}
}
enum class DisableReason(val message: String) {
VERSION_NOT_MATCHING("Does not match the selected version"),
ALREADY_PATCHED("Already patched"),
NOT_TRUSTED("Not trusted"),
FAILED_TO_LOAD("Failed to load"),
}
data class SourceOption(
val source: SelectedSource,
val title: String,
val category: String? = null,
val key: String,
val disableReason: DisableReason? = null
)
}

View File

@@ -12,10 +12,14 @@ import app.revanced.manager.ui.model.SelectedVersion
import app.revanced.manager.ui.model.navigation.SelectedAppInfo
import app.revanced.manager.util.PM
import app.revanced.manager.util.patchCount
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import java.io.File
class VersionSelectorViewModel(
val input: SelectedAppInfo.VersionSelector.ViewModelParams
@@ -32,15 +36,25 @@ class VersionSelectorViewModel(
apps.map { it.version }
}
val availableVersions = patchBundleRepository.suggestedVersions(input.packageName, input.patchSelection)
.map { versions ->
versions.orEmpty()
.map { (key, value) -> SelectedVersion.Specific(key) to patchCount - value }
.sortedWith(
compareBy<Pair<SelectedVersion.Specific, Int>>{ it.second }
.thenByDescending { it.first.version }
)
}
private val _localVersion = MutableStateFlow<String?>(null)
val localVersion: StateFlow<String?> = _localVersion
val availableVersions = combine(
patchBundleRepository.suggestedVersions(input.packageName, input.patchSelection),
_localVersion,
) { versions, local ->
versions.orEmpty()
.let { versions ->
local?.let {
versions.toMutableMap().also { it.putIfAbsent(local, 0) }
} ?: versions
}
.map { (key, value) -> SelectedVersion.Specific(key) to patchCount - value }
.sortedWith(
compareBy<Pair<SelectedVersion.Specific, Int>>{ it.second }
.thenByDescending { it.first.version }
)
}
var installedAppVersion by mutableStateOf<String?>(null)
@@ -54,6 +68,12 @@ class VersionSelectorViewModel(
installedAppVersion = currentApp?.versionName
}
input.localPath?.let { local ->
viewModelScope.launch {
val packageInfo = pm.getPackageInfo(File(local))
_localVersion.value = packageInfo?.versionName
}
}
}
var selectedVersion by mutableStateOf(input.selectedVersion)