diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3e18d3e472..3c14593981 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/aidl/app/revanced/manager/IRootSystemService.aidl b/app/src/main/aidl/app/revanced/manager/IRootSystemService.aidl new file mode 100644 index 0000000000..5dbb41c6d8 --- /dev/null +++ b/app/src/main/aidl/app/revanced/manager/IRootSystemService.aidl @@ -0,0 +1,8 @@ +// IRootService.aidl +package app.revanced.manager; + +// Declare any non-default types here with import statements + +interface IRootSystemService { + IBinder getFileSystemService(); +} \ No newline at end of file diff --git a/app/src/main/assets/root/module.prop b/app/src/main/assets/root/module.prop new file mode 100644 index 0000000000..17f4a7b2e4 --- /dev/null +++ b/app/src/main/assets/root/module.prop @@ -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 \ No newline at end of file diff --git a/app/src/main/assets/root/service.sh b/app/src/main/assets/root/service.sh new file mode 100644 index 0000000000..dc3bcb5f45 --- /dev/null +++ b/app/src/main/assets/root/service.sh @@ -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" diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index 8fba9f6d8a..162ecff6eb 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -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() diff --git a/app/src/main/java/app/revanced/manager/ManagerApplication.kt b/app/src/main/java/app/revanced/manager/ManagerApplication.kt index 8f9393d670..8a2811bd9c 100644 --- a/app/src/main/java/app/revanced/manager/ManagerApplication.kt +++ b/app/src/main/java/app/revanced/manager/ManagerApplication.kt @@ -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()) + scope.launch { prefs.preload() } diff --git a/app/src/main/java/app/revanced/manager/di/RootModule.kt b/app/src/main/java/app/revanced/manager/di/RootModule.kt new file mode 100644 index 0000000000..acfad58dce --- /dev/null +++ b/app/src/main/java/app/revanced/manager/di/RootModule.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/installer/RootInstaller.kt b/app/src/main/java/app/revanced/manager/domain/installer/RootInstaller.kt new file mode 100644 index 0000000000..0b2ee41370 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/domain/installer/RootInstaller.kt @@ -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") \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt index f2133a9016..fc4faf6afa 100644 --- a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt +++ b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt @@ -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>, - 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) } diff --git a/app/src/main/java/app/revanced/manager/service/RootService.kt b/app/src/main/java/app/revanced/manager/service/RootService.kt new file mode 100644 index 0000000000..6fa68b6cbd --- /dev/null +++ b/app/src/main/java/app/revanced/manager/service/RootService.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/SegmentedButton.kt b/app/src/main/java/app/revanced/manager/ui/component/SegmentedButton.kt index c2dc9463cb..bf07d72582 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/SegmentedButton.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/SegmentedButton.kt @@ -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) - is Painter -> { - Icon( - painter = icon, - contentDescription = iconDescription, - tint = MaterialTheme.colorScheme.primary + 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 + ) + } } - } - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - modifier = Modifier.basicMarquee() - ) + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + modifier = Modifier.basicMarquee() + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/AppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/AppInfoScreen.kt index 74b5c1857a..1cd3762be6 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/AppInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/AppInfoScreen.kt @@ -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( } } -} \ No newline at end of file +} + +@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)) + } + } +) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/InstallerScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/InstallerScreen.kt index 69d9c07497..02fe4ee810 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/InstallerScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/InstallerScreen.kt @@ -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 diff --git a/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt index 374cfad6d3..9e8011a902 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt @@ -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 } ) diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/AppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/AppInfoViewModel.kt index 73a0c6b048..7cf317278e 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/AppInfoViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/AppInfoViewModel.kt @@ -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)) diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt index 7b9cac2f5b..ff137be81c 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt @@ -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) } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppsViewModel.kt index 6ed37b55c0..27bec4c44f 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstalledAppsViewModel.kt @@ -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 } } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt index 315dd7e4da..a1a5ebd272 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt @@ -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(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( "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 diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt index 87ca563b26..222d89dfd2 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt @@ -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? by mutableStateOf(null) + var installedApp: Pair? 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() } } diff --git a/app/src/main/java/app/revanced/manager/util/PM.kt b/app/src/main/java/app/revanced/manager/util/PM.kt index 96710e364c..45f2137761 100644 --- a/app/src/main/java/app/revanced/manager/util/PM.kt +++ b/app/src/main/java/app/revanced/manager/util/PM.kt @@ -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 { 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) = 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 sessionParams - get() = PackageInstaller.SessionParams( - PackageInstaller.SessionParams.MODE_FULL_INSTALL - ).apply { - setInstallReason(PackageManager.INSTALL_REASON_USER) - } -private val Context.installIntentSender - get() = PendingIntent.getService( - this, - 0, - Intent(this, InstallService::class.java), - intentFlags - ).intentSender - -private val Context.uninstallIntentSender - get() = PendingIntent.getService( - this, - 0, - Intent(this, UninstallService::class.java), - intentFlags - ).intentSender \ No newline at end of file + private val intentFlags + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + PendingIntent.FLAG_MUTABLE + else + 0 + + private val sessionParams + get() = PackageInstaller.SessionParams( + PackageInstaller.SessionParams.MODE_FULL_INSTALL + ).apply { + setInstallReason(PackageManager.INSTALL_REASON_USER) + } + + private val Context.installIntentSender + get() = PendingIntent.getService( + this, + 0, + Intent(this, InstallService::class.java), + intentFlags + ).intentSender + + private val Context.uninstallIntentSender + get() = PendingIntent.getService( + this, + 0, + Intent(this, UninstallService::class.java), + intentFlags + ).intentSender +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 204d2cc84a..2905507777 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,8 +23,6 @@ Missing Error - - Select version Select updates to receive Periodically connect to update providers to check for updates. @@ -156,9 +154,11 @@ Failed to load apk Loading… Not installed + Installed App info Uninstall + Unpatch Repatch Installation type Package name @@ -167,9 +167,20 @@ View applied patches Default Root + Mounted + Not mounted + Mount + Unmount + Failed to mount: %s + Failed to unmount: %s + Unpatch app? + Are you sure you want to unpatch this app? An error occurred Already downloaded + Select version + Downloadable versions + Already patched Edit More options @@ -193,6 +204,7 @@ Apk exported Failed to sign Apk: %s Save logs + Select installation type Preparation Load patches diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d02fedadfc..d854cee22f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }