diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.kt index 64827d89d..5e604f65a 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.kt @@ -16,6 +16,7 @@ import org.citra.citra_emu.NativeLibrary import org.citra.citra_emu.R import org.citra.citra_emu.features.hotkeys.Hotkey import org.citra.citra_emu.features.settings.model.AbstractSetting +import org.citra.citra_emu.features.settings.model.AbstractStringSetting import org.citra.citra_emu.features.settings.model.Settings class InputBindingSetting( @@ -330,5 +331,22 @@ class InputBindingSetting( event.keyCode } } + + fun getInputObject(key: String, preferences: SharedPreferences): AbstractStringSetting { + return object : AbstractStringSetting { + override var string: String + get() = preferences.getString(key, "")!! + set(value) { + preferences.edit() + .putString(key, value) + .apply() + } + override val key = key + override val section = Settings.SECTION_CONTROLS + override val isRuntimeEditable = true + override val valueAsString = preferences.getString(key, "")!! + override val defaultValue = "" + } + } } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/ControllerAutomappingDialog.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/ControllerAutomappingDialog.kt new file mode 100644 index 000000000..570d5e59c --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/ControllerAutomappingDialog.kt @@ -0,0 +1,241 @@ +package org.citra.citra_emu.features.settings.ui + +import android.app.AlertDialog +import android.content.Context +import android.content.SharedPreferences +import android.graphics.drawable.Drawable +import android.view.InputDevice +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import androidx.core.content.ContextCompat +import org.citra.citra_emu.R +import org.citra.citra_emu.databinding.DialogControllerautomappingBinding +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting +import kotlin.math.abs + + +class ControllerAutomappingDialog( + private var context: Context, + buttons: ArrayList>, + titles: ArrayList>, + private var preferences: SharedPreferences +) { + + private var index = 0 + val inflater = LayoutInflater.from(context) + val automappingBinding = DialogControllerautomappingBinding.inflate(inflater) + var dialog: AlertDialog? = null + + var allButtons = arrayListOf() + var allTitles = arrayListOf() + + init { + buttons.forEach {group -> + group.forEach {button -> + allButtons.add(button) + } + } + titles.forEach {group -> + group.forEach {title -> + allTitles.add(title) + } + } + + } + + fun show() { + val builder: AlertDialog.Builder = AlertDialog.Builder(context) + builder + .setView(automappingBinding.root) + .setTitle("Automapper") + .setPositiveButton("Next") {_,_ -> } + .setNegativeButton("Close") { dialog, which -> + dialog.dismiss() + } + + dialog = builder.create() + dialog?.show() + + dialog?.setOnKeyListener { _, _, event -> onKeyEvent(event) } + automappingBinding.root.setOnGenericMotionListener { _, event -> onMotionEvent(event) } + + // Prepare the first element + prepareUIforIndex(index) + + val nextButton = dialog?.getButton(AlertDialog.BUTTON_POSITIVE) + nextButton?.setOnClickListener { + // Skip to next: + prepareUIforIndex(index++) + } + + } + + private fun prepareUIforIndex(i: Int) { + if (allButtons.size-1 < i) { + dialog?.dismiss() + return + } + + if(index>0) { + automappingBinding.lastMappingIcon.visibility = View.VISIBLE + automappingBinding.lastMappingDescription.visibility = View.VISIBLE + } + + val currentButton = allButtons[i] + val currentTitleInt = allTitles[i] + + val button = InputBindingSetting.getInputObject(currentButton, preferences) + + var lastTitle = setting?.value ?: "" + if(lastTitle.isBlank()) { + lastTitle = context.getString(R.string.unassigned) + } + automappingBinding.lastMappingDescription.text = lastTitle + automappingBinding.lastMappingIcon.setImageDrawable(automappingBinding.currentMappingIcon.drawable) + setting = InputBindingSetting(button, currentTitleInt) + + automappingBinding.currentMappingTitle.text = calculateTitle() + automappingBinding.currentMappingDescription.text = setting?.value + automappingBinding.currentMappingIcon.setImageDrawable(getIcon()) + + + if (allButtons.size-1 < index) { + dialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.text = + context.getString(R.string.finish) + dialog?.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE + } + + } + + private fun calculateTitle(): String { + + val inputTypeId = when { + setting!!.isCirclePad() -> R.string.controller_circlepad + setting!!.isCStick() -> R.string.controller_c + setting!!.isDPad() -> R.string.controller_dpad + setting!!.isTrigger() -> R.string.controller_trigger + else -> R.string.button + } + + val nameId = setting?.nameId?.let { context.getString(it) } + + return String.format( + context.getString(R.string.input_dialog_title), + context.getString(inputTypeId), + nameId + ) + } + + private fun getIcon(): Drawable? { + val id = when { + setting!!.isCirclePad() -> R.drawable.stick_main + setting!!.isCStick() -> R.drawable.stick_c + setting!!.isDPad() -> R.drawable.dpad + else -> { + val resourceTitle = context.resources.getResourceEntryName(setting!!.nameId) + if(resourceTitle.startsWith("direction")) { + R.drawable.dpad + } else { + context.resources.getIdentifier(resourceTitle, "drawable", context.packageName) + } + } + } + return ContextCompat.getDrawable(context, id) + } + + private val previousValues = ArrayList() + private var prevDeviceId = 0 + private var waitingForEvent = true + private var setting: InputBindingSetting? = null + + + private var debounceTimestamp = System.currentTimeMillis() + + + private fun onKeyEvent(event: KeyEvent): Boolean { + return when (event.action) { + KeyEvent.ACTION_UP -> { + if(System.currentTimeMillis()-debounceTimestamp < 500) { + return true + } + + debounceTimestamp = System.currentTimeMillis() + + index++ + setting?.onKeyInput(event) + prepareUIforIndex(index) + // Even if we ignore the key, we still consume it. Thus return true regardless. + true + } + + else -> false + } + } + + private fun onMotionEvent(event: MotionEvent): Boolean { + if (event.source and InputDevice.SOURCE_CLASS_JOYSTICK == 0) return false + if (event.action != MotionEvent.ACTION_MOVE) return false + + val input = event.device + + val motionRanges = input.motionRanges + + if (input.id != prevDeviceId) { + previousValues.clear() + } + prevDeviceId = input.id + val firstEvent = previousValues.isEmpty() + + var numMovedAxis = 0 + var axisMoveValue = 0.0f + var lastMovedRange: InputDevice.MotionRange? = null + var lastMovedDir = '?' + if (waitingForEvent) { + for (i in motionRanges.indices) { + val range = motionRanges[i] + val axis = range.axis + val origValue = event.getAxisValue(axis) + if (firstEvent) { + previousValues.add(origValue) + } else { + val previousValue = previousValues[i] + + // Only handle the axes that are not neutral (more than 0.5) + // but ignore any axis that has a constant value (e.g. always 1) + if (abs(origValue) > 0.5f && origValue != previousValue) { + // It is common to have multiple axes with the same physical input. For example, + // shoulder butters are provided as both AXIS_LTRIGGER and AXIS_BRAKE. + // To handle this, we ignore an axis motion that's the exact same as a motion + // we already saw. This way, we ignore axes with two names, but catch the case + // where a joystick is moved in two directions. + // ref: bottom of https://developer.android.com/training/game-controllers/controller-input.html + if (origValue != axisMoveValue) { + axisMoveValue = origValue + numMovedAxis++ + lastMovedRange = range + lastMovedDir = if (origValue < 0.0f) '-' else '+' + } + } else if (abs(origValue) < 0.25f && abs(previousValue) > 0.75f) { + // Special case for d-pads (axis value jumps between 0 and 1 without any values + // in between). Without this, the user would need to press the d-pad twice + // due to the first press being caught by the "if (firstEvent)" case further up. + numMovedAxis++ + lastMovedRange = range + lastMovedDir = if (previousValue < 0.0f) '-' else '+' + } + } + previousValues[i] = origValue + } + + // If only one axis moved, that's the winner. + if (numMovedAxis == 1) { + waitingForEvent = false + setting?.onMotionInput(input, lastMovedRange!!, lastMovedDir) + } + } + return true + } + +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.kt index bc55bd5d6..2ae742f3d 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.kt @@ -43,6 +43,7 @@ import org.citra.citra_emu.features.settings.model.AbstractStringSetting import org.citra.citra_emu.features.settings.model.FloatSetting import org.citra.citra_emu.features.settings.model.ScaledFloatSetting import org.citra.citra_emu.features.settings.model.AbstractShortSetting +import org.citra.citra_emu.features.settings.model.Settings import org.citra.citra_emu.features.settings.model.view.DateTimeSetting import org.citra.citra_emu.features.settings.model.view.InputBindingSetting import org.citra.citra_emu.features.settings.model.view.SettingsItem @@ -64,6 +65,7 @@ import org.citra.citra_emu.features.settings.ui.viewholder.SubmenuViewHolder import org.citra.citra_emu.features.settings.ui.viewholder.SwitchSettingViewHolder import org.citra.citra_emu.fragments.MessageDialogFragment import org.citra.citra_emu.fragments.MotionBottomSheetDialogFragment +import org.citra.citra_emu.utils.PermissionsHandler.preferences import org.citra.citra_emu.utils.SystemSaveGame import java.lang.NumberFormatException import java.text.SimpleDateFormat @@ -595,6 +597,32 @@ class SettingsAdapter( .show() } + fun onClickAutoconfigureControls() { + + val buttons = arrayListOf( + Settings.buttonKeys, + Settings.circlePadKeys, + Settings.cStickKeys, + Settings.dPadAxisKeys, + Settings.dPadButtonKeys, + Settings.triggerKeys + ) + + val titles = arrayListOf( + Settings.buttonTitles, + Settings.axisTitles, + Settings.axisTitles, + Settings.axisTitles, + Settings.dPadTitles, + Settings.triggerTitles + ) + + Settings.buttonTitles + ControllerAutomappingDialog(context, buttons, titles, preferences).show() + + + } + fun closeDialog() { if (dialog != null) { if (clickedPosition != -1) { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt index d4baf6166..bafa09d3d 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -761,44 +761,56 @@ class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView) private fun addControlsSettings(sl: ArrayList) { settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_controls)) sl.apply { + add(HeaderSetting(R.string.auto_configure)) + + add( + RunnableSetting( + R.string.auto_configure, + 0, + false, + 0, + { settingsAdapter.onClickAutoconfigureControls() } + ) + ) + add(HeaderSetting(R.string.generic_buttons)) Settings.buttonKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.buttonTitles[i])) } add(HeaderSetting(R.string.controller_circlepad)) Settings.circlePadKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.axisTitles[i])) } add(HeaderSetting(R.string.controller_c)) Settings.cStickKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.axisTitles[i])) } add(HeaderSetting(R.string.controller_dpad_axis,R.string.controller_dpad_axis_description)) Settings.dPadAxisKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.axisTitles[i])) } add(HeaderSetting(R.string.controller_dpad_button,R.string.controller_dpad_button_description)) Settings.dPadButtonKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.dPadTitles[i])) } add(HeaderSetting(R.string.controller_triggers)) Settings.triggerKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.triggerTitles[i])) } add(HeaderSetting(R.string.controller_hotkeys)) Settings.hotKeys.forEachIndexed { i: Int, key: String -> - val button = getInputObject(key) + val button = InputBindingSetting.getInputObject(key, preferences) add(InputBindingSetting(button, Settings.hotkeyTitles[i])) } add(HeaderSetting(R.string.miscellaneous)) @@ -814,23 +826,6 @@ class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView) } } - private fun getInputObject(key: String): AbstractStringSetting { - return object : AbstractStringSetting { - override var string: String - get() = preferences.getString(key, "")!! - set(value) { - preferences.edit() - .putString(key, value) - .apply() - } - override val key = key - override val section = Settings.SECTION_CONTROLS - override val isRuntimeEditable = true - override val valueAsString = preferences.getString(key, "")!! - override val defaultValue = "" - } - } - private fun addGraphicsSettings(sl: ArrayList) { settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_graphics)) sl.apply { diff --git a/src/android/app/src/main/res/layout/dialog_controllerautomapping.xml b/src/android/app/src/main/res/layout/dialog_controllerautomapping.xml new file mode 100644 index 000000000..9dd0160ed --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_controllerautomapping.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index f60fdd0e3..305e25bcb 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -175,6 +175,7 @@ HOME Menu + Auto Configuration Buttons Button @@ -385,6 +386,8 @@ Don\'t show again Visibility Information + Finish + Unassigned Select Game Folder