app/src/main/java/dev/equwal/inkrecents/Shell.kt (5156 bytes)
1 // SPDX-License-Identifier: GPL-3.0-or-later 2 // Copyright (c) 2026 equwal 3 package dev.equwal.inkrecents 4 5 import android.content.Context 6 import android.content.pm.PackageManager 7 import android.os.Handler 8 import android.os.Looper 9 import android.util.Log 10 import rikka.shizuku.Shizuku 11 import java.util.concurrent.Executors 12 13 /** 14 * Shell access, by way of Shizuku. 15 * 16 * Shizuku is a separate, free app. The user starts it from the device itself 17 * through Android's wireless debugging. There is no computer and no root. Once 18 * it runs and has said yes to this app, this object can start a process under 19 * the shell uid. That is what reads the real task list and closes a task. 20 * 21 * It uses Shizuku's plain remote-process call and nothing more. Shizuku's bound 22 * "user service" is the tidier design, but its starter dies inside 23 * LoadedApk.makeApplication on Android 16 before our code is loaded. 24 * 25 * Shell access is optional. Without it the app uses the usage log. 26 */ 27 object Shell { 28 29 private const val TAG = "InkRecents" 30 31 const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" 32 private const val REQUEST_CODE = 7301 33 34 enum class State { NOT_INSTALLED, NOT_RUNNING, NO_PERMISSION, READY } 35 36 private val main = Handler(Looper.getMainLooper()) 37 private val worker = Executors.newSingleThreadExecutor() 38 private val listeners = ArrayList<() -> Unit>() 39 private var hooked = false 40 41 val ready: Boolean get() = alive() && permitted() 42 43 fun state(c: Context): State = when { 44 ready -> State.READY 45 !installed(c) -> State.NOT_INSTALLED 46 !alive() -> State.NOT_RUNNING 47 else -> State.NO_PERMISSION 48 } 49 50 fun describe(c: Context): String = when (state(c)) { 51 State.READY -> "On" 52 State.NO_PERMISSION -> "Shizuku runs, permission needed" 53 State.NOT_RUNNING -> "Shizuku is installed, not started" 54 State.NOT_INSTALLED -> "Off, needs the free Shizuku app" 55 } 56 57 /** Called when the state can have changed. Always on the main thread. */ 58 fun onChange(l: () -> Unit) { if (l !in listeners) listeners.add(l) } 59 fun removeOnChange(l: () -> Unit) { listeners.remove(l) } 60 private fun changed() = main.post { ArrayList(listeners).forEach { it() } } 61 62 private fun installed(c: Context): Boolean = 63 runCatching { c.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0); true }.getOrDefault(false) 64 65 private fun alive(): Boolean = runCatching { Shizuku.pingBinder() }.getOrDefault(false) 66 67 private fun permitted(): Boolean = runCatching { 68 !Shizuku.isPreV11() && Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED 69 }.getOrDefault(false) 70 71 /** Safe to call often. It registers for Shizuku's comings and goings once. */ 72 fun connect(c: Context) { 73 if (!hooked) { 74 hooked = true 75 runCatching { 76 Shizuku.addBinderReceivedListenerSticky { changed() } 77 Shizuku.addBinderDeadListener { changed() } 78 Shizuku.addRequestPermissionResultListener { _, _ -> changed() } 79 } 80 } 81 changed() 82 } 83 84 fun requestPermission() { 85 runCatching { if (alive() && !permitted()) Shizuku.requestPermission(REQUEST_CODE) } 86 } 87 88 // ---- processes --------------------------------------------------------- 89 90 /** 91 * Shizuku.newProcess is private in the v13 API, but it is the supported wire 92 * call underneath, and the one that every shell-style client uses. 93 */ 94 private val newProcess by lazy { 95 Shizuku::class.java.getDeclaredMethod( 96 "newProcess", Array<String>::class.java, Array<String>::class.java, String::class.java 97 ).apply { isAccessible = true } 98 } 99 100 private fun start(vararg argv: String): Process? = runCatching { 101 newProcess.invoke(null, argv, null, null) as Process 102 }.onFailure { Log.w(TAG, "shell process failed: " + argv.joinToString(" "), it) }.getOrNull() 103 104 data class Result(val exit: Int, val output: String) { 105 val ok: Boolean get() = exit == 0 106 } 107 108 private fun execNow(command: String): Result { 109 val p = start("sh", "-c", "$command 2>&1") ?: return Result(-1, "no shell access") 110 return runCatching { 111 val out = p.inputStream.bufferedReader().readText() 112 Result(p.waitFor(), out) 113 }.getOrElse { Result(-1, it.toString()) } 114 } 115 116 /** Runs off the main thread. [done] comes back on the main thread. */ 117 fun run(command: String, done: ((Result) -> Unit)? = null) { 118 worker.execute { 119 val r = if (ready) execNow(command) else Result(-1, "no shell access") 120 if (!r.ok) Log.i(TAG, "shell: " + command + " -> " + r.exit + " " + r.output.take(200)) 121 done?.let { main.post { it(r) } } 122 } 123 } 124 125 /** Commands in order. [done] gets true only if every command succeeded. */ 126 fun runAll(commands: List<String>, done: ((Boolean) -> Unit)? = null) { 127 worker.execute { 128 var allOk = true 129 for (c in commands) if (!ready || !execNow(c).ok) allOk = false 130 done?.let { main.post { it(allOk) } } 131 } 132 } 133 }