Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fa0237b
feat: Implemented `Magisk module` installation
secp192k1 Apr 7, 2026
71f75f6
fix(RootInstaller): Use `service.sh` for reliable magisk data mounts
secp192k1 Apr 7, 2026
7c69819
feat(ui): Add restart recommendation after Magisk installation
secp192k1 Apr 7, 2026
3933e93
fix: Support non-installed/uninstalled apps
secp192k1 Apr 7, 2026
f5280ab
fix: Support native library extraction for modules
secp192k1 Apr 7, 2026
977668a
feat: Migration of magisk logic from `manager` to `library`
secp192k1 Apr 7, 2026
81dc146
feat: Activate and roll back Magisk modules without a reboot
secp192k1 Apr 7, 2026
668a94f
feat: Update Magisk install success message for live activation
secp192k1 Apr 7, 2026
5a90c43
refactor: Removed deprecated files
secp192k1 Apr 9, 2026
2f9271a
fix: `Magisk module` logic, root checks and migration
secp192k1 Apr 9, 2026
0a825e7
enhance: Display module status
secp192k1 Apr 9, 2026
1a35ce9
refactor: Updated strings
secp192k1 Apr 9, 2026
51d57b4
enhance: Finished library migration
secp192k1 Apr 9, 2026
1d49ffe
refactor: Use `Constants` value instead of hardcode
secp192k1 Apr 9, 2026
626feef
fix: Surface root fail-safe to default install
secp192k1 Apr 9, 2026
3374693
feat: Root status in `About device` and patch logs
secp192k1 Apr 9, 2026
1b014b5
enhance: Remove pollution to avoid confusion
secp192k1 Apr 12, 2026
8f8efa5
refactor: Standardize naming to "prepare"
secp192k1 Apr 14, 2026
14f8a09
refactor: Use current user instead of static value
secp192k1 Apr 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions app/src/main/assets/root/module.prop

This file was deleted.

40 changes: 0 additions & 40 deletions app/src/main/assets/root/service.sh

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import app.revanced.manager.R

enum class InstallType(val stringResource: Int) {
DEFAULT(R.string.default_install),
MOUNT(R.string.mount_install)
MOUNT(R.string.mount_install),
MAGISK(R.string.magisk_install)
}

@Entity(tableName = "installed_app")
Expand Down
175 changes: 58 additions & 117 deletions app/src/main/java/app/revanced/manager/domain/installer/RootInstaller.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,32 @@ import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import app.revanced.library.MagiskUtils
import app.revanced.library.installation.installer.Constants
import app.revanced.library.installation.installer.Constants.invoke
import app.revanced.manager.IRootSystemService
import app.revanced.manager.ui.model.RootCheckResult
import app.revanced.manager.service.ManagerRootService
import app.revanced.manager.util.PM
import com.topjohnwu.superuser.Shell
import com.topjohnwu.superuser.ipc.RootService
import com.topjohnwu.superuser.nio.FileSystemManager
import java.io.File
import java.time.Duration
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.time.withTimeoutOrNull
import kotlinx.coroutines.withContext
import java.io.File
import java.time.Duration

class RootInstaller(
private val app: Application,
private val pm: PM
) : ServiceConnection {
private var remoteFS = CompletableDeferred<FileSystemManager>()

// Android user (0 for primary, 10+ for secondary/work profiles) via pure public API.
private val userId = android.os.Process.myUid() / 100000

override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val ipc = IRootSystemService.Stub.asInterface(service)
val binder = ipc.fileSystemService
Expand All @@ -43,12 +50,12 @@ class RootInstaller(
}
}

return withTimeoutOrNull(Duration.ofSeconds(20L)) {
remoteFS.await()
} ?: throw RootServiceException()
return withTimeoutOrNull(Duration.ofSeconds(20L)) { remoteFS.await() }
?: throw RootServiceException()
}

