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:
co-authored by
Claude Opus 5
parent
9809ae57b4
commit
aaed22dd3f
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user