diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e6e334b..b69ad59 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -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">
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/se/ajpanton/navbuttons/NavButtonSettingsStore.kt b/app/src/main/java/se/ajpanton/navbuttons/NavButtonSettingsStore.kt
new file mode 100644
index 0000000..cde8cab
--- /dev/null
+++ b/app/src/main/java/se/ajpanton/navbuttons/NavButtonSettingsStore.kt
@@ -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"
+ }
+ }
+}
diff --git a/app/src/main/java/se/ajpanton/navbuttons/SettingsActivity.kt b/app/src/main/java/se/ajpanton/navbuttons/SettingsActivity.kt
new file mode 100644
index 0000000..deae4c1
--- /dev/null
+++ b/app/src/main/java/se/ajpanton/navbuttons/SettingsActivity.kt
@@ -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)
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/bg_button_section.xml b/app/src/main/res/drawable/bg_button_section.xml
new file mode 100644
index 0000000..afaa7f3
--- /dev/null
+++ b/app/src/main/res/drawable/bg_button_section.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_settings_window.xml b/app/src/main/res/drawable/bg_settings_window.xml
new file mode 100644
index 0000000..b55018a
--- /dev/null
+++ b/app/src/main/res/drawable/bg_settings_window.xml
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_spinner_field.xml b/app/src/main/res/drawable/bg_spinner_field.xml
new file mode 100644
index 0000000..570f6f1
--- /dev/null
+++ b/app/src/main/res/drawable/bg_spinner_field.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_spinner_popup.xml b/app/src/main/res/drawable/bg_spinner_popup.xml
new file mode 100644
index 0000000..f249b7c
--- /dev/null
+++ b/app/src/main/res/drawable/bg_spinner_popup.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_test_hold_overlay.xml b/app/src/main/res/drawable/bg_test_hold_overlay.xml
new file mode 100644
index 0000000..538836d
--- /dev/null
+++ b/app/src/main/res/drawable/bg_test_hold_overlay.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml
new file mode 100644
index 0000000..3d2bfbc
--- /dev/null
+++ b/app/src/main/res/layout/activity_settings.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_nav_button_section.xml b/app/src/main/res/layout/item_nav_button_section.xml
new file mode 100644
index 0000000..68f183b
--- /dev/null
+++ b/app/src/main/res/layout/item_nav_button_section.xml
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_spinner_dropdown.xml b/app/src/main/res/layout/item_spinner_dropdown.xml
new file mode 100644
index 0000000..dd90fb4
--- /dev/null
+++ b/app/src/main/res/layout/item_spinner_dropdown.xml
@@ -0,0 +1,14 @@
+
+
diff --git a/app/src/main/res/layout/item_spinner_selected.xml b/app/src/main/res/layout/item_spinner_selected.xml
new file mode 100644
index 0000000..1630b53
--- /dev/null
+++ b/app/src/main/res/layout/item_spinner_selected.xml
@@ -0,0 +1,11 @@
+
+
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..8bbf479
--- /dev/null
+++ b/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,17 @@
+
+ #EAF2FF
+ #C4D0E1
+ #9EB0C5
+ #B2C1D3
+ #F3F7FF
+
+ #0F1722
+ #1B2433
+ #CC162231
+ #4B8FAED1
+ #223346
+ #5F8AAAC7
+ #1A2635
+ #7098B6D3
+ #2EDAE8FF
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..171d669
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,17 @@
+
+ #102135
+ #4F5F73
+ #64768A
+ #506174
+ #102135
+
+ #EAF6FF
+ #FFF4EC
+ #F2FFFFFF
+ #1F27425C
+ #F9FCFF
+ #33536A82
+ #FFFFFFFF
+ #264E657D
+ #70FFFFFF
+
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..026a1a5
--- /dev/null
+++ b/app/src/main/res/values/dimens.xml
@@ -0,0 +1,5 @@
+
+ 20dp
+ 28dp
+ 16dp
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ae72fff..40ce52a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,4 +1,18 @@
NavButtons
- Kill foreground app on "Back" button long-press.
+ Customize navigation button press and long-press actions.
+ Nav Button Settings
+ Choose what each navigation button should do on press and long press, then tune how long a long press should feel.
+ Back Button
+ Home Button
+ Recents Button
+ When pressed
+ When long-pressed
+ Long-press duration (ms)
+ Stock
+ Kill foreground app
+ None
+ -
+ +
+ Duration test (hold)
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..70801bb
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,6 @@
+
+
+