feat: app installation (#15)

This commit is contained in:
Canny 2022-11-26 22:48:13 +03:00 committed by GitHub
parent 0adf6c1eaa
commit cf49b49494
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 375 additions and 79 deletions

View File

@ -50,6 +50,8 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
</application>
<service android:name=".installer.service.InstallService" />
<service android:name=".installer.service.UninstallService" />
</application>
</manifest> </manifest>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,11 @@
package app.revanced.manager.ui.screen.subscreens package app.revanced.manager.ui.screen.subscreens
import android.annotation.SuppressLint 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.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
@ -13,11 +15,15 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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
import app.revanced.manager.ui.viewmodel.PatchingScreenViewModel.PatchLog import app.revanced.manager.ui.viewmodel.PatchingScreenViewModel.PatchLog
import app.revanced.manager.ui.viewmodel.PatchingScreenViewModel.Status import app.revanced.manager.ui.viewmodel.PatchingScreenViewModel.Status
import kotlinx.coroutines.launch
import org.koin.androidx.compose.getViewModel import org.koin.androidx.compose.getViewModel
@ -27,14 +33,10 @@ import org.koin.androidx.compose.getViewModel
fun PatchingSubscreen( fun PatchingSubscreen(
onBackClick: () -> Unit, onBackClick: () -> Unit,
vm: PatchingScreenViewModel = getViewModel() vm: PatchingScreenViewModel = getViewModel()
) { ) {
var patching by mutableStateOf(false) val scrollState = rememberScrollState()
LaunchedEffect(patching) { val coroutineScope = rememberCoroutineScope()
if (!patching) {
patching = true
vm.startPatcher()
}
}
Scaffold( Scaffold(
topBar = { topBar = {
@ -49,6 +51,13 @@ fun PatchingSubscreen(
} }
) { paddingValues -> ) { paddingValues ->
Column { Column {
if (vm.installFailure) {
InstallFailureDialog(
onDismiss = { vm.installFailure = false },
status = vm.pmStatus,
result = vm.extra
)
}
Column( Column(
modifier = modifier =
Modifier Modifier
@ -62,7 +71,6 @@ fun PatchingSubscreen(
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
when (vm.status) { when (vm.status) {
is Status.Failure -> { is Status.Failure -> {
Icon( Icon(
@ -72,7 +80,7 @@ fun PatchingSubscreen(
.padding(vertical = 16.dp) .padding(vertical = 16.dp)
.size(30.dp) .size(30.dp)
) )
Text(text = "Failed!", fontSize = 30.sp) Text(text = stringResource(R.string.failed), fontSize = 30.sp)
} }
is Status.Patching -> { is Status.Patching -> {
CircularProgressIndicator( CircularProgressIndicator(
@ -80,7 +88,7 @@ fun PatchingSubscreen(
.padding(vertical = 16.dp) .padding(vertical = 16.dp)
.size(30.dp) .size(30.dp)
) )
Text(text = "Patching...", fontSize = 30.sp) Text(text = stringResource(R.string.patching), fontSize = 30.sp)
} }
is Status.Success -> { is Status.Success -> {
Icon( Icon(
@ -90,7 +98,7 @@ fun PatchingSubscreen(
.padding(vertical = 16.dp) .padding(vertical = 16.dp)
.size(30.dp) .size(30.dp)
) )
Text(text = "Completed!", fontSize = 30.sp) Text(text = stringResource(R.string.completed), fontSize = 30.sp)
} }
Status.Idle -> {} Status.Idle -> {}
} }
@ -98,18 +106,23 @@ fun PatchingSubscreen(
} }
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxHeight()
.padding(20.dp) .padding(20.dp)
) { ) {
ElevatedCard { ElevatedCard(
LazyColumn( Modifier
.weight(1f, true)
.fillMaxWidth()
) {
Column(
Modifier Modifier
.padding(horizontal = 20.dp, vertical = 10.dp) .padding(horizontal = 20.dp, vertical = 10.dp)
.fillMaxSize() .fillMaxSize()
.verticalScroll(scrollState)
) { ) {
items(vm.logs) { log -> vm.logs.forEach { log ->
Text( Text(
modifier = Modifier.height(36.dp), modifier = Modifier.requiredHeightIn(min = 36.dp),
text = log.message, text = log.message,
color = when (log) { color = when (log) {
is PatchLog.Success -> Color.Green is PatchLog.Success -> Color.Green
@ -117,10 +130,31 @@ fun PatchingSubscreen(
is PatchLog.Error -> Color.Red is PatchLog.Error -> Color.Red
}, },
fontSize = 20.sp, 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))
}
}
}
} }
} }
} }

View File

@ -1,16 +1,24 @@
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.IntentFilter
import android.content.pm.PackageInstaller
import android.os.Environment import android.os.Environment
import android.os.PowerManager import android.os.PowerManager
import android.util.Log import android.util.Log
import android.view.WindowManager
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.network.api.ManagerAPI
import app.revanced.manager.patcher.PatcherUtils import app.revanced.manager.patcher.PatcherUtils
import app.revanced.manager.patcher.aapt.Aapt import app.revanced.manager.patcher.aapt.Aapt
@ -24,7 +32,6 @@ import app.revanced.patcher.PatcherOptions
import app.revanced.patcher.logging.Logger import app.revanced.patcher.logging.Logger
import io.sentry.Sentry import io.sentry.Sentry
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
@ -39,6 +46,11 @@ class PatchingScreenViewModel(
private val patcherUtils: PatcherUtils private val patcherUtils: PatcherUtils
) : ViewModel() { ) : ViewModel() {
var installFailure by mutableStateOf(false)
var pmStatus by mutableStateOf(-999)
var extra by mutableStateOf("")
sealed interface PatchLog { sealed interface PatchLog {
val message: String val message: String
@ -47,9 +59,6 @@ class PatchingScreenViewModel(
data class Error(override val message: String) : PatchLog data class Error(override val message: String) : PatchLog
} }
val logs = mutableStateListOf<PatchLog>()
var status by mutableStateOf<Status>(Status.Idle)
sealed class Status { sealed class Status {
object Idle : Status() object Idle : Status()
object Patching : Status() object Patching : Status()
@ -57,72 +66,95 @@ class PatchingScreenViewModel(
object Failure : Status() object Failure : Status()
} }
fun startPatcher() { val outputFile = File(app.filesDir, "output.apk")
cancelPatching() // cancel patching if its still running val logs = mutableStateListOf<PatchLog>()
logs.clear() var status by mutableStateOf<Status>(Status.Idle)
status = Status.Patching
viewModelScope.launch(Dispatchers.IO) { private val installBroadcastReceiver = object : BroadcastReceiver() {
try {
runPatcher(createWorkDir()) override fun onReceive(context: Context?, intent: Intent?) {
} catch (e: Exception) { when (intent?.action) {
status = Status.Failure InstallService.APP_INSTALL_ACTION -> {
Log.e(tag, "Error while patching: ${e.message ?: e::class.simpleName}") pmStatus = intent.getIntExtra(InstallService.EXTRA_INSTALL_STATUS, -999)
Sentry.captureException(e) extra = intent.getStringExtra(InstallService.EXTRA_INSTALL_STATUS_MESSAGE)!!
postInstallStatus()
}
UninstallService.APP_UNINSTALL_ACTION -> {
}
} }
} }
} }
private fun cancelPatching() { init {
viewModelScope.coroutineContext.cancelChildren(CancellationException("Patching was cancelled by user.")) status = Status.Patching
logs.clear() app.registerReceiver(
installBroadcastReceiver,
IntentFilter().apply {
addAction(InstallService.APP_INSTALL_ACTION)
addAction(UninstallService.APP_UNINSTALL_ACTION)
}
)
} }
private suspend fun runPatcher( fun installApk(apk: File) {
workdir: 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 = val wakeLock: PowerManager.WakeLock =
(app.getSystemService(Context.POWER_SERVICE) as PowerManager).run { (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) acquire(10 * 60 * 1000L)
} }
} }
Log.d(tag, "Acquired wakelock.") 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 { 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")) log(PatchLog.Info("Decoding resources"))
val patcher = Patcher( // start patcher val patcher = Patcher( // start patcher
PatcherOptions(inputFile, PatcherOptions(
inputFile,
cacheDirectory.absolutePath, cacheDirectory.absolutePath,
aaptPath = aaptPath, aaptPath = aaptPath,
frameworkFolderLocation = frameworkPath, frameworkFolderLocation = frameworkPath,
@ -189,12 +221,22 @@ class PatchingScreenViewModel(
log(PatchLog.Success("Successfully patched!")) log(PatchLog.Success("Successfully patched!"))
patcherUtils.cleanup() patcherUtils.cleanup()
status = Status.Success status = Status.Success
} finally { } catch (e: Exception) {
Log.d(tag, "Deleting workdir") status = Status.Failure
workdir.deleteRecursively() Log.e(tag, "Error while patching: ${e.message ?: e::class.simpleName}")
wakeLock.release() Sentry.captureException(e)
Log.d(tag, "Released wakelock.")
} }
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 { private fun createWorkDir(): File {

View File

@ -52,4 +52,17 @@
<string name="dismiss">Dismiss</string> <string name="dismiss">Dismiss</string>
<string name="patch">Patch</string> <string name="patch">Patch</string>
<string name="no_compatible_patches">No compatible patches found.</string> <string name="no_compatible_patches">No compatible patches found.</string>
<string name="status_failure_blocked">Installation failed due to installer being blocked by the operating system.</string>
<string name="status_failure">Installation failed due to an unknown error.</string>
<string name="status_failure_conflict">Installation failed due to conflicting packages. Please uninstall the conflicting app before retrying installation.</string>
<string name="status_failure_aborted">Installation failed due to user abortion.</string>
<string name="status_failure_incompatible">Installation failed due to the app you\'re trying to install being incompatible with current device.</string>
<string name="status_failure_invalid">Installation failed due to package being invalid. This might be caused by unsigned or corrupted package.</string>
<string name="status_failure_storage">Installation failed due to insufficient storage.</string>
<string name="details">Details</string>
<string name="install">Install</string>
<string name="ok">OK</string>
<string name="patching">Patching…</string>
<string name="completed">Completed!</string>
<string name="failed">Failed!</string>
</resources> </resources>