Compare commits

...

4 Commits

Author SHA1 Message Date
semantic-release-bot
35fb59b31d chore: Release v1.26.0-dev.15 [skip ci]
# app [1.26.0-dev.15](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.14...v1.26.0-dev.15) (2025-12-29)

### Bug Fixes

* install dialog getting stuck ([#2900](https://github.com/ReVanced/revanced-manager/issues/2900)) ([18a4df9](18a4df9af9))
2025-12-29 22:49:24 +00:00
Ax333l
18a4df9af9 fix: install dialog getting stuck (#2900) 2025-12-29 23:42:14 +01:00
semantic-release-bot
bd69b45a69 chore: Release v1.26.0-dev.14 [skip ci]
# app [1.26.0-dev.14](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.13...v1.26.0-dev.14) (2025-12-28)

### Bug Fixes

* Update selected patch count when SelectionState changes ([#2896](https://github.com/ReVanced/revanced-manager/issues/2896)) ([0d26df0](0d26df03f4))
2025-12-28 18:20:22 +00:00
Robert
0d26df03f4 fix: Update selected patch count when SelectionState changes (#2896) 2025-12-28 19:13:00 +01:00
17 changed files with 385 additions and 398 deletions

View File

@@ -1,3 +1,17 @@
# app [1.26.0-dev.15](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.14...v1.26.0-dev.15) (2025-12-29)
### Bug Fixes
* install dialog getting stuck ([#2900](https://github.com/ReVanced/revanced-manager/issues/2900)) ([18a4df9](https://github.com/ReVanced/revanced-manager/commit/18a4df9af9cac120fdb8e4ff7aadd2e2a8d5c1a6))
# app [1.26.0-dev.14](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.13...v1.26.0-dev.14) (2025-12-28)
### Bug Fixes
* Update selected patch count when SelectionState changes ([#2896](https://github.com/ReVanced/revanced-manager/issues/2896)) ([0d26df0](https://github.com/ReVanced/revanced-manager/commit/0d26df03f463195dae550240c7f652680763079c))
# app [1.26.0-dev.13](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.12...v1.26.0-dev.13) (2025-12-17)

View File

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

View File

@@ -1 +1 @@
version = 1.26.0-dev.13
version = 1.26.0-dev.15

View File

@@ -51,9 +51,6 @@
<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
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="specialUse"
@@ -75,5 +72,15 @@
android:value="androidx.startup"
tools:node="remove" />
</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>
</manifest>

View File

@@ -48,7 +48,8 @@ class ManagerApplication : Application() {
workerModule,
viewModelModule,
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
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 ->

View File

@@ -25,6 +25,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -76,12 +77,12 @@ fun SelectedAppInfoScreen(
val bundles by vm.bundleInfoFlow.collectAsStateWithLifecycle(emptyList())
val allowIncompatiblePatches by vm.prefs.disablePatchVersionCompatCheck.getAsState()
val patches = remember(bundles, allowIncompatiblePatches) {
vm.getPatches(bundles, allowIncompatiblePatches)
}
val selectedPatchCount = remember(patches) {
patches.values.sumOf { it.size }
val patches by remember {
derivedStateOf {
vm.getPatches(bundles, allowIncompatiblePatches)
}
}
val selectedPatchCount = patches.values.sumOf { it.size }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),

View File

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

View File

@@ -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<String?>(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<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 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> {
ParcelUuid(workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>(
"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<Boolean>()) {
currentActivityRequest = this to plugin.name
await()
}
if (!accepted) throw UserInteractionException.RequestDenied()
// Launch the activity and wait for the result.
ParcelUuid(
workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>(
"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<ActivityResult>()) {
launchedActivity = this
launchActivityChannel.send(intent)
// 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 {
with(CompletableDeferred<ActivityResult>()) {
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<InstallFailure>) = 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<InstallFailure> -> {
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<UninstallFailure> -> {
result.failure.message?.let(logger::trace)
packageInstallerStatus = result.failure.asCode()
return@launch
}
Session.State.Succeeded -> {}
}
startInstallation(outputFile, pkgName)
}
} finally {
isInstalling = false
}
}
}

View File

@@ -8,7 +8,6 @@ import android.os.Parcelable
import android.util.Log
import androidx.activity.result.ActivityResult
import androidx.annotation.StringRes
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -129,8 +128,6 @@ class SelectedAppInfoViewModel(
}
var options: Options by savedStateHandle.saveable {
val state = mutableStateOf<Options>(emptyMap())
viewModelScope.launch {
if (!persistConfiguration) return@launch // TODO: save options for patched apps.
val bundlePatches = bundleInfoFlow.first()
@@ -141,7 +138,7 @@ class SelectedAppInfoViewModel(
}
}
state
mutableStateOf(emptyMap())
}
private set
@@ -149,8 +146,6 @@ class SelectedAppInfoViewModel(
if (input.patches != null)
return@saveable mutableStateOf(SelectionState.Customized(input.patches))
val selection: MutableState<SelectionState> = mutableStateOf(SelectionState.Default)
// Try to get the previous selection if customization is enabled.
viewModelScope.launch {
if (!prefs.disableSelectionWarning.get()) return@launch
@@ -160,7 +155,7 @@ class SelectedAppInfoViewModel(
selectionState = SelectionState.Customized(previous)
}
selection
mutableStateOf(SelectionState.Default)
}
var showSourceSelector by mutableStateOf(false)
@@ -311,7 +306,7 @@ class SelectedAppInfoViewModel(
}
}
enum class Error(@StringRes val resourceId: Int) {
enum class Error(@param:StringRes val resourceId: Int) {
NoPlugins(R.string.downloader_no_plugins_available)
}

View File

@@ -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<InstallFailure> -> 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),

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.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<File>) = 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
}

View File

@@ -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) {

View File

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