diff --git a/docs/build-status.md b/docs/build-status.md index 7b4106a..a16379b 100644 --- a/docs/build-status.md +++ b/docs/build-status.md @@ -520,3 +520,14 @@ Detection is listener-based (`addBinderReceivedListenerSticky` + binder-dead), b `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. + +### Shizuku banner is actionable; progress + cancel verified on-device (2026-08-01) +Tapping the shell-tier banner now does the right thing per state: **installed-but-stopped** → +deep-links into the Shizuku app (a third-party app *cannot* start Shizuku itself; the wireless- +debugging pairing flow is privileged and lives in that app, so taking the user there in one tap is +the best available), **running-but-unauthorised** → fires the Shizuku permission request directly. +The hint line states which action the tap performs. +Verified on-device in one screenshot: progress bar at "test 4 of 8 · icmp.ping6 · ~33s left", +Cancel button beside the disabled Run button, title clear of the status bar/cutout, and the banner +having live-switched from "installed but not running" to "running but not authorised" via the +binder listener when Shizuku was started mid-session. 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 1c42332..86f45eb 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 @@ -12,6 +12,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* +import androidx.compose.foundation.clickable import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -64,6 +65,19 @@ class MainActivity : ComponentActivity() { state = vm.state, onRun = { vm.run() }, onCancel = vm::cancel, + onShizukuAction = { + // Shizuku can only be started from its own app (the pairing flow lives + // there), so send the user straight to it; if it is already running we + // just need permission. + when (vm.state.shizukuState) { + app.echo_lot.shizuku.ShizukuAvailability.State.NEEDS_PERMISSION -> + app.echo_lot.shizuku.ShizukuAvailability.requestPermission() + app.echo_lot.shizuku.ShizukuAvailability.State.INSTALLED_NOT_RUNNING -> + app.echo_lot.shizuku.ShizukuAvailability.launchIntent(this) + ?.let { startActivity(it) } + else -> Unit + } + }, onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) }, ) } @@ -99,6 +113,7 @@ private fun EcholotScreen( state: UiState, onRun: () -> Unit, onCancel: () -> Unit, + onShizukuAction: () -> Unit, onExport: (MeasurementDocument) -> Unit, ) { Column( @@ -119,15 +134,23 @@ private fun EcholotScreen( // only users who actually use it get reminded that it must be running. state.shizukuNotice?.let { notice -> Card( - Modifier.fillMaxWidth(), + Modifier.fillMaxWidth().let { m -> + if (state.shizukuHint != null) m.clickable { onShizukuAction() } else m + }, 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), - ) + Column(Modifier.padding(10.dp)) { + Text( + notice, fontSize = 12.sp, + color = if (state.shizukuReady) Color(0xFF9CCFA8) else Color(0xFFFFD08A), + ) + state.shizukuHint?.let { + Text(it, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, + color = Color(0xFFFFB454)) + } + } } } 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 f9042e2..3ad4e0a 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 @@ -41,6 +41,8 @@ data class UiState( /** Shell-tier readiness, shown before a run; null message = say nothing (Shizuku not installed). */ val shizukuNotice: String? = null, val shizukuReady: Boolean = false, + val shizukuHint: String? = null, + val shizukuState: ShizukuAvailability.State = ShizukuAvailability.State.NOT_INSTALLED, ) /** @@ -65,6 +67,8 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { state = state.copy( shizukuNotice = ShizukuAvailability.describe(st), shizukuReady = st == ShizukuAvailability.State.READY, + shizukuHint = ShizukuAvailability.actionHint(st), + shizukuState = st, ) } } diff --git a/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/ShizukuAvailability.kt b/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/ShizukuAvailability.kt index 491a1d8..1a598a9 100644 --- a/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/ShizukuAvailability.kt +++ b/echolot-app/core-shizuku/src/main/kotlin/app/echo_lot/shizuku/ShizukuAvailability.kt @@ -58,6 +58,28 @@ object ShizukuAvailability { State.NOT_INSTALLED -> null } + /** + * Intent that opens the Shizuku manager, where the user starts the service ("Start with + * wireless debugging" / root). A third-party app cannot start Shizuku itself — the pairing + * flow lives inside that app — so the best we can do is take the user straight there. + * Null when Shizuku isn't installed or exposes no launcher activity. + */ + fun launchIntent(context: Context): android.content.Intent? = + context.packageManager.getLaunchIntentForPackage(SHIZUKU_PACKAGE) + ?.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + + /** Asks Shizuku for permission (only meaningful while it is running). */ + fun requestPermission(requestCode: Int = 0xE1) { + runCatching { Shizuku.requestPermission(requestCode) } + } + + /** What tapping the notice should do, so the UI can label it honestly. */ + fun actionHint(s: State): String? = when (s) { + State.INSTALLED_NOT_RUNNING -> "Tap to open Shizuku and start it" + State.NEEDS_PERMISSION -> "Tap to grant permission" + else -> null + } + /** * Reports the state now and on every binder transition. Returns a function that removes the * listeners again (call it from onCleared).