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")