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.compose = true
buildFeatures.aidl = true
composeOptions.kotlinCompilerExtensionVersion = "1.5.1" composeOptions.kotlinCompilerExtensionVersion = "1.5.1"
} }
@ -124,6 +125,10 @@ dependencies {
implementation(libs.apksign) implementation(libs.apksign)
implementation(libs.bcpkix.jdk18on) implementation(libs.bcpkix.jdk18on)
implementation(libs.libsu.core)
implementation(libs.libsu.service)
implementation(libs.libsu.nio)
// Koin // Koin
implementation(libs.koin.android) implementation(libs.koin.android)
implementation(libs.koin.compose) 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val vm: MainViewModel = getActivityViewModel()
installSplashScreen() installSplashScreen()
val vm: MainViewModel = getActivityViewModel()
setContent { setContent {
val theme by vm.prefs.theme.getAsState() val theme by vm.prefs.theme.getAsState()
val dynamicColor by vm.prefs.dynamicColor.getAsState() val dynamicColor by vm.prefs.dynamicColor.getAsState()

View File

@ -1,16 +1,23 @@
package app.revanced.manager package app.revanced.manager
import android.app.Application import android.app.Application
import android.content.Intent
import app.revanced.manager.di.* import app.revanced.manager.di.*
import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.service.ManagerRootService
import app.revanced.manager.service.RootConnection
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import coil.Coil import coil.Coil
import coil.ImageLoader 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.MainScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.zhanghai.android.appiconloader.coil.AppIconFetcher import me.zhanghai.android.appiconloader.coil.AppIconFetcher
import me.zhanghai.android.appiconloader.coil.AppIconKeyer 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.android.inject
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger import org.koin.android.ext.koin.androidLogger
@ -37,6 +44,7 @@ class ManagerApplication : Application() {
workerModule, workerModule,
viewModelModule, viewModelModule,
databaseModule, databaseModule,
rootModule
) )
} }
@ -50,6 +58,12 @@ class ManagerApplication : Application() {
.build() .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 { scope.launch {
prefs.preload() 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.ForegroundInfo
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import app.revanced.manager.R 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.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository 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.repository.PatchBundleRepository
import app.revanced.manager.domain.worker.Worker import app.revanced.manager.domain.worker.Worker
import app.revanced.manager.domain.worker.WorkerRepository 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.manager.util.tag
import app.revanced.patcher.extensions.PatchExtensions.options import app.revanced.patcher.extensions.PatchExtensions.options
import app.revanced.patcher.extensions.PatchExtensions.patchName import app.revanced.patcher.extensions.PatchExtensions.patchName
import app.revanced.patcher.logging.Logger
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -49,6 +51,8 @@ class PatcherWorker(
private val prefs: PreferencesManager by inject() private val prefs: PreferencesManager by inject()
private val downloadedAppRepository: DownloadedAppRepository by inject() private val downloadedAppRepository: DownloadedAppRepository by inject()
private val pm: PM by inject() private val pm: PM by inject()
private val installedAppRepository: InstalledAppRepository by inject()
private val rootInstaller: RootInstaller by inject()
data class Args( data class Args(
val input: SelectedApp, val input: SelectedApp,
@ -58,7 +62,9 @@ class PatcherWorker(
val packageName: String, val packageName: String,
val packageVersion: String, val packageVersion: String,
val progress: MutableStateFlow<ImmutableList<Step>>, val progress: MutableStateFlow<ImmutableList<Step>>,
val logger: ManagerLogger val logger: ManagerLogger,
val selectedApp: SelectedApp,
val setInputFile: (File) -> Unit
) )
companion object { companion object {
@ -148,6 +154,15 @@ class PatcherWorker(
} }
return try { 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. // TODO: consider passing all the classes directly now that the input no longer needs to be serializable.
val selectedBundles = args.selectedPatches.keys val selectedBundles = args.selectedPatches.keys
val allPatches = bundles.filterKeys { selectedBundles.contains(it) } val allPatches = bundles.filterKeys { selectedBundles.contains(it) }
@ -190,11 +205,12 @@ class PatcherWorker(
args.input.version, args.input.version,
it it
) )
args.setInputFile(it)
updateProgress() // Downloading 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) 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.RowScope
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.painter.Painter import androidx.compose.ui.graphics.painter.Painter
@ -26,16 +28,28 @@ import androidx.compose.ui.unit.dp
@Composable @Composable
fun RowScope.SegmentedButton( fun RowScope.SegmentedButton(
icon: Any, icon: Any,
iconDescription: String? = null,
text: String, text: String,
onClick: () -> Unit onClick: () -> Unit,
iconDescription: String? = null,
enabled: Boolean = true
) { ) {
val contentColor = if (enabled)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurface.copy(0.38f)
CompositionLocalProvider(LocalContentColor provides contentColor) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
modifier = Modifier modifier = Modifier
.clickable(onClick = onClick) .clickable(enabled = enabled, onClick = onClick)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)) .background(
if (enabled)
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
else
MaterialTheme.colorScheme.onSurface.copy(0.12f)
)
.weight(1f) .weight(1f)
.padding(vertical = 20.dp) .padding(vertical = 20.dp)
) { ) {
@ -43,16 +57,14 @@ fun RowScope.SegmentedButton(
is ImageVector -> { is ImageVector -> {
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = iconDescription, contentDescription = iconDescription
tint = MaterialTheme.colorScheme.primary
) )
} }
is Painter -> { is Painter -> {
Icon( Icon(
painter = icon, painter = icon,
contentDescription = iconDescription, contentDescription = iconDescription
tint = MaterialTheme.colorScheme.primary
) )
} }
} }
@ -60,9 +72,9 @@ fun RowScope.SegmentedButton(
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
maxLines = 1, maxLines = 1,
modifier = Modifier.basicMarquee() modifier = Modifier.basicMarquee()
) )
} }
}
} }

