Android: Add automatic controller mapping option

This commit is contained in:
Connor McLaughlin 2021-03-21 14:32:12 +10:00
parent ad991c122d
commit 0155d6ed61
3 changed files with 361 additions and 4 deletions

View file

@ -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<InputDevice.MotionRange> 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<InputDevice.MotionRange> 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<InputDevice> 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;
}
}
}

View file

@ -163,6 +163,15 @@ public class ControllerSettingsActivity extends AppCompatActivity {
pref.updateValue(); 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 { public static class SettingsFragment extends PreferenceFragmentCompat {
ControllerSettingsActivity parent; ControllerSettingsActivity parent;
@ -216,9 +225,7 @@ public class ControllerSettingsActivity extends AppCompatActivity {
private void createPreferences() { private void createPreferences() {
final PreferenceScreen ps = getPreferenceScreen(); final PreferenceScreen ps = getPreferenceScreen();
final SharedPreferences sp = getPreferenceManager().getSharedPreferences(); final SharedPreferences sp = getPreferenceManager().getSharedPreferences();
final String defaultControllerType = (controllerIndex == 1) ? "DigitalController" : "None"; final String controllerType = getControllerType(sp, controllerIndex);
final String controllerTypeKey = String.format("Controller%d/Type", controllerIndex);
final String controllerType = sp.getString(controllerTypeKey, defaultControllerType);
final String[] controllerButtons = AndroidHostInterface.getControllerButtonNames(controllerType); final String[] controllerButtons = AndroidHostInterface.getControllerButtonNames(controllerType);
final String[] axisButtons = AndroidHostInterface.getControllerAxisNames(controllerType); final String[] axisButtons = AndroidHostInterface.getControllerAxisNames(controllerType);
final int vibrationMotors = AndroidHostInterface.getControllerVibrationMotorCount(controllerType); final int vibrationMotors = AndroidHostInterface.getControllerVibrationMotorCount(controllerType);
@ -226,7 +233,7 @@ public class ControllerSettingsActivity extends AppCompatActivity {
final ListPreference typePreference = new ListPreference(getContext()); final ListPreference typePreference = new ListPreference(getContext());
typePreference.setEntries(R.array.settings_controller_type_entries); typePreference.setEntries(R.array.settings_controller_type_entries);
typePreference.setEntryValues(R.array.settings_controller_type_values); typePreference.setEntryValues(R.array.settings_controller_type_values);
typePreference.setKey(controllerTypeKey); typePreference.setKey(getControllerTypeKey(controllerIndex));
typePreference.setValue(controllerType); typePreference.setValue(controllerType);
typePreference.setTitle(R.string.settings_controller_type); typePreference.setTitle(R.string.settings_controller_type);
typePreference.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance()); typePreference.setSummaryProvider(ListPreference.SimpleSummaryProvider.getInstance());
@ -238,6 +245,37 @@ public class ControllerSettingsActivity extends AppCompatActivity {
}); });
ps.addPreference(typePreference); 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 = new PreferenceCategory(getContext());
mButtonsCategory.setTitle(getContext().getString(R.string.controller_settings_category_button_bindings)); mButtonsCategory.setTitle(getContext().getString(R.string.controller_settings_category_button_bindings));
mButtonsCategory.setIconSpaceReserved(false); mButtonsCategory.setIconSpaceReserved(false);
@ -315,6 +353,26 @@ public class ControllerSettingsActivity extends AppCompatActivity {
} }
mSettingsCategory.removeAll(); 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 { public static class HotkeyFragment extends PreferenceFragmentCompat {

View file

@ -312,6 +312,15 @@
<string name="memory_card_editor_import_card_failed">Failed to import card \'%s\'. It may not be a supported format.</string> <string name="memory_card_editor_import_card_failed">Failed to import card \'%s\'. It may not be a supported format.</string>
<string name="memory_card_editor_import_card_success">Imported card \'%s\'.</string> <string name="memory_card_editor_import_card_success">Imported card \'%s\'.</string>
<string name="menu_game_list_entry_choose_cover_image">Choose Cover Image</string> <string name="menu_game_list_entry_choose_cover_image">Choose Cover Image</string>
<string name="controller_settings_automatic_mapping">Perform Automatic Mapping</string>
<string name="controller_settings_summary_automatic_mapping">Attempts to automatically bind all buttons/axes to a connected controller.</string>
<string name="controller_settings_clear_controller_bindings">Clear Bindings</string>
<string name="controller_settings_summary_clear_controller_bindings">Unbinds all buttons/axes for this controller.</string>
<string name="controller_settings_clear_controller_bindings_confirm">Are you sure you want to clear all bindings? This cannot be reversed.</string>
<string name="controller_settings_clear_controller_bindings_done">All bindings cleared for Controller %d.</string>
<string name="controller_auto_mapping_no_devices">No suitable devices found. Automatic binding only supports gamepad devices, but you can still bind other device types manually.</string>
<string name="controller_auto_mapping_select_device">Select Device</string>
<string name="controller_auto_mapping_results">Automatic Binding Results</string>
<string name="update_notes_title">Update Notes</string> <string name="update_notes_title">Update Notes</string>
<string name="update_notes_message_version_controller_update">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?</string> <string name="update_notes_message_version_controller_update">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?</string>
</resources> </resources>