Recently Written · git

ink-dim

Ink Dim: tap to set the frontlight of a Viwoods AiPaper reader below the lowest system level. Tap again to go back.

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

Log | Files | Refs


app/src/main/java/dev/equwal/inkdim/Shell.kt (4703 bytes)

1 // SPDX-License-Identifier: GPL-3.0-or-later
2 // Copyright (c) 2026 equwal
3 package dev.equwal.inkdim
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 writes the light node.
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 object Shell {
26 
27     private const val TAG = "InkDim"
28 
29     const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
30     private const val REQUEST_CODE = 7301
31 
32     enum class State { NOT_INSTALLED, NOT_RUNNING, NO_PERMISSION, READY }
33 
34     private val main = Handler(Looper.getMainLooper())
35     private val worker = Executors.newSingleThreadExecutor()
36     private val listeners = ArrayList<() -> Unit>()
37     private var hooked = false
38 
39     val ready: Boolean get() = alive() && permitted()
40 
41     fun state(c: Context): State = when {
42         ready -> State.READY
43         !installed(c) -> State.NOT_INSTALLED
44         !alive() -> State.NOT_RUNNING
45         else -> State.NO_PERMISSION
46     }
47 
48     fun describe(c: Context): String = when (state(c)) {
49         State.READY -> "On"
50         State.NO_PERMISSION -> "Shizuku runs, permission needed"
51         State.NOT_RUNNING -> "Shizuku is installed, not started"
52         State.NOT_INSTALLED -> "Off, needs the free Shizuku app"
53     }
54 
55     /** Called when the state can have changed. Always on the main thread. */
56     fun onChange(l: () -> Unit) { if (l !in listeners) listeners.add(l) }
57     fun removeOnChange(l: () -> Unit) { listeners.remove(l) }
58     private fun changed() = main.post { ArrayList(listeners).forEach { it() } }
59 
60     private fun installed(c: Context): Boolean =
61         runCatching { c.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0); true }.getOrDefault(false)
62 
63     private fun alive(): Boolean = runCatching { Shizuku.pingBinder() }.getOrDefault(false)
64 
65     private fun permitted(): Boolean = runCatching {
66         !Shizuku.isPreV11() && Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
67     }.getOrDefault(false)
68 
69     /** Safe to call often. It registers for Shizuku's comings and goings once. */
70     fun connect(c: Context) {
71         if (!hooked) {
72             hooked = true
73             runCatching {
74                 Shizuku.addBinderReceivedListenerSticky { changed() }
75                 Shizuku.addBinderDeadListener { changed() }
76                 Shizuku.addRequestPermissionResultListener { _, _ -> changed() }
77             }
78         }
79         changed()
80     }
81 
82     fun requestPermission() {
83         runCatching { if (alive() && !permitted()) Shizuku.requestPermission(REQUEST_CODE) }
84     }
85 
86     // ---- processes ---------------------------------------------------------
87 
88     /**
89      * Shizuku.newProcess is private in the v13 API, but it is the supported wire
90      * call underneath, and the one that every shell-style client uses.
91      */
92     private val newProcess by lazy {
93         Shizuku::class.java.getDeclaredMethod(
94             "newProcess", Array<String>::class.java, Array<String>::class.java, String::class.java
95         ).apply { isAccessible = true }
96     }
97 
98     private fun start(vararg argv: String): Process? = runCatching {
99         newProcess.invoke(null, argv, null, null) as Process
100     }.onFailure { Log.w(TAG, "shell process failed: " + argv.joinToString(" "), it) }.getOrNull()
101 
102     data class Result(val exit: Int, val output: String) {
103         val ok: Boolean get() = exit == 0
104     }
105 
106     private fun execNow(command: String): Result {
107         val p = start("sh", "-c", "$command 2>&1") ?: return Result(-1, "no shell access")
108         return runCatching {
109             val out = p.inputStream.bufferedReader().readText()
110             Result(p.waitFor(), out)
111         }.getOrElse { Result(-1, it.toString()) }
112     }
113 
114     /** Runs off the main thread. [done] comes back on the main thread. */
115     fun run(command: String, done: ((Result) -> Unit)? = null) {
116         worker.execute {
117             val r = if (ready) execNow(command) else Result(-1, "no shell access")
118             if (!r.ok) Log.i(TAG, "shell: " + command + " -> " + r.exit + " " + r.output.take(200))
119             done?.let { main.post { it(r) } }
120         }
121     }
122 }