View File

@ -13,17 +13,25 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowRight 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.Delete
import androidx.compose.material.icons.outlined.OpenInNew import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.material.icons.outlined.SettingsBackupRestore
import androidx.compose.material.icons.outlined.Update import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.revanced.manager.R 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.AppIcon
import app.revanced.manager.ui.component.AppLabel import app.revanced.manager.ui.component.AppLabel
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
@ -49,6 +58,14 @@ fun AppInfoScreen(
viewModel.onBackClick = onBackClick viewModel.onBackClick = onBackClick
} }
var showUninstallDialog by rememberSaveable { mutableStateOf(false) }
if (showUninstallDialog)
UninstallDialog(
onDismiss = { showUninstallDialog = false },
onConfirm = { viewModel.uninstall() }
)
Scaffold( Scaffold(
topBar = { topBar = {
AppTopBar( AppTopBar(
@ -84,6 +101,17 @@ fun AppInfoScreen(
) )
Text(viewModel.installedApp.version, style = MaterialTheme.typography.bodySmall) 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( Row(
@ -98,12 +126,31 @@ fun AppInfoScreen(
onClick = viewModel::launch onClick = viewModel::launch
) )
SegmentedButton( when (viewModel.installedApp.installType) {
InstallType.DEFAULT -> SegmentedButton(
icon = Icons.Outlined.Delete, icon = Icons.Outlined.Delete,
text = stringResource(R.string.uninstall), text = stringResource(R.string.uninstall),
onClick = viewModel::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( SegmentedButton(
icon = Icons.Outlined.Update, icon = Icons.Outlined.Update,
text = stringResource(R.string.repatch), text = stringResource(R.string.repatch),
@ -111,7 +158,8 @@ fun AppInfoScreen(
viewModel.appliedPatches?.let { viewModel.appliedPatches?.let {
onPatchClick(viewModel.installedApp.originalPackageName, it) onPatchClick(viewModel.installedApp.originalPackageName, it)
} }
} },
enabled = viewModel.installedApp.installType != InstallType.ROOT || viewModel.rootInstaller.hasRootAccess()
) )
} }
@ -156,3 +204,30 @@ 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.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -35,6 +36,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R 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.Step
import app.revanced.manager.patcher.worker.State import app.revanced.manager.patcher.worker.State
import app.revanced.manager.ui.component.AppScaffold import app.revanced.manager.ui.component.AppScaffold
@ -59,6 +61,13 @@ fun InstallerScreen(
val steps by vm.progress.collectAsStateWithLifecycle() val steps by vm.progress.collectAsStateWithLifecycle()
val canInstall by remember { derivedStateOf { patcherState == true && (vm.installedPackageName != null || !vm.isInstalling) } } val canInstall by remember { derivedStateOf { patcherState == true && (vm.installedPackageName != null || !vm.isInstalling) } }
var dropdownActive by rememberSaveable { mutableStateOf(false) } var dropdownActive by rememberSaveable { mutableStateOf(false) }
var showInstallPicker by rememberSaveable { mutableStateOf(false) }
if (showInstallPicker)
InstallPicker(
onDismiss = { showInstallPicker = false },
onConfirm = { vm.install(it) }
)
AppScaffold( AppScaffold(
topBar = { topBar = {
@ -111,7 +120,12 @@ fun InstallerScreen(
} }
Button( Button(
onClick = vm::installOrOpen, onClick = {
if (vm.installedPackageName == null)
showInstallPicker = true
else
vm.open()
},
enabled = canInstall enabled = canInstall
) { ) {
Text(stringResource(vm.appButtonText)) 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 // Credits: https://github.com/Aliucord/AliucordManager/blob/main/app/src/main/kotlin/com/aliucord/manager/ui/component/installer/InstallGroup.kt
@Composable @Composable

View File

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

View File

@ -7,6 +7,7 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.util.Log
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -15,10 +16,13 @@ import androidx.lifecycle.viewModelScope
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.data.room.apps.installed.InstalledApp 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.domain.repository.InstalledAppRepository
import app.revanced.manager.service.UninstallService import app.revanced.manager.service.UninstallService
import app.revanced.manager.util.PM import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchesSelection 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 app.revanced.manager.util.toast
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -32,20 +36,46 @@ class AppInfoViewModel(
private val app: Application by inject() private val app: Application by inject()
private val pm: PM by inject() private val pm: PM by inject()
private val installedAppRepository: InstalledAppRepository by inject() private val installedAppRepository: InstalledAppRepository by inject()
val rootInstaller: RootInstaller by inject()
lateinit var onBackClick: () -> Unit lateinit var onBackClick: () -> Unit
var appInfo: PackageInfo? by mutableStateOf(null) var appInfo: PackageInfo? by mutableStateOf(null)
private set private set
var appliedPatches: PatchesSelection? by mutableStateOf(null) var appliedPatches: PatchesSelection? by mutableStateOf(null)
var isMounted by mutableStateOf(rootInstaller.isAppMounted(installedApp.currentPackageName))
private set
fun launch() = pm.launch(installedApp.currentPackageName) 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() { fun uninstall() {
when (installedApp.installType) { when (installedApp.installType) {
InstallType.DEFAULT -> pm.uninstallPackage(installedApp.currentPackageName) 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) { if (extraStatus == PackageInstaller.STATUS_SUCCESS) {
viewModelScope.launch { viewModelScope.launch {
installedAppRepository.delete(installedApp) installedAppRepository.delete(installedApp)
withContext(Dispatchers.Main) { onBackClick() } onBackClick()
} }
} else if (extraStatus != PackageInstaller.STATUS_FAILURE_ABORTED) { } else if (extraStatus != PackageInstaller.STATUS_FAILURE_ABORTED) {
app.toast(app.getString(R.string.uninstall_app_fail, extraStatusMessage)) app.toast(app.getString(R.string.uninstall_app_fail, extraStatusMessage))

View File

@ -11,22 +11,19 @@ import java.nio.file.Files
class AppSelectorViewModel( class AppSelectorViewModel(
private val app: Application, private val app: Application,
pm: PM private val pm: PM
) : ViewModel() { ) : ViewModel() {
private val packageManager = app.packageManager
val appList = pm.appList 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) = fun loadSelectedFile(uri: Uri) =
app.contentResolver.openInputStream(uri)?.use { stream -> app.contentResolver.openInputStream(uri)?.use { stream ->
File(app.cacheDir, "input.apk").also { File(app.cacheDir, "input.apk").also {
it.delete() it.delete()
Files.copy(stream, it.toPath()) Files.copy(stream, it.toPath())
}.let { file -> }.let { file ->
packageManager.getPackageArchiveInfo(file.absolutePath, 0) pm.getPackageInfo(file)
?.let { packageInfo -> ?.let { packageInfo ->
SelectedApp.Local(packageName = packageInfo.packageName, version = packageInfo.versionName, file = file) 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.compose.runtime.mutableStateMapOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.domain.repository.InstalledAppRepository
import app.revanced.manager.util.PM import app.revanced.manager.util.PM
import app.revanced.manager.util.collectEach import app.revanced.manager.util.collectEach
@ -14,7 +17,8 @@ import kotlinx.coroutines.withContext
class InstalledAppsViewModel( class InstalledAppsViewModel(
private val installedAppsRepository: InstalledAppRepository, private val installedAppsRepository: InstalledAppRepository,
private val pm: PM private val pm: PM,
private val rootInstaller: RootInstaller
) : ViewModel() { ) : ViewModel() {
val apps = installedAppsRepository.getAll().flowOn(Dispatchers.IO) val apps = installedAppsRepository.getAll().flowOn(Dispatchers.IO)
@ -24,8 +28,23 @@ class InstalledAppsViewModel(
viewModelScope.launch { viewModelScope.launch {
apps.collectEach { installedApp -> apps.collectEach { installedApp ->
packageInfoMap[installedApp.currentPackageName] = withContext(Dispatchers.IO) { packageInfoMap[installedApp.currentPackageName] = withContext(Dispatchers.IO) {
pm.getPackageInfo(installedApp.currentPackageName) try {
.also { if (it == null) installedAppsRepository.delete(installedApp) } 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 androidx.work.WorkManager
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType 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.manager.KeystoreManager
import app.revanced.manager.domain.repository.InstalledAppRepository import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.worker.WorkerRepository 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.InstallService
import app.revanced.manager.service.UninstallService import app.revanced.manager.service.UninstallService
import app.revanced.manager.ui.destination.Destination 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.PM
import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag import app.revanced.manager.util.tag
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -48,18 +52,23 @@ import java.util.logging.Level
import java.util.logging.LogRecord import java.util.logging.LogRecord
@Stable @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 keystoreManager: KeystoreManager by inject()
private val app: Application by inject() private val app: Application by inject()
private val pm: PM by inject() private val pm: PM by inject()
private val workerRepository: WorkerRepository 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 val packageName: String = input.selectedApp.packageName
private val outputFile = File(app.cacheDir, "output.apk") private val outputFile = File(app.cacheDir, "output.apk")
private val signedFile = File(app.cacheDir, "signed.apk").also { if (it.exists()) it.delete() } private val signedFile = File(app.cacheDir, "signed.apk").also { if (it.exists()) it.delete() }
private var hasSigned = false private var hasSigned = false
var inputFile: File? = null
private var installedApp: InstalledApp? = null
var isInstalling by mutableStateOf(false) var isInstalling by mutableStateOf(false)
private set private set
var installedPackageName by mutableStateOf<String?>(null) var installedPackageName by mutableStateOf<String?>(null)
@ -73,13 +82,18 @@ class InstallerViewModel(input: Destination.Installer) : ViewModel(), KoinCompon
private val logger = ManagerLogger() private val logger = ManagerLogger()
init { init {
viewModelScope.launch {
installedApp = installedAppRepository.get(packageName)
}
val (selectedApp, patches, options) = input val (selectedApp, patches, options) = input
_progress = MutableStateFlow(PatcherProgressManager.generateSteps( _progress = MutableStateFlow(PatcherProgressManager.generateSteps(
app, app,
patches.flatMap { (_, selected) -> selected }, patches.flatMap { (_, selected) -> selected },
input.selectedApp selectedApp
).toImmutableList()) ).toImmutableList())
patcherWorkerId = patcherWorkerId =
workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>( workerRepository.launchExpedited<PatcherWorker, PatcherWorker.Args>(
"patching", PatcherWorker.Args( "patching", PatcherWorker.Args(
@ -90,7 +104,9 @@ class InstallerViewModel(input: Destination.Installer) : ViewModel(), KoinCompon
packageName, packageName,
selectedApp.version, selectedApp.version,
_progress, _progress,
logger logger,
selectedApp,
setInputFile = { inputFile = it }
) )
) )
} }
@ -118,7 +134,7 @@ class InstallerViewModel(input: Destination.Installer) : ViewModel(), KoinCompon
installedPackageName = installedPackageName =
intent.getStringExtra(InstallService.EXTRA_PACKAGE_NAME) intent.getStringExtra(InstallService.EXTRA_PACKAGE_NAME)
viewModelScope.launch { viewModelScope.launch {
installedAppReceiver.add( installedAppRepository.add(
installedPackageName!!, installedPackageName!!,
packageName, packageName,
input.selectedApp.version, input.selectedApp.version,
@ -162,6 +178,19 @@ class InstallerViewModel(input: Destination.Installer) : ViewModel(), KoinCompon
outputFile.delete() outputFile.delete()
signedFile.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 { private suspend fun signApk(): Boolean {
@ -192,20 +221,62 @@ class InstallerViewModel(input: Destination.Installer) : ViewModel(), KoinCompon
} }
} }
fun installOrOpen() = viewModelScope.launch { fun install(installType: InstallType) = viewModelScope.launch {
installedPackageName?.let {
pm.launch(it)
return@launch
}
isInstalling = true isInstalling = true
try { try {
if (!signApk()) return@launch if (!signApk()) return@launch
pm.installApp(listOf(signedFile))
when (installType) {
InstallType.DEFAULT -> { pm.installApp(listOf(signedFile)) }
InstallType.ROOT -> { installAsRoot() }
}
} finally { } finally {
isInstalling = false 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 // 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.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.DownloadedAppRepository
import app.revanced.manager.domain.repository.InstalledAppRepository import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.PatchBundleRepository
@ -35,8 +36,9 @@ class VersionSelectorViewModel(
private val patchBundleRepository: PatchBundleRepository by inject() private val patchBundleRepository: PatchBundleRepository by inject()
private val pm: PM by inject() private val pm: PM by inject()
private val appDownloader: AppDownloader = APKMirror() 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 private set
var isLoading by mutableStateOf(true) var isLoading by mutableStateOf(true)
private set private set
@ -72,15 +74,11 @@ class VersionSelectorViewModel(
init { init {
viewModelScope.launch(Dispatchers.Main) { viewModelScope.launch(Dispatchers.Main) {
val packageInfo = async(Dispatchers.IO) { pm.getPackageInfo(packageName) } val packageInfo = async(Dispatchers.IO) { pm.getPackageInfo(packageName) }
val alreadyPatched = async(Dispatchers.IO) { val installedAppDeferred = async(Dispatchers.IO) { installedAppRepository.get(packageName) }
installedAppRepository.get(packageName)
?.let { it.installType == InstallType.DEFAULT }
?: false
}
installedApp = installedApp =
packageInfo.await()?.let { 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( data class AppInfo(
val packageName: String, val packageName: String,
val patches: Int?, val patches: Int?,
val packageInfo: PackageInfo?, val packageInfo: PackageInfo?
val path: File? = null
) : Parcelable ) : Parcelable
@SuppressLint("QueryPermissionsNeeded") @SuppressLint("QueryPermissionsNeeded")
@ -57,8 +56,7 @@ class PM(
AppInfo( AppInfo(
pkg, pkg,
compatiblePackages[pkg], compatiblePackages[pkg],
packageInfo, packageInfo
File(packageInfo.applicationInfo.sourceDir)
) )
} ?: AppInfo( } ?: AppInfo(
pkg, pkg,
@ -73,8 +71,7 @@ class PM(
AppInfo( AppInfo(
packageInfo.packageName, packageInfo.packageName,
0, 0,
packageInfo, packageInfo
File(packageInfo.applicationInfo.sourceDir)
) )
} }
} }
@ -85,9 +82,13 @@ class PM(
.sortedWith( .sortedWith(
compareByDescending<AppInfo> { compareByDescending<AppInfo> {
it.patches 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) }.flowOn(Dispatchers.IO)
fun getPackageInfo(packageName: String): PackageInfo? = fun getPackageInfo(packageName: String): PackageInfo? =
@ -97,6 +98,10 @@ class PM(
null 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) { suspend fun installApp(apks: List<File>) = withContext(Dispatchers.IO) {
val packageInstaller = app.packageManager.packageInstaller val packageInstaller = app.packageManager.packageInstaller
packageInstaller.openSession(packageInstaller.createSession(sessionParams)).use { session -> packageInstaller.openSession(packageInstaller.createSession(sessionParams)).use { session ->
@ -114,31 +119,30 @@ class PM(
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
app.startActivity(it) app.startActivity(it)
} }
}
private fun PackageInstaller.Session.writeApk(apk: File) { private fun PackageInstaller.Session.writeApk(apk: File) {
apk.inputStream().use { inputStream -> apk.inputStream().use { inputStream ->
openWrite(apk.name, 0, apk.length()).use { outputStream -> openWrite(apk.name, 0, apk.length()).use { outputStream ->
inputStream.copyTo(outputStream, byteArraySize) inputStream.copyTo(outputStream, byteArraySize)
fsync(outputStream) fsync(outputStream)
} }
} }
} }
private val intentFlags private val intentFlags
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
PendingIntent.FLAG_MUTABLE PendingIntent.FLAG_MUTABLE
else else
0 0
private val sessionParams private val sessionParams
get() = PackageInstaller.SessionParams( get() = PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL PackageInstaller.SessionParams.MODE_FULL_INSTALL
).apply { ).apply {
setInstallReason(PackageManager.INSTALL_REASON_USER) setInstallReason(PackageManager.INSTALL_REASON_USER)
} }
private val Context.installIntentSender private val Context.installIntentSender
get() = PendingIntent.getService( get() = PendingIntent.getService(
this, this,
0, 0,
@ -146,10 +150,11 @@ private val Context.installIntentSender
intentFlags intentFlags
).intentSender ).intentSender
private val Context.uninstallIntentSender private val Context.uninstallIntentSender
get() = PendingIntent.getService( get() = PendingIntent.getService(
this, this,
0, 0,
Intent(this, UninstallService::class.java), Intent(this, UninstallService::class.java),
intentFlags intentFlags
).intentSender ).intentSender
}

View File

@ -24,8 +24,6 @@
<string name="bundle_missing">Missing</string> <string name="bundle_missing">Missing</string>
<string name="bundle_error">Error</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_title">Select updates to receive</string>
<string name="auto_updates_dialog_description">Periodically connect to update providers to check for updates.</string> <string name="auto_updates_dialog_description">Periodically connect to update providers to check for updates.</string>
<string name="auto_updates_dialog_manager">Manager updates</string> <string name="auto_updates_dialog_manager">Manager updates</string>
@ -156,9 +154,11 @@
<string name="failed_to_load_apk">Failed to load apk</string> <string name="failed_to_load_apk">Failed to load apk</string>
<string name="loading">Loading…</string> <string name="loading">Loading…</string>
<string name="not_installed">Not installed</string> <string name="not_installed">Not installed</string>
<string name="installed">Installed</string>
<string name="app_info">App info</string> <string name="app_info">App info</string>
<string name="uninstall">Uninstall</string> <string name="uninstall">Uninstall</string>
<string name="unpatch">Unpatch</string>
<string name="repatch">Repatch</string> <string name="repatch">Repatch</string>
<string name="install_type">Installation type</string> <string name="install_type">Installation type</string>
<string name="package_name">Package name</string> <string name="package_name">Package name</string>
@ -167,9 +167,20 @@
<string name="view_applied_patches">View applied patches</string> <string name="view_applied_patches">View applied patches</string>
<string name="default_install">Default</string> <string name="default_install">Default</string>
<string name="root_install">Root</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="error_occurred">An error occurred</string>
<string name="already_downloaded">Already downloaded</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_icon_description">Edit</string>
<string name="string_option_menu_description">More options</string> <string name="string_option_menu_description">More options</string>
@ -193,6 +204,7 @@
<string name="export_app_success">Apk exported</string> <string name="export_app_success">Apk exported</string>
<string name="sign_fail">Failed to sign Apk: %s</string> <string name="sign_fail">Failed to sign Apk: %s</string>
<string name="save_logs">Save logs</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_group_prepare">Preparation</string>
<string name="patcher_step_load_patches">Load patches</string> <string name="patcher_step_load_patches">Load patches</string>

View File

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