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() }