feat: improve patcher UI (#1494)

This commit is contained in:
Robert 2024-01-06 16:51:11 +01:00 committed by GitHub
parent b7cb6b94f5
commit 3232bb10e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 881 additions and 729 deletions

View File

@ -105,6 +105,7 @@ dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.preview)
implementation(libs.compose.ui.tooling)
implementation(libs.compose.livedata)
implementation(libs.compose.material.icons.extended)
implementation(libs.compose.material3)

View File

@ -20,7 +20,7 @@ import app.revanced.manager.ui.destination.SettingsDestination
import app.revanced.manager.ui.screen.AppSelectorScreen
import app.revanced.manager.ui.screen.DashboardScreen
import app.revanced.manager.ui.screen.InstalledAppInfoScreen
import app.revanced.manager.ui.screen.InstallerScreen
import app.revanced.manager.ui.screen.PatcherScreen
import app.revanced.manager.ui.screen.SelectedAppInfoScreen
import app.revanced.manager.ui.screen.SettingsScreen
import app.revanced.manager.ui.screen.VersionSelectorScreen
@ -157,7 +157,7 @@ class MainActivity : ComponentActivity() {
is Destination.SelectedApplicationInfo -> SelectedAppInfoScreen(
onPatchClick = { app, patches, options ->
navController.navigate(
Destination.Installer(
Destination.Patcher(
app, patches, options
)
)
@ -173,7 +173,7 @@ class MainActivity : ComponentActivity() {
}
)
is Destination.Installer -> InstallerScreen(
is Destination.Patcher -> PatcherScreen(
onBackClick = { navController.popUpTo { it is Destination.Dashboard } },
vm = getComposeViewModel { parametersOf(destination) }
)

View File

@ -13,7 +13,7 @@ val viewModelModule = module {
viewModelOf(::AdvancedSettingsViewModel)
viewModelOf(::AppSelectorViewModel)
viewModelOf(::VersionSelectorViewModel)
viewModelOf(::InstallerViewModel)
viewModelOf(::PatcherViewModel)
viewModelOf(::UpdateViewModel)
viewModelOf(::ChangelogsViewModel)
viewModelOf(::ImportExportViewModel)

View File

@ -1,12 +1,16 @@
package app.revanced.manager.patcher
import android.content.Context
import app.revanced.library.ApkUtils
import app.revanced.manager.ui.viewmodel.ManagerLogger
import app.revanced.manager.R
import app.revanced.manager.patcher.logger.ManagerLogger
import app.revanced.manager.ui.model.State
import app.revanced.patcher.Patcher
import app.revanced.patcher.PatcherOptions
import app.revanced.patcher.patch.Patch
import app.revanced.patcher.patch.PatchResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
import java.io.Closeable
import java.io.File
@ -21,10 +25,15 @@ class Session(
frameworkDir: String,
aaptPath: String,
multithreadingDexFileWriter: Boolean,
private val androidContext: Context,
private val logger: ManagerLogger,
private val input: File,
private val onStepSucceeded: suspend () -> Unit
private val patchesProgress: MutableStateFlow<Pair<Int, Int>>,
private val onProgress: (name: String?, state: State?, message: String?) -> Unit
) : Closeable {
private fun updateProgress(name: String? = null, state: State? = null, message: String? = null) =
onProgress(name, state, message)
private val tempDir = File(cacheDir).resolve("patcher").also { it.mkdirs() }
private val patcher = Patcher(
PatcherOptions(
@ -36,22 +45,56 @@ class Session(
)
)
private suspend fun Patcher.applyPatchesVerbose(selectedPatches: PatchList) {
var nextPatchIndex = 0
updateProgress(
name = androidContext.getString(R.string.applying_patch, selectedPatches[nextPatchIndex]),
state = State.RUNNING
)
private suspend fun Patcher.applyPatchesVerbose() {
this.apply(true).collect { (patch, exception) ->
if (exception == null) {
logger.info("$patch succeeded")
onStepSucceeded()
return@collect
if (patch !in selectedPatches) return@collect
if (exception != null) {
updateProgress(
name = androidContext.getString(R.string.failed_to_apply_patch, patch.name),
state = State.FAILED,
message = exception.stackTraceToString()
)
logger.error("${patch.name} failed:")
logger.error(exception.stackTraceToString())
throw exception
}
logger.error("$patch failed:")
logger.error(exception.stackTraceToString())
throw exception
nextPatchIndex++
patchesProgress.value.let {
patchesProgress.emit(it.copy(it.first + 1))
}
selectedPatches.getOrNull(nextPatchIndex)?.let { nextPatch ->
updateProgress(
name = androidContext.getString(R.string.applying_patch, nextPatch.name)
)
}
logger.info("${patch.name} succeeded")
}
updateProgress(
state = State.COMPLETED,
name = androidContext.resources.getQuantityString(
R.plurals.patches_applied,
selectedPatches.size,
selectedPatches.size
)
)
}
suspend fun run(output: File, selectedPatches: PatchList, integrations: List<File>) {
onStepSucceeded() // Unpacking
updateProgress(state = State.COMPLETED) // Unpacking
Logger.getLogger("").apply {
handlers.forEach {
it.close()
@ -64,10 +107,10 @@ class Session(
logger.info("Merging integrations")
acceptIntegrations(integrations)
acceptPatches(selectedPatches)
onStepSucceeded() // Merging
updateProgress(state = State.COMPLETED) // Merging
logger.info("Applying patches...")
applyPatchesVerbose()
applyPatchesVerbose(selectedPatches.sortedBy { it.name })
}
logger.info("Writing patched files...")
@ -81,7 +124,7 @@ class Session(
withContext(Dispatchers.IO) {
Files.move(aligned.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
onStepSucceeded() // Saving
updateProgress(state = State.COMPLETED) // Saving
}
override fun close() {
@ -90,7 +133,7 @@ class Session(
}
companion object {
operator fun PatchResult.component1() = patch.name
operator fun PatchResult.component1() = patch
operator fun PatchResult.component2() = exception
}
}

View File

@ -0,0 +1,59 @@
package app.revanced.manager.patcher.logger
import android.util.Log
import java.util.logging.Handler
import java.util.logging.Level
import java.util.logging.LogRecord
class ManagerLogger : Handler() {
private val logs = mutableListOf<Pair<LogLevel, String>>()
private fun log(level: LogLevel, msg: String) {
level.androidLog(msg)
if (level == LogLevel.TRACE) return
logs.add(level to msg)
}
fun export() =
logs.asSequence().map { (level, msg) -> "[${level.name}]: $msg" }.joinToString("\n")
fun trace(msg: String) = log(LogLevel.TRACE, msg)
fun info(msg: String) = log(LogLevel.INFO, msg)
fun warn(msg: String) = log(LogLevel.WARN, msg)
fun error(msg: String) = log(LogLevel.ERROR, msg)
override fun publish(record: LogRecord) {
val msg = record.message
val fn = when (record.level) {
Level.INFO -> ::info
Level.SEVERE -> ::error
Level.WARNING -> ::warn
else -> ::trace
}
fn(msg)
}
override fun flush() = Unit
override fun close() = Unit
}
enum class LogLevel {
TRACE {
override fun androidLog(msg: String) = Log.v(androidTag, msg)
},
INFO {
override fun androidLog(msg: String) = Log.i(androidTag, msg)
},
WARN {
override fun androidLog(msg: String) = Log.w(androidTag, msg)
},
ERROR {
override fun androidLog(msg: String) = Log.e(androidTag, msg)
};
abstract fun androidLog(msg: String): Int
private companion object {
const val androidTag = "ReVanced Patcher"
}
}

View File

@ -1,125 +0,0 @@
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
enum class State {
WAITING, COMPLETED, FAILED
}
class SubStep(
val name: String,
val state: State = State.WAITING,
val message: String? = null,
val progress: StateFlow<Pair<Float, Float>?>? = null
)
class Step(
@StringRes val name: Int,
val subSteps: ImmutableList<SubStep>,
val state: State = State.WAITING
)
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
val newStepState = when {
// This step failed because one of its sub-steps failed.
state == State.FAILED -> State.FAILED
// All sub-steps succeeded.
state == State.COMPLETED && isLastSubStep -> State.COMPLETED
// Keep the old status.
else -> step.state
}
Step(step.name, step.subSteps.mapIndexed { index, subStep ->
if (index != key.substep) subStep else SubStep(subStep.name, state, message)
}.toImmutableList(), newStepState)
}
val isFinal = isLastSubStep && key.step == steps.lastIndex
if (state == State.COMPLETED) {
// Move the cursor to the next step.
currentStep = when {
isFinal -> null // Final step has been completed.
isLastSubStep -> StepKey(key.step + 1, 0) // Move to the next step.
else -> StepKey(
key.step,
key.substep + 1
) // Move to the next sub-step.
}
}
}
fun replacePatchesList(newList: List<String>) {
steps[1] = generatePatchesStep(newList)
}
private fun updateCurrent(newState: State, message: String? = null) {
currentStep?.let { update(it, newState, message) }
}
fun failure(error: Throwable) = updateCurrent(
State.FAILED,
error.stackTraceToString()
)
fun success() = updateCurrent(State.COMPLETED)
fun getProgress(): List<Step> = steps
companion object {
private fun generatePatchesStep(selectedPatches: List<String>) = Step(
R.string.patcher_step_group_patching,
selectedPatches.map { SubStep(it) }.toImmutableList()
)
fun generateSteps(
context: Context,
selectedPatches: List<String>,
selectedApp: SelectedApp,
downloadProgress: StateFlow<Pair<Float, Float>?>? = null
) = mutableListOf(
Step(
R.string.patcher_step_group_prepare,
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(
R.string.patcher_step_group_saving,
persistentListOf(
SubStep(context.getString(R.string.patcher_step_write_patched)),
SubStep(context.getString(R.string.patcher_step_sign_apk))
)
)
)
}
private data class StepKey(val step: Int, val substep: Int)
}

View File

@ -28,14 +28,13 @@ 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.patcher.logger.ManagerLogger
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.ManagerLogger
import app.revanced.manager.ui.model.State
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 kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import org.koin.core.component.KoinComponent
@ -47,7 +46,6 @@ class PatcherWorker(
context: Context,
parameters: WorkerParameters
) : Worker<PatcherWorker.Args>(context, parameters), KoinComponent {
private val patchBundleRepository: PatchBundleRepository by inject()
private val workerRepository: WorkerRepository by inject()
private val prefs: PreferencesManager by inject()
@ -63,18 +61,15 @@ class PatcherWorker(
val output: String,
val selectedPatches: PatchesSelection,
val options: Options,
val progress: MutableStateFlow<ImmutableList<Step>>,
val logger: ManagerLogger,
val setInputFile: (File) -> Unit
val downloadProgress: MutableStateFlow<Pair<Float, Float>?>,
val patchesProgress: MutableStateFlow<Pair<Int, Int>>,
val setInputFile: (File) -> Unit,
val onProgress: (name: String?, state: State?, message: String?) -> Unit
) {
val packageName get() = input.packageName
}
companion object {
private const val logPrefix = "[Worker]:"
private fun String.logFmt() = "$logPrefix $this"
}
override suspend fun getForegroundInfo() =
ForegroundInfo(
1,
@ -107,8 +102,6 @@ class PatcherWorker(
return Result.failure()
}
val args = workerRepository.claimInput(this)
try {
// This does not always show up for some reason.
setForeground(getForegroundInfo())
@ -117,12 +110,13 @@ class PatcherWorker(
}
val wakeLock: PowerManager.WakeLock =
(applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, "$tag::Patcher").apply {
(applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, "$tag::Patcher").apply {
acquire(10 * 60 * 1000L)
Log.d(tag, "Acquired wakelock.")
}
}
val args = workerRepository.claimInput(this)
return try {
runPatcher(args)
@ -132,38 +126,32 @@ class PatcherWorker(
}
private suspend fun runPatcher(args: Args): Result {
val aaptPath =
Aapt.binary(applicationContext)?.absolutePath
?: throw FileNotFoundException("Could not resolve aapt.")
val frameworkPath =
applicationContext.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath
val bundles = patchBundleRepository.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 },
args.input,
downloadProgress
)
val progressFlow = args.progress
fun updateProgress(advanceCounter: Boolean = true) {
if (advanceCounter) {
progressManager.success()
}
progressFlow.value = progressManager.getProgress().toImmutableList()
}
fun updateProgress(name: String? = null, state: State? = null, message: String? = null) =
args.onProgress(name, state, message)
val patchedApk = fs.tempDir.resolve("patched.apk")
return try {
val bundles = patchBundleRepository.bundles.first()
// TODO: consider passing all the classes directly now that the input no longer needs to be serializable.
val selectedBundles = args.selectedPatches.keys
val allPatches = bundles.filterKeys { selectedBundles.contains(it) }
.mapValues { (_, bundle) -> bundle.patchClasses(args.packageName) }
val selectedPatches = args.selectedPatches.flatMap { (bundle, selected) ->
allPatches[bundle]?.filter { selected.contains(it.name) }
?: throw IllegalArgumentException("Patch bundle $bundle does not exist")
}
val aaptPath = Aapt.binary(applicationContext)?.absolutePath
?: throw FileNotFoundException("Could not resolve aapt.")
val frameworkPath =
applicationContext.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath
val integrations = bundles.mapNotNull { (_, bundle) -> bundle.integrations }
if (args.input is SelectedApp.Installed) {
installedAppRepository.get(args.packageName)?.let {
@ -173,11 +161,6 @@ class PatcherWorker(
}
}
// TODO: consider passing all the classes directly now that the input no longer needs to be serializable.
val selectedBundles = args.selectedPatches.keys
val allPatches = bundles.filterKeys { selectedBundles.contains(it) }
.mapValues { (_, bundle) -> bundle.patchClasses(args.packageName) }
// Set all patch options.
args.options.forEach { (bundle, bundlePatchOptions) ->
val patches = allPatches[bundle] ?: return@forEach
@ -189,25 +172,17 @@ class PatcherWorker(
}
}
val patches = args.selectedPatches.flatMap { (bundle, selected) ->
allPatches[bundle]?.filter { selected.contains(it.name) }
?: throw IllegalArgumentException("Patch bundle $bundle does not exist")
}
// Ensure they are in the correct order so we can track progress properly.
progressManager.replacePatchesList(patches.map { it.name.orEmpty() })
updateProgress() // Loading patches
updateProgress(state = State.COMPLETED) // Loading patches
val inputFile = when (val selectedApp = args.input) {
is SelectedApp.Download -> {
downloadedAppRepository.download(
selectedApp.app,
prefs.preferSplits.get(),
onDownload = { downloadProgress.emit(it) }
onDownload = { args.downloadProgress.emit(it) }
).also {
args.setInputFile(it)
updateProgress() // Downloading
updateProgress(state = State.COMPLETED) // Download APK
}
}
@ -220,26 +195,35 @@ class PatcherWorker(
frameworkPath,
aaptPath,
prefs.multithreadingDexFileWriter.get(),
applicationContext,
args.logger,
inputFile,
onStepSucceeded = ::updateProgress
args.patchesProgress,
args.onProgress
).use { session ->
session.run(patchedApk, patches, integrations)
session.run(
patchedApk,
selectedPatches,
integrations
)
}
keystoreManager.sign(patchedApk, File(args.output))
updateProgress() // Signing
updateProgress(state = State.COMPLETED) // Signing
Log.i(tag, "Patching succeeded".logFmt())
progressManager.success()
Result.success()
} catch (e: Exception) {
Log.e(tag, "Exception while patching".logFmt(), e)
progressManager.failure(e)
updateProgress(state = State.FAILED, message = e.stackTraceToString())
Result.failure()
} finally {
updateProgress(false)
patchedApk.delete()
}
}
companion object {
private const val logPrefix = "[Worker]:"
private fun String.logFmt() = "$logPrefix $this"
}
}

View File

@ -13,17 +13,28 @@ import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
@Composable
fun ArrowButton(modifier: Modifier = Modifier, expanded: Boolean,onClick: () -> Unit) {
IconButton(onClick = onClick) {
val description = if (expanded) R.string.collapse_content else R.string.expand_content
val rotation by animateFloatAsState(targetValue = if (expanded) 0f else 180f, label = "rotation")
fun ArrowButton(modifier: Modifier = Modifier, expanded: Boolean, onClick: (() -> Unit)?) {
val description = if (expanded) R.string.collapse_content else R.string.expand_content
val rotation by animateFloatAsState(
targetValue = if (expanded) 0f else 180f,
label = "rotation"
)
Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
contentDescription = stringResource(description),
modifier = Modifier
.rotate(rotation)
.then(modifier)
)
}
onClick?.let {
IconButton(onClick = it) {
Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
contentDescription = stringResource(description),
modifier = Modifier
.rotate(rotation)
.then(modifier)
)
}
} ?: Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
contentDescription = stringResource(description),
modifier = Modifier
.rotate(rotation)
.then(modifier)
)
}

View File

@ -1,37 +1,37 @@
package app.revanced.manager.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.Dp
@Composable
fun LoadingIndicator(
modifier: Modifier = Modifier,
progress: Float? = null,
text: String? = null
progress: () -> Float? = { null },
color: Color = ProgressIndicatorDefaults.circularColor,
strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth,
trackColor: Color = ProgressIndicatorDefaults.circularTrackColor,
strokeCap: StrokeCap = ProgressIndicatorDefaults.CircularDeterminateStrokeCap
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
text?.let { Text(text) }
progress?.let {
CircularProgressIndicator(
progress = { progress },
modifier = Modifier.padding(vertical = 16.dp).then(modifier),
)
} ?:
CircularProgressIndicator(
modifier = Modifier.padding(vertical = 16.dp).then(modifier)
)
}
progress()?.let {
CircularProgressIndicator(
progress = { it },
modifier = modifier,
color = color,
strokeWidth = strokeWidth,
trackColor = trackColor,
strokeCap = strokeCap
)
} ?:
CircularProgressIndicator(
modifier = modifier,
color = color,
strokeWidth = strokeWidth,
trackColor = trackColor,
strokeCap = strokeCap
)
}

View File

@ -0,0 +1,62 @@
package app.revanced.manager.ui.component.patcher
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ListItem
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
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.res.stringResource
import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType
@Composable
fun InstallPickerDialog(
onDismiss: () -> Unit,
onConfirm: (InstallType) -> Unit
) {
var selectedInstallType by rememberSaveable { mutableStateOf(InstallType.DEFAULT) }
AlertDialog(
onDismissRequest = onDismiss,
dismissButton = {
Button(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
},
confirmButton = {
Button(
onClick = {
onConfirm(selectedInstallType)
onDismiss()
}
) {
Text(stringResource(R.string.install_app))
}
},
title = { Text(stringResource(R.string.select_install_type)) },
text = {
Column {
InstallType.values().forEach {
ListItem(
modifier = Modifier.clickable { selectedInstallType = it },
leadingContent = {
RadioButton(
selected = selectedInstallType == it,
onClick = null
)
},
headlineContent = { Text(stringResource(it.stringResource)) }
)
}
}
}
)
}

View File

@ -0,0 +1,240 @@
package app.revanced.manager.ui.component.patcher
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.ui.component.ArrowButton
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.model.State
import app.revanced.manager.ui.model.Step
import app.revanced.manager.ui.model.StepCategory
import kotlin.math.floor
// Credits: https://github.com/Aliucord/AliucordManager/blob/main/app/src/main/kotlin/com/aliucord/manager/ui/component/installer/InstallGroup.kt
@Composable
fun Steps(
category: StepCategory,
steps: List<Step>,
stepCount: Pair<Int, Int>? = null,
) {
var expanded by rememberSaveable { mutableStateOf(true) }
val categoryColor by animateColorAsState(
if (expanded) MaterialTheme.colorScheme.surfaceContainerHigh else Color.Transparent,
label = "category"
)
val cardColor by animateColorAsState(
if (expanded) MaterialTheme.colorScheme.surfaceContainer else Color.Transparent,
label = "card"
)
val state = remember(steps) {
when {
steps.all { it.state == State.COMPLETED } -> State.COMPLETED
steps.any { it.state == State.FAILED } -> State.FAILED
steps.any { it.state == State.RUNNING } -> State.RUNNING
else -> State.WAITING
}
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(cardColor)
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clickable { expanded = !expanded }
.background(categoryColor)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(16.dp)
) {
StepIcon(state = state, size = 24.dp)
Text(stringResource(category.displayName))
Spacer(modifier = Modifier.weight(1f))
val stepProgress = remember(stepCount, steps) {
stepCount?.let { (current, total) -> "$current/$total}" }
?: "${steps.count { it.state == State.COMPLETED }}/${steps.size}"
}
Text(
text = stepProgress,
style = MaterialTheme.typography.labelSmall
)
ArrowButton(modifier = Modifier.size(24.dp), expanded = expanded, onClick = null)
}
}
AnimatedVisibility(visible = expanded) {
Column(
modifier = Modifier.fillMaxWidth()
) {
steps.forEach { step ->
val downloadProgress = step.downloadProgress?.collectAsStateWithLifecycle()
SubStep(
name = step.name,
state = step.state,
message = step.message,
downloadProgress = downloadProgress?.value
)
}
}
}
}
}
@Composable
fun SubStep(
name: String,
state: State,
message: String? = null,
downloadProgress: Pair<Float, Float>? = null
) {
var messageExpanded by rememberSaveable { mutableStateOf(true) }
Column(
modifier = Modifier
.run {
if (message != null)
clickable { messageExpanded = !messageExpanded }
else this
}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center
) {
StepIcon(state, downloadProgress, size = 20.dp)
}
Text(
text = name,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, true),
)
if (message != null) {
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center
) {
ArrowButton(
modifier = Modifier.size(20.dp),
expanded = messageExpanded,
onClick = null
)
}
} else {
downloadProgress?.let { (current, total) ->
Text(
"$current/$total MB",
style = MaterialTheme.typography.labelSmall
)
}
}
}
AnimatedVisibility(visible = messageExpanded && message != null) {
Text(
text = message.orEmpty(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(horizontal = 52.dp, vertical = 8.dp)
)
}
}
}
@Composable
fun StepIcon(state: State, progress: Pair<Float, Float>? = null, size: Dp) {
val strokeWidth = Dp(floor(size.value / 10) + 1)
when (state) {
State.COMPLETED -> Icon(
Icons.Filled.CheckCircle,
contentDescription = stringResource(R.string.step_completed),
tint = MaterialTheme.colorScheme.surfaceTint,
modifier = Modifier.size(size)
)
State.FAILED -> Icon(
Icons.Filled.Cancel,
contentDescription = stringResource(R.string.step_failed),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(size)
)
State.WAITING -> Icon(
Icons.Outlined.Circle,
contentDescription = stringResource(R.string.step_waiting),
tint = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.size(size)
)
State.RUNNING ->
LoadingIndicator(
modifier = stringResource(R.string.step_running).let { description ->
Modifier
.size(size)
.semantics {
contentDescription = description
}
},
progress = { progress?.let { (current, total) -> current / total } },
strokeWidth = strokeWidth
)
}
}

View File

@ -29,6 +29,6 @@ sealed interface Destination : Parcelable {
data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchesSelection: PatchesSelection? = null) : Destination
@Parcelize
data class Installer(val selectedApp: SelectedApp, val selectedPatches: PatchesSelection, val options: @RawValue Options) : Destination
data class Patcher(val selectedApp: SelectedApp, val selectedPatches: PatchesSelection, val options: @RawValue Options) : Destination
}

View File

@ -0,0 +1,23 @@
package app.revanced.manager.ui.model
import androidx.annotation.StringRes
import app.revanced.manager.R
import kotlinx.coroutines.flow.StateFlow
enum class StepCategory(@StringRes val displayName: Int) {
PREPARING(R.string.patcher_step_group_preparing),
PATCHING(R.string.patcher_step_group_patching),
SAVING(R.string.patcher_step_group_saving)
}
enum class State {
WAITING, RUNNING, FAILED, COMPLETED
}
data class Step(
val name: String,
val category: StepCategory,
val state: State = State.WAITING,
val message: String? = null,
val downloadProgress: StateFlow<Pair<Float, Float>?>? = null
)

View File

@ -13,7 +13,7 @@ sealed class SelectedApp : Parcelable {
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, val shouldDelete: Boolean) : SelectedApp()
data class Local(override val packageName: String, override val version: String, val file: File, val temporary: Boolean) : SelectedApp()
@Parcelize
data class Installed(override val packageName: String, override val version: String) : SelectedApp()

View File

@ -164,7 +164,8 @@ fun AppSelectorScreen(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(paddingValues),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
ListItem(

View File

@ -1,305 +0,0 @@
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
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material.icons.outlined.PostAdd
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.patcher.worker.State
import app.revanced.manager.patcher.worker.Step
import app.revanced.manager.ui.component.AppScaffold
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ArrowButton
import app.revanced.manager.ui.viewmodel.InstallerViewModel
import app.revanced.manager.util.APK_MIMETYPE
import kotlin.math.floor
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun InstallerScreen(
onBackClick: () -> Unit,
vm: InstallerViewModel
) {
BackHandler(onBack = onBackClick)
val context = LocalContext.current
val exportApkLauncher =
rememberLauncherForActivityResult(CreateDocument(APK_MIMETYPE), vm::export)
val patcherState by vm.patcherState.observeAsState(null)
val steps by vm.progress.collectAsStateWithLifecycle()
val canInstall by remember { derivedStateOf { patcherState == true && (vm.installedPackageName != null || !vm.isInstalling) } }
var showInstallPicker by rememberSaveable { mutableStateOf(false) }
if (showInstallPicker)
InstallPicker(
onDismiss = { showInstallPicker = false },
onConfirm = { vm.install(it) }
)
AppScaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.installer),
onBackClick = onBackClick
)
},
bottomBar = {
AnimatedVisibility(patcherState != null) {
BottomAppBar(
actions = {
if (canInstall) {
IconButton(onClick = { exportApkLauncher.launch("${vm.packageName}.apk") }) {
Icon(Icons.Outlined.Save, stringResource(id = R.string.save_apk))
}
}
IconButton(onClick = { vm.exportLogs(context) }) {
Icon(Icons.Outlined.PostAdd, stringResource(id = R.string.save_logs))
}
},
floatingActionButton = {
if (canInstall) {
ExtendedFloatingActionButton(
text = { Text(stringResource(vm.appButtonText)) },
icon = { Icon(Icons.Outlined.FileDownload, stringResource(id = R.string.install_app)) },
containerColor = BottomAppBarDefaults.bottomAppBarFabColor,
elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(),
onClick = {
if (vm.installedPackageName == null)
showInstallPicker = true
else
vm.open()
}
)
}
}
)
}
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.fillMaxSize()
) {
steps.forEach {
InstallStep(it)
}
}
}
}
@Composable
fun InstallPicker(
onDismiss: () -> Unit,
onConfirm: (InstallType) -> Unit
) {
var selectedInstallType by rememberSaveable { mutableStateOf(InstallType.DEFAULT) }
AlertDialog(
onDismissRequest = onDismiss,
dismissButton = {
Button(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
},
confirmButton = {
Button(
onClick = {
onConfirm(selectedInstallType)
onDismiss()
}
) {
Text(stringResource(R.string.install_app))
}
},
title = { Text(stringResource(R.string.select_install_type)) },
text = {
Column {
InstallType.values().forEach {
ListItem(
modifier = Modifier.clickable { selectedInstallType = it },
leadingContent = {
RadioButton(
selected = selectedInstallType == it,
onClick = null
)
},
headlineContent = { Text(stringResource(it.stringResource)) }
)
}
}
}
)
}
// Credits: https://github.com/Aliucord/AliucordManager/blob/main/app/src/main/kotlin/com/aliucord/manager/ui/component/installer/InstallGroup.kt
@Composable
fun InstallStep(step: Step) {
var expanded by rememberSaveable { mutableStateOf(true) }
Column(
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.run {
if (expanded) {
background(MaterialTheme.colorScheme.secondaryContainer)
} else this
}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp)
.background(if (expanded) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface)
) {
StepIcon(step.state, size = 24.dp)
Text(text = stringResource(step.name), style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.weight(1f))
ArrowButton(modifier = Modifier.size(24.dp), expanded = expanded) {
expanded = !expanded
}
}
AnimatedVisibility(visible = expanded) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.background(MaterialTheme.colorScheme.background.copy(0.6f))
.fillMaxWidth()
.padding(16.dp)
) {
step.subSteps.forEach { subStep ->
var messageExpanded by rememberSaveable { mutableStateOf(true) }
val stacktrace = subStep.message
val downloadProgress = subStep.progress?.collectAsStateWithLifecycle()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
StepIcon(subStep.state, downloadProgress?.value, size = 24.dp)
Text(
text = subStep.name,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, true),
)
if (stacktrace != null) {
ArrowButton(
modifier = Modifier.size(24.dp),
expanded = messageExpanded
) {
messageExpanded = !messageExpanded
}
} else {
downloadProgress?.value?.let { (downloaded, total) ->
Text(
"$downloaded/$total MB",
style = MaterialTheme.typography.labelSmall
)
}
}
}
AnimatedVisibility(visible = messageExpanded && stacktrace != null) {
Text(
text = stacktrace ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.secondary
)
}
}
}
}
}
}
@Composable
fun StepIcon(status: State, downloadProgress: Pair<Float, Float>? = null, size: Dp) {
val strokeWidth = Dp(floor(size.value / 10) + 1)
when (status) {
State.COMPLETED -> Icon(
Icons.Filled.CheckCircle,
contentDescription = stringResource(R.string.step_completed),
tint = MaterialTheme.colorScheme.surfaceTint,
modifier = Modifier.size(size)
)
State.FAILED -> Icon(
Icons.Filled.Cancel,
contentDescription = stringResource(R.string.step_failed),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(size)
)
State.WAITING ->
downloadProgress?.let { (downloaded, total) ->
CircularProgressIndicator(
progress = { downloaded / total },
modifier = stringResource(R.string.step_running).let { description ->
Modifier
.size(size)
.semantics {
contentDescription = description
}
},
strokeWidth = strokeWidth,
)
} ?: CircularProgressIndicator(
strokeWidth = strokeWidth,
modifier = stringResource(R.string.step_running).let { description ->
Modifier
.size(size)
.semantics {
contentDescription = description
}
}
)
}
}

