Add first settings UI for nav button configuration

This commit is contained in:
ajp_anton
2026-04-23 00:17:45 +00:00
parent cade5e360e
commit a3e6855745
17 changed files with 738 additions and 2 deletions
+14 -1
View File
@@ -11,6 +11,19 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" />
android:supportsRtl="true"
android:theme="@style/Theme.NavButtons">
<activity
android:name=".SettingsActivity"
android:exported="true"
android:label="@string/settings_title">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,112 @@
package se.ajpanton.navbuttons
import android.content.Context
import android.view.ViewConfiguration
import androidx.annotation.StringRes
enum class NavButtonId(
val preferencePrefix: String,
@get:StringRes val titleRes: Int,
) {
BACK("back", R.string.button_back),
HOME("home", R.string.button_home),
RECENTS("recents", R.string.button_recents),
}
enum class NavButtonAction(
val storageValue: String,
@get:StringRes val labelRes: Int,
) {
STOCK("stock", R.string.action_stock),
KILL_FOREGROUND_APP("kill_foreground_app", R.string.action_kill_foreground_app),
NONE("none", R.string.action_none),
;
companion object {
fun fromStorageValue(value: String?): NavButtonAction {
return entries.firstOrNull { it.storageValue == value } ?: STOCK
}
}
}
data class NavButtonConfig(
val pressAction: NavButtonAction,
val longPressAction: NavButtonAction,
val longPressDurationMs: Int,
)
class NavButtonSettingsStore(context: Context) {
private val preferences = context.applicationContext.getSharedPreferences(
PREFERENCES_NAME,
Context.MODE_PRIVATE,
)
fun getConfig(buttonId: NavButtonId): NavButtonConfig {
return NavButtonConfig(
pressAction = NavButtonAction.fromStorageValue(
preferences.getString(pressActionKey(buttonId), null),
),
longPressAction = NavButtonAction.fromStorageValue(
preferences.getString(longPressActionKey(buttonId), null),
),
longPressDurationMs = sanitizeDuration(
preferences.getInt(
longPressDurationKey(buttonId),
defaultLongPressDurationMs(),
),
),
)
}
fun setPressAction(buttonId: NavButtonId, action: NavButtonAction) {
preferences.edit()
.putString(pressActionKey(buttonId), action.storageValue)
.apply()
}
fun setLongPressAction(buttonId: NavButtonId, action: NavButtonAction) {
preferences.edit()
.putString(longPressActionKey(buttonId), action.storageValue)
.apply()
}
fun setLongPressDurationMs(buttonId: NavButtonId, durationMs: Int) {
preferences.edit()
.putInt(longPressDurationKey(buttonId), sanitizeDuration(durationMs))
.apply()
}
fun defaultLongPressDurationMs(): Int {
return sanitizeDuration(ViewConfiguration.getLongPressTimeout())
}
fun sanitizeDuration(durationMs: Int): Int {
return durationMs.coerceIn(MIN_LONG_PRESS_DURATION_MS, MAX_LONG_PRESS_DURATION_MS)
}
companion object {
const val PREFERENCES_NAME = "nav_button_settings"
const val KEY_PRESS_ACTION = "press_action"
const val KEY_LONG_PRESS_ACTION = "long_press_action"
const val KEY_LONG_PRESS_DURATION_MS = "long_press_duration_ms"
const val MIN_LONG_PRESS_DURATION_MS = 100
const val MAX_LONG_PRESS_DURATION_MS = 10_000
const val MIN_DURATION_MS = 100
const val MAX_DURATION_MS = 10_000
const val DURATION_STEP_MS = 100
const val HOLD_REPEAT_START_DELAY_MS = 700L
const val HOLD_REPEAT_INTERVAL_MS = 100L
fun pressActionKey(buttonId: NavButtonId): String = key(buttonId, KEY_PRESS_ACTION)
fun longPressActionKey(buttonId: NavButtonId): String = key(buttonId, KEY_LONG_PRESS_ACTION)
fun longPressDurationKey(buttonId: NavButtonId): String =
key(buttonId, KEY_LONG_PRESS_DURATION_MS)
private fun key(buttonId: NavButtonId, suffix: String): String {
return "${buttonId.preferencePrefix}_$suffix"
}
}
}
@@ -0,0 +1,305 @@
package se.ajpanton.navbuttons
import android.app.Activity
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Spinner
import android.widget.TextView
class SettingsActivity : Activity() {
private val handler = Handler(Looper.getMainLooper())
private lateinit var settingsStore: NavButtonSettingsStore
private lateinit var sectionsContainer: LinearLayout
private lateinit var previewOverlay: View
private lateinit var previewRunnable: Runnable
private var previewScheduled = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
title = getString(R.string.settings_title)
settingsStore = NavButtonSettingsStore(applicationContext)
sectionsContainer = findViewById(R.id.button_sections_container)
previewOverlay = findViewById(R.id.test_hold_overlay)
previewRunnable = Runnable {
previewScheduled = false
previewOverlay.visibility = View.VISIBLE
previewOverlay.animate()
.alpha(1f)
.setDuration(120)
.start()
}
addButtonSections()
}
override fun onPause() {
super.onPause()
stopTestPreview()
}
private fun addButtonSections() {
val inflater = LayoutInflater.from(this)
NavButtonId.entries.forEachIndexed { index, buttonId ->
val sectionView = inflater.inflate(
R.layout.item_nav_button_section,
sectionsContainer,
false,
)
if (index > 0) {
val marginTop = resources.getDimensionPixelSize(R.dimen.section_spacing)
val layoutParams = sectionView.layoutParams as? ViewGroup.MarginLayoutParams
if (layoutParams != null) {
layoutParams.topMargin = marginTop
} else {
sectionView.layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply {
topMargin = marginTop
}
}
}
NavButtonSectionController(sectionView, buttonId).bind()
sectionsContainer.addView(sectionView)
}
}
private fun startTestPreview(durationMs: Int) {
stopTestPreview()
previewScheduled = true
handler.postDelayed(previewRunnable, durationMs.toLong())
}
private fun stopTestPreview() {
if (previewScheduled) {
handler.removeCallbacks(previewRunnable)
previewScheduled = false
}
previewOverlay.animate().cancel()
previewOverlay.alpha = 0f
previewOverlay.visibility = View.GONE
}
private inner class NavButtonSectionController(
sectionView: View,
private val buttonId: NavButtonId,
) {
private val titleView: TextView = sectionView.findViewById(R.id.section_title)
private val pressActionSpinner: Spinner = sectionView.findViewById(R.id.press_action_spinner)
private val longPressActionSpinner: Spinner =
sectionView.findViewById(R.id.long_press_action_spinner)
private val durationInput: EditText = sectionView.findViewById(R.id.long_press_duration_input)
private val minusButton: Button = sectionView.findViewById(R.id.minus_button)
private val plusButton: Button = sectionView.findViewById(R.id.plus_button)
private val testButton: Button = sectionView.findViewById(R.id.test_button)
private var lastValidDurationMs = settingsStore.defaultLongPressDurationMs()
private var updatingDurationText = false
fun bind() {
val config = settingsStore.getConfig(buttonId)
titleView.setText(buttonId.titleRes)
bindActionSpinner(
pressActionSpinner,
config.pressAction,
settingsStore::setPressAction,
)
bindActionSpinner(
longPressActionSpinner,
config.longPressAction,
settingsStore::setLongPressAction,
)
applyDuration(config.longPressDurationMs)
bindDurationInput()
bindStepButtons()
bindTestButton()
}
private fun bindActionSpinner(
spinner: Spinner,
selectedAction: NavButtonAction,
storeAction: (NavButtonId, NavButtonAction) -> Unit,
) {
val labels = NavButtonAction.entries.map { getString(it.labelRes) }
val adapter = ArrayAdapter(
this@SettingsActivity,
R.layout.item_spinner_selected,
labels,
)
adapter.setDropDownViewResource(R.layout.item_spinner_dropdown)
spinner.adapter = adapter
spinner.setSelection(NavButtonAction.entries.indexOf(selectedAction), false)
spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parent: AdapterView<*>?,
view: View?,
position: Int,
id: Long,
) {
storeAction(buttonId, NavButtonAction.entries[position])
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}
}
private fun bindDurationInput() {
durationInput.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
normalizeTypedDuration()
}
}
durationInput.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(
s: CharSequence?,
start: Int,
count: Int,
after: Int,
) = Unit
override fun onTextChanged(
s: CharSequence?,
start: Int,
before: Int,
count: Int,
) = Unit
override fun afterTextChanged(editable: Editable?) {
if (updatingDurationText) {
return
}
val parsed = editable?.toString()?.toIntOrNull() ?: return
if (parsed in NavButtonSettingsStore.MIN_DURATION_MS..NavButtonSettingsStore.MAX_DURATION_MS) {
lastValidDurationMs = parsed
settingsStore.setLongPressDurationMs(buttonId, parsed)
}
}
})
}
private fun bindStepButtons() {
attachRepeatButton(minusButton) {
adjustDuration(-NavButtonSettingsStore.DURATION_STEP_MS)
}
attachRepeatButton(plusButton) {
adjustDuration(NavButtonSettingsStore.DURATION_STEP_MS)
}
}
private fun bindTestButton() {
testButton.setOnTouchListener { view, event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
view.isPressed = true
startTestPreview(resolveDurationForUse())
true
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL,
-> {
view.isPressed = false
stopTestPreview()
true
}
else -> false
}
}
}
private fun attachRepeatButton(button: Button, action: () -> Unit) {
var repeated = false
val repeatRunnable = object : Runnable {
override fun run() {
repeated = true
action()
handler.postDelayed(this, NavButtonSettingsStore.HOLD_REPEAT_INTERVAL_MS)
}
}
button.setOnClickListener {
if (!repeated) {
action()
}
}
button.setOnTouchListener { view, event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
repeated = false
handler.postDelayed(
repeatRunnable,
NavButtonSettingsStore.HOLD_REPEAT_START_DELAY_MS,
)
false
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL,
-> {
handler.removeCallbacks(repeatRunnable)
false
}
else -> false
}
}
}
private fun adjustDuration(deltaMs: Int) {
val current = resolveDurationForUse()
val updated = (current + deltaMs).coerceIn(
NavButtonSettingsStore.MIN_DURATION_MS,
NavButtonSettingsStore.MAX_DURATION_MS,
)
applyDuration(updated)
}
private fun normalizeTypedDuration(): Int {
val parsed = durationInput.text.toString().toIntOrNull() ?: lastValidDurationMs
val normalized = settingsStore.sanitizeDuration(parsed)
applyDuration(normalized)
return normalized
}
private fun resolveDurationForUse(): Int {
return normalizeTypedDuration()
}
private fun applyDuration(durationMs: Int) {
val sanitized = settingsStore.sanitizeDuration(durationMs)
val text = sanitized.toString()
updatingDurationText = true
if (durationInput.text.toString() != text) {
durationInput.setText(text)
durationInput.setSelection(durationInput.text.length)
}
updatingDurationText = false
lastValidDurationMs = sanitized
settingsStore.setLongPressDurationMs(buttonId, sanitized)
}
}
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="22dp" />
<solid android:color="@color/settings_card_fill" />
<stroke
android:width="1dp"
android:color="@color/settings_card_stroke" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="270"
android:endColor="@color/settings_window_gradient_end"
android:startColor="@color/settings_window_gradient_start" />
</shape>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="12dp" />
<solid android:color="@color/settings_spinner_fill" />
<stroke
android:width="1dp"
android:color="@color/settings_spinner_stroke" />
</shape>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="14dp" />
<solid android:color="@color/settings_spinner_popup_fill" />
<stroke
android:width="1dp"
android:color="@color/settings_spinner_popup_stroke" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/settings_test_overlay" />
</shape>
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:fillViewport="true"
android:fitsSystemWindows="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="@dimen/screen_padding"
android:paddingTop="@dimen/screen_padding"
android:paddingEnd="@dimen/screen_padding"
android:paddingBottom="@dimen/screen_padding_large">
<TextView
android:id="@+id/settings_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_title"
android:textColor="@color/settings_text_primary"
android:textSize="30sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="3dp"
android:text="@string/settings_summary"
android:textColor="@color/settings_text_secondary"
android:textSize="15sp" />
<LinearLayout
android:id="@+id/button_sections_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>
<View
android:id="@+id/test_hold_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:alpha="0"
android:background="@drawable/bg_test_hold_overlay"
android:clickable="false"
android:focusable="false"
android:visibility="gone" />
</FrameLayout>
@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_button_section"
android:elevation="2dp"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:id="@+id/section_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/settings_text_primary"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/press_action_label"
android:textColor="@color/settings_label_text"
android:textSize="13sp"
android:textStyle="bold" />
<Spinner
android:id="@+id/press_action_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/bg_spinner_field"
android:dropDownWidth="match_parent"
android:paddingStart="12dp"
android:paddingTop="6dp"
android:paddingEnd="12dp"
android:paddingBottom="6dp"
android:popupBackground="@drawable/bg_spinner_popup" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/long_press_action_label"
android:textColor="@color/settings_label_text"
android:textSize="13sp"
android:textStyle="bold" />
<Spinner
android:id="@+id/long_press_action_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/bg_spinner_field"
android:dropDownWidth="match_parent"
android:paddingStart="12dp"
android:paddingTop="6dp"
android:paddingEnd="12dp"
android:paddingBottom="6dp"
android:popupBackground="@drawable/bg_spinner_popup" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/long_press_duration_label"
android:textColor="@color/settings_label_text"
android:textSize="13sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<Button
android:id="@+id/minus_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:minWidth="48dp"
android:text="@string/minus_button_label"
android:textAllCaps="false"
android:textSize="22sp" />
<EditText
android:id="@+id/long_press_duration_input"
android:layout_width="88dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:gravity="center"
android:importantForAutofill="no"
android:inputType="number"
android:maxLength="5"
android:singleLine="true"
android:textColor="@color/settings_input_text"
android:textSize="18sp" />
<Button
android:id="@+id/plus_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:minWidth="48dp"
android:text="@string/plus_button_label"
android:textAllCaps="false"
android:textSize="20sp" />
</LinearLayout>
<Button
android:id="@+id/test_button"
android:layout_width="wrap_content"
android:layout_height="54dp"
android:layout_marginTop="14dp"
android:minWidth="160dp"
android:text="@string/test_button_label"
android:textAllCaps="false"
android:textSize="16sp" />
</LinearLayout>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="center_vertical"
android:minHeight="48dp"
android:paddingStart="16dp"
android:paddingTop="12dp"
android:paddingEnd="16dp"
android:paddingBottom="12dp"
android:singleLine="true"
android:textColor="@color/settings_text_primary"
android:textSize="16sp" />
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="center_vertical"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:singleLine="true"
android:textColor="@color/settings_text_primary"
android:textSize="16sp" />
+17
View File
@@ -0,0 +1,17 @@
<resources>
<color name="settings_text_primary">#EAF2FF</color>
<color name="settings_text_secondary">#C4D0E1</color>
<color name="settings_text_tertiary">#9EB0C5</color>
<color name="settings_label_text">#B2C1D3</color>
<color name="settings_input_text">#F3F7FF</color>
<color name="settings_window_gradient_start">#0F1722</color>
<color name="settings_window_gradient_end">#1B2433</color>
<color name="settings_card_fill">#CC162231</color>
<color name="settings_card_stroke">#4B8FAED1</color>
<color name="settings_spinner_fill">#223346</color>
<color name="settings_spinner_stroke">#5F8AAAC7</color>
<color name="settings_spinner_popup_fill">#1A2635</color>
<color name="settings_spinner_popup_stroke">#7098B6D3</color>
<color name="settings_test_overlay">#2EDAE8FF</color>
</resources>
+17
View File
@@ -0,0 +1,17 @@
<resources>
<color name="settings_text_primary">#102135</color>
<color name="settings_text_secondary">#4F5F73</color>
<color name="settings_text_tertiary">#64768A</color>
<color name="settings_label_text">#506174</color>
<color name="settings_input_text">#102135</color>
<color name="settings_window_gradient_start">#EAF6FF</color>
<color name="settings_window_gradient_end">#FFF4EC</color>
<color name="settings_card_fill">#F2FFFFFF</color>
<color name="settings_card_stroke">#1F27425C</color>
<color name="settings_spinner_fill">#F9FCFF</color>
<color name="settings_spinner_stroke">#33536A82</color>
<color name="settings_spinner_popup_fill">#FFFFFFFF</color>
<color name="settings_spinner_popup_stroke">#264E657D</color>
<color name="settings_test_overlay">#70FFFFFF</color>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<resources>
<dimen name="screen_padding">20dp</dimen>
<dimen name="screen_padding_large">28dp</dimen>
<dimen name="section_spacing">16dp</dimen>
</resources>
+15 -1
View File
@@ -1,4 +1,18 @@
<resources>
<string name="app_name">NavButtons</string>
<string name="xposed_desc">Kill foreground app on "Back" button long-press.</string>
<string name="xposed_desc">Customize navigation button press and long-press actions.</string>
<string name="settings_title">Nav Button Settings</string>
<string name="settings_summary">Choose what each navigation button should do on press and long press, then tune how long a long press should feel.</string>
<string name="button_back">Back Button</string>
<string name="button_home">Home Button</string>
<string name="button_recents">Recents Button</string>
<string name="press_action_label">When pressed</string>
<string name="long_press_action_label">When long-pressed</string>
<string name="long_press_duration_label">Long-press duration (ms)</string>
<string name="action_stock">Stock</string>
<string name="action_kill_foreground_app">Kill foreground app</string>
<string name="action_none">None</string>
<string name="minus_button_label">-</string>
<string name="plus_button_label">+</string>
<string name="test_button_label">Duration test (hold)</string>
</resources>
+6
View File
@@ -0,0 +1,6 @@
<resources>
<style name="Theme.NavButtons" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="android:windowBackground">@drawable/bg_settings_window</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
</style>
</resources>