Recently Written · git

ink-recents

Ink Recents: a recent-apps switcher drawn for e-ink readers. No animation, swipe up to close, swipe down to close the others.

git clone https://github.com/equwal/ink-recents

Log | Files | Refs


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 }