diff --git a/docs/build-status.md b/docs/build-status.md
index 8732645..a779513 100644
--- a/docs/build-status.md
+++ b/docs/build-status.md
@@ -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
against the restricted LAN (cleartext policy, egress filtering, shared-LAN mDNS crossing, fast
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.
diff --git a/echolot-app/app/build.gradle.kts b/echolot-app/app/build.gradle.kts
index 98fc0bf..63ba4c5 100644
--- a/echolot-app/app/build.gradle.kts
+++ b/echolot-app/app/build.gradle.kts
@@ -37,6 +37,7 @@ dependencies {
implementation(project(":core-protocol"))
implementation(project(":core-engine"))
implementation(project(":core-probe"))
+ implementation(project(":core-shizuku"))
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt
index fbb645a..87fb111 100644
--- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt
+++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt
@@ -16,6 +16,7 @@ import app.echo_lot.probe.LinkSnapshotProbe
import app.echo_lot.probe.NetworkInventory
import app.echo_lot.probe.Probe
import app.echo_lot.probe.ProbeIds
+import app.echo_lot.shizuku.ShizukuProbe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
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 summary = Verdicts.derive(tests, findings)
@@ -102,7 +117,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
manufacturer = Build.MANUFACTURER, model = Build.MODEL,
androidSdk = Build.VERSION.SDK_INT, androidRelease = Build.VERSION.RELEASE,
),
- tiers = Tiers(app = true),
+ tiers = Tiers(app = true, shizuku = shizukuAvailable),
),
networks = networks,
tests = tests,
diff --git a/echolot-app/core-shizuku/.gitignore b/echolot-app/core-shizuku/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/echolot-app/core-shizuku/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/echolot-app/core-shizuku/build.gradle.kts b/echolot-app/core-shizuku/build.gradle.kts
new file mode 100644
index 0000000..5f3ada3
--- /dev/null
+++ b/echolot-app/core-shizuku/build.gradle.kts
@@ -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)
+}
diff --git a/echolot-app/core-shizuku/src/main/AndroidManifest.xml b/echolot-app/core-shizuku/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..21a1d02
--- /dev/null
+++ b/echolot-app/core-shizuku/src/main/AndroidManifest.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/echolot-app/core-shizuku/src/main/aidl/app/echo_lot/shizuku/IUserService.aidl b/echolot-app/core-shizuku/src/main/aidl/app/echo_lot/shizuku/IUserService.aidl
new file mode 100644
index 0000000..8dcf76f
--- /dev/null
+++ b/echolot-app/core-shizuku/src/main/aidl/app/echo_lot/shizuku/IUserService.aidl
@@ -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);
+}
diff --git a/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/ShizukuProbe.kt b/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/ShizukuProbe.kt
new file mode 100644
index 0000000..23f5bc1
--- /dev/null
+++ b/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/ShizukuProbe.kt
@@ -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)
+ }
+}
diff --git a/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/ShizukuRunner.kt b/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/ShizukuRunner.kt
new file mode 100644
index 0000000..0766e3c
--- /dev/null
+++ b/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/ShizukuRunner.kt
@@ -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)
+
+ 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()
+ 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>, timeoutMs: Int = 6000): BatchResult {
+ val out = LinkedHashMap()
+ val bound = CompletableDeferred()
+ 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>, timeoutMs: Int): BatchResult {
+ val out = LinkedHashMap()
+ val method = runCatching {
+ Shizuku::class.java.getDeclaredMethod(
+ "newProcess", Array::class.java, Array::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)
+ }
+}
diff --git a/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/UserService.kt b/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/UserService.kt
new file mode 100644
index 0000000..f79fdc7
--- /dev/null
+++ b/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/UserService.kt
@@ -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}"
+ }
+ }
+}
diff --git a/echolot-app/settings.gradle.kts b/echolot-app/settings.gradle.kts
index f60bc10..2685952 100644
--- a/echolot-app/settings.gradle.kts
+++ b/echolot-app/settings.gradle.kts
@@ -25,5 +25,6 @@ include(":core-protocol")
include(":core-measurement")
include(":core-engine")
include(":core-probe")
+include(":core-shizuku")
include(":app")
include(":adb-beacon")