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 e0eedf59db
commit 6cfdcaba7d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 569 additions and 562 deletions

View File

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

View File

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

View File

@ -9,7 +9,7 @@ val viewModelModule = module {
viewModelOf(::PatchesSelectorViewModel)
viewModelOf(::SettingsViewModel)
viewModelOf(::AppSelectorViewModel)
viewModelOf(::AppDownloaderViewModel)
viewModelOf(::VersionSelectorViewModel)
viewModelOf(::SourcesViewModel)
viewModelOf(::InstallerViewModel)
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.p
import it.skrape.selects.html5.span
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject
import java.io.File
class APKMirror : AppDownloader, KoinComponent {
@ -33,11 +34,6 @@ class APKMirror : AppDownloader, KoinComponent {
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 {
val searchResults = httpClient.getHtml { url("$apkMirror/?post_type=app_release&searchtype=app&s=$packageName") }
.div {
@ -92,7 +88,7 @@ class APKMirror : AppDownloader, KoinComponent {
} ?: 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...
val appCategory = if (packageName == "com.google.android.apps.youtube.music")
@ -102,9 +98,11 @@ class APKMirror : AppDownloader, KoinComponent {
var page = 1
val versions = mutableListOf<String>()
while (
if (versionFilter.isNotEmpty())
versionMap.filterKeys { it in versionFilter }.size < versionFilter.size && page <= 7
versions.size < versionFilter.size && page <= 7
else
page <= 1
) {
@ -119,33 +117,37 @@ class APKMirror : AppDownloader, KoinComponent {
findFirst {
children.mapNotNull { element ->
if (element.className.isEmpty()) {
val version = element.div {
withClass = "infoSlide"
findFirst {
p {
findFirst {
span {
withClass = "infoSlide-value"
findFirst {
text
APKMirrorApp(
packageName = packageName,
version = element.div {
withClass = "infoSlide"
findFirst {
p {
findFirst {
span {
withClass = "infoSlide-value"
findFirst {
text
}
}
}
}
}
}
}
val link = element.findFirst {
a {
withClass = "downloadLink"
findFirst {
attribute("href")
}.also {
if (it in versionFilter)
versions.add(it)
},
downloadLink = element.findFirst {
a {
withClass = "downloadLink"
findFirst {
attribute("href")
}
}
}
}
)
versionMap[version] = link
version
} else null
}
}
@ -157,116 +159,125 @@ class APKMirror : AppDownloader, KoinComponent {
}
}
override suspend fun downloadApp(
version: String,
saveDirectory: File,
preferSplit: Boolean
): File {
val variants = httpClient.getHtml { url(apkMirror + versionMap[version]) }
.div {
withClass = "variants-table"
findFirst { // list of variants
children.drop(1).map {
Variant(
apkType = it.div {
findFirst {
span {
findFirst {
enumValueOf(text)
}
}
}
},
arch = it.div {
findSecond {
text
}
},
link = it.div {
findFirst {
a {
findFirst {
attribute("href")
@Parcelize
private class APKMirrorApp(
override val packageName: String,
override val version: String,
private val downloadLink: String,
) : AppDownloader.App, KoinComponent {
@IgnoredOnParcel private val httpClient: HttpService by inject()
override suspend fun download(
saveDirectory: File,
preferSplit: Boolean,
onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> Unit
): File {
val variants = httpClient.getHtml { url(apkMirror + downloadLink) }
.div {
withClass = "variants-table"
findFirst { // list of variants
children.drop(1).map {
Variant(
apkType = it.div {
findFirst {
span {
findFirst {
enumValueOf(text)
}
}
}
},
arch = it.div {
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 {
withAttribute = "name" to "key"
findFirst {
attribute("value")
val key = input {
withAttribute = "name" to "key"
findFirst {
attribute("value")
}
}
"$apkLink?id=$id&key=$key"
}
"$apkLink?id=$id&key=$key"
}
}
val saveLocation = if (variant.apkType == APKType.BUNDLE)
saveDirectory.resolve(version).also { it.mkdirs() }
else
saveDirectory.resolve("$version.apk")
try {
val downloadLocation = if (variant.apkType == APKType.BUNDLE)
saveLocation.resolve("temp.zip")
val saveLocation = if (variant.apkType == APKType.BUNDLE)
saveDirectory.resolve(version).also { it.mkdirs() }
else
saveLocation
saveDirectory.resolve("$version.apk")
httpClient.download(downloadLocation) {
url(apkMirror + downloadLink)
onDownload { bytesSentTotal, contentLength ->
_downloadProgress.emit(bytesSentTotal.div(100000).toFloat().div(10) to contentLength.div(100000).toFloat().div(10))
try {
val downloadLocation = if (variant.apkType == APKType.BUNDLE)
saveLocation.resolve("temp.zip")
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) {
// TODO: Extract temp.zip
downloadLocation.delete()
}
} catch (e: Exception) {
saveLocation.deleteRecursively()
throw e
} finally {
_downloadProgress.emit(null)
return saveLocation
}
return saveLocation
}
companion object {

View File

@ -1,11 +1,10 @@
package app.revanced.manager.network.downloader
import android.os.Parcelable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.io.File
interface AppDownloader {
val downloadProgress: StateFlow<Pair<Float, Float>?>
/**
* Returns all downloadable apps.
@ -13,19 +12,17 @@ interface AppDownloader {
* @param packageName The package name of the app.
* @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.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) =
compatiblePackages?.any { compatiblePackages.any { it.versions.isEmpty() || it.versions.any { version -> version == versionName } } }
@ -38,7 +38,7 @@ data class PatchInfo(
@Immutable
data class CompatiblePackage(
val name: String,
val packageName: String,
val versions: ImmutableList<String>
) {
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 androidx.annotation.StringRes
import app.revanced.manager.R
import app.revanced.manager.ui.model.SelectedApp
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.StateFlow
sealed class Progress {
object Downloading : Progress()
object Unpacking : Progress()
object Merging : Progress()
object PatchingStart : Progress()
@ -24,23 +27,24 @@ enum class State {
class SubStep(
val name: String,
val state: State = State.WAITING,
val message: String? = null
val message: String? = null,
val progress: StateFlow<Pair<Float, Float>?>? = null
)
class Step(
@StringRes val name: Int,
val substeps: ImmutableList<SubStep>,
val subSteps: ImmutableList<SubStep>,
val state: State = State.WAITING
)
class PatcherProgressManager(context: Context, selectedPatches: List<String>) {
val steps = generateSteps(context, selectedPatches)
class PatcherProgressManager(context: Context, selectedPatches: List<String>, selectedApp: SelectedApp, downloadProgress: StateFlow<Pair<Float, Float>?>) {
val steps = generateSteps(context, selectedPatches, selectedApp, downloadProgress)
private var currentStep: StepKey? = StepKey(0, 0)
private fun update(key: StepKey, state: State, message: String? = null) {
val isLastSubStep: Boolean
steps[key.step] = steps[key.step].let { step ->
isLastSubStep = key.substep == step.substeps.lastIndex
isLastSubStep = key.substep == step.subSteps.lastIndex
val newStepState = when {
// This step failed because one of its sub-steps failed.
@ -51,7 +55,7 @@ class PatcherProgressManager(context: Context, selectedPatches: List<String>) {
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)
}.toImmutableList(), newStepState)
}
@ -100,10 +104,11 @@ class PatcherProgressManager(context: Context, selectedPatches: List<String>) {
* A map of [Progress] to the corresponding position in [steps]
*/
private val stepKeyMap = mapOf(
Progress.Unpacking to StepKey(0, 1),
Progress.Merging to StepKey(0, 2),
//Progress.Downloading to StepKey(0, 1),
//Progress.Unpacking to StepKey(0, 2),
//Progress.Merging to StepKey(0, 3),
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(
@ -111,14 +116,15 @@ class PatcherProgressManager(context: Context, selectedPatches: List<String>) {
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(
R.string.patcher_step_group_prepare,
persistentListOf(
listOfNotNull(
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_integrations))
)
).toImmutableList()
),
generatePatchesStep(selectedPatches),
Step(

View File

@ -14,12 +14,16 @@ import androidx.core.content.ContextCompat
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
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.worker.Worker
import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.patcher.Session
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.PM
import app.revanced.manager.util.PatchesSelection
import app.revanced.manager.util.tag
import app.revanced.patcher.extensions.PatchExtensions.options
@ -34,14 +38,19 @@ import org.koin.core.component.inject
import java.io.File
import java.io.FileNotFoundException
class PatcherWorker(context: Context, parameters: WorkerParameters) :
Worker<PatcherWorker.Args>(context, parameters),
KoinComponent {
class PatcherWorker(
context: Context,
parameters: WorkerParameters
) : Worker<PatcherWorker.Args>(context, parameters), KoinComponent {
private val sourceRepository: SourceRepository 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(
val input: String,
val input: SelectedApp,
val output: String,
val selectedPatches: PatchesSelection,
val options: Options,
@ -118,8 +127,10 @@ class PatcherWorker(context: Context, parameters: WorkerParameters) :
val bundles = sourceRepository.bundles.first()
val integrations = bundles.mapNotNull { (_, bundle) -> bundle.integrations }
val downloadProgress = MutableStateFlow<Pair<Float, Float>?>(null)
val progressManager =
PatcherProgressManager(applicationContext, args.selectedPatches.flatMap { it.value })
PatcherProgressManager(applicationContext, args.selectedPatches.flatMap { it.value }, args.input, downloadProgress)
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.
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)
Session(
@ -162,10 +195,9 @@ class PatcherWorker(context: Context, parameters: WorkerParameters) :
frameworkPath,
aaptPath,
args.logger,
File(args.input)
) {
updateProgress(it)
}.use { session ->
inputFile,
onProgress = { updateProgress(it) }
).use { session ->
session.run(File(args.output), patches, integrations)
}
@ -173,7 +205,7 @@ class PatcherWorker(context: Context, parameters: WorkerParameters) :
progressManager.success()
Result.success()
} catch (e: Exception) {
Log.e(tag, "Got exception while patching".logFmt(), e)
Log.e(tag, "Exception while patching".logFmt(), e)
progressManager.failure(e)
Result.failure()
} finally {

View File

@ -1,39 +1,49 @@
package app.revanced.manager.ui.component
import android.content.pm.PackageInfo
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.filled.Android
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
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.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.unit.dp
import app.revanced.manager.util.AppInfo
import coil.compose.AsyncImage
import com.google.accompanist.placeholder.placeholder
@Composable
fun AppIcon(
app: AppInfo,
packageInfo: PackageInfo?,
contentDescription: String?,
modifier: Modifier = Modifier
) {
if (app.packageInfo == null) {
var showPlaceHolder by rememberSaveable { mutableStateOf(true) }
if (packageInfo == null) {
val image = rememberVectorPainter(Icons.Default.Android)
val colorFilter = ColorFilter.tint(LocalContentColor.current)
Image(
image,
contentDescription,
Modifier.size(36.dp).then(modifier),
Modifier.placeholder(visible = showPlaceHolder, color = MaterialTheme.colorScheme.inverseOnSurface, shape = RoundedCornerShape(100)).then(modifier),
colorFilter = colorFilter
)
showPlaceHolder = false
} else {
AsyncImage(
app.packageInfo,
packageInfo,
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
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.PatchesSelection
import kotlinx.parcelize.Parcelize
@ -19,11 +19,11 @@ sealed interface Destination : Parcelable {
object Settings : Destination
@Parcelize
data class AppDownloader(val app: AppInfo) : Destination
data class VersionSelector(val packageName: String) : Destination
@Parcelize
data class PatchesSelector(val input: AppInfo) : Destination
data class PatchesSelector(val selectedApp: SelectedApp) : Destination
@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 app.revanced.manager.R
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.LoadingIndicator
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.AppInfo
import app.revanced.manager.util.toast
import org.koin.androidx.compose.getViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSelectorScreen(
onAppClick: (AppInfo) -> Unit,
onDownloaderClick: (AppInfo) -> Unit,
onAppClick: (packageName: String) -> Unit,
onStorageClick: (SelectedApp.Local) -> Unit,
onBackClick: () -> Unit,
vm: AppSelectorViewModel = getViewModel()
) {
@ -47,7 +48,7 @@ fun AppSelectorScreen(
val pickApkLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
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
if (search) {
SearchBar(
@ -104,13 +94,11 @@ fun AppSelectorScreen(
) { app ->
ListItem(
modifier = Modifier.clickable {
app.packageInfo?.let { onAppClick(app) }
},
leadingContent = { AppIcon(app, null) },
headlineContent = { Text(vm.loadLabel(app.packageInfo)) },
modifier = Modifier.clickable { onAppClick(app.packageName) },
leadingContent = { AppIcon(app.packageInfo, null, Modifier.size(36.dp)) },
headlineContent = { AppLabel(app.packageInfo) },
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 ->
ListItem(
modifier = Modifier.clickable { selectedApp = app },
leadingContent = { AppIcon(app, null) },
headlineContent = { Text(vm.loadLabel(app.packageInfo)) },
modifier = Modifier.clickable { onAppClick(app.packageName) },
leadingContent = { AppIcon(app.packageInfo, null, Modifier.size(36.dp)) },
headlineContent = { AppLabel(app.packageInfo) },
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
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.compose.animation.AnimatedVisibility
@ -49,6 +50,8 @@ fun InstallerScreen(
onBackClick: () -> Unit,
vm: InstallerViewModel
) {
BackHandler(onBack = onBackClick)
val context = LocalContext.current
val exportApkLauncher =
rememberLauncherForActivityResult(CreateDocument(APK_MIMETYPE), vm::export)
@ -142,7 +145,7 @@ fun InstallStep(step: Step) {
.padding(start = 16.dp, end = 16.dp)
.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)
@ -162,18 +165,19 @@ fun InstallStep(step: Step) {
.padding(16.dp)
.padding(start = 4.dp)
) {
step.substeps.forEach {
step.subSteps.forEach { subStep ->
var messageExpanded by rememberSaveable { mutableStateOf(true) }
val stacktrace = it.message
val stacktrace = subStep.message
val downloadProgress = subStep.progress?.collectAsStateWithLifecycle()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
StepIcon(it.state, size = 18.dp)
StepIcon(subStep.state, downloadProgress?.value, size = 18.dp)
Text(
text = it.name,
text = subStep.name,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@ -184,6 +188,13 @@ fun InstallStep(step: Step) {
ArrowButton(expanded = 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
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)
when (status) {
@ -219,15 +230,28 @@ fun StepIcon(status: State, size: Dp) {
modifier = Modifier.size(size)
)
State.WAITING -> CircularProgressIndicator(
strokeWidth = strokeWidth,
modifier = stringResource(R.string.step_running).let { description ->
Modifier
.size(size)
.semantics {
contentDescription = description
State.WAITING ->
downloadProgress?.let { (downloaded, total) ->
CircularProgressIndicator(
progress = downloaded / total,
strokeWidth = strokeWidth,
modifier = stringResource(R.string.step_running).let { 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
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -66,8 +65,6 @@ fun PatchesSelectorScreen(
onBackClick: () -> Unit,
vm: PatchesSelectorViewModel
) {
BackHandler(onBack = onBackClick)
val pagerState = rememberPagerState()
val composableScope = rememberCoroutineScope()
@ -75,7 +72,7 @@ fun PatchesSelectorScreen(
if (vm.compatibleVersions.isNotEmpty())
UnsupportedDialog(
appVersion = vm.appInfo.packageInfo!!.versionName,
appVersion = vm.selectedApp.version,
supportedVersions = vm.compatibleVersions,
onDismissRequest = vm::dismissDialogs
)

View File

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

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.net.Uri
import androidx.lifecycle.ViewModel
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.PM
import java.io.File
import java.nio.file.Files
class AppSelectorViewModel(
private val app: Application,
private val pm: PM
pm: PM
) : ViewModel() {
private val packageManager = app.packageManager
@ -18,11 +19,17 @@ class AppSelectorViewModel(
fun loadLabel(app: PackageInfo?) = (app?.applicationInfo?.loadLabel(packageManager) ?: "Not installed").toString()
@Suppress("DEPRECATION")
fun loadSelectedFile(uri: Uri) =
app.contentResolver.openInputStream(uri)!!.use { stream ->
app.contentResolver.openInputStream(uri)?.use { stream ->
File(app.cacheDir, "input.apk").also {
if (it.exists()) it.delete()
it.delete()
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 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 signedFile = File(app.cacheDir, "signed.apk").also { if (it.exists()) it.delete() }
private var hasSigned = false
@ -69,21 +69,22 @@ class InstallerViewModel(input: Destination.Installer) : ViewModel(), KoinCompon
private val logger = ManagerLogger()
init {
val (appInfo, patches, options) = input
val (selectedApp, patches, options) = input
_progress = MutableStateFlow(PatcherProgressManager.generateSteps(
app,
patches.flatMap { (_, selected) -> selected }
patches.flatMap { (_, selected) -> selected },
input.selectedApp
).toImmutableList())
patcherWorkerId =
workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>(
"patching", PatcherWorker.Args(
appInfo.path!!.absolutePath,
selectedApp,
outputFile.path,
patches,
options,
packageName,
appInfo.packageInfo!!.versionName,
selectedApp.version,
_progress,
logger
)

View File

@ -3,25 +3,16 @@ package app.revanced.manager.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.util.PM
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MainViewModel(
sourceRepository: SourceRepository,
pm: PM
sourceRepository: SourceRepository
) : ViewModel() {
init {
with(viewModelScope) {
launch {
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.SourceRepository
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.PatchesSelection
import app.revanced.manager.util.SnapshotStateSet
@ -37,7 +37,7 @@ import org.koin.core.component.get
@Stable
@OptIn(SavedStateHandleSaveableApi::class)
class PatchesSelectorViewModel(
val appInfo: AppInfo
val selectedApp: SelectedApp
) : ViewModel(), KoinComponent {
private val selectionRepository: PatchSelectionRepository = get()
private val savedStateHandle: SavedStateHandle = get()
@ -52,12 +52,12 @@ class PatchesSelectorViewModel(
val unsupported = mutableListOf<PatchInfo>()
val universal = mutableListOf<PatchInfo>()
bundle.patches.filter { it.compatibleWith(appInfo.packageName) }.forEach {
bundle.patches.filter { it.compatibleWith(selectedApp.packageName) }.forEach {
val targetList =
if (it.compatiblePackages == null) universal else if (it.supportsVersion(
appInfo.packageInfo!!.versionName
)
) supported else unsupported
if (it.compatiblePackages == null) universal else if (it.supportsVersion(selectedApp.version))
supported
else
unsupported
targetList.add(it)
}
@ -71,7 +71,7 @@ class PatchesSelectorViewModel(
viewModelScope.launch(Dispatchers.Default) {
val bundles = bundlesFlow.first()
val filteredSelection =
selectionRepository.getSelection(appInfo.packageName).mapValues { (uid, patches) ->
selectionRepository.getSelection(selectedApp.packageName).mapValues { (uid, patches) ->
// Filter out patches that don't exist.
val filteredPatches = bundles.singleOrNull { it.uid == uid }
?.let { bundle ->
@ -117,7 +117,7 @@ class PatchesSelectorViewModel(
suspend fun getAndSaveSelection(): PatchesSelection =
selectedPatches.also {
withContext(Dispatchers.Default) {
selectionRepository.updateSelection(appInfo.packageName, it)
selectionRepository.updateSelection(selectedApp.packageName, it)
}
}.mapValues { it.value.toMutableSet() }.apply {
if (allowExperimental.get()) {
@ -150,7 +150,7 @@ class PatchesSelectorViewModel(
val set = HashSet<String>()
unsupportedVersions.forEach { patch ->
patch.compatiblePackages?.find { it.name == appInfo.packageName }
patch.compatiblePackages?.find { it.packageName == selectedApp.packageName }
?.let { compatiblePackage ->
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.PackageManager
import android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES
import android.content.pm.PackageManager.NameNotFoundException
import android.os.Build
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.service.InstallService
import app.revanced.manager.service.UninstallService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import java.io.File
@ -29,7 +31,7 @@ private const val byteArraySize = 1024 * 1024 // Because 1,048,576 is not readab
@Parcelize
data class AppInfo(
val packageName: String,
val patches: Int,
val patches: Int?,
val packageInfo: PackageInfo?,
val path: File? = null
) : Parcelable
@ -38,69 +40,65 @@ data class AppInfo(
@Suppress("DEPRECATION")
class PM(
private val app: Application,
private val sourceRepository: SourceRepository
sourceRepository: SourceRepository
) {
private val installedApps = MutableStateFlow(emptyList<AppInfo>())
private val compatibleApps = MutableStateFlow(emptyList<AppInfo>())
private val scope = CoroutineScope(Dispatchers.IO)
val appList: Flow<List<AppInfo>> = compatibleApps.combine(installedApps) { compatibleApps, installedApps ->
if (compatibleApps.isNotEmpty()) {
(compatibleApps + installedApps)
val appList = sourceRepository.bundles.map { bundles ->
val compatibleApps = scope.async {
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 }
.sortedWith(
compareByDescending<AppInfo> {
it.patches
}.thenBy { it.packageInfo?.applicationInfo?.loadLabel(app.packageManager).toString() }.thenBy { it.packageName }
)
} else {
emptyList()
} else { 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) {
val packageInstaller = app.packageManager.packageInstaller
@ -119,15 +117,6 @@ class PM(
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
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) {

View File

@ -1,11 +1,7 @@
package app.revanced.manager.util
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.pm.PackageManager.NameNotFoundException
import android.graphics.drawable.Drawable
import android.util.Log
import android.widget.Toast
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) {
Toast.makeText(this, string, duration).show()
}

View File

@ -128,10 +128,10 @@
<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="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="downloading_app">Downloading app… (%1$s MB/%2$s MB)</string>
<string name="select_file">Select file</string>