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 }