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(platform(libs.compose.bom))
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.ui.preview) implementation(libs.compose.ui.preview)
implementation(libs.compose.ui.tooling)
implementation(libs.compose.livedata) implementation(libs.compose.livedata)
implementation(libs.compose.material.icons.extended) implementation(libs.compose.material.icons.extended)
implementation(libs.compose.material3) 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.AppSelectorScreen
import app.revanced.manager.ui.screen.DashboardScreen import app.revanced.manager.ui.screen.DashboardScreen
import app.revanced.manager.ui.screen.InstalledAppInfoScreen 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.SelectedAppInfoScreen
import app.revanced.manager.ui.screen.SettingsScreen import app.revanced.manager.ui.screen.SettingsScreen
import app.revanced.manager.ui.screen.VersionSelectorScreen import app.revanced.manager.ui.screen.VersionSelectorScreen
@ -157,7 +157,7 @@ class MainActivity : ComponentActivity() {
is Destination.SelectedApplicationInfo -> SelectedAppInfoScreen( is Destination.SelectedApplicationInfo -> SelectedAppInfoScreen(
onPatchClick = { app, patches, options -> onPatchClick = { app, patches, options ->
navController.navigate( navController.navigate(
Destination.Installer( Destination.Patcher(
app, patches, options 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 } }, onBackClick = { navController.popUpTo { it is Destination.Dashboard } },
vm = getComposeViewModel { parametersOf(destination) } vm = getComposeViewModel { parametersOf(destination) }
) )

View File

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

View File

@ -1,12 +1,16 @@
package app.revanced.manager.patcher package app.revanced.manager.patcher
import android.content.Context
import app.revanced.library.ApkUtils 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.Patcher
import app.revanced.patcher.PatcherOptions import app.revanced.patcher.PatcherOptions
import app.revanced.patcher.patch.Patch import app.revanced.patcher.patch.Patch
import app.revanced.patcher.patch.PatchResult import app.revanced.patcher.patch.PatchResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.Closeable import java.io.Closeable
import java.io.File import java.io.File
@ -21,10 +25,15 @@ class Session(
frameworkDir: String, frameworkDir: String,
aaptPath: String, aaptPath: String,
multithreadingDexFileWriter: Boolean, multithreadingDexFileWriter: Boolean,
private val androidContext: Context,
private val logger: ManagerLogger, private val logger: ManagerLogger,
private val input: File, 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 { ) : 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 tempDir = File(cacheDir).resolve("patcher").also { it.mkdirs() }
private val patcher = Patcher( private val patcher = Patcher(
PatcherOptions( 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) -> this.apply(true).collect { (patch, exception) ->
if (exception == null) { if (patch !in selectedPatches) return@collect
logger.info("$patch succeeded")
onStepSucceeded() if (exception != null) {
return@collect 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()) nextPatchIndex++
throw exception
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>) { suspend fun run(output: File, selectedPatches: PatchList, integrations: List<File>) {
onStepSucceeded() // Unpacking updateProgress(state = State.COMPLETED) // Unpacking
Logger.getLogger("").apply { Logger.getLogger("").apply {
handlers.forEach { handlers.forEach {
it.close() it.close()
@ -64,10 +107,10 @@ class Session(
logger.info("Merging integrations") logger.info("Merging integrations")
acceptIntegrations(integrations) acceptIntegrations(integrations)
acceptPatches(selectedPatches) acceptPatches(selectedPatches)
onStepSucceeded() // Merging updateProgress(state = State.COMPLETED) // Merging
logger.info("Applying patches...") logger.info("Applying patches...")
applyPatchesVerbose() applyPatchesVerbose(selectedPatches.sortedBy { it.name })
} }
logger.info("Writing patched files...") logger.info("Writing patched files...")
@ -81,7 +124,7 @@ class Session(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Files.move(aligned.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING) Files.move(aligned.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING)
} }
onStepSucceeded() // Saving updateProgress(state = State.COMPLETED) // Saving
} }
override fun close() { override fun close() {
@ -90,7 +133,7 @@ class Session(
} }
companion object { companion object {
operator fun PatchResult.component1() = patch.name operator fun PatchResult.component1() = patch
operator fun PatchResult.component2() = exception 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.domain.worker.WorkerRepository
import app.revanced.manager.patcher.Session import app.revanced.manager.patcher.Session
import app.revanced.manager.patcher.aapt.Aapt import app.revanced.manager.patcher.aapt.Aapt
import app.revanced.manager.patcher.logger.ManagerLogger
import app.revanced.manager.ui.model.SelectedApp 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.Options
import app.revanced.manager.util.PM import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchesSelection import app.revanced.manager.util.PatchesSelection
import app.revanced.manager.util.tag import app.revanced.manager.util.tag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -47,7 +46,6 @@ class PatcherWorker(
context: Context, context: Context,
parameters: WorkerParameters parameters: WorkerParameters
) : Worker<PatcherWorker.Args>(context, parameters), KoinComponent { ) : Worker<PatcherWorker.Args>(context, parameters), KoinComponent {
private val patchBundleRepository: PatchBundleRepository by inject() private val patchBundleRepository: PatchBundleRepository by inject()
private val workerRepository: WorkerRepository by inject() private val workerRepository: WorkerRepository by inject()
private val prefs: PreferencesManager by inject() private val prefs: PreferencesManager by inject()
@ -63,18 +61,15 @@ class PatcherWorker(
val output: String, val output: String,
val selectedPatches: PatchesSelection, val selectedPatches: PatchesSelection,
val options: Options, val options: Options,
val progress: MutableStateFlow<ImmutableList<Step>>,
val logger: ManagerLogger, 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 val packageName get() = input.packageName
} }
companion object {
private const val logPrefix = "[Worker]:"
private fun String.logFmt() = "$logPrefix $this"
}
override suspend fun getForegroundInfo() = override suspend fun getForegroundInfo() =
ForegroundInfo( ForegroundInfo(
1, 1,
@ -107,8 +102,6 @@ class PatcherWorker(
return Result.failure() return Result.failure()
} }
val args = workerRepository.claimInput(this)
try { try {
// This does not always show up for some reason. // This does not always show up for some reason.
setForeground(getForegroundInfo()) setForeground(getForegroundInfo())
@ -117,12 +110,13 @@ class PatcherWorker(
} }
val wakeLock: PowerManager.WakeLock = val wakeLock: PowerManager.WakeLock =
(applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager).run { (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
newWakeLock(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, "$tag::Patcher").apply { .newWakeLock(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, "$tag::Patcher").apply {
acquire(10 * 60 * 1000L) acquire(10 * 60 * 1000L)
Log.d(tag, "Acquired wakelock.") Log.d(tag, "Acquired wakelock.")
} }
}
val args = workerRepository.claimInput(this)
return try { return try {
runPatcher(args) runPatcher(args)
@ -132,38 +126,32 @@ class PatcherWorker(
} }
private suspend fun runPatcher(args: Args): Result { private suspend fun runPatcher(args: Args): Result {
val aaptPath =
Aapt.binary(applicationContext)?.absolutePath
?: throw FileNotFoundException("Could not resolve aapt.")
val frameworkPath = fun updateProgress(name: String? = null, state: State? = null, message: String? = null) =
applicationContext.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath args.onProgress(name, state, message)
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()
}
val patchedApk = fs.tempDir.resolve("patched.apk") val patchedApk = fs.tempDir.resolve("patched.apk")
return try { 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) { if (args.input is SelectedApp.Installed) {
installedAppRepository.get(args.packageName)?.let { 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. // Set all patch options.
args.options.forEach { (bundle, bundlePatchOptions) -> args.options.forEach { (bundle, bundlePatchOptions) ->
val patches = allPatches[bundle] ?: return@forEach val patches = allPatches[bundle] ?: return@forEach
@ -189,25 +172,17 @@ class PatcherWorker(
} }
} }
val patches = args.selectedPatches.flatMap { (bundle, selected) -> updateProgress(state = State.COMPLETED) // Loading patches
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
val inputFile = when (val selectedApp = args.input) { val inputFile = when (val selectedApp = args.input) {
is SelectedApp.Download -> { is SelectedApp.Download -> {
downloadedAppRepository.download( downloadedAppRepository.download(
selectedApp.app, selectedApp.app,
prefs.preferSplits.get(), prefs.preferSplits.get(),
onDownload = { downloadProgress.emit(it) } onDownload = { args.downloadProgress.emit(it) }
).also { ).also {
args.setInputFile(it) args.setInputFile(it)
updateProgress() // Downloading updateProgress(state = State.COMPLETED) // Download APK
} }
} }
@ -220,26 +195,35 @@ class PatcherWorker(
frameworkPath, frameworkPath,
aaptPath, aaptPath,
prefs.multithreadingDexFileWriter.get(), prefs.multithreadingDexFileWriter.get(),
applicationContext,
args.logger, args.logger,
inputFile, inputFile,
onStepSucceeded = ::updateProgress args.patchesProgress,
args.onProgress
).use { session -> ).use { session ->
session.run(patchedApk, patches, integrations) session.run(
patchedApk,
selectedPatches,
integrations
)
} }
keystoreManager.sign(patchedApk, File(args.output)) keystoreManager.sign(patchedApk, File(args.output))
updateProgress() // Signing updateProgress(state = State.COMPLETED) // Signing
Log.i(tag, "Patching succeeded".logFmt()) Log.i(tag, "Patching succeeded".logFmt())
progressManager.success()
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(tag, "Exception while patching".logFmt(), e) Log.e(tag, "Exception while patching".logFmt(), e)
progressManager.failure(e) updateProgress(state = State.FAILED, message = e.stackTraceToString())
Result.failure() Result.failure()
} finally { } finally {
updateProgress(false)
patchedApk.delete() 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 import app.revanced.manager.R
@Composable @Composable
fun ArrowButton(modifier: Modifier = Modifier, expanded: Boolean,onClick: () -> Unit) { 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 description = if (expanded) R.string.collapse_content else R.string.expand_content val rotation by animateFloatAsState(
val rotation by animateFloatAsState(targetValue = if (expanded) 0f else 180f, label = "rotation") targetValue = if (expanded) 0f else 180f,
label = "rotation"
)
Icon( onClick?.let {
imageVector = Icons.Filled.KeyboardArrowUp, IconButton(onClick = it) {
contentDescription = stringResource(description), Icon(
modifier = Modifier imageVector = Icons.Filled.KeyboardArrowUp,
.rotate(rotation) contentDescription = stringResource(description),
.then(modifier) 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 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.CircularProgressIndicator
import androidx.compose.material3.Text import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 @Composable
fun LoadingIndicator( fun LoadingIndicator(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
progress: Float? = null, progress: () -> Float? = { null },
text: String? = null color: Color = ProgressIndicatorDefaults.circularColor,
strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth,
trackColor: Color = ProgressIndicatorDefaults.circularTrackColor,
strokeCap: StrokeCap = ProgressIndicatorDefaults.CircularDeterminateStrokeCap
) { ) {
Column( progress()?.let {
modifier = Modifier.fillMaxSize(), CircularProgressIndicator(
verticalArrangement = Arrangement.Center, progress = { it },
horizontalAlignment = Alignment.CenterHorizontally modifier = modifier,
) { color = color,
text?.let { Text(text) } strokeWidth = strokeWidth,
trackColor = trackColor,
progress?.let { strokeCap = strokeCap
CircularProgressIndicator( )
progress = { progress }, } ?:
modifier = Modifier.padding(vertical = 16.dp).then(modifier), CircularProgressIndicator(
) modifier = modifier,
} ?: color = color,
CircularProgressIndicator( strokeWidth = strokeWidth,
modifier = Modifier.padding(vertical = 16.dp).then(modifier) 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 data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchesSelection: PatchesSelection? = null) : Destination
@Parcelize @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() data class Download(override val packageName: String, override val version: String, val app: AppDownloader.App) : SelectedApp()
@Parcelize @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 @Parcelize
data class Installed(override val packageName: String, override val version: String) : SelectedApp() data class Installed(override val packageName: String, override val version: String) : SelectedApp()

View File

@ -164,7 +164,8 @@ fun AppSelectorScreen(
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
item { item {
ListItem( 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.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -82,7 +83,8 @@ fun VersionSelectorScreen(
modifier = Modifier modifier = Modifier
.padding(paddingValues) .padding(paddingValues)
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
viewModel.installedApp?.let { (packageInfo, installedApp) -> viewModel.installedApp?.let { (packageInfo, installedApp) ->
SelectedApp.Installed( 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 { list.forEach {
SelectedAppItem( SelectedAppItem(

View File

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

View File

@ -9,10 +9,10 @@ import android.content.pm.PackageInstaller
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.map 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.installer.RootInstaller
import app.revanced.manager.domain.repository.InstalledAppRepository import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.worker.WorkerRepository 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.PatcherWorker
import app.revanced.manager.patcher.worker.Step
import app.revanced.manager.service.InstallService import app.revanced.manager.service.InstallService
import app.revanced.manager.service.UninstallService
import app.revanced.manager.ui.destination.Destination import app.revanced.manager.ui.destination.Destination
import app.revanced.manager.ui.model.SelectedApp 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.PM
import app.revanced.manager.util.simpleMessage import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag import app.revanced.manager.util.tag
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -49,12 +48,10 @@ import org.koin.core.component.inject
import java.io.File import java.io.File
import java.nio.file.Files import java.nio.file.Files
import java.util.UUID import java.util.UUID
import java.util.logging.Level
import java.util.logging.LogRecord
@Stable @Stable
class InstallerViewModel( class PatcherViewModel(
private val input: Destination.Installer private val input: Destination.Patcher
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val app: Application by inject() private val app: Application by inject()
private val fs: Filesystem by inject() private val fs: Filesystem by inject()
@ -63,62 +60,62 @@ class InstallerViewModel(
private val installedAppRepository: InstalledAppRepository by inject() private val installedAppRepository: InstalledAppRepository by inject()
private val rootInstaller: RootInstaller by inject() private val rootInstaller: RootInstaller by inject()
private var installedApp: InstalledApp? = null
val packageName: String = input.selectedApp.packageName 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 { private val tempDir = fs.tempDir.resolve("installer").also {
it.deleteRecursively() it.deleteRecursively()
it.mkdirs() it.mkdirs()
} }
private val outputFile = tempDir.resolve("output.apk")
private var inputFile: File? = null private var inputFile: File? = null
private val outputFile = tempDir.resolve("output.apk")
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 workManager = WorkManager.getInstance(app) private val workManager = WorkManager.getInstance(app)
private val _progress: MutableStateFlow<ImmutableList<Step>>
private val patcherWorkerId: UUID
private val logger = ManagerLogger() private val logger = ManagerLogger()
init { val patchesProgress = MutableStateFlow(Pair(0, input.selectedPatches.values.sumOf { it.size }))
// TODO: navigate away when system-initiated process death is detected because it is not possible to recover from it. private val downloadProgress = MutableStateFlow<Pair<Float, Float>?>(null)
val steps = generateSteps(
app,
input.selectedApp,
downloadProgress
).toMutableStateList()
private var currentStepIndex = 0
viewModelScope.launch { private val patcherWorkerId: UUID =
installedApp = installedAppRepository.get(packageName) 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( steps[currentStepIndex] = steps[currentStepIndex].copy(state = State.RUNNING)
PatcherProgressManager.generateSteps( }
app, }
patches.flatMap { (_, selected) -> selected }, )
selectedApp
).toImmutableList()
) )
patcherWorkerId = val patcherSucceeded =
workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>(
"patching", PatcherWorker.Args(
selectedApp,
outputFile.path,
patches,
options,
_progress,
logger,
setInputFile = { inputFile = it }
)
)
}
val progress = _progress.asStateFlow()
val patcherState =
workManager.getWorkInfoByIdLiveData(patcherWorkerId).map { workInfo: WorkInfo -> workManager.getWorkInfoByIdLiveData(patcherWorkerId).map { workInfo: WorkInfo ->
when (workInfo.state) { when (workInfo.state) {
WorkInfo.State.SUCCEEDED -> true WorkInfo.State.SUCCEEDED -> true
@ -151,29 +148,18 @@ class InstallerViewModel(
app.toast(app.getString(R.string.install_app_fail, extra)) 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 { ContextCompat.registerReceiver(app, installBroadcastReceiver, IntentFilter().apply {
addAction(InstallService.APP_INSTALL_ACTION) addAction(InstallService.APP_INSTALL_ACTION)
addAction(UninstallService.APP_UNINSTALL_ACTION)
}, ContextCompat.RECEIVER_NOT_EXPORTED) }, ContextCompat.RECEIVER_NOT_EXPORTED)
}
fun exportLogs(context: Context) { viewModelScope.launch {
val sendIntent: Intent = Intent().apply { installedApp = installedAppRepository.get(packageName)
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, logger.export())
type = "text/plain"
} }
val shareIntent = Intent.createChooser(sendIntent, null)
context.startActivity(shareIntent)
} }
override fun onCleared() { override fun onCleared() {
@ -183,7 +169,7 @@ class InstallerViewModel(
when (val selectedApp = input.selectedApp) { when (val selectedApp = input.selectedApp) {
is SelectedApp.Local -> { is SelectedApp.Local -> {
if (selectedApp.shouldDelete) selectedApp.file.delete() if (selectedApp.temporary) selectedApp.file.delete()
} }
is SelectedApp.Installed -> { is SelectedApp.Installed -> {
@ -199,7 +185,7 @@ class InstallerViewModel(
} }
} }
else -> {} else -> Unit
} }
tempDir.deleteRecursively() 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 { fun install(installType: InstallType) = viewModelScope.launch {
isInstalling = true
try { try {
isInstalling = true
when (installType) { when (installType) {
InstallType.DEFAULT -> { InstallType.DEFAULT -> {
pm.installApp(listOf(outputFile)) pm.installApp(listOf(outputFile))
} }
InstallType.ROOT -> { 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 { } finally {
isInstalling = false 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() { Step(
try { context.getString(R.string.apply_patches),
val label = with(pm) { StepCategory.PATCHING
getPackageInfo(outputFile)?.label() ),
?: throw Exception("Failed to load application info")
}
rootInstaller.install( Step(context.getString(R.string.patcher_step_write_patched), StepCategory.SAVING),
outputFile, Step(context.getString(R.string.patcher_step_sign_apk), StepCategory.SAVING)
inputFile,
packageName,
input.selectedApp.version,
label
) )
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="one">%d applied patch</item>
<item quantity="other">%d applied patches</item> <item quantity="other">%d applied patches</item>
</plurals> </plurals>
<plurals name="patches_applied">
<item quantity="one">Applied %d patch</item>
<item quantity="other">Applied %d patches</item>
</plurals>
</resources> </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_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">Clear all patch options</string>
<string name="patch_options_clear_all_description">Resets 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">Prefer split APK\'s</string>
<string name="prefer_splits_description">Prefer split apks instead of full apks</string> <string name="prefer_splits_description">Prefer split APK\'s instead of full APK\'s</string>
<string name="prefer_universal">Prefer universal apks</string> <string name="prefer_universal">Prefer universal APK\'s</string>
<string name="prefer_universal_description">Prefer universal instead of arch-specific apks</string> <string name="prefer_universal_description">Prefer universal instead of arch-specific APK\'s</string>
<string name="search_apps">Search apps…</string> <string name="search_apps">Search apps…</string>
<string name="loading_body">Loading…</string> <string name="loading_body">Loading…</string>
@ -155,6 +155,7 @@
<string name="continue_anyways">Continue anyways</string> <string name="continue_anyways">Continue anyways</string>
<string name="download_another_version">Download another version</string> <string name="download_another_version">Download another version</string>
<string name="download_app">Download app</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_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_fail">Failed to load updated patch bundle: %s</string>
<string name="source_replace_integrations_fail">Failed to update integrations: %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="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="download_application">Download application?</string>
<string name="app_not_installed">The app you selected isn\'t installed. Do you want to download it?</string> <string name="app_not_installed">The app you selected isn\'t installed. Do you want to download it?</string>
<string name="failed_to_load_apk">Failed to load apk</string> <string name="failed_to_load_apk">Failed to load APK</string>
<string name="loading">Loading…</string> <string name="loading">Loading…</string>
<string name="not_installed">Not installed</string> <string name="not_installed">Not installed</string>
<string name="installed">Installed</string> <string name="installed">Installed</string>
@ -233,23 +234,27 @@
<string name="open_app">Open</string> <string name="open_app">Open</string>
<string name="save_apk">Save APK</string> <string name="save_apk">Save APK</string>
<string name="save_apk_success">APK Saved</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="save_logs">Save logs</string>
<string name="select_install_type">Select installation type</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_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_integrations">Merge Integrations</string>
<string name="patcher_step_group_patching">Patching</string> <string name="patcher_step_group_patching">Patching</string>
<string name="patcher_step_group_saving">Saving</string> <string name="patcher_step_group_saving">Saving</string>
<string name="patcher_step_write_patched">Write patched Apk</string> <string name="patcher_step_write_patched">Write patched APK</string>
<string name="patcher_step_sign_apk">Sign Apk</string> <string name="patcher_step_sign_apk">Sign APK</string>
<string name="patcher_notification_message">Patching in progress…</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_completed">completed</string>
<string name="step_failed">failed</string> <string name="step_failed">failed</string>
<string name="step_running">running</string> <string name="step_running">running</string>
<string name="step_waiting">waiting</string>
<string name="expand_content">expand</string> <string name="expand_content">expand</string>
<string name="collapse_content">collapse</string> <string name="collapse_content">collapse</string>

View File

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