private suspend fun getShell() = with(CompletableDeferred<Shell>()) {
Shell.getCachedShell()?.takeIf { !it.isRoot }?.close()
Shell.getShell(::complete)

await()
Expand All @@ -60,14 +67,24 @@ class RootInstaller(
return getShell().newJob().add(*commands).to(stdout, stderr).exec()
}

fun hasRootAccess() = Shell.isAppGrantedRoot() ?: false
fun hasRootAccess() = MagiskUtils.hasRootAccess()

fun isDeviceRooted() = System.getenv("PATH")?.split(":")?.any { path ->
File(path, "su").canExecute()
} ?: false
fun isDeviceRooted() = MagiskUtils.isDeviceRooted()

fun isMagiskInstalled() = MagiskUtils.isMagiskInstalled()

fun requestRoot() = MagiskUtils.requestRoot()

fun checkRootStatus(): RootCheckResult = when {
!isDeviceRooted() -> RootCheckResult.UNAVAILABLE
isMagiskInstalled() -> RootCheckResult.GRANTED
else -> RootCheckResult.DENIED
}
suspend fun isAppInstalled(packageName: String) =
awaitRemoteFS().getFile("$modulesPath/$packageName-revanced").exists()
MagiskUtils.isInstalled(packageName, awaitRemoteFS())

suspend fun isAppInstalledAsMagiskModule(packageName: String) =
MagiskUtils.isInstalledAsMagiskModule(packageName, awaitRemoteFS())

suspend fun isAppMounted(packageName: String) = withContext(Dispatchers.IO) {
pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir?.let {
Expand All @@ -79,144 +96,68 @@ class RootInstaller(
if (isAppMounted(packageName)) return

withContext(Dispatchers.IO) {
val stockAPK = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir
?: throw Exception("Failed to load application info")
val patchedAPK = "$modulesPath/$packageName-revanced/$packageName.apk"

execute("mount -o bind \"$patchedAPK\" \"$stockAPK\"").assertSuccess("Failed to mount APK")
val sourceDir =
pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir
?: throw Exception("Failed to load application info")
MagiskUtils.mount(packageName, sourceDir)
}
}

suspend fun unmount(packageName: String) {
if (!isAppMounted(packageName)) return

withContext(Dispatchers.IO) {
val stockAPK = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir
val sourceDir = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir
?: throw Exception("Failed to load application info")

execute("umount -l \"$stockAPK\"").assertSuccess("Failed to unmount APK")
MagiskUtils.unmount(sourceDir)
}
}

suspend fun install(
patchedAPK: File,
stockAPK: File?,
packageName: String,
version: String,
label: String
) = withContext(Dispatchers.IO) {
val remoteFS = awaitRemoteFS()
val assets = app.assets
val modulePath = "$modulesPath/$packageName-revanced"
val patchedPackageName = withContext(Dispatchers.IO) { pm.getPackageInfo(patchedAPK)?.packageName } ?: packageName

unmount(packageName)
if (isAppInstalledAsMagiskModule(packageName)) {
uninstallMagiskModule(packageName, patchedPackageName)
}

stockAPK?.let { stockApp ->
// TODO: get user id programmatically
execute("pm uninstall -k --user 0 $packageName")

execute("pm install -r -d --user 0 \"${stockApp.absolutePath}\"")
.assertSuccess("Failed to install stock app")

MagiskUtils.uninstallKeepData(packageName)
execute("pm install -r -d --user $userId \"${stockApp.absolutePath}\"")
stockApp.delete()
}

remoteFS.getFile(modulePath).apply {
if (!mkdirs() && !exists()) {
throw Exception("Failed to create module directory")
}
}

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)
}
}

execute(
"chmod 644 $apkPath",
"chown system:system $apkPath",
"chcon u:object_r:apk_data_file:s0 $apkPath",
"chmod +x $modulePath/service.sh"
).assertSuccess("Failed to set file permissions")
}
MagiskUtils.prepareRootFolder(remoteFS, packageName, patchedAPK)
}

