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" }