Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions core/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ kotlin {

implementation(libs.dhizuku.api)

implementation(libs.libsu.core)

implementation(libs.ktor.client.okhttp)

implementation(libs.androidx.work.runtime)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -22,36 +19,27 @@ class RootServiceManager(
val status: StateFlow<RootStatus> = _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()
}
Expand All @@ -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 safeInstaller = installerPackageName?.takeIf { it.isNotBlank() }
if (safeInstaller != null && !PACKAGE_NAME_PATTERN.matches(safeInstaller)) {
Logger.w(TAG) {
"installPackage() — rejecting non-conformant installerPackageName='$safeInstaller'"
val tmpPath = "/data/local/tmp/ghs_${System.currentTimeMillis()}_${(0..Int.MAX_VALUE).random()}.apk"
val srcPath = shellQuote(apkFile.absolutePath)
val tmpPathQuoted = shellQuote(tmpPath)

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
}
val pm = "/system/bin/pm"

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()
Expand All @@ -207,93 +124,41 @@ 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()
.setTimeout(SHELL_TIMEOUT_SECONDS),
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
} 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",
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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."
]
}
]
Expand Down
Loading