Android: Hotkey Enable Button (#1464)

This commit is contained in:
David Griswold 2026-02-26 09:40:42 -08:00 committed by GitHub
parent 5ac0ef8fde
commit 6b2ac400eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 177 additions and 64 deletions

View file

@ -267,36 +267,28 @@ class EmulationActivity : AppCompatActivity() {
return super.dispatchKeyEvent(event) return super.dispatchKeyEvent(event)
} }
val button = when (event.action) {
preferences.getInt(InputBindingSetting.getInputButtonKey(event.keyCode), event.keyCode)
val action: Int = when (event.action) {
KeyEvent.ACTION_DOWN -> { KeyEvent.ACTION_DOWN -> {
hotkeyUtility.handleHotkey(button)
// On some devices, the back gesture / button press is not intercepted by androidx // On some devices, the back gesture / button press is not intercepted by androidx
// and fails to open the emulation menu. So we're stuck running deprecated code to // and fails to open the emulation menu. So we're stuck running deprecated code to
// cover for either a fault on androidx's side or in OEM skins (MIUI at least) // cover for either a fault on androidx's side or in OEM skins (MIUI at least)
if (event.keyCode == KeyEvent.KEYCODE_BACK) { if (event.keyCode == KeyEvent.KEYCODE_BACK) {
// If the hotkey is pressed, we don't want to open the drawer // If the hotkey is pressed, we don't want to open the drawer
if (!hotkeyUtility.HotkeyIsPressed) { if (!hotkeyUtility.hotkeyIsPressed) {
onBackPressed() onBackPressed()
return true
} }
} }
return hotkeyUtility.handleKeyPress(event)
// Normal key events.
NativeLibrary.ButtonState.PRESSED
} }
KeyEvent.ACTION_UP -> { KeyEvent.ACTION_UP -> {
hotkeyUtility.HotkeyIsPressed = false return hotkeyUtility.handleKeyRelease(event)
NativeLibrary.ButtonState.RELEASED }
else -> {
return false;
} }
else -> return false
} }
val input = event.device
?: // Controller was disconnected
return false
return NativeLibrary.onGamePadEvent(input.descriptor, button, action)
} }
private fun onAmiiboSelected(selectedFile: String) { private fun onAmiiboSelected(selectedFile: String) {

View file

@ -11,5 +11,6 @@ enum class Hotkey(val button: Int) {
PAUSE_OR_RESUME(10004), PAUSE_OR_RESUME(10004),
QUICKSAVE(10005), QUICKSAVE(10005),
QUICKLOAD(10006), QUICKLOAD(10006),
TURBO_LIMIT(10007); TURBO_LIMIT(10007),
ENABLE(10008);
} }

View file

@ -5,50 +5,140 @@
package org.citra.citra_emu.features.hotkeys package org.citra.citra_emu.features.hotkeys
import android.content.Context import android.content.Context
import android.view.KeyEvent
import android.widget.Toast import android.widget.Toast
import androidx.preference.PreferenceManager
import org.citra.citra_emu.CitraApplication
import org.citra.citra_emu.NativeLibrary import org.citra.citra_emu.NativeLibrary
import org.citra.citra_emu.R import org.citra.citra_emu.R
import org.citra.citra_emu.utils.EmulationLifecycleUtil import org.citra.citra_emu.utils.EmulationLifecycleUtil
import org.citra.citra_emu.utils.TurboHelper import org.citra.citra_emu.utils.TurboHelper
import org.citra.citra_emu.display.ScreenAdjustmentUtil import org.citra.citra_emu.display.ScreenAdjustmentUtil
import org.citra.citra_emu.features.settings.model.view.InputBindingSetting
import org.citra.citra_emu.features.settings.model.Settings
class HotkeyUtility( class HotkeyUtility(
private val screenAdjustmentUtil: ScreenAdjustmentUtil, private val screenAdjustmentUtil: ScreenAdjustmentUtil,
private val context: Context) { private val context: Context
) {
private val hotkeyButtons = Hotkey.entries.map { it.button } private val hotkeyButtons = Hotkey.entries.map { it.button }
var HotkeyIsPressed = false private var hotkeyIsEnabled = false
var hotkeyIsPressed = false
private val currentlyPressedButtons = mutableSetOf<Int>()
fun handleKeyPress(keyEvent: KeyEvent): Boolean {
var handled = false
val buttonSet = InputBindingSetting.getButtonSet(keyEvent)
val enableButton =
PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
.getString(Settings.HOTKEY_ENABLE, "")
val thisKeyIsEnableButton = buttonSet.contains(Hotkey.ENABLE.button)
val thisKeyIsHotkey =
!thisKeyIsEnableButton && Hotkey.entries.any { buttonSet.contains(it.button) }
hotkeyIsEnabled = hotkeyIsEnabled || enableButton == "" || thisKeyIsEnableButton
// Now process all internal buttons associated with this keypress
for (button in buttonSet) {
currentlyPressedButtons.add(button)
//option 1 - this is the enable command, which was already handled
if (button == Hotkey.ENABLE.button) {
handled = true
}
// option 2 - this is a different hotkey command
else if (hotkeyButtons.contains(button)) {
if (hotkeyIsEnabled) {
handled = handleHotkey(button) || handled
}
}
// option 3 - this is a normal key
else {
// if this key press is ALSO associated with a hotkey that will process, skip
// the normal key event.
if (!thisKeyIsHotkey || !hotkeyIsEnabled) {
handled = NativeLibrary.onGamePadEvent(
keyEvent.device.descriptor,
button,
NativeLibrary.ButtonState.PRESSED
) || handled
}
}
}
return handled
}
fun handleKeyRelease(keyEvent: KeyEvent): Boolean {
var handled = false
val buttonSet = InputBindingSetting.getButtonSet(keyEvent)
val thisKeyIsEnableButton = buttonSet.contains(Hotkey.ENABLE.button)
val thisKeyIsHotkey =
!thisKeyIsEnableButton && Hotkey.entries.any { buttonSet.contains(it.button) }
if (thisKeyIsEnableButton) {
handled = true; hotkeyIsEnabled = false
}
for (button in buttonSet) {
// this is a hotkey button
if (hotkeyButtons.contains(button)) {
currentlyPressedButtons.remove(button)
if (!currentlyPressedButtons.any { hotkeyButtons.contains(it) }) {
// all hotkeys are no longer pressed
hotkeyIsPressed = false
}
} else {
// if this key ALSO sends a hotkey command that we already/will handle,
// or if we did not register the press of this button, e.g. if this key
// was also a hotkey pressed after enable, but released after enable button release, then
// skip the normal key event
if ((!thisKeyIsHotkey || !hotkeyIsEnabled) && currentlyPressedButtons.contains(
button
)
) {
handled = NativeLibrary.onGamePadEvent(
keyEvent.device.descriptor,
button,
NativeLibrary.ButtonState.RELEASED
) || handled
currentlyPressedButtons.remove(button)
}
}
}
return handled
}
fun handleHotkey(bindedButton: Int): Boolean { fun handleHotkey(bindedButton: Int): Boolean {
if(hotkeyButtons.contains(bindedButton)) { when (bindedButton) {
when (bindedButton) { Hotkey.SWAP_SCREEN.button -> screenAdjustmentUtil.swapScreen()
Hotkey.SWAP_SCREEN.button -> screenAdjustmentUtil.swapScreen() Hotkey.CYCLE_LAYOUT.button -> screenAdjustmentUtil.cycleLayouts()
Hotkey.CYCLE_LAYOUT.button -> screenAdjustmentUtil.cycleLayouts() Hotkey.CLOSE_GAME.button -> EmulationLifecycleUtil.closeGame()
Hotkey.CLOSE_GAME.button -> EmulationLifecycleUtil.closeGame() Hotkey.PAUSE_OR_RESUME.button -> EmulationLifecycleUtil.pauseOrResume()
Hotkey.PAUSE_OR_RESUME.button -> EmulationLifecycleUtil.pauseOrResume() Hotkey.TURBO_LIMIT.button -> TurboHelper.toggleTurbo(true)
Hotkey.TURBO_LIMIT.button -> TurboHelper.toggleTurbo(true) Hotkey.QUICKSAVE.button -> {
Hotkey.QUICKSAVE.button -> { NativeLibrary.saveState(NativeLibrary.QUICKSAVE_SLOT)
NativeLibrary.saveState(NativeLibrary.QUICKSAVE_SLOT) Toast.makeText(
Toast.makeText(context, context,
context.getString(R.string.saving), context.getString(R.string.saving),
Toast.LENGTH_SHORT).show() Toast.LENGTH_SHORT
} ).show()
Hotkey.QUICKLOAD.button -> {
val wasLoaded = NativeLibrary.loadStateIfAvailable(NativeLibrary.QUICKSAVE_SLOT)
val stringRes = if(wasLoaded) {
R.string.loading
} else {
R.string.quickload_not_found
}
Toast.makeText(context,
context.getString(stringRes),
Toast.LENGTH_SHORT).show()
}
else -> {}
} }
HotkeyIsPressed = true
return true Hotkey.QUICKLOAD.button -> {
val wasLoaded = NativeLibrary.loadStateIfAvailable(NativeLibrary.QUICKSAVE_SLOT)
val stringRes = if (wasLoaded) {
R.string.loading
} else {
R.string.quickload_not_found
}
Toast.makeText(
context,
context.getString(stringRes),
Toast.LENGTH_SHORT
).show()
}
else -> {}
} }
return false hotkeyIsPressed = true
return true
} }
} }

View file

@ -135,6 +135,7 @@ class Settings {
const val KEY_CSTICK_AXIS_HORIZONTAL = "cstick_axis_horizontal" const val KEY_CSTICK_AXIS_HORIZONTAL = "cstick_axis_horizontal"
const val KEY_DPAD_AXIS_VERTICAL = "dpad_axis_vertical" const val KEY_DPAD_AXIS_VERTICAL = "dpad_axis_vertical"
const val KEY_DPAD_AXIS_HORIZONTAL = "dpad_axis_horizontal" const val KEY_DPAD_AXIS_HORIZONTAL = "dpad_axis_horizontal"
const val HOTKEY_ENABLE = "hotkey_enable"
const val HOTKEY_SCREEN_SWAP = "hotkey_screen_swap" const val HOTKEY_SCREEN_SWAP = "hotkey_screen_swap"
const val HOTKEY_CYCLE_LAYOUT = "hotkey_toggle_layout" const val HOTKEY_CYCLE_LAYOUT = "hotkey_toggle_layout"
const val HOTKEY_CLOSE_GAME = "hotkey_close_game" const val HOTKEY_CLOSE_GAME = "hotkey_close_game"
@ -202,6 +203,7 @@ class Settings {
R.string.button_zr R.string.button_zr
) )
val hotKeys = listOf( val hotKeys = listOf(
HOTKEY_ENABLE,
HOTKEY_SCREEN_SWAP, HOTKEY_SCREEN_SWAP,
HOTKEY_CYCLE_LAYOUT, HOTKEY_CYCLE_LAYOUT,
HOTKEY_CLOSE_GAME, HOTKEY_CLOSE_GAME,
@ -211,6 +213,7 @@ class Settings {
HOTKEY_TURBO_LIMIT HOTKEY_TURBO_LIMIT
) )
val hotkeyTitles = listOf( val hotkeyTitles = listOf(
R.string.controller_hotkey_enable_button,
R.string.emulation_swap_screens, R.string.emulation_swap_screens,
R.string.emulation_cycle_landscape_layouts, R.string.emulation_cycle_landscape_layouts,
R.string.emulation_close_game, R.string.emulation_close_game,

View file

@ -128,6 +128,7 @@ class InputBindingSetting(
Settings.KEY_BUTTON_DOWN -> NativeLibrary.ButtonType.DPAD_DOWN Settings.KEY_BUTTON_DOWN -> NativeLibrary.ButtonType.DPAD_DOWN
Settings.KEY_BUTTON_LEFT -> NativeLibrary.ButtonType.DPAD_LEFT Settings.KEY_BUTTON_LEFT -> NativeLibrary.ButtonType.DPAD_LEFT
Settings.KEY_BUTTON_RIGHT -> NativeLibrary.ButtonType.DPAD_RIGHT Settings.KEY_BUTTON_RIGHT -> NativeLibrary.ButtonType.DPAD_RIGHT
Settings.HOTKEY_ENABLE -> Hotkey.ENABLE.button
Settings.HOTKEY_SCREEN_SWAP -> Hotkey.SWAP_SCREEN.button Settings.HOTKEY_SCREEN_SWAP -> Hotkey.SWAP_SCREEN.button
Settings.HOTKEY_CYCLE_LAYOUT -> Hotkey.CYCLE_LAYOUT.button Settings.HOTKEY_CYCLE_LAYOUT -> Hotkey.CYCLE_LAYOUT.button
Settings.HOTKEY_CLOSE_GAME -> Hotkey.CLOSE_GAME.button Settings.HOTKEY_CLOSE_GAME -> Hotkey.CLOSE_GAME.button
@ -162,36 +163,40 @@ class InputBindingSetting(
fun removeOldMapping() { fun removeOldMapping() {
// Try remove all possible keys we wrote for this setting // Try remove all possible keys we wrote for this setting
val oldKey = preferences.getString(reverseKey, "") val oldKey = preferences.getString(reverseKey, "")
(setting as AbstractStringSetting).string = ""
if (oldKey != "") { if (oldKey != "") {
(setting as AbstractStringSetting).string = ""
preferences.edit() preferences.edit()
.remove(abstractSetting.key) // Used for ui text .remove(abstractSetting.key) // Used for ui text
.remove(oldKey) // Used for button mapping
.remove(oldKey + "_GuestOrientation") // Used for axis orientation .remove(oldKey + "_GuestOrientation") // Used for axis orientation
.remove(oldKey + "_GuestButton") // Used for axis button .remove(oldKey + "_GuestButton") // Used for axis button
.remove(oldKey + "_Inverted") // used for axis inversion .remove(oldKey + "_Inverted") // used for axis inversion
.apply() .remove(reverseKey)
val buttonCodes = try {
preferences.getStringSet(oldKey, mutableSetOf<String>())!!.toMutableSet()
} catch (e: ClassCastException) {
// if this is an int pref, either old button or an axis, so just remove it
preferences.edit().remove(oldKey).apply()
return;
}
buttonCodes.remove(buttonCode.toString());
preferences.edit().putStringSet(oldKey,buttonCodes).apply()
} }
} }
/** /**
* Helper function to write a gamepad button mapping for the setting. * Helper function to write a gamepad button mapping for the setting.
*/ */
private fun writeButtonMapping(key: String) { private fun writeButtonMapping(keyEvent: KeyEvent) {
val editor = preferences.edit() val editor = preferences.edit()
val key = getInputButtonKey(keyEvent)
// Remove mapping for another setting using this input // Pull in all codes associated with this key
val oldButtonCode = preferences.getInt(key, -1) // Migrate from the old int preference if need be
if (oldButtonCode != -1) { val buttonCodes = InputBindingSetting.getButtonSet(keyEvent)
val oldKey = getButtonKey(oldButtonCode) buttonCodes.add(buttonCode)
editor.remove(oldKey) // Only need to remove UI text setting, others will be overwritten
}
// Cleanup old mapping for this setting // Cleanup old mapping for this setting
removeOldMapping() removeOldMapping()
// Write new mapping editor.putStringSet(key, buttonCodes.mapTo(mutableSetOf()) {it.toString()})
editor.putInt(key, buttonCode)
// Write next reverse mapping for future cleanup // Write next reverse mapping for future cleanup
editor.putString(reverseKey, key) editor.putString(reverseKey, key)
@ -229,7 +234,7 @@ class InputBindingSetting(
} }
val code = translateEventToKeyId(keyEvent) val code = translateEventToKeyId(keyEvent)
writeButtonMapping(getInputButtonKey(code)) writeButtonMapping(keyEvent)
val uiString = "${keyEvent.device.name}: Button $code" val uiString = "${keyEvent.device.name}: Button $code"
value = uiString value = uiString
} }
@ -289,6 +294,26 @@ class InputBindingSetting(
NativeLibrary.ButtonType.DPAD_RIGHT -> Settings.KEY_BUTTON_RIGHT NativeLibrary.ButtonType.DPAD_RIGHT -> Settings.KEY_BUTTON_RIGHT
else -> "" else -> ""
} }
/**
* Get the mutable set of int button values this key should map to given an event
*/
fun getButtonSet(keyCode: KeyEvent):MutableSet<Int> {
val key = getInputButtonKey(keyCode)
val preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext)
var buttonCodes = try {
preferences.getStringSet(key, mutableSetOf<String>())
} catch (e: ClassCastException) {
val prefInt = preferences.getInt(key, -1);
val migratedSet = if (prefInt != -1) {
mutableSetOf(prefInt.toString())
} else {
mutableSetOf<String>()
}
migratedSet
}
if (buttonCodes == null) buttonCodes = mutableSetOf<String>()
return buttonCodes.mapNotNull { it.toIntOrNull() }.toMutableSet()
}
/** /**
* Helper function to get the settings key for an gamepad button. * Helper function to get the settings key for an gamepad button.

View file

@ -811,7 +811,7 @@ class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView)
add(InputBindingSetting(button, Settings.triggerTitles[i])) add(InputBindingSetting(button, Settings.triggerTitles[i]))
} }
add(HeaderSetting(R.string.controller_hotkeys)) add(HeaderSetting(R.string.controller_hotkeys,R.string.controller_hotkeys_description))
Settings.hotKeys.forEachIndexed { i: Int, key: String -> Settings.hotKeys.forEachIndexed { i: Int, key: String ->
val button = getInputObject(key) val button = getInputObject(key)
add(InputBindingSetting(button, Settings.hotkeyTitles[i])) add(InputBindingSetting(button, Settings.hotkeyTitles[i]))

View file

@ -122,6 +122,8 @@
<string name="controller_circlepad">Circle Pad</string> <string name="controller_circlepad">Circle Pad</string>
<string name="controller_c">C-Stick</string> <string name="controller_c">C-Stick</string>
<string name="controller_hotkeys">Hotkeys</string> <string name="controller_hotkeys">Hotkeys</string>
<string name="controller_hotkeys_description">If the "Hotkey Enable" key is mapped, that key must be pressed in addition to the mapped hotkey</string>
<string name="controller_hotkey_enable_button">Hotkey Enable</string>
<string name="controller_triggers">Triggers</string> <string name="controller_triggers">Triggers</string>
<string name="controller_trigger">Trigger</string> <string name="controller_trigger">Trigger</string>
<string name="controller_dpad">D-Pad</string> <string name="controller_dpad">D-Pad</string>