Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 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
38 changes: 26 additions & 12 deletions app/src/main/assets/root/service.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,52 @@ DIR=${0%/*}

package_name="__PKG_NAME__"
version="__VERSION__"
sanitized_package_name=$(echo "$package_name" | sed 's/\./_/g')

rm "$DIR/log"
rm -f "$DIR/log"

{
echo "Induction check for $package_name"

until [ "$(getprop sys.boot_completed)" = 1 ]; do sleep 5; done
sleep 5
# Wait a bit more for package manager to settle
sleep 10

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)"
base_path="$DIR/system/app/$sanitized_package_name/base.apk"
if [ ! -f "$base_path" ]; then
# Fallback to old path for compatibility during transition
base_path="$DIR/$package_name.apk"
fi

stock_path="$(pm path "$package_name" | grep base | sed 's/package://g' | head -n 1)"
stock_version="$(dumpsys package "$package_name" | grep versionName | cut -d "=" -f2 | head -n 1 | sed 's/ //g')"

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"
if [ -z "$stock_path" ]; then
echo "App $package_name is not installed. System app induction might have failed or still being processed."
exit 1
fi

if [ "$version" != "$stock_version" ]; then
echo "Not mounting as versions don't match"
exit 1
if echo "$stock_path" | grep -q "^/system/"; then
echo "App is already running from system partition (likely our Magisk overlay). Skipping bind mount."
exit 0
fi

if [ -z "$stock_path" ]; then
echo "Not mounting as app info could not be loaded"
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 "Version mismatch: base=$version, stock=$stock_version. Attempting to mount anyway as it might be a minor diff."
# Optional: exit 1 if you want to be strict
fi

echo "Mounting $base_path over $stock_path"
mount -o bind "$base_path" "$stock_path"

} >> "$DIR/log"
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
150 changes: 36 additions & 114 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,18 +5,20 @@ 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.ShellCommandException
import app.revanced.manager.IRootSystemService
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,
Expand All @@ -43,9 +45,8 @@ 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>()) {
Expand All @@ -60,14 +61,15 @@ 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()

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,22 +81,22 @@ 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)
}
}

Expand All @@ -106,117 +108,37 @@ class RootInstaller(
label: String
) = withContext(Dispatchers.IO) {
val remoteFS = awaitRemoteFS()
val assets = app.assets
val modulePath = "$modulesPath/$packageName-revanced"

unmount(packageName)

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

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)
}
val result = execute("pm install -r -d --user 0 \"${stockApp.absolutePath}\"")
if (!result.isSuccess) {
throw ShellCommandException("Failed to install stock app", result.code, result.out, result.err)
}
stockApp.delete()
}

"$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.provisionRootFolder(remoteFS, app.assets, packageName, version, label, 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,
version: String,
label: String
) = withContext(Dispatchers.IO) {
MagiskUtils.provisionMagiskModule(awaitRemoteFS(), app.assets, packageName, version, label, patchedAPK)
}

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) {
MagiskUtils.uninstallMagiskModule(packageName, 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 @@ -3,4 +3,5 @@ package app.revanced.manager.ui.model
interface InstallerModel {
fun reinstall()
fun install()
fun reboot()
}
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ fun InstalledAppInfoScreen(
style = MaterialTheme.typography.bodySmall
)
}

if (installedApp.installType == InstallType.MAGISK) {
Text(
text = stringResource(R.string.magisk_install),
style = MaterialTheme.typography.bodySmall
)
}
}

Row(
Expand Down Expand Up @@ -142,6 +149,15 @@ fun InstalledAppInfoScreen(
)
}

InstallType.MAGISK -> {
SegmentedButton(
icon = Icons.Outlined.SettingsBackupRestore,
text = stringResource(R.string.unpatch),
onClick = { showUninstallDialog = true },
enabled = viewModel.rootInstaller.hasRootAccess()
)
}

}

SegmentedButton(
Expand All @@ -150,7 +166,7 @@ fun InstalledAppInfoScreen(
onClick = {
onPatchClick(installedApp.originalPackageName)
},
enabled = installedApp.installType != InstallType.MOUNT || viewModel.rootInstaller.hasRootAccess()
enabled = installedApp.installType == InstallType.DEFAULT || viewModel.rootInstaller.hasRootAccess()
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,18 @@ class AppsViewModel(
} catch (_: RootServiceException) { }
}

if (app.installType == InstallType.MAGISK) {
try {
if (!rootInstaller.isAppInstalledAsMagiskModule(app.currentPackageName)) {
installedAppsRepository.delete(app)
return@withContext null
}
} catch (_: RootServiceException) { }
}

val packageInfo = pm.getPackageInfo(app.currentPackageName)

if (packageInfo == null && app.installType != InstallType.MOUNT) {
if (packageInfo == null && app.installType != InstallType.MOUNT && app.installType != InstallType.MAGISK) {
installedAppsRepository.delete(app)
return@withContext null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ class InstalledAppInfoViewModel(
}

InstallType.MOUNT -> rootInstaller.uninstall(app.currentPackageName)

InstallType.MAGISK -> rootInstaller.uninstallMagiskModule(app.currentPackageName)
}
installedAppRepository.delete(app)
onBackClick()
Expand Down
Loading