suspend fun uninstall(packageName: String) {
val remoteFS = awaitRemoteFS()
if (isAppMounted(packageName))
unmount(packageName)

remoteFS.getFile("$modulesPath/$packageName-revanced").deleteRecursively()
.also { if (!it) throw Exception("Failed to delete files") }
suspend fun installAsMagiskModule(
patchedAPK: File,
packageName: String,
patchedPackageName: String,
) = withContext(Dispatchers.IO) {
if (isAppInstalledAsMagiskModule(packageName)) {
uninstallMagiskModule(packageName, patchedPackageName)
} else if (isAppInstalled(packageName)) {
uninstall(packageName)
}
MagiskUtils.prepareMagiskModule(awaitRemoteFS(), packageName, patchedPackageName, patchedAPK)
runCatching { execute("pm install -r -d --user $userId \"${Constants.MOUNTED_APK_PATH(packageName)}\"") }
}

companion object {
const val modulesPath = "/data/adb/modules"

private fun Shell.Result.assertSuccess(errorMessage: String) {
if (!isSuccess) {
throw ShellCommandException(
errorMessage,
code,
out,
err
)
}
}
suspend fun uninstallMagiskModule(packageName: String, patchedPackageName: String) {
MagiskUtils.uninstallMagiskModule(packageName, patchedPackageName, awaitRemoteFS())
}
}

class ShellCommandException(
val userMessage: String,
val exitCode: Int,
val stdout: List<String>,
val stderr: List<String>
) : Exception(format(userMessage, exitCode, stdout, stderr)) {
companion object {
private fun format(
message: String,
exitCode: Int,
stdout: List<String>,
stderr: List<String>
): String =
buildString {
appendLine(message)
appendLine("Exit code: $exitCode")

val output = stdout.filter { it.isNotBlank() }
val errors = stderr.filter { it.isNotBlank() }

if (output.isNotEmpty()) {
appendLine("stdout:")
output.forEach(::appendLine)
}
if (errors.isNotEmpty()) {
appendLine("stderr:")
errors.forEach(::appendLine)
}
}
suspend fun uninstall(packageName: String) {
if (isAppMounted(packageName)) unmount(packageName)
MagiskUtils.uninstall(packageName, awaitRemoteFS())
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ enum class DialogKind(
contentStringResId = R.string.installation_timeout_description,
confirmButton = installerStatusDialogButton(R.string.try_again) { it.install() },
dismissButton = installerStatusDialogButton(R.string.cancel),
),
SUCCESS_MAGISK(
flag = 1000,
title = R.string.magisk_install_success_title,
contentStringResId = R.string.magisk_install_success_description,
confirmButton = installerStatusDialogButton(R.string.restart_now) { it.reboot() },
dismissButton = installerStatusDialogButton(R.string.close),
Comment thread
secp192k1 marked this conversation as resolved.
);

// Needed due to the @FromValue annotation.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import app.revanced.manager.util.transparentListItemColors
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun InstallPickerDialog(
isMagiskInstalled: Boolean,
onDismiss: () -> Unit,
onConfirm: (InstallType) -> Unit
) {
Expand Down Expand Up @@ -47,6 +48,8 @@ fun InstallPickerDialog(
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
InstallType.entries.forEach {
// Dont show magisk if its not installed
if (it == InstallType.MAGISK && !isMagiskInstalled) return@forEach
ListItem(
modifier = Modifier.clickable { selectedInstallType = it },
leadingContent = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ package app.revanced.manager.ui.model
interface InstallerModel {
fun reinstall()
fun install()
fun reboot()
}
9 changes: 9 additions & 0 deletions app/src/main/java/app/revanced/manager/ui/model/RootStatus.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package app.revanced.manager.ui.model

import app.revanced.manager.R

enum class RootCheckResult(val displayName: Int) {
GRANTED(R.string.generic_active),
DENIED(R.string.generic_inactive),
UNAVAILABLE(R.string.generic_not_available)
}
Loading
Loading