Compare commits

..

2 Commits

Author SHA1 Message Date
semantic-release-bot
25d82e869c chore: Release v1.26.0-dev.17 [skip ci]
# app [1.26.0-dev.17](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.16...v1.26.0-dev.17) (2026-01-06)

### Bug Fixes

* allow updating patches on metered networks ([9d9a0e8](9d9a0e81db))
2026-01-06 21:45:09 +00:00
Ax333l
9d9a0e81db fix: allow updating patches on metered networks 2026-01-06 22:37:25 +01:00
34 changed files with 647 additions and 1086 deletions

View File

@@ -1,3 +1,10 @@
# app [1.26.0-dev.17](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.16...v1.26.0-dev.17) (2026-01-06)
### Bug Fixes
* allow updating patches on metered networks ([9d9a0e8](https://github.com/ReVanced/revanced-manager/commit/9d9a0e81dbc9e73e6e3181f6bea9cabb69e49ea8))
# app [1.26.0-dev.16](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.15...v1.26.0-dev.16) (2025-12-30)

View File

@@ -1 +1 @@
version = 1.26.0-dev.16
version = 1.26.0-dev.17

View File

@@ -30,7 +30,7 @@ import app.revanced.manager.ui.model.navigation.ComplexParameter
import app.revanced.manager.ui.model.navigation.Dashboard
import app.revanced.manager.ui.model.navigation.InstalledApplicationInfo
import app.revanced.manager.ui.model.navigation.Patcher
import app.revanced.manager.ui.model.navigation.SelectedAppInfo
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
import app.revanced.manager.ui.model.navigation.Settings
import app.revanced.manager.ui.model.navigation.Update
import app.revanced.manager.ui.screen.AppSelectorScreen
@@ -41,9 +41,7 @@ import app.revanced.manager.ui.screen.PatchesSelectorScreen
import app.revanced.manager.ui.screen.RequiredOptionsScreen
import app.revanced.manager.ui.screen.SelectedAppInfoScreen
import app.revanced.manager.ui.screen.SettingsScreen
import app.revanced.manager.ui.screen.SourceSelectorScreen
import app.revanced.manager.ui.screen.UpdateScreen
import app.revanced.manager.ui.screen.VersionSelectorScreen
import app.revanced.manager.ui.screen.settings.AboutSettingsScreen
import app.revanced.manager.ui.screen.settings.AdvancedSettingsScreen
import app.revanced.manager.ui.screen.settings.ContributorSettingsScreen
@@ -97,16 +95,23 @@ class MainActivity : ComponentActivity() {
dynamicColor = dynamicColor,
pureBlackTheme = pureBlackTheme
) {
ReVancedManager()
ReVancedManager(vm)
}
}
}
}
@Composable
private fun ReVancedManager() {
private fun ReVancedManager(vm: MainViewModel) {
val navController = rememberNavController()
EventEffect(vm.appSelectFlow) { app ->
navController.navigateComplex(
SelectedApplicationInfo,
SelectedApplicationInfo.ViewModelParams(app)
)
}
NavHost(
navController = navController,
startDestination = Dashboard,
@@ -137,12 +142,7 @@ private fun ReVancedManager() {
val data = it.toRoute<InstalledApplicationInfo>()
InstalledAppInfoScreen(
onPatchClick = { packageName ->
navController.navigateComplex(
SelectedAppInfo,
SelectedAppInfo.ViewModelParams(packageName)
)
},
onPatchClick = vm::selectApp,
onBackClick = navController::popBackStack,
viewModel = koinViewModel { parametersOf(data.packageName) }
)
@@ -150,20 +150,8 @@ private fun ReVancedManager() {
composable<AppSelector> {
AppSelectorScreen(
onSelect = { packageName ->
navController.navigateComplex(
SelectedAppInfo,
SelectedAppInfo.ViewModelParams(packageName)
)
},
onStorageSelect = { packageName, localPath ->
navController.navigateComplex(
SelectedAppInfo,
SelectedAppInfo.ViewModelParams(
packageName, localPath
)
)
},
onSelect = vm::selectApp,
onStorageSelect = vm::selectApp,
onBackClick = navController::popBackStack
)
}
@@ -191,11 +179,11 @@ private fun ReVancedManager() {
)
}
navigation<SelectedAppInfo>(startDestination = SelectedAppInfo.Main) {
composable<SelectedAppInfo.Main> {
navigation<SelectedApplicationInfo>(startDestination = SelectedApplicationInfo.Main) {
composable<SelectedApplicationInfo.Main> {
val parentBackStackEntry = navController.navGraphEntry(it)
val data =
parentBackStackEntry.getComplexArg<SelectedAppInfo.ViewModelParams>()
parentBackStackEntry.getComplexArg<SelectedApplicationInfo.ViewModelParams>()
val viewModel =
koinNavViewModel<SelectedAppInfoViewModel>(viewModelStoreOwner = parentBackStackEntry) {
parametersOf(data)
@@ -211,47 +199,23 @@ private fun ReVancedManager() {
)
}
},
onPatchSelectorClick = { packageName, version, patchSelection, options ->
onPatchSelectorClick = { app, patches, options ->
navController.navigateComplex(
SelectedAppInfo.PatchesSelector,
SelectedAppInfo.PatchesSelector.ViewModelParams(
packageName,
version,
patchSelection,
options,
SelectedApplicationInfo.PatchesSelector,
SelectedApplicationInfo.PatchesSelector.ViewModelParams(
app,
patches,
options
)
)
},
onRequiredOptions = { packageName, version, patchSelection, options ->
onRequiredOptions = { app, patches, options ->
navController.navigateComplex(
SelectedAppInfo.RequiredOptions,
SelectedAppInfo.PatchesSelector.ViewModelParams(
packageName,
version,
patchSelection,
options,
)
)
},
onVersionClick = { packageName, patchSelection, selectedVersion, local ->
navController.navigateComplex(
SelectedAppInfo.VersionSelector,
SelectedAppInfo.VersionSelector.ViewModelParams(
packageName,
patchSelection,
selectedVersion,
local,
)
)
},
onSourceClick = { packageName, version, selectedSource, local ->
navController.navigateComplex(
SelectedAppInfo.SourceSelector,
SelectedAppInfo.SourceSelector.ViewModelParams(
packageName,
version,
selectedSource,
local,
SelectedApplicationInfo.RequiredOptions,
SelectedApplicationInfo.PatchesSelector.ViewModelParams(
app,
patches,
options
)
)
},
@@ -259,9 +223,9 @@ private fun ReVancedManager() {
)
}
composable<SelectedAppInfo.PatchesSelector> {
composable<SelectedApplicationInfo.PatchesSelector> {
val data =
it.getComplexArg<SelectedAppInfo.PatchesSelector.ViewModelParams>()
it.getComplexArg<SelectedApplicationInfo.PatchesSelector.ViewModelParams>()
val selectedAppInfoVm = koinNavViewModel<SelectedAppInfoViewModel>(
viewModelStoreOwner = navController.navGraphEntry(it)
)
@@ -276,43 +240,9 @@ private fun ReVancedManager() {
)
}
composable<SelectedAppInfo.VersionSelector> {
composable<SelectedApplicationInfo.RequiredOptions> {
val data =
it.getComplexArg<SelectedAppInfo.VersionSelector.ViewModelParams>()
val selectedAppInfoVm = koinNavViewModel<SelectedAppInfoViewModel>(
viewModelStoreOwner = navController.navGraphEntry(it)
)
VersionSelectorScreen(
onBackClick = navController::popBackStack,
onSave = { version ->
selectedAppInfoVm.updateVersion(version)
navController.popBackStack()
},
viewModel = koinViewModel { parametersOf(data) }
)
}
composable<SelectedAppInfo.SourceSelector> {
val data =
it.getComplexArg<SelectedAppInfo.SourceSelector.ViewModelParams>()
val selectedAppInfoVm = koinNavViewModel<SelectedAppInfoViewModel>(
viewModelStoreOwner = navController.navGraphEntry(it)
)
SourceSelectorScreen(
onBackClick = navController::popBackStack,
onSave = { source ->
selectedAppInfoVm.updateSource(source)
navController.popBackStack()
},
viewModel = koinViewModel { parametersOf(data) }
)
}
composable<SelectedAppInfo.RequiredOptions> {
val data =
it.getComplexArg<SelectedAppInfo.PatchesSelector.ViewModelParams>()
it.getComplexArg<SelectedApplicationInfo.PatchesSelector.ViewModelParams>()
val selectedAppInfoVm = koinNavViewModel<SelectedAppInfoViewModel>(
viewModelStoreOwner = navController.navGraphEntry(it)
)

View File

@@ -15,5 +15,5 @@ class NetworkInfo(app: Application) {
/**
* Returns true if it is safe to download large files.
*/
fun isSafe() = isConnected() && isUnmetered()
fun isSafe(ignoreMetered: Boolean) = isConnected() && (ignoreMetered || isUnmetered())
}

View File

@@ -2,6 +2,7 @@ package app.revanced.manager.data.room.apps.downloaded
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@@ -11,9 +12,6 @@ interface DownloadedAppDao {
@Query("SELECT * FROM downloaded_app")
fun getAllApps(): Flow<List<DownloadedApp>>
@Query("SELECT * FROM downloaded_app WHERE package_name = :packageName")
fun get(packageName: String): Flow<List<DownloadedApp>>
@Query("SELECT * FROM downloaded_app WHERE package_name = :packageName AND version = :version")
suspend fun get(packageName: String, version: String): DownloadedApp?

View File

@@ -24,6 +24,4 @@ val viewModelModule = module {
viewModelOf(::InstalledAppInfoViewModel)
viewModelOf(::UpdatesSettingsViewModel)
viewModelOf(::BundleListViewModel)
viewModelOf(::VersionSelectorViewModel)
viewModelOf(::SourceSelectorViewModel)
}

View File

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

View File

@@ -30,8 +30,6 @@ class DownloadedAppRepository(
fun getAll() = dao.getAllApps().distinctUntilChanged()
fun get(packageName: String) = dao.get(packageName)
fun getApkFileForApp(app: DownloadedApp): File =
getApkFileForDir(dir.resolve(app.directory))

View File

@@ -26,7 +26,6 @@ import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.patcher.patch.PatchBundle
import app.revanced.manager.patcher.patch.PatchBundleInfo
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast
@@ -75,17 +74,6 @@ class PatchBundleRepository(
val patchCountsFlow = bundleInfoFlow.map { it.mapValues { (_, info) -> info.patches.size } }
fun suggestedVersions(packageName: String, patchSelection: PatchSelection) =
bundleInfoFlow.map {
val allPatches = patchSelection.flatMap { (uid, patches) ->
val bundle = it[uid] ?: return@flatMap emptyList()
bundle.patches.filter { patch -> patches.contains(patch.name) }
.map(PatchInfo::toPatcherPatch)
}.toSet()
allPatches.mostCommonCompatibleVersions(countUnusedPatches = true)[packageName]
}
val suggestedVersions = bundleInfoFlow.map {
val allPatches =
it.values.flatMap { bundle -> bundle.patches.map(PatchInfo::toPatcherPatch) }.toSet()
@@ -298,28 +286,29 @@ class PatchBundleRepository(
State(sources.toPersistentMap(), info.toPersistentMap())
}
suspend fun createLocal(createStream: suspend () -> InputStream) = dispatchAction("Add bundle") {
with(createEntity("", SourceInfo.Local).load() as LocalPatchBundle) {
try {
createStream().use { patches -> replace(patches) }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(tag, "Got exception while importing bundle", e)
withContext(Dispatchers.Main) {
app.toast(app.getString(R.string.patches_replace_fail, e.simpleMessage()))
suspend fun createLocal(createStream: suspend () -> InputStream) =
dispatchAction("Add bundle") {
with(createEntity("", SourceInfo.Local).load() as LocalPatchBundle) {
try {
createStream().use { patches -> replace(patches) }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(tag, "Got exception while importing bundle", e)
withContext(Dispatchers.Main) {
app.toast(app.getString(R.string.patches_replace_fail, e.simpleMessage()))
}
deleteLocalFile()
}
deleteLocalFile()
}
}
doReload()
}
doReload()
}
suspend fun createRemote(url: String, autoUpdate: Boolean) =
dispatchAction("Add bundle ($url)") { state ->
val src = createEntity("", SourceInfo.from(url), autoUpdate).load() as RemotePatchBundle
update(src)
update(src, force = true)
state.copy(sources = state.sources.put(src.uid, src))
}
@@ -341,32 +330,38 @@ class PatchBundleRepository(
state.copy(sources = state.sources.put(uid, newSrc))
}
suspend fun update(vararg sources: RemotePatchBundle, showToast: Boolean = false) {
suspend fun update(
vararg sources: RemotePatchBundle,
showToast: Boolean = false,
force: Boolean = false
) {
val uids = sources.map { it.uid }.toSet()
store.dispatch(Update(showToast = showToast) { it.uid in uids })
store.dispatch(Update(showToast = showToast, force = force) { it.uid in uids })
}
suspend fun redownloadRemoteBundles() = store.dispatch(Update(force = true))
suspend fun redownloadRemoteBundles() = store.dispatch(Update(force = true, redownload = true))
/**
* Updates all bundles that should be automatically updated.
*/
suspend fun updateCheck() = store.dispatch(Update { it.autoUpdate })
suspend fun updateCheck() =
store.dispatch(Update(force = prefs.allowMeteredNetworks.get()) { it.autoUpdate })
private inner class Update(
private val force: Boolean = false,
private val redownload: Boolean = false,
private val showToast: Boolean = false,
private val predicate: (bundle: RemotePatchBundle) -> Boolean = { true },
) : Action<State> {
private suspend fun toast(@StringRes id: Int, vararg args: Any?) =
withContext(Dispatchers.Main) { app.toast(app.getString(id, *args)) }
override fun toString() = if (force) "Redownload remote bundles" else "Update check"
override fun toString() = if (redownload) "Redownload remote bundles" else "Update check"
override suspend fun ActionContext.execute(
current: State
) = coroutineScope {
if (!networkInfo.isSafe()) {
if (!networkInfo.isSafe(force)) {
Log.d(tag, "Skipping update check because the network is down or metered.")
return@coroutineScope current
}
@@ -379,7 +374,7 @@ class PatchBundleRepository(
Log.d(tag, "Updating patch bundle: ${it.name}")
val newVersion = with(it) {
if (force) downloadLatest() else update()
if (redownload) downloadLatest() else update()
} ?: return@async null
it to newVersion

View File

@@ -16,7 +16,7 @@ class PatchSelectionRepository(db: AppDatabase) {
packageName = packageName
).also { dao.createSelection(it) }.uid
suspend fun getSelection(packageName: String): app.revanced.manager.util.PatchSelection =
suspend fun getSelection(packageName: String): Map<Int, Set<String>> =
dao.getSelectedPatches(packageName).mapValues { it.value.toSet() }
suspend fun updateSelection(packageName: String, selection: Map<Int, Set<String>>) =

View File

@@ -40,7 +40,7 @@ data class PatchInfo(
if (pkg.packageName != packageName) return@any false
if (pkg.versions == null) return@any true
versionName == null || versionName in pkg.versions
versionName != null && versionName in pkg.versions
}
}

View File

@@ -39,7 +39,7 @@ import app.revanced.manager.patcher.toRemoteError
import app.revanced.manager.plugin.downloader.GetScope
import app.revanced.manager.plugin.downloader.PluginHostApi
import app.revanced.manager.plugin.downloader.UserInteractionException
import app.revanced.manager.ui.model.SelectedSource
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options
import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection
@@ -67,9 +67,7 @@ class PatcherWorker(
private val rootInstaller: RootInstaller by inject()
class Args(
val packageName: String,
val version: String?,
val source: SelectedSource,
val input: SelectedApp,
val output: String,
val selectedPatches: PatchSelection,
val options: Options,
@@ -77,7 +75,9 @@ class PatcherWorker(
val handleStartActivityRequest: suspend (LoadedDownloaderPlugin, Intent) -> ActivityResult,
val setInputFile: suspend (File) -> Unit,
val onEvent: (ProgressEvent) -> Unit,
)
) {
val packageName get() = input.packageName
}
override suspend fun getForegroundInfo() =
ForegroundInfo(
@@ -142,7 +142,7 @@ class PatcherWorker(
val patchedApk = fs.tempDir.resolve("patched.apk")
return try {
if (args.source is SelectedSource.Installed) {
if (args.input is SelectedApp.Installed) {
installedAppRepository.get(args.packageName)?.let {
if (it.installType == InstallType.MOUNT) {
rootInstaller.unmount(args.packageName)
@@ -155,23 +155,32 @@ class PatcherWorker(
plugin,
data,
args.packageName,
args.version,
args.input.version,
prefs.suggestedVersionSafeguard.get(),
!prefs.disablePatchVersionCompatCheck.get(),
) { progress ->
args.onEvent(
ProgressEvent.Progress(
stepId = StepId.DownloadAPK,
current = progress.first,
total = progress.second
onDownload = { progress ->
args.onEvent(
ProgressEvent.Progress(
stepId = StepId.DownloadAPK,
current = progress.first,
total = progress.second
)
)
)
}.also { args.setInputFile(it) }
}
).also { args.setInputFile(it) }
val inputFile = when (val source = args.source) {
is SelectedSource.Auto -> throw Exception("Auto source is not supported in worker.")
val inputFile = when (val selectedApp = args.input) {
is SelectedApp.Download -> {
runStep(StepId.DownloadAPK, args.onEvent) {
val (plugin, data) = downloaderPluginRepository.unwrapParceledData(
selectedApp.data
)
is SelectedSource.Plugin -> {
download(plugin, data)
}
}
is SelectedApp.Search -> {
runStep(StepId.DownloadAPK, args.onEvent) {
downloaderPluginRepository.loadedPluginsFlow.first()
.firstNotNullOfOrNull { plugin ->
@@ -197,10 +206,10 @@ class PatcherWorker(
withContext(Dispatchers.IO) {
plugin.get(
getScope,
args.packageName,
args.version
selectedApp.packageName,
selectedApp.version
)
}?.takeIf { (_, version) -> args.version == null || version == args.version }
}?.takeIf { (_, version) -> selectedApp.version == null || version == selectedApp.version }
} catch (e: UserInteractionException.Activity.NotCompleted) {
throw e
} catch (_: UserInteractionException) {
@@ -210,10 +219,8 @@ class PatcherWorker(
}
}
is SelectedSource.Downloaded -> File(source.path)
is SelectedSource.Local -> File(source.path)
is SelectedSource.Installed -> File(pm.getPackageInfo(args.packageName)!!.applicationInfo!!.sourceDir)
is SelectedApp.Local -> selectedApp.file.also { args.setInputFile(it) }
is SelectedApp.Installed -> File(pm.getPackageInfo(selectedApp.packageName)!!.applicationInfo!!.sourceDir)
}
val runtime = if (prefs.useProcessRuntime.get()) {
@@ -251,7 +258,9 @@ class PatcherWorker(
Result.failure()
} finally {
patchedApk.delete()
if (args.source is SelectedSource.Local) File(args.source.path).delete()
if (args.input is SelectedApp.Local && args.input.temporary) {
args.input.file.delete()
}
}
}

View File

@@ -15,7 +15,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import app.revanced.manager.R
import io.github.fornewid.placeholder.material3.placeholder
import kotlinx.coroutines.Dispatchers
@@ -48,8 +47,6 @@ fun AppLabel(
shape = RoundedCornerShape(100)
)
.then(modifier),
style = style,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = style
)
}

View File

@@ -0,0 +1,35 @@
package app.revanced.manager.ui.model
import android.os.Parcelable
import app.revanced.manager.network.downloader.ParceledDownloaderData
import kotlinx.parcelize.Parcelize
import java.io.File
sealed interface SelectedApp : Parcelable {
val packageName: String
val version: String?
@Parcelize
data class Download(
override val packageName: String,
override val version: String?,
val data: ParceledDownloaderData
) : SelectedApp
@Parcelize
data class Search(override val packageName: String, override val version: String?) : SelectedApp
@Parcelize
data class Local(
override val packageName: String,
override val version: String,
val file: File,
val temporary: Boolean
) : SelectedApp
@Parcelize
data class Installed(
override val packageName: String,
override val version: String
) : SelectedApp
}

View File

@@ -1,13 +0,0 @@
package app.revanced.manager.ui.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
sealed class SelectedSource : Parcelable {
data object Auto : SelectedSource()
data object Installed : SelectedSource()
data class Downloaded(val path: String, val version: String) : SelectedSource()
data class Local(val path: String) : SelectedSource()
data class Plugin(val packageName: String?) : SelectedSource()
}

View File

@@ -1,11 +0,0 @@
package app.revanced.manager.ui.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
sealed class SelectedVersion : Parcelable {
data object Auto : SelectedVersion()
data object Any : SelectedVersion()
data class Specific(val version: String) : SelectedVersion()
}

View File

@@ -1,8 +1,7 @@
package app.revanced.manager.ui.model.navigation
import android.os.Parcelable
import app.revanced.manager.ui.model.SelectedSource
import app.revanced.manager.ui.model.SelectedVersion
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import kotlinx.parcelize.Parcelize
@@ -24,11 +23,10 @@ data class InstalledApplicationInfo(val packageName: String)
data class Update(val downloadOnScreenEntry: Boolean = false)
@Serializable
data object SelectedAppInfo : ComplexParameter<SelectedAppInfo.ViewModelParams> {
data object SelectedApplicationInfo : ComplexParameter<SelectedApplicationInfo.ViewModelParams> {
@Parcelize
data class ViewModelParams(
val packageName: String,
val localPath: String? = null,
val app: SelectedApp,
val patches: PatchSelection? = null
) : Parcelable
@@ -39,35 +37,12 @@ data object SelectedAppInfo : ComplexParameter<SelectedAppInfo.ViewModelParams>
data object PatchesSelector : ComplexParameter<PatchesSelector.ViewModelParams> {
@Parcelize
data class ViewModelParams(
val packageName: String,
val version: String?,
val patchSelection: PatchSelection?,
val app: SelectedApp,
val currentSelection: PatchSelection?,
val options: @RawValue Options,
) : Parcelable
}
@Serializable
data object VersionSelector : ComplexParameter<VersionSelector.ViewModelParams> {
@Parcelize
data class ViewModelParams(
val packageName: String,
val patchSelection: PatchSelection,
val selectedVersion: SelectedVersion,
val localPath: String? = null,
) : Parcelable
}
@Serializable
data object SourceSelector : ComplexParameter<SourceSelector.ViewModelParams> {
@Parcelize
data class ViewModelParams(
val packageName: String,
val version: String?,
val selectedSource: SelectedSource,
val localPath: String? = null,
) : Parcelable
}
@Serializable
data object RequiredOptions : ComplexParameter<PatchesSelector.ViewModelParams>
}
@@ -76,9 +51,7 @@ data object SelectedAppInfo : ComplexParameter<SelectedAppInfo.ViewModelParams>
data object Patcher : ComplexParameter<Patcher.ViewModelParams> {
@Parcelize
data class ViewModelParams(
val packageName: String,
val version: String?,
val selectedSource: SelectedSource,
val selectedApp: SelectedApp,
val selectedPatches: PatchSelection,
val options: @RawValue Options
) : Parcelable

View File

@@ -44,6 +44,7 @@ import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.component.NonSuggestedVersionDialog
import app.revanced.manager.ui.component.SearchView
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.AppSelectorViewModel
import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.EventEffect
@@ -53,13 +54,13 @@ import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSelectorScreen(
onSelect: (packageName: String) -> Unit,
onStorageSelect: (packageName: String, path: String) -> Unit,
onSelect: (String) -> Unit,
onStorageSelect: (SelectedApp.Local) -> Unit,
onBackClick: () -> Unit,
vm: AppSelectorViewModel = koinViewModel()
) {
EventEffect(flow = vm.storageSelectionFlow) {
onStorageSelect(it.first, it.second)
onStorageSelect(it)
}
val pickApkLauncher =
@@ -82,12 +83,12 @@ fun AppSelectorScreen(
}
}
// vm.nonSuggestedVersionDialogSubject?.let {
// NonSuggestedVersionDialog(
// suggestedVersion = suggestedVersions[it.packageName].orEmpty(),
// onDismiss = vm::dismissNonSuggestedVersionDialog
// )
// }
vm.nonSuggestedVersionDialogSubject?.let {
NonSuggestedVersionDialog(
suggestedVersion = suggestedVersions[it.packageName].orEmpty(),
onDismiss = vm::dismissNonSuggestedVersionDialog
)
}
if (search)
SearchView(
@@ -114,7 +115,8 @@ fun AppSelectorScreen(
)
},
headlineContent = { AppLabel(app.packageInfo) },
supportingContent = app.patches?.let {
supportingContent = { Text(app.packageName) },
trailingContent = app.patches?.let {
{
Text(
pluralStringResource(
@@ -212,7 +214,12 @@ fun AppSelectorScreen(
defaultText = app.packageName
)
},
supportingContent = app.patches?.let {
supportingContent = {
suggestedVersions[app.packageName]?.let {
Text(stringResource(R.string.suggested_version_info, it))
}
},
trailingContent = app.patches?.let {
{
Text(
pluralStringResource(

View File

@@ -498,7 +498,7 @@ private fun PatchItem(
leadingContent = {
HapticCheckbox(
checked = selected,
onCheckedChange = null,
onCheckedChange = { onToggle() },
enabled = compatible
)
},

View File

@@ -1,12 +1,16 @@
package app.revanced.manager.ui.screen
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.filled.AutoFixHigh
@@ -17,9 +21,11 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -31,30 +37,33 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.AppInfo
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.component.NotificationCard
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.model.SelectedSource
import app.revanced.manager.ui.model.SelectedVersion
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
import app.revanced.manager.util.EventEffect
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.enabled
import app.revanced.manager.util.patchCount
import app.revanced.manager.util.toast
import app.revanced.manager.util.transparentListItemColors
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SelectedAppInfoScreen(
onPatchSelectorClick: (packageName: String, version: String?, PatchSelection?, Options) -> Unit,
onRequiredOptions: (packageName: String, version: String?, PatchSelection?, Options) -> Unit,
onPatchSelectorClick: (SelectedApp, PatchSelection?, Options) -> Unit,
onRequiredOptions: (SelectedApp, PatchSelection?, Options) -> Unit,
onPatchClick: () -> Unit,
onVersionClick: (packageName: String, patchSelection: PatchSelection, selectedVersion: SelectedVersion, localPath: String?) -> Unit,
onSourceClick: (packageName: String, version: String?, SelectedSource, localPath: String?) -> Unit,
onBackClick: () -> Unit,
vm: SelectedAppInfoViewModel
) {
@@ -63,23 +72,29 @@ fun SelectedAppInfoScreen(
val networkConnected = remember { networkInfo.isConnected() }
val networkMetered = remember { !networkInfo.isUnmetered() }
val packageName = vm.packageName
val packageName = vm.selectedApp.packageName
val version = vm.selectedApp.version
val bundles by vm.bundleInfoFlow.collectAsStateWithLifecycle(emptyList())
val allowIncompatiblePatches by vm.prefs.disablePatchVersionCompatCheck.getAsState()
val patches by remember {
derivedStateOf {
vm.getPatches(bundles, allowIncompatiblePatches)
}
}
val selectedPatchCount = patches.values.sumOf { it.size }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = vm::handlePluginActivityResult
)
EventEffect(flow = vm.launchActivityFlow) { intent ->
launcher.launch(intent)
}
val composableScope = rememberCoroutineScope()
val error by vm.errorFlow.collectAsStateWithLifecycle(null)
val selectedVersion by vm.selectedVersion.collectAsStateWithLifecycle()
val resolvedVersion by vm.resolvedVersion.collectAsStateWithLifecycle(null)
val selectedSource by vm.selectedSource.collectAsStateWithLifecycle()
val resolvedSource by vm.resolvedSource.collectAsStateWithLifecycle(null)
val customSelection by vm.customSelection.collectAsStateWithLifecycle(null)
val fullPatchSelection by vm.patchSelection.collectAsStateWithLifecycle(emptyMap())
val patchCount = fullPatchSelection.patchCount
val incompatibleCount by vm.incompatiblePatchCount.collectAsStateWithLifecycle(0)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
@@ -102,18 +117,18 @@ fun SelectedAppInfoScreen(
)
},
onClick = patchClick@{
if (patchCount == 0) {
if (selectedPatchCount == 0) {
context.toast(context.getString(R.string.no_patches_selected))
return@patchClick
}
composableScope.launch {
if (!vm.hasSetRequiredOptions(fullPatchSelection)) {
if (!vm.hasSetRequiredOptions(patches)) {
onRequiredOptions(
vm.packageName,
resolvedVersion,
customSelection,
vm.options,
vm.selectedApp,
vm.getCustomPatches(bundles, allowIncompatiblePatches),
vm.options
)
return@launch
}
@@ -125,96 +140,94 @@ fun SelectedAppInfoScreen(
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
val plugins by vm.plugins.collectAsStateWithLifecycle(emptyList())
if (vm.showSourceSelector) {
val requiredVersion by vm.requiredVersion.collectAsStateWithLifecycle(null)
AppSourceSelectorDialog(
plugins = plugins,
installedApp = vm.installedAppData,
searchApp = SelectedApp.Search(
vm.packageName,
vm.desiredVersion
),
activeSearchJob = vm.activePluginAction,
hasRoot = vm.hasRoot,
onDismissRequest = vm::dismissSourceSelector,
onSelectPlugin = vm::searchUsingPlugin,
requiredVersion = requiredVersion,
onSelect = {
vm.selectedApp = it
vm.dismissSourceSelector()
}
)
}
ColumnWithScrollbar(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
AppInfo(vm.selectedAppInfo, placeholderLabel = packageName) {
vm.selectedAppInfo?.let {
Text(
it.packageName,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
version ?: stringResource(R.string.selected_app_meta_any_version),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
PageItem(
R.string.patch_selector_item,
stringResource(R.string.patch_selector_item_description, patchCount),
stringResource(
R.string.patch_selector_item_description,
selectedPatchCount
),
onClick = {
onPatchSelectorClick(
vm.packageName,
resolvedVersion,
customSelection,
vm.selectedApp,
vm.getCustomPatches(
bundles,
allowIncompatiblePatches
),
vm.options
)
},
extraDescription = if (incompatibleCount > 0) { {
Text(
"$incompatibleCount incompatible",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
} } else null,
)
val versionText = resolvedVersion ?: "Any available version"
val versionDescription = if (selectedVersion is SelectedVersion.Auto)
"Auto ($versionText)" // stringResource(R.string.selected_app_meta_auto_version, actualVersion)
else versionText
PageItem(
R.string.version_selector_item,
versionDescription,
onClick = {
onVersionClick(
packageName,
fullPatchSelection,
selectedVersion,
vm.localPath,
)
},
)
val sourceText = when (val source = resolvedSource) {
is SelectedSource.Installed -> "Installed APK"
is SelectedSource.Downloaded -> "Downloaded APK"
is SelectedSource.Local -> "Local APK"
is SelectedSource.Plugin -> {
source.packageName ?: "Any available downloader"
}
else -> "Auto"
}
val sourceDescription = if (selectedSource is SelectedSource.Auto)
"Auto ($sourceText)" // stringResource(R.string.selected_app_meta_auto_version, actualVersion)
else sourceText
)
PageItem(
R.string.apk_source_selector_item,
sourceDescription,
onClick = { onSourceClick(
packageName,
resolvedVersion,
selectedSource,
vm.localPath,
) },
)
when (val app = vm.selectedApp) {
is SelectedApp.Search -> stringResource(R.string.apk_source_auto)
is SelectedApp.Installed -> stringResource(R.string.apk_source_installed)
is SelectedApp.Download -> stringResource(
R.string.apk_source_downloader,
plugins.find { it.packageName == app.data.pluginPackageName }?.name
?: app.data.pluginPackageName
)
is SelectedApp.Local -> stringResource(R.string.apk_source_local)
},
onClick = {
vm.showSourceSelector()
}
)
error?.let {
Text(
stringResource(it.resourceId),
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp)
modifier = Modifier.padding(horizontal = 24.dp)
)
}
if (resolvedSource is SelectedSource.Plugin) Column(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
Column(
modifier = Modifier.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
val needsInternet =
vm.selectedApp.let { it is SelectedApp.Search || it is SelectedApp.Download }
when {
!needsInternet -> {}
!networkConnected -> {
NotificationCard(
isWarning = true,
@@ -223,6 +236,7 @@ fun SelectedAppInfoScreen(
onDismiss = null
)
}
networkMetered -> {
NotificationCard(
isWarning = true,
@@ -238,17 +252,11 @@ fun SelectedAppInfoScreen(
}
@Composable
private fun PageItem(
@StringRes title: Int,
description: String,
onClick: () -> Unit,
enabled: Boolean = true,
extraDescription: @Composable (ColumnScope.() -> Unit)? = null,
) {
private fun PageItem(@StringRes title: Int, description: String, onClick: () -> Unit) {
ListItem(
modifier = Modifier
.clickable(enabled, onClick = onClick)
.enabled(enabled),
.clickable(onClick = onClick)
.padding(start = 8.dp),
headlineContent = {
Text(
stringResource(title),
@@ -257,17 +265,99 @@ private fun PageItem(
)
},
supportingContent = {
Column {
Text(
description,
color = MaterialTheme.colorScheme.outline,
style = MaterialTheme.typography.bodyMedium
)
extraDescription?.invoke(this)
}
Text(
description,
color = MaterialTheme.colorScheme.outline,
style = MaterialTheme.typography.bodyMedium
)
},
trailingContent = {
Icon(Icons.AutoMirrored.Outlined.ArrowRight, null)
}
)
}
@Composable
private fun AppSourceSelectorDialog(
plugins: List<LoadedDownloaderPlugin>,
installedApp: Pair<SelectedApp.Installed, InstalledApp?>?,
searchApp: SelectedApp.Search,
activeSearchJob: String?,
hasRoot: Boolean,
requiredVersion: String?,
onDismissRequest: () -> Unit,
onSelectPlugin: (LoadedDownloaderPlugin) -> Unit,
onSelect: (SelectedApp) -> Unit,
) {
val canSelect = activeSearchJob == null
AlertDialogExtended(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.cancel))
}
},
title = { Text(stringResource(R.string.app_source_dialog_title)) },
textHorizontalPadding = PaddingValues(horizontal = 0.dp),
text = {
LazyColumn {
item(key = "auto") {
val hasPlugins = plugins.isNotEmpty()
ListItem(
modifier = Modifier
.clickable(enabled = canSelect && hasPlugins) { onSelect(searchApp) }
.enabled(hasPlugins),
headlineContent = { Text(stringResource(R.string.app_source_dialog_option_auto)) },
supportingContent = {
Text(
if (hasPlugins)
stringResource(R.string.app_source_dialog_option_auto_description)
else
stringResource(R.string.app_source_dialog_option_auto_unavailable)
)
},
colors = transparentListItemColors
)
}
installedApp?.let { (app, meta) ->
item(key = "installed") {
val (usable, text) = when {
// Mounted apps must be unpatched before patching, which cannot be done without root access.
meta?.installType == InstallType.MOUNT && !hasRoot -> false to stringResource(
R.string.app_source_dialog_option_installed_no_root
)
// Patching already patched apps is not allowed because patches expect unpatched apps.
meta?.installType == InstallType.DEFAULT -> false to stringResource(R.string.already_patched)
// Version does not match suggested version.
requiredVersion != null && app.version != requiredVersion -> false to stringResource(
R.string.app_source_dialog_option_installed_version_not_suggested,
app.version
)
else -> true to app.version
}
ListItem(
modifier = Modifier
.clickable(enabled = canSelect && usable) { onSelect(app) }
.enabled(usable),
headlineContent = { Text(stringResource(R.string.installed)) },
supportingContent = { Text(text) },
colors = transparentListItemColors
)
}
}
items(plugins, key = { "plugin_${it.packageName}" }) { plugin ->
ListItem(
modifier = Modifier.clickable(enabled = canSelect) { onSelectPlugin(plugin) },
headlineContent = { Text(plugin.name) },
trailingContent = (@Composable { LoadingIndicator() }).takeIf { activeSearchJob == plugin.packageName },
colors = transparentListItemColors
)
}
}
}
)
}

View File

@@ -1,154 +0,0 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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
import app.revanced.manager.util.enabled
import app.revanced.manager.util.transparentListItemColors
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SourceSelectorScreen(
onBackClick: () -> Unit,
onSave: (source: SelectedSource) -> Unit,
viewModel: SourceSelectorViewModel,
) {
val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(emptyList())
val plugins by viewModel.plugins.collectAsStateWithLifecycle(emptyList())
Scaffold(
topBar = {
AppTopBar(
title = { Text("Select source") },
onBackClick = onBackClick,
)
},
floatingActionButton = {
HapticExtendedFloatingActionButton(
text = { Text(stringResource(R.string.save)) },
icon = { Icon(Icons.Outlined.Save, null) },
onClick = { onSave(viewModel.selectedSource) },
)
}
) { paddingValues ->
LazyColumnWithScrollbar (
contentPadding = paddingValues,
) {
item {
SourceOption(
isSelected = viewModel.selectedSource == SelectedSource.Auto,
onSelect = { viewModel.selectSource(SelectedSource.Auto) },
headlineContent = { Text("Auto (Recommended)") },
supportingContent = { Text("Automatically select the best available source") }
)
}
item {
SourceOption(
isSelected = viewModel.selectedSource == SelectedSource.Plugin(null),
onSelect = { viewModel.selectSource(SelectedSource.Plugin(null)) },
headlineContent = { Text("Any available downloader") },
)
}
viewModel.localApp?.let { option ->
item {
HorizontalDivider()
SourceOption(
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.key }) { option ->
SourceOption(
sourceOption = option,
isSelected = viewModel.selectedSource == option.source,
onSelect = viewModel::selectSource,
)
}
if (plugins.isNotEmpty()) item { HorizontalDivider() }
items(plugins, key = { it.key }) { option ->
SourceOption(
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,
onSelect: () -> Unit,
headlineContent: @Composable (() -> Unit),
supportingContent: @Composable (() -> Unit)? = null,
overlineContent: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
) {
ListItem(
modifier = Modifier
.clickable(enabled) { onSelect() }
.enabled(enabled),
leadingContent = {
RadioButton(
selected = isSelected,
onClick = null
)
},
headlineContent = headlineContent,
supportingContent = supportingContent,
overlineContent = overlineContent,
colors = transparentListItemColors
)
}

View File

@@ -1,139 +0,0 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.model.SelectedVersion
import app.revanced.manager.ui.viewmodel.VersionSelectorViewModel
import app.revanced.manager.util.transparentListItemColors
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VersionSelectorScreen(
onBackClick: () -> Unit,
onSave: (version: SelectedVersion) -> Unit,
viewModel: VersionSelectorViewModel,
) {
val versions by viewModel.availableVersions.collectAsStateWithLifecycle(emptyList())
val downloadedVersions by viewModel.downloadedVersions.collectAsStateWithLifecycle(emptyList())
val localVersion by viewModel.localVersion.collectAsStateWithLifecycle(null)
Scaffold(
topBar = {
AppTopBar(
title = { Text("Select version") },
onBackClick = onBackClick,
actions = {
IconButton({}) {
Icon(Icons.Outlined.MoreVert, contentDescription = null)
}
}
)
},
floatingActionButton = {
HapticExtendedFloatingActionButton(
text = { Text(stringResource(R.string.save)) },
icon = { Icon(Icons.Outlined.Save, contentDescription = null) },
onClick = { onSave(viewModel.selectedVersion) }
)
}
) { paddingValues ->
Column(
modifier = Modifier.padding(paddingValues)
) {
VersionOption(
version = SelectedVersion.Auto,
isSelected = viewModel.selectedVersion is SelectedVersion.Auto,
onSelect = viewModel::selectVersion,
headlineContent = { Text("Auto (Recommended)") },
supportingContent = { Text("Automatically select the best available version") }
)
HorizontalDivider()
if (versions.isNotEmpty()) {
LazyColumn {
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"
else -> null
}
VersionOption(
version = version.first,
isSelected = viewModel.selectedVersion == version.first,
onSelect = viewModel::selectVersion,
headlineContent = { Text(version.first.version) },
supportingContent = {
Text(
"${version.second.let { if (it == 0) "No" else it }} incompatible patches"
)
},
overlineContent = overlineText?.let { { Text(it) } }
)
}
}
} else {
VersionOption(
version = SelectedVersion.Any,
isSelected = viewModel.selectedVersion is SelectedVersion.Any,
onSelect = viewModel::selectVersion,
headlineContent = { Text("Any available version") },
supportingContent = { Text("Use any available version regardless of compatibility") }
)
}
}
}
}
@Composable
private fun VersionOption(
version: SelectedVersion,
isSelected: Boolean,
onSelect: (SelectedVersion) -> Unit,
headlineContent: @Composable (() -> Unit),
supportingContent: @Composable (() -> Unit)? = null,
overlineContent: @Composable (() -> Unit)? = null,
) {
ListItem(
modifier = Modifier
.clickable { onSelect(version) },
leadingContent = {
RadioButton(
selected = isSelected,
onClick = null
)
},
headlineContent = headlineContent,
supportingContent = supportingContent,
trailingContent = overlineContent,
colors = transparentListItemColors
)
}

View File

@@ -105,6 +105,14 @@ fun GeneralSettingsScreen(
description = R.string.pure_black_theme_description
)
}
GroupHeader(stringResource(R.string.networking))
BooleanItem(
preference = prefs.allowMeteredNetworks,
coroutineScope = coroutineScope,
headline = R.string.allow_metered_networks,
description = R.string.allow_metered_networks_description
)
}
}
}

View File

@@ -3,6 +3,9 @@ package app.revanced.manager.ui.viewmodel
import android.app.Application
import android.content.pm.PackageInfo
import android.net.Uri
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
@@ -11,6 +14,7 @@ import androidx.lifecycle.viewmodel.compose.saveable
import app.revanced.manager.R
import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.PM
import app.revanced.manager.util.toast
import kotlinx.coroutines.Dispatchers
@@ -27,7 +31,7 @@ class AppSelectorViewModel(
private val app: Application,
private val pm: PM,
fs: Filesystem,
patchBundleRepository: PatchBundleRepository,
private val patchBundleRepository: PatchBundleRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val inputFile = savedStateHandle.saveable(key = "inputFile") {
@@ -38,19 +42,19 @@ class AppSelectorViewModel(
}
val appList = pm.appList
private val storageSelectionChannel = Channel<Pair<String, String>>()
private val storageSelectionChannel = Channel<SelectedApp.Local>()
val storageSelectionFlow = storageSelectionChannel.receiveAsFlow()
val suggestedAppVersions = patchBundleRepository.suggestedVersions.flowOn(Dispatchers.Default)
// var nonSuggestedVersionDialogSubject by mutableStateOf<SelectedApp.Local?>(null)
// private set
var nonSuggestedVersionDialogSubject by mutableStateOf<SelectedApp.Local?>(null)
private set
fun loadLabel(app: PackageInfo?) = with(pm) { app?.label() ?: "Not installed" }
// fun dismissNonSuggestedVersionDialog() {
// nonSuggestedVersionDialogSubject = null
// }
fun dismissNonSuggestedVersionDialog() {
nonSuggestedVersionDialogSubject = null
}
fun handleStorageResult(uri: Uri) = viewModelScope.launch {
val selectedApp = withContext(Dispatchers.IO) {
@@ -62,8 +66,11 @@ class AppSelectorViewModel(
return@launch
}
// TODO: Disallow if 0 patches are compatible
storageSelectionChannel.send(selectedApp)
if (patchBundleRepository.isVersionAllowed(selectedApp.packageName, selectedApp.version)) {
storageSelectionChannel.send(selectedApp)
} else {
nonSuggestedVersionDialogSubject = selectedApp
}
}
private fun loadSelectedFile(uri: Uri) =
@@ -73,7 +80,12 @@ class AppSelectorViewModel(
Files.copy(stream, toPath())
pm.getPackageInfo(this)?.let { packageInfo ->
Pair(packageInfo.packageName, path)
SelectedApp.Local(
packageName = packageInfo.packageName,
version = packageInfo.versionName!!,
file = this,
temporary = true
)
}
}
}

View File

@@ -54,6 +54,7 @@ class BundleListViewModel : ViewModel(), KoinComponent {
patchBundleRepository.update(
*getSelectedSources().filterIsInstance<RemotePatchBundle>().toTypedArray(),
showToast = true,
force = true
)
}
}
@@ -65,7 +66,7 @@ class BundleListViewModel : ViewModel(), KoinComponent {
fun update(src: PatchBundleSource) = viewModelScope.launch {
if (src !is RemotePatchBundle) return@launch
patchBundleRepository.update(src, showToast = true)
patchBundleRepository.update(src, showToast = true, force = true)
}
enum class Event {

View File

@@ -12,9 +12,11 @@ import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
import app.revanced.manager.domain.manager.KeystoreManager
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.domain.repository.SerializedSelection
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.theme.Theme
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast
@@ -29,14 +31,44 @@ import kotlinx.serialization.json.Json
class MainViewModel(
private val patchBundleRepository: PatchBundleRepository,
private val patchSelectionRepository: PatchSelectionRepository,
private val downloadedAppRepository: DownloadedAppRepository,
private val keystoreManager: KeystoreManager,
private val app: Application,
val prefs: PreferencesManager,
private val json: Json
) : ViewModel() {
private val appSelectChannel = Channel<SelectedApp>()
val appSelectFlow = appSelectChannel.receiveAsFlow()
private val legacyImportActivityChannel = Channel<Intent>()
val legacyImportActivityFlow = legacyImportActivityChannel.receiveAsFlow()
private suspend fun suggestedVersion(packageName: String) =
patchBundleRepository.suggestedVersions.first()[packageName]
private suspend fun findDownloadedApp(app: SelectedApp): SelectedApp.Local? {
if (app !is SelectedApp.Search) return null
val suggestedVersion = suggestedVersion(app.packageName) ?: return null
val downloadedApp =
downloadedAppRepository.get(app.packageName, suggestedVersion, markUsed = true)
?: return null
return SelectedApp.Local(
downloadedApp.packageName,
downloadedApp.version,
downloadedAppRepository.getApkFileForApp(downloadedApp),
false
)
}
fun selectApp(app: SelectedApp) = viewModelScope.launch {
appSelectChannel.send(findDownloadedApp(app) ?: app)
}
fun selectApp(packageName: String) = viewModelScope.launch {
selectApp(SelectedApp.Search(packageName, suggestedVersion(packageName)))
}
init {
viewModelScope.launch {
if (!prefs.firstLaunch.get()) return@launch

View File

@@ -37,7 +37,7 @@ import app.revanced.manager.patcher.worker.PatcherWorker
import app.revanced.manager.plugin.downloader.PluginHostApi
import app.revanced.manager.plugin.downloader.UserInteractionException
import app.revanced.manager.ui.model.InstallerModel
import app.revanced.manager.ui.model.SelectedSource
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.model.State
import app.revanced.manager.ui.model.StepCategory
import app.revanced.manager.ui.model.Step
@@ -93,8 +93,9 @@ class PatcherViewModel(
private val ackpineInstaller: PackageInstaller = get()
private var installedApp: InstalledApp? = null
val packageName = input.packageName
val version = input.version
private val selectedApp = input.selectedApp
val packageName = selectedApp.packageName
val version = selectedApp.version
var installedPackageName by savedStateHandle.saveable(
key = "installedPackageName",
@@ -159,7 +160,7 @@ class PatcherViewModel(
}
val steps by savedStateHandle.saveable(saver = snapshotStateListSaver()) {
generateSteps(app, input.selectedSource, input.selectedPatches).toMutableStateList()
generateSteps(app, input.selectedApp, input.selectedPatches).toMutableStateList()
}
val progress by derivedStateOf {
@@ -177,9 +178,7 @@ class PatcherViewModel(
ParcelUuid(
workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>(
"patching", PatcherWorker.Args(
input.packageName,
input.version,
input.selectedSource,
input.selectedApp,
outputFile.path,
input.selectedPatches,
input.options,
@@ -258,7 +257,7 @@ class PatcherViewModel(
super.onCleared()
workManager.cancelWorkById(patcherWorkerId.uuid)
if (input.selectedSource is SelectedSource.Installed && installedApp?.installType == InstallType.MOUNT) {
if (input.selectedApp is SelectedApp.Installed && installedApp?.installType == InstallType.MOUNT) {
GlobalScope.launch(Dispatchers.Main) {
uiSafe(app, R.string.failed_to_mount, "Failed to mount") {
withTimeout(Duration.ofMinutes(1L)) {
@@ -382,7 +381,7 @@ class PatcherViewModel(
installedAppRepository.addOrUpdate(
installerPkgName,
packageName,
input.version
input.selectedApp.version
?: withContext(Dispatchers.IO) { pm.getPackageInfo(outputFile)?.versionName!! },
InstallType.DEFAULT,
input.selectedPatches
@@ -444,7 +443,7 @@ class PatcherViewModel(
}
}
val inputVersion = input.version
val inputVersion = input.selectedApp.version
?: withContext(Dispatchers.IO) { inputFile?.let(pm::getPackageInfo)?.versionName }
?: throw Exception("Failed to determine input APK version")
@@ -536,10 +535,10 @@ class PatcherViewModel(
fun generateSteps(
context: Context,
selectedSource: SelectedSource,
selectedApp: SelectedApp,
selectedPatches: PatchSelection
): List<Step> = buildList {
if (selectedSource is SelectedSource.Plugin)
if (selectedApp is SelectedApp.Download || selectedApp is SelectedApp.Search)
add(
Step(
StepId.DownloadAPK,

View File

@@ -20,7 +20,7 @@ import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.patcher.patch.PatchBundleInfo
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.toPatchSelection
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.model.navigation.SelectedAppInfo
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.saver.Nullable
@@ -45,14 +45,14 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.get
@OptIn(SavedStateHandleSaveableApi::class)
class PatchesSelectorViewModel(input: SelectedAppInfo.PatchesSelector.ViewModelParams) :
class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.ViewModelParams) :
ViewModel(), KoinComponent {
private val app: Application = get()
private val savedStateHandle: SavedStateHandle = get()
private val prefs: PreferencesManager = get()
private val packageName = input.packageName
val appVersion = input.version
private val packageName = input.app.packageName
val appVersion = input.app.version
var selectionWarningEnabled by mutableStateOf(true)
private set
@@ -62,7 +62,7 @@ class PatchesSelectorViewModel(input: SelectedAppInfo.PatchesSelector.ViewModelP
val allowIncompatiblePatches =
get<PreferencesManager>().disablePatchVersionCompatCheck.getBlocking()
val bundlesFlow =
get<PatchBundleRepository>().scopedBundleInfoFlow(packageName, input.version)
get<PatchBundleRepository>().scopedBundleInfoFlow(packageName, input.app.version)
init {
viewModelScope.launch {
@@ -88,7 +88,7 @@ class PatchesSelectorViewModel(input: SelectedAppInfo.PatchesSelector.ViewModelP
key = "selection",
stateSaver = selectionSaver,
) {
mutableStateOf(input.patchSelection?.toPersistentPatchSelection())
mutableStateOf(input.currentSelection?.toPersistentPatchSelection())
}
private val patchOptions: PersistentOptions by savedStateHandle.saveable(

View File

@@ -1,188 +1,130 @@
package app.revanced.manager.ui.viewmodel
import android.app.Activity
import android.app.Application
import android.content.Intent
import android.content.pm.PackageInfo
import android.os.Parcelable
import android.util.Log
import androidx.activity.result.ActivityResult
import androidx.annotation.StringRes
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
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.domain.manager.PreferencesManager
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.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.repository.PatchOptionsRepository
import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.patcher.patch.PatchBundleInfo
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.requiredOptionsSet
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.toPatchSelection
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
import app.revanced.manager.network.downloader.ParceledDownloaderData
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.requiredOptionsSet
import app.revanced.manager.plugin.downloader.GetScope
import app.revanced.manager.plugin.downloader.PluginHostApi
import app.revanced.manager.ui.model.SelectedSource
import app.revanced.manager.ui.model.SelectedVersion
import app.revanced.manager.plugin.downloader.UserInteractionException
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.model.navigation.Patcher
import app.revanced.manager.ui.model.navigation.SelectedAppInfo
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
import app.revanced.manager.util.Options
import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.patchCount
import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
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(
private val input: SelectedAppInfo.ViewModelParams
input: SelectedApplicationInfo.ViewModelParams
) : ViewModel(), KoinComponent {
private val app: Application = get()
private val bundleRepository: PatchBundleRepository = get()
private val selectionRepository: PatchSelectionRepository = get()
private val optionsRepository: PatchOptionsRepository = get()
private val pluginsRepository: DownloaderPluginRepository = get()
private val installedAppRepository: InstalledAppRepository = get()
private val downloadedAppRepository: DownloadedAppRepository = get()
private val rootInstaller: RootInstaller = get()
private val pm: PM = get()
private val savedStateHandle: SavedStateHandle = get()
private val prefs: PreferencesManager = get()
val prefs: PreferencesManager = get()
val plugins = pluginsRepository.loadedPluginsFlow
val packageName = input.packageName
val localPath = input.localPath
val desiredVersion = input.app.version
val packageName = input.app.packageName
private val persistConfiguration = input.patches == null
val hasRoot = rootInstaller.hasRootAccess()
var installedAppData: Pair<SelectedApp.Installed, InstalledApp?>? by mutableStateOf(null)
private set
// User selection
private var selectionFlow = MutableStateFlow(
input.patches?.let { selection ->
SelectionState.Customized(selection)
} ?: SelectionState.Default
)
private val _selectedVersion = MutableStateFlow<SelectedVersion>(SelectedVersion.Auto)
val selectedVersion: StateFlow<SelectedVersion> = _selectedVersion
private val _selectedSource = MutableStateFlow<SelectedSource>(SelectedSource.Auto)
val selectedSource: StateFlow<SelectedSource> = _selectedSource
fun updateVersion(version: SelectedVersion) {
_selectedVersion.value = version
private var _selectedApp by savedStateHandle.saveable {
mutableStateOf(input.app)
}
fun updateSource(source: SelectedSource) {
_selectedSource.value = source
}
fun updateConfiguration(
selection: PatchSelection?,
selectedOptions: Options
) = viewModelScope.launch {
selectionFlow.value = selection?.let(SelectionState::Customized) ?: SelectionState.Default
val filteredOptions = selectedOptions.filtered(bundleInfoFlow.first())
options = filteredOptions
var selectedAppInfo: PackageInfo? by mutableStateOf(null)
private set
if (persistConfiguration) {
selection?.let { selectionRepository.updateSelection(packageName, it) }
?: selectionRepository.resetSelectionForPackage(packageName)
optionsRepository.saveOptions(packageName, filteredOptions)
var selectedApp
get() = _selectedApp
set(value) {
_selectedApp = value
invalidateSelectedAppInfo()
}
}
init {
invalidateSelectedAppInfo()
viewModelScope.launch(Dispatchers.Main) {
val packageInfo = async(Dispatchers.IO) { pm.getPackageInfo(packageName) }
val installedAppDeferred =
async(Dispatchers.IO) { installedAppRepository.get(packageName) }
// All patches for package
val bundles = bundleRepository.scopedBundleInfoFlow(packageName, null)
// Selection derived from selectionFlow
val patchSelection = combine(
selectionFlow,
bundles,
) { selection, bundles ->
selection.patches(bundles, allowIncompatible = true)
}
val customSelection = combine(
selectionFlow,
bundles,
) { selection, bundles ->
(selection as? SelectionState.Customized)?.patches(bundles, allowIncompatible = true)
}
// Most compatible versions based on patch selection
@OptIn(ExperimentalCoroutinesApi::class)
val mostCompatibleVersions = patchSelection.flatMapLatest { patchSelection ->
bundleRepository.suggestedVersions(
packageName,
patchSelection
)
}
// Resolve actual version from user selection
val resolvedVersion = combine(
_selectedVersion,
mostCompatibleVersions,
) { selected, mostCompatible ->
when (selected) {
is SelectedVersion.Specific -> selected.version
is SelectedVersion.Any -> null
is SelectedVersion.Auto -> mostCompatible?.maxWithOrNull(
compareBy<Map.Entry<String, Int>> { it.value }
.thenBy { it.key }
)?.key
}
}
@OptIn(ExperimentalCoroutinesApi::class)
val scopedBundles = resolvedVersion.flatMapLatest { version ->
bundleRepository.scopedBundleInfoFlow(packageName, version)
}
val incompatiblePatchCount = scopedBundles.map { bundles ->
bundles.sumOf { bundle ->
bundle.incompatible.size
}
}
// Resolve actual source from user selection
val resolvedSource = combine(
_selectedSource,
resolvedVersion
) { source, version ->
when (source) {
is SelectedSource.Installed -> source
is SelectedSource.Local -> source
is SelectedSource.Downloaded -> source
is SelectedSource.Plugin -> source
is SelectedSource.Auto -> {
val app = version?.let {
downloadedAppRepository.get(packageName, it)
installedAppData =
packageInfo.await()?.let {
SelectedApp.Installed(
packageName,
it.versionName!!
) to installedAppDeferred.await()
}
val file = app?.let {
downloadedAppRepository.getApkFileForApp(it)
}
file?.let { SelectedSource.Downloaded(it.path, version) }
?: SelectedSource.Plugin(null)
}
}
}
val requiredVersion = combine(
prefs.suggestedVersionSafeguard.flow,
bundleRepository.suggestedVersions
) { suggestedVersionSafeguard, suggestedVersions ->
if (!suggestedVersionSafeguard) return@combine null
suggestedVersions[input.app.packageName]
}
val bundleInfoFlow by derivedStateOf {
bundleRepository.scopedBundleInfoFlow(packageName, null)
bundleRepository.scopedBundleInfoFlow(packageName, selectedApp.version)
}
var options: Options by savedStateHandle.saveable {
@@ -200,42 +142,121 @@ class SelectedAppInfoViewModel(
}
private set
private var selectionState: SelectionState by savedStateHandle.saveable {
if (input.patches != null)
return@saveable mutableStateOf(SelectionState.Customized(input.patches))
val errorFlow = combine(
plugins,
resolvedSource,
) { pluginsList, source ->
// Try to get the previous selection if customization is enabled.
viewModelScope.launch {
if (!prefs.disableSelectionWarning.get()) return@launch
val previous = selectionRepository.getSelection(packageName)
if (previous.values.sumOf { it.size } == 0) return@launch
selectionState = SelectionState.Customized(previous)
}
mutableStateOf(SelectionState.Default)
}
var showSourceSelector by mutableStateOf(false)
private set
private var pluginAction: Pair<LoadedDownloaderPlugin, Job>? by mutableStateOf(null)
val activePluginAction get() = pluginAction?.first?.packageName
private var launchedActivity by mutableStateOf<CompletableDeferred<ActivityResult>?>(null)
private val launchActivityChannel = Channel<Intent>()
val launchActivityFlow = launchActivityChannel.receiveAsFlow()
val errorFlow = combine(plugins, snapshotFlow { selectedApp }) { pluginsList, app ->
when {
source is SelectedSource.Plugin && pluginsList.isEmpty() -> Error.NoPlugins
app is SelectedApp.Search && pluginsList.isEmpty() -> Error.NoPlugins
else -> null
}
}
// var installedAppData: Pair<SelectedApp.Installed, InstalledApp?>? by mutableStateOf(null)
// private set
private var _selectedApp by savedStateHandle.saveable {
mutableStateOf(null)
fun showSourceSelector() {
dismissSourceSelector()
showSourceSelector = true
}
var selectedAppInfo: PackageInfo? by mutableStateOf(null)
private set
private fun cancelPluginAction() {
pluginAction?.second?.cancel()
pluginAction = null
}
var selectedApp
get() = _selectedApp
set(value) {
_selectedApp = value
invalidateSelectedAppInfo()
fun dismissSourceSelector() {
cancelPluginAction()
showSourceSelector = false
}
fun searchUsingPlugin(plugin: LoadedDownloaderPlugin) {
cancelPluginAction()
pluginAction = plugin to viewModelScope.launch {
try {
val scope = object : GetScope {
override val hostPackageName = app.packageName
override val pluginPackageName = plugin.packageName
override suspend fun requestStartActivity(intent: Intent) =
withContext(Dispatchers.Main) {
if (launchedActivity != null) error("Previous activity has not finished")
try {
val result = with(CompletableDeferred<ActivityResult>()) {
launchedActivity = this
launchActivityChannel.send(intent)
await()
}
when (result.resultCode) {
Activity.RESULT_OK -> result.data
Activity.RESULT_CANCELED -> throw UserInteractionException.Activity.Cancelled()
else -> throw UserInteractionException.Activity.NotCompleted(
result.resultCode,
result.data
)
}
} finally {
launchedActivity = null
}
}
}
withContext(Dispatchers.IO) {
plugin.get(scope, packageName, desiredVersion)
}?.let { (data, version) ->
if (desiredVersion != null && version != desiredVersion) {
app.toast(app.getString(R.string.downloader_invalid_version))
return@launch
}
selectedApp = SelectedApp.Download(
packageName,
version,
ParceledDownloaderData(plugin, data)
)
} ?: app.toast(app.getString(R.string.downloader_app_not_found))
} catch (e: UserInteractionException.Activity) {
app.toast(e.message!!)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
app.toast(app.getString(R.string.downloader_error, e.simpleMessage()))
Log.e(tag, "Downloader.get threw an exception", e)
} finally {
pluginAction = null
dismissSourceSelector()
}
}
}
fun handlePluginActivityResult(result: ActivityResult) {
launchedActivity?.complete(result)
}
private fun invalidateSelectedAppInfo() = viewModelScope.launch {
val info = when (val app = selectedApp) {
is SelectedApp.Local -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.file) }
is SelectedApp.Installed -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.packageName) }
else -> null
}
// TODO: Load from local file or downloaded app
private fun invalidateSelectedAppInfo() = viewModelScope.launch {
selectedAppInfo = pm.getPackageInfo(packageName)
selectedAppInfo = info
}
fun getOptionsFiltered(bundles: List<PatchBundleInfo.Scoped>) = options.filtered(bundles)
@@ -251,50 +272,37 @@ class SelectedAppInfoViewModel(
val allowIncompatible = prefs.disablePatchVersionCompatCheck.get()
val bundles = bundleInfoFlow.first()
return Patcher.ViewModelParams(
input.packageName,
resolvedVersion.first(),
resolvedSource.first(),
patchSelection.first(),
selectedApp,
getPatches(bundles, allowIncompatible),
getOptionsFiltered(bundles)
)
}
init {
invalidateSelectedAppInfo()
fun getPatches(bundles: List<PatchBundleInfo.Scoped>, allowIncompatible: Boolean) =
selectionState.patches(bundles, allowIncompatible)
input.localPath?.let { local ->
viewModelScope.launch {
val packageInfo = pm.getPackageInfo(File(local))
fun getCustomPatches(
bundles: List<PatchBundleInfo.Scoped>,
allowIncompatible: Boolean
): PatchSelection? =
(selectionState as? SelectionState.Customized)?.patches(bundles, allowIncompatible)
_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()) {
val previous = selectionRepository.getSelection(packageName)
if (previous.patchCount == 0) return@launch
selectionFlow.value = SelectionState.Customized(previous)
}
}
fun updateConfiguration(
selection: PatchSelection?,
options: Options
) = viewModelScope.launch {
selectionState = selection?.let(SelectionState::Customized) ?: SelectionState.Default
// Get installed app info
viewModelScope.launch {
val packageInfo = async(Dispatchers.IO) { pm.getPackageInfo(packageName) }
val installedAppDeferred =
async(Dispatchers.IO) { installedAppRepository.get(packageName) }
val filteredOptions = options.filtered(bundleInfoFlow.first())
this@SelectedAppInfoViewModel.options = filteredOptions
// installedAppData =
// packageInfo.await()?.let {
// SelectedApp.Installed(
// packageName,
// it.versionName!!
// ) to installedAppDeferred.await()
// }
if (!persistConfiguration) return@launch
viewModelScope.launch(Dispatchers.Default) {
selection?.let { selectionRepository.updateSelection(packageName, it) }
?: selectionRepository.resetSelectionForPackage(packageName)
optionsRepository.saveOptions(packageName, filteredOptions)
}
}

View File

@@ -1,136 +0,0 @@
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.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
import app.revanced.manager.util.PM
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()
var selectedSource by mutableStateOf(input.selectedSource)
private set
fun selectSource(source: SelectedSource) {
selectedSource = source
}
var localApp by mutableStateOf<SourceOption?>(null)
private set
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
}
)
}
}
fun getPackageInfo(packageName: String) = pm.getPackageInfo(packageName)
var installedSource by mutableStateOf<SourceOption?>(null)
private set
init {
viewModelScope.launch {
val packageInfo = pm.getPackageInfo(input.packageName) ?: return@launch
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

@@ -82,7 +82,7 @@ class UpdateViewModel(
uiSafe(app, R.string.failed_to_download_update, "Failed to download update") {
val release = releaseInfo!!
withContext(Dispatchers.IO) {
if (!networkInfo.isSafe() && !ignoreInternetCheck) {
if (!networkInfo.isSafe(false) && !ignoreInternetCheck) {
showInternetCheckDialog = true
} else {
state = State.DOWNLOADING

View File

@@ -1,85 +0,0 @@
package app.revanced.manager.ui.viewmodel
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.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
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
) : ViewModel(), KoinComponent {
private val patchBundleRepository: PatchBundleRepository = get()
private val downloadedAppsRepository: DownloadedAppRepository = get()
private val installedAppRepository: InstalledAppRepository = get()
private val pm: PM = get()
val patchCount = input.patchSelection.patchCount
val downloadedVersions = downloadedAppsRepository.get(input.packageName)
.map { apps ->
apps.map { it.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)
init {
viewModelScope.launch {
val currentApp = pm.getPackageInfo(input.packageName)
val patchedApp = installedAppRepository.get(input.packageName)
// Skip if installed app is patched
if (patchedApp?.currentPackageName == input.packageName) return@launch
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)
private set
fun selectVersion(version: SelectedVersion) {
selectedVersion = version
}
}

View File

@@ -58,9 +58,6 @@ import kotlin.reflect.KProperty
typealias PatchSelection = Map<Int, Set<String>>
typealias Options = Map<Int, Map<String, Map<String, Any?>>>
val PatchSelection.patchCount
get() = this.values.sumOf { it.size }
val Context.isDebuggable get() = 0 != applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE
fun Context.openUrl(url: String) {

View File

@@ -61,15 +61,14 @@ Second \"item\" text"</string>
<string name="app_source_dialog_option_installed_version_not_suggested">Version %s does not match the suggested version</string>
<string name="patch_item_description">Start patching the application</string>
<string name="patch_selector_item">Patches</string>
<string name="patch_selector_item_description">%d selected</string>
<string name="patch_selector_item">Select patches</string>
<string name="patch_selector_item_description">%d patches selected</string>
<string name="no_patches_selected">No patches selected</string>
<string name="version_selector_item">Version</string>
<string name="network_unavailable_warning">Your device is not connected to the internet. Downloading will fail later.</string>
<string name="network_metered_warning">You are currently on a metered connection. Data charges from your service provider may apply.</string>
<string name="apk_source_selector_item">APK source</string>
<string name="apk_source_selector_item">Select APK source</string>
<string name="apk_source_auto">Using all APK downloaders</string>
<string name="apk_source_downloader">Using %s</string>
<string name="apk_source_installed">Using installed APK</string>
@@ -203,7 +202,7 @@ You will not be able to update the previously installed apps from this source."<
<string name="share">Share</string>
<string name="patch">Patch</string>
<string name="select_from_storage">Select from storage</string>
<string name="select_from_storage_description">Select an APK file from storage</string>
<string name="select_from_storage_description">Select an APK file from storage using file picker</string>
<string name="suggested_version_info">Suggested version: %s</string>
<string name="type_anything">Type anything to continue</string>
<string name="search">Search patches…</string>
@@ -218,6 +217,10 @@ You will not be able to update the previously installed apps from this source."<
<string name="light">Light</string>
<string name="dark">Dark</string>
<string name="appearance">Appearance</string>
<string name="networking">Networking</string>
<string name="allow_metered_networks">Allow metered networks</string>
<string name="allow_metered_networks_description">Permits automatic updates on metered networks.
The application might still warn about metered networks for manual operations.</string>
<string name="downloaded_apps">Downloaded apps</string>
<string name="process_runtime">Run Patcher in another process (experimental)</string>
<string name="process_runtime_description">This is faster and allows Patcher to use more memory</string>