diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3d33dbc2..7e7bfbc4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index 5c714a93..d5192ae5 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -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) } ) diff --git a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt index f34b02b2..5a7ea70c 100644 --- a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt @@ -13,7 +13,7 @@ val viewModelModule = module { viewModelOf(::AdvancedSettingsViewModel) viewModelOf(::AppSelectorViewModel) viewModelOf(::VersionSelectorViewModel) - viewModelOf(::InstallerViewModel) + viewModelOf(::PatcherViewModel) viewModelOf(::UpdateViewModel) viewModelOf(::ChangelogsViewModel) viewModelOf(::ImportExportViewModel) diff --git a/app/src/main/java/app/revanced/manager/patcher/Session.kt b/app/src/main/java/app/revanced/manager/patcher/Session.kt index 337bd195..70af8d58 100644 --- a/app/src/main/java/app/revanced/manager/patcher/Session.kt +++ b/app/src/main/java/app/revanced/manager/patcher/Session.kt @@ -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>, + 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) { - 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 } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/logger/ManagerLogger.kt b/app/src/main/java/app/revanced/manager/patcher/logger/ManagerLogger.kt new file mode 100644 index 00000000..03a741a4 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/patcher/logger/ManagerLogger.kt @@ -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>() + 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" + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherProgressManager.kt b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherProgressManager.kt deleted file mode 100644 index 938f7b12..00000000 --- a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherProgressManager.kt +++ /dev/null @@ -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?>? = null -) - -class Step( - @StringRes val name: Int, - val subSteps: ImmutableList, - val state: State = State.WAITING -) - -class PatcherProgressManager( - context: Context, - selectedPatches: List, - selectedApp: SelectedApp, - downloadProgress: StateFlow?> -) { - 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) { - 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 = steps - - companion object { - private fun generatePatchesStep(selectedPatches: List) = Step( - R.string.patcher_step_group_patching, - selectedPatches.map { SubStep(it) }.toImmutableList() - ) - - fun generateSteps( - context: Context, - selectedPatches: List, - selectedApp: SelectedApp, - downloadProgress: StateFlow?>? = 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) -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt index 4779677a..868f1bf0 100644 --- a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt +++ b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt @@ -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(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>, val logger: ManagerLogger, - val setInputFile: (File) -> Unit + val downloadProgress: MutableStateFlow?>, + val patchesProgress: MutableStateFlow>, + 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?>(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" + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/ArrowButton.kt b/app/src/main/java/app/revanced/manager/ui/component/ArrowButton.kt index 3762ae25..d97749c0 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/ArrowButton.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/ArrowButton.kt @@ -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) + ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/LoadingIndicator.kt b/app/src/main/java/app/revanced/manager/ui/component/LoadingIndicator.kt index f14e83a2..44d5c9cd 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/LoadingIndicator.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/LoadingIndicator.kt @@ -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 + ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/patcher/InstallPickerDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/patcher/InstallPickerDialog.kt new file mode 100644 index 00000000..ec3cf979 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/patcher/InstallPickerDialog.kt @@ -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)) } + ) + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/patcher/Steps.kt b/app/src/main/java/app/revanced/manager/ui/component/patcher/Steps.kt new file mode 100644 index 00000000..4385700c --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/patcher/Steps.kt @@ -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, + stepCount: Pair? = 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? = 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? = 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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt b/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt index a7712532..8586f458 100644 --- a/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt +++ b/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/model/PatcherStep.kt b/app/src/main/java/app/revanced/manager/ui/model/PatcherStep.kt new file mode 100644 index 00000000..4c7fc417 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/model/PatcherStep.kt @@ -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?>? = null +) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/model/SelectedApp.kt b/app/src/main/java/app/revanced/manager/ui/model/SelectedApp.kt index f5e1b5f8..4e3e8807 100644 --- a/app/src/main/java/app/revanced/manager/ui/model/SelectedApp.kt +++ b/app/src/main/java/app/revanced/manager/ui/model/SelectedApp.kt @@ -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() diff --git a/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt index 186d0fe0..90a14a9d 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt @@ -164,7 +164,8 @@ fun AppSelectorScreen( LazyColumn( modifier = Modifier .fillMaxSize() - .padding(paddingValues) + .padding(paddingValues), + horizontalAlignment = Alignment.CenterHorizontally ) { item { ListItem( diff --git a/app/src/main/java/app/revanced/manager/ui/screen/InstallerScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/InstallerScreen.kt deleted file mode 100644 index 6a90521a..00000000 --- a/app/src/main/java/app/revanced/manager/ui/screen/InstallerScreen.kt +++ /dev/null @@ -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? = 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 - } - } - ) - } -} diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatcherScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatcherScreen.kt new file mode 100644 index 00000000..7f13425a --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatcherScreen.kt @@ -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 + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt index dd52326c..1ef0b76f 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt @@ -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( diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt index 62915e0b..abb69a3f 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt @@ -31,7 +31,7 @@ class AppSelectorViewModel( packageName = packageInfo.packageName, version = packageInfo.versionName, file = this, - shouldDelete = true + temporary = true ) } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt similarity index 57% rename from app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt rename to app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt index 177a7e5f..d1b3d360 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt @@ -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(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(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> - 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?>(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( + "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( - "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?>? = null + ): List { + 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>() - 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" - } } \ No newline at end of file diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml index c64f7d97..339822d8 100644 --- a/app/src/main/res/values/plurals.xml +++ b/app/src/main/res/values/plurals.xml @@ -8,4 +8,8 @@ %d applied patch %d applied patches + + Applied %d patch + Applied %d patches + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5cb02d50..f4afdcd7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -99,10 +99,10 @@ Resets patch options for all patches in a bundle Clear all patch options Resets all patch options - Prefer split apks - Prefer split apks instead of full apks - Prefer universal apks - Prefer universal instead of arch-specific apks + Prefer split APK\'s + Prefer split APK\'s instead of full APK\'s + Prefer universal APK\'s + Prefer universal instead of arch-specific APK\'s Search apps… Loading… @@ -155,6 +155,7 @@ Continue anyways Download another version Download app + Download APK Failed to download patch bundle: %s Failed to load updated patch bundle: %s Failed to update integrations: %s @@ -179,7 +180,7 @@ Not all patches support this version (%s). Do you want to continue anyway? Download application? The app you selected isn\'t installed. Do you want to download it? - Failed to load apk + Failed to load APK Loading… Not installed Installed @@ -233,23 +234,27 @@ Open Save APK APK Saved - Failed to sign Apk: %s + Failed to sign APK: %s Save logs Select installation type - Preparation + Preparing Load patches - Unpack Apk + Unpack APK Merge Integrations Patching Saving - Write patched Apk - Sign Apk + Write patched APK + Sign APK Patching in progress… + Apply patches + Applying %s + Failed to apply %s completed failed running + waiting expand collapse diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3bee7cba..7d22e40d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }