feat: root installation (#1243)

This commit is contained in:
Robert 2023-09-09 15:18:00 +02:00 committed by GitHub
parent b4dfcf1bb4
commit bf10af2ae2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 681 additions and 126 deletions

View File

@ -66,6 +66,7 @@ android {
}
buildFeatures.compose = true
buildFeatures.aidl = true
composeOptions.kotlinCompilerExtensionVersion = "1.5.1"
}
@ -124,6 +125,10 @@ dependencies {
implementation(libs.apksign)
implementation(libs.bcpkix.jdk18on)
implementation(libs.libsu.core)
implementation(libs.libsu.service)
implementation(libs.libsu.nio)
// Koin
implementation(libs.koin.android)
implementation(libs.koin.compose)

View File

@ -0,0 +1,8 @@
// IRootService.aidl
package app.revanced.manager;
// Declare any non-default types here with import statements
interface IRootSystemService {
IBinder getFileSystemService();
}

View File

@ -0,0 +1,6 @@
id=__PKG_NAME__-ReVanced
name=__LABEL__ ReVanced
version=__VERSION__
versionCode=0
author=ReVanced
description=Mounts the patched apk on top of the original apk

View File

@ -0,0 +1,40 @@
#!/system/bin/sh
DIR=${0%/*}
package_name="__PKG_NAME__"
version="__VERSION__"
rm "$DIR/log"
{
until [ "$(getprop sys.boot_completed)" = 1 ]; do sleep 5; done
sleep 5
base_path="$DIR/$package_name.apk"
stock_path="$(pm path "$package_name" | grep base | sed 's/package://g')"
stock_version="$(dumpsys package "$package_name" | grep versionName | cut -d "=" -f2)"
echo "base_path: $base_path"
echo "stock_path: $stock_path"
echo "base_version: $version"
echo "stock_version: $stock_version"
if mount | grep -q "$stock_path" ; then
echo "Not mounting as stock path is already mounted"
exit 1
fi
if [ "$version" != "$stock_version" ]; then
echo "Not mounting as versions don't match"
exit 1
fi
if [ -z "$stock_path" ]; then
echo "Not mounting as app info could not be loaded"
exit 1
fi
mount -o bind "$base_path" "$stock_path"
} >> "$DIR/log"

View File

@ -34,10 +34,10 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val vm: MainViewModel = getActivityViewModel()
installSplashScreen()
val vm: MainViewModel = getActivityViewModel()
setContent {
val theme by vm.prefs.theme.getAsState()
val dynamicColor by vm.prefs.dynamicColor.getAsState()

View File

@ -1,16 +1,23 @@
package app.revanced.manager
import android.app.Application
import android.content.Intent
import app.revanced.manager.di.*
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.service.ManagerRootService
import app.revanced.manager.service.RootConnection
import kotlinx.coroutines.Dispatchers
import coil.Coil
import coil.ImageLoader
import com.topjohnwu.superuser.Shell
import com.topjohnwu.superuser.internal.BuilderImpl
import com.topjohnwu.superuser.ipc.RootService
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import me.zhanghai.android.appiconloader.coil.AppIconFetcher
import me.zhanghai.android.appiconloader.coil.AppIconKeyer
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
@ -37,6 +44,7 @@ class ManagerApplication : Application() {
workerModule,
viewModelModule,
databaseModule,
rootModule
)
}
@ -50,6 +58,12 @@ class ManagerApplication : Application() {
.build()
)
val shellBuilder = BuilderImpl.create().setFlags(Shell.FLAG_MOUNT_MASTER)
Shell.setDefaultBuilder(shellBuilder)
val intent = Intent(this, ManagerRootService::class.java)
RootService.bind(intent, get<RootConnection>())
scope.launch {
prefs.preload()
}

View File

@ -0,0 +1,11 @@
package app.revanced.manager.di
import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.service.RootConnection
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val rootModule = module {
singleOf(::RootConnection)
singleOf(::RootInstaller)
}

View File

@ -0,0 +1,131 @@
package app.revanced.manager.domain.installer
import android.app.Application
import app.revanced.manager.service.RootConnection
import app.revanced.manager.util.PM
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
class RootInstaller(
private val app: Application,
private val rootConnection: RootConnection,
private val pm: PM
) {
fun hasRootAccess() = Shell.isAppGrantedRoot() ?: false
fun isAppInstalled(packageName: String) =
rootConnection.remoteFS?.getFile("$modulesPath/$packageName-revanced")
?.exists() ?: throw RootServiceException()
fun isAppMounted(packageName: String): Boolean {
return pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir?.let {
Shell.cmd("mount | grep \"$it\"").exec().isSuccess
} ?: false
}
fun mount(packageName: String) {
if (isAppMounted(packageName)) return
val stockAPK = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir
?: throw Exception("Failed to load application info")
val patchedAPK = "$modulesPath/$packageName-revanced/$packageName.apk"
Shell.cmd("mount -o bind \"$patchedAPK\" \"$stockAPK\"").exec()
.also { if (!it.isSuccess) throw Exception("Failed to mount APK") }
}
fun unmount(packageName: String) {
if (!isAppMounted(packageName)) return
val stockAPK = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir
?: throw Exception("Failed to load application info")
Shell.cmd("umount -l \"$stockAPK\"").exec()
.also { if (!it.isSuccess) throw Exception("Failed to unmount APK") }
}
suspend fun install(
patchedAPK: File,
stockAPK: File?,
packageName: String,
version: String,
label: String
) {
withContext(Dispatchers.IO) {
rootConnection.remoteFS?.let { remoteFS ->
val assets = app.assets
val modulePath = "$modulesPath/$packageName-revanced"
unmount(packageName)
stockAPK?.let { stockApp ->
pm.getPackageInfo(packageName)?.let { packageInfo ->
if (packageInfo.versionName <= version)
Shell.cmd("pm uninstall -k --user 0 $packageName").exec()
.also { if (!it.isSuccess) throw Exception("Failed to uninstall stock app") }
}
Shell.cmd("pm install \"${stockApp.absolutePath}\"").exec()
.also { if (!it.isSuccess) throw Exception("Failed to install stock app") }
}
remoteFS.getFile(modulePath).mkdir()
listOf(
"service.sh",
"module.prop",
).forEach { file ->
assets.open("root/$file").use { inputStream ->
remoteFS.getFile("$modulePath/$file").newOutputStream()
.use { outputStream ->
val content = String(inputStream.readBytes())
.replace("__PKG_NAME__", packageName)
.replace("__VERSION__", version)
.replace("__LABEL__", label)
.toByteArray()
outputStream.write(content)
}
}
}
"$modulePath/$packageName.apk".let { apkPath ->
remoteFS.getFile(patchedAPK.absolutePath)
.also { if (!it.exists()) throw Exception("File doesn't exist") }
.newInputStream().use { inputStream ->
remoteFS.getFile(apkPath).newOutputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
}
Shell.cmd(
"chmod 644 $apkPath",
"chown system:system $apkPath",
"chcon u:object_r:apk_data_file:s0 $apkPath",
"chmod +x $modulePath/service.sh"
).exec()
.let { if (!it.isSuccess) throw Exception("Failed to set file permissions") }
}
} ?: throw RootServiceException()
}
}
fun uninstall(packageName: String) {
rootConnection.remoteFS?.let { remoteFS ->
if (isAppMounted(packageName))
unmount(packageName)
remoteFS.getFile("$modulesPath/$packageName-revanced").deleteRecursively()
.also { if (!it) throw Exception("Failed to delete files") }
} ?: throw RootServiceException()
}
companion object {
const val modulesPath = "/data/adb/modules"
}
}
class RootServiceException: Exception("Root not available")

View File

@ -14,8 +14,11 @@ import androidx.core.content.ContextCompat
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.worker.Worker
import app.revanced.manager.domain.worker.WorkerRepository
@ -29,7 +32,6 @@ import app.revanced.manager.util.PatchesSelection
import app.revanced.manager.util.tag
import app.revanced.patcher.extensions.PatchExtensions.options
import app.revanced.patcher.extensions.PatchExtensions.patchName
import app.revanced.patcher.logging.Logger
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
@ -49,6 +51,8 @@ class PatcherWorker(
private val prefs: PreferencesManager by inject()
private val downloadedAppRepository: DownloadedAppRepository by inject()
private val pm: PM by inject()
private val installedAppRepository: InstalledAppRepository by inject()
private val rootInstaller: RootInstaller by inject()
data class Args(
val input: SelectedApp,
@ -58,7 +62,9 @@ class PatcherWorker(
val packageName: String,
val packageVersion: String,
val progress: MutableStateFlow<ImmutableList<Step>>,
val logger: ManagerLogger
val logger: ManagerLogger,
val selectedApp: SelectedApp,
val setInputFile: (File) -> Unit
)
companion object {
@ -148,6 +154,15 @@ class PatcherWorker(
}
return try {
if (args.selectedApp is SelectedApp.Installed) {
installedAppRepository.get(args.packageName)?.let {
if (it.installType == InstallType.ROOT) {
rootInstaller.unmount(args.packageName)
}
}
}
// TODO: consider passing all the classes directly now that the input no longer needs to be serializable.
val selectedBundles = args.selectedPatches.keys
val allPatches = bundles.filterKeys { selectedBundles.contains(it) }
@ -190,11 +205,12 @@ class PatcherWorker(
args.input.version,
it
)
args.setInputFile(it)
updateProgress() // Downloading
}
}
is SelectedApp.Local -> selectedApp.file
is SelectedApp.Local -> selectedApp.file.also { args.setInputFile(it) }
is SelectedApp.Installed -> File(pm.getPackageInfo(selectedApp.packageName)!!.applicationInfo.sourceDir)
}

View File

@ -0,0 +1,36 @@
package app.revanced.manager.service
import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import app.revanced.manager.IRootSystemService
import com.topjohnwu.superuser.ipc.RootService
import com.topjohnwu.superuser.nio.FileSystemManager
class ManagerRootService : RootService() {
class RootSystemService : IRootSystemService.Stub() {
override fun getFileSystemService() =
FileSystemManager.getService()
}
override fun onBind(intent: Intent): IBinder {
return RootSystemService()
}
}
class RootConnection : ServiceConnection {
var remoteFS: FileSystemManager? = null
private set
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val ipc = IRootSystemService.Stub.asInterface(service)
val binder = ipc.fileSystemService
remoteFS = FileSystemManager.getRemote(binder)
}
override fun onServiceDisconnected(name: ComponentName?) {
remoteFS = null
}
}

View File

@ -9,10 +9,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
@ -26,43 +28,53 @@ import androidx.compose.ui.unit.dp
@Composable
fun RowScope.SegmentedButton(
icon: Any,
iconDescription: String? = null,
text: String,
onClick: () -> Unit
onClick: () -> Unit,
iconDescription: String? = null,
enabled: Boolean = true
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
modifier = Modifier
.clickable(onClick = onClick)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp))
.weight(1f)
.padding(vertical = 20.dp)
) {
when (icon) {
is ImageVector -> {
Icon(
imageVector = icon,
contentDescription = iconDescription,
tint = MaterialTheme.colorScheme.primary
val contentColor = if (enabled)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurface.copy(0.38f)
CompositionLocalProvider(LocalContentColor provides contentColor) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
modifier = Modifier
.clickable(enabled = enabled, onClick = onClick)
.background(
if (enabled)
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
else
MaterialTheme.colorScheme.onSurface.copy(0.12f)
)
.weight(1f)
.padding(vertical = 20.dp)
) {
when (icon) {
is ImageVector -> {
Icon(
imageVector = icon,
contentDescription = iconDescription
)
}
is Painter -> {
Icon(
painter = icon,
contentDescription = iconDescription
)
}
}
is Painter -> {
Icon(
painter = icon,
contentDescription = iconDescription,
tint = MaterialTheme.colorScheme.primary
)
}
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
modifier = Modifier.basicMarquee()
)
}
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
modifier = Modifier.basicMarquee()
)
}
}

View File

@ -13,17 +13,25 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowRight
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.material.icons.outlined.SettingsBackupRestore
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -31,6 +39,7 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.ui.component.AppIcon
import app.revanced.manager.ui.component.AppLabel
import app.revanced.manager.ui.component.AppTopBar
@ -49,6 +58,14 @@ fun AppInfoScreen(
viewModel.onBackClick = onBackClick
}
var showUninstallDialog by rememberSaveable { mutableStateOf(false) }
if (showUninstallDialog)
UninstallDialog(
onDismiss = { showUninstallDialog = false },
onConfirm = { viewModel.uninstall() }
)
Scaffold(
topBar = {
AppTopBar(
@ -84,6 +101,17 @@ fun AppInfoScreen(
)
Text(viewModel.installedApp.version, style = MaterialTheme.typography.bodySmall)
if (viewModel.installedApp.installType == InstallType.ROOT) {
Text(
text = if (viewModel.rootInstaller.isAppMounted(viewModel.installedApp.currentPackageName)) {
stringResource(R.string.mounted)
} else {
stringResource(R.string.not_mounted)
},
style = MaterialTheme.typography.bodySmall
)
}
}
Row(
@ -98,11 +126,30 @@ fun AppInfoScreen(
onClick = viewModel::launch
)
SegmentedButton(
icon = Icons.Outlined.Delete,
text = stringResource(R.string.uninstall),
onClick = viewModel::uninstall
)
when (viewModel.installedApp.installType) {
InstallType.DEFAULT -> SegmentedButton(
icon = Icons.Outlined.Delete,
text = stringResource(R.string.uninstall),
onClick = viewModel::uninstall
)
InstallType.ROOT -> {
SegmentedButton(
icon = Icons.Outlined.SettingsBackupRestore,
text = stringResource(R.string.unpatch),
onClick = { showUninstallDialog = true },
enabled = viewModel.rootInstaller.hasRootAccess()
)
SegmentedButton(
icon = Icons.Outlined.Circle,
text = if (viewModel.isMounted) stringResource(R.string.unmount) else stringResource(R.string.mount),
onClick = viewModel::mountOrUnmount,
enabled = viewModel.rootInstaller.hasRootAccess()
)
}
}
SegmentedButton(
icon = Icons.Outlined.Update,
@ -111,7 +158,8 @@ fun AppInfoScreen(
viewModel.appliedPatches?.let {
onPatchClick(viewModel.installedApp.originalPackageName, it)
}
}
},
enabled = viewModel.installedApp.installType != InstallType.ROOT || viewModel.rootInstaller.hasRootAccess()
)
}
@ -155,4 +203,31 @@ fun AppInfoScreen(
}
}
}
}
@Composable
fun UninstallDialog(
onDismiss: () -> Unit,
onConfirm: () -> Unit
) = AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.unpatch_app)) },
text = { Text(stringResource(R.string.unpatch_description)) },
confirmButton = {
TextButton(
onClick = {
onConfirm()
onDismiss()
}
) {
Text(stringResource(R.string.ok))
}
},
dismissButton = {
TextButton(
onClick = onDismiss
) {
Text(stringResource(R.string.cancel))
}
}
)

View File

@ -5,6 +5,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@ -35,6 +36,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.patcher.worker.Step
import app.revanced.manager.patcher.worker.State
import app.revanced.manager.ui.component.AppScaffold
@ -59,6 +61,13 @@ fun InstallerScreen(
val steps by vm.progress.collectAsStateWithLifecycle()
val canInstall by remember { derivedStateOf { patcherState == true && (vm.installedPackageName != null || !vm.isInstalling) } }
var dropdownActive by rememberSaveable { mutableStateOf(false) }
var showInstallPicker by rememberSaveable { mutableStateOf(false) }
if (showInstallPicker)
InstallPicker(
onDismiss = { showInstallPicker = false },
onConfirm = { vm.install(it) }
)
AppScaffold(
topBar = {
@ -111,7 +120,12 @@ fun InstallerScreen(
}
Button(
onClick = vm::installOrOpen,
onClick = {
if (vm.installedPackageName == null)
showInstallPicker = true
else
vm.open()
},
enabled = canInstall
) {
Text(stringResource(vm.appButtonText))
@ -121,6 +135,51 @@ fun InstallerScreen(
}
}
@Composable
fun InstallPicker(
onDismiss: () -> Unit,
onConfirm: (InstallType) -> Unit
) {
var selectedInstallType by rememberSaveable { mutableStateOf(InstallType.DEFAULT) }
AlertDialog(
onDismissRequest = onDismiss,
dismissButton = {
Button(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
},
confirmButton = {
Button(
onClick = {
onConfirm(selectedInstallType)
onDismiss()
}
) {
Text(stringResource(R.string.install_app))
}
},
title = { Text(stringResource(R.string.select_install_type)) },
text = {
Column {
InstallType.values().forEach {
ListItem(
modifier = Modifier.clickable { selectedInstallType = it },
leadingContent = {
RadioButton(
selected = selectedInstallType == it,
onClick = null
)
},
headlineContent = { Text(stringResource(it.stringResource)) }
)
}
}
}
)
}
// Credits: https://github.com/Aliucord/AliucordManager/blob/main/app/src/main/kotlin/com/aliucord/manager/ui/component/installer/InstallGroup.kt
@Composable

View File

@ -33,6 +33,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.component.LoadingIndicator
@ -78,7 +79,7 @@ fun VersionSelectorScreen(
},
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text("Select version") },
text = { Text(stringResource(R.string.select_version)) },
icon = { Icon(Icons.Default.Check, null) },
onClick = { selectedVersion?.let(onAppClick) }
)
@ -90,7 +91,7 @@ fun VersionSelectorScreen(
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
viewModel.installedApp?.let { (packageInfo, alreadyPatched) ->
viewModel.installedApp?.let { (packageInfo, installedApp) ->
SelectedApp.Installed(
packageName = viewModel.packageName,
version = packageInfo.versionName
@ -100,12 +101,14 @@ fun VersionSelectorScreen(
selected = selectedVersion == it,
onClick = { selectedVersion = it },
patchCount = supportedVersions[it.version],
alreadyPatched = alreadyPatched
enabled =
!(installedApp?.installType == InstallType.ROOT && !viewModel.rootInstaller.hasRootAccess()),
alreadyPatched = installedApp != null && installedApp.installType != InstallType.ROOT
)
}
}
GroupHeader("Downloadable versions")
GroupHeader(stringResource(R.string.downloadable_versions))
list.forEach {
SelectedAppItem(
@ -140,6 +143,7 @@ fun SelectedAppItem(
selected: Boolean,
onClick: () -> Unit,
patchCount: Int?,
enabled: Boolean = true,
alreadyPatched: Boolean = false
) {
ListItem(
@ -148,9 +152,9 @@ fun SelectedAppItem(
supportingContent = when (selectedApp) {
is SelectedApp.Installed ->
if (alreadyPatched) {
{ Text("Already patched") }
{ Text(stringResource(R.string.already_patched)) }
} else {
{ Text("Installed") }
{ Text(stringResource(R.string.installed)) }
}
is SelectedApp.Local -> {
@ -163,9 +167,9 @@ fun SelectedAppItem(
Text(pluralStringResource(R.plurals.patches_count, it, it))
} },
modifier = Modifier
.clickable(enabled = !alreadyPatched, onClick = onClick)
.clickable(enabled = !alreadyPatched && enabled, onClick = onClick)
.run {
if (alreadyPatched) alpha(0.5f)
if (!enabled || alreadyPatched) alpha(0.5f)
else this
}
)

View File

@ -7,6 +7,7 @@ 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
@ -15,10 +16,13 @@ import androidx.lifecycle.viewModelScope
import app.revanced.manager.R
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.PatchesSelection
import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -32,20 +36,46 @@ class AppInfoViewModel(
private val app: Application by inject()
private val pm: PM by inject()
private val installedAppRepository: InstalledAppRepository by inject()
val rootInstaller: RootInstaller by inject()
lateinit var onBackClick: () -> Unit
var appInfo: PackageInfo? by mutableStateOf(null)
private set
var appliedPatches: PatchesSelection? by mutableStateOf(null)
var isMounted by mutableStateOf(rootInstaller.isAppMounted(installedApp.currentPackageName))
private set
fun launch() = pm.launch(installedApp.currentPackageName)
fun mountOrUnmount() {
try {
if (isMounted)
rootInstaller.unmount(installedApp.currentPackageName)
else
rootInstaller.mount(installedApp.currentPackageName)
} catch (e: Exception) {
if (isMounted) {
app.toast(app.getString(R.string.failed_to_unmount, e.simpleMessage()))
Log.e(tag, "Failed to unmount", e)
} else {
app.toast(app.getString(R.string.failed_to_mount, e.simpleMessage()))
Log.e(tag, "Failed to mount", e)
}
} finally {
isMounted = rootInstaller.isAppMounted(installedApp.currentPackageName)
}
}
fun uninstall() {
when (installedApp.installType) {
InstallType.DEFAULT -> pm.uninstallPackage(installedApp.currentPackageName)
InstallType.ROOT -> TODO()
InstallType.ROOT -> viewModelScope.launch {
rootInstaller.uninstall(installedApp.currentPackageName)
installedAppRepository.delete(installedApp)
onBackClick()
}
}
}
@ -59,7 +89,7 @@ class AppInfoViewModel(
if (extraStatus == PackageInstaller.STATUS_SUCCESS) {
viewModelScope.launch {
installedAppRepository.delete(installedApp)
withContext(Dispatchers.Main) { onBackClick() }
onBackClick()
}
} else if (extraStatus != PackageInstaller.STATUS_FAILURE_ABORTED) {
app.toast(app.getString(R.string.uninstall_app_fail, extraStatusMessage))

View File

@ -11,22 +11,19 @@ import java.nio.file.Files
class AppSelectorViewModel(
private val app: Application,
pm: PM
private val pm: PM
) : ViewModel() {
private val packageManager = app.packageManager
val appList = pm.appList
fun loadLabel(app: PackageInfo?) = (app?.applicationInfo?.loadLabel(packageManager) ?: "Not installed").toString()
fun loadLabel(app: PackageInfo?) = with(pm) { app?.label() ?: "Not installed" }
@Suppress("DEPRECATION")
fun loadSelectedFile(uri: Uri) =
app.contentResolver.openInputStream(uri)?.use { stream ->
File(app.cacheDir, "input.apk").also {
it.delete()
Files.copy(stream, it.toPath())
}.let { file ->
packageManager.getPackageArchiveInfo(file.absolutePath, 0)
pm.getPackageInfo(file)
?.let { packageInfo ->
SelectedApp.Local(packageName = packageInfo.packageName, version = packageInfo.versionName, file = file)
}

View File

@ -4,6 +4,9 @@ import android.content.pm.PackageInfo
import androidx.compose.runtime.mutableStateMapOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.domain.installer.RootServiceException
import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.util.PM
import app.revanced.manager.util.collectEach
@ -14,7 +17,8 @@ import kotlinx.coroutines.withContext
class InstalledAppsViewModel(
private val installedAppsRepository: InstalledAppRepository,
private val pm: PM
private val pm: PM,
private val rootInstaller: RootInstaller
) : ViewModel() {
val apps = installedAppsRepository.getAll().flowOn(Dispatchers.IO)
@ -24,8 +28,23 @@ class InstalledAppsViewModel(
viewModelScope.launch {
apps.collectEach { installedApp ->
packageInfoMap[installedApp.currentPackageName] = withContext(Dispatchers.IO) {
pm.getPackageInfo(installedApp.currentPackageName)
.also { if (it == null) installedAppsRepository.delete(installedApp) }
try {
if (
installedApp.installType == InstallType.ROOT && !rootInstaller.isAppInstalled(installedApp.currentPackageName)
) {
installedAppsRepository.delete(installedApp)
return@withContext null
}
} catch (_: RootServiceException) { }
val packageInfo = pm.getPackageInfo(installedApp.currentPackageName)
if (packageInfo == null && installedApp.installType != InstallType.ROOT) {
installedAppsRepository.delete(installedApp)
return@withContext null
}
packageInfo
}
}
}

View File

@ -20,6 +20,8 @@ import androidx.work.WorkInfo
import androidx.work.WorkManager
import app.revanced.manager.R
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.manager.KeystoreManager
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.worker.WorkerRepository
@ -29,7 +31,9 @@ import app.revanced.manager.patcher.worker.Step
import app.revanced.manager.service.InstallService
import app.revanced.manager.service.UninstallService
import app.revanced.manager.ui.destination.Destination
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.PM
import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast
import kotlinx.collections.immutable.ImmutableList
@ -48,18 +52,23 @@ import java.util.logging.Level
import java.util.logging.LogRecord
@Stable
class InstallerViewModel(input: Destination.Installer) : ViewModel(), KoinComponent {
class InstallerViewModel(
private val input: Destination.Installer
) : ViewModel(), KoinComponent {
private val keystoreManager: KeystoreManager by inject()
private val app: Application by inject()
private val pm: PM by inject()
private val workerRepository: WorkerRepository by inject()
private val installedAppReceiver: InstalledAppRepository by inject()
private val installedAppRepository: InstalledAppRepository by inject()
private val rootInstaller: RootInstaller by inject()
val packageName: String = input.selectedApp.packageName
private val outputFile = File(app.cacheDir, "output.apk")
private val signedFile = File(app.cacheDir, "signed.apk").also { if (it.exists()) it.delete() }
private var hasSigned = false
var inputFile: File? = null
private var installedApp: InstalledApp? = null
var isInstalling by mutableStateOf(false)
private set
var installedPackageName by mutableStateOf<String?>(null)
@ -73,13 +82,18 @@ class InstallerViewModel(input: Destination.Installer) : ViewModel(), KoinCompon
private val logger = ManagerLogger()
init {
viewModelScope.launch {
installedApp = installedAppRepository.get(packageName)
}
val (selectedApp, patches, options) = input
_progress = MutableStateFlow(PatcherProgressManager.generateSteps(
app,
patches.flatMap { (_, selected) -> selected },
input.selectedApp
selectedApp
).toImmutableList())
patcherWorkerId =
workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>(
"patching", PatcherWorker.Args(
@ -90,7 +104,9 @@ class InstallerViewModel(input: Destination.Installer) : ViewModel(), KoinCompon
packageName,
selectedApp.version,
_progress,
logger
logger,
selectedApp,
setInputFile = { inputFile = it }
)
)
}
@ -118,7 +134,7 @@ class InstallerViewModel(input: Destination.Installer) : ViewModel(), KoinCompon
installedPackageName =
intent.getStringExtra(InstallService.EXTRA_PACKAGE_NAME)
viewModelScope.launch {
installedAppReceiver.add(
installedAppRepository.add(
installedPackageName!!,
packageName,
input.selectedApp.version,
@ -162,6 +178,19 @@ class InstallerViewModel(input: Destination.Installer) : ViewModel(), KoinCompon
outputFile.delete()
signedFile.delete()
try {
if (input.selectedApp is SelectedApp.Installed) {
installedApp?.let {
if (it.installType == InstallType.ROOT) {
rootInstaller.mount(packageName)
}
}
}
} catch (e: Exception) {
Log.e(tag, "Failed to mount", e)
app.toast(app.getString(R.string.failed_to_mount, e.simpleMessage()))
}
}
private suspend fun signApk(): Boolean {
@ -192,20 +221,62 @@ class InstallerViewModel(input: Destination.Installer) : ViewModel(), KoinCompon
}
}
fun installOrOpen() = viewModelScope.launch {
installedPackageName?.let {
pm.launch(it)
return@launch
}
fun install(installType: InstallType) = viewModelScope.launch {
isInstalling = true
try {
if (!signApk()) return@launch
pm.installApp(listOf(signedFile))
when (installType) {
InstallType.DEFAULT -> { pm.installApp(listOf(signedFile)) }
InstallType.ROOT -> { installAsRoot() }
}
} finally {
isInstalling = false
}
}
fun open() = installedPackageName?.let { pm.launch(it) }
private suspend fun installAsRoot() {
try {
val label = with(pm) {
getPackageInfo(signedFile)?.label()
?: throw Exception("Failed to load application info")
}
rootInstaller.install(
outputFile,
inputFile,
packageName,
input.selectedApp.version,
label
)
rootInstaller.mount(packageName)
installedApp?.let { installedAppRepository.delete(it) }
installedAppRepository.add(
packageName,
packageName,
input.selectedApp.version,
InstallType.ROOT,
input.selectedPatches
)
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) { }
}
}
}
// TODO: move this to a better place

View File

@ -7,7 +7,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.DownloadedAppRepository
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
@ -35,8 +36,9 @@ class VersionSelectorViewModel(
private val patchBundleRepository: PatchBundleRepository by inject()
private val pm: PM by inject()
private val appDownloader: AppDownloader = APKMirror()
val rootInstaller: RootInstaller by inject()
var installedApp: Pair<PackageInfo, Boolean>? by mutableStateOf(null)
var installedApp: Pair<PackageInfo, InstalledApp?>? by mutableStateOf(null)
private set
var isLoading by mutableStateOf(true)
private set
@ -72,15 +74,11 @@ class VersionSelectorViewModel(
init {
viewModelScope.launch(Dispatchers.Main) {
val packageInfo = async(Dispatchers.IO) { pm.getPackageInfo(packageName) }
val alreadyPatched = async(Dispatchers.IO) {
installedAppRepository.get(packageName)
?.let { it.installType == InstallType.DEFAULT }
?: false
}
val installedAppDeferred = async(Dispatchers.IO) { installedAppRepository.get(packageName) }
installedApp =
packageInfo.await()?.let {
it to alreadyPatched.await()
it to installedAppDeferred.await()
}
}

View File

@ -32,8 +32,7 @@ private const val byteArraySize = 1024 * 1024 // Because 1,048,576 is not readab
data class AppInfo(
val packageName: String,
val patches: Int?,
val packageInfo: PackageInfo?,
val path: File? = null
val packageInfo: PackageInfo?
) : Parcelable
@SuppressLint("QueryPermissionsNeeded")
@ -57,8 +56,7 @@ class PM(
AppInfo(
pkg,
compatiblePackages[pkg],
packageInfo,
File(packageInfo.applicationInfo.sourceDir)
packageInfo
)
} ?: AppInfo(
pkg,
@ -73,8 +71,7 @@ class PM(
AppInfo(
packageInfo.packageName,
0,
packageInfo,
File(packageInfo.applicationInfo.sourceDir)
packageInfo
)
}
}
@ -85,9 +82,13 @@ class PM(
.sortedWith(
compareByDescending<AppInfo> {
it.patches
}.thenBy { it.packageInfo?.applicationInfo?.loadLabel(app.packageManager).toString() }.thenBy { it.packageName }
}.thenBy {
it.packageInfo?.label()
}.thenBy { it.packageName }
)
} else { emptyList() }
} else {
emptyList()
}
}.flowOn(Dispatchers.IO)
fun getPackageInfo(packageName: String): PackageInfo? =
@ -97,6 +98,10 @@ class PM(
null
}
fun getPackageInfo(file: File): PackageInfo? = app.packageManager.getPackageArchiveInfo(file.absolutePath, 0)
fun PackageInfo.label() = this.applicationInfo.loadLabel(app.packageManager).toString()
suspend fun installApp(apks: List<File>) = withContext(Dispatchers.IO) {
val packageInstaller = app.packageManager.packageInstaller
packageInstaller.openSession(packageInstaller.createSession(sessionParams)).use { session ->
@ -114,42 +119,42 @@ class PM(
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
app.startActivity(it)
}
}
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 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 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 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.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
private val Context.uninstallIntentSender
get() = PendingIntent.getService(
this,
0,
Intent(this, UninstallService::class.java),
intentFlags
).intentSender
}

View File

@ -23,8 +23,6 @@
<string name="bundle_missing">Missing</string>
<string name="bundle_error">Error</string>
<string name="select_version">Select version</string>
<string name="auto_updates_dialog_title">Select updates to receive</string>
<string name="auto_updates_dialog_description">Periodically connect to update providers to check for updates.</string>
@ -156,9 +154,11 @@
<string name="failed_to_load_apk">Failed to load apk</string>
<string name="loading">Loading…</string>
<string name="not_installed">Not installed</string>
<string name="installed">Installed</string>
<string name="app_info">App info</string>
<string name="uninstall">Uninstall</string>
<string name="unpatch">Unpatch</string>
<string name="repatch">Repatch</string>
<string name="install_type">Installation type</string>
<string name="package_name">Package name</string>
@ -167,9 +167,20 @@
<string name="view_applied_patches">View applied patches</string>
<string name="default_install">Default</string>
<string name="root_install">Root</string>
<string name="mounted">Mounted</string>
<string name="not_mounted">Not mounted</string>
<string name="mount">Mount</string>
<string name="unmount">Unmount</string>
<string name="failed_to_mount">Failed to mount: %s</string>
<string name="failed_to_unmount">Failed to unmount: %s</string>
<string name="unpatch_app">Unpatch app?</string>
<string name="unpatch_description">Are you sure you want to unpatch this app?</string>
<string name="error_occurred">An error occurred</string>
<string name="already_downloaded">Already downloaded</string>
<string name="select_version">Select version</string>
<string name="downloadable_versions">Downloadable versions</string>
<string name="already_patched">Already patched</string>
<string name="string_option_icon_description">Edit</string>
<string name="string_option_menu_description">More options</string>
@ -193,6 +204,7 @@
<string name="export_app_success">Apk exported</string>
<string name="sign_fail">Failed to sign Apk: %s</string>
<string name="save_logs">Save logs</string>
<string name="select_install_type">Select installation type</string>
<string name="patcher_step_group_prepare">Preparation</string>
<string name="patcher_step_load_patches">Load patches</string>

View File

@ -26,6 +26,7 @@ aboutLibrariesGradlePlugin = "10.8.2"
coil = "2.4.0"
app-icon-loader-coil = "1.5.0"
skrapeit = "1.2.1"
libsu = "5.2.0"
[libraries]
# AndroidX Core
@ -98,6 +99,11 @@ skrapeit-parser = { group = "it.skrape", name = "skrapeit-html-parser", version.
# Markdown
markdown = { group = "org.jetbrains", name = "markdown", version.ref = "markdown" }
# LibSU
libsu-core = { group = "com.github.topjohnwu.libsu", name = "core", version.ref = "libsu" }
libsu-service = { group = "com.github.topjohnwu.libsu", name = "service", version.ref = "libsu" }
libsu-nio = { group = "com.github.topjohnwu.libsu", name = "nio", version.ref = "libsu" }
[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlinGradlePlugin" }