From 05ace8180e722ec8ad3b305574e52ccb2eb80f50 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Mon, 29 Dec 2025 21:53:06 +0100 Subject: [PATCH] fix: install dialog getting stuck --- 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" }