From 962e68d38a445756cc5cead906d9cb021a1a9224 Mon Sep 17 00:00:00 2001 From: oSumAtrIX Date: Tue, 23 Jan 2024 01:08:18 +0100 Subject: [PATCH] feat: Add installer status dialog --- app/build.gradle.kts | 4 + .../ui/component/InstallerStatusDialog.kt | 161 ++++++++++++++++++ .../manager/ui/screen/PatcherScreen.kt | 4 + .../manager/ui/viewmodel/PatcherViewModel.kt | 80 +++++++-- app/src/main/res/values/strings.xml | 22 +++ gradle/libs.versions.toml | 6 + 6 files changed, 265 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/ui/component/InstallerStatusDialog.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 981d57b90c..a6d5d63643 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -167,4 +167,8 @@ dependencies { // Scrollbars implementation(libs.scrollbars) + + // EnumUtil + implementation(libs.enumutil) + ksp(libs.enumutil.ksp) } 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 new file mode 100644 index 0000000000..c8094b1c26 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/InstallerStatusDialog.kt @@ -0,0 +1,161 @@ +package app.revanced.manager.ui.component + +import android.content.pm.PackageInstaller +import androidx.annotation.RequiresApi +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import app.revanced.manager.R +import app.revanced.manager.ui.viewmodel.PackageInstallerResult +import com.github.materiiapps.enumutil.FromValue + +private typealias InstallerStatusDialogButtonHandler = ((model: InstallerModel) -> Unit) +private typealias InstallerStatusDialogButton = @Composable (model: InstallerStatusDialogModel) -> Unit + +interface InstallerModel { + fun reinstall() + fun install() +} + +interface InstallerStatusDialogModel : InstallerModel { + var packageInstallerStatus: Int? +} + +@Composable +fun InstallerStatusDialog(model: InstallerStatusDialogModel) { + val dialogKind = remember { + DialogKind.fromValue(model.packageInstallerStatus!!) ?: DialogKind.FAILURE + } + + AlertDialog( + onDismissRequest = { + model.packageInstallerStatus = null + }, + confirmButton = { + dialogKind.confirmButton(model) + }, + dismissButton = { + dialogKind.dismissButton?.invoke(model) + }, + icon = { + Icon(dialogKind.icon, null) + }, + title = { + Text( + text = stringResource(dialogKind.title), + style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center), + color = MaterialTheme.colorScheme.onSurface, + ) + }, + text = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(stringResource(dialogKind.contentStringResId)) + } + } + ) +} + +private fun installerStatusDialogButton( + @StringRes buttonStringResId: Int, + buttonHandler: InstallerStatusDialogButtonHandler = { }, +): InstallerStatusDialogButton = { model -> + TextButton( + onClick = { + model.packageInstallerStatus = null + buttonHandler(model) + } + ) { + Text(stringResource(buttonStringResId)) + } +} + +@FromValue("flag") +enum class DialogKind( + val flag: Int, + val title: Int, + @StringRes val contentStringResId: Int, + val icon: ImageVector = Icons.Outlined.ErrorOutline, + val confirmButton: InstallerStatusDialogButton = installerStatusDialogButton(R.string.ok), + val dismissButton: InstallerStatusDialogButton? = null, +) { + FAILURE( + flag = PackageInstaller.STATUS_FAILURE, + title = R.string.installation_failed, + contentStringResId = R.string.installation_failed_description, + ), + FAILURE_ABORTED( + flag = PackageInstaller.STATUS_FAILURE_ABORTED, + title = R.string.installation_cancelled, + contentStringResId = R.string.installation_aborted_description, + ), + FAILURE_BLOCKED( + flag = PackageInstaller.STATUS_FAILURE_BLOCKED, + title = R.string.installation_blocked, + contentStringResId = R.string.installation_blocked_description, + ), + FAILURE_CONFLICT( + flag = PackageInstaller.STATUS_FAILURE_CONFLICT, + title = R.string.installation_conflict, + contentStringResId = R.string.installation_conflict_description, + confirmButton = installerStatusDialogButton(R.string.reinstall) { model -> + model.reinstall() + }, + dismissButton = installerStatusDialogButton(R.string.cancel), + ), + FAILURE_INCOMPATIBLE( + flag = PackageInstaller.STATUS_FAILURE_INCOMPATIBLE, + title = R.string.installation_incompatible, + contentStringResId = R.string.installation_incompatible_description, + ), + FAILURE_INVALID( + flag = PackageInstaller.STATUS_FAILURE_INVALID, + title = R.string.installation_invalid, + contentStringResId = R.string.installation_invalid_description, + confirmButton = installerStatusDialogButton(R.string.reinstall) { model -> + model.reinstall() + }, + dismissButton = installerStatusDialogButton(R.string.cancel), + ), + FAILURE_STORAGE( + flag = PackageInstaller.STATUS_FAILURE_STORAGE, + title = R.string.installation_storage_issue, + contentStringResId = R.string.installation_storage_issue_description, + ), + + @RequiresApi(34) + FAILURE_TIMEOUT( + flag = PackageInstaller.STATUS_FAILURE_TIMEOUT, + title = R.string.installation_timeout, + contentStringResId = R.string.installation_timeout_description, + confirmButton = installerStatusDialogButton(R.string.install_app) { model -> + model.install() + }, + ), + SUCCESS( + flag = PackageInstaller.STATUS_SUCCESS, + title = R.string.installation_success, + contentStringResId = R.string.installation_success_description, + icon = Icons.Outlined.Check, + ); + + // Needed due to the @FromValue annotation. + companion object +} 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 882d8cfedd..012cc062e2 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 @@ -40,6 +40,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.ui.component.AppScaffold import app.revanced.manager.ui.component.AppTopBar +import app.revanced.manager.ui.component.InstallerStatusDialog import app.revanced.manager.ui.component.patcher.InstallPickerDialog import app.revanced.manager.ui.component.patcher.Steps import app.revanced.manager.ui.model.State @@ -89,6 +90,9 @@ fun PatcherScreen( onConfirm = vm::install ) + if (vm.installerStatusDialogModel.packageInstallerStatus != null) + InstallerStatusDialog(vm.installerStatusDialogModel) + AppScaffold( topBar = { AppTopBar( 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 d1b3d360fc..02ec2167a0 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,6 +29,8 @@ import app.revanced.manager.domain.worker.WorkerRepository import app.revanced.manager.patcher.logger.ManagerLogger import app.revanced.manager.patcher.worker.PatcherWorker import app.revanced.manager.service.InstallService +import app.revanced.manager.service.UninstallService +import app.revanced.manager.ui.component.InstallerStatusDialogModel import app.revanced.manager.ui.destination.Destination import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.State @@ -38,6 +40,7 @@ import app.revanced.manager.util.PM import app.revanced.manager.util.simpleMessage import app.revanced.manager.util.tag import app.revanced.manager.util.toast +import app.revanced.manager.util.uiSafe import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -49,6 +52,11 @@ import java.io.File import java.nio.file.Files import java.util.UUID +data class PackageInstallerResult( + val status: Int = PackageInstaller.STATUS_FAILURE, + val extraStatusMessage: String?, +) + @Stable class PatcherViewModel( private val input: Destination.Patcher @@ -60,6 +68,20 @@ class PatcherViewModel( private val installedAppRepository: InstalledAppRepository by inject() private val rootInstaller: RootInstaller by inject() + val installerStatusDialogModel = object : InstallerStatusDialogModel { + override var packageInstallerStatus by mutableStateOf(null) + + override fun reinstall() { + this@PatcherViewModel.reinstall() + } + + override fun install() { + // Since this is a package installer status dialog, + // InstallType.ROOT is never used here. + install(InstallType.DEFAULT) + } + } + private var installedApp: InstalledApp? = null val packageName: String = input.selectedApp.packageName var installedPackageName by mutableStateOf(null) @@ -109,7 +131,8 @@ class PatcherViewModel( if (state == State.COMPLETED && currentStepIndex != steps.lastIndex) { currentStepIndex++ - steps[currentStepIndex] = steps[currentStepIndex].copy(state = State.RUNNING) + steps[currentStepIndex] = + steps[currentStepIndex].copy(state = State.RUNNING) } } ) @@ -124,15 +147,19 @@ class PatcherViewModel( } } - private val installBroadcastReceiver = object : BroadcastReceiver() { + 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, -999) - val extra = intent.getStringExtra(InstallService.EXTRA_INSTALL_STATUS_MESSAGE)!! + val pmStatus = intent.getIntExtra( + InstallService.EXTRA_INSTALL_STATUS, + PackageInstaller.STATUS_FAILURE + ) + + // TODO: This is unused and may be completely removed everywhere. + // intent.getStringExtra(UninstallService.EXTRA_UNINSTALL_STATUS_MESSAGE) if (pmStatus == PackageInstaller.STATUS_SUCCESS) { - app.toast(app.getString(R.string.install_app_success)) installedPackageName = intent.getStringExtra(InstallService.EXTRA_PACKAGE_NAME) viewModelScope.launch { @@ -144,8 +171,22 @@ class PatcherViewModel( input.selectedPatches ) } - } else { - app.toast(app.getString(R.string.install_app_fail, extra)) + } + + installerStatusDialogModel.packageInstallerStatus = pmStatus + } + + UninstallService.APP_UNINSTALL_ACTION -> { + val pmStatus = intent.getIntExtra( + UninstallService.EXTRA_UNINSTALL_STATUS, + PackageInstaller.STATUS_FAILURE + ) + + // TODO: This is unused and may be completely removed everywhere. + // intent.getStringExtra(UninstallService.EXTRA_UNINSTALL_STATUS_MESSAGE) + + if (pmStatus != PackageInstaller.STATUS_SUCCESS) { + installerStatusDialogModel.packageInstallerStatus = pmStatus } } } @@ -153,9 +194,14 @@ class PatcherViewModel( } init { // TODO: navigate away when system-initiated process death is detected because it is not possible to recover from it. - ContextCompat.registerReceiver(app, installBroadcastReceiver, IntentFilter().apply { - addAction(InstallService.APP_INSTALL_ACTION) - }, ContextCompat.RECEIVER_NOT_EXPORTED) + 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) @@ -164,7 +210,7 @@ class PatcherViewModel( override fun onCleared() { super.onCleared() - app.unregisterReceiver(installBroadcastReceiver) + app.unregisterReceiver(installerBroadcastReceiver) workManager.cancelWorkById(patcherWorkerId) when (val selectedApp = input.selectedApp) { @@ -255,7 +301,8 @@ class PatcherViewModel( app.toast(app.getString(R.string.install_app_fail, e.simpleMessage())) try { rootInstaller.uninstall(packageName) - } catch (_: Exception) { } + } catch (_: Exception) { + } } } } @@ -264,6 +311,15 @@ class PatcherViewModel( } } + 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)) + } + } + companion object { fun generateSteps( context: Context, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bae85e4178..42e8cdca15 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -229,6 +229,7 @@ Install App installed Failed to install app: %s + Failed to reinstall app: %s Failed to uninstall app: %s Open Save APK @@ -328,4 +329,25 @@ Import local files from your storage, does not automatically update Import remote files from a URL, can automatically update Recommended + + Installation failed + Installation cancelled + Installation blocked + Installation conflict + Installation incompatible + Installation invalid + Installation storage issue + Installation timeout + Installation succeeded + The installation failed due to an unknown reason. Please try again. + The installation was cancelled manually. + The installation was blocked. Adjust your security settings and try again. + An existing installation of the app prevents the installation. Uninstall the app and try again? + The app is incompatible with this device. Contact the developer of the app and ask for support. + The app is invalid. Uninstall the app and try again? + The app could not be installed due to insufficient storage. Free up some space and try again. + The installation took too long. + The app has been installed successfully. + + Reinstall \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 318a0a2b2b..73b2688ab5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,8 @@ app-icon-loader-coil = "1.5.0" skrapeit = "1.2.2" libsu = "5.2.0" scrollbars = "1.0.4" +enumutil = "1.1.0" +enumutil-ksp = "1.1.0" [libraries] # AndroidX Core @@ -110,6 +112,10 @@ libsu-nio = { group = "com.github.topjohnwu.libsu", name = "nio", version.ref = # Scrollbars scrollbars = { group = "com.github.GIGAMOLE", name = "ComposeScrollbars", version.ref = "scrollbars" } +# EnumUtil +enumutil = { group = "io.github.materiiapps", name = "enumutil", version.ref = "enumutil" } +enumutil-ksp = { group = "io.github.materiiapps", name = "enumutil-ksp", version.ref = "enumutil-ksp" } + [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlinGradlePlugin" }