feat: download apps in patcher screen (#73)

This commit is contained in:
Robert 2023-07-30 10:29:22 +00:00 committed by GitHub
parent 83b9573b52
commit aec8cec9b8
25 changed files with 569 additions and 562 deletions

View File

@ -96,6 +96,7 @@ dependencies {
// Accompanist // Accompanist
implementation(libs.accompanist.drawablepainter) implementation(libs.accompanist.drawablepainter)
implementation(libs.accompanist.webview) implementation(libs.accompanist.webview)
implementation(libs.accompanist.placeholder)
// HTML Scraper // HTML Scraper
implementation(libs.skrapeit.dsl) implementation(libs.skrapeit.dsl)

View File

@ -9,7 +9,7 @@ import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.ui.destination.Destination import app.revanced.manager.ui.destination.Destination
import app.revanced.manager.ui.screen.AppDownloaderScreen import app.revanced.manager.ui.screen.VersionSelectorScreen
import app.revanced.manager.ui.screen.AppSelectorScreen import app.revanced.manager.ui.screen.AppSelectorScreen
import app.revanced.manager.ui.screen.DashboardScreen import app.revanced.manager.ui.screen.DashboardScreen
import app.revanced.manager.ui.screen.InstallerScreen import app.revanced.manager.ui.screen.InstallerScreen
@ -83,29 +83,29 @@ class MainActivity : ComponentActivity() {
) )
is Destination.AppSelector -> AppSelectorScreen( is Destination.AppSelector -> AppSelectorScreen(
onAppClick = { navController.navigate(Destination.PatchesSelector(it)) }, onAppClick = { navController.navigate(Destination.VersionSelector(it)) },
onDownloaderClick = { navController.navigate(Destination.AppDownloader(it)) }, onStorageClick = { navController.navigate(Destination.PatchesSelector(it)) },
onBackClick = { navController.pop() } onBackClick = { navController.pop() }
) )
is Destination.AppDownloader -> AppDownloaderScreen( is Destination.VersionSelector -> VersionSelectorScreen(
onBackClick = { navController.pop() }, onBackClick = { navController.pop() },
onApkClick = { navController.navigate(Destination.PatchesSelector(it)) }, onAppClick = { navController.navigate(Destination.PatchesSelector(it)) },
viewModel = getViewModel { parametersOf(destination.app) } viewModel = getViewModel { parametersOf(destination.packageName) }
) )
is Destination.PatchesSelector -> PatchesSelectorScreen( is Destination.PatchesSelector -> PatchesSelectorScreen(
onBackClick = { navController.popUpTo { it is Destination.AppSelector } }, onBackClick = { navController.pop() },
onPatchClick = { patches, options -> onPatchClick = { patches, options ->
navController.navigate( navController.navigate(
Destination.Installer( Destination.Installer(
destination.input, destination.selectedApp,
patches, patches,
options options
) )
) )
}, },
vm = getViewModel { parametersOf(destination.input) } vm = getViewModel { parametersOf(destination.selectedApp) }
) )
is Destination.Installer -> InstallerScreen( is Destination.Installer -> InstallerScreen(

View File

@ -9,7 +9,7 @@ val viewModelModule = module {
viewModelOf(::PatchesSelectorViewModel) viewModelOf(::PatchesSelectorViewModel)
viewModelOf(::SettingsViewModel) viewModelOf(::SettingsViewModel)
viewModelOf(::AppSelectorViewModel) viewModelOf(::AppSelectorViewModel)
viewModelOf(::AppDownloaderViewModel) viewModelOf(::VersionSelectorViewModel)
viewModelOf(::SourcesViewModel) viewModelOf(::SourcesViewModel)
viewModelOf(::InstallerViewModel) viewModelOf(::InstallerViewModel)
viewModelOf(::UpdateProgressViewModel) viewModelOf(::UpdateProgressViewModel)

View File

@ -12,11 +12,12 @@ import it.skrape.selects.html5.h5
import it.skrape.selects.html5.input import it.skrape.selects.html5.input
import it.skrape.selects.html5.p import it.skrape.selects.html5.p
import it.skrape.selects.html5.span import it.skrape.selects.html5.span
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
import org.koin.core.component.inject
import java.io.File import java.io.File
class APKMirror : AppDownloader, KoinComponent { class APKMirror : AppDownloader, KoinComponent {
@ -33,11 +34,6 @@ class APKMirror : AppDownloader, KoinComponent {
val link: String val link: String
) )
private val _downloadProgress: MutableStateFlow<Pair<Float, Float>?> = MutableStateFlow(null)
override val downloadProgress = _downloadProgress.asStateFlow()
private val versionMap = HashMap<String, String>()
private suspend fun getAppLink(packageName: String): String { private suspend fun getAppLink(packageName: String): String {
val searchResults = httpClient.getHtml { url("$apkMirror/?post_type=app_release&searchtype=app&s=$packageName") } val searchResults = httpClient.getHtml { url("$apkMirror/?post_type=app_release&searchtype=app&s=$packageName") }
.div { .div {
@ -92,7 +88,7 @@ class APKMirror : AppDownloader, KoinComponent {
} ?: throw Exception("App isn't available for download") } ?: throw Exception("App isn't available for download")
} }
override fun getAvailableVersions(packageName: String, versionFilter: Set<String>) = flow { override fun getAvailableVersions(packageName: String, versionFilter: Set<String>) = flow<AppDownloader.App> {
// Vanced music uses the same package name so we have to hardcode... // Vanced music uses the same package name so we have to hardcode...
val appCategory = if (packageName == "com.google.android.apps.youtube.music") val appCategory = if (packageName == "com.google.android.apps.youtube.music")
@ -102,9 +98,11 @@ class APKMirror : AppDownloader, KoinComponent {
var page = 1 var page = 1
val versions = mutableListOf<String>()
while ( while (
if (versionFilter.isNotEmpty()) if (versionFilter.isNotEmpty())
versionMap.filterKeys { it in versionFilter }.size < versionFilter.size && page <= 7 versions.size < versionFilter.size && page <= 7
else else
page <= 1 page <= 1
) { ) {
@ -119,33 +117,37 @@ class APKMirror : AppDownloader, KoinComponent {
findFirst { findFirst {
children.mapNotNull { element -> children.mapNotNull { element ->
if (element.className.isEmpty()) { if (element.className.isEmpty()) {
val version = element.div {
withClass = "infoSlide" APKMirrorApp(
findFirst { packageName = packageName,
p { version = element.div {
findFirst { withClass = "infoSlide"
span { findFirst {
withClass = "infoSlide-value" p {
findFirst { findFirst {
text span {
withClass = "infoSlide-value"
findFirst {
text
}
} }
} }
} }
} }
} }.also {
} if (it in versionFilter)
versions.add(it)
val link = element.findFirst { },
a { downloadLink = element.findFirst {
withClass = "downloadLink" a {
findFirst { withClass = "downloadLink"
attribute("href") findFirst {
attribute("href")
}
} }
} }
} )
versionMap[version] = link
version
} else null } else null
} }
} }
@ -157,116 +159,125 @@ class APKMirror : AppDownloader, KoinComponent {
} }
} }
override suspend fun downloadApp( @Parcelize
version: String, private class APKMirrorApp(
saveDirectory: File, override val packageName: String,
preferSplit: Boolean override val version: String,
): File { private val downloadLink: String,
val variants = httpClient.getHtml { url(apkMirror + versionMap[version]) } ) : AppDownloader.App, KoinComponent {
.div { @IgnoredOnParcel private val httpClient: HttpService by inject()
withClass = "variants-table"
findFirst { // list of variants override suspend fun download(
children.drop(1).map { saveDirectory: File,
Variant( preferSplit: Boolean,
apkType = it.div { onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> Unit
findFirst { ): File {
span { val variants = httpClient.getHtml { url(apkMirror + downloadLink) }
findFirst { .div {
enumValueOf(text) withClass = "variants-table"
} findFirst { // list of variants
} children.drop(1).map {
} Variant(
}, apkType = it.div {
arch = it.div { findFirst {
findSecond { span {
text findFirst {
} enumValueOf(text)
}, }
link = it.div { }
findFirst { }
a { },
findFirst { arch = it.div {
attribute("href") findSecond {
text
}
},
link = it.div {
findFirst {
a {
findFirst {
attribute("href")
}
} }
} }
} }
)
}
}
}
val orderedAPKTypes = mutableListOf(APKType.APK, APKType.BUNDLE)
.also { if (preferSplit) it.reverse() }
val variant = orderedAPKTypes.firstNotNullOfOrNull { apkType ->
supportedArches.firstNotNullOfOrNull { arch ->
variants.find { it.arch == arch && it.apkType == apkType }
}
} ?: throw Exception("No compatible variant found")
if (variant.apkType == APKType.BUNDLE) throw Exception("Split apks are not supported yet") // TODO
val downloadPage = httpClient.getHtml { url(apkMirror + variant.link) }
.a {
withClass = "downloadButton"
findFirst {
attribute("href")
}
}
val downloadLink = httpClient.getHtml { url(apkMirror + downloadPage) }
.form {
withId = "filedownload"
findFirst {
val apkLink = attribute("action")
val id = input {
withAttribute = "name" to "id"
findFirst {
attribute("value")
} }
)
}
}
}
val orderedAPKTypes = mutableListOf(APKType.APK, APKType.BUNDLE)
.also { if (preferSplit) it.reverse() }
val variant = orderedAPKTypes.firstNotNullOfOrNull { apkType ->
supportedArches.firstNotNullOfOrNull { arch ->
variants.find { it.arch == arch && it.apkType == apkType }
}
} ?: throw Exception("No compatible variant found")
if (variant.apkType == APKType.BUNDLE) TODO("\nSplit apks are not supported yet")
val downloadPage = httpClient.getHtml { url(apkMirror + variant.link) }
.a {
withClass = "downloadButton"
findFirst {
attribute("href")
}
}
val downloadLink = httpClient.getHtml { url(apkMirror + downloadPage) }
.form {
withId = "filedownload"
findFirst {
val apkLink = attribute("action")
val id = input {
withAttribute = "name" to "id"
findFirst {
attribute("value")
} }
} val key = input {
val key = input { withAttribute = "name" to "key"
withAttribute = "name" to "key" findFirst {
findFirst { attribute("value")
attribute("value") }
} }
"$apkLink?id=$id&key=$key"
} }
"$apkLink?id=$id&key=$key"
} }
}
val saveLocation = if (variant.apkType == APKType.BUNDLE) val saveLocation = if (variant.apkType == APKType.BUNDLE)
saveDirectory.resolve(version).also { it.mkdirs() } saveDirectory.resolve(version).also { it.mkdirs() }
else
saveDirectory.resolve("$version.apk")
try {
val downloadLocation = if (variant.apkType == APKType.BUNDLE)
saveLocation.resolve("temp.zip")
else else
saveLocation saveDirectory.resolve("$version.apk")
httpClient.download(downloadLocation) { try {
url(apkMirror + downloadLink) val downloadLocation = if (variant.apkType == APKType.BUNDLE)
onDownload { bytesSentTotal, contentLength -> saveLocation.resolve("temp.zip")
_downloadProgress.emit(bytesSentTotal.div(100000).toFloat().div(10) to contentLength.div(100000).toFloat().div(10)) else
saveLocation
httpClient.download(downloadLocation) {
url(apkMirror + downloadLink)
onDownload { bytesSentTotal, contentLength ->
onDownload(bytesSentTotal.div(100000).toFloat().div(10) to contentLength.div(100000).toFloat().div(10))
}
} }
if (variant.apkType == APKType.BUNDLE) {
// TODO: Extract temp.zip
downloadLocation.delete()
}
} catch (e: Exception) {
saveLocation.deleteRecursively()
throw e
} finally {
onDownload(null)
} }
if (variant.apkType == APKType.BUNDLE) { return saveLocation
// TODO: Extract temp.zip
downloadLocation.delete()
}
} catch (e: Exception) {
saveLocation.deleteRecursively()
throw e
} finally {
_downloadProgress.emit(null)
} }
return saveLocation
} }
companion object { companion object {

View File

@ -1,11 +1,10 @@
package app.revanced.manager.network.downloader package app.revanced.manager.network.downloader
import android.os.Parcelable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.io.File import java.io.File
interface AppDownloader { interface AppDownloader {
val downloadProgress: StateFlow<Pair<Float, Float>?>
/** /**
* Returns all downloadable apps. * Returns all downloadable apps.
@ -13,19 +12,17 @@ interface AppDownloader {
* @param packageName The package name of the app. * @param packageName The package name of the app.
* @param versionFilter A set of versions to filter. * @param versionFilter A set of versions to filter.
*/ */
fun getAvailableVersions(packageName: String, versionFilter: Set<String>): Flow<String> fun getAvailableVersions(packageName: String, versionFilter: Set<String>): Flow<App>
interface App : Parcelable {
val packageName: String
val version: String
suspend fun download(
saveDirectory: File,
preferSplit: Boolean,
onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> Unit = {}
): File
}
/**
* Downloads the specific app version.
*
* @param version The version to download.
* @param saveDirectory The folder where the downloaded app should be stored.
* @param preferSplit Whether it should prefer a split or a full apk.
* @return the downloaded apk or the folder containing all split apks.
*/
suspend fun downloadApp(
version: String,
saveDirectory: File,
preferSplit: Boolean = false
): File
} }

View File

@ -29,7 +29,7 @@ data class PatchInfo(
patch.compatiblePackages?.map { CompatiblePackage(it) }?.toImmutableList(), patch.compatiblePackages?.map { CompatiblePackage(it) }?.toImmutableList(),
patch.options?.map { Option(it) }?.toImmutableList()) patch.options?.map { Option(it) }?.toImmutableList())
fun compatibleWith(packageName: String) = compatiblePackages?.any { it.name == packageName } ?: true fun compatibleWith(packageName: String) = compatiblePackages?.any { it.packageName == packageName } ?: true
fun supportsVersion(versionName: String) = fun supportsVersion(versionName: String) =
compatiblePackages?.any { compatiblePackages.any { it.versions.isEmpty() || it.versions.any { version -> version == versionName } } } compatiblePackages?.any { compatiblePackages.any { it.versions.isEmpty() || it.versions.any { version -> version == versionName } } }
@ -38,7 +38,7 @@ data class PatchInfo(
@Immutable @Immutable
data class CompatiblePackage( data class CompatiblePackage(
val name: String, val packageName: String,
val versions: ImmutableList<String> val versions: ImmutableList<String>
) { ) {
constructor(pkg: Package) : this(pkg.name, pkg.versions.toList().toImmutableList()) constructor(pkg: Package) : this(pkg.name, pkg.versions.toList().toImmutableList())

View File

@ -3,11 +3,14 @@ package app.revanced.manager.patcher.worker
import android.content.Context import android.content.Context
import androidx.annotation.StringRes import androidx.annotation.StringRes
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.model.SelectedApp
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.StateFlow
sealed class Progress { sealed class Progress {
object Downloading : Progress()
object Unpacking : Progress() object Unpacking : Progress()
object Merging : Progress() object Merging : Progress()
object PatchingStart : Progress() object PatchingStart : Progress()
@ -24,23 +27,24 @@ enum class State {
class SubStep( class SubStep(
val name: String, val name: String,
val state: State = State.WAITING, val state: State = State.WAITING,
val message: String? = null val message: String? = null,
val progress: StateFlow<Pair<Float, Float>?>? = null
) )
class Step( class Step(
@StringRes val name: Int, @StringRes val name: Int,
val substeps: ImmutableList<SubStep>, val subSteps: ImmutableList<SubStep>,
val state: State = State.WAITING val state: State = State.WAITING
) )
class PatcherProgressManager(context: Context, selectedPatches: List<String>) { class PatcherProgressManager(context: Context, selectedPatches: List<String>, selectedApp: SelectedApp, downloadProgress: StateFlow<Pair<Float, Float>?>) {
val steps = generateSteps(context, selectedPatches) val steps = generateSteps(context, selectedPatches, selectedApp, downloadProgress)
private var currentStep: StepKey? = StepKey(0, 0) private var currentStep: StepKey? = StepKey(0, 0)
private fun update(key: StepKey, state: State, message: String? = null) { private fun update(key: StepKey, state: State, message: String? = null) {
val isLastSubStep: Boolean val isLastSubStep: Boolean
steps[key.step] = steps[key.step].let { step -> steps[key.step] = steps[key.step].let { step ->
isLastSubStep = key.substep == step.substeps.lastIndex isLastSubStep = key.substep == step.subSteps.lastIndex
val newStepState = when { val newStepState = when {
// This step failed because one of its sub-steps failed. // This step failed because one of its sub-steps failed.
@ -51,7 +55,7 @@ class PatcherProgressManager(context: Context, selectedPatches: List<String>) {
else -> step.state else -> step.state
} }
Step(step.name, step.substeps.mapIndexed { index, subStep -> Step(step.name, step.subSteps.mapIndexed { index, subStep ->
if (index != key.substep) subStep else SubStep(subStep.name, state, message) if (index != key.substep) subStep else SubStep(subStep.name, state, message)
}.toImmutableList(), newStepState) }.toImmutableList(), newStepState)
} }
@ -100,10 +104,11 @@ class PatcherProgressManager(context: Context, selectedPatches: List<String>) {
* A map of [Progress] to the corresponding position in [steps] * A map of [Progress] to the corresponding position in [steps]
*/ */
private val stepKeyMap = mapOf( private val stepKeyMap = mapOf(
Progress.Unpacking to StepKey(0, 1), //Progress.Downloading to StepKey(0, 1),
Progress.Merging to StepKey(0, 2), //Progress.Unpacking to StepKey(0, 2),
//Progress.Merging to StepKey(0, 3),
Progress.PatchingStart to StepKey(1, 0), Progress.PatchingStart to StepKey(1, 0),
Progress.Saving to StepKey(2, 0), //Progress.Saving to StepKey(2, 0),
) )
private fun generatePatchesStep(selectedPatches: List<String>) = Step( private fun generatePatchesStep(selectedPatches: List<String>) = Step(
@ -111,14 +116,15 @@ class PatcherProgressManager(context: Context, selectedPatches: List<String>) {
selectedPatches.map { SubStep(it) }.toImmutableList() selectedPatches.map { SubStep(it) }.toImmutableList()
) )
fun generateSteps(context: Context, selectedPatches: List<String>) = mutableListOf( fun generateSteps(context: Context, selectedPatches: List<String>, selectedApp: SelectedApp, downloadProgress: StateFlow<Pair<Float, Float>?>? = null) = mutableListOf(
Step( Step(
R.string.patcher_step_group_prepare, R.string.patcher_step_group_prepare,
persistentListOf( listOfNotNull(
SubStep(context.getString(R.string.patcher_step_load_patches)), SubStep(context.getString(R.string.patcher_step_load_patches)),
SubStep("Download apk", progress = downloadProgress).takeIf { selectedApp is SelectedApp.Download },
SubStep(context.getString(R.string.patcher_step_unpack)), SubStep(context.getString(R.string.patcher_step_unpack)),
SubStep(context.getString(R.string.patcher_step_integrations)) SubStep(context.getString(R.string.patcher_step_integrations))
) ).toImmutableList()
), ),
generatePatchesStep(selectedPatches), generatePatchesStep(selectedPatches),
Step( Step(

View File

@ -14,12 +14,16 @@ import androidx.core.content.ContextCompat
import androidx.work.ForegroundInfo import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.SourceRepository import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.domain.worker.Worker import app.revanced.manager.domain.worker.Worker
import app.revanced.manager.domain.worker.WorkerRepository import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.patcher.Session import app.revanced.manager.patcher.Session
import app.revanced.manager.patcher.aapt.Aapt import app.revanced.manager.patcher.aapt.Aapt
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchesSelection import app.revanced.manager.util.PatchesSelection
import app.revanced.manager.util.tag import app.revanced.manager.util.tag
import app.revanced.patcher.extensions.PatchExtensions.options import app.revanced.patcher.extensions.PatchExtensions.options
@ -34,14 +38,19 @@ import org.koin.core.component.inject
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
class PatcherWorker(context: Context, parameters: WorkerParameters) : class PatcherWorker(
Worker<PatcherWorker.Args>(context, parameters), context: Context,
KoinComponent { parameters: WorkerParameters
) : Worker<PatcherWorker.Args>(context, parameters), KoinComponent {
private val sourceRepository: SourceRepository by inject() private val sourceRepository: SourceRepository by inject()
private val workerRepository: WorkerRepository by inject() private val workerRepository: WorkerRepository by inject()
private val prefs: PreferencesManager by inject()
private val downloadedAppRepository: DownloadedAppRepository by inject()
private val pm: PM by inject()
data class Args( data class Args(
val input: String, val input: SelectedApp,
val output: String, val output: String,
val selectedPatches: PatchesSelection, val selectedPatches: PatchesSelection,
val options: Options, val options: Options,
@ -118,8 +127,10 @@ class PatcherWorker(context: Context, parameters: WorkerParameters) :
val bundles = sourceRepository.bundles.first() val bundles = sourceRepository.bundles.first()
val integrations = bundles.mapNotNull { (_, bundle) -> bundle.integrations } val integrations = bundles.mapNotNull { (_, bundle) -> bundle.integrations }
val downloadProgress = MutableStateFlow<Pair<Float, Float>?>(null)
val progressManager = val progressManager =
PatcherProgressManager(applicationContext, args.selectedPatches.flatMap { it.value }) PatcherProgressManager(applicationContext, args.selectedPatches.flatMap { it.value }, args.input, downloadProgress)
val progressFlow = args.progress val progressFlow = args.progress
@ -155,6 +166,28 @@ class PatcherWorker(context: Context, parameters: WorkerParameters) :
// Ensure they are in the correct order so we can track progress properly. // Ensure they are in the correct order so we can track progress properly.
progressManager.replacePatchesList(patches.map { it.patchName }) progressManager.replacePatchesList(patches.map { it.patchName })
val inputFile = when (val selectedApp = args.input) {
is SelectedApp.Download -> {
updateProgress(Progress.Downloading)
val savePath = applicationContext.filesDir.resolve("downloaded-apps").resolve(args.input.packageName).also { it.mkdirs() }
selectedApp.app.download(
savePath,
prefs.preferSplits.get(),
onDownload = { downloadProgress.emit(it) }
).also {
downloadedAppRepository.add(
args.input.packageName,
args.input.version,
it
)
}
}
is SelectedApp.Local -> selectedApp.file
is SelectedApp.Installed -> File(pm.getPackageInfo(selectedApp.packageName)!!.applicationInfo.sourceDir)
}
updateProgress(Progress.Unpacking) updateProgress(Progress.Unpacking)
Session( Session(
@ -162,10 +195,9 @@ class PatcherWorker(context: Context, parameters: WorkerParameters) :
frameworkPath, frameworkPath,
aaptPath, aaptPath,
args.logger, args.logger,
File(args.input) inputFile,
) { onProgress = { updateProgress(it) }
updateProgress(it) ).use { session ->
}.use { session ->
session.run(File(args.output), patches, integrations) session.run(File(args.output), patches, integrations)
} }
@ -173,7 +205,7 @@ class PatcherWorker(context: Context, parameters: WorkerParameters) :
progressManager.success() progressManager.success()
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(tag, "Got exception while patching".logFmt(), e) Log.e(tag, "Exception while patching".logFmt(), e)
progressManager.failure(e) progressManager.failure(e)
Result.failure() Result.failure()
} finally { } finally {

View File

@ -1,39 +1,49 @@
package app.revanced.manager.ui.component package app.revanced.manager.ui.component
import android.content.pm.PackageInfo
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Android import androidx.compose.material.icons.filled.Android
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.unit.dp
import app.revanced.manager.util.AppInfo
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.google.accompanist.placeholder.placeholder
@Composable @Composable
fun AppIcon( fun AppIcon(
app: AppInfo, packageInfo: PackageInfo?,
contentDescription: String?, contentDescription: String?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (app.packageInfo == null) { var showPlaceHolder by rememberSaveable { mutableStateOf(true) }
if (packageInfo == null) {
val image = rememberVectorPainter(Icons.Default.Android) val image = rememberVectorPainter(Icons.Default.Android)
val colorFilter = ColorFilter.tint(LocalContentColor.current) val colorFilter = ColorFilter.tint(LocalContentColor.current)
Image( Image(
image, image,
contentDescription, contentDescription,
Modifier.size(36.dp).then(modifier), Modifier.placeholder(visible = showPlaceHolder, color = MaterialTheme.colorScheme.inverseOnSurface, shape = RoundedCornerShape(100)).then(modifier),
colorFilter = colorFilter colorFilter = colorFilter
) )
showPlaceHolder = false
} else { } else {
AsyncImage( AsyncImage(
app.packageInfo, packageInfo,
contentDescription, contentDescription,
Modifier.size(36.dp).then(modifier) Modifier.placeholder(visible = showPlaceHolder, color = MaterialTheme.colorScheme.inverseOnSurface, shape = RoundedCornerShape(100)).then(modifier),
onSuccess = { showPlaceHolder = false }
) )
} }
} }

View File

@ -0,0 +1,52 @@
package app.revanced.manager.ui.component
import android.content.pm.PackageInfo
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import app.revanced.manager.R
import com.google.accompanist.placeholder.placeholder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun AppLabel(
packageInfo: PackageInfo?,
modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current,
defaultText: String = stringResource(R.string.not_installed)
) {
val context = LocalContext.current
var label: String? by rememberSaveable { mutableStateOf(null) }
LaunchedEffect(packageInfo) {
label = withContext(Dispatchers.IO) {
packageInfo?.applicationInfo?.loadLabel(context.packageManager)?.toString()
?: defaultText
}
}
Text(
label ?: stringResource(R.string.loading),
modifier = Modifier
.placeholder(
visible = label == null,
color = MaterialTheme.colorScheme.inverseOnSurface,
shape = RoundedCornerShape(100)
)
.then(modifier),
style = style
)
}

View File

@ -1,7 +1,7 @@
package app.revanced.manager.ui.destination package app.revanced.manager.ui.destination
import android.os.Parcelable import android.os.Parcelable
import app.revanced.manager.util.AppInfo import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchesSelection import app.revanced.manager.util.PatchesSelection
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -19,11 +19,11 @@ sealed interface Destination : Parcelable {
object Settings : Destination object Settings : Destination
@Parcelize @Parcelize
data class AppDownloader(val app: AppInfo) : Destination data class VersionSelector(val packageName: String) : Destination
@Parcelize @Parcelize
data class PatchesSelector(val input: AppInfo) : Destination data class PatchesSelector(val selectedApp: SelectedApp) : Destination
@Parcelize @Parcelize
data class Installer(val app: AppInfo, val selectedPatches: PatchesSelection, val options: @RawValue Options) : Destination data class Installer(val selectedApp: SelectedApp, val selectedPatches: PatchesSelection, val options: @RawValue Options) : Destination
} }

View File

@ -0,0 +1,20 @@
package app.revanced.manager.ui.model
import android.os.Parcelable
import app.revanced.manager.network.downloader.AppDownloader
import kotlinx.parcelize.Parcelize
import java.io.File
sealed class SelectedApp : Parcelable {
abstract val packageName: String
abstract val version: String
@Parcelize
data class Download(override val packageName: String, override val version: String, val app: AppDownloader.App) : SelectedApp()
@Parcelize
data class Local(override val packageName: String, override val version: String, val file: File) : SelectedApp()
@Parcelize
data class Installed(override val packageName: String, override val version: String) : SelectedApp()
}

View File

@ -26,19 +26,20 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.component.AppIcon import app.revanced.manager.ui.component.AppIcon
import app.revanced.manager.ui.component.AppLabel
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.LoadingIndicator import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.AppSelectorViewModel import app.revanced.manager.ui.viewmodel.AppSelectorViewModel
import app.revanced.manager.util.APK_MIMETYPE import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.AppInfo
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import org.koin.androidx.compose.getViewModel import org.koin.androidx.compose.getViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AppSelectorScreen( fun AppSelectorScreen(
onAppClick: (AppInfo) -> Unit, onAppClick: (packageName: String) -> Unit,
onDownloaderClick: (AppInfo) -> Unit, onStorageClick: (SelectedApp.Local) -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
vm: AppSelectorViewModel = getViewModel() vm: AppSelectorViewModel = getViewModel()
) { ) {
@ -47,7 +48,7 @@ fun AppSelectorScreen(
val pickApkLauncher = val pickApkLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { apkUri -> uri?.let { apkUri ->
vm.loadSelectedFile(apkUri)?.let(onAppClick) ?: context.toast(context.getString(R.string.failed_to_load_apk)) vm.loadSelectedFile(apkUri)?.let(onStorageClick) ?: context.toast(context.getString(R.string.failed_to_load_apk))
} }
} }
@ -64,17 +65,6 @@ fun AppSelectorScreen(
} }
} }
var selectedApp: AppInfo? by rememberSaveable { mutableStateOf(null) }
selectedApp?.let {
VersionDialog(
selectedApp = it,
onDismissRequest = { selectedApp = null },
onSelectVersionClick = onDownloaderClick,
onContinueClick = onAppClick
)
}
// TODO: find something better for this // TODO: find something better for this
if (search) { if (search) {
SearchBar( SearchBar(
@ -104,13 +94,11 @@ fun AppSelectorScreen(
) { app -> ) { app ->
ListItem( ListItem(
modifier = Modifier.clickable { modifier = Modifier.clickable { onAppClick(app.packageName) },
app.packageInfo?.let { onAppClick(app) } leadingContent = { AppIcon(app.packageInfo, null, Modifier.size(36.dp)) },
}, headlineContent = { AppLabel(app.packageInfo) },
leadingContent = { AppIcon(app, null) },
headlineContent = { Text(vm.loadLabel(app.packageInfo)) },
supportingContent = { Text(app.packageName) }, supportingContent = { Text(app.packageName) },
trailingContent = if (app.patches > 0) { { Text(pluralStringResource(R.plurals.patches_count, app.patches, app.patches)) } } else null trailingContent = app.patches?.let { { Text(pluralStringResource(R.plurals.patches_count, it, it)) } }
) )
} }
@ -169,11 +157,11 @@ fun AppSelectorScreen(
) { app -> ) { app ->
ListItem( ListItem(
modifier = Modifier.clickable { selectedApp = app }, modifier = Modifier.clickable { onAppClick(app.packageName) },
leadingContent = { AppIcon(app, null) }, leadingContent = { AppIcon(app.packageInfo, null, Modifier.size(36.dp)) },
headlineContent = { Text(vm.loadLabel(app.packageInfo)) }, headlineContent = { AppLabel(app.packageInfo) },
supportingContent = { Text(app.packageName) }, supportingContent = { Text(app.packageName) },
trailingContent = if (app.patches > 0) { { Text(pluralStringResource(R.plurals.patches_count, app.patches, app.patches)) } } else null trailingContent = app.patches?.let { { Text(pluralStringResource(R.plurals.patches_count, it, it)) } }
) )
} }
@ -182,51 +170,4 @@ fun AppSelectorScreen(
} }
} }
} }
} }
@Composable
fun VersionDialog(
selectedApp: AppInfo,
onDismissRequest: () -> Unit,
onSelectVersionClick: (AppInfo) -> Unit,
onContinueClick: (AppInfo) -> Unit
) = if (selectedApp.packageInfo != null) AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(stringResource(R.string.continue_with_version)) },
text = { Text(stringResource(R.string.version_not_supported, selectedApp.packageInfo.versionName)) },
confirmButton = {
Column(
horizontalAlignment = Alignment.End
) {
TextButton(onClick = {
onSelectVersionClick(selectedApp)
onDismissRequest()
}) {
Text(stringResource(R.string.download_another_version))
}
TextButton(onClick = {
onContinueClick(selectedApp)
onDismissRequest()
}) {
Text(stringResource(R.string.continue_anyways))
}
}
}
) else AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(stringResource(R.string.download_application)) },
text = { Text(stringResource(R.string.app_not_installed)) },
confirmButton = {
TextButton(onClick = {
onDismissRequest()
}) {
Text(stringResource(R.string.cancel))
}
TextButton(onClick = {
onSelectVersionClick(selectedApp)
onDismissRequest()
}) {
Text(stringResource(R.string.download_app))
}
}
)

View File

@ -1,5 +1,6 @@
package app.revanced.manager.ui.screen package app.revanced.manager.ui.screen
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@ -49,6 +50,8 @@ fun InstallerScreen(
onBackClick: () -> Unit, onBackClick: () -> Unit,
vm: InstallerViewModel vm: InstallerViewModel
) { ) {
BackHandler(onBack = onBackClick)
val context = LocalContext.current val context = LocalContext.current
val exportApkLauncher = val exportApkLauncher =
rememberLauncherForActivityResult(CreateDocument(APK_MIMETYPE), vm::export) rememberLauncherForActivityResult(CreateDocument(APK_MIMETYPE), vm::export)
@ -142,7 +145,7 @@ fun InstallStep(step: Step) {
.padding(start = 16.dp, end = 16.dp) .padding(start = 16.dp, end = 16.dp)
.background(if (expanded) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface) .background(if (expanded) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface)
) { ) {
StepIcon(step.state, 24.dp) StepIcon(step.state, size = 24.dp)
Text(text = stringResource(step.name), style = MaterialTheme.typography.titleMedium) Text(text = stringResource(step.name), style = MaterialTheme.typography.titleMedium)
@ -162,18 +165,19 @@ fun InstallStep(step: Step) {
.padding(16.dp) .padding(16.dp)
.padding(start = 4.dp) .padding(start = 4.dp)
) { ) {
step.substeps.forEach { step.subSteps.forEach { subStep ->
var messageExpanded by rememberSaveable { mutableStateOf(true) } var messageExpanded by rememberSaveable { mutableStateOf(true) }
val stacktrace = it.message val stacktrace = subStep.message
val downloadProgress = subStep.progress?.collectAsStateWithLifecycle()
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
StepIcon(it.state, size = 18.dp) StepIcon(subStep.state, downloadProgress?.value, size = 18.dp)
Text( Text(
text = it.name, text = subStep.name,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@ -184,6 +188,13 @@ fun InstallStep(step: Step) {
ArrowButton(expanded = messageExpanded) { ArrowButton(expanded = messageExpanded) {
messageExpanded = !messageExpanded messageExpanded = !messageExpanded
} }
} else {
downloadProgress?.value?.let { (downloaded, total) ->
Text(
"$downloaded/$total MB",
style = MaterialTheme.typography.labelSmall
)
}
} }
} }
@ -201,7 +212,7 @@ fun InstallStep(step: Step) {
} }
@Composable @Composable
fun StepIcon(status: State, size: Dp) { fun StepIcon(status: State, downloadProgress: Pair<Float, Float>? = null, size: Dp) {
val strokeWidth = Dp(floor(size.value / 10) + 1) val strokeWidth = Dp(floor(size.value / 10) + 1)
when (status) { when (status) {
@ -219,15 +230,28 @@ fun StepIcon(status: State, size: Dp) {
modifier = Modifier.size(size) modifier = Modifier.size(size)
) )
State.WAITING -> CircularProgressIndicator( State.WAITING ->
strokeWidth = strokeWidth, downloadProgress?.let { (downloaded, total) ->
modifier = stringResource(R.string.step_running).let { description -> CircularProgressIndicator(
Modifier progress = downloaded / total,
.size(size) strokeWidth = strokeWidth,
.semantics { modifier = stringResource(R.string.step_running).let { description ->
contentDescription = description Modifier
.size(size)
.semantics {
contentDescription = description
}
} }
} )
) } ?: CircularProgressIndicator(
strokeWidth = strokeWidth,
modifier = stringResource(R.string.step_running).let { description ->
Modifier
.size(size)
.semantics {
contentDescription = description
}
}
)
} }
} }

View File

@ -1,6 +1,5 @@
package app.revanced.manager.ui.screen package app.revanced.manager.ui.screen
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -66,8 +65,6 @@ fun PatchesSelectorScreen(
onBackClick: () -> Unit, onBackClick: () -> Unit,
vm: PatchesSelectorViewModel vm: PatchesSelectorViewModel
) { ) {
BackHandler(onBack = onBackClick)
val pagerState = rememberPagerState() val pagerState = rememberPagerState()
val composableScope = rememberCoroutineScope() val composableScope = rememberCoroutineScope()
@ -75,7 +72,7 @@ fun PatchesSelectorScreen(
if (vm.compatibleVersions.isNotEmpty()) if (vm.compatibleVersions.isNotEmpty())
UnsupportedDialog( UnsupportedDialog(
appVersion = vm.appInfo.packageInfo!!.versionName, appVersion = vm.selectedApp.version,
supportedVersions = vm.compatibleVersions, supportedVersions = vm.compatibleVersions,
onDismissRequest = vm::dismissDialogs onDismissRequest = vm::dismissDialogs
) )

View File

@ -18,7 +18,6 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -31,33 +30,28 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.LoadingIndicator import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.viewmodel.AppDownloaderViewModel import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.AppInfo import app.revanced.manager.ui.viewmodel.VersionSelectorViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AppDownloaderScreen( fun VersionSelectorScreen(
onBackClick: () -> Unit, onBackClick: () -> Unit,
onApkClick: (AppInfo) -> Unit, onAppClick: (SelectedApp) -> Unit,
viewModel: AppDownloaderViewModel viewModel: VersionSelectorViewModel
) { ) {
SideEffect { val supportedVersions by viewModel.supportedVersions.collectAsStateWithLifecycle(emptyMap())
viewModel.onComplete = onApkClick
}
val downloadProgress by viewModel.appDownloader.downloadProgress.collectAsStateWithLifecycle()
val compatibleVersions by viewModel.compatibleVersions.collectAsStateWithLifecycle(emptyMap())
val downloadedVersions by viewModel.downloadedVersions.collectAsStateWithLifecycle(emptyList()) val downloadedVersions by viewModel.downloadedVersions.collectAsStateWithLifecycle(emptyList())
val list by remember { val list by remember {
derivedStateOf { derivedStateOf {
(downloadedVersions + viewModel.availableVersions) (downloadedVersions + viewModel.downloadableVersions)
.distinct() .distinctBy { it.version }
.sortedWith( .sortedWith(
compareByDescending<String> { compareByDescending<SelectedApp> {
downloadedVersions.contains(it) it is SelectedApp.Local
}.thenByDescending { compatibleVersions[it] } }.thenByDescending { supportedVersions[it.version] }
.thenByDescending { it } .thenByDescending { it.version }
) )
} }
} }
@ -92,18 +86,15 @@ fun AppDownloaderScreen(
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
list.forEach { version -> list.forEach { selectedApp ->
ListItem( ListItem(
modifier = Modifier.clickable { modifier = Modifier.clickable { onAppClick(selectedApp) },
viewModel.downloadApp(version) headlineContent = { Text(selectedApp.version) },
},
headlineContent = { Text(version) },
supportingContent = supportingContent =
if (downloadedVersions.contains(version)) { if (selectedApp is SelectedApp.Local) {
{ Text(stringResource(R.string.already_downloaded)) } { Text(stringResource(R.string.already_downloaded)) }
} else null, } else null,
trailingContent = compatibleVersions[version]?.let { trailingContent = supportedVersions[selectedApp.version]?.let { {
{
Text( Text(
pluralStringResource( pluralStringResource(
R.plurals.patches_count, R.plurals.patches_count,
@ -140,10 +131,7 @@ fun AppDownloaderScreen(
} }
else -> { else -> {
LoadingIndicator( LoadingIndicator()
progress = downloadProgress?.let { (it.first / it.second) },
text = downloadProgress?.let { stringResource(R.string.downloading_app, it.first, it.second) }
)
} }
} }
} }

View File

@ -1,144 +0,0 @@
package app.revanced.manager.ui.viewmodel
import android.app.Application
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.network.downloader.APKMirror
import app.revanced.manager.network.downloader.AppDownloader
import app.revanced.manager.util.AppInfo
import app.revanced.manager.util.PM
import app.revanced.manager.util.mutableStateSetOf
import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
class AppDownloaderViewModel(
private val selectedApp: AppInfo
) : ViewModel(), KoinComponent {
private val app: Application = get()
private val downloadedAppRepository: DownloadedAppRepository = get()
private val sourceRepository: SourceRepository = get()
private val pm: PM = get()
private val prefs: PreferencesManager = get()
val appDownloader: AppDownloader = APKMirror()
var isDownloading: Boolean by mutableStateOf(false)
private set
var isLoading by mutableStateOf(true)
private set
var errorMessage: String? by mutableStateOf(null)
private set
val availableVersions = mutableStateSetOf<String>()
val compatibleVersions = sourceRepository.bundles.map { bundles ->
var patchesWithoutVersions = 0
bundles.flatMap { (_, bundle) ->
bundle.patches.flatMap { patch ->
patch.compatiblePackages
.orEmpty()
.filter { it.name == selectedApp.packageName }
.onEach {
if (it.versions.isEmpty()) patchesWithoutVersions += 1
}
.flatMap { it.versions }
}
}.groupingBy { it }
.eachCount()
.toMutableMap()
.apply {
replaceAll { _, count ->
count + patchesWithoutVersions
}
}
}
val downloadedVersions = downloadedAppRepository.getAll().map { downloadedApps ->
downloadedApps.mapNotNull {
if (it.packageName == selectedApp.packageName)
it.version
else
null
}
}
private val job = viewModelScope.launch(Dispatchers.IO) {
try {
val compatibleVersions = compatibleVersions.first()
appDownloader.getAvailableVersions(
selectedApp.packageName,
compatibleVersions.keys
).collect {
if (it in compatibleVersions || compatibleVersions.isEmpty()) {
availableVersions.add(it)
}
}
withContext(Dispatchers.Main) {
isLoading = false
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Log.e(tag, "Failed to load apps", e)
errorMessage = e.simpleMessage()
}
}
}
lateinit var onComplete: (AppInfo) -> Unit
fun downloadApp(
version: String
) {
isDownloading = true
job.cancel()
viewModelScope.launch(Dispatchers.IO) {
try {
val savePath = app.filesDir.resolve("downloaded-apps").resolve(selectedApp.packageName).also { it.mkdirs() }
val downloadedFile =
downloadedAppRepository.get(selectedApp.packageName, version)?.file
?: appDownloader.downloadApp(
version,
savePath,
preferSplit = prefs.preferSplits.get()
).also {
downloadedAppRepository.add(
selectedApp.packageName,
version,
it
)
}
val apkInfo = pm.getApkInfo(downloadedFile)
?: throw Exception("Failed to load apk info")
withContext(Dispatchers.Main) {
onComplete(apkInfo)
}
} catch (e: Throwable) {
withContext(Dispatchers.Main) {
Log.e(tag, "Failed to download apk", e)
errorMessage = e.simpleMessage()
}
}
}
}
}

View File

@ -4,13 +4,14 @@ import android.app.Application
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.net.Uri import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.PM import app.revanced.manager.util.PM
import java.io.File import java.io.File
import java.nio.file.Files import java.nio.file.Files
class AppSelectorViewModel( class AppSelectorViewModel(
private val app: Application, private val app: Application,
private val pm: PM pm: PM
) : ViewModel() { ) : ViewModel() {
private val packageManager = app.packageManager private val packageManager = app.packageManager
@ -18,11 +19,17 @@ class AppSelectorViewModel(
fun loadLabel(app: PackageInfo?) = (app?.applicationInfo?.loadLabel(packageManager) ?: "Not installed").toString() fun loadLabel(app: PackageInfo?) = (app?.applicationInfo?.loadLabel(packageManager) ?: "Not installed").toString()
@Suppress("DEPRECATION")
fun loadSelectedFile(uri: Uri) = fun loadSelectedFile(uri: Uri) =
app.contentResolver.openInputStream(uri)!!.use { stream -> app.contentResolver.openInputStream(uri)?.use { stream ->
File(app.cacheDir, "input.apk").also { File(app.cacheDir, "input.apk").also {
if (it.exists()) it.delete() it.delete()
Files.copy(stream, it.toPath()) Files.copy(stream, it.toPath())
}.let { pm.getApkInfo(it) } }.let { file ->
packageManager.getPackageArchiveInfo(file.absolutePath, 0)
?.let { packageInfo ->
SelectedApp.Local(packageName = packageInfo.packageName, version = packageInfo.versionName, file = file)
}
}
} }
} }

View File

@ -51,7 +51,7 @@ class InstallerViewModel(input: Destination.Installer) : ViewModel(), KoinCompon
private val pm: PM by inject() private val pm: PM by inject()
private val workerRepository: WorkerRepository by inject() private val workerRepository: WorkerRepository by inject()
val packageName: String = input.app.packageName val packageName: String = input.selectedApp.packageName
private val outputFile = File(app.cacheDir, "output.apk") private val outputFile = File(app.cacheDir, "output.apk")
private val signedFile = File(app.cacheDir, "signed.apk").also { if (it.exists()) it.delete() } private val signedFile = File(app.cacheDir, "signed.apk").also { if (it.exists()) it.delete() }
private var hasSigned = false private var hasSigned = false
@ -69,21 +69,22 @@ class InstallerViewModel(input: Destination.Installer) : ViewModel(), KoinCompon
private val logger = ManagerLogger() private val logger = ManagerLogger()
init { init {
val (appInfo, patches, options) = input val (selectedApp, patches, options) = input
_progress = MutableStateFlow(PatcherProgressManager.generateSteps( _progress = MutableStateFlow(PatcherProgressManager.generateSteps(
app, app,
patches.flatMap { (_, selected) -> selected } patches.flatMap { (_, selected) -> selected },
input.selectedApp
).toImmutableList()) ).toImmutableList())
patcherWorkerId = patcherWorkerId =
workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>( workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>(
"patching", PatcherWorker.Args( "patching", PatcherWorker.Args(
appInfo.path!!.absolutePath, selectedApp,
outputFile.path, outputFile.path,
patches, patches,
options, options,
packageName, packageName,
appInfo.packageInfo!!.versionName, selectedApp.version,
_progress, _progress,
logger logger
) )

View File

@ -3,25 +3,16 @@ package app.revanced.manager.ui.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.revanced.manager.domain.repository.SourceRepository import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.util.PM
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainViewModel( class MainViewModel(
sourceRepository: SourceRepository, sourceRepository: SourceRepository
pm: PM
) : ViewModel() { ) : ViewModel() {
init { init {
with(viewModelScope) { with(viewModelScope) {
launch { launch {
sourceRepository.loadSources() sourceRepository.loadSources()
} }
launch {
pm.getCompatibleApps()
}
launch(Dispatchers.IO) {
pm.getInstalledApps()
}
} }
} }
} }

View File

@ -17,7 +17,7 @@ import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchSelectionRepository import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.domain.repository.SourceRepository import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.util.AppInfo import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchesSelection import app.revanced.manager.util.PatchesSelection
import app.revanced.manager.util.SnapshotStateSet import app.revanced.manager.util.SnapshotStateSet
@ -37,7 +37,7 @@ import org.koin.core.component.get
@Stable @Stable
@OptIn(SavedStateHandleSaveableApi::class) @OptIn(SavedStateHandleSaveableApi::class)
class PatchesSelectorViewModel( class PatchesSelectorViewModel(
val appInfo: AppInfo val selectedApp: SelectedApp
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val selectionRepository: PatchSelectionRepository = get() private val selectionRepository: PatchSelectionRepository = get()
private val savedStateHandle: SavedStateHandle = get() private val savedStateHandle: SavedStateHandle = get()
@ -52,12 +52,12 @@ class PatchesSelectorViewModel(
val unsupported = mutableListOf<PatchInfo>() val unsupported = mutableListOf<PatchInfo>()
val universal = mutableListOf<PatchInfo>() val universal = mutableListOf<PatchInfo>()
bundle.patches.filter { it.compatibleWith(appInfo.packageName) }.forEach { bundle.patches.filter { it.compatibleWith(selectedApp.packageName) }.forEach {
val targetList = val targetList =
if (it.compatiblePackages == null) universal else if (it.supportsVersion( if (it.compatiblePackages == null) universal else if (it.supportsVersion(selectedApp.version))
appInfo.packageInfo!!.versionName supported
) else
) supported else unsupported unsupported
targetList.add(it) targetList.add(it)
} }
@ -71,7 +71,7 @@ class PatchesSelectorViewModel(
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
val bundles = bundlesFlow.first() val bundles = bundlesFlow.first()
val filteredSelection = val filteredSelection =
selectionRepository.getSelection(appInfo.packageName).mapValues { (uid, patches) -> selectionRepository.getSelection(selectedApp.packageName).mapValues { (uid, patches) ->
// Filter out patches that don't exist. // Filter out patches that don't exist.
val filteredPatches = bundles.singleOrNull { it.uid == uid } val filteredPatches = bundles.singleOrNull { it.uid == uid }
?.let { bundle -> ?.let { bundle ->
@ -117,7 +117,7 @@ class PatchesSelectorViewModel(
suspend fun getAndSaveSelection(): PatchesSelection = suspend fun getAndSaveSelection(): PatchesSelection =
selectedPatches.also { selectedPatches.also {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
selectionRepository.updateSelection(appInfo.packageName, it) selectionRepository.updateSelection(selectedApp.packageName, it)
} }
}.mapValues { it.value.toMutableSet() }.apply { }.mapValues { it.value.toMutableSet() }.apply {
if (allowExperimental.get()) { if (allowExperimental.get()) {
@ -150,7 +150,7 @@ class PatchesSelectorViewModel(
val set = HashSet<String>() val set = HashSet<String>()
unsupportedVersions.forEach { patch -> unsupportedVersions.forEach { patch ->
patch.compatiblePackages?.find { it.name == appInfo.packageName } patch.compatiblePackages?.find { it.packageName == selectedApp.packageName }
?.let { compatiblePackage -> ?.let { compatiblePackage ->
set.addAll(compatiblePackage.versions) set.addAll(compatiblePackage.versions)
} }

View File

@ -0,0 +1,96 @@
package app.revanced.manager.ui.viewmodel
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.network.downloader.APKMirror
import app.revanced.manager.network.downloader.AppDownloader
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.mutableStateSetOf
import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
class VersionSelectorViewModel(
val packageName: String
) : ViewModel(), KoinComponent {
private val downloadedAppRepository: DownloadedAppRepository = get()
private val sourceRepository: SourceRepository = get()
private val appDownloader: AppDownloader = APKMirror()
var isDownloading: Boolean by mutableStateOf(false)
private set
var isLoading by mutableStateOf(true)
private set
var errorMessage: String? by mutableStateOf(null)
private set
val downloadableVersions = mutableStateSetOf<SelectedApp.Download>()
val supportedVersions = sourceRepository.bundles.map { bundles ->
var patchesWithoutVersions = 0
bundles.flatMap { (_, bundle) ->
bundle.patches.flatMap { patch ->
patch.compatiblePackages.orEmpty()
.filter { it.packageName == packageName }
.onEach { if (it.versions.isEmpty()) patchesWithoutVersions++ }
.flatMap { it.versions }
}
}.groupingBy { it }
.eachCount()
.toMutableMap()
.apply {
replaceAll { _, count ->
count + patchesWithoutVersions
}
}
}
val downloadedVersions = downloadedAppRepository.getAll().map { downloadedApps ->
downloadedApps.filter { it.packageName == packageName }.map { SelectedApp.Local(it.packageName, it.version, it.file) }
}
init {
viewModelScope.launch(Dispatchers.IO) {
try {
val compatibleVersions = supportedVersions.first()
appDownloader.getAvailableVersions(
packageName,
compatibleVersions.keys
).collect {
if (it.version in compatibleVersions || compatibleVersions.isEmpty()) {
downloadableVersions.add(
SelectedApp.Download(
packageName,
it.version,
it
)
)
}
}
withContext(Dispatchers.Main) {
isLoading = false
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Log.e(tag, "Failed to load apps", e)
errorMessage = e.simpleMessage()
}
}
}
}
}

View File

@ -9,16 +9,18 @@ import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES import android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES
import android.content.pm.PackageManager.NameNotFoundException
import android.os.Build import android.os.Build
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import app.revanced.manager.domain.repository.SourceRepository import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.service.InstallService import app.revanced.manager.service.InstallService
import app.revanced.manager.service.UninstallService import app.revanced.manager.service.UninstallService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.io.File import java.io.File
@ -29,7 +31,7 @@ private const val byteArraySize = 1024 * 1024 // Because 1,048,576 is not readab
@Parcelize @Parcelize
data class AppInfo( data class AppInfo(
val packageName: String, val packageName: String,
val patches: Int, val patches: Int?,
val packageInfo: PackageInfo?, val packageInfo: PackageInfo?,
val path: File? = null val path: File? = null
) : Parcelable ) : Parcelable
@ -38,69 +40,65 @@ data class AppInfo(
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
class PM( class PM(
private val app: Application, private val app: Application,
private val sourceRepository: SourceRepository sourceRepository: SourceRepository
) { ) {
private val installedApps = MutableStateFlow(emptyList<AppInfo>()) private val scope = CoroutineScope(Dispatchers.IO)
private val compatibleApps = MutableStateFlow(emptyList<AppInfo>())
val appList: Flow<List<AppInfo>> = compatibleApps.combine(installedApps) { compatibleApps, installedApps -> val appList = sourceRepository.bundles.map { bundles ->
if (compatibleApps.isNotEmpty()) { val compatibleApps = scope.async {
(compatibleApps + installedApps) val compatiblePackages = bundles.values
.flatMap { it.patches }
.flatMap { it.compatiblePackages.orEmpty() }
.groupingBy { it.packageName }
.eachCount()
compatiblePackages.keys.map { pkg ->
try {
val packageInfo = app.packageManager.getPackageInfo(pkg, 0)
AppInfo(
pkg,
compatiblePackages[pkg],
packageInfo,
File(packageInfo.applicationInfo.sourceDir)
)
} catch (e: NameNotFoundException) {
AppInfo(
pkg,
compatiblePackages[pkg],
null
)
}
}
}
val installedApps = scope.async {
app.packageManager.getInstalledPackages(MATCH_UNINSTALLED_PACKAGES).map { packageInfo ->
AppInfo(
packageInfo.packageName,
0,
packageInfo,
File(packageInfo.applicationInfo.sourceDir)
)
}
}
if (compatibleApps.await().isNotEmpty()) {
(compatibleApps.await() + installedApps.await())
.distinctBy { it.packageName } .distinctBy { it.packageName }
.sortedWith( .sortedWith(
compareByDescending<AppInfo> { compareByDescending<AppInfo> {
it.patches it.patches
}.thenBy { it.packageInfo?.applicationInfo?.loadLabel(app.packageManager).toString() }.thenBy { it.packageName } }.thenBy { it.packageInfo?.applicationInfo?.loadLabel(app.packageManager).toString() }.thenBy { it.packageName }
) )
} else { } else { emptyList() }
emptyList() }.flowOn(Dispatchers.IO)
fun getPackageInfo(packageName: String): PackageInfo? =
try {
app.packageManager.getPackageInfo(packageName, 0)
} catch (e: NameNotFoundException) {
null
} }
}
suspend fun getCompatibleApps() {
sourceRepository.bundles.collect { bundles ->
val compatiblePackages = HashMap<String, Int>()
bundles.flatMap { it.value.patches }.forEach {
it.compatiblePackages?.forEach { pkg ->
compatiblePackages[pkg.name] = compatiblePackages.getOrDefault(pkg.name, 0) + 1
}
}
withContext(Dispatchers.IO) {
compatibleApps.emit(
compatiblePackages.keys.map { pkg ->
try {
val packageInfo = app.packageManager.getPackageInfo(pkg, 0)
AppInfo(
pkg,
compatiblePackages[pkg] ?: 0,
packageInfo,
File(packageInfo.applicationInfo.sourceDir)
)
} catch (e: PackageManager.NameNotFoundException) {
AppInfo(
pkg,
compatiblePackages[pkg] ?: 0,
null
)
}
}
)
}
}
}
suspend fun getInstalledApps() {
installedApps.emit(app.packageManager.getInstalledPackages(MATCH_UNINSTALLED_PACKAGES).map { packageInfo ->
AppInfo(
packageInfo.packageName,
0,
packageInfo,
File(packageInfo.applicationInfo.sourceDir)
)
})
}
suspend fun installApp(apks: List<File>) = withContext(Dispatchers.IO) { suspend fun installApp(apks: List<File>) = withContext(Dispatchers.IO) {
val packageInstaller = app.packageManager.packageInstaller val packageInstaller = app.packageManager.packageInstaller
@ -119,15 +117,6 @@ class PM(
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
app.startActivity(it) app.startActivity(it)
} }
fun getApkInfo(apk: File) = app.packageManager.getPackageArchiveInfo(apk.path, 0)?.let {
AppInfo(
it.packageName,
0,
it,
apk
)
}
} }
private fun PackageInstaller.Session.writeApk(apk: File) { private fun PackageInstaller.Session.writeApk(apk: File) {

View File

@ -1,11 +1,7 @@
package app.revanced.manager.util package app.revanced.manager.util
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.ContextWrapper
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager.NameNotFoundException
import android.graphics.drawable.Drawable
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.annotation.StringRes import androidx.annotation.StringRes
@ -33,14 +29,6 @@ fun Context.openUrl(url: String) {
}) })
} }
fun Context.loadIcon(string: String): Drawable? {
return try {
packageManager.getApplicationIcon(string)
} catch (e: NameNotFoundException) {
null
}
}
fun Context.toast(string: String, duration: Int = Toast.LENGTH_SHORT) { fun Context.toast(string: String, duration: Int = Toast.LENGTH_SHORT) {
Toast.makeText(this, string, duration).show() Toast.makeText(this, string, duration).show()
} }

View File

@ -128,10 +128,10 @@
<string name="download_application">Download application?</string> <string name="download_application">Download application?</string>
<string name="app_not_installed">The app you selected isn\'t installed. Do you want to download it?</string> <string name="app_not_installed">The app you selected isn\'t installed. Do you want to download it?</string>
<string name="failed_to_load_apk">Failed to load apk</string> <string name="failed_to_load_apk">Failed to load apk</string>
<string name="loading">Loading…</string>
<string name="not_installed">Not installed</string>
<string name="error_occurred">An error occurred</string>
<string name="already_downloaded">Already downloaded</string> <string name="already_downloaded">Already downloaded</string>
<string name="downloading_app">Downloading app… (%1$s MB/%2$s MB)</string>
<string name="select_file">Select file</string> <string name="select_file">Select file</string>