From b28e9a15be806bf159ac85dcfdccb82c427057fd Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 8 Jan 2026 15:30:16 +0100 Subject: [PATCH] Add local APK to version and source selector --- .../java/app/revanced/manager/MainActivity.kt | 10 +- .../manager/ui/model/navigation/Nav.kt | 5 +- .../manager/ui/screen/AppSelectorScreen.kt | 3 +- .../ui/screen/SelectedAppInfoScreen.kt | 12 +- .../manager/ui/screen/SourceSelectorScreen.kt | 85 +++++++------- .../ui/screen/VersionSelectorScreen.kt | 3 + .../ui/viewmodel/AppSelectorViewModel.kt | 4 +- .../ui/viewmodel/SelectedAppInfoViewModel.kt | 14 ++- .../ui/viewmodel/SourceSelectorViewModel.kt | 108 +++++++++++++++--- .../ui/viewmodel/VersionSelectorViewModel.kt | 38 ++++-- 10 files changed, 200 insertions(+), 82 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index cabe2574..551b49e0 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -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, ) ) }, diff --git a/app/src/main/java/app/revanced/manager/ui/model/navigation/Nav.kt b/app/src/main/java/app/revanced/manager/ui/model/navigation/Nav.kt index c88611a5..2fe2d829 100644 --- a/app/src/main/java/app/revanced/manager/ui/model/navigation/Nav.kt +++ b/app/src/main/java/app/revanced/manager/ui/model/navigation/Nav.kt @@ -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 @@ -29,7 +28,7 @@ data object SelectedAppInfo : ComplexParameter @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 val packageName: String, val patchSelection: PatchSelection, val selectedVersion: SelectedVersion, + val localPath: String? = null, ) : Parcelable } @@ -64,6 +64,7 @@ data object SelectedAppInfo : ComplexParameter val packageName: String, val version: String?, val selectedSource: SelectedSource, + val localPath: String? = null, ) : Parcelable } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt index 5b775ba6..a9e8f4ab 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt @@ -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() ) { diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt index d5785222..50e8d39c 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt @@ -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 { diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SourceSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SourceSelectorScreen.kt index ffca89a4..c21b5ea9 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/SourceSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/SourceSelectorScreen.kt @@ -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, diff --git a/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt index aabca5ce..b7d94a82 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt @@ -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" diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt index f85895a5..ea397e32 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt @@ -38,7 +38,7 @@ class AppSelectorViewModel( } val appList = pm.appList - private val storageSelectionChannel = Channel>() + private val storageSelectionChannel = Channel>() 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) } } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt index a6375385..7fc17e2a 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt @@ -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()) { diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/SourceSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/SourceSelectorViewModel.kt index 32250ce1..6a85054f 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/SourceSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/SourceSelectorViewModel.kt @@ -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(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(null) + fun getPackageInfo(packageName: String) = pm.getPackageInfo(packageName) + + var installedSource by mutableStateOf(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 + ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt index e0db7584..719401b3 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt @@ -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>{ it.second } - .thenByDescending { it.first.version } - ) - } + private val _localVersion = MutableStateFlow(null) + val localVersion: StateFlow = _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>{ it.second } + .thenByDescending { it.first.version } + ) + } var installedAppVersion by mutableStateOf(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)