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
+17
View File
@@ -355,3 +355,20 @@ Wireless-adb beacon (tools/adb-beacon) made this practical: both devices self-re
rotating wireless-debug port to fmr:443; a PC connector keeps adb connected. Debugged live rotating wireless-debug port to fmr:443; a PC connector keeps adb connected. Debugged live
against the restricted LAN (cleartext policy, egress filtering, shared-LAN mDNS crossing, fast against the restricted LAN (cleartext policy, egress filtering, shared-LAN mDNS crossing, fast
port rotation) — all handled. port rotation) — all handled.
## App: core-shizuku (shell tier) built + wired (2026-07-31)
Ported the prober's validated Shizuku executor into the app as a library module:
- AIDL IUserService + UserService (runs `sh -c` as shell/root in the Shizuku-spawned process),
Shizuku provider merged into the app manifest.
- **ShizukuRunner: the build-4 dual-path executor** — bind the UserService (25s + retry) where it
works (OnePlus 7/7), fall back to the legacy `Shizuku.newProcess` reflection API where it never
binds (Lenovo). `exec_path` records which path ran.
- ShizukuProbe: runs the shell battery (ip neigh / ip -6 route / ip addr / ip monitor /
network_stack DHCP / wifi dump) and emits a shizuku-tier `link.ip_monitor` Test with the raw
per-device dumps as evidence + commands_ok/exec_path metrics. Self-degrades to UNSUPPORTED when
Shizuku isn't running.
Wired into RunViewModel (runs after app-tier probes; sets tiers.shizuku). App APK assembles clean.
On-device test deferred: at build time no device was reachable (tablet wifi/beacon dropped on the
churning LAN; phone wireless debugging disabled to stop reconnect notifications). Will verify on a
device later — expecting UserService on the OnePlus, newProcess fallback on the Lenovo, per the
prober.
+1
View File
@@ -37,6 +37,7 @@ dependencies {
implementation(project(":core-protocol")) implementation(project(":core-protocol"))
implementation(project(":core-engine")) implementation(project(":core-engine"))
implementation(project(":core-probe")) implementation(project(":core-probe"))
implementation(project(":core-shizuku"))
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
@@ -16,6 +16,7 @@ import app.echo_lot.probe.LinkSnapshotProbe
import app.echo_lot.probe.NetworkInventory import app.echo_lot.probe.NetworkInventory
import app.echo_lot.probe.Probe import app.echo_lot.probe.Probe
import app.echo_lot.probe.ProbeIds import app.echo_lot.probe.ProbeIds
import app.echo_lot.shizuku.ShizukuProbe
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -87,6 +88,20 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
) )
} }
// Shizuku shell tier — self-degrades to UNSUPPORTED when Shizuku isn't running.
step("shizuku.command_battery")
val shizukuTest = try {
ShizukuProbe().run(ctx, ids::uuid, ids::monoNs)
} catch (t: Throwable) {
Test(
id = ids.uuid(), type = TestType.LINK_IP_MONITOR, tier = Tier.SHIZUKU,
startedMonoNs = ids.monoNs(), endedMonoNs = ids.monoNs(),
status = TestStatus.FAILED, error = TestError("uncaught", t.message ?: t.javaClass.simpleName),
)
}
tests.add(shizukuTest)
val shizukuAvailable = shizukuTest.status == TestStatus.OK || shizukuTest.status == TestStatus.PARTIAL
val findings = deriveFindings(tests) val findings = deriveFindings(tests)
val summary = Verdicts.derive(tests, findings) val summary = Verdicts.derive(tests, findings)
@@ -102,7 +117,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
manufacturer = Build.MANUFACTURER, model = Build.MODEL, manufacturer = Build.MANUFACTURER, model = Build.MODEL,
androidSdk = Build.VERSION.SDK_INT, androidRelease = Build.VERSION.RELEASE, androidSdk = Build.VERSION.SDK_INT, androidRelease = Build.VERSION.RELEASE,
), ),
tiers = Tiers(app = true), tiers = Tiers(app = true, shizuku = shizukuAvailable),
), ),
networks = networks, networks = networks,
tests = tests, tests = tests,
+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}"
}
}
}
+1
View File
@@ -25,5 +25,6 @@ include(":core-protocol")
include(":core-measurement") include(":core-measurement")
include(":core-engine") include(":core-engine")
include(":core-probe") include(":core-probe")
include(":core-shizuku")
include(":app") include(":app")
include(":adb-beacon") include(":adb-beacon")