From cf49b4949421b77252c60ef446bf2a3611f91831 Mon Sep 17 00:00:00 2001 From: Canny <94744045+Canny1913@users.noreply.github.com> Date: Sat, 26 Nov 2022 22:48:13 +0300 Subject: [PATCH] feat: app installation (#15) --- app/src/main/AndroidManifest.xml | 4 +- .../installer/service/InstallService.kt | 47 +++++ .../installer/service/UninstallService.kt | 42 +++++ .../revanced/manager/installer/utils/PM.kt | 68 ++++++++ .../ui/component/InstallFailureDialog.kt | 48 ++++++ .../ui/screen/subscreens/PatchingSubscreen.kt | 70 ++++++-- .../ui/viewmodel/PatchingScreenViewModel.kt | 162 +++++++++++------- app/src/main/res/values/strings.xml | 13 ++ 8 files changed, 375 insertions(+), 79 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/installer/service/InstallService.kt create mode 100644 app/src/main/java/app/revanced/manager/installer/service/UninstallService.kt create mode 100644 app/src/main/java/app/revanced/manager/installer/utils/PM.kt create mode 100644 app/src/main/java/app/revanced/manager/ui/component/InstallFailureDialog.kt 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