app: core-shizuku — dual-path shell-tier executor + probe, wired into the app

Ports the prober's validated Shizuku tier: AIDL UserService, the build-4
dual-path ShizukuRunner (UserService bind where it works, legacy
newProcess reflection fallback where it doesn't — exec_path records
which), and ShizukuProbe running the shell command battery, emitting a
shizuku-tier link.ip_monitor Test with per-device dumps as evidence.
Self-degrades to UNSUPPORTED without Shizuku.

Wired into RunViewModel (sets tiers.shizuku); app APK assembles. On-device
verification deferred — no device reachable at build time (flaky LAN
dropped the tablet, phone debugging off). Expect UserService on OnePlus,
newProcess on Lenovo per the prober.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-07-31 22:37:48 +02:00
co-authored by Claude Opus 5
parent 9809ae57b4
commit aaed22dd3f
11 changed files with 359 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
/build
+35
View File
@@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
// AGP 9 built-in Kotlin (no kotlin.android — see core-probe note).
alias(libs.plugins.android.library)
}
// The Shizuku (shell-tier) executor + probe. Ported from the validated
// echolot-prober with the build-4 dual-path fix: bind the UserService when it
// works (OnePlus), fall back to the legacy newProcess API when it doesn't
// (Lenovo). Emits a core-measurement Test.
android {
namespace = "app.echo_lot.shizuku"
compileSdk = 36
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
aidl = true // IUserService
}
}
dependencies {
implementation(project(":core-measurement"))
implementation(libs.shizuku.api)
implementation(libs.shizuku.provider)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-FileCopyrightText: 2026 Echolot contributors
SPDX-License-Identifier: GPL-3.0-or-later -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
<application>
<!-- Shizuku binder provider (merged into the host app). -->
<provider
android:name="rikka.shizuku.ShizukuProvider"
android:authorities="${applicationId}.shizuku"
android:multiprocess="false"
android:enabled="true"
android:exported="true"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
</application>
</manifest>
@@ -0,0 +1,8 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.shizuku;
interface IUserService {
void destroy();
String exec(String command, int timeoutMs);
}
@@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.shizuku
import android.content.Context
import app.echo_lot.measurement.Test
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.TestType
import app.echo_lot.measurement.Tier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* The Shizuku shell-tier probe: runs the privileged command battery (neighbor table, RA routes
* with lifetimes, netlink monitor, IpClient DHCP logs, wifi dump) that the app UID cannot, and
* captures the real per-device dump formats the production parsers must handle. Emitted as a
* shizuku-tier `link.ip_monitor` test (the representative shell-tier link test); `exec_path`
* records whether the UserService or the newProcess fallback carried it.
*/
class ShizukuProbe {
val type = TestType.LINK_IP_MONITOR
val tier = Tier.SHIZUKU
private val battery = listOf(
"id" to "id",
"ip_neigh" to "ip neigh show",
"ip6_route" to "ip -6 route show table all",
"ip_addr" to "ip addr show",
"ip_monitor" to "(ping -c 2 -W 1 \$(ip route show default | head -1 | cut -d' ' -f3) >/dev/null 2>&1 &); timeout 5 ip monitor all || true",
"dhcp_log" to "dumpsys network_stack 2>/dev/null | grep -iA2 -m 20 -e dhcp -e 'IpClient' || true",
"wifi_dump" to "dumpsys wifi 2>/dev/null | grep -iA1 -m 20 -e 'mDhcpResults' -e 'Gateway' -e 'DNS' || true",
)
/** Runs the battery and returns a Test. [uuid]/[monoNs] come from the run's id/clock source. */
suspend fun run(context: Context, uuid: () -> String, monoNs: () -> Long): Test = withContext(Dispatchers.IO) {
val id = uuid()
val started = monoNs()
val runner = ShizukuRunner(context)
val st = runner.status()
fun envelope(status: TestStatus, evidence: kotlinx.serialization.json.JsonObject, metrics: kotlinx.serialization.json.JsonObject? = null) =
Test(id = id, type = type, tier = tier, startedMonoNs = started, endedMonoNs = monoNs(),
status = status, evidence = evidence, metrics = metrics)
if (!st.binderAlive) {
return@withContext envelope(TestStatus.UNSUPPORTED, buildJsonObject {
put("binder_alive", false); put("detail", "Shizuku not running")
})
}
if (!st.permissionGranted && !runner.requestPermission()) {
return@withContext envelope(TestStatus.UNSUPPORTED, buildJsonObject {
put("binder_alive", true); put("permission", false)
})
}
val batch = runner.execBatch(battery, timeoutMs = 6000)
var ok = 0
val evidence = buildJsonObject {
put("binder_alive", true); put("version", st.version); put("runs_as", st.uidName)
put("exec_path", batch.execPath)
for ((key, out) in batch.results) {
val trimmed = out.trim().take(1200)
put(key, trimmed)
if (!out.startsWith("SHIZUKU_") && !out.startsWith("EXEC_") &&
!out.startsWith("NEWPROCESS_") && out.isNotBlank()
) ok++
}
}
val metrics = buildJsonObject {
put("commands_ok", ok); put("commands_total", battery.size); put("exec_path", batch.execPath)
}
val status = when {
ok >= 4 -> TestStatus.OK
ok >= 1 -> TestStatus.PARTIAL
else -> TestStatus.FAILED
}
envelope(status, evidence, metrics)
}
}
@@ -0,0 +1,142 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.shizuku
import android.content.ComponentName
import android.content.Context
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.os.IBinder
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeoutOrNull
import rikka.shizuku.Shizuku
/**
* Thin wrapper over the Shizuku binder + a bound UserService, with a dual execution path proven
* necessary on real hardware (echolot-prober):
* - UserService bind works on the OnePlus 15 (7/7 commands);
* - it never binds on the Lenovo TB330FU (two 25 s attempts, binder alive, permission granted),
* so this falls back to the legacy `Shizuku.newProcess` remote-process API via reflection.
* core-shizuku must not assume UserService works everywhere.
*/
class ShizukuRunner(private val context: Context) {
data class Status(
val binderAlive: Boolean,
val version: Int,
val uidName: String,
val permissionGranted: Boolean,
)
data class BatchResult(val execPath: String, val results: LinkedHashMap<String, String>)
fun status(): Status {
val alive = runCatching { Shizuku.pingBinder() }.getOrDefault(false)
val version = if (alive) runCatching { Shizuku.getVersion() }.getOrDefault(-1) else -1
val uid = if (alive) runCatching { Shizuku.getUid() }.getOrDefault(-1) else -1
val uidName = when (uid) {
0 -> "root(0)"; 2000 -> "shell(2000)"; -1 -> "unknown"; else -> "uid=$uid"
}
val granted = alive && runCatching {
Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
}.getOrDefault(false)
return Status(alive, version, uidName, granted)
}
suspend fun requestPermission(): Boolean {
val s = status()
if (!s.binderAlive) return false
if (s.permissionGranted) return true
val deferred = CompletableDeferred<Boolean>()
val code = 0xE1
val listener = object : Shizuku.OnRequestPermissionResultListener {
override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) {
if (requestCode == code) {
Shizuku.removeRequestPermissionResultListener(this)
deferred.complete(grantResult == PackageManager.PERMISSION_GRANTED)
}
}
}
Shizuku.addRequestPermissionResultListener(listener)
runCatching { Shizuku.requestPermission(code) }
return withTimeoutOrNull(30000) { deferred.await() } ?: false
}
private val serviceArgs = Shizuku.UserServiceArgs(
ComponentName(context.packageName, UserService::class.java.name),
).daemon(false).processNameSuffix("echolot").version(1)
/**
* Bind the UserService once for the whole battery (per-command binding races Shizuku's
* unbind — 3/7 spurious timeouts on the OnePlus). 25 s + one retry; on total failure, fall
* back to newProcess. Returns which path ran + one output per command.
*/
suspend fun execBatch(commands: List<Pair<String, String>>, timeoutMs: Int = 6000): BatchResult {
val out = LinkedHashMap<String, String>()
val bound = CompletableDeferred<IUserService?>()
val conn = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
bound.complete(if (binder != null && binder.pingBinder()) IUserService.Stub.asInterface(binder) else null)
}
override fun onServiceDisconnected(name: ComponentName?) {}
}
try {
Shizuku.bindUserService(serviceArgs, conn)
var svc = withTimeoutOrNull(25_000) { bound.await() }
if (svc == null) {
runCatching { Shizuku.unbindUserService(serviceArgs, conn, true) }
Shizuku.bindUserService(serviceArgs, conn)
svc = withTimeoutOrNull(25_000) { bound.await() }
}
if (svc == null) return execBatchViaNewProcess(commands, timeoutMs)
for ((key, cmd) in commands) {
out[key] = try {
withTimeoutOrNull(timeoutMs.toLong() + 4000) { svc.exec(cmd, timeoutMs) } ?: "EXEC_TIMEOUT"
} catch (e: Throwable) {
"SHIZUKU_ERROR: ${e.message ?: e.javaClass.simpleName}"
}
}
} catch (e: Throwable) {
commands.forEach { (key, _) -> out.putIfAbsent(key, "SHIZUKU_ERROR: ${e.message ?: e.javaClass.simpleName}") }
} finally {
runCatching { Shizuku.unbindUserService(serviceArgs, conn, true) }
}
return BatchResult("UserService", out)
}
/** Legacy Shizuku.newProcess (private, reflection) — carries the Lenovo where UserService won't
* bind. Whether it works per device is itself evidence core-shizuku records. */
private fun execBatchViaNewProcess(commands: List<Pair<String, String>>, timeoutMs: Int): BatchResult {
val out = LinkedHashMap<String, String>()
val method = runCatching {
Shizuku::class.java.getDeclaredMethod(
"newProcess", Array<String>::class.java, Array<String>::class.java, String::class.java,
).apply { isAccessible = true }
}.getOrNull()
if (method == null) {
commands.forEach { (key, _) -> out[key] = "SHIZUKU_BIND_TIMEOUT; newProcess API absent" }
return BatchResult("UserService bind failed; newProcess absent", out)
}
for ((key, cmd) in commands) {
out[key] = runCatching {
val proc = method.invoke(null, arrayOf("sh", "-c", cmd), null, null) as Process
val output = StringBuilder()
val reader = Thread {
runCatching {
proc.inputStream.bufferedReader().forEachLine { line ->
if (output.length < 64 * 1024) output.append(line).append('\n')
}
}
}
reader.start(); reader.join(timeoutMs.toLong())
if (reader.isAlive) proc.destroy()
output.toString().ifBlank { "EXEC_TIMEOUT(newProcess)" }
}.getOrElse { e ->
val cause = (e as? java.lang.reflect.InvocationTargetException)?.cause ?: e
"NEWPROCESS_ERROR: ${cause.message ?: cause.javaClass.simpleName}"
}
}
return BatchResult("newProcess fallback (UserService bind failed)", out)
}
}
@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.shizuku
import kotlin.system.exitProcess
/**
* Runs inside the Shizuku-spawned process (uid = shell 2000, or root). This process has the shell
* SELinux domain, so commands here read `ip neigh`, `ip monitor`, `dumpsys network_stack`, etc. —
* things the app UID cannot. The implicit no-arg primary constructor is the one Shizuku requires.
*/
class UserService : IUserService.Stub() {
override fun destroy() {
exitProcess(0)
}
override fun exec(command: String, timeoutMs: Int): String {
return try {
val proc = ProcessBuilder("sh", "-c", command).redirectErrorStream(true).start()
val output = StringBuilder()
val reader = proc.inputStream.bufferedReader()
val deadline = System.currentTimeMillis() + timeoutMs.coerceIn(500, 30000)
val readerThread = Thread {
runCatching {
reader.forEachLine { line -> if (output.length < 64 * 1024) output.append(line).append('\n') }
}
}
readerThread.start()
while (readerThread.isAlive && System.currentTimeMillis() < deadline) Thread.sleep(20)
if (proc.isAlive) proc.destroy()
"uid=" + android.os.Process.myUid() + "\n" + output.toString()
} catch (e: Throwable) {
"EXEC_ERROR: ${e.message ?: e.javaClass.simpleName}"
}
}
}