diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/ControllerAutoMapper.java b/android/app/src/main/java/com/github/stenzek/duckstation/ControllerAutoMapper.java new file mode 100644 index 000000000..88be8a557 --- /dev/null +++ b/android/app/src/main/java/com/github/stenzek/duckstation/ControllerAutoMapper.java @@ -0,0 +1,290 @@ +package com.github.stenzek.duckstation; + +import android.content.SharedPreferences; +import android.os.Vibrator; +import android.text.InputType; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.appcompat.app.AlertDialog; +import androidx.preference.PreferenceManager; + +import java.util.ArrayList; +import java.util.List; + +public class ControllerAutoMapper { + public interface CompleteCallback { + public void onComplete(); + } + + final private ControllerSettingsActivity parent; + final private int port; + private final CompleteCallback completeCallback; + + private InputDevice device; + private SharedPreferences prefs; + private SharedPreferences.Editor editor; + private StringBuilder log; + private String keyBase; + private String controllerType; + + public ControllerAutoMapper(ControllerSettingsActivity activity, int port, CompleteCallback completeCallback) { + this.parent = activity; + this.port = port; + this.completeCallback = completeCallback; + } + + private void log(String format, Object... args) { + log.append(String.format(format, args)); + log.append('\n'); + } + + private void setButtonBindingToKeyCode(String buttonName, int keyCode) { + log("Binding button '%s' to key '%s' (%d)", buttonName, KeyEvent.keyCodeToString(keyCode), keyCode); + + final String key = String.format("%sButton%s", keyBase, buttonName); + final String value = String.format("%s/Button%d", device.getDescriptor(), keyCode); + editor.putString(key, value); + } + + private void setButtonBindingToAxis(String buttonName, int axis, int direction) { + final char directionIndicator = (direction < 0) ? '-' : '+'; + log("Binding button '%s' to axis '%s' (%d) direction %c", buttonName, MotionEvent.axisToString(axis), axis, directionIndicator); + + final String key = String.format("%sButton%s", keyBase, buttonName); + final String value = String.format("%s/%cAxis%d", device.getDescriptor(), directionIndicator, axis); + editor.putString(key, value); + } + + private void setAxisBindingToAxis(String axisName, int axis) { + log("Binding axis '%s' to axis '%s' (%d)", axisName, MotionEvent.axisToString(axis), axis); + + final String key = String.format("%sAxis%s", keyBase, axisName); + final String value = String.format("%s/Axis%d", device.getDescriptor(), axis); + editor.putString(key, value); + } + + private void doAutoBindingButton(String buttonName, int[] keyCodes, int[][] axisCodes) { + // Prefer the axis codes, as it dispatches to that first. + if (axisCodes != null) { + final List motionRangeList = device.getMotionRanges(); + for (int[] axisAndDirection : axisCodes) { + final int axis = axisAndDirection[0]; + final int direction = axisAndDirection[1]; + for (InputDevice.MotionRange range : motionRangeList) { + if (range.getAxis() == axis) { + setButtonBindingToAxis(buttonName, axis, direction); + return; + } + } + } + } + + if (keyCodes != null) { + final boolean[] keysPresent = device.hasKeys(keyCodes); + for (int i = 0; i < keysPresent.length; i++) { + if (keysPresent[i]) { + setButtonBindingToKeyCode(buttonName, keyCodes[i]); + return; + } + } + } + + log("No automatic bindings found for button '%s'", buttonName); + } + + private void doAutoBindingAxis(String axisName, int[] axisCodes) { + // Prefer the axis codes, as it dispatches to that first. + if (axisCodes != null) { + final List motionRangeList = device.getMotionRanges(); + for (final int axis : axisCodes) { + for (InputDevice.MotionRange range : motionRangeList) { + if (range.getAxis() == axis) { + setAxisBindingToAxis(axisName, axis); + return; + } + } + } + } + + log.append(String.format("No automatic bindings found for axis '%s'\n", axisName)); + } + + public void start() { + final ArrayList deviceList = new ArrayList<>(); + for (final int deviceId : InputDevice.getDeviceIds()) { + final InputDevice inputDevice = InputDevice.getDevice(deviceId); + if (inputDevice == null || !EmulationSurfaceView.isBindableDevice(inputDevice) || + !EmulationSurfaceView.isGamepadDevice(inputDevice)) { + continue; + } + + deviceList.add(inputDevice); + } + + if (deviceList.isEmpty()) { + final AlertDialog.Builder builder = new AlertDialog.Builder(parent); + builder.setTitle(R.string.main_activity_error); + builder.setMessage(R.string.controller_auto_mapping_no_devices); + builder.setPositiveButton(R.string.main_activity_ok, (dialog, which) -> dialog.dismiss()); + builder.create().show(); + return; + } + + final String[] deviceNames = new String[deviceList.size()]; + for (int i = 0; i < deviceList.size(); i++) + deviceNames[i] = deviceList.get(i).getName(); + + final AlertDialog.Builder builder = new AlertDialog.Builder(parent); + builder.setTitle(R.string.controller_auto_mapping_select_device); + builder.setItems(deviceNames, (dialog, which) -> { + process(deviceList.get(which)); + }); + builder.create().show(); + } + + private void process(InputDevice device) { + this.prefs = PreferenceManager.getDefaultSharedPreferences(parent); + this.editor = prefs.edit(); + this.log = new StringBuilder(); + this.device = device; + + this.keyBase = String.format("Controller%d/", port); + this.controllerType = parent.getControllerType(prefs, port); + + setButtonBindings(); + setAxisBindings(); + setVibrationBinding(); + + this.editor.commit(); + this.editor = null; + + final AlertDialog.Builder builder = new AlertDialog.Builder(parent); + builder.setTitle(R.string.controller_auto_mapping_results); + + final EditText editText = new EditText(parent); + editText.setText(log.toString()); + editText.setInputType(InputType.TYPE_NULL | InputType.TYPE_TEXT_FLAG_MULTI_LINE); + editText.setSingleLine(false); + editText.setMinLines(10); + builder.setView(editText); + + builder.setPositiveButton(R.string.main_activity_ok, (dialog, which) -> dialog.dismiss()); + builder.create().show(); + + if (completeCallback != null) + completeCallback.onComplete(); + } + + private void setButtonBindings() { + final String[] buttonNames = AndroidHostInterface.getInstance().getControllerButtonNames(controllerType); + if (buttonNames == null || buttonNames.length == 0) { + log("No axes to bind."); + return; + } + + for (final String buttonName : buttonNames) { + switch (buttonName) { + case "Up": + doAutoBindingButton(buttonName, new int[]{KeyEvent.KEYCODE_DPAD_UP}, new int[][]{{MotionEvent.AXIS_HAT_Y, -1}}); + break; + case "Down": + doAutoBindingButton(buttonName, new int[]{KeyEvent.KEYCODE_DPAD_DOWN}, new int[][]{{MotionEvent.AXIS_HAT_Y, 1}}); + break; + case "Left": + doAutoBindingButton(buttonName, new int[]{KeyEvent.KEYCODE_DPAD_LEFT}, new int[][]{{MotionEvent.AXIS_HAT_X, -1}}); + break; + case "Right": + doAutoBindingButton(buttonName, new int[]{KeyEvent.KEYCODE_DPAD_RIGHT}, new int[][]{{MotionEvent.AXIS_HAT_X, 1}}); + break; + case "Select": + doAutoBindingButton(buttonName, new int[]{KeyEvent.KEYCODE_BUTTON_SELECT}, null); + break; + case "Start": + doAutoBindingButton(buttonName, new int[]{KeyEvent.KEYCODE_BUTTON_START}, null); + break; + case "Triangle": + doAutoBindingButton(buttonName, new int[]{KeyEvent.KEYCODE_BUTTON_Y}, null); + break; + case "Cross": + doAutoBindingButton(buttonName, new int[]{KeyEvent.KEYCODE_BUTTON_A}, null); + break; + case "Circle": + doAutoBindingButton(buttonName, new int[]{KeyEvent.KEYCODE_BUTTON_B}, null); + break; + case "Square": + doAutoBindingButton(buttonName, new int[]{KeyEvent.KEYCODE_BUTTON_X}, null); + break; + case "L1": + doAutoBindingButton(buttonName, new int[]{KeyEvent.KEYCODE_BUTTON_L1}, null); + break; + case "L2": + doAutoBindingButton(buttonName, new int[]{KeyEvent.KEYCODE_BUTTON_L2}, new int[][]{{MotionEvent.AXIS_LTRIGGER, 1}, {MotionEvent.AXIS_BRAKE, 1}}); + break; + case "R1": + doAutoBindingButton(buttonName, new int[]{KeyEvent.KEYCODE_BUTTON_R1}, null); + break; + case "R2": + doAutoBindingButton(buttonName, new int[]{KeyEvent.KEYCODE_BUTTON_R2}, new int[][]{{MotionEvent.AXIS_RTRIGGER, 1}, {MotionEvent.AXIS_GAS, 1}}); + break; + case "L3": + doAutoBindingButton(buttonName, new int[]{KeyEvent.KEYCODE_BUTTON_THUMBL}, null); + break; + case "R3": + doAutoBindingButton(buttonName, new int[]{KeyEvent.KEYCODE_BUTTON_THUMBR}, null); + break; + case "Analog": + doAutoBindingButton(buttonName, new int[]{KeyEvent.KEYCODE_BUTTON_MODE}, null); + break; + default: + log("Button '%s' not supported by auto mapping.", buttonName); + break; + } + } + } + + private void setAxisBindings() { + final String[] axisNames = AndroidHostInterface.getInstance().getControllerAxisNames(controllerType); + if (axisNames == null || axisNames.length == 0) { + log("No axes to bind."); + return; + } + + for (final String axisName : axisNames) { + switch (axisName) { + case "LeftX": + doAutoBindingAxis(axisName, new int[]{MotionEvent.AXIS_X}); + break; + case "LeftY": + doAutoBindingAxis(axisName, new int[]{MotionEvent.AXIS_Y}); + break; + case "RightX": + doAutoBindingAxis(axisName, new int[]{MotionEvent.AXIS_Z, MotionEvent.AXIS_RX}); + break; + case "RightY": + doAutoBindingAxis(axisName, new int[]{MotionEvent.AXIS_RZ, MotionEvent.AXIS_RY}); + break; + default: + log("Axis '%s' not supported by auto mapping.", axisName); + break; + } + } + } + + private void setVibrationBinding() { + final int motorCount = AndroidHostInterface.getInstance().getControllerVibrationMotorCount(controllerType); + if (motorCount == 0) { + log("No vibration motors to bind."); + return; + } + + final Vibrator vibrator = device.getVibrator(); + if (vibrator == null || !vibrator.hasVibrator()) { + log("Selected device has no vibrator, cannot bind vibration."); + return; + } + } +} diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/ControllerSettingsActivity.java b/android/app/src/main/java/com/github/stenzek/duckstation/ControllerSettingsActivity.java index aae2da7fb..771e9e5d7 100644 --- a/android/app/src/main/java/com/github/stenzek/duckstation/ControllerSettingsActivity.java +++ b/android/app/src/main/java/com/github/stenzek/duckstation/ControllerSettingsActivity.java @@ -163,6 +163,15 @@ public class ControllerSettingsActivity extends AppCompatActivity { pref.updateValue(); } + public static String getControllerTypeKey(int port) { + return String.format("Controller%d/Type", port); + } + + public static String getControllerType(SharedPreferences prefs, int port) { + final String defaultControllerType = (port == 1) ? "DigitalController" : "None"; + return prefs.getString(getControllerTypeKey(port), defaultControllerType); + } + public static class SettingsFragment extends PreferenceFragmentCompat { ControllerSettingsActivity parent; @@ -216,9 +225,7 @@ public class ControllerSettingsActivity extends AppCompatActivity { private void createPreferences() { final PreferenceScreen ps = getPreferenceScreen(); final SharedPreferences sp = getPreferenceManager().getSharedPreferences(); - final String defaultControllerType = (controllerIndex == 1) ? "DigitalController" : "None"; - final String controllerTypeKey = String.format("Controller%d/Type", controllerIndex); - final String controllerType = sp.getString(controllerTypeKey, defaultControllerType); + final String controllerType = getControllerType(sp, controllerIndex); final String[] controllerButtons = AndroidHostInterface.getControllerButtonNames(controllerType); final String[] axisButtons = AndroidHostInterface.getControllerAxisNames(controllerType); final int vibrationMotors = AndroidHostInterface.getControllerVibrationMotorCount(controllerType); @@ -226,7 +233,7 @@ public class ControllerSettingsActivity extends AppCompatActivity { final ListPreference typePreference = new ListPreference(getContext()); typePreference.setEntries(R.array.settings_controller_type_entries); typePreference.setEntryValues(R.array.settings_controller_type_values); - typePreference.setKey(controllerTypeKey); + typePreference.setKey(getControllerTypeKey(controllerIndex)); typePreference.setValue(controllerType); typePreference.setTitle(R.string.settings_controller_type); typePreference.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance()); @@ -238,6 +245,37 @@ public class ControllerSettingsActivity extends AppCompatActivity { }); ps.addPreference(typePreference); + final Preference autoBindPreference = new Preference(getContext()); + autoBindPreference.setTitle(R.string.controller_settings_automatic_mapping); + autoBindPreference.setSummary(R.string.controller_settings_summary_automatic_mapping); + autoBindPreference.setIconSpaceReserved(false); + autoBindPreference.setOnPreferenceClickListener(preference -> { + final ControllerAutoMapper mapper = new ControllerAutoMapper(activity, controllerIndex, () -> { + removePreferences(); + createPreferences(typePreference.getValue()); + }); + mapper.start(); + return true; + }); + ps.addPreference(autoBindPreference); + + final Preference clearBindingsPreference = new Preference(getContext()); + clearBindingsPreference.setTitle(R.string.controller_settings_clear_controller_bindings); + clearBindingsPreference.setSummary(R.string.controller_settings_summary_clear_controller_bindings); + clearBindingsPreference.setIconSpaceReserved(false); + clearBindingsPreference.setOnPreferenceClickListener(preference -> { + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setMessage(R.string.controller_settings_clear_controller_bindings_confirm); + builder.setPositiveButton(R.string.main_activity_yes, (dialog, which) -> { + dialog.dismiss(); + clearBindings(); + }); + builder.setNegativeButton(R.string.main_activity_no, (dialog, which) -> dialog.dismiss()); + builder.create().show(); + return true; + }); + ps.addPreference(clearBindingsPreference); + mButtonsCategory = new PreferenceCategory(getContext()); mButtonsCategory.setTitle(getContext().getString(R.string.controller_settings_category_button_bindings)); mButtonsCategory.setIconSpaceReserved(false); @@ -315,6 +353,26 @@ public class ControllerSettingsActivity extends AppCompatActivity { } mSettingsCategory.removeAll(); } + + private static void clearBindingsInCategory(SharedPreferences.Editor editor, PreferenceCategory category) { + for (int i = 0; i < category.getPreferenceCount(); i++) { + final Preference preference = category.getPreference(i); + if (preference instanceof ControllerBindingPreference) + ((ControllerBindingPreference)preference).clearBinding(editor); + } + } + + private void clearBindings() { + final SharedPreferences.Editor editor = getPreferenceManager().getSharedPreferences().edit(); + clearBindingsInCategory(editor, mButtonsCategory); + clearBindingsInCategory(editor, mAxisCategory); + clearBindingsInCategory(editor, mSettingsCategory); + editor.commit(); + + Toast.makeText(activity, activity.getString( + R.string.controller_settings_clear_controller_bindings_done, controllerIndex), + Toast.LENGTH_LONG).show(); + } } public static class HotkeyFragment extends PreferenceFragmentCompat { diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ccb54697e..76df7e7cd 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -312,6 +312,15 @@ Failed to import card \'%s\'. It may not be a supported format. Imported card \'%s\'. Choose Cover Image + Perform Automatic Mapping + Attempts to automatically bind all buttons/axes to a connected controller. + Clear Bindings + Unbinds all buttons/axes for this controller. + Are you sure you want to clear all bindings? This cannot be reversed. + All bindings cleared for Controller %d. + No suitable devices found. Automatic binding only supports gamepad devices, but you can still bind other device types manually. + Select Device + Automatic Binding Results Update Notes This DuckStation update includes support for multiple controllers, and binding devices such as keyboards/volume buttons.\n\nYou must re-bind your controllers, otherwise they will no longer function. Do you want to do this now?