Recently Written · git

assistkey

Hardware key remapper for the Viwoods AiPaper Reader: AI key, volume keys and power, with multi-tap, hold and chords

git clone https://github.com/equwal/assistkey

Log | Files | Refs


commit 79cd2e6f42ff751d775fc7430ad5e6118df03f98
truex <truex@equwal.com>
2026-09-20 15:28:39 -0700

Handle hidden power settings being unreadable, not just unwritable

Android 12 blocks non-system apps from reading settings keys annotated
@hide, and holding WRITE_SECURE_SETTINGS does not exempt you - reading
power_button_short_press threw SecurityException and took the main
screen down on first launch.

Every read now returns null on refusal, the UI says unknown instead of
inventing a value, and the two gesture switches became explicit On/Off
pickers because there is nothing to toggle away from when the current
value cannot be read. Writes are unaffected and still work.

 .../dev/equwal/assistkey/native/PowerNative.kt     |  76 +++++++++-----
 .../java/dev/equwal/assistkey/ui/MainActivity.kt   |   6 +-
 .../java/dev/equwal/assistkey/ui/PowerActivity.kt  | 112 +++++++++++++--------
 3 files changed, 127 insertions(+), 67 deletions(-)
diff --git a/app/src/main/java/dev/equwal/assistkey/native/PowerNative.kt b/app/src/main/java/dev/equwal/assistkey/native/PowerNative.kt
index f227fe3..ffb5e3c 100644
--- a/app/src/main/java/dev/equwal/assistkey/native/PowerNative.kt
+++ b/app/src/main/java/dev/equwal/assistkey/native/PowerNative.kt
@@ -10,33 +10,38 @@ import android.provider.Settings
  * Settings.Secure rather than in anything an app can intercept.
  *
  * This is the only way to touch short-press Power at all: no app ever sees that
