fix: install dialog getting stuck

This commit is contained in:
Ax333l
2025-12-29 21:53:06 +01:00
parent 0d26df03f4
commit 05ace8180e
13 changed files with 361 additions and 384 deletions

View File

@@ -108,6 +108,10 @@ dependencies {
// Compose Icons // Compose Icons
implementation(libs.compose.icons.fontawesome) implementation(libs.compose.icons.fontawesome)
// Ackpine
implementation(libs.ackpine.core)
implementation(libs.ackpine.ktx)
} }
buildscript { buildscript {

View File

@@ -51,9 +51,6 @@
<activity android:name=".plugin.downloader.webview.WebViewActivity" android:exported="false" android:theme="@style/Theme.WebViewActivity" /> <activity android:name=".plugin.downloader.webview.WebViewActivity" android:exported="false" android:theme="@style/Theme.WebViewActivity" />
<service android:name=".service.InstallService" />
<service android:name=".service.UninstallService" />
<service <service
android:name="androidx.work.impl.foreground.SystemForegroundService" android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="specialUse" android:foregroundServiceType="specialUse"
@@ -75,5 +72,15 @@
android:value="androidx.startup" android:value="androidx.startup"
tools:node="remove" /> tools:node="remove" />
</provider> </provider>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="ru.solrudev.ackpine.AckpineInitializer"
tools:node="remove" />
</provider>
</application> </application>
</manifest> </manifest>

View File

@@ -48,7 +48,8 @@ class ManagerApplication : Application() {
workerModule, workerModule,
viewModelModule, viewModelModule,
databaseModule, databaseModule,
rootModule rootModule,
ackpineModule
) )
} }

View File

@@ -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())
}
}

View File

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

View File

