Compare commits

...

15 Commits

Author SHA1 Message Date
Ushie
1c23b58f5c Merge remote-tracking branch 'origin/dev' into feat/improve-user-flow
# Conflicts:
#	app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt
#	app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt
2026-03-13 00:45:38 +03:00
semantic-release-bot
5eea987b82 chore: Release v1.26.0-dev.35 [skip ci]
# app [1.26.0-dev.35](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.34...v1.26.0-dev.35) (2026-03-12)

### Features

* Add support for writing downloader UI with fragments ([#2998](https://github.com/ReVanced/revanced-manager/issues/2998)) ([7471710](7471710f28))
2026-03-12 20:49:42 +00:00
Ushie
9432e57cb0 Merge branch 'dev' into feat/improve-user-flow 2026-03-12 22:17:11 +03:00
Ushie
8f13cdaf11 refactor: Remove unused SourceOption from SourceSelectorScreen 2026-03-12 22:13:44 +03:00
Ushie
02ac195307 refactor: Cleanup screens and viewmodels 2026-03-12 22:04:57 +03:00
planshim
3911e95746 Merge branch 'dev' into feat/improve-user-flow 2026-03-12 06:54:54 +03:00
Robert
b28e9a15be Add local APK to version and source selector 2026-01-08 22:50:50 +01:00
Robert
9fc2b4fdef Show selected source in overview 2026-01-07 20:51:39 +01:00
Robert
08662b2132 Use selected source in patcher 2026-01-07 00:42:21 +01:00
Robert
0169fd2109 Merge branch 'dev' into feat/improve-user-flow 2026-01-06 19:33:12 +01:00
Robert
c53d0462d6 Show selected source in overview 2026-01-03 20:56:45 +01:00
Robert
af8f2afa36 Implement basic source selector UI 2025-12-31 00:14:54 +01:00
Robert
9cdb8eafb3 Update selected app info screen and version selector screen 2025-12-30 21:19:22 +01:00
Robert
fda0e1697b feat: replace SelectedApp with packageName in selector 2025-12-30 17:58:54 +01:00
Robert
2d98923f50 feat: Separate version and source selection 2025-12-30 16:15:16 +01:00
28 changed files with 1269 additions and 698 deletions

View File

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

View File

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

View File

@@ -29,7 +29,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.SelectedApplicationInfo
import app.revanced.manager.ui.model.navigation.SelectedAppInfo
import app.revanced.manager.ui.model.navigation.Settings
import app.revanced.manager.ui.model.navigation.Update
import app.revanced.manager.ui.screen.AnnouncementScreen
@@ -42,7 +42,9 @@ 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,8 +99,8 @@ private fun ReVancedManager(vm: MainViewModel) {
EventEffect(vm.appSelectFlow) { app ->
navController.navigateComplex(
SelectedApplicationInfo,
SelectedApplicationInfo.ViewModelParams(app)
SelectedAppInfo,
app
)
}
@@ -138,7 +140,9 @@ private fun ReVancedManager(vm: MainViewModel) {
val data = it.toRoute<InstalledApplicationInfo>()
InstalledAppInfoScreen(
onPatchClick = vm::selectApp,
onPatchClick = { packageName ->
vm.selectApp(packageName)
},
onBackClick = navController::popBackStack,
viewModel = koinViewModel { parametersOf(data.packageName) }
)
@@ -146,8 +150,12 @@ private fun ReVancedManager(vm: MainViewModel) {
composable<AppSelector> {
AppSelectorScreen(
onSelect = vm::selectApp,
onStorageSelect = vm::selectApp,
onSelect = { packageName ->
vm.selectApp(packageName)
},
onStorageSelect = { packageName, localPath ->
vm.selectApp(packageName, localPath)
},
onBackClick = navController::popBackStack
)
}
@@ -191,11 +199,11 @@ private fun ReVancedManager(vm: MainViewModel) {
)
}
navigation<SelectedApplicationInfo>(startDestination = SelectedApplicationInfo.Main) {
composable<SelectedApplicationInfo.Main> {
navigation<SelectedAppInfo>(startDestination = SelectedAppInfo.Main) {
composable<SelectedAppInfo.Main> {
val parentBackStackEntry = navController.navGraphEntry(it)
val data =
parentBackStackEntry.getComplexArg<SelectedApplicationInfo.ViewModelParams>()
parentBackStackEntry.getComplexArg<SelectedAppInfo.ViewModelParams>()
val viewModel =
koinViewModel<SelectedAppInfoViewModel>(viewModelStoreOwner = parentBackStackEntry) {
parametersOf(data)
@@ -211,23 +219,47 @@ private fun ReVancedManager(vm: MainViewModel) {
)
}
},
onPatchSelectorClick = { app, patches, options ->
onPatchSelectorClick = { packageName, version, patchSelection, options ->
navController.navigateComplex(
SelectedApplicationInfo.PatchesSelector,
SelectedApplicationInfo.PatchesSelector.ViewModelParams(
app,
patches,
options
SelectedAppInfo.PatchesSelector,
SelectedAppInfo.PatchesSelector.ViewModelParams(
packageName,
version,
patchSelection,
options,
)
)
},
onRequiredOptions = { app, patches, options ->
onRequiredOptions = { packageName, version, patchSelection, options ->
navController.navigateComplex(
SelectedApplicationInfo.RequiredOptions,
SelectedApplicationInfo.PatchesSelector.ViewModelParams(
app,
patches,
options
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,
)
)
},
@@ -235,9 +267,9 @@ private fun ReVancedManager(vm: MainViewModel) {
)
}
composable<SelectedApplicationInfo.PatchesSelector> {
composable<SelectedAppInfo.PatchesSelector> {
val data =
it.getComplexArg<SelectedApplicationInfo.PatchesSelector.ViewModelParams>()
it.getComplexArg<SelectedAppInfo.PatchesSelector.ViewModelParams>()
val selectedAppInfoVm = koinViewModel<SelectedAppInfoViewModel>(
viewModelStoreOwner = navController.navGraphEntry(it)
)
@@ -252,9 +284,43 @@ private fun ReVancedManager(vm: MainViewModel) {
)
}
composable<SelectedApplicationInfo.RequiredOptions> {
composable<SelectedAppInfo.VersionSelector> {
val data =
it.getComplexArg<SelectedApplicationInfo.PatchesSelector.ViewModelParams>()
it.getComplexArg<SelectedAppInfo.VersionSelector.ViewModelParams>()
val selectedAppInfoVm = koinViewModel<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 = koinViewModel<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>()
val selectedAppInfoVm = koinViewModel<SelectedAppInfoViewModel>(
viewModelStoreOwner = navController.navGraphEntry(it)
)
@@ -347,4 +413,4 @@ private fun <T : Parcelable, R : ComplexParameter<T>> NavController.navigateComp
getBackStackEntry(route).savedStateHandle["args"] = data
}
private fun <T : Parcelable> NavBackStackEntry.getComplexArg() = savedStateHandle.get<T>("args")!!
private fun <T : Parcelable> NavBackStackEntry.getComplexArg() = savedStateHandle.get<T>("args")!!

View File

@@ -2,7 +2,6 @@ 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
@@ -12,6 +11,9 @@ 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

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

View File

@@ -32,6 +32,8 @@ 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,6 +26,7 @@ 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,6 +76,17 @@ 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()

View File

@@ -16,7 +16,7 @@ class PatchSelectionRepository(db: AppDatabase) {
packageName = packageName
).also { dao.createSelection(it) }.uid
suspend fun getSelection(packageName: String): Map<Int, Set<String>> =
suspend fun getSelection(packageName: String): app.revanced.manager.util.PatchSelection =
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

@@ -1,56 +1,56 @@
package app.revanced.manager.patcher.worker
import android.app.Activity
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.graphics.drawable.Icon
import android.os.Build
import android.os.Parcelable
import android.os.PowerManager
import android.util.Log
import androidx.activity.result.ActivityResult
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import app.revanced.manager.MainActivity
import app.revanced.manager.R
import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.domain.installer.RootInstaller
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.DownloaderRepository
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.worker.Worker
import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.network.downloader.LoadedDownloader
import app.revanced.manager.patcher.ProgressEvent
import app.revanced.manager.patcher.StepId
import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.runStep
import app.revanced.manager.patcher.runtime.CoroutineRuntime
import app.revanced.manager.patcher.runtime.ProcessRuntime
import app.revanced.manager.patcher.toRemoteError
import app.revanced.manager.downloader.GetScope
import app.revanced.manager.downloader.DownloaderHostApi
import app.revanced.manager.downloader.Scope
import app.revanced.manager.downloader.UserInteractionException
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
import app.revanced.manager.util.tag
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
import android.app.Activity
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.graphics.drawable.Icon
import android.os.Build
import android.os.Parcelable
import android.os.PowerManager
import android.util.Log
import androidx.activity.result.ActivityResult
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import app.revanced.manager.MainActivity
import app.revanced.manager.R
import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.domain.installer.RootInstaller
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.DownloaderRepository
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.worker.Worker
import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.downloader.DownloaderHostApi
import app.revanced.manager.downloader.GetScope
import app.revanced.manager.downloader.Scope
import app.revanced.manager.downloader.UserInteractionException
import app.revanced.manager.network.downloader.LoadedDownloader
import app.revanced.manager.patcher.ProgressEvent
import app.revanced.manager.patcher.StepId
import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.runStep
import app.revanced.manager.patcher.runtime.CoroutineRuntime
import app.revanced.manager.patcher.runtime.ProcessRuntime
import app.revanced.manager.patcher.toRemoteError
import app.revanced.manager.ui.model.SelectedSource
import app.revanced.manager.util.Options
import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.tag
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
@OptIn(DownloaderHostApi::class)
class PatcherWorker(
@@ -68,7 +68,9 @@ class PatcherWorker(
private val rootInstaller: RootInstaller by inject()
class Args(
val input: SelectedApp,
val packageName: String,
val version: String?,
val source: SelectedSource,
val output: String,
val selectedPatches: PatchSelection,
val options: Options,
@@ -76,9 +78,7 @@ class PatcherWorker(
val handleStartActivityRequest: suspend (LoadedDownloader, Intent) -> ActivityResult,
val setInputFile: suspend (File) -> Unit,
val onEvent: (ProgressEvent) -> Unit,
) {
val packageName get() = input.packageName
}
)
override suspend fun getForegroundInfo() =
ForegroundInfo(
@@ -143,7 +143,7 @@ class PatcherWorker(
val patchedApk = fs.tempDir.resolve("patched.apk")
return try {
if (args.input is SelectedApp.Installed) {
if (args.source is SelectedSource.Installed) {
installedAppRepository.get(args.packageName)?.let {
if (it.installType == InstallType.MOUNT) {
rootInstaller.unmount(args.packageName)
@@ -156,34 +156,31 @@ class PatcherWorker(
downloader,
data,
args.packageName,
args.input.version,
args.version,
prefs.suggestedVersionSafeguard.get(),
!prefs.disablePatchVersionCompatCheck.get(),
onDownload = { progress ->
args.onEvent(
ProgressEvent.Progress(
stepId = StepId.DownloadAPK,
current = progress.first,
total = progress.second
)
) { 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 selectedApp = args.input) {
is SelectedApp.Download -> {
runStep(StepId.DownloadAPK, args.onEvent) {
val (downloader, data) = downloaderRepository.unwrapParceledData(
selectedApp.data
)
download(downloader, data)
}
}
is SelectedApp.Search -> {
val inputFile = when (val source = args.source) {
SelectedSource.Auto -> throw Exception("Auto source is not supported in worker.")
is SelectedSource.Downloader -> {
runStep(StepId.DownloadAPK, args.onEvent) {
downloaderRepository.loadedDownloadersFlow.first()
.filter { downloader ->
(source.packageName == null || downloader.packageName == source.packageName) &&
(source.className == null || downloader.className == source.className)
}
.ifEmpty {
throw Exception("No downloader available.")
}
.firstNotNullOfOrNull { downloader ->
try {
val getScope = object : GetScope, Scope by downloader.scopeImpl {
@@ -200,24 +197,32 @@ class PatcherWorker(
}
}
}
withContext(Dispatchers.IO) {
downloader.impl.get(
getScope,
selectedApp.packageName,
selectedApp.version
args.packageName,
args.version
)
}?.takeIf { (_, version) -> selectedApp.version == null || version == selectedApp.version }
}?.takeIf { (_, version) ->
args.version == null || version == args.version
}
} catch (e: UserInteractionException.Activity.NotCompleted) {
throw e
} catch (_: UserInteractionException) {
null
}?.let { (data, _) -> download(downloader, data) }
}?.let { (data, _) ->
download(downloader, data)
}
} ?: throw Exception("App is not available.")
}
}
is SelectedApp.Local -> selectedApp.file.also { args.setInputFile(it) }
is SelectedApp.Installed -> File(pm.getPackageInfo(selectedApp.packageName)!!.applicationInfo!!.sourceDir)
is SelectedSource.Downloaded -> File(source.path).also { args.setInputFile(it) }
is SelectedSource.Local -> File(source.path).also { args.setInputFile(it) }
is SelectedSource.Installed -> File(
pm.getPackageInfo(args.packageName)!!.applicationInfo!!.sourceDir
).also { args.setInputFile(it) }
}
val runtime = if (prefs.useProcessRuntime.get()) {
@@ -265,8 +270,8 @@ class PatcherWorker(
Result.failure()
} finally {
patchedApk.delete()
if (args.input is SelectedApp.Local && args.input.temporary) {
args.input.file.delete()
if (args.source is SelectedSource.Local && args.source.path.startsWith(fs.uiTempDir.path)) {
File(args.source.path).delete()
}
}
}
@@ -275,4 +280,4 @@ class PatcherWorker(
private const val LOG_PREFIX = "[Worker]"
private fun String.logFmt() = "$LOG_PREFIX $this"
}
}
}

View File

@@ -15,6 +15,7 @@ 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 com.eygraber.compose.placeholder.material3.placeholder
import kotlinx.coroutines.Dispatchers
@@ -47,6 +48,8 @@ fun AppLabel(
shape = RoundedCornerShape(100)
)
.then(modifier),
style = style
style = style,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}

View File

@@ -1,35 +0,0 @@
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

@@ -0,0 +1,16 @@
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 Downloader(
val packageName: String? = null,
val className: String? = null
) : SelectedSource()
}

View File

@@ -0,0 +1,11 @@
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,9 @@
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.network.dto.ReVancedAnnouncement
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import kotlinx.parcelize.Parcelize
@@ -30,10 +31,11 @@ data object Announcements
data object Announcement : ComplexParameter<ReVancedAnnouncement>
@Serializable
data object SelectedApplicationInfo : ComplexParameter<SelectedApplicationInfo.ViewModelParams> {
data object SelectedAppInfo : ComplexParameter<SelectedAppInfo.ViewModelParams> {
@Parcelize
data class ViewModelParams(
val app: SelectedApp,
val packageName: String,
val localPath: String? = null,
val patches: PatchSelection? = null
) : Parcelable
@@ -44,12 +46,35 @@ data object SelectedApplicationInfo : ComplexParameter<SelectedApplicationInfo.V
data object PatchesSelector : ComplexParameter<PatchesSelector.ViewModelParams> {
@Parcelize
data class ViewModelParams(
val app: SelectedApp,
val currentSelection: PatchSelection?,
val packageName: String,
val version: String?,
val patchSelection: 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>
}
@@ -58,7 +83,9 @@ data object SelectedApplicationInfo : ComplexParameter<SelectedApplicationInfo.V
data object Patcher : ComplexParameter<Patcher.ViewModelParams> {
@Parcelize
data class ViewModelParams(
val selectedApp: SelectedApp,
val packageName: String,
val version: String?,
val selectedSource: SelectedSource,
val selectedPatches: PatchSelection,
val options: @RawValue Options
) : Parcelable
@@ -100,4 +127,4 @@ object Settings {
@Serializable
data object Developer : Destination
}
}

View File

@@ -42,9 +42,7 @@ import app.revanced.manager.ui.component.AppLabel
import app.revanced.manager.ui.component.AppTopBar
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
@@ -54,13 +52,13 @@ import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSelectorScreen(
onSelect: (String) -> Unit,
onStorageSelect: (SelectedApp.Local) -> Unit,
onSelect: (packageName: String) -> Unit,
onStorageSelect: (packageName: String, path: String) -> Unit,
onBackClick: () -> Unit,
vm: AppSelectorViewModel = koinViewModel()
) {
EventEffect(flow = vm.storageSelectionFlow) {
onStorageSelect(it)
onStorageSelect(it.first, it.second)
}
val pickApkLauncher =
@@ -68,19 +66,10 @@ fun AppSelectorScreen(
uri?.let(vm::handleStorageResult)
}
val suggestedVersions by vm.suggestedAppVersions.collectAsStateWithLifecycle()
var search by rememberSaveable { mutableStateOf(false) }
val appList by vm.apps.collectAsStateWithLifecycle()
val appsListFiltered by vm.filteredApps.collectAsStateWithLifecycle()
vm.nonSuggestedVersionDialogSubject?.let {
NonSuggestedVersionDialog(
suggestedVersion = suggestedVersions[it.packageName].orEmpty(),
onDismiss = vm::dismissNonSuggestedVersionDialog
)
}
if (search) {
val filterText by vm.filterText.collectAsState()
@@ -109,18 +98,20 @@ fun AppSelectorScreen(
)
},
headlineContent = { AppLabel(app.packageInfo) },
supportingContent = { Text(app.packageName) },
trailingContent = app.patches?.let {
supportingContent = app.patches?.let {
{
Text(
pluralStringResource(
R.plurals.patch_count,
R.plurals.available_patch_count,
it,
it
)
)
}
},
trailingContent = if (app.packageInfo == null) {
{ Text(stringResource(R.string.not_installed)) }
} else null,
colors = transparentListItemColors
)
}
@@ -226,22 +217,20 @@ fun AppSelectorScreen(
defaultText = app.packageName
)
},
supportingContent = {
suggestedVersions[app.packageName]?.let {
Text(stringResource(R.string.suggested_version_info, it))
}
},
trailingContent = app.patches?.let {
supportingContent = app.patches?.let {
{
Text(
pluralStringResource(
R.plurals.patch_count,
R.plurals.available_patch_count,
it,
it
)
)
}
}
},
trailingContent = if (app.packageInfo == null) {
{ Text(stringResource(R.string.not_installed)) }
} else null
)
}
}

View File

@@ -1,16 +1,12 @@
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.PaddingValues
import androidx.compose.foundation.layout.ColumnScope
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
@@ -21,82 +17,73 @@ 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
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.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.LoadedDownloader
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.SelectedApp
import app.revanced.manager.ui.model.SelectedSource
import app.revanced.manager.ui.model.SelectedVersion
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.flow.first
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SelectedAppInfoScreen(
onPatchSelectorClick: (SelectedApp, PatchSelection?, Options) -> Unit,
onRequiredOptions: (SelectedApp, PatchSelection?, Options) -> Unit,
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, localPath: String?) -> Unit,
onSourceClick: (packageName: String, version: String?, SelectedSource, localPath: String?) -> Unit,
onBackClick: () -> Unit,
vm: SelectedAppInfoViewModel
) {
val context = LocalContext.current
val resources = LocalResources.current
val context = androidx.compose.ui.platform.LocalContext.current
val networkInfo = koinInject<NetworkInfo>()
val networkConnected = remember { networkInfo.isConnected() }
val networkMetered = remember { !networkInfo.isUnmetered() }
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::handleDownloaderActivityResult
)
EventEffect(flow = vm.launchActivityFlow) { intent ->
launcher.launch(intent)
}
val packageName = vm.packageName
val composableScope = rememberCoroutineScope()
val downloaders by vm.downloaders.collectAsStateWithLifecycle(emptyList())
val error by vm.errorFlow.collectAsStateWithLifecycle(null)
val selectedVersion by vm.selectedVersion.collectAsStateWithLifecycle()
val resolvedVersion by vm.resolvedVersion.collectAsStateWithLifecycle(null)
val versionSelection by vm.versionPatchSelection.collectAsStateWithLifecycle(emptyMap())
val selectedSource by vm.selectedSource.collectAsStateWithLifecycle()
val resolvedSource by vm.resolvedSource.collectAsStateWithLifecycle(SelectedSource.Downloader())
val customSelection by vm.customSelection.collectAsStateWithLifecycle(null)
val currentPatchSelection by vm.patchSelection.collectAsStateWithLifecycle(emptyMap())
val patchCount = currentPatchSelection.patchCount
val incompatibleCount by vm.incompatiblePatchCount.collectAsStateWithLifecycle(0)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
@@ -119,22 +106,28 @@ fun SelectedAppInfoScreen(
)
},
onClick = patchClick@{
if (selectedPatchCount == 0) {
context.toast(resources.getString(R.string.no_patches_selected))
if (patchCount == 0) {
context.toast(context.getString(R.string.no_patches_selected))
return@patchClick
}
composableScope.launch {
if (!vm.hasSetRequiredOptions(patches)) {
if (!vm.hasSetRequiredOptions(currentPatchSelection)) {
onRequiredOptions(
vm.selectedApp,
vm.getCustomPatches(bundles, allowIncompatiblePatches),
vm.options
packageName,
resolvedVersion,
customSelection,
vm.options,
)
return@launch
}
vm.reloadDownloaders()
if (vm.errorFlow.first() != null) {
context.toast(context.getString(R.string.no_downloader_available))
return@launch
}
onPatchClick()
}
}
@@ -142,110 +135,110 @@ fun SelectedAppInfoScreen(
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
val downloaders by vm.downloaders.collectAsStateWithLifecycle(emptyList())
if (vm.showSourceSelector) {
val requiredVersion by vm.requiredVersion.collectAsStateWithLifecycle(null)
AppSourceSelectorDialog(
downloaders = downloaders,
installedApp = vm.installedAppData,
searchApp = SelectedApp.Search(
vm.packageName,
vm.desiredVersion
),
activeSearchJob = vm.activeDownloader,
hasRoot = vm.hasRoot,
onDismissRequest = vm::dismissSourceSelector,
onSelectDownloader = vm::searchUsingDownloader,
requiredVersion = requiredVersion,
onSelect = {
vm.selectedApp = it
vm.dismissSourceSelector()
}
)
}
ColumnWithScrollbar(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
AppInfo(vm.selectedAppInfo, placeholderLabel = packageName) {
Text(
version ?: stringResource(R.string.selected_app_meta_any_version),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
vm.selectedAppInfo?.packageName?.let {
Text(
it,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
}
PageItem(
R.string.patch_selector_item,
stringResource(
R.string.patch_selector_item_description,
selectedPatchCount
),
title = R.string.patch_selector_item,
description = stringResource(R.string.patch_selector_item_description, patchCount),
onClick = {
onPatchSelectorClick(
vm.selectedApp,
vm.getCustomPatches(
bundles,
allowIncompatiblePatches
),
packageName,
resolvedVersion,
customSelection,
vm.options
)
}
)
PageItem(
R.string.apk_source_selector_item,
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,
downloaders.find { it.packageName == app.data.downloaderPackageName && it.name == app.data.downloaderClassName }?.name
?: app.data.downloaderPackageName
)
is SelectedApp.Local -> stringResource(R.string.apk_source_local)
},
onClick = {
vm.showSourceSelector()
extraDescription = if (incompatibleCount > 0) {
{
Text(
pluralStringResource(
R.plurals.version_selector_incompatible_patches,
incompatibleCount,
incompatibleCount
),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
null
}
)
PageItem(
title = R.string.version_selector_item,
description = selectedVersionDescription(selectedVersion, resolvedVersion),
onClick = {
onVersionClick(
packageName,
versionSelection,
selectedVersion,
vm.localPath,
)
},
)
PageItem(
title = R.string.apk_source_selector_item,
description = selectedSourceDescription(
selectedSource = selectedSource,
resolvedSource = resolvedSource,
downloaders = downloaders,
),
onClick = {
onSourceClick(
packageName,
resolvedVersion,
selectedSource,
vm.localPath,
)
},
)
error?.let {
Text(
stringResource(it.resourceId),
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 24.dp)
modifier = Modifier.padding(horizontal = 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 }
if (resolvedSource is SelectedSource.Downloader) {
Column(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
when {
!networkConnected -> {
NotificationCard(
isWarning = true,
icon = Icons.Outlined.WarningAmber,
text = stringResource(R.string.network_unavailable_warning),
onDismiss = null
)
}
when {
!needsInternet -> {}
!networkConnected -> {
NotificationCard(
isWarning = true,
icon = Icons.Outlined.WarningAmber,
text = stringResource(R.string.network_unavailable_warning),
onDismiss = null
)
}
networkMetered -> {
NotificationCard(
isWarning = true,
icon = Icons.Outlined.WarningAmber,
text = stringResource(R.string.network_metered_warning),
onDismiss = null
)
networkMetered -> {
NotificationCard(
isWarning = true,
icon = Icons.Outlined.WarningAmber,
text = stringResource(R.string.network_metered_warning),
onDismiss = null
)
}
}
}
}
@@ -254,11 +247,64 @@ fun SelectedAppInfoScreen(
}
@Composable
private fun PageItem(@StringRes title: Int, description: String, onClick: () -> Unit) {
private fun selectedVersionDescription(
selectedVersion: SelectedVersion,
resolvedVersion: String?,
): String {
val resolvedText = resolvedVersion ?: stringResource(R.string.selected_app_meta_any_version)
return if (selectedVersion is SelectedVersion.Auto) {
stringResource(R.string.selected_app_meta_auto, stringResource(R.string.app_source_dialog_option_auto), resolvedText)
} else {
resolvedText
}
}
@Composable
private fun selectedSourceDescription(
selectedSource: SelectedSource,
resolvedSource: SelectedSource,
downloaders: List<LoadedDownloader>,
): String {
val resolvedText = when (resolvedSource) {
SelectedSource.Auto -> stringResource(R.string.app_source_dialog_option_auto)
SelectedSource.Installed -> stringResource(R.string.selected_app_meta_source_installed_apk)
is SelectedSource.Downloaded -> stringResource(R.string.selected_app_meta_source_downloaded_apk)
is SelectedSource.Local -> stringResource(R.string.selected_app_meta_source_local_apk)
is SelectedSource.Downloader -> {
if (resolvedSource.packageName == null && resolvedSource.className == null) {
stringResource(R.string.selected_app_meta_source_any_downloader)
} else {
downloaders.firstOrNull { downloader ->
downloader.packageName == resolvedSource.packageName &&
downloader.className == resolvedSource.className
}?.name ?: stringResource(R.string.selected_app_meta_source_any_downloader)
}
}
}
return if (selectedSource is SelectedSource.Auto) {
stringResource(
R.string.selected_app_meta_auto,
stringResource(R.string.app_source_dialog_option_auto),
resolvedText
)
} else {
resolvedText
}
}
@Composable
private fun PageItem(
@StringRes title: Int,
description: String,
onClick: () -> Unit,
enabled: Boolean = true,
extraDescription: @Composable (ColumnScope.() -> Unit)? = null,
) {
ListItem(
modifier = Modifier
.clickable(onClick = onClick)
.padding(start = 8.dp),
.clickable(enabled, onClick = onClick)
.enabled(enabled),
headlineContent = {
Text(
stringResource(title),
@@ -267,105 +313,17 @@ private fun PageItem(@StringRes title: Int, description: String, onClick: () ->
)
},
supportingContent = {
Text(
description,
color = MaterialTheme.colorScheme.outline,
style = MaterialTheme.typography.bodyMedium
)
Column {
Text(
description,
color = MaterialTheme.colorScheme.outline,
style = MaterialTheme.typography.bodyMedium
)
extraDescription?.invoke(this)
}
},
trailingContent = {
Icon(Icons.AutoMirrored.Outlined.ArrowRight, null)
}
)
}
@Composable
private fun AppSourceSelectorDialog(
downloaders: List<LoadedDownloader>,
installedApp: Pair<SelectedApp.Installed, InstalledApp?>?,
searchApp: SelectedApp.Search,
activeSearchJob: LoadedDownloader?,
hasRoot: Boolean,
requiredVersion: String?,
onDismissRequest: () -> Unit,
onSelectDownloader: (LoadedDownloader) -> 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 hasDownloader = downloaders.isNotEmpty()
ListItem(
modifier = Modifier
.clickable(enabled = canSelect && hasDownloader) { onSelect(searchApp) }
.enabled(hasDownloader),
headlineContent = { Text(stringResource(R.string.app_source_dialog_option_auto)) },
supportingContent = {
Text(
if (hasDownloader)
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(
downloaders,
key = { "downloader_${it.packageName}_${it.className}" }) { downloader ->
ListItem(
modifier = Modifier.clickable(enabled = canSelect) {
onSelectDownloader(
downloader
)
},
headlineContent = { Text(downloader.name) },
trailingContent = (@Composable { LoadingIndicator() }).takeIf { activeSearchJob == downloader },
colors = transparentListItemColors
)
}
}
}
)
}

View File

@@ -0,0 +1,139 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.ExperimentalMaterial3Api
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.ColumnWithScrollbar
import app.revanced.manager.ui.component.GroupHeader
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 allDownloaderSections by viewModel.downloaderSections.collectAsStateWithLifecycle(emptyList())
val (downloaderSections, unavailableSections) = allDownloaderSections.partition { section ->
section.options.all { it.disableReason == null }
}
val unavailableDownloaders = unavailableSections.flatMap { it.options }
Scaffold(
topBar = {
AppTopBar(
title = { Text(stringResource(R.string.app_source_dialog_title)) },
onBackClick = onBackClick,
)
},
floatingActionButton = {
HapticExtendedFloatingActionButton(
text = { Text(stringResource(R.string.save)) },
icon = { Icon(Icons.Outlined.Save, null) },
onClick = { onSave(viewModel.selectedSource) },
)
}
) { paddingValues ->
ColumnWithScrollbar(modifier = Modifier.padding(paddingValues)) {
SourceOption(
isSelected = viewModel.selectedSource == SelectedSource.Auto,
onSelect = { viewModel.selectSource(SelectedSource.Auto) },
headlineContent = { Text(stringResource(R.string.version_selector_auto_title)) },
supportingContent = { Text(stringResource(R.string.source_selector_auto_description)) },
)
viewModel.localApp?.let { option ->
GroupHeader(stringResource(R.string.source_selector_category_local))
SourceOption(option, viewModel.selectedSource, viewModel::selectSource)
}
viewModel.installedSource?.let { option ->
GroupHeader(stringResource(R.string.installed))
SourceOption(option, viewModel.selectedSource, viewModel::selectSource)
}
if (downloadedApps.isNotEmpty()) {
GroupHeader(stringResource(R.string.source_selector_category_downloaded))
downloadedApps.forEach { option ->
SourceOption(option, viewModel.selectedSource, viewModel::selectSource)
}
}
downloaderSections.forEach { section ->
GroupHeader(section.title)
section.options.forEach { option ->
SourceOption(option, viewModel.selectedSource, viewModel::selectSource)
}
}
if (unavailableDownloaders.isNotEmpty()) {
GroupHeader(stringResource(R.string.downloaders))
unavailableDownloaders.forEach { option ->
SourceOption(option, viewModel.selectedSource, viewModel::selectSource)
}
}
}
}
}
@Composable
private fun SourceOption(
sourceOption: SourceSelectorViewModel.SourceOption,
selectedSource: SelectedSource,
onSelect: (SelectedSource) -> Unit,
) {
SourceOption(
isSelected = selectedSource == sourceOption.source,
onSelect = { onSelect(sourceOption.source) },
headlineContent = { Text(sourceOption.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = sourceOption.disableReason?.let { { Text(stringResource(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

@@ -0,0 +1,128 @@
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.pluralStringResource
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.LazyColumnWithScrollbar
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(stringResource(R.string.version_selector_title)) },
onBackClick = onBackClick
)
},
floatingActionButton = {
HapticExtendedFloatingActionButton(
text = { Text(stringResource(R.string.save)) },
icon = { Icon(Icons.Outlined.Save, contentDescription = null) },
onClick = { onSave(viewModel.selectedVersion) }
)
}
) { paddingValues ->
LazyColumnWithScrollbar(contentPadding = paddingValues) {
item {
VersionOption(
version = SelectedVersion.Auto,
isSelected = viewModel.selectedVersion is SelectedVersion.Auto,
onSelect = viewModel::selectVersion,
headlineContent = { Text(stringResource(R.string.version_selector_auto_title)) },
supportingContent = { Text(stringResource(R.string.version_selector_auto_description)) }
)
HorizontalDivider()
}
if (versions.isNotEmpty()) {
items(versions, key = { (version, _) -> version.version }) { (version, incompatibleCount) ->
val overlineText = when {
localVersion == version.version -> stringResource(R.string.version_selector_state_local)
version.version in downloadedVersions && viewModel.installedAppVersion == version.version ->
stringResource(R.string.version_selector_state_downloaded_installed)
version.version in downloadedVersions -> stringResource(R.string.version_selector_state_downloaded)
viewModel.installedAppVersion == version.version -> stringResource(R.string.version_selector_state_installed)
else -> null
}
VersionOption(
version = version,
isSelected = viewModel.selectedVersion == version,
onSelect = viewModel::selectVersion,
headlineContent = { Text(version.version) },
supportingContent = {
Text(
if (incompatibleCount == 0) stringResource(R.string.version_selector_all_patches_compatible)
else pluralStringResource(R.plurals.version_selector_incompatible_patches, incompatibleCount, incompatibleCount)
)
},
overlineContent = overlineText?.let { { Text(it) } }
)
}
} else {
item {
VersionOption(
version = SelectedVersion.Any,
isSelected = viewModel.selectedVersion is SelectedVersion.Any,
onSelect = viewModel::selectVersion,
headlineContent = { Text(stringResource(R.string.version_selector_any_title)) },
supportingContent = { Text(stringResource(R.string.version_selector_any_description)) }
)
}
}
}
}
}
@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

@@ -3,9 +3,6 @@ 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
@@ -13,8 +10,6 @@ import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
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
@@ -35,7 +30,6 @@ class AppSelectorViewModel(
private val app: Application,
private val pm: PM,
fs: Filesystem,
private val patchBundleRepository: PatchBundleRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val inputFile = savedStateHandle.saveable(key = "inputFile") {
@@ -73,28 +67,15 @@ class AppSelectorViewModel(
initialValue = null,
)
private val storageSelectionChannel = Channel<SelectedApp.Local>()
private val storageSelectionChannel = Channel<Pair<String, String>>()
val storageSelectionFlow = storageSelectionChannel.receiveAsFlow()
val suggestedAppVersions = patchBundleRepository.suggestedVersions.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyMap(),
)
var nonSuggestedVersionDialogSubject by mutableStateOf<SelectedApp.Local?>(null)
private set
fun setFilterText(filter: String) {
filterTextFlow.value = filter
}
fun loadLabel(app: PackageInfo?) = with(pm) { app?.label() ?: "Not installed" }
fun dismissNonSuggestedVersionDialog() {
nonSuggestedVersionDialogSubject = null
}
fun handleStorageResult(uri: Uri) = viewModelScope.launch {
val selectedApp = withContext(Dispatchers.IO) {
loadSelectedFile(uri)
@@ -105,11 +86,8 @@ class AppSelectorViewModel(
return@launch
}
if (patchBundleRepository.isVersionAllowed(selectedApp.packageName, selectedApp.version)) {
storageSelectionChannel.send(selectedApp)
} else {
nonSuggestedVersionDialogSubject = selectedApp
}
// TODO: Disallow if 0 patches are compatible
storageSelectionChannel.send(selectedApp)
}
private fun loadSelectedFile(uri: Uri) =
@@ -119,12 +97,7 @@ class AppSelectorViewModel(
Files.copy(stream, toPath())
pm.getPackageInfo(this)?.let { packageInfo ->
SelectedApp.Local(
packageName = packageInfo.packageName,
version = packageInfo.versionName!!,
file = this,
temporary = true
)
Pair(packageInfo.packageName, path)
}
}
}

View File

@@ -12,12 +12,11 @@ import app.revanced.manager.data.room.apps.installed.InstallType
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.InstalledAppRepository
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.model.navigation.SelectedAppInfo
import app.revanced.manager.ui.theme.Theme
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast
@@ -36,41 +35,17 @@ private const val LEGACY_LIST_PREFIX = "VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIGxpc3Qu
class MainViewModel(
private val patchBundleRepository: PatchBundleRepository,
private val patchSelectionRepository: PatchSelectionRepository,
private val downloadedAppRepository: DownloadedAppRepository,
private val installedAppRepository: InstalledAppRepository,
private val keystoreManager: KeystoreManager,
private val app: Application,
val prefs: PreferencesManager,
private val json: Json
) : ViewModel() {
private val appSelectChannel = Channel<SelectedApp>()
private val appSelectChannel = Channel<SelectedAppInfo.ViewModelParams>()
val appSelectFlow = appSelectChannel.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)))
fun selectApp(packageName: String, localPath: String? = null) = viewModelScope.launch {
appSelectChannel.send(SelectedAppInfo.ViewModelParams(packageName, localPath))
}
init {

View File

@@ -37,7 +37,7 @@ import app.revanced.manager.patcher.logger.LogLevel
import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.worker.PatcherWorker
import app.revanced.manager.ui.model.InstallerModel
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.model.SelectedSource
import app.revanced.manager.ui.model.State
import app.revanced.manager.ui.model.Step
import app.revanced.manager.ui.model.StepCategory
@@ -96,9 +96,8 @@ class PatcherViewModel(
private val ackpineInstaller: PackageInstaller = get()
private var installedApp: InstalledApp? = null
private val selectedApp = input.selectedApp
val packageName = selectedApp.packageName
val version = selectedApp.version
val packageName = input.packageName
val version = input.version
var installedPackageName by savedStateHandle.saveable(
key = "installedPackageName",
@@ -163,7 +162,7 @@ class PatcherViewModel(
}
val steps by savedStateHandle.saveable(saver = snapshotStateListSaver()) {
generateSteps(app, input.selectedApp, input.selectedPatches).toMutableStateList()
generateSteps(app, input.selectedSource, input.selectedPatches).toMutableStateList()
}
val progress by derivedStateOf {
@@ -181,7 +180,9 @@ class PatcherViewModel(
ParcelUuid(
workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>(
"patching", PatcherWorker.Args(
input.selectedApp,
input.packageName,
input.version,
input.selectedSource,
outputFile.path,
input.selectedPatches,
input.options,
@@ -260,7 +261,7 @@ class PatcherViewModel(
super.onCleared()
workManager.cancelWorkById(patcherWorkerId.uuid)
if (input.selectedApp is SelectedApp.Installed && installedApp?.installType == InstallType.MOUNT) {
if (input.selectedSource is SelectedSource.Installed && installedApp?.installType == InstallType.MOUNT) {
GlobalScope.launch(Dispatchers.Main) {
uiSafe(app, R.string.failed_to_mount, "Failed to mount") {
withTimeout(Duration.ofMinutes(1L)) {
@@ -385,7 +386,7 @@ class PatcherViewModel(
installedAppRepository.addOrUpdate(
installerPkgName,
packageName,
input.selectedApp.version
input.version
?: withContext(Dispatchers.IO) { pm.getPackageInfo(outputFile)?.versionName!! },
InstallType.DEFAULT,
input.selectedPatches,
@@ -439,9 +440,20 @@ class PatcherViewModel(
currentPackageInfo.label()
}
val inputVersion = input.selectedApp.version
?: withContext(Dispatchers.IO) { inputFile?.let(pm::getPackageInfo)?.versionName }
?: throw Exception("Failed to determine input APK version")
val existingPackageInfo =
withContext(Dispatchers.IO) { pm.getPackageInfo(currentPackageInfo.packageName) }
if (existingPackageInfo == null && currentPackageInfo.splitNames.isNotEmpty()) {
packageInstallerStatus =
AndroidPackageInstaller.STATUS_FAILURE_INVALID
return@launch
}
val inputVersion = when (val source = input.selectedSource) {
is SelectedSource.Downloaded -> source.version
else -> input.version
?: withContext(Dispatchers.IO) { inputFile?.let(pm::getPackageInfo)?.versionName }
?: throw Exception("Failed to determine input APK version")
}
needsRootUninstall = true
// Install as root
@@ -533,10 +545,10 @@ class PatcherViewModel(
fun generateSteps(
context: Context,
selectedApp: SelectedApp,
selectedSource: SelectedSource,
selectedPatches: PatchSelection
): List<Step> = buildList {
if (selectedApp is SelectedApp.Download || selectedApp is SelectedApp.Search)
if (selectedSource is SelectedSource.Downloader)
add(
Step(
StepId.DownloadAPK,
@@ -594,4 +606,4 @@ class PatcherViewModel(
)
}
}
}
}

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.SelectedApplicationInfo
import app.revanced.manager.ui.model.navigation.SelectedAppInfo
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: SelectedApplicationInfo.PatchesSelector.ViewModelParams) :
class PatchesSelectorViewModel(input: SelectedAppInfo.PatchesSelector.ViewModelParams) :
ViewModel(), KoinComponent {
private val app: Application = get()
private val savedStateHandle: SavedStateHandle = get()
private val prefs: PreferencesManager = get()
private val packageName = input.app.packageName
val appVersion = input.app.version
private val packageName = input.packageName
val appVersion = input.version
var selectionWarningEnabled by mutableStateOf(true)
private set
@@ -62,7 +62,7 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
val allowIncompatiblePatches =
get<PreferencesManager>().disablePatchVersionCompatCheck.getBlocking()
val bundlesFlow =
get<PatchBundleRepository>().scopedBundleInfoFlow(packageName, input.app.version)
get<PatchBundleRepository>().scopedBundleInfoFlow(packageName, input.version)
init {
viewModelScope.launch {
@@ -88,7 +88,7 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
key = "selection",
stateSaver = selectionSaver,
) {
mutableStateOf(input.currentSelection?.toPersistentPatchSelection())
mutableStateOf(input.patchSelection?.toPersistentPatchSelection())
}
private val patchOptions: PersistentOptions by savedStateHandle.saveable(

View File

@@ -1,137 +1,143 @@
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.data.room.apps.installed.InstallType
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.DownloaderRepository
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.toPatchSelection
import app.revanced.manager.network.downloader.LoadedDownloader
import app.revanced.manager.network.downloader.ParceledDownloaderData
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.requiredOptionsSet
import app.revanced.manager.downloader.GetScope
import app.revanced.manager.downloader.DownloaderHostApi
import app.revanced.manager.downloader.Scope
import app.revanced.manager.downloader.UserInteractionException
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.toPatchSelection
import app.revanced.manager.ui.model.SelectedSource
import app.revanced.manager.ui.model.SelectedVersion
import app.revanced.manager.ui.model.navigation.Patcher
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
import app.revanced.manager.ui.model.navigation.SelectedAppInfo
import app.revanced.manager.util.Options
import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection
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 app.revanced.manager.util.patchCount
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.flatMapLatest
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, DownloaderHostApi::class)
@OptIn(SavedStateHandleSaveableApi::class)
class SelectedAppInfoViewModel(
input: SelectedApplicationInfo.ViewModelParams
input: SelectedAppInfo.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 downloaderRepository: DownloaderRepository = 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()
val prefs: PreferencesManager = get()
val downloaders = downloaderRepository.loadedDownloadersFlow
val desiredVersion = input.app.version
val packageName = input.app.packageName
val downloaders = downloaderRepository.loadedDownloadersFlow
val packageName = input.packageName
val localPath = input.localPath
private val persistConfiguration = input.patches == null
val hasRoot = rootInstaller.hasRootAccess()
var installedAppData: Pair<SelectedApp.Installed, InstalledApp?>? by mutableStateOf(null)
private set
private val selectionFlow = MutableStateFlow(
input.patches?.let(SelectionState::Customized) ?: SelectionState.Default
)
private var _selectedApp by savedStateHandle.saveable {
mutableStateOf(input.app)
private val _selectedVersion = MutableStateFlow<SelectedVersion>(SelectedVersion.Auto)
val selectedVersion: StateFlow<SelectedVersion> = _selectedVersion
private val _selectedSource = MutableStateFlow<SelectedSource>(SelectedSource.Auto)
val selectedSource: StateFlow<SelectedSource> = _selectedSource
private val unscopedBundles = bundleRepository.scopedBundleInfoFlow(packageName, null)
val versionPatchSelection = combine(selectionFlow, unscopedBundles) { selection, bundleInfo ->
selection.patches(bundleInfo, allowIncompatible = true)
}
var selectedAppInfo: PackageInfo? by mutableStateOf(null)
private set
@OptIn(ExperimentalCoroutinesApi::class)
val mostCompatibleVersions = versionPatchSelection.flatMapLatest { selection ->
bundleRepository.suggestedVersions(packageName, selection)
}
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) }
installedAppData =
packageInfo.await()?.let {
SelectedApp.Installed(
packageName,
it.versionName!!
) to installedAppDeferred.await()
}
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
}
}
val requiredVersion = combine(
prefs.suggestedVersionSafeguard.flow,
bundleRepository.suggestedVersions
) { suggestedVersionSafeguard, suggestedVersions ->
if (!suggestedVersionSafeguard) return@combine null
suggestedVersions[input.app.packageName]
@OptIn(ExperimentalCoroutinesApi::class)
val scopedBundles = resolvedVersion.flatMapLatest { version ->
bundleRepository.scopedBundleInfoFlow(packageName, version)
}
val bundleInfoFlow by derivedStateOf {
bundleRepository.scopedBundleInfoFlow(packageName, selectedApp.version)
val patchSelection = combine(selectionFlow, scopedBundles) { selection, bundleInfo ->
selection.patches(bundleInfo, allowIncompatible = true)
}
val customSelection = combine(selectionFlow, scopedBundles) { selection, bundleInfo ->
(selection as? SelectionState.Customized)?.patches(bundleInfo, allowIncompatible = true)
}
val incompatiblePatchCount = combine(selectionFlow, scopedBundles) { selection, bundleInfo ->
val selectedPatches = selection.patches(bundleInfo, allowIncompatible = true)
bundleInfo.sumOf { bundle ->
bundle.incompatible.count { patch ->
patch.name in selectedPatches[bundle.uid].orEmpty()
}
}
}
val resolvedSource = combine(
_selectedSource,
resolvedVersion,
) { source, version ->
when {
source == SelectedSource.Auto -> resolveAutoSource(version)
isSourceValid(source, version) -> source
else -> resolveAutoSource(version)
}
}
var options: Options by savedStateHandle.saveable {
viewModelScope.launch {
if (!persistConfiguration) return@launch // TODO: save options for patched apps.
val bundlePatches = bundleInfoFlow.first()
val bundlePatches = unscopedBundles.first()
.associate { it.uid to it.patches.associateBy { patch -> patch.name } }
options = withContext(Dispatchers.Default) {
@@ -143,165 +149,106 @@ class SelectedAppInfoViewModel(
}
private set
private var selectionState: SelectionState by savedStateHandle.saveable {
if (input.patches != null)
return@saveable mutableStateOf(SelectionState.Customized(input.patches))
// 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 downloaderAction: Pair<LoadedDownloader, Job>? by mutableStateOf(null)
val activeDownloader get() = downloaderAction?.first
private var launchedActivity by mutableStateOf<CompletableDeferred<ActivityResult>?>(null)
private val launchActivityChannel = Channel<Intent>()
val launchActivityFlow = launchActivityChannel.receiveAsFlow()
val errorFlow = combine(downloaders, snapshotFlow { selectedApp }) { downloaderList, app ->
val errorFlow = combine(downloaders, resolvedSource) { downloaderList, source ->
when {
app is SelectedApp.Search && downloaderList.isEmpty() -> Error.NoDownloaders
source is SelectedSource.Downloader && matchingDownloaders(
source,
downloaderList
).isEmpty() -> Error.NoDownloaders
else -> null
}
}
fun showSourceSelector() {
dismissSourceSelector()
showSourceSelector = true
var selectedAppInfo: PackageInfo? by mutableStateOf(null)
private set
fun updateVersion(version: SelectedVersion) {
_selectedVersion.value = version
}
private fun cancelDownloaderAction() {
downloaderAction?.second?.cancel()
downloaderAction = null
fun updateSource(source: SelectedSource) {
_selectedSource.value = source
}
fun dismissSourceSelector() {
cancelDownloaderAction()
showSourceSelector = false
}
fun searchUsingDownloader(downloader: LoadedDownloader) {
cancelDownloaderAction()
downloaderAction = downloader to viewModelScope.launch {
try {
val scope = object : GetScope, Scope by downloader.scopeImpl {
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) {
downloader.impl.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(downloader, 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 {
downloaderAction = null
dismissSourceSelector()
}
}
}
fun handleDownloaderActivityResult(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
}
selectedAppInfo = info
suspend fun reloadDownloaders() {
downloaderRepository.reload()
}
fun getOptionsFiltered(bundles: List<PatchBundleInfo.Scoped>) = options.filtered(bundles)
suspend fun hasSetRequiredOptions(patchSelection: PatchSelection) = bundleInfoFlow
.first()
.requiredOptionsSet(
allowIncompatible = prefs.disablePatchVersionCompatCheck.get(),
isSelected = { bundle, patch -> patch.name in patchSelection[bundle.uid]!! },
suspend fun hasSetRequiredOptions(patchSelection: PatchSelection): Boolean {
val allowIncompatible = prefs.disablePatchVersionCompatCheck.get()
val bundles = scopedBundles.first()
return bundles.requiredOptionsSet(
allowIncompatible = allowIncompatible,
isSelected = { bundle, patch -> patch.name in patchSelection[bundle.uid].orEmpty() },
optionsForPatch = { bundle, patch -> options[bundle.uid]?.get(patch.name) },
)
}
suspend fun getPatcherParams(): Patcher.ViewModelParams {
val allowIncompatible = prefs.disablePatchVersionCompatCheck.get()
val bundles = bundleInfoFlow.first()
val bundles = scopedBundles.first()
return Patcher.ViewModelParams(
selectedApp,
getPatches(bundles, allowIncompatible),
packageName,
resolvedVersion.first(),
resolvedSource.first(),
selectionFlow.value.patches(bundles, allowIncompatible),
getOptionsFiltered(bundles)
)
}
fun getPatches(bundles: List<PatchBundleInfo.Scoped>, allowIncompatible: Boolean) =
selectionState.patches(bundles, allowIncompatible)
fun getCustomPatches(
bundles: List<PatchBundleInfo.Scoped>,
allowIncompatible: Boolean
): PatchSelection? =
(selectionState as? SelectionState.Customized)?.patches(bundles, allowIncompatible)
fun updateConfiguration(
selection: PatchSelection?,
options: Options
selectedOptions: Options
) = viewModelScope.launch {
selectionState = selection?.let(SelectionState::Customized) ?: SelectionState.Default
selectionFlow.value = selection?.let(SelectionState::Customized) ?: SelectionState.Default
val filteredOptions = options.filtered(bundleInfoFlow.first())
this@SelectedAppInfoViewModel.options = filteredOptions
val filteredOptions = selectedOptions.filtered(scopedBundles.first())
options = filteredOptions
if (!persistConfiguration) return@launch
viewModelScope.launch(Dispatchers.Default) {
selection?.let { selectionRepository.updateSelection(packageName, it) }
?: selectionRepository.resetSelectionForPackage(packageName)
optionsRepository.saveOptions(packageName, filteredOptions)
selection?.let { selectionRepository.updateSelection(packageName, it) }
?: selectionRepository.resetSelectionForPackage(packageName)
optionsRepository.saveOptions(packageName, filteredOptions)
}
init {
viewModelScope.launch {
reloadDownloaders()
invalidateSelectedAppInfo()
}
localPath?.let { local ->
viewModelScope.launch {
val packageInfo = withContext(Dispatchers.IO) { pm.getPackageInfo(File(local)) }
?: return@launch
_selectedVersion.value = SelectedVersion.Specific(packageInfo.versionName!!)
_selectedSource.value = SelectedSource.Local(local)
}
}
viewModelScope.launch {
if (prefs.disableSelectionWarning.get()) {
val previous = selectionRepository.getSelection(packageName)
if (previous.patchCount != 0) {
selectionFlow.value = SelectionState.Customized(previous)
}
}
}
viewModelScope.launch {
combine(_selectedSource, resolvedVersion) { source, version -> source to version }
.collect { (source, version) ->
if (source != SelectedSource.Auto && !isSourceValid(source, version)) {
_selectedSource.value = SelectedSource.Auto
}
}
}
}
@@ -309,6 +256,70 @@ class SelectedAppInfoViewModel(
NoDownloaders(R.string.no_downloader_available)
}
private suspend fun invalidateSelectedAppInfo() {
selectedAppInfo = withContext(Dispatchers.IO) {
pm.getPackageInfo(packageName) ?: localPath?.let { pm.getPackageInfo(File(it)) }
}
}
private suspend fun resolveAutoSource(version: String?): SelectedSource {
val installedPackage = pm.getPackageInfo(packageName)
val installedApp = installedAppRepository.get(packageName)
val installType = installedApp?.installType
val isInstalledVersionMatch = version == null || installedPackage?.versionName == version
if (
installedPackage != null &&
isInstalledVersionMatch &&
installType != InstallType.DEFAULT &&
!(installType == InstallType.MOUNT && !rootInstaller.hasRootAccess()) &&
installedPackage.applicationInfo?.splitSourceDirs.isNullOrEmpty()
) {
return SelectedSource.Installed
}
if (version != null) {
downloadedAppRepository.get(packageName, version)?.let { app ->
return SelectedSource.Downloaded(
path = downloadedAppRepository.getApkFileForApp(app).path,
version = version
)
}
}
return SelectedSource.Downloader()
}
private suspend fun isSourceValid(source: SelectedSource, version: String?): Boolean =
when (source) {
SelectedSource.Auto -> true
SelectedSource.Installed -> {
val installedPackage = pm.getPackageInfo(packageName) ?: return false
val installedApp = installedAppRepository.get(packageName)
installedApp?.installType != InstallType.DEFAULT &&
!(installedApp?.installType == InstallType.MOUNT && !rootInstaller.hasRootAccess()) &&
(version == null || installedPackage.versionName == version)
}
is SelectedSource.Downloaded -> version == null || source.version == version
is SelectedSource.Local -> {
val packageInfo = withContext(Dispatchers.IO) { pm.getPackageInfo(File(source.path)) }
?: return false
version == null || packageInfo.versionName == version
}
is SelectedSource.Downloader -> true
}
private fun matchingDownloaders(
source: SelectedSource.Downloader,
downloaderList: List<app.revanced.manager.network.downloader.LoadedDownloader>
) = downloaderList.filter { downloader ->
(source.packageName == null || downloader.packageName == source.packageName) &&
(source.className == null || downloader.className == source.className)
}
private companion object {
/**
* Returns a copy with all nonexistent options removed.
@@ -342,9 +353,7 @@ private sealed interface SelectionState : Parcelable {
@Parcelize
data class Customized(val patchSelection: PatchSelection) : SelectionState {
override fun patches(bundles: List<PatchBundleInfo.Scoped>, allowIncompatible: Boolean) =
bundles.toPatchSelection(
allowIncompatible
) { uid, patch ->
bundles.toPatchSelection(allowIncompatible) { uid, patch ->
patchSelection[uid]?.contains(patch.name) ?: false
}
}
@@ -355,4 +364,3 @@ private sealed interface SelectionState : Parcelable {
bundles.toPatchSelection(allowIncompatible) { _, patch -> patch.include }
}
}

View File

@@ -0,0 +1,161 @@
package app.revanced.manager.ui.viewmodel
import androidx.annotation.StringRes
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.R
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.DownloaderRepository
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.network.downloader.DownloaderPackageState
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 downloadedAppRepository: DownloadedAppRepository = get()
private val downloaderRepository: DownloaderRepository = get()
private val installedAppRepository: InstalledAppRepository = get()
private val rootInstaller: RootInstaller = get()
private val pm: PM = get()
var selectedSource by mutableStateOf(input.selectedSource)
private set
var localApp by mutableStateOf<SourceOption?>(null)
private set
var installedSource by mutableStateOf<SourceOption?>(null)
private set
fun selectSource(source: SelectedSource) {
selectedSource = source
}
private fun versionMismatchReason(version: String?) =
DisableReason.VERSION_NOT_MATCHING.takeIf { input.version != null && version != input.version }
val downloadedApps = downloadedAppRepository.get(input.packageName)
.map { apps ->
apps.sortedByDescending { it.version }.map { app ->
SourceOption(
source = SelectedSource.Downloaded(
path = downloadedAppRepository.getApkFileForApp(app).path,
version = app.version
),
title = app.version,
key = app.version,
disableReason = versionMismatchReason(app.version)
)
}
}
val downloaderSections = downloaderRepository.downloaderPackageStates.map { packageStates ->
packageStates
.filter { (_, state) -> state !is DownloaderPackageState.Loaded || state.downloaders.isNotEmpty() }
.map { (packageName, state) ->
when (state) {
is DownloaderPackageState.Loaded -> DownloaderSection(
title = state.name.ifBlank { packageName },
key = packageName,
options = state.downloaders.map { downloader ->
SourceOption(
source = SelectedSource.Downloader(
packageName,
downloader.className
),
title = downloader.name,
key = "${packageName}:${downloader.className}",
)
}
)
DownloaderPackageState.Untrusted,
is DownloaderPackageState.Failed -> {
val title =
with(pm) { pm.getPackageInfo(packageName)?.label() ?: packageName }
DownloaderSection(
title = title,
key = "unavailable_$packageName",
options = listOf(
SourceOption(
source = SelectedSource.Downloader(packageName),
title = title,
key = "unavailable_$packageName",
disableReason = when (state) {
DownloaderPackageState.Untrusted -> DisableReason.NOT_TRUSTED
else -> DisableReason.FAILED_TO_LOAD
},
)
)
)
}
}
}
}
init {
viewModelScope.launch {
pm.getPackageInfo(input.packageName)?.let { packageInfo ->
val installedApp = installedAppRepository.get(input.packageName)
installedSource = SourceOption(
source = SelectedSource.Installed,
title = packageInfo.versionName.toString(),
key = input.packageName,
disableReason = when {
installedApp?.installType == InstallType.DEFAULT -> DisableReason.ALREADY_PATCHED
installedApp?.installType == InstallType.MOUNT && !rootInstaller.hasRootAccess() ->
DisableReason.NO_ROOT
else -> versionMismatchReason(packageInfo.versionName)
}
)
}
input.localPath?.let { local ->
pm.getPackageInfo(File(local))?.let { packageInfo ->
localApp = SourceOption(
source = SelectedSource.Local(local),
title = packageInfo.versionName.toString(),
key = "local",
disableReason = versionMismatchReason(packageInfo.versionName)
)
}
}
}
}
enum class DisableReason(@param:StringRes val message: Int) {
VERSION_NOT_MATCHING(R.string.source_selector_disable_reason_version_not_matching),
ALREADY_PATCHED(R.string.already_patched),
NO_ROOT(R.string.app_source_dialog_option_installed_no_root),
NOT_TRUSTED(R.string.source_selector_disable_reason_not_trusted),
FAILED_TO_LOAD(R.string.source_selector_disable_reason_failed_to_load),
}
data class SourceOption(
val source: SelectedSource,
val title: String,
val key: String,
val disableReason: DisableReason? = null
)
data class DownloaderSection(
val title: String,
val key: String,
val options: List<SourceOption>,
)
}

View File

@@ -0,0 +1,76 @@
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.data.room.apps.installed.InstallType
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
var selectedVersion by mutableStateOf(input.selectedVersion)
private set
var installedAppVersion by mutableStateOf<String?>(null)
private set
fun selectVersion(version: SelectedVersion) {
selectedVersion = version
}
private val _localVersion = MutableStateFlow<String?>(null)
val localVersion: StateFlow<String?> = _localVersion
val downloadedVersions = downloadedAppsRepository.get(input.packageName)
.map { apps -> apps.map { it.version } }
val availableVersions = combine(
patchBundleRepository.suggestedVersions(input.packageName, input.patchSelection),
_localVersion,
) { versions, local ->
val allVersions = versions.orEmpty().toMutableMap().apply {
if (local != null) putIfAbsent(local, 0)
}
allVersions
.map { (version, supported) -> SelectedVersion.Specific(version) to patchCount - supported }
.sortedWith(compareBy<Pair<SelectedVersion.Specific, Int>> { it.second }.thenByDescending { it.first.version })
}
init {
viewModelScope.launch {
val patchedApp = installedAppRepository.get(input.packageName)
if (patchedApp?.installType != InstallType.DEFAULT) {
installedAppVersion = pm.getPackageInfo(input.packageName)?.versionName
}
input.localPath?.let { local ->
_localVersion.value = pm.getPackageInfo(File(local))?.versionName
}
}
}
}

View File

@@ -57,6 +57,9 @@ import kotlin.time.Clock
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

@@ -53,22 +53,45 @@ Second \"item\" text"</string>
<string name="no_network_toast">No internet connection available</string>
<string name="selected_app_meta_any_version">Any available version</string>
<string name="selected_app_meta_auto">%1$s (%2$s)</string>
<string name="selected_app_meta_source_installed_apk">Installed APK</string>
<string name="selected_app_meta_source_downloaded_apk">Downloaded APK</string>
<string name="selected_app_meta_source_local_apk">Local APK</string>
<string name="selected_app_meta_source_any_downloader">Any available downloader</string>
<string name="version_selector_title">Select version</string>
<string name="version_selector_auto_title">Auto (Recommended)</string>
<string name="version_selector_auto_description">Automatically select the best available version</string>
<string name="version_selector_all_patches_compatible">All patches are compatible</string>
<string name="version_selector_state_local">Local</string>
<string name="version_selector_state_downloaded_installed">Downloaded, Installed</string>
<string name="version_selector_state_downloaded">Downloaded</string>
<string name="version_selector_state_installed">Installed</string>
<string name="version_selector_any_title">Any available version</string>
<string name="version_selector_any_description">Use any available version regardless of compatibility</string>
<string name="app_source_dialog_title">Select source</string>
<string name="app_source_dialog_option_auto">Auto</string>
<string name="app_source_dialog_option_auto_description">Use all available downloader to download the app</string>
<string name="app_source_dialog_option_auto_unavailable">No downloader available</string>
<string name="app_source_dialog_option_installed_no_root">Mounted apps cannot be patched again without root access</string>
<string name="app_source_dialog_option_installed_version_not_suggested">Version %s does not match the suggested version</string>
<string name="source_selector_auto_description">Automatically select the best available source</string>
<string name="source_selector_any_available_downloader">Any available downloader</string>
<string name="source_selector_category_downloaded">Downloaded</string>
<string name="source_selector_category_local">Local</string>
<string name="source_selector_disable_reason_version_not_matching">Does not match the selected version</string>
<string name="source_selector_disable_reason_not_trusted">Not trusted</string>
<string name="source_selector_disable_reason_failed_to_load">Failed to load</string>
<string name="patch_item_description">Start patching the application</string>
<string name="patch_selector_item">Select patches</string>
<string name="patch_selector_item_description">%d patches selected</string>
<string name="patch_selector_item">Patches</string>
<string name="patch_selector_item_description">%d 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">Select APK source</string>
<string name="apk_source_selector_item">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>
@@ -208,7 +231,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 using file picker</string>
<string name="select_from_storage_description">Select an APK file from storage</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>
@@ -528,4 +551,12 @@ Click on the patches to see more details.</string>
<plurals name="selected_count">
<item quantity="other">%d selected</item>
</plurals>
<plurals name="version_selector_incompatible_patches">
<item quantity="one">%d incompatible patch</item>
<item quantity="other">%d incompatible patches</item>
</plurals>
<plurals name="available_patch_count">
<item quantity="one">%d patch available</item>
<item quantity="other">%d patches available</item>
</plurals>
</resources>