diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c15ec19..e7f3698 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -50,6 +50,8 @@
-
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/installer/service/InstallService.kt b/app/src/main/java/app/revanced/manager/installer/service/InstallService.kt
new file mode 100644
index 0000000..8889672
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/installer/service/InstallService.kt
@@ -0,0 +1,47 @@
+package app.revanced.manager.installer.service
+
+import android.app.Service
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.os.Build
+import android.os.IBinder
+
+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)
+ 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
+ putExtra(EXTRA_INSTALL_STATUS, extraStatus)
+ putExtra(EXTRA_INSTALL_STATUS_MESSAGE, extraStatusMessage)
+ })
+ }
+ }
+ 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"
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/installer/service/UninstallService.kt b/app/src/main/java/app/revanced/manager/installer/service/UninstallService.kt
new file mode 100644
index 0000000..3b1a99e
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/installer/service/UninstallService.kt
@@ -0,0 +1,42 @@
+package app.revanced.manager.installer.service
+
+import android.app.Service
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.os.Build
+import android.os.IBinder
+
+class UninstallService : Service() {
+
+ override fun onStartCommand(
+ intent: Intent,
+ flags: Int,
+ startId: Int
+ ): Int {
+ when (intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999)) {
+ 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_UNINSTALL_ACTION
+ })
+ }
+ }
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ companion object {
+ const val APP_UNINSTALL_ACTION = "APP_UNINSTALL_ACTION"
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/installer/utils/PM.kt b/app/src/main/java/app/revanced/manager/installer/utils/PM.kt
new file mode 100644
index 0000000..8f4580b
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/installer/utils/PM.kt
@@ -0,0 +1,68 @@
+package app.revanced.manager.installer.utils
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.content.pm.PackageManager
+import android.os.Build
+import app.revanced.manager.installer.service.InstallService
+import app.revanced.manager.installer.service.UninstallService
+import java.io.File
+
+private const val byteArraySize = 1024 * 1024 // Because 1,048,576 is not readable
+
+object PM {
+
+ fun installApp(apk: File, context: Context) {
+ val packageInstaller = context.packageManager.packageInstaller
+ val session =
+ packageInstaller.openSession(packageInstaller.createSession(sessionParams))
+ session.writeApk(apk)
+ session.commit(context.installIntentSender)
+ session.close()
+ }
+
+ fun uninstallPackage(pkg: String, context: Context) {
+ val packageInstaller = context.packageManager.packageInstaller
+ packageInstaller.uninstall(pkg, context.uninstallIntentSender)
+ }
+}
+
+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 {
+ 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
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/InstallFailureDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/InstallFailureDialog.kt
new file mode 100644
index 0000000..c43357b
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/InstallFailureDialog.kt
@@ -0,0 +1,48 @@
+package app.revanced.manager.ui.component
+
+import android.content.pm.PackageInstaller
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.*
+import androidx.compose.ui.res.stringResource
+import app.revanced.manager.R
+
+@Composable
+fun InstallFailureDialog(
+ onDismiss: () -> Unit, status: Int, result: String
+) {
+ var showDetails by remember { mutableStateOf(false) }
+
+ val reason = when (status) {
+ PackageInstaller.STATUS_FAILURE_BLOCKED -> stringResource(R.string.status_failure_blocked)
+ PackageInstaller.STATUS_FAILURE_ABORTED -> stringResource(R.string.status_failure_aborted)
+ PackageInstaller.STATUS_FAILURE_CONFLICT -> stringResource(R.string.status_failure_conflict)
+ PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> stringResource(R.string.status_failure_incompatible)
+ PackageInstaller.STATUS_FAILURE_INVALID -> stringResource(R.string.status_failure_invalid)
+ PackageInstaller.STATUS_FAILURE_STORAGE -> stringResource(R.string.status_failure_storage)
+ else -> stringResource(R.string.status_failure)
+ }
+ if (showDetails) {
+ AlertDialog(onDismissRequest = onDismiss, confirmButton = {
+ Button(onClick = { showDetails = false }) {
+ Text(stringResource(R.string.ok))
+ }
+ }, title = { Text(stringResource(R.string.details)) }, text = {
+ Text(result)
+ })
+ } else {
+ AlertDialog(onDismissRequest = onDismiss, confirmButton = {
+ Button(onClick = onDismiss) {
+ Text(stringResource(R.string.ok))
+ }
+ }, dismissButton = {
+ OutlinedButton(onClick = { showDetails = true }) {
+ Text(stringResource(R.string.details))
+ }
+ }, title = { Text(stringResource(R.string.install)) }, text = {
+ Text(reason)
+ })
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/screen/subscreens/PatchingSubscreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/subscreens/PatchingSubscreen.kt
index ba13190..37ac38e 100644
--- a/app/src/main/java/app/revanced/manager/ui/screen/subscreens/PatchingSubscreen.kt
+++ b/app/src/main/java/app/revanced/manager/ui/screen/subscreens/PatchingSubscreen.kt
@@ -1,9 +1,11 @@
package app.revanced.manager.ui.screen.subscreens
import android.annotation.SuppressLint
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
@@ -13,11 +15,15 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import app.revanced.manager.R
+import app.revanced.manager.ui.component.InstallFailureDialog
import app.revanced.manager.ui.viewmodel.PatchingScreenViewModel
import app.revanced.manager.ui.viewmodel.PatchingScreenViewModel.PatchLog
import app.revanced.manager.ui.viewmodel.PatchingScreenViewModel.Status
+import kotlinx.coroutines.launch
import org.koin.androidx.compose.getViewModel
@@ -27,14 +33,10 @@ import org.koin.androidx.compose.getViewModel
fun PatchingSubscreen(
onBackClick: () -> Unit,
vm: PatchingScreenViewModel = getViewModel()
+
) {
- var patching by mutableStateOf(false)
- LaunchedEffect(patching) {
- if (!patching) {
- patching = true
- vm.startPatcher()
- }
- }
+ val scrollState = rememberScrollState()
+ val coroutineScope = rememberCoroutineScope()
Scaffold(
topBar = {
@@ -49,6 +51,13 @@ fun PatchingSubscreen(
}
) { paddingValues ->
Column {
+ if (vm.installFailure) {
+ InstallFailureDialog(
+ onDismiss = { vm.installFailure = false },
+ status = vm.pmStatus,
+ result = vm.extra
+ )
+ }
Column(
modifier =
Modifier
@@ -62,7 +71,6 @@ fun PatchingSubscreen(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
-
when (vm.status) {
is Status.Failure -> {
Icon(
@@ -72,7 +80,7 @@ fun PatchingSubscreen(
.padding(vertical = 16.dp)
.size(30.dp)
)
- Text(text = "Failed!", fontSize = 30.sp)
+ Text(text = stringResource(R.string.failed), fontSize = 30.sp)
}
is Status.Patching -> {
CircularProgressIndicator(
@@ -80,7 +88,7 @@ fun PatchingSubscreen(
.padding(vertical = 16.dp)
.size(30.dp)
)
- Text(text = "Patching...", fontSize = 30.sp)
+ Text(text = stringResource(R.string.patching), fontSize = 30.sp)
}
is Status.Success -> {
Icon(
@@ -90,7 +98,7 @@ fun PatchingSubscreen(
.padding(vertical = 16.dp)
.size(30.dp)
)
- Text(text = "Completed!", fontSize = 30.sp)
+ Text(text = stringResource(R.string.completed), fontSize = 30.sp)
}
Status.Idle -> {}
}
@@ -98,18 +106,23 @@ fun PatchingSubscreen(
}
Column(
Modifier
- .fillMaxWidth()
+ .fillMaxHeight()
.padding(20.dp)
) {
- ElevatedCard {
- LazyColumn(
+ ElevatedCard(
+ Modifier
+ .weight(1f, true)
+ .fillMaxWidth()
+ ) {
+ Column(
Modifier
.padding(horizontal = 20.dp, vertical = 10.dp)
.fillMaxSize()
+ .verticalScroll(scrollState)
) {
- items(vm.logs) { log ->
+ vm.logs.forEach { log ->
Text(
- modifier = Modifier.height(36.dp),
+ modifier = Modifier.requiredHeightIn(min = 36.dp),
text = log.message,
color = when (log) {
is PatchLog.Success -> Color.Green
@@ -117,10 +130,31 @@ fun PatchingSubscreen(
is PatchLog.Error -> Color.Red
},
fontSize = 20.sp,
+ onTextLayout = {
+ coroutineScope.launch {
+ scrollState.animateScrollTo(
+ 9999, tween(1000, easing = LinearEasing)
+ )
+ }
+ }
)
}
}
}
+ if (vm.status is Status.Success) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(4.dp)
+ ) {
+ Spacer(Modifier.weight(1f, true))
+ Button(onClick = {
+ vm.installApk(vm.outputFile)
+ }) {
+ Text(text = stringResource(R.string.install))
+ }
+ }
+ }
}
}
}
diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchingScreenViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchingScreenViewModel.kt
index def5384..0c2f0c4 100644
--- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchingScreenViewModel.kt
+++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchingScreenViewModel.kt
@@ -1,16 +1,24 @@
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.os.Environment
import android.os.PowerManager
import android.util.Log
+import android.view.WindowManager
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import app.revanced.manager.installer.service.InstallService
+import app.revanced.manager.installer.service.UninstallService
+import app.revanced.manager.installer.utils.PM
import app.revanced.manager.network.api.ManagerAPI
import app.revanced.manager.patcher.PatcherUtils
import app.revanced.manager.patcher.aapt.Aapt
@@ -24,7 +32,6 @@ import app.revanced.patcher.PatcherOptions
import app.revanced.patcher.logging.Logger
import io.sentry.Sentry
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
@@ -39,6 +46,11 @@ class PatchingScreenViewModel(
private val patcherUtils: PatcherUtils
) : ViewModel() {
+ var installFailure by mutableStateOf(false)
+
+ var pmStatus by mutableStateOf(-999)
+ var extra by mutableStateOf("")
+
sealed interface PatchLog {
val message: String
@@ -47,9 +59,6 @@ class PatchingScreenViewModel(
data class Error(override val message: String) : PatchLog
}
- val logs = mutableStateListOf()
- var status by mutableStateOf(Status.Idle)
-
sealed class Status {
object Idle : Status()
object Patching : Status()
@@ -57,72 +66,95 @@ class PatchingScreenViewModel(
object Failure : Status()
}
- fun startPatcher() {
- cancelPatching() // cancel patching if its still running
- logs.clear()
- status = Status.Patching
- viewModelScope.launch(Dispatchers.IO) {
- try {
- runPatcher(createWorkDir())
- } catch (e: Exception) {
- status = Status.Failure
- Log.e(tag, "Error while patching: ${e.message ?: e::class.simpleName}")
- Sentry.captureException(e)
+ val outputFile = File(app.filesDir, "output.apk")
+ val logs = mutableStateListOf()
+ var status by mutableStateOf(Status.Idle)
+
+ private val installBroadcastReceiver = object : BroadcastReceiver() {
+
+ override fun onReceive(context: Context?, intent: Intent?) {
+ when (intent?.action) {
+ InstallService.APP_INSTALL_ACTION -> {
+ pmStatus = intent.getIntExtra(InstallService.EXTRA_INSTALL_STATUS, -999)
+ extra = intent.getStringExtra(InstallService.EXTRA_INSTALL_STATUS_MESSAGE)!!
+ postInstallStatus()
+ }
+ UninstallService.APP_UNINSTALL_ACTION -> {
+ }
}
}
}
- private fun cancelPatching() {
- viewModelScope.coroutineContext.cancelChildren(CancellationException("Patching was cancelled by user."))
- logs.clear()
+ init {
+ status = Status.Patching
+ app.registerReceiver(
+ installBroadcastReceiver,
+ IntentFilter().apply {
+ addAction(InstallService.APP_INSTALL_ACTION)
+ addAction(UninstallService.APP_UNINSTALL_ACTION)
+ }
+ )
}
- private suspend fun runPatcher(
- workdir: File
- ) {
+ fun installApk(apk: File) {
+ PM.installApp(apk, app)
+ log(PatchLog.Info("Installing..."))
+ }
+
+ fun postInstallStatus() {
+ if (pmStatus == PackageInstaller.STATUS_SUCCESS) {
+ log(PatchLog.Success("Successfully installed!"))
+ } else {
+ installFailure = true
+ log(PatchLog.Error("Failed to install!"))
+ }
+ }
+
+ private val patcher = viewModelScope.launch(Dispatchers.IO) {
+ val workdir = createWorkDir()
val wakeLock: PowerManager.WakeLock =
(app.getSystemService(Context.POWER_SERVICE) as PowerManager).run {
- newWakeLock(PowerManager.FULL_WAKE_LOCK, "$tag::Patcher").apply {
+ newWakeLock(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, "$tag::Patcher").apply {
acquire(10 * 60 * 1000L)
}
}
Log.d(tag, "Acquired wakelock.")
- val aaptPath = Aapt.binary(app)?.absolutePath
- if (aaptPath == null) {
- log(PatchLog.Error("AAPT2 not found."))
- throw FileNotFoundException()
- }
- val frameworkPath = app.filesDir.resolve("framework").also { it.mkdirs() }.absolutePath
- val integrationsCacheDir = app.filesDir.resolve("integrations-cache").also { it.mkdirs() }
- val reVancedFolder =
- Environment.getExternalStorageDirectory().resolve("ReVanced").also { it.mkdirs() }
- val appInfo = patcherUtils.selectedAppPackage.value.get()
-
- log(PatchLog.Info("Checking prerequisites..."))
- val patches = patcherUtils.findPatchesByIds(patcherUtils.selectedPatches)
- if (patches.isEmpty()) return
-
- log(PatchLog.Info("Creating directories..."))
- val inputFile = File(app.filesDir, "input.apk")
- val patchedFile = File(workdir, "patched.apk")
- val outputFile = File(app.filesDir, "output.apk")
- val cacheDirectory = workdir.resolve("cache")
-
- val integrations = managerAPI.downloadIntegrations(integrationsCacheDir)
-
- log(PatchLog.Info("Copying APK from device..."))
- withContext(Dispatchers.IO) {
- Files.copy(
- File(appInfo.publicSourceDir).toPath(),
- inputFile.toPath(),
- StandardCopyOption.REPLACE_EXISTING
- )
- }
-
try {
+ val aaptPath = Aapt.binary(app)?.absolutePath
+ if (aaptPath == null) {
+ log(PatchLog.Error("AAPT2 not found."))
+ throw FileNotFoundException()
+ }
+ val frameworkPath = app.filesDir.resolve("framework").also { it.mkdirs() }.absolutePath
+ val integrationsCacheDir =
+ app.filesDir.resolve("integrations-cache").also { it.mkdirs() }
+ val reVancedFolder =
+ Environment.getExternalStorageDirectory().resolve("ReVanced").also { it.mkdirs() }
+ val appInfo = patcherUtils.selectedAppPackage.value.get()
+
+ log(PatchLog.Info("Checking prerequisites..."))
+ val patches = patcherUtils.findPatchesByIds(patcherUtils.selectedPatches)
+ if (patches.isEmpty()) throw IllegalStateException("No patches selected.")
+
+ log(PatchLog.Info("Creating directories..."))
+ val inputFile = File(app.filesDir, "input.apk")
+ val patchedFile = File(workdir, "patched.apk")
+ val cacheDirectory = workdir.resolve("cache")
+
+ val integrations = managerAPI.downloadIntegrations(integrationsCacheDir)
+
+ log(PatchLog.Info("Copying APK from device..."))
+ withContext(Dispatchers.IO) {
+ Files.copy(
+ File(appInfo.publicSourceDir).toPath(),
+ inputFile.toPath(),
+ StandardCopyOption.REPLACE_EXISTING
+ )
+ }
log(PatchLog.Info("Decoding resources"))
val patcher = Patcher( // start patcher
- PatcherOptions(inputFile,
+ PatcherOptions(
+ inputFile,
cacheDirectory.absolutePath,
aaptPath = aaptPath,
frameworkFolderLocation = frameworkPath,
@@ -189,12 +221,22 @@ class PatchingScreenViewModel(
log(PatchLog.Success("Successfully patched!"))
patcherUtils.cleanup()
status = Status.Success
- } finally {
- Log.d(tag, "Deleting workdir")
- workdir.deleteRecursively()
- wakeLock.release()
- Log.d(tag, "Released wakelock.")
+ } catch (e: Exception) {
+ status = Status.Failure
+ Log.e(tag, "Error while patching: ${e.message ?: e::class.simpleName}")
+ Sentry.captureException(e)
}
+ Log.d(tag, "Deleting workdir")
+ workdir.deleteRecursively()
+ wakeLock.release()
+ Log.d(tag, "Released wakelock.")
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ app.unregisterReceiver(installBroadcastReceiver)
+ patcher.cancel(CancellationException("ViewModel cleared"))
+ logs.clear()
}
private fun createWorkDir(): File {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 40bc086..b4c9c9e 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -52,4 +52,17 @@
Dismiss
Patch
No compatible patches found.
+ Installation failed due to installer being blocked by the operating system.
+ Installation failed due to an unknown error.
+ Installation failed due to conflicting packages. Please uninstall the conflicting app before retrying installation.
+ Installation failed due to user abortion.
+ Installation failed due to the app you\'re trying to install being incompatible with current device.
+ Installation failed due to package being invalid. This might be caused by unsigned or corrupted package.
+ Installation failed due to insufficient storage.
+ Details
+ Install
+ OK
+ Patching…
+ Completed!
+ Failed!
\ No newline at end of file