@@ -1,5 +1,6 @@
package app.revanced.manager.ui.component package app.revanced.manager.ui.component
import android.annotation.SuppressLint
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.annotation.StringRes import androidx.annotation.StringRes
@@ -79,7 +80,7 @@ private fun installerStatusDialogButton(
enum class DialogKind( enum class DialogKind(
val flag: Int, val flag: Int,
val title: Int, val title: Int,
@StringRes val contentStringResId: Int, @param:StringRes val contentStringResId: Int,
val icon: ImageVector = Icons.Outlined.ErrorOutline, val icon: ImageVector = Icons.Outlined.ErrorOutline,
val confirmButton: InstallerStatusDialogButton = installerStatusDialogButton(R.string.ok), val confirmButton: InstallerStatusDialogButton = installerStatusDialogButton(R.string.ok),
val dismissButton: InstallerStatusDialogButton? = null, val dismissButton: InstallerStatusDialogButton? = null,
@@ -133,10 +134,8 @@ enum class DialogKind(
title = R.string.installation_storage_issue_dialog_title, title = R.string.installation_storage_issue_dialog_title,
contentStringResId = R.string.installation_storage_issue_description, contentStringResId = R.string.installation_storage_issue_description,
), ),
@RequiresApi(34)
FAILURE_TIMEOUT( FAILURE_TIMEOUT(
flag = PackageInstaller.STATUS_FAILURE_TIMEOUT, flag = @SuppressLint("InlinedApi") PackageInstaller.STATUS_FAILURE_TIMEOUT,
title = R.string.installation_timeout_dialog_title, title = R.string.installation_timeout_dialog_title,
contentStringResId = R.string.installation_timeout_description, contentStringResId = R.string.installation_timeout_description,
confirmButton = installerStatusDialogButton(R.string.install_app) { model -> confirmButton = installerStatusDialogButton(R.string.install_app) { model ->

View File

@@ -1,17 +1,11 @@
package app.revanced.manager.ui.viewmodel package app.revanced.manager.ui.viewmodel
import android.app.Application 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.PackageInfo
import android.content.pm.PackageInstaller
import android.util.Log import android.util.Log
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.revanced.manager.R 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.data.room.apps.installed.InstalledApp
import app.revanced.manager.domain.installer.RootInstaller import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.domain.repository.InstalledAppRepository import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.service.UninstallService
import app.revanced.manager.util.PM import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.simpleMessage import app.revanced.manager.util.simpleMessage
@@ -30,6 +23,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import ru.solrudev.ackpine.session.Session
import ru.solrudev.ackpine.uninstaller.UninstallFailure
class InstalledAppInfoViewModel( class InstalledAppInfoViewModel(
packageName: String packageName: String
@@ -87,51 +82,28 @@ class InstalledAppInfoViewModel(
fun uninstall() { fun uninstall() {
val app = installedApp ?: return val app = installedApp ?: return
when (app.installType) { viewModelScope.launch {
InstallType.DEFAULT -> pm.uninstallPackage(app.currentPackageName) when (app.installType) {
InstallType.DEFAULT -> {
InstallType.MOUNT -> viewModelScope.launch { when (val result = pm.uninstallPackage(app.currentPackageName)) {
rootInstaller.uninstall(app.currentPackageName) is Session.State.Failed<UninstallFailure> -> {
installedAppRepository.delete(app) val msg = result.failure.message.orEmpty()
onBackClick() context.toast(
} this@InstalledAppInfoViewModel.context.getString(
} R.string.uninstall_app_fail,
} msg
)
private val uninstallBroadcastReceiver = object : BroadcastReceiver() { )
override fun onReceive(context: Context?, intent: Intent?) { return@launch
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()
} }
} else if (extraStatus != PackageInstaller.STATUS_FAILURE_ABORTED) { Session.State.Succeeded -> {}
this@InstalledAppInfoViewModel.context.toast(this@InstalledAppInfoViewModel.context.getString(R.string.uninstall_app_fail, extraStatusMessage))
} }
} }
}
}
}.also {
ContextCompat.registerReceiver(
context,
it,
IntentFilter(UninstallService.APP_UNINSTALL_ACTION),
ContextCompat.RECEIVER_NOT_EXPORTED
)
}
override fun onCleared() { InstallType.MOUNT -> rootInstaller.uninstall(app.currentPackageName)
super.onCleared() }
context.unregisterReceiver(uninstallBroadcastReceiver) installedAppRepository.delete(app)
onBackClick()
}
} }
} }

View File

@@ -1,11 +1,9 @@
package app.revanced.manager.ui.viewmodel package app.revanced.manager.ui.viewmodel
import android.app.Application import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.pm.PackageInstaller as AndroidPackageInstaller
import android.content.pm.PackageInstaller
import android.net.Uri import android.net.Uri
import android.os.ParcelUuid import android.os.ParcelUuid
import android.util.Log import android.util.Log
@@ -16,7 +14,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.autoSaver import androidx.compose.runtime.saveable.autoSaver
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList import androidx.compose.runtime.toMutableStateList
import androidx.core.content.ContextCompat
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.map 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.patcher.worker.PatcherWorker
import app.revanced.manager.plugin.downloader.PluginHostApi import app.revanced.manager.plugin.downloader.PluginHostApi
import app.revanced.manager.plugin.downloader.UserInteractionException 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.InstallerModel
import app.revanced.manager.ui.model.ProgressKey import app.revanced.manager.ui.model.ProgressKey
import app.revanced.manager.ui.model.SelectedApp 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.StepProgressProvider
import app.revanced.manager.ui.model.navigation.Patcher import app.revanced.manager.ui.model.navigation.Patcher
import app.revanced.manager.util.PM import app.revanced.manager.util.PM
import app.revanced.manager.util.asCode
import app.revanced.manager.util.saveableVar import app.revanced.manager.util.saveableVar
import app.revanced.manager.util.saver.snapshotStateListSaver 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.toast
import app.revanced.manager.util.uiSafe import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -66,6 +64,15 @@ import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
import org.koin.core.component.inject 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.io.File
import java.nio.file.Files import java.nio.file.Files
import java.time.Duration import java.time.Duration
@@ -81,6 +88,7 @@ class PatcherViewModel(
private val installedAppRepository: InstalledAppRepository by inject() private val installedAppRepository: InstalledAppRepository by inject()
private val rootInstaller: RootInstaller by inject() private val rootInstaller: RootInstaller by inject()
private val savedStateHandle: SavedStateHandle = get() private val savedStateHandle: SavedStateHandle = get()
private val ackpineInstaller: PackageInstaller = get()
private var installedApp: InstalledApp? = null private var installedApp: InstalledApp? = null
private val selectedApp = input.selectedApp private val selectedApp = input.selectedApp
@@ -95,7 +103,6 @@ class PatcherViewModel(
mutableStateOf<String?>(null) mutableStateOf<String?>(null)
} }
private set private set
private var ongoingPmSession: Boolean by savedStateHandle.saveableVar { false }
var packageInstallerStatus: Int? by savedStateHandle.saveable( var packageInstallerStatus: Int? by savedStateHandle.saveable(
key = "packageInstallerStatus", key = "packageInstallerStatus",
stateSaver = autoSaver() stateSaver = autoSaver()
@@ -104,7 +111,7 @@ class PatcherViewModel(
} }
private set private set
var isInstalling by mutableStateOf(ongoingPmSession) var isInstalling by mutableStateOf(false)
private set private set
private var currentActivityRequest: Pair<CompletableDeferred<Boolean>, String>? by mutableStateOf( private var currentActivityRequest: Pair<CompletableDeferred<Boolean>, 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 var inputFile: File? by savedStateHandle.saveableVar()
private val outputFile = tempDir.resolve("output.apk") private val outputFile = tempDir.resolve("output.apk")
@@ -174,67 +193,68 @@ class PatcherViewModel(
private val workManager = WorkManager.getInstance(app) private val workManager = WorkManager.getInstance(app)
private val patcherWorkerId by savedStateHandle.saveable<ParcelUuid> { private val patcherWorkerId by savedStateHandle.saveable<ParcelUuid> {
ParcelUuid(workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>( ParcelUuid(
"patching", PatcherWorker.Args( workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>(
input.selectedApp, "patching", PatcherWorker.Args(
outputFile.path, input.selectedApp,
input.selectedPatches, outputFile.path,
input.options, input.selectedPatches,
logger, input.options,
onDownloadProgress = { logger,
withContext(Dispatchers.Main) { onDownloadProgress = {
downloadProgress = it withContext(Dispatchers.Main) {
} downloadProgress = it
}, }
onPatchCompleted = { withContext(Dispatchers.Main) { completedPatchCount += 1 } }, },
setInputFile = { withContext(Dispatchers.Main) { inputFile = it } }, onPatchCompleted = { withContext(Dispatchers.Main) { completedPatchCount += 1 } },
handleStartActivityRequest = { plugin, intent -> setInputFile = { withContext(Dispatchers.Main) { inputFile = it } },
withContext(Dispatchers.Main) { handleStartActivityRequest = { plugin, intent ->
if (currentActivityRequest != null) throw Exception("Another request is already pending.") withContext(Dispatchers.Main) {
try { if (currentActivityRequest != null) throw Exception("Another request is already pending.")
// Wait for the dialog interaction.
val accepted = with(CompletableDeferred<Boolean>()) {
currentActivityRequest = this to plugin.name
await()
}
if (!accepted) throw UserInteractionException.RequestDenied()
// Launch the activity and wait for the result.
try { try {
with(CompletableDeferred<ActivityResult>()) { // Wait for the dialog interaction.
launchedActivity = this val accepted = with(CompletableDeferred<Boolean>()) {
launchActivityChannel.send(intent) currentActivityRequest = this to plugin.name
await() await()
} }
if (!accepted) throw UserInteractionException.RequestDenied()
// Launch the activity and wait for the result.
try {
with(CompletableDeferred<ActivityResult>()) {
launchedActivity = this
launchActivityChannel.send(intent)
await()
}
} finally {
launchedActivity = null
}
} finally { } 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 = val patcherSucceeded =
@@ -246,64 +266,26 @@ class PatcherViewModel(
} }
} }
private val installerBroadcastReceiver = object : BroadcastReceiver() { init {
override fun onReceive(context: Context?, intent: Intent?) { // TODO: detect system-initiated process death during the patching process.
when (intent?.action) {
InstallService.APP_INSTALL_ACTION -> {
val pmStatus = intent.getIntExtra(
InstallService.EXTRA_INSTALL_STATUS,
PackageInstaller.STATUS_FAILURE
)
intent.getStringExtra(UninstallService.EXTRA_UNINSTALL_STATUS_MESSAGE) installerSessionId?.uuid?.let { id ->
?.let(logger::trace) viewModelScope.launch {
try {
if (pmStatus == PackageInstaller.STATUS_SUCCESS) { isInstalling = true
app.toast(app.getString(R.string.install_app_success)) uiSafe(app, R.string.install_app_fail, "Failed to install") {
installedPackageName = // The process was killed during installation. Await the session again.
intent.getStringExtra(InstallService.EXTRA_PACKAGE_NAME) withContext(Dispatchers.IO) {
viewModelScope.launch { ackpineInstaller.getSession(id)
installedAppRepository.addOrUpdate( }?.let {
installedPackageName!!, awaitInstallation(it)
packageName,
input.selectedApp.version
?: pm.getPackageInfo(outputFile)?.versionName!!,
InstallType.DEFAULT,
input.selectedPatches
)
} }
} else packageInstallerStatus = pmStatus }
} finally {
isInstalling = false 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 { viewModelScope.launch {
installedApp = installedAppRepository.get(packageName) installedApp = installedAppRepository.get(packageName)
@@ -313,7 +295,6 @@ class PatcherViewModel(
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
app.unregisterReceiver(installerBroadcastReceiver)
workManager.cancelWorkById(patcherWorkerId.uuid) workManager.cancelWorkById(patcherWorkerId.uuid)
if (input.selectedApp is SelectedApp.Installed && installedApp?.installType == InstallType.MOUNT) { if (input.selectedApp is SelectedApp.Installed && installedApp?.installType == InstallType.MOUNT) {
@@ -328,6 +309,7 @@ class PatcherViewModel(
} }
fun onBack() { fun onBack() {
installerCoroutineScope.cancel()
// tempDir cannot be deleted inside onCleared because it gets called on system-initiated process death. // tempDir cannot be deleted inside onCleared because it gets called on system-initiated process death.
tempDir.deleteRecursively() tempDir.deleteRecursively()
} }
@@ -372,44 +354,93 @@ class PatcherViewModel(
fun open() = installedPackageName?.let(pm::launch) fun open() = installedPackageName?.let(pm::launch)
fun install(installType: InstallType) = viewModelScope.launch { private suspend fun startInstallation(file: File, packageName: String) {
var pmInstallStarted = false val session = withContext(Dispatchers.IO) {
try { ackpineInstaller.createSession(Uri.fromFile(file)) {
isInstalling = true confirmation = Confirmation.IMMEDIATE
}
}
withContext(Dispatchers.Main) {
installerPkgName = packageName
}
awaitInstallation(session)
}
val currentPackageInfo = pm.getPackageInfo(outputFile) private suspend fun awaitInstallation(session: ProgressSession<InstallFailure>) = withContext(
?: throw Exception("Failed to load application info") Dispatchers.Main
) {
// If the app is currently installed val result = installerCoroutineScope.async {
val existingPackageInfo = pm.getPackageInfo(currentPackageInfo.packageName) try {
if (existingPackageInfo != null) { installerSessionId = ParcelUuid(session.id)
// Check if the app version is less than the installed version withContext(Dispatchers.IO) {
if (pm.getVersionCode(currentPackageInfo) < pm.getVersionCode(existingPackageInfo)) { session.await()
// Exit if the selected app version is less than the installed version
packageInstallerStatus = PackageInstaller.STATUS_FAILURE_CONFLICT
return@launch
} }
} finally {
installerSessionId = null
}
}.await()
when (result) {
is Session.State.Failed<InstallFailure> -> {
result.failure.message?.let(logger::trace)
packageInstallerStatus = result.failure.asCode()
} }
when (installType) { Session.State.Succeeded -> {
InstallType.DEFAULT -> { app.toast(app.getString(R.string.install_app_success))
// Check if the app is mounted as root installedPackageName = installerPkgName
// If it is, unmount it first, silently installedAppRepository.addOrUpdate(
if (rootInstaller.hasRootAccess() && rootInstaller.isAppMounted(packageName)) { installerPkgName,
rootInstaller.unmount(packageName) packageName,
} input.selectedApp.version
?: withContext(Dispatchers.IO) { pm.getPackageInfo(outputFile)?.versionName!! },
InstallType.DEFAULT,
input.selectedPatches
)
}
}
}
// Install regularly fun install(installType: InstallType) = viewModelScope.launch {
pm.installApp(listOf(outputFile)) isInstalling = true
pmInstallStarted = 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 -> { when (installType) {
try { InstallType.DEFAULT -> {
val packageInfo = pm.getPackageInfo(outputFile) // Check if the app is mounted as root
?: throw Exception("Failed to load application info") // 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) { val label = with(pm) {
packageInfo.label() currentPackageInfo.label()
} }
// Check for base APK, first check if the app is already installed // 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 the app is not installed, check if the output file is a base apk
if (currentPackageInfo.splitNames.isNotEmpty()) { if (currentPackageInfo.splitNames.isNotEmpty()) {
// Exit if there is no base APK package // Exit if there is no base APK package
packageInstallerStatus = PackageInstaller.STATUS_FAILURE_INVALID packageInstallerStatus =
AndroidPackageInstaller.STATUS_FAILURE_INVALID
return@launch return@launch
} }
} }
val inputVersion = input.selectedApp.version 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") ?: throw Exception("Failed to determine input APK version")
needsRootUninstall = true
// Install as root // Install as root
rootInstaller.install( rootInstaller.install(
outputFile, outputFile,
@@ -436,7 +469,7 @@ class PatcherViewModel(
) )
installedAppRepository.addOrUpdate( installedAppRepository.addOrUpdate(
packageInfo.packageName, currentPackageInfo.packageName,
packageName, packageName,
inputVersion, inputVersion,
InstallType.MOUNT, InstallType.MOUNT,
@@ -448,21 +481,20 @@ class PatcherViewModel(
installedPackageName = packageName installedPackageName = packageName
app.toast(app.getString(R.string.install_app_success)) app.toast(app.getString(R.string.install_app_success))
} catch (e: Exception) { needsRootUninstall = false
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) {
}
} }
} }
} }
} catch (e: Exception) {
Log.e(tag, "Failed to install", e)
app.toast(app.getString(R.string.install_app_fail, e.simpleMessage()))
} finally { } 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() { override fun reinstall() {
viewModelScope.launch { viewModelScope.launch {
uiSafe(app, R.string.reinstall_app_fail, "Failed to reinstall") { try {
pm.getPackageInfo(outputFile)?.packageName?.let { pm.uninstallPackage(it) }
?: throw Exception("Failed to load application info")
pm.installApp(listOf(outputFile))
isInstalling = true 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<UninstallFailure> -> {
result.failure.message?.let(logger::trace)
packageInstallerStatus = result.failure.asCode()
return@launch
}
Session.State.Succeeded -> {}
}
startInstallation(outputFile, pkgName)
}
} finally {
isInstalling = false
} }
} }
} }

View File

@@ -1,18 +1,13 @@
package app.revanced.manager.ui.viewmodel package app.revanced.manager.ui.viewmodel
import android.app.Application import android.app.Application
import android.content.BroadcastReceiver import android.net.Uri
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.revanced.manager.R 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.api.ReVancedAPI
import app.revanced.manager.network.dto.ReVancedAsset import app.revanced.manager.network.dto.ReVancedAsset
import app.revanced.manager.network.service.HttpService 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.toast
import app.revanced.manager.util.uiSafe import app.revanced.manager.util.uiSafe
import io.ktor.client.plugins.onDownload import io.ktor.client.plugins.onDownload
@@ -31,7 +24,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject 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( class UpdateViewModel(
private val downloadOnScreenEntry: Boolean private val downloadOnScreenEntry: Boolean
@@ -39,10 +39,11 @@ class UpdateViewModel(
private val app: Application by inject() private val app: Application by inject()
private val reVancedAPI: ReVancedAPI by inject() private val reVancedAPI: ReVancedAPI by inject()
private val http: HttpService by inject() private val http: HttpService by inject()
private val pm: PM by inject()
private val networkInfo: NetworkInfo by inject() private val networkInfo: NetworkInfo by inject()
private val fs: Filesystem 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) var downloadedSize by mutableLongStateOf(0L)
private set private set
var totalSize by mutableLongStateOf(0L) var totalSize by mutableLongStateOf(0L)
@@ -62,14 +63,17 @@ class UpdateViewModel(
private set private set
private val location = fs.tempDir.resolve("updater.apk") 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) { init {
downloadUpdate() viewModelScope.launch {
} else { uiSafe(app, R.string.download_manager_failed, "Failed to download ReVanced Manager") {
state = State.CAN_DOWNLOAD 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 { fun installUpdate() = viewModelScope.launch {
state = State.INSTALLING state = State.INSTALLING
val result = withContext(Dispatchers.IO) {
ackpineInstaller.createSession(Uri.fromFile(location)) {
confirmation = Confirmation.IMMEDIATE
}.await()
}
pm.installApp(listOf(location)) when (result) {
} is Session.State.Failed<InstallFailure> -> when (val failure = result.failure) {
is InstallFailure.Aborted -> state = State.CAN_INSTALL
private val installBroadcastReceiver = object : BroadcastReceiver() { else -> {
override fun onReceive(context: Context?, intent: Intent?) { val msg = failure.message.orEmpty()
intent?.let { app.toast(app.getString(R.string.install_app_fail, msg))
val pmStatus = intent.getIntExtra(InstallService.EXTRA_INSTALL_STATUS, -999) installError = msg
val extra = state = State.FAILED
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
}
} }
} }
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() { override fun onCleared() {
super.onCleared() super.onCleared()
app.unregisterReceiver(installBroadcastReceiver)
job.cancel()
location.delete() location.delete()
} }
enum class State(@StringRes val title: Int) { enum class State(@param:StringRes val title: Int) {
CAN_DOWNLOAD(R.string.update_available), CAN_DOWNLOAD(R.string.update_available),
DOWNLOADING(R.string.downloading_manager_update), DOWNLOADING(R.string.downloading_manager_update),
CAN_INSTALL(R.string.ready_to_install_update), CAN_INSTALL(R.string.ready_to_install_update),

View File

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

View File

@@ -2,11 +2,8 @@ package app.revanced.manager.util
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Application import android.app.Application
import android.app.PendingIntent
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.PackageManager.PackageInfoFlags import android.content.pm.PackageManager.PackageInfoFlags
import android.content.pm.PackageManager.NameNotFoundException import android.content.pm.PackageManager.NameNotFoundException
@@ -16,8 +13,6 @@ import android.os.Build
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import app.revanced.manager.domain.repository.PatchBundleRepository 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -25,10 +20,13 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize 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 import java.io.File
private const val byteArraySize = 1024 * 1024 // Because 1,048,576 is not readable
@Immutable @Immutable
@Parcelize @Parcelize
data class AppInfo( data class AppInfo(
@@ -40,7 +38,8 @@ data class AppInfo(
@SuppressLint("QueryPermissionsNeeded") @SuppressLint("QueryPermissionsNeeded")
class PM( class PM(
private val app: Application, private val app: Application,
patchBundleRepository: PatchBundleRepository patchBundleRepository: PatchBundleRepository,
private val uninstaller: PackageUninstaller
) { ) {
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
@@ -145,17 +144,11 @@ class PM(
false false
) )
suspend fun installApp(apks: List<File>) = withContext(Dispatchers.IO) { suspend fun uninstallPackage(pkg: String, config: UninstallParametersDsl.() -> Unit = {}) = withContext(Dispatchers.IO) {
val packageInstaller = app.packageManager.packageInstaller uninstaller.createSession(pkg) {
packageInstaller.openSession(packageInstaller.createSession(sessionParams)).use { session -> confirmation = Confirmation.IMMEDIATE
apks.forEach { apk -> session.writeApk(apk) } config()
session.commit(app.installIntentSender) }.await()
}
}
fun uninstallPackage(pkg: String) {
val packageInstaller = app.packageManager.packageInstaller
packageInstaller.uninstall(pkg, app.uninstallIntentSender)
} }
fun launch(pkg: String) = app.packageManager.getLaunchIntentForPackage(pkg)?.let { fun launch(pkg: String) = app.packageManager.getLaunchIntentForPackage(pkg)?.let {
@@ -164,44 +157,4 @@ class PM(
} }
fun canInstallPackages() = app.packageManager.canRequestPackageInstalls() 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
} }

View File

@@ -33,6 +33,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers 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) { inline fun uiSafe(context: Context, @StringRes toastMsg: Int, logMsg: String, block: () -> Unit) {
try { try {
block() block()
} catch (e: CancellationException) {
throw e
} catch (error: Exception) { } catch (error: Exception) {
// You can only toast on the main thread. // You can only toast on the main thread.
GlobalScope.launch(Dispatchers.Main) { GlobalScope.launch(Dispatchers.Main) {

View File

@@ -37,6 +37,7 @@ kotlin-process = "1.5.1"
hidden-api-stub = "4.3.3" hidden-api-stub = "4.3.3"
binary-compatibility-validator = "0.17.0" binary-compatibility-validator = "0.17.0"
semver-parser = "3.0.0" semver-parser = "3.0.0"
ackpine = "0.18.5"
[libraries] [libraries]
# AndroidX Core # AndroidX Core
@@ -133,6 +134,10 @@ compose-icons-fontawesome = { group = "com.github.BenjaminHalko.compose-icons",
# Semantic versioning parser # Semantic versioning parser
semver-parser = { module = "io.github.z4kn4fein:semver", version.ref = "semver-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] [plugins]
android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" }
android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" }