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