app: tell the user before the run when Shizuku is installed but not started

Distinguishes not-installed (say nothing — don't nag users who don't use
Shizuku) from installed-but-stopped (amber banner: start it to include
shell-tier tests), plus running-unauthorised and ready. Detection is
listener-based since pingBinder() only becomes truthful once
ShizukuProvider delivers the binder; a launch-time poll would show a false
"not running". Installed-vs-not needs the <queries> entry on Android 11+.

Verified on-device: with shizuku_server stopped, the banner shows before
pressing Run; the title also now clears the status bar/cutout after the
safeDrawingPadding fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 09:51:01 +02:00
co-authored by Claude Opus 5
parent 217818f7b3
commit d7dda40e4e
5 changed files with 135 additions and 0 deletions
@@ -115,6 +115,22 @@ private fun EcholotScreen(
Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
Text("measure, don't guess", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp)
// Shell-tier readiness, before the run. Nothing is shown when Shizuku isn't installed —
// only users who actually use it get reminded that it must be running.
state.shizukuNotice?.let { notice ->
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (state.shizukuReady) Color(0xFF14301F) else Color(0xFF3A2E12),
),
) {
Text(
notice, Modifier.padding(10.dp), fontSize = 12.sp,
color = if (state.shizukuReady) Color(0xFF9CCFA8) else Color(0xFFFFD08A),
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
Button(onClick = onRun, enabled = !state.running) {
Text(if (state.running) "Running…" else "Run measurement")
@@ -20,6 +20,7 @@ import app.echo_lot.probe.NetworkInventory
import app.echo_lot.probe.Probe
import app.echo_lot.probe.ProbeIds
import app.echo_lot.probe.RouterIdentityProbe
import app.echo_lot.shizuku.ShizukuAvailability
import app.echo_lot.shizuku.ShizukuProbe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -37,6 +38,9 @@ data class UiState(
val stepsDone: Int = 0,
val stepsTotal: Int = 0,
val etaSeconds: Int = 0,
/** Shell-tier readiness, shown before a run; null message = say nothing (Shizuku not installed). */
val shizukuNotice: String? = null,
val shizukuReady: Boolean = false,
)
/**
@@ -51,6 +55,24 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
private set
private var runJob: kotlinx.coroutines.Job? = null
private val stopShizukuObserver: () -> Unit
init {
// Report shell-tier readiness up front. The binder arrives asynchronously, so this is a
// listener, not a one-shot poll — otherwise a running Shizuku would look "not running"
// for the first moment after launch.
stopShizukuObserver = ShizukuAvailability.observe(app) { st ->
state = state.copy(
shizukuNotice = ShizukuAvailability.describe(st),
shizukuReady = st == ShizukuAvailability.State.READY,
)
}
}
override fun onCleared() {
stopShizukuObserver()
super.onCleared()
}
// Results collected so far. A cancelled run must still be able to show what it measured.
private val collected = mutableListOf<Test>()
private var runIds: RunIds = RunIds()
@@ -5,6 +5,12 @@
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
<!-- Android 11+ package visibility: needed to tell "Shizuku installed but stopped"
(worth a reminder) apart from "not installed" (say nothing). -->
<queries>
<package android:name="moe.shizuku.privileged.api" />
</queries>
<application>
<!-- Shizuku binder provider (merged into the host app). -->
<provider
@@ -0,0 +1,78 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.shizuku
import android.content.Context
import android.content.pm.PackageManager
import rikka.shizuku.Shizuku
/**
* Tells the UI whether the shell tier is usable **before** a run starts, so the app can say
* "Shizuku isn't running, shell-tier tests will be skipped" instead of silently producing an
* UNSUPPORTED result minutes later.
*
* Detection is reliable but *asynchronous*: `Shizuku.pingBinder()` answers truthfully only once
* ShizukuProvider has delivered the binder to this process, which happens shortly after start.
* [observe] therefore reports the current state immediately and again whenever the binder
* arrives or dies — a poll at t=0 alone would show a false "not running".
*/
object ShizukuAvailability {
const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
enum class State {
/** Running and this app may use it — shell tier will execute. */
READY,
/** Running, but the user hasn't granted this app permission yet (we can ask). */
NEEDS_PERMISSION,
/** Shizuku IS installed but its service isn't started — worth reminding this user. */
INSTALLED_NOT_RUNNING,
/** Shizuku isn't installed at all — say nothing; this user doesn't use the shell tier. */
NOT_INSTALLED,
}
/** Is the Shizuku manager installed? Needs the <queries> entry on Android 11+. */
fun isInstalled(context: Context): Boolean = runCatching {
context.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0); true
}.getOrDefault(false)
fun current(context: Context): State = when {
runCatching { Shizuku.pingBinder() }.getOrDefault(false) ->
if (runCatching { Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED }
.getOrDefault(false)
) State.READY else State.NEEDS_PERMISSION
isInstalled(context) -> State.INSTALLED_NOT_RUNNING
else -> State.NOT_INSTALLED
}
/**
* UI one-liner, or null when nothing should be said. Users without Shizuku get no nag; users
* who have it installed but stopped get the reminder that makes the difference between a
* full run and a silently skipped shell tier.
*/
fun describe(s: State): String? = when (s) {
State.READY -> "Shizuku ready — shell-tier tests will run"
State.NEEDS_PERMISSION -> "Shizuku is running but not authorised — it will ask on first use"
State.INSTALLED_NOT_RUNNING -> "Shizuku is installed but not running — start it to include shell-tier tests"
State.NOT_INSTALLED -> null
}
/**
* Reports the state now and on every binder transition. Returns a function that removes the
* listeners again (call it from onCleared).
*/
fun observe(context: Context, onChange: (State) -> Unit): () -> Unit {
val app = context.applicationContext
val received = Shizuku.OnBinderReceivedListener { onChange(current(app)) }
val dead = Shizuku.OnBinderDeadListener { onChange(current(app)) }
// "Sticky" fires immediately if the binder already arrived before we registered.
runCatching { Shizuku.addBinderReceivedListenerSticky(received) }
runCatching { Shizuku.addBinderDeadListener(dead) }
onChange(current(app))
return {
runCatching { Shizuku.removeBinderReceivedListener(received) }
runCatching { Shizuku.removeBinderDeadListener(dead) }
}
}
}