View File

@ -0,0 +1,171 @@
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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material.icons.outlined.PostAdd
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppScaffold
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.patcher.InstallPickerDialog
import app.revanced.manager.ui.component.patcher.Steps
import app.revanced.manager.ui.model.State
import app.revanced.manager.ui.model.StepCategory
import app.revanced.manager.ui.viewmodel.PatcherViewModel
import app.revanced.manager.util.APK_MIMETYPE
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PatcherScreen(
onBackClick: () -> Unit,
vm: PatcherViewModel
) {
BackHandler(onBack = onBackClick)
val context = LocalContext.current
val exportApkLauncher =
rememberLauncherForActivityResult(CreateDocument(APK_MIMETYPE), vm::export)
val patcherSucceeded by vm.patcherSucceeded.observeAsState(null)
val canInstall by remember { derivedStateOf { patcherSucceeded == true && (vm.installedPackageName != null || !vm.isInstalling) } }
var showInstallPicker by rememberSaveable { mutableStateOf(false) }
val steps by remember {
derivedStateOf {
vm.steps.groupBy { it.category }
}
}
val patchesProgress by vm.patchesProgress.collectAsStateWithLifecycle()
val progress = remember(vm.steps, patchesProgress) {
val current = vm.steps.filter {
it.state == State.COMPLETED && it.category != StepCategory.PATCHING
}.size + patchesProgress.first
val total = vm.steps.size - 1 + patchesProgress.second
current.toFloat() / total.toFloat()
}
if (showInstallPicker)
InstallPickerDialog(
onDismiss = { showInstallPicker = false },
onConfirm = vm::install
)
AppScaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.patcher),
onBackClick = onBackClick
)
},
bottomBar = {
BottomAppBar(
actions = {
IconButton(
onClick = { exportApkLauncher.launch("${vm.packageName}.apk") },
enabled = canInstall
) {
Icon(Icons.Outlined.Save, stringResource(id = R.string.save_apk))
}
IconButton(
onClick = { vm.exportLogs(context) },
enabled = patcherSucceeded != null
) {
Icon(Icons.Outlined.PostAdd, stringResource(id = R.string.save_logs))
}
},
floatingActionButton = {
AnimatedVisibility(visible = canInstall) {
ExtendedFloatingActionButton(
text = {
Text(
stringResource(if (vm.installedPackageName == null) R.string.install_app else R.string.open_app)
)
},
icon = {
vm.installedPackageName?.let {
Icon(
Icons.AutoMirrored.Outlined.OpenInNew,
stringResource(R.string.open_app)
)
} ?: Icon(
Icons.Outlined.FileDownload,
stringResource(R.string.install_app)
)
},
onClick = {
if (vm.installedPackageName == null)
showInstallPicker = true
else vm.open()
}
)
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth()
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(16.dp)
) {
items(
items = steps.toList(),
key = { it.first }
) { (category, steps) ->
Steps(
category = category,
steps = steps,
stepCount = if (category == StepCategory.PATCHING) patchesProgress else null
)
}
}
}
}
}