- * event. Writing here needs WRITE_SECURE_SETTINGS, which is not grantable from
- * a settings screen - it has to come over adb - so every write is attempted and
- * reported rather than assumed.
+ * event.
+ *
+ * Two separate restrictions apply, and they are not symmetric. Writing needs
+ * WRITE_SECURE_SETTINGS, which is not grantable from a settings screen - it has
+ * to come over adb. Reading is blocked outright: since Android 12 a settings
+ * key annotated @hide throws SecurityException for any non-system caller, and
+ * holding WRITE_SECURE_SETTINGS does not exempt you. So every read here returns
+ * null on refusal and the UI says "unknown" rather than inventing a value.
  */
 object PowerNative {
 
     const val GRANT_COMMAND =
         "adb shell pm grant dev.equwal.assistkey android.permission.WRITE_SECURE_SETTINGS"
 
-    private const val SHORT_PRESS = "power_button_short_press"
-    private const val LONG_PRESS = "power_button_long_press"
-    private const val LONG_PRESS_MS = "power_button_long_press_duration_ms"
-    private const val CHORD_VOL_UP = "key_chord_power_volume_up"
-    private const val CAMERA_DOUBLE_TAP = "camera_double_tap_power_gesture_disabled"
-    private const val DOUBLE_TAP = "double_tap_power_button_gesture_enabled"
+    const val SHORT_PRESS = "power_button_short_press"
+    const val LONG_PRESS = "power_button_long_press"
+    const val LONG_PRESS_MS = "power_button_long_press_duration_ms"
+    const val CHORD_VOL_UP = "key_chord_power_volume_up"
+    const val CAMERA_DOUBLE_TAP = "camera_double_tap_power_gesture_disabled"
+    const val DOUBLE_TAP = "double_tap_power_button_gesture_enabled"
 
     /** A firmware behaviour with its raw value, for a plain radio list. */
     data class Option(val value: Int, val label: String, val note: String = "")
 
     /**
      * SHORT_PRESS_POWER_* in PhoneWindowManager. 6 and above only exist on
-     * newer builds; an unsupported value is simply ignored by the framework,
-     * so they are offered with a warning rather than hidden.
+     * newer builds; an unsupported value is ignored by the framework, so they
+     * are offered with a warning rather than hidden.
      */
     val shortPress = listOf(
         Option(1, "Sleep", "Stock behaviour"),
-        Option(0, "Nothing", "Power becomes a free button - use with a long-press binding"),
+        Option(0, "Nothing", "Power becomes a free button - pair with a hold binding"),
         Option(4, "Home"),
         Option(5, "Close keyboard, else Home"),
         Option(2, "Sleep immediately"),
@@ -62,34 +67,58 @@ object PowerNative {
         Option(0, "Nothing")
     )
 
+    val onOff = listOf(Option(1, "On"), Option(0, "Off"))
+
     fun canWriteSecure(c: Context): Boolean =
         c.checkSelfPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS) ==
             PackageManager.PERMISSION_GRANTED
 
-    // ---- reads (always allowed) -------------------------------------------
+    // ---- reads: null means unset or refused --------------------------------
 
     private fun cr(c: Context): ContentResolver = c.contentResolver
 
-    fun shortPressValue(c: Context): Int = Settings.Global.getInt(cr(c), SHORT_PRESS, 1)
-    fun longPressValue(c: Context): Int = Settings.Global.getInt(cr(c), LONG_PRESS, 1)
-    fun longPressMs(c: Context): Int = Settings.Global.getInt(cr(c), LONG_PRESS_MS, 500)
-    fun chordVolumeUpValue(c: Context): Int = Settings.Global.getInt(cr(c), CHORD_VOL_UP, 2)
+    private fun globalInt(c: Context, key: String): Int? =
+        runCatching { Settings.Global.getString(cr(c), key)?.trim()?.toIntOrNull() }.getOrNull()
+
+    private fun secureInt(c: Context, key: String): Int? =
+        runCatching { Settings.Secure.getString(cr(c), key)?.trim()?.toIntOrNull() }.getOrNull()
+
+    fun shortPressValue(c: Context): Int? = globalInt(c, SHORT_PRESS)
+    fun longPressValue(c: Context): Int? = globalInt(c, LONG_PRESS)
+    fun longPressMs(c: Context): Int? = globalInt(c, LONG_PRESS_MS)
+    fun chordVolumeUpValue(c: Context): Int? = globalInt(c, CHORD_VOL_UP)
 
     /** Inverted in the framework: the setting records "disabled". */
-    fun cameraDoubleTapEnabled(c: Context): Boolean =
-        Settings.Secure.getInt(cr(c), CAMERA_DOUBLE_TAP, 0) == 0
+    fun cameraDoubleTapEnabled(c: Context): Boolean? =
+        secureInt(c, CAMERA_DOUBLE_TAP)?.let { it == 0 }
 
-    fun doubleTapGestureEnabled(c: Context): Boolean =
-        Settings.Secure.getInt(cr(c), DOUBLE_TAP, 0) == 1
+    fun doubleTapGestureEnabled(c: Context): Boolean? =
+        secureInt(c, DOUBLE_TAP)?.let { it == 1 }
 
-    // ---- writes (need the adb grant) --------------------------------------
+    /**
+     * Whether this build lets us read these at all. Used to explain an
+     * "unknown" rather than leaving the user wondering.
+     */
+    fun canRead(c: Context): Boolean =
+        runCatching { Settings.Global.getString(cr(c), LONG_PRESS); true }.getOrDefault(false)
+
+    /** Renders a value against a list, coping with unknown and with junk. */
+    fun describe(options: List<Option>, value: Int?): String = when {
+        value == null -> "Unknown"
+        else -> options.firstOrNull { it.value == value }?.label ?: ("Unknown (" + value + ")")
+    }
+
+    // ---- writes: need the adb grant ----------------------------------------
 
     fun setShortPress(c: Context, v: Int) = putGlobal(c, SHORT_PRESS, v)
     fun setLongPress(c: Context, v: Int) = putGlobal(c, LONG_PRESS, v)
     fun setLongPressMs(c: Context, v: Int) = putGlobal(c, LONG_PRESS_MS, v)
     fun setChordVolumeUp(c: Context, v: Int) = putGlobal(c, CHORD_VOL_UP, v)
+
+    /** Takes the user-facing sense; the stored value is inverted. */
     fun setCameraDoubleTapEnabled(c: Context, on: Boolean) =
         putSecure(c, CAMERA_DOUBLE_TAP, if (on) 0 else 1)
+
     fun setDoubleTapGestureEnabled(c: Context, on: Boolean) =
         putSecure(c, DOUBLE_TAP, if (on) 1 else 0)
 
@@ -102,7 +131,4 @@ object PowerNative {
     /** So the user can paste the exact command when a write is refused. */
     fun adbFallback(scope: String, key: String, value: Int): String =
         "adb shell settings put " + scope + " " + key + " " + value
-
-    fun shortPressAdb(v: Int) = adbFallback("global", SHORT_PRESS, v)
-    fun longPressAdb(v: Int) = adbFallback("global", LONG_PRESS, v)
 }
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 bf9849b..55e2aac 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt
@@ -125,8 +125,10 @@ class MainActivity : Activity() {
     private fun powerSummary(): String {
         val b = Store.bindings(this)
         val parts = ArrayList<String>()
-        PowerNative.shortPress.firstOrNull { it.value == PowerNative.shortPressValue(this) }
-            ?.let { parts.add("short → " + it.label.lowercase()) }
+        PowerNative.shortPressValue(this)?.let { v ->
+            PowerNative.shortPress.firstOrNull { it.value == v }
+                ?.let { parts.add("short → " + it.label.lowercase()) }
+        }
         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(", ")
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 f4a6754..65a5fb4 100644
--- a/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt
+++ b/app/src/main/java/dev/equwal/assistkey/ui/PowerActivity.kt
@@ -24,25 +24,35 @@ import dev.equwal.assistkey.ui.Ui.title
  */
 class PowerActivity : Activity() {
 
+    private var readable = true
+
     override fun onResume() {
         super.onResume()
         build()
     }
 
     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(
-                "Short press and the firmware gesture switches below need a " +
+                "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)
@@ -56,16 +66,14 @@ class PowerActivity : Activity() {
     private fun shortPress(col: LinearLayout) {
         col.header("Short press")
         val cur = PowerNative.shortPressValue(this)
-        val label = PowerNative.shortPress.firstOrNull { it.value == cur }?.label
-            ?: ("Unknown (" + cur + ")")
         col.row(
-            "Currently: " + label,
+            "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", "power_button_short_press", v
+                    "global", PowerNative.SHORT_PRESS, v
                 )
             }
         }
@@ -95,27 +103,27 @@ class PowerActivity : Activity() {
             startActivity(ActionPickerActivity.intent(this, Channel.POWER_DOUBLE))
         }
 
-        col.row(
-            "Firmware double-press gesture: " +
-                if (PowerNative.doubleTapGestureEnabled(this)) "on" else "off",
+        onOffRow(
+            col,
+            "Firmware double-press gesture",
+            PowerNative.doubleTapGestureEnabled(this),
             "Must be on for double press to do anything at all"
-        ) {
-            val on = !PowerNative.doubleTapGestureEnabled(this)
+        ) { on ->
             apply(
                 PowerNative.setDoubleTapGestureEnabled(this, on),
-                "secure", "double_tap_power_button_gesture_enabled", if (on) 1 else 0
+                "secure", PowerNative.DOUBLE_TAP, if (on) 1 else 0
             )
         }
 
-        col.row(
-            "Camera double-press gesture: " +
-                if (PowerNative.cameraDoubleTapEnabled(this)) "on" else "off",
+        onOffRow(
+            col,
+            "Camera double-press gesture",
+            PowerNative.cameraDoubleTapEnabled(this),
             "The older camera-specific route, still live on this firmware"
-        ) {
-            val on = !PowerNative.cameraDoubleTapEnabled(this)
+        ) { on ->
             apply(
                 PowerNative.setCameraDoubleTapEnabled(this, on),
-                "secure", "camera_double_tap_power_gesture_disabled", if (on) 0 else 1
+                "secure", PowerNative.CAMERA_DOUBLE_TAP, if (on) 0 else 1
             )
         }
     }
@@ -125,16 +133,20 @@ class PowerActivity : Activity() {
     private fun longPress(col: LinearLayout) {
         col.header("Press and hold")
         val cur = PowerNative.longPressValue(this)
-        val assistant = cur == 5
         val held = Channels.isSatisfied(this, Channel.ASSISTANT)
 
         col.note(
             when {
-                assistant && held ->
+                cur == 5 && held ->
                     "Routed here: the firmware sends hold to the assistant, and that is us."
-                assistant ->
+                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."
@@ -146,28 +158,27 @@ class PowerActivity : Activity() {
             startActivity(ActionPickerActivity.intent(this, Channel.POWER_HOLD))
         }
 
-        val label = PowerNative.longPress.firstOrNull { it.value == cur }?.label
-            ?: ("Unknown (" + cur + ")")
-        col.row("Firmware behaviour: " + label, "Set this to Digital assistant") {
+        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", "power_button_long_press", v
-                )
+                apply(PowerNative.setLongPress(this, v), "global", PowerNative.LONG_PRESS, v)
             }
         }
 
+        val ms = PowerNative.longPressMs(this)
+        val opts = listOf(250, 350, 500, 650, 800, 1000).map {
+            PowerNative.Option(it, it.toString() + " ms")
+        }
         col.row(
-            "Hold time: " + PowerNative.longPressMs(this) + " ms",
+            "Hold time: " + PowerNative.describe(opts, ms),
             "How long the button must be down before it counts as a hold"
         ) {
-            val opts = listOf(250, 350, 500, 650, 800, 1000).map {
-                PowerNative.Option(it, it.toString() + " ms")
-            }
-            pickNative("Hold time", opts, PowerNative.longPressMs(this)) { v ->
+            pickNative("Hold time", opts, ms) { v ->
                 apply(
                     PowerNative.setLongPressMs(this, v),
-                    "global", "power_button_long_press_duration_ms", v
+                    "global", PowerNative.LONG_PRESS_MS, v
                 )
             }
         }
@@ -176,16 +187,14 @@ class PowerActivity : Activity() {
     private fun escapeHatch(col: LinearLayout) {
         col.header("Escape hatch")
         val cur = PowerNative.chordVolumeUpValue(this)
-        val label = PowerNative.chordVolumeUp.firstOrNull { it.value == cur }?.label
-            ?: ("Unknown (" + cur + ")")
         col.row(
-            "Power + Volume up: " + label,
+            "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", "key_chord_power_volume_up", v
+                    "global", PowerNative.CHORD_VOL_UP, v
                 )
             }
         }
@@ -193,14 +202,37 @@ class PowerActivity : Activity() {
 
     // ---- helpers -----------------------------------------------------------
 
+    /**
+     * 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"
+        }
+        col.row(title + ": " + shown, blurb) {
+            pickNative(title, PowerNative.onOff, current?.let { if (it) 1 else 0 }) { v ->
+                onPick(v == 1)
+            }
+        }
+    }
+
     private fun pickNative(
         title: String,
         options: List<PowerNative.Option>,
-        current: Int,
+        current: Int?,
         onPick: (Int) -> Unit
     ) {
         val labels = options.map { o ->
-            val mark = if (o.value == current) "* " else "   "
+            val mark = if (current != null && o.value == current) "* " else "   "
             mark + o.label + if (o.note.isBlank()) "" else "  -  " + o.note
         }
         Ui.pick(this, title, labels) { i -> onPick(options[i].value) }
@@ -213,7 +245,7 @@ class PowerActivity : Activity() {
         } else {
             Toast.makeText(
                 this,
-                "Refused - run: adb shell settings put " + scope + " " + key + " " + value,
+                "Refused - run: " + PowerNative.adbFallback(scope, key, value),
                 Toast.LENGTH_LONG
             ).show()
         }