From 5b02d408027246e96c8d4c3c9a41260c837d9078 Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sun, 2 Aug 2026 08:37:45 +0200 Subject: [PATCH] app: notice when Shizuku is authorised MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The banner watched only for the binder arriving or dying, and granting permission does neither. So after the user tapped the banner, answered the dialog and came back, it still read "running but not authorised" — wrong at precisely the moment they were looking for confirmation that it had worked. Two listeners were missing, because there are two ways this changes. Answering our own request now fires OnRequestPermissionResultListener. That is not enough on its own: permission can equally be granted inside Shizuku's own app, and Shizuku started or stopped there, none of which calls back into this process — so the state is re-read whenever the screen comes forward, which is the only thing that covers every route. Co-Authored-By: Claude Opus 5 --- .../kotlin/app/echo_lot/app/MainActivity.kt | 14 ++++++++++++++ .../kotlin/app/echo_lot/app/RunViewModel.kt | 17 +++++++++++++++++ .../app/echo_lot/shizuku/ShizukuAvailability.kt | 16 ++++++++++++++-- 3 files changed, 45 insertions(+), 2 deletions(-) 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 603be7e..972f692 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 @@ -149,6 +149,20 @@ class MainActivity : ComponentActivity() { screen = Screen.SETTINGS } } + // Shizuku can be started, stopped or authorised in its own app, where nothing + // calls back into this process. Asking again each time this screen comes + // forward is what makes the banner right after the user has been away to fix + // it — which is exactly the moment they look at it. + val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current + androidx.compose.runtime.DisposableEffect(lifecycleOwner) { + val obs = androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) { + vm.refreshShizuku() + } + } + lifecycleOwner.lifecycle.addObserver(obs) + onDispose { lifecycleOwner.lifecycle.removeObserver(obs) } + } androidx.compose.runtime.LaunchedEffect(autorun) { if (autorun) vm.run(devUpload = true) } 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 2160fcc..05685ce 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 @@ -103,6 +103,23 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { } } + /** + * Re-reads the shell tier's state, for when it changed somewhere this process cannot see. + * + * Permission can be granted inside Shizuku's own app, and Shizuku can be started or stopped + * there too; none of that calls back here. Asking again on resume is the only way to be right + * after the user has been somewhere else to fix it. + */ + fun refreshShizuku() { + val st = ShizukuAvailability.current(getApplication()) + state = state.copy( + shizukuNotice = ShizukuAvailability.describe(st), + shizukuReady = st == ShizukuAvailability.State.READY, + shizukuHint = ShizukuAvailability.actionHint(st), + shizukuState = st, + ) + } + override fun onCleared() { stopShizukuObserver() super.onCleared() 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 d634041..42f2c4c 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 @@ -98,20 +98,32 @@ object ShizukuAvailability { .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) /** - * Reports the state now and on every binder transition. Returns a function that removes the - * listeners again (call it from onCleared). + * Reports the state now, on every binder transition, and when a permission request is + * answered. Returns a function that removes the listeners again (call it from onCleared). + * + * The permission listener matters as much as the binder ones: granting permission does not + * make the binder arrive or die, so without it the banner still read "running but not + * authorised" after the user had just authorised it — the one moment they are looking for + * confirmation that it worked. + * + * It is still not sufficient on its own. Permission can be granted inside Shizuku's own app, + * where nothing calls back into this process at all, so callers should re-check on resume as + * well; see [current]. */ 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)) } + val permission = Shizuku.OnRequestPermissionResultListener { _, _ -> onChange(current(app)) } // "Sticky" fires immediately if the binder already arrived before we registered. runCatching { Shizuku.addBinderReceivedListenerSticky(received) } runCatching { Shizuku.addBinderDeadListener(dead) } + runCatching { Shizuku.addRequestPermissionResultListener(permission) } onChange(current(app)) return { runCatching { Shizuku.removeBinderReceivedListener(received) } runCatching { Shizuku.removeBinderDeadListener(dead) } + runCatching { Shizuku.removeRequestPermissionResultListener(permission) } } } }