From fa65e78b4ad96e94c78d62007e71cf2979b79609 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 26 May 2026 19:31:20 +0500 Subject: [PATCH 1/2] fix: rewrite root installer on libsu to fix Magisk prompt --- core/data/build.gradle.kts | 2 + .../data/services/root/RootServiceManager.kt | 300 +++++------------- .../composeResources/files/whatsnew/19.json | 3 +- gradle/libs.versions.toml | 4 + settings.gradle.kts | 5 + 5 files changed, 96 insertions(+), 218 deletions(-) diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 7d491d791..4ff1e7507 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -39,6 +39,8 @@ kotlin { implementation(libs.dhizuku.api) + implementation(libs.libsu.core) + implementation(libs.ktor.client.okhttp) implementation(libs.androidx.work.runtime) diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt index 195c66e86..d8f5de3cd 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt @@ -1,7 +1,7 @@ package zed.rainxch.core.data.services.root -import android.os.ParcelFileDescriptor import co.touchlab.kermit.Logger +import com.topjohnwu.superuser.Shell import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -10,10 +10,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import zed.rainxch.core.data.services.root.model.RootStatus -import java.io.BufferedReader import java.io.File -import java.io.InputStreamReader -import java.util.concurrent.TimeUnit class RootServiceManager( private val scope: CoroutineScope, @@ -22,36 +19,27 @@ class RootServiceManager( val status: StateFlow = _status.asStateFlow() @Volatile - private var cachedSuPath: String? = null + private var configured = false fun initialize() { - - scope.launch(Dispatchers.IO) { - refreshStatusBlocking() - } + configureDefaultShell() + scope.launch(Dispatchers.IO) { refreshStatusBlocking() } } fun refreshStatus() { + configureDefaultShell() scope.launch(Dispatchers.IO) { refreshStatusBlocking() } } fun requestPermission() { + configureDefaultShell() scope.launch(Dispatchers.IO) { - val su = cachedSuPath ?: locateSuBinary()?.path ?: run { - Logger.d(TAG) { "requestPermission() — no su binary on device, skipping" } - refreshStatusBlocking() - return@launch - } + Logger.d(TAG) { "requestPermission() — forcing main shell creation" } try { - Logger.d(TAG) { "requestPermission() — invoking '$su -c true' to surface root prompt" } - val proc = Runtime.getRuntime().exec(arrayOf(su, "-c", "true")) - proc.waitFor(PROMPT_TIMEOUT_SECONDS, TimeUnit.SECONDS) - if (proc.isAlive) { - Logger.w(TAG) { "requestPermission() — prompt invocation still running after ${PROMPT_TIMEOUT_SECONDS}s, destroying" } - proc.destroyForcibly() - } + val shell = Shell.getShell() + Logger.d(TAG) { "requestPermission() — shell rootStatus=${shell.status}" } } catch (e: Exception) { - Logger.w(TAG) { "requestPermission() failed: ${e.javaClass.simpleName}: ${e.message}" } + Logger.w(TAG) { "requestPermission() — getShell failed: ${e.javaClass.simpleName}: ${e.message}" } } refreshStatusBlocking() } @@ -60,143 +48,72 @@ class RootServiceManager( suspend fun installPackage( apkFile: File, installerPackageName: String?, - ): Int? = - withContext(Dispatchers.IO) { - val su = cachedSuPath ?: locateSuBinary()?.path ?: run { - Logger.w(TAG) { "installPackage() — no su binary available" } - return@withContext null + ): Int? = withContext(Dispatchers.IO) { + configureDefaultShell() + if (!isRootGranted()) { + Logger.w(TAG) { "installPackage() — root not granted, aborting" } + return@withContext null + } + val safeInstaller = installerPackageName?.takeIf { it.isNotBlank() } + if (safeInstaller != null && !PACKAGE_NAME_PATTERN.matches(safeInstaller)) { + Logger.w(TAG) { + "installPackage() — rejecting non-conformant installerPackageName='$safeInstaller'" } + return@withContext STATUS_FAILURE + } + + val tmpPath = "/data/local/tmp/ghs_${System.currentTimeMillis()}_${(0..Int.MAX_VALUE).random()}.apk" + val srcPath = shellQuote(apkFile.absolutePath) + val tmpPathQuoted = shellQuote(tmpPath) - val safeInstaller = installerPackageName?.takeIf { it.isNotBlank() } - if (safeInstaller != null && !PACKAGE_NAME_PATTERN.matches(safeInstaller)) { - Logger.w(TAG) { - "installPackage() — rejecting non-conformant installerPackageName='$safeInstaller'" - } - return@withContext STATUS_FAILURE + val copyRes = Shell.cmd("cp $srcPath $tmpPathQuoted && chmod 644 $tmpPathQuoted").exec() + if (!copyRes.isSuccess) { + Logger.e(TAG) { + "installPackage() — staging copy failed: exit=${copyRes.code} out='${copyRes.out.joinToString("\n")}' err='${copyRes.err.joinToString("\n")}'" } - val pm = "/system/bin/pm" + return@withContext STATUS_FAILURE + } + try { val command = buildString { - append(pm).append(" install ") + append("pm install ") if (safeInstaller != null) append("-i ").append(safeInstaller).append(' ') - append("-S ").append(apkFile.length()).append(' ') - append('-') - } - Logger.d(TAG) { "installPackage() — executing via $su: $command" } - val proc = try { - Runtime.getRuntime().exec(arrayOf(su, "-c", command)) - } catch (e: Exception) { - Logger.e(TAG) { "installPackage() — su exec failed: ${e.message}" } - return@withContext null - } - - val stdoutBuf = StringBuilder() - val stderrBuf = StringBuilder() - val stdoutThread = Thread { - try { - BufferedReader(InputStreamReader(proc.inputStream)).use { reader -> - reader.forEachLine { stdoutBuf.append(it).append('\n') } - } - } catch (_: Exception) { - } - } - val stderrThread = Thread { - try { - BufferedReader(InputStreamReader(proc.errorStream)).use { reader -> - reader.forEachLine { stderrBuf.append(it).append('\n') } - } - } catch (_: Exception) { - } - } - stdoutThread.start() - stderrThread.start() - - val pipeError = StringBuilder() - val pipeThread = Thread { - try { - apkFile.inputStream().use { input -> - proc.outputStream.use { output -> - input.copyTo(output) - } - } - } catch (e: Exception) { - pipeError.append(e.javaClass.simpleName).append(": ").append(e.message) - Logger.e(TAG) { "installPackage() — stdin pipe failed: $pipeError" } - } - } - pipeThread.start() - pipeThread.join(INSTALL_TIMEOUT_SECONDS * 1000L) - if (pipeThread.isAlive) { - Logger.e(TAG) { "installPackage() — pipe thread still alive after ${INSTALL_TIMEOUT_SECONDS}s, destroying process" } - pipeThread.interrupt() - proc.destroyForcibly() - stdoutThread.join(READER_DRAIN_TIMEOUT_MS) - stderrThread.join(READER_DRAIN_TIMEOUT_MS) - return@withContext STATUS_FAILURE - } - - val finished = proc.waitFor(INSTALL_TIMEOUT_SECONDS, TimeUnit.SECONDS) - if (!finished) { - Logger.e(TAG) { "installPackage() — pm process timed out, destroying" } - proc.destroyForcibly() - stdoutThread.join(READER_DRAIN_TIMEOUT_MS) - stderrThread.join(READER_DRAIN_TIMEOUT_MS) - return@withContext STATUS_FAILURE - } - - stdoutThread.join(READER_DRAIN_TIMEOUT_MS) - stderrThread.join(READER_DRAIN_TIMEOUT_MS) - - val stdout = stdoutBuf.toString().trim() - val stderr = stderrBuf.toString().trim() - val exit = proc.exitValue() - Logger.d(TAG) { "installPackage() — exit=$exit stdout='$stdout' stderr='$stderr'" } - if (exit == 0 && stdout.contains("Success")) { + append("-r ") + append(tmpPathQuoted) + } + Logger.d(TAG) { "installPackage() — executing: $command" } + val result = Shell.cmd(command).exec() + val stdout = result.out.joinToString("\n").trim() + val stderr = result.err.joinToString("\n").trim() + Logger.d(TAG) { "installPackage() — exit=${result.code} stdout='$stdout' stderr='$stderr'" } + if (result.isSuccess && stdout.contains("Success")) { STATUS_SUCCESS } else { Logger.w(TAG) { "installPackage() — pm reported failure: stdout='$stdout' stderr='$stderr'" } STATUS_FAILURE } + } finally { + Shell.cmd("rm -f $tmpPathQuoted").submit() } + } - suspend fun uninstallPackage(packageName: String): Int? = - withContext(Dispatchers.IO) { - val su = cachedSuPath ?: locateSuBinary()?.path ?: return@withContext null - if (!PACKAGE_NAME_PATTERN.matches(packageName)) { - Logger.w(TAG) { - "uninstallPackage() — rejecting non-conformant packageName='$packageName'" - } - return@withContext STATUS_FAILURE - } - val command = "/system/bin/pm uninstall $packageName" - try { - val proc = Runtime.getRuntime().exec(arrayOf(su, "-c", command)) - val stdoutBuf = StringBuilder() - val stdoutThread = Thread { - try { - BufferedReader(InputStreamReader(proc.inputStream)).use { reader -> - reader.forEachLine { stdoutBuf.append(it).append('\n') } - } - } catch (_: Exception) { - } - } - stdoutThread.start() - val finished = proc.waitFor(UNINSTALL_TIMEOUT_SECONDS, TimeUnit.SECONDS) - if (!finished) { - proc.destroyForcibly() - stdoutThread.join(READER_DRAIN_TIMEOUT_MS) - return@withContext STATUS_FAILURE - } - stdoutThread.join(READER_DRAIN_TIMEOUT_MS) - val stdout = stdoutBuf.toString().trim() - val exit = proc.exitValue() - Logger.d(TAG) { "uninstallPackage($packageName) — exit=$exit stdout='$stdout'" } - if (exit == 0 && stdout.contains("Success")) STATUS_SUCCESS else STATUS_FAILURE - } catch (e: Exception) { - Logger.e(TAG) { "uninstallPackage($packageName) — su exec failed: ${e.message}" } - STATUS_FAILURE + suspend fun uninstallPackage(packageName: String): Int? = withContext(Dispatchers.IO) { + configureDefaultShell() + if (!isRootGranted()) { + Logger.w(TAG) { "uninstallPackage() — root not granted, aborting" } + return@withContext null + } + if (!PACKAGE_NAME_PATTERN.matches(packageName)) { + Logger.w(TAG) { + "uninstallPackage() — rejecting non-conformant packageName='$packageName'" } + return@withContext STATUS_FAILURE } + val result = Shell.cmd("pm uninstall $packageName").exec() + val stdout = result.out.joinToString("\n").trim() + Logger.d(TAG) { "uninstallPackage($packageName) — exit=${result.code} stdout='$stdout'" } + if (result.isSuccess && stdout.contains("Success")) STATUS_SUCCESS else STATUS_FAILURE + } private fun refreshStatusBlocking() { val computed = computeStatus() @@ -207,93 +124,42 @@ class RootServiceManager( } private fun computeStatus(): RootStatus { - val probe = locateSuBinary() - if (probe == null) { - cachedSuPath = null - return RootStatus.NOT_AVAILABLE - } - cachedSuPath = probe.path - return when (probe.kind) { - ProbeResultKind.UID_ZERO -> RootStatus.READY - ProbeResultKind.NOT_ZERO -> RootStatus.PERMISSION_NEEDED + val granted = Shell.isAppGrantedRoot() + return when (granted) { + true -> RootStatus.READY + false -> RootStatus.PERMISSION_NEEDED + null -> RootStatus.NOT_AVAILABLE } } - private fun locateSuBinary(): SuProbe? { + private fun isRootGranted(): Boolean = Shell.isAppGrantedRoot() == true - var firstNotZero: SuProbe? = null - for (path in SU_PATHS) { - val result = probeSu(path) ?: continue - if (result == ProbeResultKind.UID_ZERO) { - return SuProbe(path = path, kind = result) - } - if (firstNotZero == null) { - firstNotZero = SuProbe(path = path, kind = result) - } + private fun configureDefaultShell() { + if (configured) return + synchronized(this) { + if (configured) return + try { + Shell.setDefaultBuilder( + Shell.Builder.create() + .setFlags(Shell.FLAG_MOUNT_MASTER) + .setTimeout(SHELL_TIMEOUT_SECONDS), + ) + } catch (e: IllegalStateException) { + Logger.d(TAG) { "configureDefaultShell() — main shell already constructed, skipping setDefaultBuilder" } + } + configured = true } - return firstNotZero } - private fun probeSu(path: String): ProbeResultKind? = - try { - val proc = Runtime.getRuntime().exec(arrayOf(path, "-c", "id")) - val finished = proc.waitFor(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS) - if (!finished) { - Logger.d(TAG) { "probeSu($path) timed out, treating as PERMISSION_NEEDED" } - proc.destroyForcibly() - ProbeResultKind.NOT_ZERO - } else { - val output = - BufferedReader(InputStreamReader(proc.inputStream)).use { - it.readText().trim() - } - if (proc.exitValue() == 0 && output.contains("uid=0")) { - ProbeResultKind.UID_ZERO - } else { - ProbeResultKind.NOT_ZERO - } - } - } catch (_: java.io.IOException) { - - null - } catch (e: Exception) { - Logger.w(TAG) { "probeSu($path) threw: ${e.javaClass.simpleName}: ${e.message}" } - null - } - - private data class SuProbe( - val path: String, - val kind: ProbeResultKind, - ) - - private enum class ProbeResultKind { - UID_ZERO, - NOT_ZERO, - } + private fun shellQuote(value: String): String = "'" + value.replace("'", "'\\''") + "'" companion object { private const val TAG = "RootServiceManager" private const val STATUS_SUCCESS = 0 private const val STATUS_FAILURE = -1 - private const val INSTALL_TIMEOUT_SECONDS = 120L - private const val UNINSTALL_TIMEOUT_SECONDS = 30L - private const val PROBE_TIMEOUT_SECONDS = 5L - private const val PROMPT_TIMEOUT_SECONDS = 60L - private const val READER_DRAIN_TIMEOUT_MS = 1_000L + private const val SHELL_TIMEOUT_SECONDS = 10L private val PACKAGE_NAME_PATTERN = Regex("""^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$""") - - private val SU_PATHS = - listOf( - "/system/bin/su", - "/system/xbin/su", - "/sbin/su", - "/su/bin/su", - "/magisk/.core/bin/su", - "/data/adb/magisk/su", - "/data/adb/ksu/bin/su", - "/data/adb/ap/su", - ) } } diff --git a/core/presentation/src/commonMain/composeResources/files/whatsnew/19.json b/core/presentation/src/commonMain/composeResources/files/whatsnew/19.json index 261a2c92a..ebab7377a 100644 --- a/core/presentation/src/commonMain/composeResources/files/whatsnew/19.json +++ b/core/presentation/src/commonMain/composeResources/files/whatsnew/19.json @@ -20,7 +20,8 @@ "Discovery cards now show every platform a repo ships installers for, not just the one whose endpoint listed it.", "What's New tag row no longer wraps long release tags into a one-character-wide vertical date column.", "Checksum mismatch when a mirror was active — fixed a race between mirror and direct downloads writing to the same destination file.", - "Windows 11 title bar now matches system dark mode instead of staying glaringly white." + "Windows 11 title bar now matches system dark mode instead of staying glaringly white.", + "Silent install via root now properly triggers the Magisk / KernelSU permission prompt on Android 14+ instead of immediately reporting 'No root'; the root path was rewritten on top of libsu." ] } ] diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 740191ec1..2a014bde6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -45,6 +45,7 @@ landscapist = "2.9.5" coil3 = "3.3.0" highlights = "1.0.0" shizuku = "13.1.5" +libsu = "6.0.0" dhizuku = "2.5.4" hidden-api = "4.4.0" jsystemthemedetector = "3.9.1" @@ -170,6 +171,9 @@ shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizuku" } shizuku-provider = { module = "dev.rikka.shizuku:provider", version.ref = "shizuku" } hidden-api-stub = { module = "dev.rikka.hidden:stub", version.ref = "hidden-api" } +# libsu (root shell) +libsu-core = { module = "com.github.topjohnwu.libsu:core", version.ref = "libsu" } + # Dhizuku dhizuku-api = { module = "io.github.iamr0s:Dhizuku-API", version.ref = "dhizuku" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 4a7f63eac..4d37c8783 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -27,6 +27,11 @@ dependencyResolutionManagement { } } mavenCentral() + maven("https://jitpack.io") { + mavenContent { + includeGroupAndSubgroups("com.github.topjohnwu") + } + } } } From a26254ae132c95eb6510d82ffdfc0a3a67c53230 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 26 May 2026 20:45:12 +0500 Subject: [PATCH 2/2] fix(root): wrap install in outer try/finally and drop FLAG_MOUNT_MASTER --- .../core/data/services/root/RootServiceManager.kt | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt index d8f5de3cd..b9a4158ee 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/root/RootServiceManager.kt @@ -66,15 +66,15 @@ class RootServiceManager( val srcPath = shellQuote(apkFile.absolutePath) val tmpPathQuoted = shellQuote(tmpPath) - val copyRes = Shell.cmd("cp $srcPath $tmpPathQuoted && chmod 644 $tmpPathQuoted").exec() - if (!copyRes.isSuccess) { - Logger.e(TAG) { - "installPackage() — staging copy failed: exit=${copyRes.code} out='${copyRes.out.joinToString("\n")}' err='${copyRes.err.joinToString("\n")}'" + try { + val copyRes = Shell.cmd("cp $srcPath $tmpPathQuoted && chmod 644 $tmpPathQuoted").exec() + if (!copyRes.isSuccess) { + Logger.e(TAG) { + "installPackage() — staging copy failed: exit=${copyRes.code} out='${copyRes.out.joinToString("\n")}' err='${copyRes.err.joinToString("\n")}'" + } + return@withContext STATUS_FAILURE } - return@withContext STATUS_FAILURE - } - try { val command = buildString { append("pm install ") if (safeInstaller != null) append("-i ").append(safeInstaller).append(' ') @@ -141,7 +141,6 @@ class RootServiceManager( try { Shell.setDefaultBuilder( Shell.Builder.create() - .setFlags(Shell.FLAG_MOUNT_MASTER) .setTimeout(SHELL_TIMEOUT_SECONDS), ) } catch (e: IllegalStateException) {