diff --git a/docs/build-status.md b/docs/build-status.md index 4e7f60c..7b4106a 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -507,3 +507,16 @@ an elvis-operator bug that printed "no UPnP response" even when UPnP data was pr - **Insets/cutout**: Android 15 draws edge-to-edge by default, so the title was running under the status-bar clock and the camera cutout. The root column now uses `safeDrawingPadding()`, which covers status bar, navigation bar and display cutout. + +### Shell-tier readiness shown before a run (2026-08-01) +`ShizukuAvailability` distinguishes four states and the UI only speaks when it is actionable: +- **NOT_INSTALLED → says nothing.** Users who don't use Shizuku are never nagged. +- **INSTALLED_NOT_RUNNING → amber banner** "Shizuku is installed but not running — start it to + include shell-tier tests". This is the case worth reminding about: the user has it, but a + stopped service silently costs them the whole shell tier. +- NEEDS_PERMISSION → "running but not authorised, it will ask on first use". +- READY → green "shell-tier tests will run". +Detection is listener-based (`addBinderReceivedListenerSticky` + binder-dead), because +`pingBinder()` is only truthful once ShizukuProvider has delivered the binder — a one-shot poll at +launch would show a false "not running". Installed-vs-not needs the `` package-visibility +entry on Android 11+. Verified on-device: with shizuku_server stopped the banner appears correctly. diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt index bf43ffa..1c42332 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt @@ -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") 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 3daf3ac..f9042e2 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 @@ -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() private var runIds: RunIds = RunIds() diff --git a/echolot-app/core-shizuku/src/main/AndroidManifest.xml b/echolot-app/core-shizuku/src/main/AndroidManifest.xml index 21a1d02..4db7c1f 100644 --- a/echolot-app/core-shizuku/src/main/AndroidManifest.xml +++ b/echolot-app/core-shizuku/src/main/AndroidManifest.xml @@ -5,6 +5,12 @@ + + + + + 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) } + } + } +}