View File

@ -2,6 +2,7 @@ package app.revanced.manager.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -82,7 +83,8 @@ fun VersionSelectorScreen(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
viewModel.installedApp?.let { (packageInfo, installedApp) ->
SelectedApp.Installed(
@ -101,7 +103,9 @@ fun VersionSelectorScreen(
}
}
GroupHeader(stringResource(R.string.downloadable_versions))
Row(Modifier.fillMaxWidth()) {
GroupHeader(stringResource(R.string.downloadable_versions))
}
list.forEach {
SelectedAppItem(

View File

@ -31,7 +31,7 @@ class AppSelectorViewModel(
packageName = packageInfo.packageName,
version = packageInfo.versionName,
file = this,
shouldDelete = true
temporary = true
)
}
}

View File

@ -9,10 +9,10 @@ import android.content.pm.PackageInstaller
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
@ -26,22 +26,21 @@ import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.patcher.worker.PatcherProgressManager
import app.revanced.manager.patcher.logger.ManagerLogger
import app.revanced.manager.patcher.worker.PatcherWorker
import app.revanced.manager.patcher.worker.Step
import app.revanced.manager.service.InstallService
import app.revanced.manager.service.UninstallService
import app.revanced.manager.ui.destination.Destination
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.model.State
import app.revanced.manager.ui.model.Step
import app.revanced.manager.ui.model.StepCategory
import app.revanced.manager.util.PM
import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
@ -49,12 +48,10 @@ import org.koin.core.component.inject
import java.io.File
import java.nio.file.Files
import java.util.UUID
import java.util.logging.Level
import java.util.logging.LogRecord
@Stable
class InstallerViewModel(
private val input: Destination.Installer
class PatcherViewModel(
private val input: Destination.Patcher
) : ViewModel(), KoinComponent {
private val app: Application by inject()
private val fs: Filesystem by inject()
@ -63,62 +60,62 @@ class InstallerViewModel(
private val installedAppRepository: InstalledAppRepository by inject()
private val rootInstaller: RootInstaller by inject()
private var installedApp: InstalledApp? = null
val packageName: String = input.selectedApp.packageName
var installedPackageName by mutableStateOf<String?>(null)
private set
var isInstalling by mutableStateOf(false)
private set
private val tempDir = fs.tempDir.resolve("installer").also {
it.deleteRecursively()
it.mkdirs()
}
private val outputFile = tempDir.resolve("output.apk")
private var inputFile: File? = null
private var installedApp: InstalledApp? = null
var isInstalling by mutableStateOf(false)
private set
var installedPackageName by mutableStateOf<String?>(null)
private set
val appButtonText by derivedStateOf { if (installedPackageName == null) R.string.install_app else R.string.open_app }
private val outputFile = tempDir.resolve("output.apk")
private val workManager = WorkManager.getInstance(app)
private val _progress: MutableStateFlow<ImmutableList<Step>>
private val patcherWorkerId: UUID
private val logger = ManagerLogger()
init {
// TODO: navigate away when system-initiated process death is detected because it is not possible to recover from it.
val patchesProgress = MutableStateFlow(Pair(0, input.selectedPatches.values.sumOf { it.size }))
private val downloadProgress = MutableStateFlow<Pair<Float, Float>?>(null)
val steps = generateSteps(
app,
input.selectedApp,
downloadProgress
).toMutableStateList()
private var currentStepIndex = 0
viewModelScope.launch {
installedApp = installedAppRepository.get(packageName)
}
private val patcherWorkerId: UUID =
workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>(
"patching", PatcherWorker.Args(
input.selectedApp,
outputFile.path,
input.selectedPatches,
input.options,
logger,
downloadProgress,
patchesProgress,
setInputFile = { inputFile = it },
onProgress = { name, state, message ->
steps[currentStepIndex] = steps[currentStepIndex].run {
copy(
name = name ?: this.name,
state = state ?: this.state,
message = message ?: this.message
)
}
val (selectedApp, patches, options) = input
if (state == State.COMPLETED && currentStepIndex != steps.lastIndex) {
currentStepIndex++
_progress = MutableStateFlow(
PatcherProgressManager.generateSteps(
app,
patches.flatMap { (_, selected) -> selected },
selectedApp
).toImmutableList()
steps[currentStepIndex] = steps[currentStepIndex].copy(state = State.RUNNING)
}
}
)
)
patcherWorkerId =
workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>(
"patching", PatcherWorker.Args(
selectedApp,
outputFile.path,
patches,
options,
_progress,
logger,
setInputFile = { inputFile = it }
)
)
}
val progress = _progress.asStateFlow()
val patcherState =
val patcherSucceeded =
workManager.getWorkInfoByIdLiveData(patcherWorkerId).map { workInfo: WorkInfo ->
when (workInfo.state) {
WorkInfo.State.SUCCEEDED -> true
@ -151,29 +148,18 @@ class InstallerViewModel(
app.toast(app.getString(R.string.install_app_fail, extra))
}
}
UninstallService.APP_UNINSTALL_ACTION -> {
}
}
}
}
init {
init { // TODO: navigate away when system-initiated process death is detected because it is not possible to recover from it.
ContextCompat.registerReceiver(app, installBroadcastReceiver, IntentFilter().apply {
addAction(InstallService.APP_INSTALL_ACTION)
addAction(UninstallService.APP_UNINSTALL_ACTION)
}, ContextCompat.RECEIVER_NOT_EXPORTED)
}
fun exportLogs(context: Context) {
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, logger.export())
type = "text/plain"
viewModelScope.launch {
installedApp = installedAppRepository.get(packageName)
}
val shareIntent = Intent.createChooser(sendIntent, null)
context.startActivity(shareIntent)
}
override fun onCleared() {
@ -183,7 +169,7 @@ class InstallerViewModel(
when (val selectedApp = input.selectedApp) {
is SelectedApp.Local -> {
if (selectedApp.shouldDelete) selectedApp.file.delete()
if (selectedApp.temporary) selectedApp.file.delete()
}
is SelectedApp.Installed -> {
@ -199,7 +185,7 @@ class InstallerViewModel(
}
}
else -> {}
else -> Unit
}
tempDir.deleteRecursively()
@ -215,117 +201,103 @@ class InstallerViewModel(
}
}
fun exportLogs(context: Context) {
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, logger.export())
type = "text/plain"
}
val shareIntent = Intent.createChooser(sendIntent, null)
context.startActivity(shareIntent)
}
fun open() = installedPackageName?.let(pm::launch)
fun install(installType: InstallType) = viewModelScope.launch {
isInstalling = true
try {
isInstalling = true
when (installType) {
InstallType.DEFAULT -> {
pm.installApp(listOf(outputFile))
}
InstallType.ROOT -> {
installAsRoot()
try {
val label = with(pm) {
getPackageInfo(outputFile)?.label()
?: throw Exception("Failed to load application info")
}
rootInstaller.install(
outputFile,
inputFile,
packageName,
input.selectedApp.version,
label
)
installedAppRepository.addOrUpdate(
packageName,
packageName,
input.selectedApp.version,
InstallType.ROOT,
input.selectedPatches
)
rootInstaller.mount(packageName)
installedPackageName = packageName
app.toast(app.getString(R.string.install_app_success))
} catch (e: Exception) {
Log.e(tag, "Failed to install as root", e)
app.toast(app.getString(R.string.install_app_fail, e.simpleMessage()))
try {
rootInstaller.uninstall(packageName)
} catch (_: Exception) { }
}
}
}
} finally {
isInstalling = false
}
}
fun open() = installedPackageName?.let { pm.launch(it) }
companion object {
fun generateSteps(
context: Context,
selectedApp: SelectedApp,
downloadProgress: StateFlow<Pair<Float, Float>?>? = null
): List<Step> {
return listOfNotNull(
Step(
context.getString(R.string.patcher_step_load_patches),
StepCategory.PREPARING,
state = State.RUNNING
),
Step(
context.getString(R.string.download_apk),
StepCategory.PREPARING,
downloadProgress = downloadProgress
).takeIf { selectedApp is SelectedApp.Download },
Step(
context.getString(R.string.patcher_step_unpack),
StepCategory.PREPARING
),
Step(
context.getString(R.string.patcher_step_integrations),
StepCategory.PREPARING
),
private suspend fun installAsRoot() {
try {
val label = with(pm) {
getPackageInfo(outputFile)?.label()
?: throw Exception("Failed to load application info")
}
Step(
context.getString(R.string.apply_patches),
StepCategory.PATCHING
),
rootInstaller.install(
outputFile,
inputFile,
packageName,
input.selectedApp.version,
label
Step(context.getString(R.string.patcher_step_write_patched), StepCategory.SAVING),
Step(context.getString(R.string.patcher_step_sign_apk), StepCategory.SAVING)
)
rootInstaller.mount(packageName)
installedApp?.let { installedAppRepository.delete(it) }
installedAppRepository.addOrUpdate(
packageName,
packageName,
input.selectedApp.version,
InstallType.ROOT,
input.selectedPatches
)
installedPackageName = packageName
app.toast(app.getString(R.string.install_app_success))
} catch (e: Exception) {
Log.e(tag, "Failed to install as root", e)
app.toast(app.getString(R.string.install_app_fail, e.simpleMessage()))
try {
rootInstaller.uninstall(packageName)
} catch (_: Exception) {
}
}
}
}
// TODO: move this to a better place
class ManagerLogger : java.util.logging.Handler() {
private val logs = mutableListOf<Pair<LogLevel, String>>()
private fun log(level: LogLevel, msg: String) {
level.androidLog(msg)
if (level == LogLevel.TRACE) return
logs.add(level to msg)
}
fun export() =
logs.asSequence().map { (level, msg) -> "[${level.name}]: $msg" }.joinToString("\n")
fun trace(msg: String) = log(LogLevel.TRACE, msg)
fun info(msg: String) = log(LogLevel.INFO, msg)
fun warn(msg: String) = log(LogLevel.WARN, msg)
fun error(msg: String) = log(LogLevel.ERROR, msg)
override fun publish(record: LogRecord) {
val msg = record.message
val fn = when (record.level) {
Level.INFO -> ::info
Level.SEVERE -> ::error
Level.WARNING -> ::warn
else -> ::trace
}
fn(msg)
}
override fun flush() = Unit
override fun close() = Unit
}
enum class LogLevel {
TRACE {
override fun androidLog(msg: String) = Log.v(androidTag, msg)
},
INFO {
override fun androidLog(msg: String) = Log.i(androidTag, msg)
},
WARN {
override fun androidLog(msg: String) = Log.w(androidTag, msg)
},
ERROR {
override fun androidLog(msg: String) = Log.e(androidTag, msg)
};
abstract fun androidLog(msg: String): Int
private companion object {
const val androidTag = "ReVanced Patcher"
}
}

View File

@ -8,4 +8,8 @@
<item quantity="one">%d applied patch</item>
<item quantity="other">%d applied patches</item>
</plurals>
<plurals name="patches_applied">
<item quantity="one">Applied %d patch</item>
<item quantity="other">Applied %d patches</item>
</plurals>
</resources>

View File

@ -99,10 +99,10 @@
<string name="patch_options_clear_bundle_description">Resets patch options for all patches in a bundle</string>
<string name="patch_options_clear_all">Clear all patch options</string>
<string name="patch_options_clear_all_description">Resets all patch options</string>
<string name="prefer_splits">Prefer split apks</string>
<string name="prefer_splits_description">Prefer split apks instead of full apks</string>
<string name="prefer_universal">Prefer universal apks</string>
<string name="prefer_universal_description">Prefer universal instead of arch-specific apks</string>
<string name="prefer_splits">Prefer split APK\'s</string>
<string name="prefer_splits_description">Prefer split APK\'s instead of full APK\'s</string>
<string name="prefer_universal">Prefer universal APK\'s</string>
<string name="prefer_universal_description">Prefer universal instead of arch-specific APK\'s</string>
<string name="search_apps">Search apps…</string>
<string name="loading_body">Loading…</string>
@ -155,6 +155,7 @@
<string name="continue_anyways">Continue anyways</string>
<string name="download_another_version">Download another version</string>
<string name="download_app">Download app</string>
<string name="download_apk">Download APK</string>
<string name="source_download_fail">Failed to download patch bundle: %s</string>
<string name="source_replace_fail">Failed to load updated patch bundle: %s</string>
<string name="source_replace_integrations_fail">Failed to update integrations: %s</string>
@ -179,7 +180,7 @@
<string name="version_not_supported">Not all patches support this version (%s). Do you want to continue anyway?</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="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="installed">Installed</string>
@ -233,23 +234,27 @@
<string name="open_app">Open</string>
<string name="save_apk">Save APK</string>
<string name="save_apk_success">APK Saved</string>
<string name="sign_fail">Failed to sign Apk: %s</string>
<string name="sign_fail">Failed to sign APK: %s</string>
<string name="save_logs">Save logs</string>
<string name="select_install_type">Select installation type</string>
<string name="patcher_step_group_prepare">Preparation</string>
<string name="patcher_step_group_preparing">Preparing</string>
<string name="patcher_step_load_patches">Load patches</string>
<string name="patcher_step_unpack">Unpack Apk</string>
<string name="patcher_step_unpack">Unpack APK</string>
<string name="patcher_step_integrations">Merge Integrations</string>
<string name="patcher_step_group_patching">Patching</string>
<string name="patcher_step_group_saving">Saving</string>
<string name="patcher_step_write_patched">Write patched Apk</string>
<string name="patcher_step_sign_apk">Sign Apk</string>
<string name="patcher_step_write_patched">Write patched APK</string>
<string name="patcher_step_sign_apk">Sign APK</string>
<string name="patcher_notification_message">Patching in progress…</string>
<string name="apply_patches">Apply patches</string>
<string name="applying_patch">Applying %s</string>
<string name="failed_to_apply_patch">Failed to apply %s</string>
<string name="step_completed">completed</string>
<string name="step_failed">failed</string>
<string name="step_running">running</string>
<string name="step_waiting">waiting</string>
<string name="expand_content">expand</string>
<string name="collapse_content">collapse</string>

View File

@ -1,5 +1,6 @@
[versions]
ktx = "1.12.0"
ui-tooling = "1.6.0-alpha08"
viewmodel-lifecycle = "2.6.2"
splash-screen = "1.0.1"
compose-activity = "1.8.2"
@ -43,6 +44,7 @@ preferences-datastore = { group = "androidx.datastore", name = "datastore-prefer
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "ui-tooling" }
compose-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" }
compose-material3 = { group = "androidx.compose.material3", name = "material3", version = "1.2.0-beta01"}
compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }