From 18a4df9af9cac120fdb8e4ff7aadd2e2a8d5c1a6 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Mon, 29 Dec 2025 23:42:14 +0100 Subject: [PATCH 1/4] fix: install dialog getting stuck (#2900) --- app/build.gradle.kts | 4 + app/src/main/AndroidManifest.xml | 13 +- .../revanced/manager/ManagerApplication.kt | 3 +- .../app/revanced/manager/di/AckpineModule.kt | 19 + .../manager/service/InstallService.kt | 53 --- .../ui/component/InstallerStatusDialog.kt | 7 +- .../ui/viewmodel/InstalledAppInfoViewModel.kt | 70 +--- .../manager/ui/viewmodel/PatcherViewModel.kt | 379 ++++++++++-------- .../manager/ui/viewmodel/UpdateViewModel.kt | 88 ++-- .../java/app/revanced/manager/util/Ackpine.kt | 30 ++ .../main/java/app/revanced/manager/util/PM.kt | 71 +--- .../java/app/revanced/manager/util/Util.kt | 3 + gradle/libs.versions.toml | 5 + 13 files changed, 361 insertions(+), 384 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/di/AckpineModule.kt delete mode 100644 app/src/main/java/app/revanced/manager/service/InstallService.kt create mode 100644 app/src/main/java/app/revanced/manager/util/Ackpine.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index da4e9fe9..de9dc96b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -108,6 +108,10 @@ dependencies { // Compose Icons implementation(libs.compose.icons.fontawesome) + + // Ackpine + implementation(libs.ackpine.core) + implementation(libs.ackpine.ktx) } buildscript { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e418d68b..f6364fbc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,9 +51,6 @@ - - - + + + + \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ManagerApplication.kt b/app/src/main/java/app/revanced/manager/ManagerApplication.kt index 1d17e5ef..b5dec19b 100644 --- a/app/src/main/java/app/revanced/manager/ManagerApplication.kt +++ b/app/src/main/java/app/revanced/manager/ManagerApplication.kt @@ -48,7 +48,8 @@ class ManagerApplication : Application() { workerModule, viewModelModule, databaseModule, - rootModule + rootModule, + ackpineModule ) } diff --git a/app/src/main/java/app/revanced/manager/di/AckpineModule.kt b/app/src/main/java/app/revanced/manager/di/AckpineModule.kt new file mode 100644 index 00000000..76fd0ec8 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/di/AckpineModule.kt @@ -0,0 +1,19 @@ +package app.revanced.manager.di + +import android.content.Context +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module +import ru.solrudev.ackpine.installer.PackageInstaller +import ru.solrudev.ackpine.uninstaller.PackageUninstaller + +val ackpineModule = module { + fun provideInstaller(context: Context) = PackageInstaller.getInstance(context) + fun provideUninstaller(context: Context) = PackageUninstaller.getInstance(context) + + single { + provideInstaller(androidContext()) + } + single { + provideUninstaller(androidContext()) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/service/InstallService.kt b/app/src/main/java/app/revanced/manager/service/InstallService.kt deleted file mode 100644 index 7bf2d213..00000000 --- a/app/src/main/java/app/revanced/manager/service/InstallService.kt +++ /dev/null @@ -1,53 +0,0 @@ -package app.revanced.manager.service - -import android.app.Service -import android.content.Intent -import android.content.pm.PackageInstaller -import android.os.Build -import android.os.IBinder - -@Suppress("DEPRECATION") -class InstallService : Service() { - - override fun onStartCommand( - intent: Intent, flags: Int, startId: Int - ): Int { - val extraStatus = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999) - val extraStatusMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) - val extraPackageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME) - when (extraStatus) { - PackageInstaller.STATUS_PENDING_USER_ACTION -> { - startActivity(if (Build.VERSION.SDK_INT >= 33) { - intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) - } else { - intent.getParcelableExtra(Intent.EXTRA_INTENT) - }.apply { - this?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - }) - } - - else -> { - sendBroadcast(Intent().apply { - action = APP_INSTALL_ACTION - `package` = packageName - putExtra(EXTRA_INSTALL_STATUS, extraStatus) - putExtra(EXTRA_INSTALL_STATUS_MESSAGE, extraStatusMessage) - putExtra(EXTRA_PACKAGE_NAME, extraPackageName) - }) - } - } - stopSelf() - return START_NOT_STICKY - } - - override fun onBind(intent: Intent?): IBinder? = null - - companion object { - const val APP_INSTALL_ACTION = "APP_INSTALL_ACTION" - - const val EXTRA_INSTALL_STATUS = "EXTRA_INSTALL_STATUS" - const val EXTRA_INSTALL_STATUS_MESSAGE = "EXTRA_INSTALL_STATUS_MESSAGE" - const val EXTRA_PACKAGE_NAME = "EXTRA_PACKAGE_NAME" - } - -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/InstallerStatusDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/InstallerStatusDialog.kt index 2ae48ce6..d881bae5 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/InstallerStatusDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/InstallerStatusDialog.kt @@ -1,5 +1,6 @@ package app.revanced.manager.ui.component +import android.annotation.SuppressLint import android.content.pm.PackageInstaller import androidx.annotation.RequiresApi import androidx.annotation.StringRes @@ -79,7 +80,7 @@ private fun installerStatusDialogButton( enum class DialogKind( val flag: Int, val title: Int, - @StringRes val contentStringResId: Int, + @param:StringRes val contentStringResId: Int, val icon: ImageVector = Icons.Outlined.ErrorOutline, val confirmButton: InstallerStatusDialogButton = installerStatusDialogButton(R.string.ok), val dismissButton: InstallerStatusDialogButton? = null, @@ -133,10 +134,8 @@ enum class DialogKind( title = R.string.installation_storage_issue_dialog_title, contentStringResId = R.string.installation_storage_issue_description, ), - - @RequiresApi(34) FAILURE_TIMEOUT( - flag = PackageInstaller.STATUS_FAILURE_TIMEOUT, + flag = @SuppressLint("InlinedApi") PackageInstaller.STATUS_FAILURE_TIMEOUT, title = R.string.installation_timeout_dialog_title, contentStringResId = R.string.installation_timeout_description, confirmButton = installerStatusDialogButton(R.string.install_app) { model -> diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppInfoViewModel.kt index 27b86263..a2df550c 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppInfoViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppInfoViewModel.kt @@ -1,17 +1,11 @@ package app.revanced.manager.ui.viewmodel import android.app.Application -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter import android.content.pm.PackageInfo -import android.content.pm.PackageInstaller import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.revanced.manager.R @@ -19,7 +13,6 @@ import app.revanced.manager.data.room.apps.installed.InstallType 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.service.UninstallService import app.revanced.manager.util.PM import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.simpleMessage @@ -30,6 +23,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import ru.solrudev.ackpine.session.Session +import ru.solrudev.ackpine.uninstaller.UninstallFailure class InstalledAppInfoViewModel( packageName: String @@ -87,51 +82,28 @@ class InstalledAppInfoViewModel( fun uninstall() { val app = installedApp ?: return - when (app.installType) { - InstallType.DEFAULT -> pm.uninstallPackage(app.currentPackageName) - - InstallType.MOUNT -> viewModelScope.launch { - rootInstaller.uninstall(app.currentPackageName) - installedAppRepository.delete(app) - onBackClick() - } - } - } - - private val uninstallBroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.action) { - UninstallService.APP_UNINSTALL_ACTION -> { - val extraStatus = - intent.getIntExtra(UninstallService.EXTRA_UNINSTALL_STATUS, -999) - val extraStatusMessage = - intent.getStringExtra(UninstallService.EXTRA_UNINSTALL_STATUS_MESSAGE) - - if (extraStatus == PackageInstaller.STATUS_SUCCESS) { - viewModelScope.launch { - installedApp?.let { - installedAppRepository.delete(it) - } - onBackClick() + viewModelScope.launch { + when (app.installType) { + InstallType.DEFAULT -> { + when (val result = pm.uninstallPackage(app.currentPackageName)) { + is Session.State.Failed -> { + val msg = result.failure.message.orEmpty() + context.toast( + this@InstalledAppInfoViewModel.context.getString( + R.string.uninstall_app_fail, + msg + ) + ) + return@launch } - } else if (extraStatus != PackageInstaller.STATUS_FAILURE_ABORTED) { - this@InstalledAppInfoViewModel.context.toast(this@InstalledAppInfoViewModel.context.getString(R.string.uninstall_app_fail, extraStatusMessage)) + Session.State.Succeeded -> {} } - } - } - } - }.also { - ContextCompat.registerReceiver( - context, - it, - IntentFilter(UninstallService.APP_UNINSTALL_ACTION), - ContextCompat.RECEIVER_NOT_EXPORTED - ) - } - override fun onCleared() { - super.onCleared() - context.unregisterReceiver(uninstallBroadcastReceiver) + InstallType.MOUNT -> rootInstaller.uninstall(app.currentPackageName) + } + installedAppRepository.delete(app) + onBackClick() + } } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt index 236129c5..0365d62c 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt @@ -1,11 +1,9 @@ package app.revanced.manager.ui.viewmodel import android.app.Application -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.content.IntentFilter -import android.content.pm.PackageInstaller +import android.content.pm.PackageInstaller as AndroidPackageInstaller import android.net.Uri import android.os.ParcelUuid import android.util.Log @@ -16,7 +14,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.autoSaver import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList -import androidx.core.content.ContextCompat import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.map @@ -37,8 +34,6 @@ import app.revanced.manager.patcher.logger.Logger import app.revanced.manager.patcher.worker.PatcherWorker import app.revanced.manager.plugin.downloader.PluginHostApi import app.revanced.manager.plugin.downloader.UserInteractionException -import app.revanced.manager.service.InstallService -import app.revanced.manager.service.UninstallService import app.revanced.manager.ui.model.InstallerModel import app.revanced.manager.ui.model.ProgressKey import app.revanced.manager.ui.model.SelectedApp @@ -48,16 +43,19 @@ import app.revanced.manager.ui.model.StepCategory import app.revanced.manager.ui.model.StepProgressProvider import app.revanced.manager.ui.model.navigation.Patcher import app.revanced.manager.util.PM +import app.revanced.manager.util.asCode import app.revanced.manager.util.saveableVar import app.revanced.manager.util.saver.snapshotStateListSaver -import app.revanced.manager.util.simpleMessage -import app.revanced.manager.util.tag import app.revanced.manager.util.toast import app.revanced.manager.util.uiSafe import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @@ -66,6 +64,15 @@ import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.get import org.koin.core.component.inject +import ru.solrudev.ackpine.installer.InstallFailure +import ru.solrudev.ackpine.installer.PackageInstaller +import ru.solrudev.ackpine.installer.createSession +import ru.solrudev.ackpine.installer.getSession +import ru.solrudev.ackpine.session.ProgressSession +import ru.solrudev.ackpine.session.Session +import ru.solrudev.ackpine.session.await +import ru.solrudev.ackpine.session.parameters.Confirmation +import ru.solrudev.ackpine.uninstaller.UninstallFailure import java.io.File import java.nio.file.Files import java.time.Duration @@ -81,6 +88,7 @@ class PatcherViewModel( private val installedAppRepository: InstalledAppRepository by inject() private val rootInstaller: RootInstaller by inject() private val savedStateHandle: SavedStateHandle = get() + private val ackpineInstaller: PackageInstaller = get() private var installedApp: InstalledApp? = null private val selectedApp = input.selectedApp @@ -95,7 +103,6 @@ class PatcherViewModel( mutableStateOf(null) } private set - private var ongoingPmSession: Boolean by savedStateHandle.saveableVar { false } var packageInstallerStatus: Int? by savedStateHandle.saveable( key = "packageInstallerStatus", stateSaver = autoSaver() @@ -104,7 +111,7 @@ class PatcherViewModel( } private set - var isInstalling by mutableStateOf(ongoingPmSession) + var isInstalling by mutableStateOf(false) private set private var currentActivityRequest: Pair, String>? by mutableStateOf( @@ -123,6 +130,18 @@ class PatcherViewModel( } } + /** + * This coroutine scope is used to await installations. + * It should not be cancelled on system-initiated process death since that would cancel the installation process. + */ + private val installerCoroutineScope = CoroutineScope(Dispatchers.Main) + + /** + * Holds the package name of the Apk we are trying to install. + */ + private var installerPkgName: String by savedStateHandle.saveableVar { "" } + private var installerSessionId: ParcelUuid? by savedStateHandle.saveableVar() + private var inputFile: File? by savedStateHandle.saveableVar() private val outputFile = tempDir.resolve("output.apk") @@ -174,67 +193,68 @@ class PatcherViewModel( private val workManager = WorkManager.getInstance(app) private val patcherWorkerId by savedStateHandle.saveable { - ParcelUuid(workerRepository.launchExpedited( - "patching", PatcherWorker.Args( - input.selectedApp, - outputFile.path, - input.selectedPatches, - input.options, - logger, - onDownloadProgress = { - withContext(Dispatchers.Main) { - downloadProgress = it - } - }, - onPatchCompleted = { withContext(Dispatchers.Main) { completedPatchCount += 1 } }, - setInputFile = { withContext(Dispatchers.Main) { inputFile = it } }, - handleStartActivityRequest = { plugin, intent -> - withContext(Dispatchers.Main) { - if (currentActivityRequest != null) throw Exception("Another request is already pending.") - try { - // Wait for the dialog interaction. - val accepted = with(CompletableDeferred()) { - currentActivityRequest = this to plugin.name - - await() - } - if (!accepted) throw UserInteractionException.RequestDenied() - - // Launch the activity and wait for the result. + ParcelUuid( + workerRepository.launchExpedited( + "patching", PatcherWorker.Args( + input.selectedApp, + outputFile.path, + input.selectedPatches, + input.options, + logger, + onDownloadProgress = { + withContext(Dispatchers.Main) { + downloadProgress = it + } + }, + onPatchCompleted = { withContext(Dispatchers.Main) { completedPatchCount += 1 } }, + setInputFile = { withContext(Dispatchers.Main) { inputFile = it } }, + handleStartActivityRequest = { plugin, intent -> + withContext(Dispatchers.Main) { + if (currentActivityRequest != null) throw Exception("Another request is already pending.") try { - with(CompletableDeferred()) { - launchedActivity = this - launchActivityChannel.send(intent) + // Wait for the dialog interaction. + val accepted = with(CompletableDeferred()) { + currentActivityRequest = this to plugin.name + await() } + if (!accepted) throw UserInteractionException.RequestDenied() + + // Launch the activity and wait for the result. + try { + with(CompletableDeferred()) { + launchedActivity = this + launchActivityChannel.send(intent) + await() + } + } finally { + launchedActivity = null + } } finally { - launchedActivity = null + currentActivityRequest = null + } + } + }, + onProgress = { name, state, message -> + viewModelScope.launch { + steps[currentStepIndex] = steps[currentStepIndex].run { + copy( + name = name ?: this.name, + state = state ?: this.state, + message = message ?: this.message + ) + } + + if (state == State.COMPLETED && currentStepIndex != steps.lastIndex) { + currentStepIndex++ + + steps[currentStepIndex] = + steps[currentStepIndex].copy(state = State.RUNNING) } - } finally { - currentActivityRequest = null } } - }, - onProgress = { name, state, message -> - viewModelScope.launch { - steps[currentStepIndex] = steps[currentStepIndex].run { - copy( - name = name ?: this.name, - state = state ?: this.state, - message = message ?: this.message - ) - } - - if (state == State.COMPLETED && currentStepIndex != steps.lastIndex) { - currentStepIndex++ - - steps[currentStepIndex] = - steps[currentStepIndex].copy(state = State.RUNNING) - } - } - } - ) - )) + ) + )) } val patcherSucceeded = @@ -246,64 +266,26 @@ class PatcherViewModel( } } - private val installerBroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.action) { - InstallService.APP_INSTALL_ACTION -> { - val pmStatus = intent.getIntExtra( - InstallService.EXTRA_INSTALL_STATUS, - PackageInstaller.STATUS_FAILURE - ) + init { + // TODO: detect system-initiated process death during the patching process. - intent.getStringExtra(UninstallService.EXTRA_UNINSTALL_STATUS_MESSAGE) - ?.let(logger::trace) - - if (pmStatus == PackageInstaller.STATUS_SUCCESS) { - app.toast(app.getString(R.string.install_app_success)) - installedPackageName = - intent.getStringExtra(InstallService.EXTRA_PACKAGE_NAME) - viewModelScope.launch { - installedAppRepository.addOrUpdate( - installedPackageName!!, - packageName, - input.selectedApp.version - ?: pm.getPackageInfo(outputFile)?.versionName!!, - InstallType.DEFAULT, - input.selectedPatches - ) + installerSessionId?.uuid?.let { id -> + viewModelScope.launch { + try { + isInstalling = true + uiSafe(app, R.string.install_app_fail, "Failed to install") { + // The process was killed during installation. Await the session again. + withContext(Dispatchers.IO) { + ackpineInstaller.getSession(id) + }?.let { + awaitInstallation(it) } - } else packageInstallerStatus = pmStatus - + } + } finally { isInstalling = false } - - UninstallService.APP_UNINSTALL_ACTION -> { - val pmStatus = intent.getIntExtra( - UninstallService.EXTRA_UNINSTALL_STATUS, - PackageInstaller.STATUS_FAILURE - ) - - intent.getStringExtra(UninstallService.EXTRA_UNINSTALL_STATUS_MESSAGE) - ?.let(logger::trace) - - if (pmStatus != PackageInstaller.STATUS_SUCCESS) - packageInstallerStatus = pmStatus - } } } - } - - init { - // TODO: detect system-initiated process death during the patching process. - ContextCompat.registerReceiver( - app, - installerBroadcastReceiver, - IntentFilter().apply { - addAction(InstallService.APP_INSTALL_ACTION) - addAction(UninstallService.APP_UNINSTALL_ACTION) - }, - ContextCompat.RECEIVER_NOT_EXPORTED - ) viewModelScope.launch { installedApp = installedAppRepository.get(packageName) @@ -313,7 +295,6 @@ class PatcherViewModel( @OptIn(DelicateCoroutinesApi::class) override fun onCleared() { super.onCleared() - app.unregisterReceiver(installerBroadcastReceiver) workManager.cancelWorkById(patcherWorkerId.uuid) if (input.selectedApp is SelectedApp.Installed && installedApp?.installType == InstallType.MOUNT) { @@ -328,6 +309,7 @@ class PatcherViewModel( } fun onBack() { + installerCoroutineScope.cancel() // tempDir cannot be deleted inside onCleared because it gets called on system-initiated process death. tempDir.deleteRecursively() } @@ -372,44 +354,93 @@ class PatcherViewModel( fun open() = installedPackageName?.let(pm::launch) - fun install(installType: InstallType) = viewModelScope.launch { - var pmInstallStarted = false - try { - isInstalling = true + private suspend fun startInstallation(file: File, packageName: String) { + val session = withContext(Dispatchers.IO) { + ackpineInstaller.createSession(Uri.fromFile(file)) { + confirmation = Confirmation.IMMEDIATE + } + } + withContext(Dispatchers.Main) { + installerPkgName = packageName + } + awaitInstallation(session) + } - val currentPackageInfo = pm.getPackageInfo(outputFile) - ?: throw Exception("Failed to load application info") - - // If the app is currently installed - val existingPackageInfo = pm.getPackageInfo(currentPackageInfo.packageName) - if (existingPackageInfo != null) { - // Check if the app version is less than the installed version - if (pm.getVersionCode(currentPackageInfo) < pm.getVersionCode(existingPackageInfo)) { - // Exit if the selected app version is less than the installed version - packageInstallerStatus = PackageInstaller.STATUS_FAILURE_CONFLICT - return@launch + private suspend fun awaitInstallation(session: ProgressSession) = withContext( + Dispatchers.Main + ) { + val result = installerCoroutineScope.async { + try { + installerSessionId = ParcelUuid(session.id) + withContext(Dispatchers.IO) { + session.await() } + } finally { + installerSessionId = null + } + }.await() + + when (result) { + is Session.State.Failed -> { + result.failure.message?.let(logger::trace) + packageInstallerStatus = result.failure.asCode() } - when (installType) { - InstallType.DEFAULT -> { - // Check if the app is mounted as root - // If it is, unmount it first, silently - if (rootInstaller.hasRootAccess() && rootInstaller.isAppMounted(packageName)) { - rootInstaller.unmount(packageName) - } + Session.State.Succeeded -> { + app.toast(app.getString(R.string.install_app_success)) + installedPackageName = installerPkgName + installedAppRepository.addOrUpdate( + installerPkgName, + packageName, + input.selectedApp.version + ?: withContext(Dispatchers.IO) { pm.getPackageInfo(outputFile)?.versionName!! }, + InstallType.DEFAULT, + input.selectedPatches + ) + } + } + } - // Install regularly - pm.installApp(listOf(outputFile)) - pmInstallStarted = true + fun install(installType: InstallType) = viewModelScope.launch { + isInstalling = true + var needsRootUninstall = false + try { + uiSafe(app, R.string.install_app_fail, "Failed to install") { + val currentPackageInfo = + withContext(Dispatchers.IO) { pm.getPackageInfo(outputFile) } + ?: throw Exception("Failed to load application info") + + // If the app is currently installed + val existingPackageInfo = + withContext(Dispatchers.IO) { pm.getPackageInfo(currentPackageInfo.packageName) } + if (existingPackageInfo != null) { + // Check if the app version is less than the installed version + if ( + pm.getVersionCode(currentPackageInfo) < pm.getVersionCode( + existingPackageInfo + ) + ) { + // Exit if the selected app version is less than the installed version + packageInstallerStatus = AndroidPackageInstaller.STATUS_FAILURE_CONFLICT + return@launch + } } - InstallType.MOUNT -> { - try { - val packageInfo = pm.getPackageInfo(outputFile) - ?: throw Exception("Failed to load application info") + when (installType) { + InstallType.DEFAULT -> { + // Check if the app is mounted as root + // If it is, unmount it first, silently + if (rootInstaller.hasRootAccess() && rootInstaller.isAppMounted(packageName)) { + rootInstaller.unmount(packageName) + } + + // Install regularly + startInstallation(outputFile, currentPackageInfo.packageName) + } + + InstallType.MOUNT -> { val label = with(pm) { - packageInfo.label() + currentPackageInfo.label() } // Check for base APK, first check if the app is already installed @@ -417,15 +448,17 @@ class PatcherViewModel( // If the app is not installed, check if the output file is a base apk if (currentPackageInfo.splitNames.isNotEmpty()) { // Exit if there is no base APK package - packageInstallerStatus = PackageInstaller.STATUS_FAILURE_INVALID + packageInstallerStatus = + AndroidPackageInstaller.STATUS_FAILURE_INVALID return@launch } } val inputVersion = input.selectedApp.version - ?: inputFile?.let(pm::getPackageInfo)?.versionName + ?: withContext(Dispatchers.IO) { inputFile?.let(pm::getPackageInfo)?.versionName } ?: throw Exception("Failed to determine input APK version") + needsRootUninstall = true // Install as root rootInstaller.install( outputFile, @@ -436,7 +469,7 @@ class PatcherViewModel( ) installedAppRepository.addOrUpdate( - packageInfo.packageName, + currentPackageInfo.packageName, packageName, inputVersion, InstallType.MOUNT, @@ -448,21 +481,20 @@ class PatcherViewModel( 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) { - } + needsRootUninstall = false } } } - } catch (e: Exception) { - Log.e(tag, "Failed to install", e) - app.toast(app.getString(R.string.install_app_fail, e.simpleMessage())) } finally { - if (!pmInstallStarted) isInstalling = false + isInstalling = false + if (needsRootUninstall) { + try { + withContext(NonCancellable) { + rootInstaller.uninstall(packageName) + } + } catch (_: Exception) { + } + } } } @@ -473,12 +505,27 @@ class PatcherViewModel( override fun reinstall() { viewModelScope.launch { - uiSafe(app, R.string.reinstall_app_fail, "Failed to reinstall") { - pm.getPackageInfo(outputFile)?.packageName?.let { pm.uninstallPackage(it) } - ?: throw Exception("Failed to load application info") - - pm.installApp(listOf(outputFile)) + try { isInstalling = true + uiSafe(app, R.string.reinstall_app_fail, "Failed to reinstall") { + val pkgName = withContext(Dispatchers.IO) { + pm.getPackageInfo(outputFile)?.packageName + ?: throw Exception("Failed to load application info") + } + + when (val result = pm.uninstallPackage(pkgName)) { + is Session.State.Failed -> { + result.failure.message?.let(logger::trace) + packageInstallerStatus = result.failure.asCode() + return@launch + } + + Session.State.Succeeded -> {} + } + startInstallation(outputFile, pkgName) + } + } finally { + isInstalling = false } } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt index db31d654..186d9619 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt @@ -1,18 +1,13 @@ package app.revanced.manager.ui.viewmodel import android.app.Application -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.PackageInstaller +import android.net.Uri import androidx.annotation.StringRes import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.revanced.manager.R @@ -21,8 +16,6 @@ import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.network.api.ReVancedAPI import app.revanced.manager.network.dto.ReVancedAsset import app.revanced.manager.network.service.HttpService -import app.revanced.manager.service.InstallService -import app.revanced.manager.util.PM import app.revanced.manager.util.toast import app.revanced.manager.util.uiSafe import io.ktor.client.plugins.onDownload @@ -31,7 +24,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent +import org.koin.core.component.get import org.koin.core.component.inject +import ru.solrudev.ackpine.installer.InstallFailure +import ru.solrudev.ackpine.installer.PackageInstaller +import ru.solrudev.ackpine.installer.createSession +import ru.solrudev.ackpine.session.Session +import ru.solrudev.ackpine.session.await +import ru.solrudev.ackpine.session.parameters.Confirmation class UpdateViewModel( private val downloadOnScreenEntry: Boolean @@ -39,10 +39,11 @@ class UpdateViewModel( private val app: Application by inject() private val reVancedAPI: ReVancedAPI by inject() private val http: HttpService by inject() - private val pm: PM by inject() private val networkInfo: NetworkInfo by inject() private val fs: Filesystem by inject() + private val ackpineInstaller: PackageInstaller = get() + // TODO: save state to handle process death. var downloadedSize by mutableLongStateOf(0L) private set var totalSize by mutableLongStateOf(0L) @@ -62,14 +63,17 @@ class UpdateViewModel( private set private val location = fs.tempDir.resolve("updater.apk") - private val job = viewModelScope.launch { - uiSafe(app, R.string.download_manager_failed, "Failed to download ReVanced Manager") { - releaseInfo = reVancedAPI.getAppUpdate() ?: throw Exception("No update available") - if (downloadOnScreenEntry) { - downloadUpdate() - } else { - state = State.CAN_DOWNLOAD + init { + viewModelScope.launch { + uiSafe(app, R.string.download_manager_failed, "Failed to download ReVanced Manager") { + releaseInfo = reVancedAPI.getAppUpdate() ?: throw Exception("No update available") + + if (downloadOnScreenEntry) { + downloadUpdate() + } else { + state = State.CAN_DOWNLOAD + } } } } @@ -98,50 +102,36 @@ class UpdateViewModel( fun installUpdate() = viewModelScope.launch { state = State.INSTALLING + val result = withContext(Dispatchers.IO) { + ackpineInstaller.createSession(Uri.fromFile(location)) { + confirmation = Confirmation.IMMEDIATE + }.await() + } - pm.installApp(listOf(location)) - } - - private val installBroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - intent?.let { - val pmStatus = intent.getIntExtra(InstallService.EXTRA_INSTALL_STATUS, -999) - val extra = - intent.getStringExtra(InstallService.EXTRA_INSTALL_STATUS_MESSAGE)!! - - when(pmStatus) { - PackageInstaller.STATUS_SUCCESS -> { - app.toast(app.getString(R.string.install_app_success)) - state = State.SUCCESS - } - PackageInstaller.STATUS_FAILURE_ABORTED -> { - state = State.CAN_INSTALL - } - else -> { - app.toast(app.getString(R.string.install_app_fail, extra)) - installError = extra - state = State.FAILED - } + when (result) { + is Session.State.Failed -> when (val failure = result.failure) { + is InstallFailure.Aborted -> state = State.CAN_INSTALL + else -> { + val msg = failure.message.orEmpty() + app.toast(app.getString(R.string.install_app_fail, msg)) + installError = msg + state = State.FAILED } } + + Session.State.Succeeded -> { + app.toast(app.getString(R.string.install_app_success)) + state = State.SUCCESS + } } } - init { - ContextCompat.registerReceiver(app, installBroadcastReceiver, IntentFilter().apply { - addAction(InstallService.APP_INSTALL_ACTION) - }, ContextCompat.RECEIVER_NOT_EXPORTED) - } - override fun onCleared() { super.onCleared() - app.unregisterReceiver(installBroadcastReceiver) - - job.cancel() location.delete() } - enum class State(@StringRes val title: Int) { + enum class State(@param:StringRes val title: Int) { CAN_DOWNLOAD(R.string.update_available), DOWNLOADING(R.string.downloading_manager_update), CAN_INSTALL(R.string.ready_to_install_update), diff --git a/app/src/main/java/app/revanced/manager/util/Ackpine.kt b/app/src/main/java/app/revanced/manager/util/Ackpine.kt new file mode 100644 index 00000000..09e51080 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/util/Ackpine.kt @@ -0,0 +1,30 @@ +package app.revanced.manager.util + +import android.annotation.SuppressLint +import android.content.pm.PackageInstaller +import ru.solrudev.ackpine.installer.InstallFailure +import ru.solrudev.ackpine.uninstaller.UninstallFailure + +/** + * Converts an Ackpine installation failure into a PM status code + */ +fun InstallFailure.asCode() = when (this) { + is InstallFailure.Aborted -> PackageInstaller.STATUS_FAILURE_ABORTED + is InstallFailure.Blocked -> PackageInstaller.STATUS_FAILURE_BLOCKED + is InstallFailure.Conflict -> PackageInstaller.STATUS_FAILURE_CONFLICT + is InstallFailure.Incompatible -> PackageInstaller.STATUS_FAILURE_INCOMPATIBLE + is InstallFailure.Invalid -> PackageInstaller.STATUS_FAILURE_INVALID + is InstallFailure.Storage -> PackageInstaller.STATUS_FAILURE_STORAGE + is InstallFailure.Timeout -> @SuppressLint("InlinedApi") PackageInstaller.STATUS_FAILURE_TIMEOUT + else -> PackageInstaller.STATUS_FAILURE +} + +/** + * Converts an Ackpine uninstallation failure into a PM status code + */ +fun UninstallFailure.asCode() = when (this) { + is UninstallFailure.Aborted -> PackageInstaller.STATUS_FAILURE_ABORTED + is UninstallFailure.Blocked -> PackageInstaller.STATUS_FAILURE_BLOCKED + is UninstallFailure.Conflict -> PackageInstaller.STATUS_FAILURE_CONFLICT + else -> PackageInstaller.STATUS_FAILURE +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/util/PM.kt b/app/src/main/java/app/revanced/manager/util/PM.kt index c2f16402..708210a8 100644 --- a/app/src/main/java/app/revanced/manager/util/PM.kt +++ b/app/src/main/java/app/revanced/manager/util/PM.kt @@ -2,11 +2,8 @@ package app.revanced.manager.util import android.annotation.SuppressLint import android.app.Application -import android.app.PendingIntent -import android.content.Context import android.content.Intent import android.content.pm.PackageInfo -import android.content.pm.PackageInstaller import android.content.pm.PackageManager import android.content.pm.PackageManager.PackageInfoFlags import android.content.pm.PackageManager.NameNotFoundException @@ -16,8 +13,6 @@ import android.os.Build import android.os.Parcelable import androidx.compose.runtime.Immutable import app.revanced.manager.domain.repository.PatchBundleRepository -import app.revanced.manager.service.InstallService -import app.revanced.manager.service.UninstallService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -25,10 +20,13 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize +import ru.solrudev.ackpine.session.await +import ru.solrudev.ackpine.session.parameters.Confirmation +import ru.solrudev.ackpine.uninstaller.PackageUninstaller +import ru.solrudev.ackpine.uninstaller.createSession +import ru.solrudev.ackpine.uninstaller.parameters.UninstallParametersDsl import java.io.File -private const val byteArraySize = 1024 * 1024 // Because 1,048,576 is not readable - @Immutable @Parcelize data class AppInfo( @@ -40,7 +38,8 @@ data class AppInfo( @SuppressLint("QueryPermissionsNeeded") class PM( private val app: Application, - patchBundleRepository: PatchBundleRepository + patchBundleRepository: PatchBundleRepository, + private val uninstaller: PackageUninstaller ) { private val scope = CoroutineScope(Dispatchers.IO) @@ -145,17 +144,11 @@ class PM( false ) - suspend fun installApp(apks: List) = withContext(Dispatchers.IO) { - val packageInstaller = app.packageManager.packageInstaller - packageInstaller.openSession(packageInstaller.createSession(sessionParams)).use { session -> - apks.forEach { apk -> session.writeApk(apk) } - session.commit(app.installIntentSender) - } - } - - fun uninstallPackage(pkg: String) { - val packageInstaller = app.packageManager.packageInstaller - packageInstaller.uninstall(pkg, app.uninstallIntentSender) + suspend fun uninstallPackage(pkg: String, config: UninstallParametersDsl.() -> Unit = {}) = withContext(Dispatchers.IO) { + uninstaller.createSession(pkg) { + confirmation = Confirmation.IMMEDIATE + config() + }.await() } fun launch(pkg: String) = app.packageManager.getLaunchIntentForPackage(pkg)?.let { @@ -164,44 +157,4 @@ class PM( } fun canInstallPackages() = app.packageManager.canRequestPackageInstalls() - - private fun PackageInstaller.Session.writeApk(apk: File) { - apk.inputStream().use { inputStream -> - openWrite(apk.name, 0, apk.length()).use { outputStream -> - inputStream.copyTo(outputStream, byteArraySize) - fsync(outputStream) - } - } - } - - private val intentFlags - get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) - PendingIntent.FLAG_MUTABLE - else - 0 - - private val sessionParams - get() = PackageInstaller.SessionParams( - PackageInstaller.SessionParams.MODE_FULL_INSTALL - ).apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) - setRequestUpdateOwnership(true) - setInstallReason(PackageManager.INSTALL_REASON_USER) - } - - private val Context.installIntentSender - get() = PendingIntent.getService( - this, - 0, - Intent(this, InstallService::class.java), - intentFlags - ).intentSender - - private val Context.uninstallIntentSender - get() = PendingIntent.getService( - this, - 0, - Intent(this, UninstallService::class.java), - intentFlags - ).intentSender } diff --git a/app/src/main/java/app/revanced/manager/util/Util.kt b/app/src/main/java/app/revanced/manager/util/Util.kt index 5b2dfa33..95b6ed0e 100644 --- a/app/src/main/java/app/revanced/manager/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/util/Util.kt @@ -33,6 +33,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import app.revanced.manager.R +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -82,6 +83,8 @@ fun Context.toast(string: String, duration: Int = Toast.LENGTH_SHORT) { inline fun uiSafe(context: Context, @StringRes toastMsg: Int, logMsg: String, block: () -> Unit) { try { block() + } catch (e: CancellationException) { + throw e } catch (error: Exception) { // You can only toast on the main thread. GlobalScope.launch(Dispatchers.Main) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index de453ebb..1ca390cd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,6 +37,7 @@ kotlin-process = "1.5.1" hidden-api-stub = "4.3.3" binary-compatibility-validator = "0.17.0" semver-parser = "3.0.0" +ackpine = "0.18.5" [libraries] # AndroidX Core @@ -133,6 +134,10 @@ compose-icons-fontawesome = { group = "com.github.BenjaminHalko.compose-icons", # Semantic versioning parser semver-parser = { module = "io.github.z4kn4fein:semver", version.ref = "semver-parser" } +# Ackpine +ackpine-core = { module = "ru.solrudev.ackpine:ackpine-core", version.ref = "ackpine" } +ackpine-ktx = { module = "ru.solrudev.ackpine:ackpine-ktx", version.ref = "ackpine" } + [plugins] android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } From 35fb59b31d65a810ea976a5936b25fea5a4c7077 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 29 Dec 2025 22:49:24 +0000 Subject: [PATCH 2/4] chore: Release v1.26.0-dev.15 [skip ci] # app [1.26.0-dev.15](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.14...v1.26.0-dev.15) (2025-12-29) ### Bug Fixes * install dialog getting stuck ([#2900](https://github.com/ReVanced/revanced-manager/issues/2900)) ([18a4df9](https://github.com/ReVanced/revanced-manager/commit/18a4df9af9cac120fdb8e4ff7aadd2e2a8d5c1a6)) --- app/CHANGELOG.md | 7 +++++++ app/gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 357662fc..4f45eeda 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -1,3 +1,10 @@ +# app [1.26.0-dev.15](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.14...v1.26.0-dev.15) (2025-12-29) + + +### Bug Fixes + +* install dialog getting stuck ([#2900](https://github.com/ReVanced/revanced-manager/issues/2900)) ([18a4df9](https://github.com/ReVanced/revanced-manager/commit/18a4df9af9cac120fdb8e4ff7aadd2e2a8d5c1a6)) + # app [1.26.0-dev.14](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.13...v1.26.0-dev.14) (2025-12-28) diff --git a/app/gradle.properties b/app/gradle.properties index 40507de2..88fe3289 100644 --- a/app/gradle.properties +++ b/app/gradle.properties @@ -1 +1 @@ -version = 1.26.0-dev.14 +version = 1.26.0-dev.15 From 11dd6e4064099427a8c9bc6f225a19412e5c70e2 Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 30 Dec 2025 01:08:54 +0100 Subject: [PATCH 3/4] feat: Show patches as individual steps in patcher screen (#2889) Co-authored-by: Ax333l --- .../manager/patcher/ProgressEventParcel.aidl | 4 + .../runtime/process/IPatcherEvents.aidl | 5 +- .../manager/patcher/PatcherProgress.kt | 78 +++++++++ .../app/revanced/manager/patcher/Session.kt | 108 +++++------- .../patcher/runtime/CoroutineRuntime.kt | 74 ++++---- .../manager/patcher/runtime/ProcessRuntime.kt | 16 +- .../manager/patcher/runtime/Runtime.kt | 5 +- .../patcher/runtime/process/PatcherProcess.kt | 61 ++++--- .../manager/patcher/worker/PatcherWorker.kt | 113 ++++++------ .../manager/ui/component/patcher/Steps.kt | 39 ++--- .../revanced/manager/ui/model/PatcherStep.kt | 24 +-- .../manager/ui/screen/PatcherScreen.kt | 6 +- .../manager/ui/viewmodel/PatcherViewModel.kt | 165 ++++++++++-------- 13 files changed, 396 insertions(+), 302 deletions(-) create mode 100644 app/src/main/aidl/app/revanced/manager/patcher/ProgressEventParcel.aidl create mode 100644 app/src/main/java/app/revanced/manager/patcher/PatcherProgress.kt diff --git a/app/src/main/aidl/app/revanced/manager/patcher/ProgressEventParcel.aidl b/app/src/main/aidl/app/revanced/manager/patcher/ProgressEventParcel.aidl new file mode 100644 index 00000000..e9f6cf2c --- /dev/null +++ b/app/src/main/aidl/app/revanced/manager/patcher/ProgressEventParcel.aidl @@ -0,0 +1,4 @@ +// ProgressEventParcel.aidl +package app.revanced.manager.patcher; + +parcelable ProgressEventParcel; \ No newline at end of file diff --git a/app/src/main/aidl/app/revanced/manager/patcher/runtime/process/IPatcherEvents.aidl b/app/src/main/aidl/app/revanced/manager/patcher/runtime/process/IPatcherEvents.aidl index 27a4f61b..fa11709a 100644 --- a/app/src/main/aidl/app/revanced/manager/patcher/runtime/process/IPatcherEvents.aidl +++ b/app/src/main/aidl/app/revanced/manager/patcher/runtime/process/IPatcherEvents.aidl @@ -1,11 +1,12 @@ // IPatcherEvents.aidl package app.revanced.manager.patcher.runtime.process; +import app.revanced.manager.patcher.ProgressEventParcel; + // Interface for sending events back to the main app process. oneway interface IPatcherEvents { void log(String level, String msg); - void patchSucceeded(); - void progress(String name, String state, String msg); + void event(in ProgressEventParcel event); // The patching process has ended. The exceptionStackTrace is null if it finished successfully. void finished(String exceptionStackTrace); } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/PatcherProgress.kt b/app/src/main/java/app/revanced/manager/patcher/PatcherProgress.kt new file mode 100644 index 00000000..ce010a01 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/patcher/PatcherProgress.kt @@ -0,0 +1,78 @@ +package app.revanced.manager.patcher + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + + +@Parcelize +sealed class ProgressEvent : Parcelable { + abstract val stepId: StepId? + + data class Started(override val stepId: StepId) : ProgressEvent() + + data class Progress( + override val stepId: StepId, + val current: Long? = null, + val total: Long? = null, + val message: String? = null, + ) : ProgressEvent() + + data class Completed( + override val stepId: StepId, + ) : ProgressEvent() + + data class Failed( + override val stepId: StepId?, + val error: RemoteError, + ) : ProgressEvent() +} + +/** + * Parcelable wrapper for [ProgressEvent]. + * + * Required because AIDL does not support sealed classes. + */ +@Parcelize +data class ProgressEventParcel(val event: ProgressEvent) : Parcelable + +fun ProgressEventParcel.toEvent(): ProgressEvent = event +fun ProgressEvent.toParcel(): ProgressEventParcel = ProgressEventParcel(this) + +@Parcelize +sealed class StepId : Parcelable { + data object DownloadAPK : StepId() + data object LoadPatches : StepId() + data object ReadAPK : StepId() + data object ExecutePatches : StepId() + data class ExecutePatch(val index: Int) : StepId() + data object WriteAPK : StepId() + data object SignAPK : StepId() +} + +@Parcelize +data class RemoteError( + val type: String, + val message: String?, + val stackTrace: String, +) : Parcelable + +fun Exception.toRemoteError() = RemoteError( + type = this::class.java.name, + message = this.message, + stackTrace = this.stackTraceToString(), +) + + +inline fun runStep( + stepId: StepId, + onEvent: (ProgressEvent) -> Unit, + block: () -> T, +): T = try { + onEvent(ProgressEvent.Started(stepId)) + val value = block() + onEvent(ProgressEvent.Completed(stepId)) + value +} catch (error: Exception) { + onEvent(ProgressEvent.Failed(stepId, error.toRemoteError())) + throw error +} \ No newline at end of file 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 dd5e7dc4..42f0e955 100644 --- a/app/src/main/java/app/revanced/manager/patcher/Session.kt +++ b/app/src/main/java/app/revanced/manager/patcher/Session.kt @@ -1,10 +1,9 @@ package app.revanced.manager.patcher -import android.content.Context import app.revanced.library.ApkUtils.applyTo -import app.revanced.manager.R +import app.revanced.manager.patcher.Session.Companion.component1 +import app.revanced.manager.patcher.Session.Companion.component2 import app.revanced.manager.patcher.logger.Logger -import app.revanced.manager.ui.model.State import app.revanced.patcher.Patcher import app.revanced.patcher.PatcherConfig import app.revanced.patcher.patch.Patch @@ -22,15 +21,10 @@ class Session( cacheDir: String, frameworkDir: String, aaptPath: String, - private val androidContext: Context, private val logger: Logger, private val input: File, - private val onPatchCompleted: suspend () -> Unit, - private val onProgress: (name: String?, state: State?, message: String?) -> Unit + private val onEvent: (ProgressEvent) -> 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( PatcherConfig( @@ -42,86 +36,68 @@ class Session( ) private suspend fun Patcher.applyPatchesVerbose(selectedPatches: PatchList) { - var nextPatchIndex = 0 - - updateProgress( - name = androidContext.getString(R.string.executing_patch, selectedPatches[nextPatchIndex]), - state = State.RUNNING - ) - this().collect { (patch, exception) -> - if (patch !in selectedPatches) return@collect + val index = selectedPatches.indexOf(patch) + if (index == -1) return@collect if (exception != null) { - updateProgress( - name = androidContext.getString(R.string.failed_to_execute_patch, patch.name), - state = State.FAILED, - message = exception.stackTraceToString() + onEvent( + ProgressEvent.Failed( + StepId.ExecutePatch(index), + exception.toRemoteError(), + ) ) - logger.error("${patch.name} failed:") logger.error(exception.stackTraceToString()) throw exception } - nextPatchIndex++ - - onPatchCompleted() - - selectedPatches.getOrNull(nextPatchIndex)?.let { nextPatch -> - updateProgress( - name = androidContext.getString(R.string.executing_patch, nextPatch.name) + onEvent( + ProgressEvent.Completed( + StepId.ExecutePatch(index), ) - } + ) logger.info("${patch.name} succeeded") } - - updateProgress( - state = State.COMPLETED, - name = androidContext.resources.getQuantityString( - R.plurals.patches_executed, - selectedPatches.size, - selectedPatches.size - ) - ) } suspend fun run(output: File, selectedPatches: PatchList) { - updateProgress(state = State.COMPLETED) // Unpacking + runStep(StepId.ExecutePatches, onEvent) { + java.util.logging.Logger.getLogger("").apply { + handlers.forEach { + it.close() + removeHandler(it) + } - java.util.logging.Logger.getLogger("").apply { - handlers.forEach { - it.close() - removeHandler(it) + addHandler(logger.handler) } - addHandler(logger.handler) + with(patcher) { + logger.info("Merging integrations") + this += selectedPatches.toSet() + + logger.info("Applying patches...") + applyPatchesVerbose(selectedPatches.sortedBy { it.name }) + } } - with(patcher) { - logger.info("Merging integrations") - this += selectedPatches.toSet() + runStep(StepId.WriteAPK, onEvent) { + logger.info("Writing patched files...") + val result = patcher.get() - logger.info("Applying patches...") - applyPatchesVerbose(selectedPatches.sortedBy { it.name }) + val patched = tempDir.resolve("result.apk") + withContext(Dispatchers.IO) { + Files.copy(input.toPath(), patched.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + result.applyTo(patched) + + logger.info("Patched apk saved to $patched") + + withContext(Dispatchers.IO) { + Files.move(patched.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING) + } } - - logger.info("Writing patched files...") - val result = patcher.get() - - val patched = tempDir.resolve("result.apk") - withContext(Dispatchers.IO) { - Files.copy(input.toPath(), patched.toPath(), StandardCopyOption.REPLACE_EXISTING) - } - result.applyTo(patched) - - logger.info("Patched apk saved to $patched") - - withContext(Dispatchers.IO) { - Files.move(patched.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING) - } - updateProgress(state = State.COMPLETED) // Saving } override fun close() { diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt index 50a96a1f..8b52f5d5 100644 --- a/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt +++ b/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt @@ -1,11 +1,12 @@ package app.revanced.manager.patcher.runtime import android.content.Context +import app.revanced.manager.patcher.ProgressEvent import app.revanced.manager.patcher.Session +import app.revanced.manager.patcher.StepId import app.revanced.manager.patcher.logger.Logger import app.revanced.manager.patcher.patch.PatchBundle -import app.revanced.manager.patcher.worker.ProgressEventHandler -import app.revanced.manager.ui.model.State +import app.revanced.manager.patcher.runStep import app.revanced.manager.util.Options import app.revanced.manager.util.PatchSelection import java.io.File @@ -13,7 +14,7 @@ import java.io.File /** * Simple [Runtime] implementation that runs the patcher using coroutines. */ -class CoroutineRuntime(private val context: Context) : Runtime(context) { +class CoroutineRuntime(context: Context) : Runtime(context) { override suspend fun execute( inputFile: String, outputFile: String, @@ -21,47 +22,50 @@ class CoroutineRuntime(private val context: Context) : Runtime(context) { selectedPatches: PatchSelection, options: Options, logger: Logger, - onPatchCompleted: suspend () -> Unit, - onProgress: ProgressEventHandler, + onEvent: (ProgressEvent) -> Unit, ) { - val selectedBundles = selectedPatches.keys - val bundles = bundles() - val uids = bundles.entries.associate { (key, value) -> value to key } + val patchList = runStep(StepId.LoadPatches, onEvent) { + val selectedBundles = selectedPatches.keys + val bundles = bundles() + val uids = bundles.entries.associate { (key, value) -> value to key } - val allPatches = - PatchBundle.Loader.patches(bundles.values, packageName) - .mapKeys { (b, _) -> uids[b]!! } - .filterKeys { it in selectedBundles } + val allPatches = + PatchBundle.Loader.patches(bundles.values, packageName) + .mapKeys { (b, _) -> uids[b]!! } + .filterKeys { it in selectedBundles } - val patchList = selectedPatches.flatMap { (bundle, selected) -> - allPatches[bundle]?.filter { it.name in selected } - ?: throw IllegalArgumentException("Patch bundle $bundle does not exist") - } + val patchList = selectedPatches.flatMap { (bundle, selected) -> + allPatches[bundle]?.filter { it.name in selected } + ?: throw IllegalArgumentException("Patch bundle $bundle does not exist") + } - // Set all patch options. - options.forEach { (bundle, bundlePatchOptions) -> - val patches = allPatches[bundle] ?: return@forEach - bundlePatchOptions.forEach { (patchName, configuredPatchOptions) -> - val patchOptions = patches.single { it.name == patchName }.options - configuredPatchOptions.forEach { (key, value) -> - patchOptions[key] = value + // Set all patch options. + options.forEach { (bundle, bundlePatchOptions) -> + val patches = allPatches[bundle] ?: return@forEach + bundlePatchOptions.forEach { (patchName, configuredPatchOptions) -> + val patchOptions = patches.single { it.name == patchName }.options + configuredPatchOptions.forEach { (key, value) -> + patchOptions[key] = value + } } } + + patchList } - onProgress(null, State.COMPLETED, null) // Loading patches + val session = runStep(StepId.ReadAPK, onEvent) { + Session( + cacheDir, + frameworkPath, + aaptPath, + logger, + File(inputFile), + onEvent, + ) + } - Session( - cacheDir, - frameworkPath, - aaptPath, - context, - logger, - File(inputFile), - onPatchCompleted = onPatchCompleted, - onProgress - ).use { session -> - session.run( + session.use { s -> + s.run( File(outputFile), patchList ) diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt index 2e026298..c8597eca 100644 --- a/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt +++ b/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt @@ -10,12 +10,13 @@ import app.revanced.manager.BuildConfig import app.revanced.manager.patcher.runtime.process.IPatcherEvents import app.revanced.manager.patcher.runtime.process.IPatcherProcess import app.revanced.manager.patcher.LibraryResolver +import app.revanced.manager.patcher.ProgressEvent +import app.revanced.manager.patcher.ProgressEventParcel import app.revanced.manager.patcher.logger.Logger import app.revanced.manager.patcher.runtime.process.Parameters import app.revanced.manager.patcher.runtime.process.PatchConfiguration import app.revanced.manager.patcher.runtime.process.PatcherProcess -import app.revanced.manager.patcher.worker.ProgressEventHandler -import app.revanced.manager.ui.model.State +import app.revanced.manager.patcher.toEvent import app.revanced.manager.util.Options import app.revanced.manager.util.PM import app.revanced.manager.util.PatchSelection @@ -66,8 +67,7 @@ class ProcessRuntime(private val context: Context) : Runtime(context) { selectedPatches: PatchSelection, options: Options, logger: Logger, - onPatchCompleted: suspend () -> Unit, - onProgress: ProgressEventHandler, + onEvent: (ProgressEvent) -> Unit, ) = coroutineScope { // Get the location of our own Apk. val managerBaseApk = pm.getPackageInfo(context.packageName)!!.applicationInfo!!.sourceDir @@ -111,7 +111,6 @@ class ProcessRuntime(private val context: Context) : Runtime(context) { } val patching = CompletableDeferred() - val scope = this launch(Dispatchers.IO) { val binder = awaitBinderConnection() @@ -124,13 +123,10 @@ class ProcessRuntime(private val context: Context) : Runtime(context) { val eventHandler = object : IPatcherEvents.Stub() { override fun log(level: String, msg: String) = logger.log(enumValueOf(level), msg) - override fun patchSucceeded() { - scope.launch { onPatchCompleted() } + override fun event(event: ProgressEventParcel?) { + event?.let { onEvent(it.toEvent()) } } - override fun progress(name: String?, state: String?, msg: String?) = - onProgress(name, state?.let { enumValueOf(it) }, msg) - override fun finished(exceptionStackTrace: String?) { binder.exit() diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/Runtime.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/Runtime.kt index 7f4616bc..67da1ee0 100644 --- a/app/src/main/java/app/revanced/manager/patcher/runtime/Runtime.kt +++ b/app/src/main/java/app/revanced/manager/patcher/runtime/Runtime.kt @@ -4,9 +4,9 @@ import android.content.Context import app.revanced.manager.data.platform.Filesystem import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.PatchBundleRepository +import app.revanced.manager.patcher.ProgressEvent import app.revanced.manager.patcher.aapt.Aapt import app.revanced.manager.patcher.logger.Logger -import app.revanced.manager.patcher.worker.ProgressEventHandler import app.revanced.manager.util.Options import app.revanced.manager.util.PatchSelection import kotlinx.coroutines.flow.first @@ -34,7 +34,6 @@ sealed class Runtime(context: Context) : KoinComponent { selectedPatches: PatchSelection, options: Options, logger: Logger, - onPatchCompleted: suspend () -> Unit, - onProgress: ProgressEventHandler, + onEvent: (ProgressEvent) -> Unit, ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt index f117f201..ab9f5229 100644 --- a/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt +++ b/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt @@ -8,12 +8,15 @@ import android.os.Build import android.os.Bundle import android.os.Looper import app.revanced.manager.BuildConfig +import app.revanced.manager.patcher.ProgressEvent import app.revanced.manager.patcher.Session +import app.revanced.manager.patcher.StepId import app.revanced.manager.patcher.logger.LogLevel import app.revanced.manager.patcher.logger.Logger import app.revanced.manager.patcher.patch.PatchBundle +import app.revanced.manager.patcher.runStep import app.revanced.manager.patcher.runtime.ProcessRuntime -import app.revanced.manager.ui.model.State +import app.revanced.manager.patcher.toParcel import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -24,7 +27,7 @@ import kotlin.system.exitProcess /** * The main class that runs inside the runner process launched by [ProcessRuntime]. */ -class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() { +class PatcherProcess() : IPatcherProcess.Stub() { private var eventBinder: IPatcherEvents? = null private val scope = @@ -46,6 +49,8 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() { override fun exit() = exitProcess(0) override fun start(parameters: Parameters, events: IPatcherEvents) { + fun onEvent(event: ProgressEvent) = events.event(event.toParcel()) + eventBinder = events scope.launch { @@ -56,38 +61,42 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() { logger.info("Memory limit: ${Runtime.getRuntime().maxMemory() / (1024 * 1024)}MB") - val allPatches = PatchBundle.Loader.patches(parameters.configurations.map { it.bundle }, parameters.packageName) - val patchList = parameters.configurations.flatMap { config -> - val patches = (allPatches[config.bundle] ?: return@flatMap emptyList()) + val patchList = runStep(StepId.LoadPatches, ::onEvent) { + val allPatches = PatchBundle.Loader.patches( + parameters.configurations.map { it.bundle }, + parameters.packageName + ) + + parameters.configurations.flatMap { config -> + val patches = (allPatches[config.bundle] ?: return@flatMap emptyList()) .filter { it.name in config.patches } .associateBy { it.name } - config.options.forEach { (patchName, opts) -> - val patchOptions = patches[patchName]?.options - ?: throw Exception("Patch with name $patchName does not exist.") + config.options.forEach { (patchName, opts) -> + val patchOptions = patches[patchName]?.options + ?: throw Exception("Patch with name $patchName does not exist.") - opts.forEach { (key, value) -> - patchOptions[key] = value + opts.forEach { (key, value) -> + patchOptions[key] = value + } } - } - patches.values + patches.values + } } - events.progress(null, State.COMPLETED.name, null) // Loading patches + val session = runStep(StepId.ReadAPK, ::onEvent) { + Session( + cacheDir = parameters.cacheDir, + aaptPath = parameters.aaptPath, + frameworkDir = parameters.frameworkDir, + logger = logger, + input = File(parameters.inputFile), + onEvent = ::onEvent, + ) + } - Session( - cacheDir = parameters.cacheDir, - aaptPath = parameters.aaptPath, - frameworkDir = parameters.frameworkDir, - androidContext = context, - logger = logger, - input = File(parameters.inputFile), - onPatchCompleted = { events.patchSucceeded() }, - onProgress = { name, state, message -> - events.progress(name, state?.name, message) - } - ).use { + session.use { it.run(File(parameters.outputFile), patchList) } @@ -119,7 +128,7 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() { } } - val ipcInterface = PatcherProcess(appContext) + val ipcInterface = PatcherProcess() appContext.sendBroadcast(Intent().apply { action = ProcessRuntime.CONNECT_TO_APP_ACTION 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 0708817d..7076e122 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 @@ -29,14 +29,17 @@ import app.revanced.manager.domain.repository.InstalledAppRepository import app.revanced.manager.domain.worker.Worker import app.revanced.manager.domain.worker.WorkerRepository import app.revanced.manager.network.downloader.LoadedDownloaderPlugin +import app.revanced.manager.patcher.ProgressEvent +import app.revanced.manager.patcher.StepId import app.revanced.manager.patcher.logger.Logger +import app.revanced.manager.patcher.runStep import app.revanced.manager.patcher.runtime.CoroutineRuntime import app.revanced.manager.patcher.runtime.ProcessRuntime +import app.revanced.manager.patcher.toRemoteError import app.revanced.manager.plugin.downloader.GetScope import app.revanced.manager.plugin.downloader.PluginHostApi import app.revanced.manager.plugin.downloader.UserInteractionException import app.revanced.manager.ui.model.SelectedApp -import app.revanced.manager.ui.model.State import app.revanced.manager.util.Options import app.revanced.manager.util.PM import app.revanced.manager.util.PatchSelection @@ -48,8 +51,6 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.io.File -typealias ProgressEventHandler = (name: String?, state: State?, message: String?) -> Unit - @OptIn(PluginHostApi::class) class PatcherWorker( context: Context, @@ -71,11 +72,9 @@ class PatcherWorker( val selectedPatches: PatchSelection, val options: Options, val logger: Logger, - val onDownloadProgress: suspend (Pair?) -> Unit, - val onPatchCompleted: suspend () -> Unit, val handleStartActivityRequest: suspend (LoadedDownloaderPlugin, Intent) -> ActivityResult, val setInputFile: suspend (File) -> Unit, - val onProgress: ProgressEventHandler + val onEvent: (ProgressEvent) -> Unit, ) { val packageName get() = input.packageName } @@ -140,10 +139,6 @@ class PatcherWorker( } private suspend fun runPatcher(args: Args): Result { - - 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 { @@ -163,51 +158,65 @@ class PatcherWorker( args.input.version, prefs.suggestedVersionSafeguard.get(), !prefs.disablePatchVersionCompatCheck.get(), - onDownload = args.onDownloadProgress - ).also { - args.setInputFile(it) - updateProgress(state = State.COMPLETED) // Download APK - } + onDownload = { progress -> + args.onEvent( + ProgressEvent.Progress( + stepId = StepId.DownloadAPK, + current = progress.first, + total = progress.second + ) + ) + } + ).also { args.setInputFile(it) } val inputFile = when (val selectedApp = args.input) { is SelectedApp.Download -> { - val (plugin, data) = downloaderPluginRepository.unwrapParceledData(selectedApp.data) + runStep(StepId.DownloadAPK, args.onEvent) { + val (plugin, data) = downloaderPluginRepository.unwrapParceledData( + selectedApp.data + ) - download(plugin, data) + download(plugin, data) + } } is SelectedApp.Search -> { - downloaderPluginRepository.loadedPluginsFlow.first() - .firstNotNullOfOrNull { plugin -> - try { - val getScope = object : GetScope { - override val pluginPackageName = plugin.packageName - override val hostPackageName = applicationContext.packageName - override suspend fun requestStartActivity(intent: Intent): Intent? { - val result = args.handleStartActivityRequest(plugin, intent) - return when (result.resultCode) { - Activity.RESULT_OK -> result.data - Activity.RESULT_CANCELED -> throw UserInteractionException.Activity.Cancelled() - else -> throw UserInteractionException.Activity.NotCompleted( - result.resultCode, - result.data - ) + runStep(StepId.DownloadAPK, args.onEvent) { + downloaderPluginRepository.loadedPluginsFlow.first() + .firstNotNullOfOrNull { plugin -> + try { + val getScope = object : GetScope { + override val pluginPackageName = plugin.packageName + override val hostPackageName = + applicationContext.packageName + + override suspend fun requestStartActivity(intent: Intent): Intent? { + val result = + args.handleStartActivityRequest(plugin, intent) + return when (result.resultCode) { + Activity.RESULT_OK -> result.data + Activity.RESULT_CANCELED -> throw UserInteractionException.Activity.Cancelled() + else -> throw UserInteractionException.Activity.NotCompleted( + result.resultCode, + result.data + ) + } } } - } - withContext(Dispatchers.IO) { - plugin.get( - getScope, - selectedApp.packageName, - selectedApp.version - ) - }?.takeIf { (_, version) -> selectedApp.version == null || version == selectedApp.version } - } catch (e: UserInteractionException.Activity.NotCompleted) { - throw e - } catch (_: UserInteractionException) { - null - }?.let { (data, _) -> download(plugin, data) } - } ?: throw Exception("App is not available.") + withContext(Dispatchers.IO) { + plugin.get( + getScope, + selectedApp.packageName, + selectedApp.version + ) + }?.takeIf { (_, version) -> selectedApp.version == null || version == selectedApp.version } + } catch (e: UserInteractionException.Activity.NotCompleted) { + throw e + } catch (_: UserInteractionException) { + null + }?.let { (data, _) -> download(plugin, data) } + } ?: throw Exception("App is not available.") + } } is SelectedApp.Local -> selectedApp.file.also { args.setInputFile(it) } @@ -227,12 +236,12 @@ class PatcherWorker( args.selectedPatches, args.options, args.logger, - args.onPatchCompleted, - args.onProgress + args.onEvent, ) - keystoreManager.sign(patchedApk, File(args.output)) - updateProgress(state = State.COMPLETED) // Signing + runStep(StepId.SignAPK, args.onEvent) { + keystoreManager.sign(patchedApk, File(args.output)) + } Log.i(tag, "Patching succeeded".logFmt()) Result.success() @@ -241,11 +250,11 @@ class PatcherWorker( tag, "An exception occurred in the remote process while patching. ${e.originalStackTrace}".logFmt() ) - updateProgress(state = State.FAILED, message = e.originalStackTrace) + args.onEvent(ProgressEvent.Failed(null, e.toRemoteError())) // Fallback if exception doesn't occur within step Result.failure() } catch (e: Exception) { Log.e(tag, "An exception occurred while patching".logFmt(), e) - updateProgress(state = State.FAILED, message = e.stackTraceToString()) + args.onEvent(ProgressEvent.Failed(null, e.toRemoteError())) // Fallback if exception doesn't occur within step Result.failure() } finally { patchedApk.delete() 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 index 537dd84c..fd62ecca 100644 --- 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 @@ -39,11 +39,9 @@ import androidx.compose.ui.unit.dp 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.ProgressKey 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.ui.model.StepProgressProvider +import app.revanced.manager.ui.model.Step import java.util.Locale import kotlin.math.floor @@ -52,8 +50,6 @@ import kotlin.math.floor fun Steps( category: StepCategory, steps: List, - stepCount: Pair? = null, - stepProgressProvider: StepProgressProvider, isExpanded: Boolean = false, onExpand: () -> Unit, onClick: () -> Unit @@ -67,8 +63,17 @@ fun Steps( } } + val filteredSteps = remember(steps) { + val failedCount = steps.count { it.state == State.FAILED } + + steps.filter { step -> + // Show hidden steps if it's the only failed step. + !step.hide || (step.state == State.FAILED && failedCount == 1) + } + } + LaunchedEffect(state) { - if (state == State.RUNNING) + if (state == State.RUNNING || state == State.FAILED) onExpand() } @@ -92,13 +97,8 @@ fun Steps( 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, + text = "${filteredSteps.count { it.state == State.COMPLETED }}/${filteredSteps.size}", style = MaterialTheme.typography.labelSmall ) @@ -112,23 +112,20 @@ fun Steps( .fillMaxWidth() .padding(top = 10.dp) ) { - steps.forEachIndexed { index, step -> - val (progress, progressText) = when (step.progressKey) { - null -> null - ProgressKey.DOWNLOAD -> stepProgressProvider.downloadProgress?.let { (downloaded, total) -> - if (total != null) downloaded.toFloat() / total.toFloat() to "${downloaded.megaBytes}/${total.megaBytes} MB" - else null to "${downloaded.megaBytes} MB" - } + filteredSteps.forEachIndexed { index, step -> + val (progress, progressText) = step.progress?.let { (current, total) -> + if (total != null) current.toFloat() / total.toFloat() to "${current.megaBytes}/${total.megaBytes} MB" + else null to "${current.megaBytes} MB" } ?: (null to null) SubStep( - name = step.name, + name = step.title, state = step.state, message = step.message, progress = progress, progressText = progressText, isFirst = index == 0, - isLast = index == steps.lastIndex, + isLast = index == filteredSteps.lastIndex, ) } } 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 index 3dbb390e..46403d72 100644 --- a/app/src/main/java/app/revanced/manager/ui/model/PatcherStep.kt +++ b/app/src/main/java/app/revanced/manager/ui/model/PatcherStep.kt @@ -3,6 +3,7 @@ package app.revanced.manager.ui.model import android.os.Parcelable import androidx.annotation.StringRes import app.revanced.manager.R +import app.revanced.manager.patcher.StepId import kotlinx.parcelize.Parcelize enum class StepCategory(@StringRes val displayName: Int) { @@ -15,19 +16,20 @@ enum class State { WAITING, RUNNING, FAILED, COMPLETED } -enum class ProgressKey { - DOWNLOAD -} - -interface StepProgressProvider { - val downloadProgress: Pair? -} - @Parcelize data class Step( - val name: String, + val id: StepId, + val title: String, val category: StepCategory, val state: State = State.WAITING, val message: String? = null, - val progressKey: ProgressKey? = null -) : Parcelable \ No newline at end of file + val progress: Pair? = null, + val hide: Boolean = false, +) : Parcelable + + +fun Step.withState( + state: State = this.state, + message: String? = this.message, + progress: Pair? = this.progress +) = copy(state = state, message = message, progress = progress) \ No newline at end of file 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 index c3839551..f3a9f059 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatcherScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatcherScreen.kt @@ -87,7 +87,7 @@ fun PatcherScreen( val steps by remember { derivedStateOf { - viewModel.steps.groupBy { it.category } + viewModel.steps.groupBy { it.category }.toList() } } @@ -230,14 +230,12 @@ fun PatcherScreen( contentPadding = PaddingValues(16.dp) ) { items( - items = steps.toList(), + items = steps, key = { it.first } ) { (category, steps) -> Steps( category = category, steps = steps, - stepCount = if (category == StepCategory.PATCHING) viewModel.patchesProgress else null, - stepProgressProvider = viewModel, isExpanded = expandedCategory == category, onExpand = { expandCategory(category) }, onClick = { diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt index 0365d62c..194f4214 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt @@ -29,20 +29,22 @@ 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.ProgressEvent +import app.revanced.manager.patcher.StepId import app.revanced.manager.patcher.logger.LogLevel import app.revanced.manager.patcher.logger.Logger import app.revanced.manager.patcher.worker.PatcherWorker import app.revanced.manager.plugin.downloader.PluginHostApi import app.revanced.manager.plugin.downloader.UserInteractionException import app.revanced.manager.ui.model.InstallerModel -import app.revanced.manager.ui.model.ProgressKey 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.ui.model.StepProgressProvider +import app.revanced.manager.ui.model.Step import app.revanced.manager.ui.model.navigation.Patcher +import app.revanced.manager.ui.model.withState import app.revanced.manager.util.PM +import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.asCode import app.revanced.manager.util.saveableVar import app.revanced.manager.util.saver.snapshotStateListSaver @@ -80,7 +82,7 @@ import java.time.Duration @OptIn(SavedStateHandleSaveableApi::class, PluginHostApi::class) class PatcherViewModel( private val input: Patcher.ViewModelParams -) : ViewModel(), KoinComponent, StepProgressProvider, InstallerModel { +) : ViewModel(), KoinComponent, InstallerModel { private val app: Application by inject() private val fs: Filesystem by inject() private val pm: PM by inject() @@ -157,35 +159,15 @@ class PatcherViewModel( } } - private val patchCount = input.selectedPatches.values.sumOf { it.size } - private var completedPatchCount by savedStateHandle.saveable { - // SavedStateHandle.saveable only supports the boxed version. - @Suppress("AutoboxingStateCreation") mutableStateOf( - 0 - ) - } - val patchesProgress get() = completedPatchCount to patchCount - override var downloadProgress by savedStateHandle.saveable( - key = "downloadProgress", - stateSaver = autoSaver() - ) { - mutableStateOf?>(null) - } - private set val steps by savedStateHandle.saveable(saver = snapshotStateListSaver()) { - generateSteps( - app, - input.selectedApp - ).toMutableStateList() + generateSteps(app, input.selectedApp, input.selectedPatches).toMutableStateList() } - private var currentStepIndex = 0 val progress by derivedStateOf { - val current = steps.count { - it.state == State.COMPLETED && it.category != StepCategory.PATCHING - } + completedPatchCount + val steps = steps.filter { it.id != StepId.ExecutePatches } - val total = steps.size - 1 + patchCount + val current = steps.count { it.state == State.COMPLETED } + val total = steps.size current.toFloat() / total.toFloat() } @@ -201,12 +183,6 @@ class PatcherViewModel( input.selectedPatches, input.options, logger, - onDownloadProgress = { - withContext(Dispatchers.Main) { - downloadProgress = it - } - }, - onPatchCompleted = { withContext(Dispatchers.Main) { completedPatchCount += 1 } }, setInputFile = { withContext(Dispatchers.Main) { inputFile = it } }, handleStartActivityRequest = { plugin, intent -> withContext(Dispatchers.Main) { @@ -235,26 +211,10 @@ class PatcherViewModel( } } }, - onProgress = { name, state, message -> - viewModelScope.launch { - steps[currentStepIndex] = steps[currentStepIndex].run { - copy( - name = name ?: this.name, - state = state ?: this.state, - message = message ?: this.message - ) - } - - if (state == State.COMPLETED && currentStepIndex != steps.lastIndex) { - currentStepIndex++ - - steps[currentStepIndex] = - steps[currentStepIndex].copy(state = State.RUNNING) - } - } - } + onEvent = ::handleProgressEvent, ) - )) + ) + ) } val patcherSucceeded = @@ -308,6 +268,35 @@ class PatcherViewModel( } } + private fun handleProgressEvent(event: ProgressEvent) = viewModelScope.launch { + val stepIndex = steps.indexOfFirst { + event.stepId?.let { id -> id == it.id } + ?: (it.state == State.RUNNING || it.state == State.WAITING) + } + + if (stepIndex != -1) steps[stepIndex] = steps[stepIndex].run { + when (event) { + is ProgressEvent.Started -> withState(State.RUNNING) + + is ProgressEvent.Progress -> withState( + message = event.message ?: message, + progress = event.current?.let { event.current to event.total } ?: progress + ) + + is ProgressEvent.Completed -> withState(State.COMPLETED, progress = null) + + is ProgressEvent.Failed -> { + if (event.stepId == null && steps.any { it.state == State.FAILED }) return@launch + withState( + State.FAILED, + message = event.error.stackTrace, + progress = null + ) + } + } + } + } + fun onBack() { installerCoroutineScope.cancel() // tempDir cannot be deleted inside onCleared because it gets called on system-initiated process death. @@ -544,34 +533,66 @@ class PatcherViewModel( LogLevel.ERROR -> Log.e(TAG, msg) } - fun generateSteps(context: Context, selectedApp: SelectedApp): List { - val needsDownload = - selectedApp is SelectedApp.Download || selectedApp is SelectedApp.Search + fun generateSteps( + context: Context, + selectedApp: SelectedApp, + selectedPatches: PatchSelection + ): List = buildList { + if (selectedApp is SelectedApp.Download || selectedApp is SelectedApp.Search) + add( + Step( + StepId.DownloadAPK, + context.getString(R.string.download_apk), + StepCategory.PREPARING + ) + ) - return listOfNotNull( - Step( - context.getString(R.string.download_apk), - StepCategory.PREPARING, - state = State.RUNNING, - progressKey = ProgressKey.DOWNLOAD, - ).takeIf { needsDownload }, + add( Step( + StepId.LoadPatches, context.getString(R.string.patcher_step_load_patches), - StepCategory.PREPARING, - state = if (needsDownload) State.WAITING else State.RUNNING, - ), + StepCategory.PREPARING + ) + ) + add( Step( + StepId.ReadAPK, context.getString(R.string.patcher_step_unpack), StepCategory.PREPARING - ), - + ) + ) + add( Step( + StepId.ExecutePatches, context.getString(R.string.execute_patches), - StepCategory.PATCHING - ), + StepCategory.PATCHING, + hide = true + ) + ) - Step(context.getString(R.string.patcher_step_write_patched), StepCategory.SAVING), - Step(context.getString(R.string.patcher_step_sign_apk), StepCategory.SAVING) + selectedPatches.values.asSequence().flatten().sorted().forEachIndexed { index, name -> + add( + Step( + StepId.ExecutePatch(index), + name, + StepCategory.PATCHING + ) + ) + } + + add( + Step( + StepId.WriteAPK, + context.getString(R.string.patcher_step_write_patched), + StepCategory.SAVING + ) + ) + add( + Step( + StepId.SignAPK, + context.getString(R.string.patcher_step_sign_apk), + StepCategory.SAVING + ) ) } } From ffa42099e34c9632827c0bf02848e7e031b6f6d1 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 30 Dec 2025 00:16:11 +0000 Subject: [PATCH 4/4] chore: Release v1.26.0-dev.16 [skip ci] # app [1.26.0-dev.16](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.15...v1.26.0-dev.16) (2025-12-30) ### Features * Show patches as individual steps in patcher screen ([#2889](https://github.com/ReVanced/revanced-manager/issues/2889)) ([11dd6e4](https://github.com/ReVanced/revanced-manager/commit/11dd6e4064099427a8c9bc6f225a19412e5c70e2)) --- app/CHANGELOG.md | 7 +++++++ app/gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 4f45eeda..fa441e51 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -1,3 +1,10 @@ +# app [1.26.0-dev.16](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.15...v1.26.0-dev.16) (2025-12-30) + + +### Features + +* Show patches as individual steps in patcher screen ([#2889](https://github.com/ReVanced/revanced-manager/issues/2889)) ([11dd6e4](https://github.com/ReVanced/revanced-manager/commit/11dd6e4064099427a8c9bc6f225a19412e5c70e2)) + # app [1.26.0-dev.15](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.14...v1.26.0-dev.15) (2025-12-29) diff --git a/app/gradle.properties b/app/gradle.properties index 88fe3289..f9f77dcd 100644 --- a/app/gradle.properties +++ b/app/gradle.properties @@ -1 +1 @@ -version = 1.26.0-dev.15 +version = 1.26.0-dev.16