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" />
</intent-filter>
</activity>
</application>
<service android:name=".installer.service.InstallService" />
<service android:name=".installer.service.UninstallService" />
</application>
</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
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))
}
}
}
}
}
}

View File

@ -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<PatchLog>()
var status by mutableStateOf<Status>(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<PatchLog>()
var status by mutableStateOf<Status>(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 {

View File

@ -52,4 +52,17 @@
<string name="dismiss">Dismiss</string>
<string name="patch">Patch</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>