commit b175979d4b009fba543fa5fd47428c20a38e88d4 truex <truex@equwal.com> 2026-09-20 21:22:32 -0700 Read the Power button through shell access; merge the Power and Navigation UI With shell access (Shizuku, started on the device through wireless debugging) the app reads KEY_POWER straight from the kernel input node and feeds it to the same gesture engine as every other key. Power gains any number of taps, hold, and true combinations with other keys. Defaults asked for: tap Back, hold Home closing the keyboard first, double tap Recents; triple tap locks, so there is still a way to sleep. While that is active the firmware's own reactions are switched to nothing: short press, long press, the double-tap camera gesture. That is what stops a single tap from grabbing a double tap - the firmware was set to Go Home on short press and acted before anything else could. The engine already waits out the multi-tap window when a double tap is bound. The values are saved first and restored the moment shell access, the licence, the bindings or the service stop justifying it. Restoring needs no shell: the app grants itself WRITE_SECURE_SETTINGS through the shell on first contact. A restart that leaves Shizuku stopped leaves a normal Power button. The press that wakes the screen is never a gesture. Power + Volume up stays the power menu. Shizuku's bound user service cannot be used here: its starter dies inside LoadedApk.makeApplication on Android 16 before our class loads. Shell access uses Shizuku's remote process call instead, and the key watcher is a pipe from `getevent -q` on the nodes that declare the key. UI. The assistant, camera and wallet "channels" were presented as equals of the key filter; they are side doors for Power when there is no shell access, and now live on the Power screen as that. The Power screen shows the direct route or the side doors, never both. Navigation applies the bar and gestures itself through the shell and only falls back to printing adb commands. The main screen gains a Setup section: key remapping, shell access. New action: Home, closing the keyboard first.
app/build.gradle.kts | 7 + app/src/main/AndroidManifest.xml | 12 + .../equwal/assistkey/engine/KeyFilterService.kt | 58 +++- .../dev/equwal/assistkey/model/GlobalAction.kt | 2 + .../java/dev/equwal/assistkey/native/NavNative.kt | 31 +- .../dev/equwal/assistkey/route/ActionRouter.kt | 18 ++ .../dev/equwal/assistkey/shell/PowerControl.kt | 136 ++++++++ .../main/java/dev/equwal/assistkey/shell/Shell.kt | 228 +++++++++++++ .../java/dev/equwal/assistkey/store/Bindings.kt | 57 ++-- .../main/java/dev/equwal/assistkey/store/Store.kt | 4 + .../java/dev/equwal/assistkey/ui/MainActivity.kt | 56 ++-- .../dev/equwal/assistkey/ui/NavigationActivity.kt | 261 ++++++++------- .../java/dev/equwal/assistkey/ui/PowerActivity.kt | 351 +++++++++------------ .../java/dev/equwal/assistkey/ui/ShellActivity.kt | 122 +++++++ .../main/res/xml/accessibility_service_config.xml | 2 +- 15 files changed, 936 insertions(+), 409 deletions(-)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 369b278..e5c9e38 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -121,4 +121,11 @@ dependencies { implementation("com.android.billingclient:billing:8.3.0") { exclude(group = "com.google.android.datatransport") } + + // Shell access. Shizuku (MIT) lets the app run a small service of its own + // under the shell uid, which is what reads the Power key and switches the + // settings Android keeps from ordinary apps. The user starts Shizuku through + // wireless debugging; nothing here needs root or a computer. + implementation("dev.rikka.shizuku:api:13.1.5") + implementation("dev.rikka.shizuku:provider:13.1.5") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 90b0b92..e66bed8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,6 +29,8 @@ <package android:name="com.viwoods.launcher" /> <package android:name="com.viwoods.viwoodsai" /> <package android:name="com.viwoods.settings" /> + <!-- Shizuku, for shell access. --> + <package android:name="moe.shizuku.privileged.api" /> </queries> <application @@ -58,6 +60,7 @@ <activity android:name=".ui.KeyTesterActivity" android:exported="false" /> <activity android:name=".ui.LicenseActivity" android:exported="false" /> <activity android:name=".ui.NavigationActivity" android:exported="false" /> + <activity android:name=".ui.ShellActivity" android:exported="false" /> <!-- The three impersonation entry points. Each starts disabled: the app @@ -162,6 +165,15 @@ android:resource="@xml/quick_access_wallet" /> </service> + <!-- How Shizuku hands this app its binder. --> + <provider + android:name="rikka.shizuku.ShizukuProvider" + android:authorities="${applicationId}.shizuku" + android:enabled="true" + android:exported="true" + android:multiprocess="false" + android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" /> + <!-- The accessibility channel. This is the only component that sees the AI key and the volume keys; Power never reaches it. diff --git a/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt b/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt index 2f55726..c3908d1 100644 --- a/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt +++ b/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt @@ -19,6 +19,8 @@ import dev.equwal.assistkey.model.Trigger import dev.equwal.assistkey.native.ViwoodsBridge import dev.equwal.assistkey.route.ActionRouter import dev.equwal.assistkey.route.ServiceHolder +import dev.equwal.assistkey.shell.PowerControl +import dev.equwal.assistkey.shell.Shell import dev.equwal.assistkey.store.Store /** @@ -40,18 +42,58 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host { ServiceHolder.service = this // The service starts at boot and may run for weeks without the settings // screen ever opening, so it keeps the licence fresh on its own. - PlayBilling.refresh(this) + PlayBilling.refresh(this) { syncPower() } + + Shell.onChange(shellChanged) + Store.onBindingsChanged = { syncPower() } + registerReceiver(screenOn, android.content.IntentFilter(android.content.Intent.ACTION_SCREEN_ON)) + Shell.connect(this) + syncPower() + } + + // ---- the Power button, when there is shell access ----------------------------- + + private val shellChanged: () -> Unit = { syncPower() } + private var powerIgnoredUntilUp = false + + /** A press that wakes the reader is not a gesture. */ + private val screenOn = object : android.content.BroadcastReceiver() { + override fun onReceive(c: android.content.Context?, i: android.content.Intent?) { + powerIgnoredUntilUp = true + if (::engine.isInitialized) engine.onCancel() + } + } + + fun syncPower() = PowerControl.sync(this, serviceRunning = ServiceHolder.service === this, ::onRawPower) + + private fun onRawPower(down: Boolean, at: Long) { + if (!::engine.isInitialized) return + if (down) { + val awake = getSystemService(android.os.PowerManager::class.java)?.isInteractive != false + powerIgnoredUntilUp = !awake + if (awake) engine.onDown(HwKey.POWER, at, 0) + } else { + if (powerIgnoredUntilUp) powerIgnoredUntilUp = false else engine.onUp(HwKey.POWER, at) + } + } + + private fun stopPower() { + Shell.removeOnChange(shellChanged) + Store.onBindingsChanged = null + runCatching { unregisterReceiver(screenOn) } + ServiceHolder.service = null + syncPower() // with no service running this hands Power back to the firmware } override fun onUnbind(intent: android.content.Intent?): Boolean { cancelAll() - ServiceHolder.service = null + stopPower() return super.onUnbind(intent) } override fun onDestroy() { cancelAll() - ServiceHolder.service = null + stopPower() super.onDestroy() } @@ -156,13 +198,14 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host { private fun bindings() = Store.bindings(this) - override fun maxTaps(keys: Set<HwKey>): Int = bindings().maxTaps(keys) + override fun maxTaps(keys: Set<HwKey>): Int = bindings().maxTaps(keys, PowerControl.active) - override fun hasHold(keys: Set<HwKey>): Boolean = bindings().hasHold(keys) + override fun hasHold(keys: Set<HwKey>): Boolean = bindings().hasHold(keys, PowerControl.active) override fun isBound(trigger: Trigger): Boolean = bindings().isBound(trigger) - override fun chordPartners(key: HwKey): Set<HwKey> = bindings().chordPartners(key) + override fun chordPartners(key: HwKey): Set<HwKey> = + bindings().chordPartners(key, PowerControl.active) companion object { /** How long after a Power hold a key press still counts as a combination. */ @@ -192,7 +235,8 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host { this, ActionSpec(ActionKind.LAUNCH_COMPONENT, ViwoodsBridge.aiTarget(this)) ) - HwKey.POWER -> Unit + // An unbound Power gesture does what Power always did. + HwKey.POWER -> performGlobalAction(GLOBAL_ACTION_LOCK_SCREEN) } } diff --git a/app/src/main/java/dev/equwal/assistkey/model/GlobalAction.kt b/app/src/main/java/dev/equwal/assistkey/model/GlobalAction.kt index af542ea..28ac48f 100644 --- a/app/src/main/java/dev/equwal/assistkey/model/GlobalAction.kt +++ b/app/src/main/java/dev/equwal/assistkey/model/GlobalAction.kt @@ -11,6 +11,8 @@ import android.os.Build enum class GlobalAction(val id: Int, val label: String, val minSdk: Int = 30) { BACK(AS.GLOBAL_ACTION_BACK, "Back"), HOME(AS.GLOBAL_ACTION_HOME, "Home"), + /** Not a framework action: Back first if a keyboard is up, then Home. */ + HOME_CLOSE_IME(-1, "Home, closing the keyboard first"), RECENTS(AS.GLOBAL_ACTION_RECENTS, "Recent apps"), NOTIFICATIONS(AS.GLOBAL_ACTION_NOTIFICATIONS, "Notification shade"), QUICK_SETTINGS(AS.GLOBAL_ACTION_QUICK_SETTINGS, "Quick settings"), diff --git a/app/src/main/java/dev/equwal/assistkey/native/NavNative.kt b/app/src/main/java/dev/equwal/assistkey/native/NavNative.kt index a44fd6a..04ac77e 100644 --- a/app/src/main/java/dev/equwal/assistkey/native/NavNative.kt +++ b/app/src/main/java/dev/equwal/assistkey/native/NavNative.kt @@ -47,17 +47,30 @@ object NavNative { return v?.trim() != "1" } - /** The commands that produce the wanted state, in the order they must run. */ - fun commands(buttons: Boolean, gestures: Boolean): List<String> { - val out = ArrayList<String>() - out += "adb shell cmd overlay enable-exclusive --category " + OVERLAY + - (if (buttons) "threebutton" else "gestural") - out += "adb shell settings put system " + GESTURE_BOTTOM + " " + (if (gestures) "0" else "1") + /** + * What a shell has to run to produce the wanted state, in the order it must + * run. The pause and the double write are not decoration: SystemUI rebuilds + * itself when the overlay changes and only notices the gesture setting if it + * changes afterwards. + */ + fun shellCommands(buttons: Boolean, gestures: Boolean): List<String> { + val off = if (gestures) "0" else "1" + val nudge = if (gestures) "1" else "0" // Only meaningful without the bar, but harmless with it, and setting it // every time means a later switch to gestural mode cannot surprise. val scale = if (gestures) "0.6" else "0" - out += "adb shell settings put secure back_gesture_inset_scale_left $scale" - out += "adb shell settings put secure back_gesture_inset_scale_right $scale" - return out + return listOf( + "cmd overlay enable-exclusive --category " + OVERLAY + (if (buttons) "threebutton" else "gestural"), + "sleep 4", + "settings put system $GESTURE_BOTTOM $nudge", + "sleep 1", + "settings put system $GESTURE_BOTTOM $off", + "settings put secure back_gesture_inset_scale_left $scale", + "settings put secure back_gesture_inset_scale_right $scale" + ) } + + /** The same, for someone typing them at a computer. */ + fun commands(buttons: Boolean, gestures: Boolean): List<String> = + shellCommands(buttons, gestures).filterNot { it.startsWith("sleep") }.distinct().map { "adb shell $it" } } diff --git a/app/src/main/java/dev/equwal/assistkey/route/ActionRouter.kt b/app/src/main/java/dev/equwal/assistkey/route/ActionRouter.kt index eb5225f..c36618c 100644 --- a/app/src/main/java/dev/equwal/assistkey/route/ActionRouter.kt +++ b/app/src/main/java/dev/equwal/assistkey/route/ActionRouter.kt @@ -43,9 +43,27 @@ object ActionRouter { val svc = ServiceHolder.service ?: return false val action = GlobalAction.fromName(name) ?: return false if (!action.available) return false + if (action == GlobalAction.HOME_CLOSE_IME) return homeClosingKeyboard(svc) return svc.performGlobalAction(action.id) } + /** + * Home alone leaves a keyboard floating over the launcher on some firmware, + * so an open keyboard is dismissed first. Seeing the keyboard's window needs + * flagRetrieveInteractiveWindows; if the list is unavailable this is Home. + */ + private fun homeClosingKeyboard(svc: android.accessibilityservice.AccessibilityService): Boolean { + val keyboardUp = runCatching { + svc.windows.any { it.type == android.view.accessibility.AccessibilityWindowInfo.TYPE_INPUT_METHOD } + }.getOrDefault(false) + if (!keyboardUp) return svc.performGlobalAction(GlobalAction.HOME.id) + svc.performGlobalAction(GlobalAction.BACK.id) + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed( + { svc.performGlobalAction(GlobalAction.HOME.id) }, 180L + ) + return true + } + private fun volume(ctx: Context, dir: String): Boolean { val am = ctx.getSystemService(AudioManager::class.java) ?: return false val adjust = when (dir) { diff --git a/app/src/main/java/dev/equwal/assistkey/shell/PowerControl.kt b/app/src/main/java/dev/equwal/assistkey/shell/PowerControl.kt new file mode 100644 index 0000000..a9741bc --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/shell/PowerControl.kt @@ -0,0 +1,136 @@ +package dev.equwal.assistkey.shell + +import android.content.Context +import android.provider.Settings +import android.util.Log +import dev.equwal.assistkey.license.License +import dev.equwal.assistkey.store.Store + +/** + * Taking the Power button over, and giving it back. + * + * No app is ever shown the Power key: the window manager acts on it before + * input reaches anybody. But the shell user can read the kernel input nodes, + * so with shell access the button can be watched from outside the framework. + * To make that useful the firmware has to stop reacting first - short press, + * long press and the double-tap camera gesture are all switched to "nothing" - + * and from then on every tap, double tap and hold is ours to time and to bind, + * through the same gesture engine that serves the other keys. + * + * The firmware's own values are saved before they are touched and restored the + * moment any of this stops being true: shell access lost, the app locked, the + * user switching it off. Restoring does not need shell access; the app grants + * itself WRITE_SECURE_SETTINGS through the shell the first time it connects, + * for exactly this reason. A restart that leaves Shizuku stopped therefore + * leaves a Power button that works normally, not one that does nothing. + * + * Power + Volume up is always left opening the power menu. + */ +object PowerControl { + + private const val TAG = "AssistKey" + private const val PREFS = "assistkey_power" + private const val K_WANTED = "wanted" + private const val K_TAKEN = "taken" + private const val K_SAVED = "saved_" + + const val KEY_POWER = 116 + + private class Knob(val scope: String, val name: String, val managed: String) + + private val knobs = listOf( + Knob("global", "power_button_short_press", "0"), + Knob("global", "power_button_long_press", "0"), + Knob("secure", "camera_double_tap_power_gesture_disabled", "1"), + Knob("secure", "double_tap_power_button_gesture_enabled", "0"), + // The way out, whatever else happens. + Knob("global", "key_chord_power_volume_up", "2") + ) + + private val main = android.os.Handler(android.os.Looper.getMainLooper()) + + /** True while raw Power presses are reaching the gesture engine. */ + @Volatile var active = false + private set + + private fun prefs(c: Context) = c.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + /** The user's switch. On by default: it only takes effect with shell access. */ + fun wanted(c: Context): Boolean = prefs(c).getBoolean(K_WANTED, true) + + fun setWanted(c: Context, on: Boolean) { + prefs(c).edit().putBoolean(K_WANTED, on).apply() + } + + private fun anyPowerBinding(c: Context): Boolean = + Store.bindings(c).all().any { (t, _) -> dev.equwal.assistkey.model.HwKey.POWER in t.keys } + + /** + * Brings reality into line with what should be true right now. Cheap and + * idempotent; call it whenever shell access, the licence, the bindings or + * the service may have changed. [onKey] is where presses are delivered. + */ + fun sync(c: Context, serviceRunning: Boolean, onKey: (down: Boolean, at: Long) -> Unit) { + val should = serviceRunning && Shell.ready && wanted(c) && + License.active(c) && anyPowerBinding(c) + when { + should && !active -> take(c, onKey) + !should && (active || prefs(c).getBoolean(K_TAKEN, false)) -> release(c) + } + } + + private var taking = false + + private fun take(c: Context, onKey: (Boolean, Long) -> Unit) { + if (taking) return + taking = true + main.postDelayed({ taking = false }, 8000L) + val p = prefs(c) + val read = knobs.joinToString("; ") { "settings get " + it.scope + " " + it.name } + Shell.run(read) { r -> + if (!r.ok) return@run + // Save once. A second take() after a crash must not record our own + // values as if they were the firmware's. + if (!p.getBoolean(K_TAKEN, false)) { + val lines = r.output.trim().lines() + val e = p.edit() + knobs.forEachIndexed { i, k -> e.putString(K_SAVED + k.name, lines.getOrNull(i)?.trim() ?: "null") } + e.putBoolean(K_TAKEN, true).apply() + } + Shell.runAll(knobs.map { "settings put " + it.scope + " " + it.name + " " + it.managed }) { + Shell.watchKeys(setOf(KEY_POWER), { _, down, at -> onKey(down, at) }) { ok -> + active = ok + taking = false + Log.i(TAG, "power button: " + if (ok) "managed" else "watch failed") + if (!ok) release(c) + } + } + } + } + + private fun release(c: Context) { + active = false + Shell.stopWatching() + val p = prefs(c) + if (!p.getBoolean(K_TAKEN, false)) return + + val saved = knobs.associateWith { p.getString(K_SAVED + it.name, "null") ?: "null" } + if (Shell.ready) { + Shell.runAll(saved.map { (k, v) -> + if (v == "null") "settings delete " + k.scope + " " + k.name + else "settings put " + k.scope + " " + k.name + " " + v + }) { ok -> if (ok) p.edit().putBoolean(K_TAKEN, false).apply() } + return + } + // No shell: fall back on the permission the shell granted us earlier. + val ok = saved.all { (k, v) -> + runCatching { + val value = if (v == "null") null else v + if (k.scope == "global") Settings.Global.putString(c.contentResolver, k.name, value) + else Settings.Secure.putString(c.contentResolver, k.name, value) + }.getOrDefault(false) + } + if (ok) p.edit().putBoolean(K_TAKEN, false).apply() + Log.i(TAG, "power button: handed back to the firmware" + if (ok) "" else " (incomplete)") + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/shell/Shell.kt b/app/src/main/java/dev/equwal/assistkey/shell/Shell.kt new file mode 100644 index 0000000..2f46da6 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/shell/Shell.kt @@ -0,0 +1,228 @@ +package dev.equwal.assistkey.shell + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.util.Log +import rikka.shizuku.Shizuku +import java.util.concurrent.Executors + +/** + * Shell access, by way of Shizuku. + * + * Shizuku is a separate, free app that a user starts from the device itself + * through Android's wireless debugging - no computer, no root. Once it is + * running and has said yes to us, this object can start processes under the + * shell uid, and everything Android keeps from ordinary apps is within reach: + * the kernel input nodes (so the Power key), `settings`, `cmd overlay`. + * + * It uses Shizuku's plain remote-process call and nothing grander. Shizuku's + * bound "user service" would be the tidier design, but its starter dies inside + * LoadedApk.makeApplication on Android 16 before our code is ever loaded, and a + * pipe from `getevent` needs none of it. + * + * All of this is optional. Without shell access the app falls back on what an + * installed app can do alone, and says what is missing. + */ +object Shell { + + private const val TAG = "AssistKey" + const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" + private const val REQUEST_CODE = 7301 + + enum class State { NOT_INSTALLED, NOT_RUNNING, NO_PERMISSION, READY } + + private val main = Handler(Looper.getMainLooper()) + private val worker = Executors.newSingleThreadExecutor() + private val listeners = ArrayList<() -> Unit>() + private var hooked = false + private var granted = false + + val ready: Boolean get() = alive() && permitted() + + fun state(c: Context): State = when { + ready -> State.READY + !installed(c) -> State.NOT_INSTALLED + !alive() -> State.NOT_RUNNING + else -> State.NO_PERMISSION + } + + fun describe(c: Context): String = when (state(c)) { + State.READY -> "On" + State.NO_PERMISSION -> "Shizuku is running - permission needed" + State.NOT_RUNNING -> "Shizuku is installed but not started" + State.NOT_INSTALLED -> "Off - needs the free Shizuku app" + } + + /** Called whenever the state may have changed; always on the main thread. */ + fun onChange(l: () -> Unit) { if (l !in listeners) listeners.add(l) } + fun removeOnChange(l: () -> Unit) { listeners.remove(l) } + private fun changed() = main.post { ArrayList(listeners).forEach { it() } } + + private fun installed(c: Context): Boolean = + runCatching { c.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0); true }.getOrDefault(false) + + private fun alive(): Boolean = runCatching { Shizuku.pingBinder() }.getOrDefault(false) + + private fun permitted(): Boolean = runCatching { + !Shizuku.isPreV11() && Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED + }.getOrDefault(false) + + /** Safe to call often. Registers for Shizuku's comings and goings, once. */ + fun connect(c: Context) { + val app = c.applicationContext + if (!hooked) { + hooked = true + runCatching { + Shizuku.addBinderReceivedListenerSticky { onUp(app) } + Shizuku.addBinderDeadListener { + stopWatching() + changed() + } + Shizuku.addRequestPermissionResultListener { _, _ -> onUp(app) } + } + } + onUp(app) + } + + private fun onUp(app: Context) { + if (ready && !granted) { + granted = true + // With this the app can put the Power button back to firmware + // defaults by itself, even when Shizuku is gone after a restart. + run("pm grant " + app.packageName + " android.permission.WRITE_SECURE_SETTINGS") + Log.i(TAG, "shell access ready") + } + changed() + } + + fun requestPermission() { + runCatching { if (alive() && !permitted()) Shizuku.requestPermission(REQUEST_CODE) } + } + + // ---- processes --------------------------------------------------------------------------- + + /** + * Shizuku.newProcess is private in the v13 API but is the supported wire + * call underneath, and the one every shell-style client uses. + */ + private val newProcess by lazy { + Shizuku::class.java.getDeclaredMethod( + "newProcess", Array<String>::class.java, Array<String>::class.java, String::class.java + ).apply { isAccessible = true } + } + + private fun start(vararg argv: String): Process? = runCatching { + newProcess.invoke(null, argv, null, null) as Process + }.onFailure { Log.w(TAG, "shell process failed: " + argv.joinToString(" "), it) }.getOrNull() + + data class Result(val exit: Int, val output: String) { + val ok: Boolean get() = exit == 0 + } + + private fun execNow(command: String): Result { + val p = start("sh", "-c", "$command 2>&1") ?: return Result(-1, "no shell access") + return runCatching { + val out = p.inputStream.bufferedReader().readText() + Result(p.waitFor(), out) + }.getOrElse { Result(-1, it.toString()) } + } + + /** Runs off the main thread; [done] comes back on it. */ + fun run(command: String, done: ((Result) -> Unit)? = null) { + worker.execute { + val r = if (ready) execNow(command) else Result(-1, "no shell access") + if (!r.ok) Log.i(TAG, "shell: " + command + " -> " + r.exit + " " + r.output.take(200)) + done?.let { main.post { it(r) } } + } + } + + /** Commands in order, stopping at nothing; [done] gets true if every one succeeded. */ + fun runAll(commands: List<String>, done: ((Boolean) -> Unit)? = null) { + worker.execute { + var allOk = true + for (c in commands) if (!ready || !execNow(c).ok) allOk = false + done?.let { main.post { it(allOk) } } + } + } + + // ---- keys ---------------------------------------------------------------------------------- + + private val watchers = ArrayList<Process>() + @Volatile private var generation = 0 + + /** + * Reports presses and releases of the given Linux key codes, read from the + * kernel input nodes by `getevent` running as the shell user. Only the + * nodes that declare one of the keys are opened, which keeps the + * touchscreen's firehose out of it. + */ + fun watchKeys(scanCodes: Set<Int>, onKey: (code: Int, down: Boolean, at: Long) -> Unit, started: (Boolean) -> Unit) { + stopWatching() + val mine = ++generation + worker.execute { + val nodes = nodesDeclaring(scanCodes) + val procs = nodes.mapNotNull { node -> start("getevent", "-q", node)?.also { pump(it, scanCodes, mine, onKey) } } + synchronized(watchers) { + if (generation == mine) watchers.addAll(procs) else procs.forEach { it.destroy() } + } + Log.i(TAG, "watching " + nodes.joinToString(" ")) + main.post { started(procs.isNotEmpty()) } + } + } + + fun stopWatching() { + generation++ + synchronized(watchers) { + watchers.forEach { p -> runCatching { p.destroy() } } + watchers.clear() + } + } + + /** Lines look like `0001 0074 00000001`: type, code, value, in hex. */ + private fun pump(p: Process, wanted: Set<Int>, mine: Int, onKey: (Int, Boolean, Long) -> Unit) { + Thread({ + runCatching { + p.inputStream.bufferedReader().forEachLine { line -> + if (generation != mine) return@forEachLine + val f = line.trim().split(' ').filter { it.isNotEmpty() } + if (f.size != 3 || f[0] != "0001") return@forEachLine + val code = f[1].toIntOrNull(16) ?: return@forEachLine + val value = f[2].toLongOrNull(16) ?: return@forEachLine + // value 2 is auto-repeat, which carries no news + if (code !in wanted || value > 1L) return@forEachLine + val at = SystemClock.uptimeMillis() + main.post { if (generation == mine) onKey(code, value == 1L, at) } + } + } + // The pipe closed: Shizuku went away, or we were told to stop. + if (generation == mine) main.post { changed() } + }, "keys").apply { isDaemon = true }.start() + } + + /** `getevent -p` prints, per device, the hex key codes it declares. */ + private fun nodesDeclaring(codes: Set<Int>): List<String> { + val hex = codes.map { "%04x".format(it) }.toSet() + val found = LinkedHashSet<String>() + var node: String? = null + var inKeys = false + execNow("getevent -p").output.lineSequence().forEach { raw -> + val line = raw.trim() + when { + line.startsWith("add device") -> { + node = line.substringAfter(": ").trim() + inKeys = false + } + line.startsWith("KEY (0001):") -> { + inKeys = true + if (line.substringAfter(":").split(' ').any { it in hex }) node?.let(found::add) + } + inKeys && line.contains("(00") -> inKeys = false // next event class + inKeys -> if (line.split(' ').any { it in hex }) node?.let(found::add) + } + } + return found.toList() + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/store/Bindings.kt b/app/src/main/java/dev/equwal/assistkey/store/Bindings.kt index f5ee943..ae689d9 100644 --- a/app/src/main/java/dev/equwal/assistkey/store/Bindings.kt +++ b/app/src/main/java/dev/equwal/assistkey/store/Bindings.kt @@ -15,51 +15,54 @@ class Bindings(private val map: Map<Trigger, ActionSpec>) { private val active: Map<Trigger, ActionSpec> = map.filterValues { it.kind != ActionKind.PASS_THROUGH } - /** - * What the gesture engine may know about. Anything involving Power is kept - * from it: Power never reaches the key filter, so an engine that believed - * in a Power chord would hold every volume press back waiting for a - * partner that cannot arrive. Power triggers are served by the channels - * and by [powerCombo] instead. - */ - private val engine: Set<Trigger> = active.keys.filter { HwKey.POWER !in it.keys }.toSet() - - private val tapsByKeys: Map<Set<HwKey>, Int> = - engine.filter { it.type == GestureType.TAP } - .groupBy { it.keys } - .mapValues { (_, v) -> v.maxOf { it.count } } + /** What the gesture engine is told, for one answer to "can it see Power?". */ + private class View(triggers: Collection<Trigger>) { + val taps: Map<Set<HwKey>, Int> = + triggers.filter { it.type == GestureType.TAP } + .groupBy { it.keys } + .mapValues { (_, v) -> v.maxOf { it.count } } - private val holdKeys: Set<Set<HwKey>> = - engine.filter { it.type == GestureType.HOLD }.map { it.keys }.toSet() + val holds: Set<Set<HwKey>> = + triggers.filter { it.type == GestureType.HOLD }.map { it.keys }.toSet() - /** For each key, the other keys it forms a bound chord with. */ - private val partners: Map<HwKey, Set<HwKey>> = buildMap { - engine.filter { it.isChord }.forEach { t -> - t.keys.forEach { k -> merge(k, t.keys - k) { a, b -> a + b } } + /** For each key, the other keys it forms a bound chord with. */ + val partners: Map<HwKey, Set<HwKey>> = buildMap { + triggers.filter { it.isChord }.forEach { t -> + t.keys.forEach { k -> merge(k, t.keys - k) { a, b -> a + b } } + } } } - fun maxTaps(keys: Set<HwKey>): Int = tapsByKeys[keys] ?: 0 - fun hasHold(keys: Set<HwKey>): Boolean = keys in holdKeys - fun chordPartners(key: HwKey): Set<HwKey> = partners[key] ?: emptySet() + /** + * Two views, because whether the engine may know about Power depends on + * whether Power presses can reach it. Without shell access they cannot, and + * an engine that believed in a Power chord would hold every volume press + * back waiting for a partner that never arrives. With it, Power is a key + * like any other. + */ + private val withPower = View(active.keys) + private val withoutPower = View(active.keys.filter { HwKey.POWER !in it.keys }) + + private fun view(power: Boolean) = if (power) withPower else withoutPower + + fun maxTaps(keys: Set<HwKey>, power: Boolean = false): Int = view(power).taps[keys] ?: 0 + fun hasHold(keys: Set<HwKey>, power: Boolean = false): Boolean = keys in view(power).holds + fun chordPartners(key: HwKey, power: Boolean = false): Set<HwKey> = + view(power).partners[key] ?: emptySet() operator fun get(trigger: Trigger): ActionSpec? = active[trigger] + fun isBound(trigger: Trigger): Boolean = trigger in active /** The action for "hold Power, then press [key]", if there is one. */ fun powerCombo(key: HwKey): ActionSpec? = active[Trigger.powerThen(key)] val hasPowerCombos: Boolean = HwKey.interceptable.any { active.containsKey(Trigger.powerThen(it)) } - fun isBound(trigger: Trigger): Boolean = trigger in active /** Including PASS_THROUGH entries, for the UI. */ fun raw(trigger: Trigger): ActionSpec = map[trigger] ?: ActionSpec.PASS fun all(): Map<Trigger, ActionSpec> = map - /** Key sets the engine should watch at all. */ - fun watchedKeys(): Set<HwKey> = - (tapsByKeys.keys + holdKeys).flatten().toSet() - fun with(trigger: Trigger, spec: ActionSpec): Bindings = Bindings(map.toMutableMap().apply { if (spec.kind == ActionKind.PASS_THROUGH) remove(trigger) else put(trigger, spec) diff --git a/app/src/main/java/dev/equwal/assistkey/store/Store.kt b/app/src/main/java/dev/equwal/assistkey/store/Store.kt index b0e53e3..63b1fe3 100644 --- a/app/src/main/java/dev/equwal/assistkey/store/Store.kt +++ b/app/src/main/java/dev/equwal/assistkey/store/Store.kt @@ -45,8 +45,12 @@ object Store { b.all().forEach { (t, spec) -> o.put(t.id, spec.toJson()) } prefs(c).edit().putString(K_BINDINGS, o.toString()).apply() cache = b + onBindingsChanged?.invoke() } + /** Set by the key service, which has things to re-decide when bindings move. */ + @Volatile var onBindingsChanged: (() -> Unit)? = null + fun bind(c: Context, trigger: Trigger, spec: ActionSpec) { save(c, bindings(c).with(trigger, spec)) } diff --git a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt index 12b8132..e3bb6bb 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt @@ -35,6 +35,7 @@ class MainActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Channels.syncComponents(this) + dev.equwal.assistkey.shell.Shell.connect(this) } override fun onResume() { @@ -51,8 +52,7 @@ class MainActivity : Activity() { val col = Ui.page(this) col.title("AssistKey") col.note( - "Remaps the AI key, both volume keys and the power button. " + - "Turn on the capture channels you want, then bind gestures below." + "Remaps this device's hardware keys, the Power button included." ) licence(col) channels(col) @@ -80,26 +80,27 @@ class MainActivity : Activity() { // ---- channels ---------------------------------------------------------- private fun channels(col: LinearLayout) { - col.header("Capture channels") - col.note( - "Each one is a different way of getting a key routed to this app. " + - "They are independent - tick any combination." - ) + col.header("Setup") - Channel.entries.forEach { ch -> - val on = Channels.isEnabled(this, ch) - val ok = Channels.isSatisfied(this, ch) - col.check(ch.title, ch.summary, on) { checked -> - Channels.setEnabled(this, ch, checked) - if (checked && !Channels.isSatisfied(this, ch)) claim(ch) - build() - } - col.row("Status: " + Channels.status(this, ch), null, enabled = on) - if (on && !ok) { - col.button("Set up " + ch.title.lowercase()) { claim(ch) } - if (ch == Channel.ACCESSIBILITY) restrictedSettingsHint(col) - } + // The key filter is the one switch everything depends on: it sees the + // keys, and it is what carries out Back, Home and the rest. + val ch = Channel.ACCESSIBILITY + val on = Channels.isEnabled(this, ch) + val ok = Channels.isSatisfied(this, ch) + col.check("Key remapping", "Uses the accessibility key filter - " + Channels.status(this, ch).lowercase(), on) { checked -> + Channels.setEnabled(this, ch, checked) + if (checked && !Channels.isSatisfied(this, ch)) claim(ch) + build() + } + if (on && !ok) { + col.button("Turn on the key filter") { claim(ch) } + restrictedSettingsHint(col) } + + col.row( + "Shell access: " + dev.equwal.assistkey.shell.Shell.describe(this), + "Optional. Unlocks the Power button, the navigation bar and system gestures" + ) { startActivity(Intent(this, ShellActivity::class.java)) } } /** @@ -185,7 +186,7 @@ class MainActivity : Activity() { col.row( "Navigation", - "Button bar, swipe gestures, Power key combinations - in any mix" + "Button bar, swipe gestures, the Power key - in any mix" ) { startActivity(Intent(this, NavigationActivity::class.java)) } col.row( @@ -212,15 +213,12 @@ class MainActivity : Activity() { } private fun powerSummary(): String { - val b = Store.bindings(this) - val parts = ArrayList<String>() - PowerNative.shortPressValue(this)?.let { v -> - PowerNative.shortPress.firstOrNull { it.value == v } - ?.let { parts.add("short → " + it.label.lowercase()) } + val bound = Store.bindings(this).all().filterKeys { HwKey.POWER in it.keys } + if (bound.isEmpty()) return "Default behaviour" + return bound.entries.joinToString(", ") { (t, spec) -> + val what = if (t.keys.size > 1) "+" + (t.keys - HwKey.POWER).first().label.lowercase() else shortGesture(t) + what + " → " + spec.describe() } - b[Channel.POWER_DOUBLE]?.let { parts.add("double → " + it.describe()) } - b[Channel.POWER_HOLD]?.let { parts.add("hold → " + it.describe()) } - return if (parts.isEmpty()) "Default behaviour" else parts.joinToString(", ") } // ---- everything else --------------------------------------------------- diff --git a/app/src/main/java/dev/equwal/assistkey/ui/NavigationActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/NavigationActivity.kt index 8d59d67..7d91dff 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/NavigationActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/NavigationActivity.kt @@ -2,17 +2,22 @@ package dev.equwal.assistkey.ui import android.app.Activity import android.content.Context +import android.content.Intent import android.widget.LinearLayout import android.widget.Toast import dev.equwal.assistkey.channel.Channel import dev.equwal.assistkey.channel.Channels +import dev.equwal.assistkey.engine.KeyFilterService import dev.equwal.assistkey.model.ActionKind import dev.equwal.assistkey.model.ActionSpec +import dev.equwal.assistkey.model.GestureType import dev.equwal.assistkey.model.GlobalAction import dev.equwal.assistkey.model.HwKey import dev.equwal.assistkey.model.Trigger import dev.equwal.assistkey.native.NavNative -import dev.equwal.assistkey.native.ViwoodsBridge +import dev.equwal.assistkey.route.ServiceHolder +import dev.equwal.assistkey.shell.PowerControl +import dev.equwal.assistkey.shell.Shell import dev.equwal.assistkey.store.Store import dev.equwal.assistkey.ui.Ui.button import dev.equwal.assistkey.ui.Ui.check @@ -23,40 +28,44 @@ import dev.equwal.assistkey.ui.Ui.row import dev.equwal.assistkey.ui.Ui.title /** - * How you get around the reader: the button bar, swipe gestures, Power key - * combinations, in any mix. + * How you get around: the button bar, swipe gestures, the Power key - any mix. * - * The three are not equals. Power combinations are this app's own and are - * switched here. The bar and the gestures belong to the system, and Android - * lets no ordinary app switch either - so for those this screen works out what - * has to change, shows where things stand now, and hands over the commands. + * One screen, one decision. Tick what you want and it is applied: the Power + * key part always by the app, the bar and the gestures through shell access + * when there is any. Only without shell access does this fall back to handing + * over commands for a computer. */ class NavigationActivity : Activity() { - private data class Setup( - val name: String, - val blurb: String, - val buttons: Boolean, - val gestures: Boolean, - val keys: Boolean - ) + private data class Setup(val name: String, val buttons: Boolean, val gestures: Boolean, val keys: Boolean) private val setups = listOf( - Setup("Buttons only", "Three-button bar at the bottom. No gestures at all.", true, false, false), - Setup("Gestures only", "Swipes, no button bar.", false, true, false), - Setup("Power key combos only", "No bar and no gestures - the whole screen is yours.", false, false, true), - Setup("Power key combos + buttons", "Bar and keys, no gestures.", true, false, true), - Setup("Power key combos + buttons + gestures", "Everything at once.", true, true, true) + Setup("Buttons only", true, false, false), + Setup("Gestures only", false, true, false), + Setup("Power key only", false, false, true), + Setup("Power key + buttons", true, false, true), + Setup("Power key + gestures", false, true, true), + Setup("Buttons + gestures", true, true, false), + Setup("Power key + buttons + gestures", true, true, true) ) private var redrawnForInsets = false + private var applying = false + private val redraw: () -> Unit = { if (!isFinishing) build() } override fun onResume() { super.onResume() + Shell.onChange(redraw) + Shell.connect(this) build() } - // ---- wanted state, remembered ------------------------------------------------ + override fun onPause() { + super.onPause() + Shell.removeOnChange(redraw) + } + + // ---- what is wanted ------------------------------------------------------------------- private fun prefs() = getSharedPreferences("assistkey_nav", Context.MODE_PRIVATE) @@ -69,145 +78,133 @@ class NavigationActivity : Activity() { build() return } - prefs().edit() - .putBoolean("buttons", buttons) - .putBoolean("gestures", gestures) - .putBoolean("keys", keys) - .apply() + prefs().edit().putBoolean("buttons", buttons).putBoolean("gestures", gestures) + .putBoolean("keys", keys).apply() applyKeys(keys) - build() + if (Shell.ready) applySystem(buttons, gestures) else build() } - // ---- the part this app owns ----------------------------------------------------- + // ---- the Power key --------------------------------------------------------------------- + + private val power = setOf(HwKey.POWER) + /** Tap back, hold home, double tap recents; triple tap keeps a way to lock. */ private val defaults = listOf( - HwKey.VOL_UP to GlobalAction.BACK, - HwKey.AI to GlobalAction.HOME, - HwKey.VOL_DOWN to GlobalAction.RECENTS + Trigger(power, GestureType.TAP, 1) to GlobalAction.BACK, + Trigger(power, GestureType.HOLD) to GlobalAction.HOME_CLOSE_IME, + Trigger(power, GestureType.TAP, 2) to GlobalAction.RECENTS, + Trigger(power, GestureType.TAP, 3) to GlobalAction.LOCK_SCREEN ) - private fun keysOn(): Boolean = Store.bindings(this).hasPowerCombos + private fun keysOn(): Boolean = + Store.bindings(this).all().keys.any { it.keys == power } /** - * On: make sure the two channels that carry a Power combination are ticked, - * and fill any empty slot with a navigation default. Slots the user has - * already bound are theirs and are left alone. Off: clear the three slots. + * On fills empty slots with the defaults and leaves anything already bound + * alone. Off clears exactly the slots that still hold a default, so a + * binding the user made by hand survives being toggled. */ private fun applyKeys(on: Boolean) { + val b = Store.bindings(this) + defaults.forEach { (t, action) -> + val spec = ActionSpec(ActionKind.GLOBAL, action.name, action.label) + if (on && !b.isBound(t)) Store.bind(this, t, spec) + if (!on && b[t] == spec) Store.bind(this, t, ActionSpec.PASS) + } if (on) { Channels.setEnabled(this, Channel.ACCESSIBILITY, true) + PowerControl.setWanted(this, true) + // The side doors, for whenever shell access is not there. Channels.setEnabled(this, Channel.ASSISTANT, true) - val b = Store.bindings(this) - defaults.forEach { (key, action) -> - val t = Trigger.powerThen(key) - if (!b.isBound(t)) { - Store.bind(this, t, ActionSpec(ActionKind.GLOBAL, action.name, action.label)) - } - } - } else { - HwKey.interceptable.forEach { Store.bind(this, Trigger.powerThen(it), ActionSpec.PASS) } } + (ServiceHolder.service as? KeyFilterService)?.syncPower() } - // ---- screen --------------------------------------------------------------------------- + // ---- the bar and the gestures ---------------------------------------------------------- + + private fun applySystem(buttons: Boolean, gestures: Boolean) { + applying = true + build() + Shell.runAll(NavNative.shellCommands(buttons, gestures)) { ok -> + applying = false + if (!ok) Toast.makeText(this, "The system refused part of that", Toast.LENGTH_LONG).show() + // The bar comes and goes with a relayout; give the insets a moment. + window.decorView.postDelayed({ if (!isFinishing) build() }, 1200L) + } + } + + // ---- screen ----------------------------------------------------------------------------- private fun build() { val buttonsNow = NavNative.buttonsShowing(this) if (buttonsNow == null && !redrawnForInsets) { - // Insets only exist once the window is attached. redrawnForInsets = true - window.decorView.post { if (!isFinishing) build() } + window.decorView.post { if (!isFinishing) build() } // insets need an attached window } val gesturesNow = NavNative.gesturesOn(this) - val keysNow = keysOn() val wantButtons = wanted("buttons", buttonsNow ?: true) val wantGestures = wanted("gestures", gesturesNow ?: true) - val wantKeys = wanted("keys", keysNow) + val wantKeys = wanted("keys", keysOn()) val col = Ui.page(this) col.title("Navigation") - col.note( - "Three ways to get around, in any mix: the button bar, swipe " + - "gestures, and Power key combinations." - ) - - col.header("Pick a setup") - setups.forEach { s -> - val current = s.buttons == wantButtons && s.gestures == wantGestures && s.keys == wantKeys - col.row((if (current) "* " else "") + s.name, s.blurb) { - choose(s.buttons, s.gestures, s.keys) - } - } + col.note("The button bar, swipe gestures and the Power key, in any mix.") - col.header("Or mix your own") col.check("Button bar", "Back, Home and Recents along the bottom edge", wantButtons) { choose(it, wantGestures, wantKeys) } - col.check( - "Swipe gestures", - "Swipe up from the bottom edge for Home; without the bar, swipe in " + - "from a side edge for Back", - wantGestures - ) { choose(wantButtons, it, wantKeys) } - col.check( - "Power key combinations", - "Hold Power, then press Volume up, the AI key or Volume down", - wantKeys - ) { choose(wantButtons, wantGestures, it) } + col.check("Swipe gestures", "Swipe up for Home; without the bar, swipe in from a side for Back", wantGestures) { + choose(wantButtons, it, wantKeys) + } + col.check("Power key", "Tap for Back, hold for Home, double tap for Recents", wantKeys) { + choose(wantButtons, wantGestures, it) + } - if (wantKeys) keys(col) + col.header("Or pick a setup") + val labels = setups.map { s -> + (if (s.buttons == wantButtons && s.gestures == wantGestures && s.keys == wantKeys) "* " else " ") + s.name + } + col.button("Choose...") { + Ui.pick(this, "Navigation setup", labels) { i -> + setups[i].let { choose(it.buttons, it.gestures, it.keys) } + } + } + + if (wantKeys) powerKey(col) system(col, wantButtons, wantGestures, buttonsNow, gesturesNow) } - private fun keys(col: LinearLayout) { - col.header("Power key combinations") + private fun powerKey(col: LinearLayout) { + col.header("Power key") val b = Store.bindings(this) - HwKey.interceptable.forEach { key -> - val t = Trigger.powerThen(key) - col.row("Power, then " + Ui.inSentence(key), b.raw(t).describe()) { - startActivity(ActionPickerActivity.intent(this, t)) + val direct = Shell.ready && PowerControl.wanted(this) + defaults.forEach { (t, _) -> + val label = when { + t.type == GestureType.HOLD -> "Hold" + t.count == 1 -> "Tap" + t.count == 2 -> "Double tap" + else -> "Triple tap" } - } - - val a11y = Channels.isSatisfied(this, Channel.ACCESSIBILITY) - val assistant = Channels.isSatisfied(this, Channel.ASSISTANT) - if (a11y && assistant) { - col.note( - "Ready. Hold Power for about half a second, then press the " + - "second key while still holding." - ) - } - if (!assistant) { - col.note("Holding Power has to reach this app: make AssistKey the digital assistant.") - col.button("Set up digital assistant") { - Channels.safeStart(this, Channels.claimIntent(this, Channel.ASSISTANT)) + // Without shell access only hold and double press have a way in. + val reachable = direct || t == Channel.POWER_HOLD || t == Channel.POWER_DOUBLE + col.row(label + ": " + b.raw(t).describe(), if (reachable) null else "Needs shell access", reachable) { + startActivity(ActionPickerActivity.intent(this, t)) } } - if (!a11y) { - col.note( - "The second key is read by the accessibility key filter, which is " + - "off. Turn it on from the main screen." - ) + col.row("More Power button gestures", "More taps, combinations with other keys, and how it works") { + startActivity(Intent(this, PowerActivity::class.java)) } - if (!ViwoodsBridge.aiHookedToUs(this) && b.powerCombo(HwKey.AI) != null) { - col.note( - "Power, then the AI key will also open the firmware's AI screen " + - "until the AI key hook points at AssistKey. See Advanced > " + - "Firmware key hooks." - ) + if (!Channels.isSatisfied(this, Channel.ACCESSIBILITY)) { + col.note("Actions like Back and Home are carried out by the accessibility key filter, which is off. Turn it on from the main screen.") } - HwKey.interceptable.filter { ViwoodsBridge.hidesFromFilter(this, it) }.forEach { k -> + if (!direct) { col.note( - "A firmware hook is hiding " + k.label.lowercase() + " from this app, so its " + - "combination cannot fire. See Advanced > Firmware key hooks." + "Without shell access a tap cannot reach any app. Hold works once " + + "AssistKey is the digital assistant, and double press once it is " + + "the default camera app - both are set up on the Power button screen." ) } - col.note( - "Back, Home and Recents on these combinations keep working even if " + - "the app is locked, so a reader with no bar and no gestures can " + - "never be left without a way out." - ) } private fun system( @@ -218,32 +215,25 @@ class NavigationActivity : Activity() { gesturesNow: Boolean? ) { col.header("Button bar and gestures") - col.row("Button bar: " + now(buttonsNow, "showing", "hidden") + want(buttonsNow, wantButtons), null, enabled = false) - col.row("Swipe-up gesture: " + now(gesturesNow, "on", "off") + want(gesturesNow, wantGestures), null, enabled = false) + col.row("Button bar: " + now(buttonsNow, "showing", "hidden"), null, enabled = false) + col.row("Swipe-up gesture: " + now(gesturesNow, "on", "off"), null, enabled = false) - val settled = buttonsNow == wantButtons && gesturesNow == wantGestures - if (settled) { - col.note("The system already matches. Nothing more to do.") + if (applying) { + col.note("Applying... the screen may redraw once.") return } - col.note( - "Android does not let apps switch these, so they are set once from a " + - "computer with the reader plugged in and USB debugging on. They " + - "survive restarts. Run them in this order:" - ) - col.code(NavNative.commands(wantButtons, wantGestures).joinToString("\n")) - col.note( - "The repository has tools/nav-mode, which runs the same commands: " + - "nav-mode " + (if (wantButtons) "buttons" else "nobuttons") + " " + - (if (wantGestures) "gestures" else "nogestures") - ) - if (!wantButtons && !wantGestures) { - col.note( - "With no bar and no gestures, set up and try the Power " + - "combinations before running these. Power + Volume up still " + - "opens the power menu whatever happens." - ) + if (buttonsNow == wantButtons && gesturesNow == wantGestures) return + + if (Shell.ready) { + col.button("Apply") { applySystem(wantButtons, wantGestures) } + return + } + col.note("Android does not let apps switch these. Shell access lets AssistKey do it from here:") + col.row("Shell access: " + Shell.describe(this), "Set it up once, on the device, with no computer") { + startActivity(Intent(this, ShellActivity::class.java)) } + col.note("Or, with a computer and USB debugging:") + col.code(NavNative.commands(wantButtons, wantGestures).joinToString("\n")) } private fun now(v: Boolean?, yes: String, no: String): String = when (v) { @@ -251,7 +241,4 @@ class NavigationActivity : Activity() { false -> no null -> "unknown" } - - private fun want(now: Boolean?, wanted: Boolean): String = - if (now == wanted) "" else " - you want it " + (if (wanted) "on" else "off") } diff --git a/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt index c4b8d6a..c903bce 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt @@ -1,14 +1,22 @@ package dev.equwal.assistkey.ui import android.app.Activity +import android.content.Intent import android.widget.LinearLayout import android.widget.Toast import dev.equwal.assistkey.channel.Channel import dev.equwal.assistkey.channel.Channels +import dev.equwal.assistkey.engine.KeyFilterService +import dev.equwal.assistkey.model.GestureType import dev.equwal.assistkey.model.HwKey import dev.equwal.assistkey.model.Trigger import dev.equwal.assistkey.native.PowerNative +import dev.equwal.assistkey.route.ServiceHolder +import dev.equwal.assistkey.shell.PowerControl +import dev.equwal.assistkey.shell.Shell import dev.equwal.assistkey.store.Store +import dev.equwal.assistkey.ui.Ui.button +import dev.equwal.assistkey.ui.Ui.check import dev.equwal.assistkey.ui.Ui.code import dev.equwal.assistkey.ui.Ui.header import dev.equwal.assistkey.ui.Ui.note @@ -16,262 +24,207 @@ import dev.equwal.assistkey.ui.Ui.row import dev.equwal.assistkey.ui.Ui.title /** - * Power is its own screen because it is not like the other keys. + * The Power button. * - * PhoneWindowManager consumes KEYCODE_POWER before the input dispatcher, so no - * app - accessibility service or otherwise - ever sees it. That leaves exactly - * three slots, each reached a different way: short press is firmware-only, - * double press arrives as a camera or wallet launch, and long press arrives as - * an assistant request. No multi-tap beyond two, and no chords. + * It has two lives. With shell access the app reads the button straight from + * the kernel and it becomes a key like any other: any number of taps, hold, and + * combinations. Without shell access no app is ever shown the key, and what is + * left is two side doors - a hold arrives as an assistant request, a double + * press as a camera launch - plus whatever the firmware's own switches offer. + * + * The bindings are the same in both lives. "Hold" is one binding whichever door + * it comes through, so gaining or losing shell access changes what can fire, + * never what is configured. */ class PowerActivity : Activity() { - private var readable = true + private val redraw: () -> Unit = { if (!isFinishing) build() } override fun onResume() { super.onResume() + Shell.onChange(redraw) + Shell.connect(this) build() } + override fun onPause() { + super.onPause() + Shell.removeOnChange(redraw) + } + private fun build() { - readable = PowerNative.canRead(this) val col = Ui.page(this) col.title("Power button") - col.note( - "The system eats the power key before any app can filter it, so " + - "these three slots are everything that is reachable." - ) - - if (!PowerNative.canWriteSecure(this)) { - col.note( - "The firmware switches below cannot be changed without a " + - "permission that only adb can grant. Run this once with the " + - "device plugged in:" - ) - col.code(PowerNative.GRANT_COMMAND) - } else if (!readable) { - col.note( - "Android blocks apps from reading these particular settings, so " + - "the current values show as unknown. Writing them still " + - "works - pick a value and it is applied." - ) - } - - shortPress(col) - doublePress(col) - longPress(col) - powerThen(col) - escapeHatch(col) + val direct = Shell.ready && PowerControl.wanted(this) + if (direct) direct(col) else sideDoors(col) } - // ---- hold Power, then press a key --------------------------------------- - - private fun powerThen(col: LinearLayout) { - col.header("Hold Power, then press a key") - col.note( - "Keep Power held for a moment, then press another key before letting " + - "go. It needs the digital assistant channel and the accessibility " + - "key filter, both on. While any of these is bound, the plain " + - "press-and-hold action above waits a second to see whether a key " + - "follows." - ) - val b = Store.bindings(this) - HwKey.interceptable.forEach { key -> - val t = Trigger.powerThen(key) - col.row("Power, then " + Ui.inSentence(key), b.raw(t).describe()) { - startActivity(ActionPickerActivity.intent(this, t)) - } + private fun bindRow(col: LinearLayout, label: String, t: Trigger, enabled: Boolean = true) { + val spec = Store.bindings(this).raw(t) + col.row(label, spec.describe(), enabled) { + startActivity(ActionPickerActivity.intent(this, t)) } } - // ---- short press: firmware only --------------------------------------- - - private fun shortPress(col: LinearLayout) { - col.header("Short press") - val cur = PowerNative.shortPressValue(this) - col.row( - "Currently: " + PowerNative.describe(PowerNative.shortPress, cur), - "Handled entirely by the firmware - this rewrites its setting" - ) { - pickNative("Short press", PowerNative.shortPress, cur) { v -> - apply( - PowerNative.setShortPress(this, v), - "global", PowerNative.SHORT_PRESS, v - ) - } - } - } + private val power = setOf(HwKey.POWER) - // ---- double press: camera / wallet impersonation ----------------------- + // ---- with shell access ---------------------------------------------------------------- - private fun doublePress(col: LinearLayout) { - col.header("Double press") - val camera = Channels.isSatisfied(this, Channel.CAMERA) - val wallet = Channels.isSatisfied(this, Channel.WALLET) + private fun direct(col: LinearLayout) { + col.note( + "AssistKey is reading the Power button directly. While anything is " + + "bound here the system's own reactions are switched off, so a " + + "gesture you leave unbound locks the screen, as Power always did." + ) + col.header("Taps") + bindRow(col, "Tap", Trigger(power, GestureType.TAP, 1)) + bindRow(col, "Double tap", Trigger(power, GestureType.TAP, 2)) + bindRow(col, "Triple tap", Trigger(power, GestureType.TAP, 3)) + bindRow(col, "4 taps", Trigger(power, GestureType.TAP, 4)) + bindRow(col, "5 taps", Trigger(power, GestureType.TAP, 5)) col.note( - when { - camera && wallet -> "Routed here through both the camera and wallet channels." - camera -> "Routed here through the camera channel." - wallet -> "Routed here through the wallet channel." - else -> - "Nothing is routing double press here yet. Turn on the camera " + - "or wallet channel on the main screen, then pick this app " + - "as the double-press target in system settings." - } + "A tap waits a moment for a second one only if double tap or more is " + + "bound. The wait is set under Gesture timing." ) - val spec = Store.bindings(this)[Channel.POWER_DOUBLE] - col.row("Action", spec?.describe() ?: "Default behaviour") { - startActivity(ActionPickerActivity.intent(this, Channel.POWER_DOUBLE)) + col.header("Hold") + bindRow(col, "Press and hold", Trigger(power, GestureType.HOLD)) + + col.header("Power held, plus another key") + HwKey.interceptable.forEach { key -> + bindRow(col, "Power + " + key.label, Trigger.powerThen(key)) } - onOffRow( - col, - "Firmware double-press gesture", - PowerNative.doubleTapGestureEnabled(this), - "Must be on for double press to do anything at all" + col.header("Safety") + col.note( + "Power + Volume up always opens the power menu. The press that wakes " + + "the screen is never treated as a gesture. If shell access is lost - " + + "after a restart, say - the button goes back to the system until it " + + "returns." + ) + col.check( + "Let AssistKey handle the Power button", + "Untick to give it back to the system and use the side doors instead", + true ) { on -> - apply( - PowerNative.setDoubleTapGestureEnabled(this, on), - "secure", PowerNative.DOUBLE_TAP, if (on) 1 else 0 - ) + PowerControl.setWanted(this, on) + (ServiceHolder.service as? KeyFilterService)?.syncPower() + build() } - onOffRow( - col, - "Camera double-press gesture", - PowerNative.cameraDoubleTapEnabled(this), - "The older camera-specific route, still live on this firmware" - ) { on -> - apply( - PowerNative.setCameraDoubleTapEnabled(this, on), - "secure", PowerNative.CAMERA_DOUBLE_TAP, if (on) 0 else 1 - ) + if (ServiceHolder.service == null) { + col.note("The accessibility key filter is off, so nothing here can fire yet.") } } - // ---- long press: assistant impersonation ------------------------------- - - private fun longPress(col: LinearLayout) { - col.header("Press and hold") - val cur = PowerNative.longPressValue(this) - val held = Channels.isSatisfied(this, Channel.ASSISTANT) + // ---- without it -------------------------------------------------------------------------- + private fun sideDoors(col: LinearLayout) { col.note( - when { - cur == 5 && held -> - "Routed here: the firmware sends hold to the assistant, and that is us." - cur == 5 -> - "The firmware sends hold to the assistant, but this app does not " + - "hold that role yet." - cur == null && held -> - "This app holds the assistant role. Whether the firmware sends " + - "hold to the assistant cannot be read, so try it and see." - cur == null -> - "Neither the firmware setting nor the role is in place yet." - else -> - "The firmware does not send hold to the assistant, so nothing " + - "reaches this app." - } + "Android does not show the Power button to apps. With shell access " + + "AssistKey can read it anyway and every gesture becomes available." ) - - val spec = Store.bindings(this)[Channel.POWER_HOLD] - col.row("Action", spec?.describe() ?: "Default behaviour") { - startActivity(ActionPickerActivity.intent(this, Channel.POWER_HOLD)) + if (Shell.ready) { + col.check("Let AssistKey handle the Power button", "Tap, double tap, hold and more", false) { + PowerControl.setWanted(this, it) + (ServiceHolder.service as? KeyFilterService)?.syncPower() + build() + } + } else { + col.row("Shell access: " + Shell.describe(this), "Unlocks tap, double tap, more taps and combinations") { + startActivity(Intent(this, ShellActivity::class.java)) + } } - col.row( - "Firmware behaviour: " + PowerNative.describe(PowerNative.longPress, cur), - "Set this to Digital assistant" - ) { - pickNative("Press and hold", PowerNative.longPress, cur) { v -> - apply(PowerNative.setLongPress(this, v), "global", PowerNative.LONG_PRESS, v) - } + col.header("Hold - through the assistant door") + door(col, Channel.ASSISTANT) + bindRow(col, "Press and hold", Channel.POWER_HOLD) + HwKey.interceptable.forEach { key -> + bindRow(col, "Power held, then " + Ui.inSentence(key), Trigger.powerThen(key)) } + col.note( + "While a held-then-key combination is bound, plain hold waits a second " + + "to see whether a key follows." + ) - val ms = PowerNative.longPressMs(this) - val opts = listOf(250, 350, 500, 650, 800, 1000).map { - PowerNative.Option(it, it.toString() + " ms") + col.header("Double press - through the camera door") + door(col, Channel.CAMERA) + bindRow(col, "Double press", Channel.POWER_DOUBLE) + + col.header("Tap") + col.note( + "A single tap cannot reach any app this way. The system's own choices " + + "are all there is:" + ) + firmware(col) + + col.header("Wallet door") + door(col, Channel.WALLET) + col.note("Runs the double-press action from the wallet tile and lock-screen button.") + } + + /** One side door: its switch, what state it is in, and how to open it. */ + private fun door(col: LinearLayout, ch: Channel) { + val on = Channels.isEnabled(this, ch) + col.check(ch.title, ch.summary, on) { checked -> + Channels.setEnabled(this, ch, checked) + if (checked && !Channels.isSatisfied(this, ch)) claim(ch) + build() } - col.row( - "Hold time: " + PowerNative.describe(opts, ms), - "How long the button must be down before it counts as a hold" - ) { - pickNative("Hold time", opts, ms) { v -> - apply( - PowerNative.setLongPressMs(this, v), - "global", PowerNative.LONG_PRESS_MS, v - ) - } + if (on) { + col.row("Status: " + Channels.status(this, ch), null, enabled = false) + if (!Channels.isSatisfied(this, ch)) col.button("Set up") { claim(ch) } } } - private fun escapeHatch(col: LinearLayout) { - col.header("Escape hatch") - val cur = PowerNative.chordVolumeUpValue(this) - col.row( - "Power + Volume up: " + PowerNative.describe(PowerNative.chordVolumeUp, cur), - "Keep this on the power menu so you can always shut down" - ) { - pickNative("Power + Volume up", PowerNative.chordVolumeUp, cur) { v -> - apply( - PowerNative.setChordVolumeUp(this, v), - "global", PowerNative.CHORD_VOL_UP, v - ) - } + private fun claim(ch: Channel) { + if (ch == Channel.CAMERA) { + Toast.makeText(this, "Choose Camera app, then pick AssistKey", Toast.LENGTH_LONG).show() + } + if (!Channels.safeStart(this, Channels.claimIntent(this, ch))) { + Toast.makeText(this, "No settings screen for that on this device", Toast.LENGTH_LONG).show() } } - // ---- helpers ----------------------------------------------------------- + // ---- the firmware's own switches ------------------------------------------------------- - /** - * An explicit On/Off choice rather than a toggle, because when the current - * value cannot be read there is nothing to toggle away from. - */ - private fun onOffRow( - col: LinearLayout, - title: String, - current: Boolean?, - blurb: String, - onPick: (Boolean) -> Unit - ) { - val shown = when (current) { - true -> "on" - false -> "off" - null -> "unknown" + private fun firmware(col: LinearLayout) { + if (!PowerNative.canWriteSecure(this)) { + col.note("Changing them needs a permission that shell access grants by itself, or this once from a computer:") + col.code(PowerNative.GRANT_COMMAND) + return } - col.row(title + ": " + shown, blurb) { - pickNative(title, PowerNative.onOff, current?.let { if (it) 1 else 0 }) { v -> - onPick(v == 1) + val short = PowerNative.shortPressValue(this) + col.row("Tap: " + PowerNative.describe(PowerNative.shortPress, short), "Handled by the system") { + pick("Tap", PowerNative.shortPress, short) { v -> + applied(PowerNative.setShortPress(this, v)) } } + val long = PowerNative.longPressValue(this) + col.row( + "Hold: " + PowerNative.describe(PowerNative.longPress, long), + "Must be Digital assistant for the assistant door to open" + ) { + pick("Hold", PowerNative.longPress, long) { v -> applied(PowerNative.setLongPress(this, v)) } + } + val ms = PowerNative.longPressMs(this) + val times = listOf(250, 350, 500, 650, 800, 1000).map { PowerNative.Option(it, it.toString() + " ms") } + col.row("Hold time: " + PowerNative.describe(times, ms), null) { + pick("Hold time", times, ms) { v -> applied(PowerNative.setLongPressMs(this, v)) } + } } - private fun pickNative( - title: String, - options: List<PowerNative.Option>, - current: Int?, - onPick: (Int) -> Unit - ) { + private fun pick(title: String, options: List<PowerNative.Option>, current: Int?, onPick: (Int) -> Unit) { val labels = options.map { o -> - val mark = if (current != null && o.value == current) "* " else " " - mark + o.label + if (o.note.isBlank()) "" else " - " + o.note + (if (current != null && o.value == current) "* " else " ") + o.label + + if (o.note.isBlank()) "" else " - " + o.note } Ui.pick(this, title, labels) { i -> onPick(options[i].value) } } - /** A refused write is the normal case without the adb grant; say so. */ - private fun apply(ok: Boolean, scope: String, key: String, value: Int) { - if (ok) { - Toast.makeText(this, "Applied", Toast.LENGTH_SHORT).show() - } else { - Toast.makeText( - this, - "Refused - run: " + PowerNative.adbFallback(scope, key, value), - Toast.LENGTH_LONG - ).show() - } + private fun applied(ok: Boolean) { + Toast.makeText(this, if (ok) "Applied" else "The system refused", Toast.LENGTH_SHORT).show() build() } } diff --git a/app/src/main/java/dev/equwal/assistkey/ui/ShellActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/ShellActivity.kt new file mode 100644 index 0000000..4f37f1d --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/ui/ShellActivity.kt @@ -0,0 +1,122 @@ +package dev.equwal.assistkey.ui + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import android.widget.LinearLayout +import dev.equwal.assistkey.channel.Channels +import dev.equwal.assistkey.shell.Shell +import dev.equwal.assistkey.ui.Ui.button +import dev.equwal.assistkey.ui.Ui.header +import dev.equwal.assistkey.ui.Ui.note +import dev.equwal.assistkey.ui.Ui.row +import dev.equwal.assistkey.ui.Ui.title + +/** + * Getting shell access, from the device itself. + * + * Shizuku is the piece that makes it possible without a computer: it pairs with + * Android's own wireless debugging over the loopback interface and then runs a + * small server as the shell user. This screen walks through that and then asks + * Shizuku for permission. + */ +class ShellActivity : Activity() { + + private val redraw: () -> Unit = { if (!isFinishing) build() } + + override fun onResume() { + super.onResume() + Shell.onChange(redraw) + Shell.connect(this) + build() + } + + override fun onPause() { + super.onPause() + Shell.removeOnChange(redraw) + } + + private fun build() { + val state = Shell.state(this) + val col = Ui.page(this) + col.title("Shell access") + col.row("Status: " + Shell.describe(this), null, enabled = false) + + col.note( + "Android keeps a few things from every installed app: the Power " + + "button, the navigation bar, the system gestures, a device maker's " + + "own key settings. The shell user - the one a computer gets over " + + "USB debugging - can reach all of them. Shizuku, a free app, gives " + + "AssistKey that same access from the device itself, with no " + + "computer and no root." + ) + + if (state == Shell.State.READY) { + col.header("What this unlocks") + col.note( + "Power button: tap, double tap, more taps, hold, and combinations " + + "with other keys.\n" + + "Navigation: show or hide the button bar and switch system " + + "gestures, from inside the app.\n" + + "Device key settings that would otherwise need a computer." + ) + col.note( + "Shizuku started over wireless debugging stops when the device " + + "restarts. Until it is started again AssistKey gives the Power " + + "button back to the system, so it always works." + ) + return + } + + steps(col, state) + } + + private fun steps(col: LinearLayout, state: Shell.State) { + col.header("1. Install Shizuku") + if (state == Shell.State.NOT_INSTALLED) { + col.button("Get Shizuku") { + val market = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=" + Shell.SHIZUKU_PACKAGE)) + val web = Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/download/")) + if (!tryStart(market)) tryStart(web) + } + } else { + col.note("Done.") + } + + col.header("2. Turn on wireless debugging") + col.note( + "Settings > About > tap Build number seven times to unlock Developer " + + "options. In Developer options, switch on Wireless debugging. It " + + "needs Wi-Fi to be connected, but nothing leaves the device." + ) + col.button("Open Developer options") { + if (!tryStart(Intent(Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS))) { + tryStart(Intent(Settings.ACTION_DEVICE_INFO_SETTINGS)) + } + } + + col.header("3. Start Shizuku") + col.note( + "In Shizuku choose Pairing, then in Wireless debugging choose Pair " + + "device with pairing code and type the code into Shizuku's " + + "notification. Back in Shizuku, press Start." + ) + if (state != Shell.State.NOT_INSTALLED) { + col.button("Open Shizuku") { + packageManager.getLaunchIntentForPackage(Shell.SHIZUKU_PACKAGE)?.let { tryStart(it) } + } + } + + col.header("4. Allow AssistKey") + when (state) { + Shell.State.NO_PERMISSION -> col.button("Ask Shizuku for permission") { + Shell.requestPermission() + } + else -> col.note("Once Shizuku is running, come back here.") + } + } + + private fun tryStart(i: Intent): Boolean = + runCatching { startActivity(i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); true }.getOrDefault(false) +} diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml index b9a3582..4799de5 100644 --- a/app/src/main/res/xml/accessibility_service_config.xml +++ b/app/src/main/res/xml/accessibility_service_config.xml @@ -8,7 +8,7 @@ <accessibility-service xmlns:android="http://schemas.android.com/apk/res/android" android:accessibilityEventTypes="typeWindowStateChanged" android:accessibilityFeedbackType="feedbackGeneric" - android:accessibilityFlags="flagDefault|flagRequestFilterKeyEvents|flagReportViewIds" + android:accessibilityFlags="flagDefault|flagRequestFilterKeyEvents|flagReportViewIds|flagRetrieveInteractiveWindows" android:canRequestFilterKeyEvents="true" android:canPerformGestures="true" android:canRetrieveWindowContent="true"