From 978ba3ed6fa19770ea73f0aab9d474b8ea1d5559 Mon Sep 17 00:00:00 2001 From: xbzk Date: Sun, 22 Feb 2026 06:04:41 +0100 Subject: [PATCH] [android,ui] added toggle to swap confirm/back buttons (#3601) Most android joypads has xbox layout, so while when in UI CONFIRM buttom (A) is the bottom one, in games it is the right one. And the opposite for BACK (B) button. And that kinda sucks. And some users complained, so i had this idea. Disabled by default. Toggle in the lonely App Settings menu. No impact at all. Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3601 Reviewed-by: DraVee Reviewed-by: CamilleLaVey Co-authored-by: xbzk Co-committed-by: xbzk --- .../java/org/yuzu/yuzu_emu/YuzuApplication.kt | 4 +- .../features/settings/model/BooleanSetting.kt | 1 + .../settings/model/view/SettingsItem.kt | 7 + .../settings/ui/SettingsFragmentPresenter.kt | 1 + .../utils/ControllerNavigationGlobalHook.kt | 168 ++++++++++++++++++ .../app/src/main/jni/android_settings.h | 2 + .../app/src/main/res/values/strings.xml | 2 + 7 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ControllerNavigationGlobalHook.kt diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt index 54fb45bd87..55282cad1c 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2023 yuzu Emulator Project @@ -24,6 +24,7 @@ import org.yuzu.yuzu_emu.utils.DocumentsTree import org.yuzu.yuzu_emu.utils.GpuDriverHelper import org.yuzu.yuzu_emu.utils.Log import org.yuzu.yuzu_emu.utils.PowerStateUpdater +import org.yuzu.yuzu_emu.utils.ControllerNavigationGlobalHook import java.util.Locale fun Context.getPublicFilesDir(): File = getExternalFilesDir(null) ?: filesDir @@ -72,6 +73,7 @@ class YuzuApplication : Application() { NativeLibrary.logDeviceInfo() PowerStateUpdater.start() Log.logDeviceInfo() + ControllerNavigationGlobalHook.install(this) createNotificationChannels() } diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt index eca1d00fbe..744c8acae9 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt @@ -37,6 +37,7 @@ enum class BooleanSetting(override val key: String) : AbstractBooleanSetting { PICTURE_IN_PICTURE("picture_in_picture"), USE_CUSTOM_RTC("custom_rtc_enabled"), BLACK_BACKGROUNDS("black_backgrounds"), + INVERT_CONFIRM_BACK_CONTROLLER_BUTTONS("invert_confirm_back_controller_buttons"), ENABLE_FOLDER_BUTTON("enable_folder_button"), ENABLE_QLAUNCH_BUTTON("enable_qlaunch_button"), diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt index c1ce40abdf..7f212e2cca 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt @@ -378,6 +378,13 @@ abstract class SettingsItem( warningMessage = R.string.warning_resolution ) ) + put( + SwitchSetting( + BooleanSetting.INVERT_CONFIRM_BACK_CONTROLLER_BUTTONS, + titleId = R.string.invert_confirm_back_controller_buttons, + descriptionId = R.string.invert_confirm_back_controller_buttons_description + ) + ) put( SwitchSetting( BooleanSetting.SHOW_INPUT_OVERLAY, diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt index 935d32d1b9..8f3c28c7a8 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -1076,6 +1076,7 @@ class SettingsFragmentPresenter( } add(BooleanSetting.ENABLE_QUICK_SETTINGS.key) + add(BooleanSetting.INVERT_CONFIRM_BACK_CONTROLLER_BUTTONS.key) add(HeaderSetting(R.string.theme_and_color)) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ControllerNavigationGlobalHook.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ControllerNavigationGlobalHook.kt new file mode 100644 index 0000000000..5be60bdbf9 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ControllerNavigationGlobalHook.kt @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import android.view.InputDevice +import android.view.KeyEvent +import android.view.View +import android.view.Window +import androidx.activity.ComponentActivity +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting +import java.util.concurrent.atomic.AtomicBoolean + +object ControllerNavigationGlobalHook { + private val installed = AtomicBoolean(false) + + fun install(application: Application) { + if (!installed.compareAndSet(false, true)) { + return + } + application.registerActivityLifecycleCallbacks(HookInstaller) + } + + private object HookInstaller : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + installHookIfNeeded(activity) + } + + override fun onActivityResumed(activity: Activity) { + installHookIfNeeded(activity) + } + + override fun onActivityStarted(activity: Activity) = Unit + override fun onActivityPaused(activity: Activity) = Unit + override fun onActivityStopped(activity: Activity) = Unit + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) = Unit + } + + private fun installHookIfNeeded(activity: Activity) { + val window = activity.window ?: return + val currentCallback = window.callback ?: return + if (currentCallback is ControllerNavigationWindowCallback) { + return + } + window.callback = ControllerNavigationWindowCallback(activity, currentCallback) + } + + private class ControllerNavigationWindowCallback( + private val activity: Activity, + private val delegate: Window.Callback + ) : Window.Callback by delegate { + private val componentActivity = activity as? ComponentActivity + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (activity.isFinishing || activity.isDestroyed) { + return delegate.dispatchKeyEvent(event) + } + + if (!BooleanSetting.INVERT_CONFIRM_BACK_CONTROLLER_BUTTONS.getBoolean()) { + return delegate.dispatchKeyEvent(event) + } + + if (!isControllerInput(event) || componentActivity == null) { + return delegate.dispatchKeyEvent(event) + } + if (shouldBypassInGameplay()) { + return delegate.dispatchKeyEvent(event) + } + + if (isConfirmAction(event.keyCode)) { + return when (event.action) { + KeyEvent.ACTION_DOWN -> { + if (event.repeatCount == 0) { + componentActivity.onBackPressedDispatcher.onBackPressed() + } + true + } + KeyEvent.ACTION_UP -> true + else -> false + } + } + + if (isBackAction(event.keyCode)) { + val remappedEvent = KeyEvent( + event.downTime, + event.eventTime, + event.action, + KeyEvent.KEYCODE_DPAD_CENTER, + event.repeatCount, + event.metaState, + event.deviceId, + event.scanCode, + event.flags, + event.source + ) + return delegate.dispatchKeyEvent(remappedEvent) + } + + return delegate.dispatchKeyEvent(event) + } + + private fun shouldBypassInGameplay(): Boolean { + if (activity.javaClass.name != "org.yuzu.yuzu_emu.activities.EmulationActivity") { + return false + } + if (!NativeLibrary.isRunning()) { + return false + } + + val surface = activity.findViewById(R.id.surface_emulation) ?: return false + if (!surface.isShown || surface.visibility != View.VISIBLE) { + return false + } + + val focused = activity.currentFocus + if (focused != null) { + val inputOverlay = activity.findViewById(R.id.surface_input_overlay) + if (!isDescendantOf(focused, inputOverlay)) { + return false + } + } + + return true + } + + private fun isDescendantOf(view: View, parent: View?): Boolean { + if (parent == null) { + return false + } + + var current: View? = view + while (current != null) { + if (current === parent) { + return true + } + current = current.parent as? View + } + + return false + } + + private fun isControllerInput(event: KeyEvent): Boolean { + val source = event.source + val deviceSources = event.device?.sources ?: InputDevice.getDevice(event.deviceId)?.sources ?: 0 + return hasControllerSource(source) || hasControllerSource(deviceSources) + } + + private fun isConfirmAction(keyCode: Int): Boolean { + return keyCode == KeyEvent.KEYCODE_BUTTON_A + } + + private fun isBackAction(keyCode: Int): Boolean { + return keyCode == KeyEvent.KEYCODE_BUTTON_B + } + + private fun hasControllerSource(source: Int): Boolean { + return source and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || + source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK || + source and InputDevice.SOURCE_DPAD == InputDevice.SOURCE_DPAD + } + } +} diff --git a/src/android/app/src/main/jni/android_settings.h b/src/android/app/src/main/jni/android_settings.h index ac64251e20..4090330d78 100644 --- a/src/android/app/src/main/jni/android_settings.h +++ b/src/android/app/src/main/jni/android_settings.h @@ -65,6 +65,8 @@ namespace AndroidSettings { Settings::Category::Android}; Settings::Setting enable_qlaunch_button{linkage, false, "enable_qlaunch_button", Settings::Category::Android}; + Settings::Setting invert_confirm_back_controller_buttons{ + linkage, false, "invert_confirm_back_controller_buttons", Settings::Category::Android}; // Input/performance overlay settings std::vector overlay_control_data; diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index b553402628..8a1193303e 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -36,6 +36,8 @@ Enable Overlay Auto Hide Hide Overlay on Controller Input Automatically hide the touch controls overlay when a physical controller is used. Overlay reappears when controller is disconnected. + Invert Confirm/Back Controller Buttons + Swap Android Confirm and Back button handling to match both Switch and Xbox styles while using the app UI. Input Overlay Configure on-screen controls