package com.limelight.binding.input; import android.annotation.TargetApi; import android.app.Activity; import android.content.Context; import android.hardware.BatteryState; import android.hardware.Sensor; import android.hardware.SensorEvent; import android.hardware.SensorEventListener; import android.hardware.SensorManager; import android.hardware.input.InputManager; import android.hardware.lights.Light; import android.hardware.lights.LightState; import android.hardware.lights.LightsManager; import android.hardware.lights.LightsRequest; import android.hardware.usb.UsbDevice; import android.hardware.usb.UsbManager; import android.media.AudioAttributes; import android.os.Build; import android.os.CombinedVibration; import android.os.Handler; import android.os.Looper; import android.os.VibrationAttributes; import android.os.VibrationEffect; import android.os.Vibrator; import android.os.VibratorManager; import android.util.SparseArray; import android.view.InputDevice; import android.view.InputEvent; import android.view.KeyEvent; import android.view.MotionEvent; import android.widget.Toast; import com.limelight.LimeLog; import com.limelight.binding.input.driver.AbstractController; import com.limelight.binding.input.driver.UsbDriverListener; import com.limelight.binding.input.driver.UsbDriverService; import com.limelight.nvstream.NvConnection; import com.limelight.nvstream.input.ControllerPacket; import com.limelight.nvstream.input.MouseButtonPacket; import com.limelight.nvstream.jni.MoonBridge; import com.limelight.preferences.PreferenceConfiguration; import com.limelight.ui.GameGestures; import com.limelight.utils.Vector2d; import org.cgutman.shieldcontrollerextensions.SceChargingState; import org.cgutman.shieldcontrollerextensions.SceConnectionType; import org.cgutman.shieldcontrollerextensions.SceManager; import java.lang.reflect.InvocationTargetException; import java.util.Map; public class ControllerHandler implements InputManager.InputDeviceListener, UsbDriverListener { private static final int MAXIMUM_BUMPER_UP_DELAY_MS = 100; private static final int START_DOWN_TIME_MOUSE_MODE_MS = 750; private static final int MINIMUM_BUTTON_DOWN_TIME_MS = 25; private static final int EMULATING_SPECIAL = 0x1; private static final int EMULATING_SELECT = 0x2; private static final short MAX_GAMEPADS = 16; // Limited by bits in activeGamepadMask private static final int BATTERY_RECHECK_INTERVAL_MS = 120 * 1000; private static final Map ANDROID_TO_LI_BUTTON_MAP = Map.ofEntries( Map.entry(KeyEvent.KEYCODE_BUTTON_A, ControllerPacket.A_FLAG), Map.entry(KeyEvent.KEYCODE_BUTTON_B, ControllerPacket.B_FLAG), Map.entry(KeyEvent.KEYCODE_BUTTON_X, ControllerPacket.X_FLAG), Map.entry(KeyEvent.KEYCODE_BUTTON_Y, ControllerPacket.Y_FLAG), Map.entry(KeyEvent.KEYCODE_DPAD_UP, ControllerPacket.UP_FLAG), Map.entry(KeyEvent.KEYCODE_DPAD_DOWN, ControllerPacket.DOWN_FLAG), Map.entry(KeyEvent.KEYCODE_DPAD_LEFT, ControllerPacket.LEFT_FLAG), Map.entry(KeyEvent.KEYCODE_DPAD_RIGHT, ControllerPacket.RIGHT_FLAG), Map.entry(KeyEvent.KEYCODE_DPAD_UP_LEFT, ControllerPacket.UP_FLAG | ControllerPacket.LEFT_FLAG), Map.entry(KeyEvent.KEYCODE_DPAD_UP_RIGHT, ControllerPacket.UP_FLAG | ControllerPacket.RIGHT_FLAG), Map.entry(KeyEvent.KEYCODE_DPAD_DOWN_LEFT, ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG), Map.entry(KeyEvent.KEYCODE_DPAD_DOWN_RIGHT, ControllerPacket.DOWN_FLAG | ControllerPacket.RIGHT_FLAG), Map.entry(KeyEvent.KEYCODE_BUTTON_L1, ControllerPacket.LB_FLAG), Map.entry(KeyEvent.KEYCODE_BUTTON_R1, ControllerPacket.RB_FLAG), Map.entry(KeyEvent.KEYCODE_BUTTON_THUMBL, ControllerPacket.LS_CLK_FLAG), Map.entry(KeyEvent.KEYCODE_BUTTON_THUMBR, ControllerPacket.RS_CLK_FLAG), Map.entry(KeyEvent.KEYCODE_BUTTON_START, ControllerPacket.PLAY_FLAG), Map.entry(KeyEvent.KEYCODE_MENU, ControllerPacket.PLAY_FLAG), Map.entry(KeyEvent.KEYCODE_BUTTON_SELECT, ControllerPacket.BACK_FLAG), Map.entry(KeyEvent.KEYCODE_BACK, ControllerPacket.BACK_FLAG), Map.entry(KeyEvent.KEYCODE_BUTTON_MODE, ControllerPacket.SPECIAL_BUTTON_FLAG), // This is the Xbox Series X Share button Map.entry(KeyEvent.KEYCODE_MEDIA_RECORD, ControllerPacket.MISC_FLAG), // This is a weird one, but it's what Android does prior to 4.10 kernels // where DualShock/DualSense touchpads weren't mapped as separate devices. // https://android.googlesource.com/platform/frameworks/base/+/master/data/keyboards/Vendor_054c_Product_0ce6_fallback.kl // https://android.googlesource.com/platform/frameworks/base/+/master/data/keyboards/Vendor_054c_Product_09cc.kl Map.entry(KeyEvent.KEYCODE_BUTTON_1, ControllerPacket.TOUCHPAD_FLAG) // FIXME: Paddles? ); private final Vector2d inputVector = new Vector2d(); private final SparseArray inputDeviceContexts = new SparseArray<>(); private final SparseArray usbDeviceContexts = new SparseArray<>(); private final NvConnection conn; private final Activity activityContext; private final double stickDeadzone; private final InputDeviceContext defaultContext = new InputDeviceContext(); private final GameGestures gestures; private final Vibrator deviceVibrator; private final SceManager sceManager; private final Handler handler; private boolean hasGameController; private final PreferenceConfiguration prefConfig; private short currentControllers, initialControllers; public ControllerHandler(Activity activityContext, NvConnection conn, GameGestures gestures, PreferenceConfiguration prefConfig) { this.activityContext = activityContext; this.conn = conn; this.gestures = gestures; this.prefConfig = prefConfig; this.deviceVibrator = (Vibrator) activityContext.getSystemService(Context.VIBRATOR_SERVICE); this.handler = new Handler(Looper.getMainLooper()); this.sceManager = new SceManager(activityContext); this.sceManager.start(); int deadzonePercentage = prefConfig.deadzonePercentage; int[] ids = InputDevice.getDeviceIds(); for (int id : ids) { InputDevice dev = InputDevice.getDevice(id); if (dev == null) { // This device was removed during enumeration continue; } if ((dev.getSources() & InputDevice.SOURCE_JOYSTICK) != 0 || (dev.getSources() & InputDevice.SOURCE_GAMEPAD) != 0) { // This looks like a gamepad, but we'll check X and Y to be sure if (getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_X) != null && getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_Y) != null) { // This is a gamepad hasGameController = true; } } } // 1% is the lowest possible deadzone we support if (deadzonePercentage <= 0) { deadzonePercentage = 1; } this.stickDeadzone = (double)deadzonePercentage / 100.0; // Initialize the default context for events with no device defaultContext.leftStickXAxis = MotionEvent.AXIS_X; defaultContext.leftStickYAxis = MotionEvent.AXIS_Y; defaultContext.leftStickDeadzoneRadius = (float) stickDeadzone; defaultContext.rightStickXAxis = MotionEvent.AXIS_Z; defaultContext.rightStickYAxis = MotionEvent.AXIS_RZ; defaultContext.rightStickDeadzoneRadius = (float) stickDeadzone; defaultContext.leftTriggerAxis = MotionEvent.AXIS_BRAKE; defaultContext.rightTriggerAxis = MotionEvent.AXIS_GAS; defaultContext.hatXAxis = MotionEvent.AXIS_HAT_X; defaultContext.hatYAxis = MotionEvent.AXIS_HAT_Y; defaultContext.controllerNumber = (short) 0; defaultContext.assignedControllerNumber = true; defaultContext.external = false; // Some devices (GPD XD) have a back button which sends input events // with device ID == 0. This hits the default context which would normally // consume these. Instead, let's ignore them since that's probably the // most likely case. defaultContext.ignoreBack = true; // Get the initially attached set of gamepads. As each gamepad receives // its initial InputEvent, we will move these from this set onto the // currentControllers set which will allow them to properly unplug // if they are removed. initialControllers = getAttachedControllerMask(activityContext); } private static InputDevice.MotionRange getMotionRangeForJoystickAxis(InputDevice dev, int axis) { InputDevice.MotionRange range; // First get the axis for SOURCE_JOYSTICK range = dev.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK); if (range == null) { // Now try the axis for SOURCE_GAMEPAD range = dev.getMotionRange(axis, InputDevice.SOURCE_GAMEPAD); } return range; } @Override public void onInputDeviceAdded(int deviceId) { // Nothing happening here yet } @Override public void onInputDeviceRemoved(int deviceId) { InputDeviceContext context = inputDeviceContexts.get(deviceId); if (context != null) { LimeLog.info("Removed controller: "+context.name+" ("+deviceId+")"); releaseControllerNumber(context); context.destroy(); inputDeviceContexts.remove(deviceId); } } // This can happen when gaining/losing input focus with some devices. // Input devices that have a trackpad may gain/lose AXIS_RELATIVE_X/Y. @Override public void onInputDeviceChanged(int deviceId) { InputDevice device = InputDevice.getDevice(deviceId); if (device == null) { return; } // If we don't have a context for this device, we don't need to update anything InputDeviceContext existingContext = inputDeviceContexts.get(deviceId); if (existingContext == null) { return; } LimeLog.info("Device changed: "+existingContext.name+" ("+deviceId+")"); // Don't release the controller number, because we will carry it over if it is present. // We also want to make sure the change is invisible to the host PC to avoid an add/remove // cycle for the gamepad which may break some games. existingContext.destroy(); InputDeviceContext newContext = createInputDeviceContextForDevice(device); // Copy over existing controller number state newContext.assignedControllerNumber = existingContext.assignedControllerNumber; newContext.reservedControllerNumber = existingContext.reservedControllerNumber; newContext.controllerNumber = existingContext.controllerNumber; inputDeviceContexts.put(deviceId, newContext); } public void stop() { for (int i = 0; i < inputDeviceContexts.size(); i++) { InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); deviceContext.destroy(); } for (int i = 0; i < usbDeviceContexts.size(); i++) { UsbDeviceContext deviceContext = usbDeviceContexts.valueAt(i); deviceContext.destroy(); } sceManager.stop(); deviceVibrator.cancel(); } private static boolean hasJoystickAxes(InputDevice device) { return (device.getSources() & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK && getMotionRangeForJoystickAxis(device, MotionEvent.AXIS_X) != null && getMotionRangeForJoystickAxis(device, MotionEvent.AXIS_Y) != null; } private static boolean hasGamepadButtons(InputDevice device) { return (device.getSources() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD; } public static boolean isGameControllerDevice(InputDevice device) { if (device == null) { return true; } if (hasJoystickAxes(device) || hasGamepadButtons(device)) { // Has real joystick axes or gamepad buttons return true; } // HACK for https://issuetracker.google.com/issues/163120692 if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) { if (device.getId() == -1) { // This "virtual" device could be input from any of the attached devices. // Look to see if any gamepads are connected. int[] ids = InputDevice.getDeviceIds(); for (int id : ids) { InputDevice dev = InputDevice.getDevice(id); if (dev == null) { // This device was removed during enumeration continue; } // If there are any gamepad devices connected, we'll // report that this virtual device is a gamepad. if (hasJoystickAxes(dev) || hasGamepadButtons(dev)) { return true; } } } } // Otherwise, we'll try anything that claims to be a non-alphabetic keyboard return device.getKeyboardType() != InputDevice.KEYBOARD_TYPE_ALPHABETIC; } public static short getAttachedControllerMask(Context context) { int count = 0; short mask = 0; // Count all input devices that are gamepads InputManager im = (InputManager) context.getSystemService(Context.INPUT_SERVICE); for (int id : im.getInputDeviceIds()) { InputDevice dev = im.getInputDevice(id); if (dev == null) { continue; } if (hasJoystickAxes(dev)) { LimeLog.info("Counting InputDevice: "+dev.getName()); mask |= 1 << count++; } } // Count all USB devices that match our drivers if (PreferenceConfiguration.readPreferences(context).usbDriver) { UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); for (UsbDevice dev : usbManager.getDeviceList().values()) { // We explicitly check not to claim devices that appear as InputDevices // otherwise we will double count them. if (UsbDriverService.shouldClaimDevice(dev, false) && !UsbDriverService.isRecognizedInputDevice(dev)) { LimeLog.info("Counting UsbDevice: "+dev.getDeviceName()); mask |= 1 << count++; } } } if (PreferenceConfiguration.readPreferences(context).onscreenController) { LimeLog.info("Counting OSC gamepad"); mask |= 1; } LimeLog.info("Enumerated "+count+" gamepads"); return mask; } private void releaseControllerNumber(GenericControllerContext context) { // If we reserved a controller number, remove that reservation if (context.reservedControllerNumber) { LimeLog.info("Controller number "+context.controllerNumber+" is now available"); currentControllers &= ~(1 << context.controllerNumber); } // If this device sent data as a gamepad, zero the values before removing. // We must do this after clearing the currentControllers entry so this // causes the device to be removed on the server PC. if (context.assignedControllerNumber) { conn.sendControllerInput(context.controllerNumber, getActiveControllerMask(), (short) 0, (byte) 0, (byte) 0, (short) 0, (short) 0, (short) 0, (short) 0); } } // Called before sending input but after we've determined that this // is definitely a controller (not a keyboard, mouse, or something else) private void assignControllerNumberIfNeeded(GenericControllerContext context) { if (context.assignedControllerNumber) { return; } if (context instanceof InputDeviceContext) { InputDeviceContext devContext = (InputDeviceContext) context; LimeLog.info(devContext.name+" ("+context.id+") needs a controller number assigned"); if (!devContext.external) { LimeLog.info("Built-in buttons hardcoded as controller 0"); context.controllerNumber = 0; } else if (prefConfig.multiController && devContext.hasJoystickAxes) { context.controllerNumber = 0; LimeLog.info("Reserving the next available controller number"); for (short i = 0; i < MAX_GAMEPADS; i++) { if ((currentControllers & (1 << i)) == 0) { // Found an unused controller value currentControllers |= (1 << i); // Take this value out of the initial gamepad set initialControllers &= ~(1 << i); context.controllerNumber = i; context.reservedControllerNumber = true; break; } } } else { LimeLog.info("Not reserving a controller number"); context.controllerNumber = 0; } } else { if (prefConfig.multiController) { context.controllerNumber = 0; LimeLog.info("Reserving the next available controller number"); for (short i = 0; i < MAX_GAMEPADS; i++) { if ((currentControllers & (1 << i)) == 0) { // Found an unused controller value currentControllers |= (1 << i); // Take this value out of the initial gamepad set initialControllers &= ~(1 << i); context.controllerNumber = i; context.reservedControllerNumber = true; break; } } } else { LimeLog.info("Not reserving a controller number"); context.controllerNumber = 0; } } LimeLog.info("Assigned as controller "+context.controllerNumber); context.assignedControllerNumber = true; // Report attributes of this new controller to the host context.sendControllerArrival(); } private UsbDeviceContext createUsbDeviceContextForDevice(AbstractController device) { UsbDeviceContext context = new UsbDeviceContext(); context.id = device.getControllerId(); context.device = device; context.external = true; context.vendorId = device.getVendorId(); context.productId = device.getProductId(); context.leftStickDeadzoneRadius = (float) stickDeadzone; context.rightStickDeadzoneRadius = (float) stickDeadzone; context.triggerDeadzone = 0.13f; return context; } private static boolean isExternal(InputDevice dev) { // The ASUS Tinker Board inaccurately reports Bluetooth gamepads as internal, // causing shouldIgnoreBack() to believe it should pass through back as a // navigation event for any attached gamepads. if (Build.MODEL.equals("Tinker Board")) { return true; } String deviceName = dev.getName(); if (deviceName.contains("gpio") || // This is the back button on Shield portable consoles deviceName.contains("joy_key") || // These are the gamepad buttons on the Archos Gamepad 2 deviceName.contains("keypad") || // These are gamepad buttons on the XPERIA Play deviceName.equalsIgnoreCase("NVIDIA Corporation NVIDIA Controller v01.01") || // Gamepad on Shield Portable deviceName.equalsIgnoreCase("NVIDIA Corporation NVIDIA Controller v01.02")) // Gamepad on Shield Portable (?) { LimeLog.info(dev.getName()+" is internal by hardcoded mapping"); return false; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Landroid/view/InputDevice;->isExternal()Z is officially public on Android Q return dev.isExternal(); } else { try { // Landroid/view/InputDevice;->isExternal()Z is on the light graylist in Android P return (Boolean)dev.getClass().getMethod("isExternal").invoke(dev); } catch (NoSuchMethodException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } catch (InvocationTargetException e) { e.printStackTrace(); } catch (ClassCastException e) { e.printStackTrace(); } } // Answer true if we don't know return true; } private boolean shouldIgnoreBack(InputDevice dev) { String devName = dev.getName(); // The Serval has a Select button but the framework doesn't // know about that because it uses a non-standard scancode. if (devName.contains("Razer Serval")) { return true; } // Classify this device as a remote by name if it has no joystick axes if (!hasJoystickAxes(dev) && devName.toLowerCase().contains("remote")) { return true; } // Otherwise, dynamically try to determine whether we should allow this // back button to function for navigation. // // First, check if this is an internal device we're being called on. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && !isExternal(dev)) { InputManager im = (InputManager) activityContext.getSystemService(Context.INPUT_SERVICE); boolean foundInternalGamepad = false; boolean foundInternalSelect = false; for (int id : im.getInputDeviceIds()) { InputDevice currentDev = im.getInputDevice(id); // Ignore external devices if (currentDev == null || isExternal(currentDev)) { continue; } // Note that we are explicitly NOT excluding the current device we're examining here, // since the other gamepad buttons may be on our current device and that's fine. if (currentDev.hasKeys(KeyEvent.KEYCODE_BUTTON_SELECT)[0]) { foundInternalSelect = true; } // We don't check KEYCODE_BUTTON_A here, since the Shield Android TV has a // virtual mouse device that claims to have KEYCODE_BUTTON_A. Instead, we rely // on the SOURCE_GAMEPAD flag to be set on gamepad devices. if (hasGamepadButtons(currentDev)) { foundInternalGamepad = true; } } // Allow the back button to function for navigation if we either: // a) have no internal gamepad (most phones) // b) have an internal gamepad but also have an internal select button (GPD XD) // but not: // c) have an internal gamepad but no internal select button (NVIDIA SHIELD Portable) return !foundInternalGamepad || foundInternalSelect; } else { // For external devices, we want to pass through the back button if the device // has no gamepad axes or gamepad buttons. return !hasJoystickAxes(dev) && !hasGamepadButtons(dev); } } private InputDeviceContext createInputDeviceContextForDevice(InputDevice dev) { InputDeviceContext context = new InputDeviceContext(); String devName = dev.getName(); LimeLog.info("Creating controller context for device: "+devName); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { LimeLog.info("Vendor ID: "+dev.getVendorId()); LimeLog.info("Product ID: "+dev.getProductId()); } LimeLog.info(dev.toString()); context.inputDevice = dev; context.name = devName; context.id = dev.getId(); context.external = isExternal(dev); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { context.vendorId = dev.getVendorId(); context.productId = dev.getProductId(); // These aren't always present in the Android key layout files, so they won't show up // in our normal InputDevice.hasKeys() probing. context.hasPaddles = MoonBridge.guessControllerHasPaddles(context.vendorId, context.productId); context.hasShare = MoonBridge.guessControllerHasShareButton(context.vendorId, context.productId); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasQuadAmplitudeControlledRumbleVibrators(dev.getVibratorManager())) { context.vibratorManager = dev.getVibratorManager(); context.quadVibrators = true; } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hasDualAmplitudeControlledRumbleVibrators(dev.getVibratorManager())) { context.vibratorManager = dev.getVibratorManager(); context.quadVibrators = false; } else if (dev.getVibrator().hasVibrator()) { context.vibrator = dev.getVibrator(); } // Detect if the gamepad has Mode and Select buttons according to the Android key layouts. // We do this first because other codepaths below may override these defaults. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { boolean[] buttons = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_MODE, KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.KEYCODE_BACK, 0); context.hasMode = buttons[0]; context.hasSelect = buttons[1] || buttons[2]; } context.touchpadXRange = dev.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_TOUCHPAD); context.touchpadYRange = dev.getMotionRange(MotionEvent.AXIS_Y, InputDevice.SOURCE_TOUCHPAD); context.touchpadPressureRange = dev.getMotionRange(MotionEvent.AXIS_PRESSURE, InputDevice.SOURCE_TOUCHPAD); context.leftStickXAxis = MotionEvent.AXIS_X; context.leftStickYAxis = MotionEvent.AXIS_Y; if (getMotionRangeForJoystickAxis(dev, context.leftStickXAxis) != null && getMotionRangeForJoystickAxis(dev, context.leftStickYAxis) != null) { // This is a gamepad hasGameController = true; context.hasJoystickAxes = true; } InputDevice.MotionRange leftTriggerRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_LTRIGGER); InputDevice.MotionRange rightTriggerRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RTRIGGER); InputDevice.MotionRange brakeRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_BRAKE); InputDevice.MotionRange gasRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_GAS); InputDevice.MotionRange throttleRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_THROTTLE); if (leftTriggerRange != null && rightTriggerRange != null) { // Some controllers use LTRIGGER and RTRIGGER (like Ouya) context.leftTriggerAxis = MotionEvent.AXIS_LTRIGGER; context.rightTriggerAxis = MotionEvent.AXIS_RTRIGGER; } else if (brakeRange != null && gasRange != null) { // Others use GAS and BRAKE (like Moga) context.leftTriggerAxis = MotionEvent.AXIS_BRAKE; context.rightTriggerAxis = MotionEvent.AXIS_GAS; } else if (brakeRange != null && throttleRange != null) { // Others use THROTTLE and BRAKE (like Xiaomi) context.leftTriggerAxis = MotionEvent.AXIS_BRAKE; context.rightTriggerAxis = MotionEvent.AXIS_THROTTLE; } else { InputDevice.MotionRange rxRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RX); InputDevice.MotionRange ryRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RY); if (rxRange != null && ryRange != null && devName != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { if (dev.getVendorId() == 0x054c) { // Sony if (dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C)[0]) { LimeLog.info("Detected non-standard DualShock 4 mapping"); context.isNonStandardDualShock4 = true; } else { LimeLog.info("Detected DualShock 4 (Linux standard mapping)"); context.usesLinuxGamepadStandardFaceButtons = true; } } } else if (!devName.contains("Xbox") && !devName.contains("XBox") && !devName.contains("X-Box")) { LimeLog.info("Assuming non-standard DualShock 4 mapping on < 4.4"); context.isNonStandardDualShock4 = true; } if (context.isNonStandardDualShock4) { // The old DS4 driver uses RX and RY for triggers context.leftTriggerAxis = MotionEvent.AXIS_RX; context.rightTriggerAxis = MotionEvent.AXIS_RY; // DS4 has Select and Mode buttons (possibly mapped non-standard) context.hasSelect = true; context.hasMode = true; } else { // If it's not a non-standard DS4 controller, it's probably an Xbox controller or // other sane controller that uses RX and RY for right stick and Z and RZ for triggers. context.rightStickXAxis = MotionEvent.AXIS_RX; context.rightStickYAxis = MotionEvent.AXIS_RY; // While it's likely that Z and RZ are triggers, we may have digital trigger buttons // instead. We must check that we actually have Z and RZ axes before assigning them. if (getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_Z) != null && getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RZ) != null) { context.leftTriggerAxis = MotionEvent.AXIS_Z; context.rightTriggerAxis = MotionEvent.AXIS_RZ; } } // Triggers always idle negative on axes that are centered at zero context.triggersIdleNegative = true; } } if (context.rightStickXAxis == -1 && context.rightStickYAxis == -1) { InputDevice.MotionRange zRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_Z); InputDevice.MotionRange rzRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RZ); // Most other controllers use Z and RZ for the right stick if (zRange != null && rzRange != null) { context.rightStickXAxis = MotionEvent.AXIS_Z; context.rightStickYAxis = MotionEvent.AXIS_RZ; } else { InputDevice.MotionRange rxRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RX); InputDevice.MotionRange ryRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_RY); // Try RX and RY now if (rxRange != null && ryRange != null) { context.rightStickXAxis = MotionEvent.AXIS_RX; context.rightStickYAxis = MotionEvent.AXIS_RY; } } } // Some devices have "hats" for d-pads InputDevice.MotionRange hatXRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_HAT_X); InputDevice.MotionRange hatYRange = getMotionRangeForJoystickAxis(dev, MotionEvent.AXIS_HAT_Y); if (hatXRange != null && hatYRange != null) { context.hatXAxis = MotionEvent.AXIS_HAT_X; context.hatYAxis = MotionEvent.AXIS_HAT_Y; } if (context.leftStickXAxis != -1 && context.leftStickYAxis != -1) { context.leftStickDeadzoneRadius = (float) stickDeadzone; } if (context.rightStickXAxis != -1 && context.rightStickYAxis != -1) { context.rightStickDeadzoneRadius = (float) stickDeadzone; } if (context.leftTriggerAxis != -1 && context.rightTriggerAxis != -1) { InputDevice.MotionRange ltRange = getMotionRangeForJoystickAxis(dev, context.leftTriggerAxis); InputDevice.MotionRange rtRange = getMotionRangeForJoystickAxis(dev, context.rightTriggerAxis); // It's important to have a valid deadzone so controller packet batching works properly context.triggerDeadzone = Math.max(Math.abs(ltRange.getFlat()), Math.abs(rtRange.getFlat())); // For triggers without (valid) deadzones, we'll use 13% (around XInput's default) if (context.triggerDeadzone < 0.13f || context.triggerDeadzone > 0.30f) { context.triggerDeadzone = 0.13f; } } // The ADT-1 controller needs a similar fixup to the ASUS Gamepad if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { // The device name provided is just "Gamepad" which is pretty useless, so we // use VID/PID instead if (dev.getVendorId() == 0x18d1 && dev.getProductId() == 0x2c40) { context.backIsStart = true; context.modeIsSelect = true; context.triggerDeadzone = 0.30f; context.hasSelect = true; context.hasMode = false; } } context.ignoreBack = shouldIgnoreBack(dev); if (devName != null) { // For the Nexus Player (and probably other ATV devices), we should // use the back button as start since it doesn't have a start/menu button // on the controller if (devName.contains("ASUS Gamepad")) { // We can only do this check on KitKat or higher, but it doesn't matter since ATV // is Android 5.0 anyway if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { boolean[] hasStartKey = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_START, KeyEvent.KEYCODE_MENU, 0); if (!hasStartKey[0] && !hasStartKey[1]) { context.backIsStart = true; context.modeIsSelect = true; context.hasSelect = true; context.hasMode = false; } } // The ASUS Gamepad has triggers that sit far forward and are prone to false presses // so we increase the deadzone on them to minimize this context.triggerDeadzone = 0.30f; } // SHIELD controllers will use small stick deadzones else if (devName.contains("SHIELD") || devName.contains("NVIDIA Controller")) { // The big Nvidia button on the Shield controllers acts like a Search button. It // summons the Google Assistant on the Shield TV. On my Pixel 4, it seems to do // nothing, so we can hijack it to act like a mode button. if (devName.contains("NVIDIA Controller v01.03") || devName.contains("NVIDIA Controller v01.04")) { context.searchIsMode = true; context.hasMode = true; } } // The Serval has a couple of unknown buttons that are start and select. It also has // a back button which we want to ignore since there's already a select button. else if (devName.contains("Razer Serval")) { context.isServal = true; // Serval has Select and Mode buttons (possibly mapped non-standard) context.hasMode = true; context.hasSelect = true; } // The Xbox One S Bluetooth controller has some mappings that need fixing up. // However, Microsoft released a firmware update with no change to VID/PID // or device name that fixed the mappings for Android. Since there's // no good way to detect this, we'll use the presence of GAS/BRAKE axes // that were added in the latest firmware. If those are present, the only // required fixup is ignoring the select button. else if (devName.equals("Xbox Wireless Controller")) { if (gasRange == null) { context.isNonStandardXboxBtController = true; // Xbox One S has Select and Mode buttons (possibly mapped non-standard) context.hasMode = true; context.hasSelect = true; } } } LimeLog.info("Analog stick deadzone: "+context.leftStickDeadzoneRadius+" "+context.rightStickDeadzoneRadius); LimeLog.info("Trigger deadzone: "+context.triggerDeadzone); return context; } private InputDeviceContext getContextForEvent(InputEvent event) { // Unknown devices use the default context if (event.getDeviceId() == 0) { return defaultContext; } else if (event.getDevice() == null) { // During device removal, sometimes we can get events after the // input device has been destroyed. In this case we'll see a // != 0 device ID but no device attached. return null; } // HACK for https://issuetracker.google.com/issues/163120692 if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) { if (event.getDeviceId() == -1) { return defaultContext; } } // Return the existing context if it exists InputDeviceContext context = inputDeviceContexts.get(event.getDeviceId()); if (context != null) { return context; } // Otherwise create a new context context = createInputDeviceContextForDevice(event.getDevice()); inputDeviceContexts.put(event.getDeviceId(), context); return context; } private byte maxByMagnitude(byte a, byte b) { int absA = Math.abs(a); int absB = Math.abs(b); if (absA > absB) { return a; } else { return b; } } private short maxByMagnitude(short a, short b) { int absA = Math.abs(a); int absB = Math.abs(b); if (absA > absB) { return a; } else { return b; } } private short getActiveControllerMask() { if (prefConfig.multiController) { return (short)(currentControllers | initialControllers | (prefConfig.onscreenController ? 1 : 0)); } else { // Only Player 1 is active with multi-controller disabled return 1; } } private static boolean areBatteryCapacitiesEqual(float first, float second) { // With no NaNs involved, it is a simple equality comparison. if (!Float.isNaN(first) && !Float.isNaN(second)) { return first == second; } else { // If we have a NaN in one or both positions, compare NaN-ness instead. // Equality comparisons will always return false for NaN. return Float.isNaN(first) == Float.isNaN(second); } } private void sendControllerBatteryPacket(InputDeviceContext context) { int currentBatteryStatus; float currentBatteryCapacity; // Use the BatteryState object introduced in Android S, if it's available and present. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && context.inputDevice.getBatteryState().isPresent()) { currentBatteryStatus = context.inputDevice.getBatteryState().getStatus(); currentBatteryCapacity = context.inputDevice.getBatteryState().getCapacity(); } else if (sceManager.isRecognizedDevice(context.inputDevice)) { // On the SHIELD Android TV, we can use a proprietary API to access battery/charge state. // We will convert it to the same form used by BatteryState to share code. int batteryPercentage = sceManager.getBatteryPercentage(context.inputDevice); if (batteryPercentage < 0) { currentBatteryCapacity = Float.NaN; } else { currentBatteryCapacity = batteryPercentage / 100.f; } SceConnectionType connectionType = sceManager.getConnectionType(context.inputDevice); SceChargingState chargingState = sceManager.getChargingState(context.inputDevice); // We can make some assumptions about charge state based on the connection type if (connectionType == SceConnectionType.WIRED || connectionType == SceConnectionType.BOTH) { if (batteryPercentage == 100) { currentBatteryStatus = BatteryState.STATUS_FULL; } else if (chargingState == SceChargingState.NOT_CHARGING) { currentBatteryStatus = BatteryState.STATUS_NOT_CHARGING; } else { currentBatteryStatus = BatteryState.STATUS_CHARGING; } } else if (connectionType == SceConnectionType.WIRELESS) { if (chargingState == SceChargingState.CHARGING) { currentBatteryStatus = BatteryState.STATUS_CHARGING; } else { currentBatteryStatus = BatteryState.STATUS_DISCHARGING; } } else { // If connection type is unknown, just use the charge state if (batteryPercentage == 100) { currentBatteryStatus = BatteryState.STATUS_FULL; } else if (chargingState == SceChargingState.NOT_CHARGING) { currentBatteryStatus = BatteryState.STATUS_DISCHARGING; } else if (chargingState == SceChargingState.CHARGING) { currentBatteryStatus = BatteryState.STATUS_CHARGING; } else { currentBatteryStatus = BatteryState.STATUS_UNKNOWN; } } } else { return; } if (currentBatteryStatus != context.lastReportedBatteryStatus || !areBatteryCapacitiesEqual(currentBatteryCapacity, context.lastReportedBatteryCapacity)) { byte state; byte percentage; switch (currentBatteryStatus) { case BatteryState.STATUS_UNKNOWN: state = MoonBridge.LI_BATTERY_STATE_UNKNOWN; break; case BatteryState.STATUS_CHARGING: state = MoonBridge.LI_BATTERY_STATE_CHARGING; break; case BatteryState.STATUS_DISCHARGING: state = MoonBridge.LI_BATTERY_STATE_DISCHARGING; break; case BatteryState.STATUS_NOT_CHARGING: state = MoonBridge.LI_BATTERY_STATE_NOT_CHARGING; break; case BatteryState.STATUS_FULL: state = MoonBridge.LI_BATTERY_STATE_FULL; break; default: return; } if (Float.isNaN(currentBatteryCapacity)) { percentage = MoonBridge.LI_BATTERY_PERCENTAGE_UNKNOWN; } else { percentage = (byte)(currentBatteryCapacity * 100); } conn.sendControllerBatteryEvent((byte)context.controllerNumber, state, percentage); context.lastReportedBatteryStatus = currentBatteryStatus; context.lastReportedBatteryCapacity = currentBatteryCapacity; } } private void sendControllerInputPacket(GenericControllerContext originalContext) { assignControllerNumberIfNeeded(originalContext); // Take the context's controller number and fuse all inputs with the same number short controllerNumber = originalContext.controllerNumber; int inputMap = 0; byte leftTrigger = 0; byte rightTrigger = 0; short leftStickX = 0; short leftStickY = 0; short rightStickX = 0; short rightStickY = 0; // In order to properly handle controllers that are split into multiple devices, // we must aggregate all controllers with the same controller number into a single // device before we send it. for (int i = 0; i < inputDeviceContexts.size(); i++) { GenericControllerContext context = inputDeviceContexts.valueAt(i); if (context.assignedControllerNumber && context.controllerNumber == controllerNumber && context.mouseEmulationActive == originalContext.mouseEmulationActive) { inputMap |= context.inputMap; leftTrigger |= maxByMagnitude(leftTrigger, context.leftTrigger); rightTrigger |= maxByMagnitude(rightTrigger, context.rightTrigger); leftStickX |= maxByMagnitude(leftStickX, context.leftStickX); leftStickY |= maxByMagnitude(leftStickY, context.leftStickY); rightStickX |= maxByMagnitude(rightStickX, context.rightStickX); rightStickY |= maxByMagnitude(rightStickY, context.rightStickY); } } for (int i = 0; i < usbDeviceContexts.size(); i++) { GenericControllerContext context = usbDeviceContexts.valueAt(i); if (context.assignedControllerNumber && context.controllerNumber == controllerNumber && context.mouseEmulationActive == originalContext.mouseEmulationActive) { inputMap |= context.inputMap; leftTrigger |= maxByMagnitude(leftTrigger, context.leftTrigger); rightTrigger |= maxByMagnitude(rightTrigger, context.rightTrigger); leftStickX |= maxByMagnitude(leftStickX, context.leftStickX); leftStickY |= maxByMagnitude(leftStickY, context.leftStickY); rightStickX |= maxByMagnitude(rightStickX, context.rightStickX); rightStickY |= maxByMagnitude(rightStickY, context.rightStickY); } } if (defaultContext.controllerNumber == controllerNumber) { inputMap |= defaultContext.inputMap; leftTrigger |= maxByMagnitude(leftTrigger, defaultContext.leftTrigger); rightTrigger |= maxByMagnitude(rightTrigger, defaultContext.rightTrigger); leftStickX |= maxByMagnitude(leftStickX, defaultContext.leftStickX); leftStickY |= maxByMagnitude(leftStickY, defaultContext.leftStickY); rightStickX |= maxByMagnitude(rightStickX, defaultContext.rightStickX); rightStickY |= maxByMagnitude(rightStickY, defaultContext.rightStickY); } if (originalContext.mouseEmulationActive) { int changedMask = inputMap ^ originalContext.mouseEmulationLastInputMap; boolean aDown = (inputMap & ControllerPacket.A_FLAG) != 0; boolean bDown = (inputMap & ControllerPacket.B_FLAG) != 0; originalContext.mouseEmulationLastInputMap = inputMap; if ((changedMask & ControllerPacket.A_FLAG) != 0) { if (aDown) { conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_LEFT); } else { conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_LEFT); } } if ((changedMask & ControllerPacket.B_FLAG) != 0) { if (bDown) { conn.sendMouseButtonDown(MouseButtonPacket.BUTTON_RIGHT); } else { conn.sendMouseButtonUp(MouseButtonPacket.BUTTON_RIGHT); } } if ((changedMask & ControllerPacket.UP_FLAG) != 0) { if ((inputMap & ControllerPacket.UP_FLAG) != 0) { conn.sendMouseScroll((byte) 1); } } if ((changedMask & ControllerPacket.DOWN_FLAG) != 0) { if ((inputMap & ControllerPacket.DOWN_FLAG) != 0) { conn.sendMouseScroll((byte) -1); } } if ((changedMask & ControllerPacket.RIGHT_FLAG) != 0) { if ((inputMap & ControllerPacket.RIGHT_FLAG) != 0) { conn.sendMouseHScroll((byte) 1); } } if ((changedMask & ControllerPacket.LEFT_FLAG) != 0) { if ((inputMap & ControllerPacket.LEFT_FLAG) != 0) { conn.sendMouseHScroll((byte) -1); } } conn.sendControllerInput(controllerNumber, getActiveControllerMask(), (short)0, (byte)0, (byte)0, (short)0, (short)0, (short)0, (short)0); } else { conn.sendControllerInput(controllerNumber, getActiveControllerMask(), inputMap, leftTrigger, rightTrigger, leftStickX, leftStickY, rightStickX, rightStickY); } } private final int REMAP_IGNORE = -1; private final int REMAP_CONSUME = -2; // Return a valid keycode, -2 to consume, or -1 to not consume the event // Device MAY BE NULL private int handleRemapping(InputDeviceContext context, KeyEvent event) { // Don't capture the back button if configured if (context.ignoreBack) { if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) { return REMAP_IGNORE; } } // If we know this gamepad has a share button and receive an unmapped // KEY_RECORD event, report that as a share button press. if (context.hasShare) { if (event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN && event.getScanCode() == 167) { return KeyEvent.KEYCODE_MEDIA_RECORD; } } // Override mode button for 8BitDo controllers if (context.vendorId == 0x2dc8 && event.getScanCode() == 306) { return KeyEvent.KEYCODE_BUTTON_MODE; } // This mapping was adding in Android 10, then changed based on // kernel changes (adding hid-nintendo) in Android 11. If we're // on anything newer than Pie, just use the built-in mapping. if ((context.vendorId == 0x057e && context.productId == 0x2009 && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) || // Switch Pro controller (context.vendorId == 0x0f0d && context.productId == 0x00c1)) { // HORIPAD for Switch switch (event.getScanCode()) { case 0x130: return KeyEvent.KEYCODE_BUTTON_A; case 0x131: return KeyEvent.KEYCODE_BUTTON_B; case 0x132: return KeyEvent.KEYCODE_BUTTON_X; case 0x133: return KeyEvent.KEYCODE_BUTTON_Y; case 0x134: return KeyEvent.KEYCODE_BUTTON_L1; case 0x135: return KeyEvent.KEYCODE_BUTTON_R1; case 0x136: return KeyEvent.KEYCODE_BUTTON_L2; case 0x137: return KeyEvent.KEYCODE_BUTTON_R2; case 0x138: return KeyEvent.KEYCODE_BUTTON_SELECT; case 0x139: return KeyEvent.KEYCODE_BUTTON_START; case 0x13A: return KeyEvent.KEYCODE_BUTTON_THUMBL; case 0x13B: return KeyEvent.KEYCODE_BUTTON_THUMBR; case 0x13D: return KeyEvent.KEYCODE_BUTTON_MODE; } } if (context.usesLinuxGamepadStandardFaceButtons) { // Android's Generic.kl swaps BTN_NORTH and BTN_WEST switch (event.getScanCode()) { case 304: return KeyEvent.KEYCODE_BUTTON_A; case 305: return KeyEvent.KEYCODE_BUTTON_B; case 307: return KeyEvent.KEYCODE_BUTTON_Y; case 308: return KeyEvent.KEYCODE_BUTTON_X; } } if (context.isNonStandardDualShock4) { switch (event.getScanCode()) { case 304: return KeyEvent.KEYCODE_BUTTON_X; case 305: return KeyEvent.KEYCODE_BUTTON_A; case 306: return KeyEvent.KEYCODE_BUTTON_B; case 307: return KeyEvent.KEYCODE_BUTTON_Y; case 308: return KeyEvent.KEYCODE_BUTTON_L1; case 309: return KeyEvent.KEYCODE_BUTTON_R1; /* **** Using analog triggers instead **** case 310: return KeyEvent.KEYCODE_BUTTON_L2; case 311: return KeyEvent.KEYCODE_BUTTON_R2; */ case 312: return KeyEvent.KEYCODE_BUTTON_SELECT; case 313: return KeyEvent.KEYCODE_BUTTON_START; case 314: return KeyEvent.KEYCODE_BUTTON_THUMBL; case 315: return KeyEvent.KEYCODE_BUTTON_THUMBR; case 316: return KeyEvent.KEYCODE_BUTTON_MODE; default: return REMAP_CONSUME; } } // If this is a Serval controller sending an unknown key code, it's probably // the start and select buttons else if (context.isServal && event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) { switch (event.getScanCode()) { case 314: return KeyEvent.KEYCODE_BUTTON_SELECT; case 315: return KeyEvent.KEYCODE_BUTTON_START; } } else if (context.isNonStandardXboxBtController) { switch (event.getScanCode()) { case 306: return KeyEvent.KEYCODE_BUTTON_X; case 307: return KeyEvent.KEYCODE_BUTTON_Y; case 308: return KeyEvent.KEYCODE_BUTTON_L1; case 309: return KeyEvent.KEYCODE_BUTTON_R1; case 310: return KeyEvent.KEYCODE_BUTTON_SELECT; case 311: return KeyEvent.KEYCODE_BUTTON_START; case 312: return KeyEvent.KEYCODE_BUTTON_THUMBL; case 313: return KeyEvent.KEYCODE_BUTTON_THUMBR; case 139: return KeyEvent.KEYCODE_BUTTON_MODE; default: // Other buttons are mapped correctly } // The Xbox button is sent as MENU if (event.getKeyCode() == KeyEvent.KEYCODE_MENU) { return KeyEvent.KEYCODE_BUTTON_MODE; } } else if (context.vendorId == 0x0b05 && // ASUS (context.productId == 0x7900 || // Kunai - USB context.productId == 0x7902)) // Kunai - Bluetooth { // ROG Kunai has special M1-M4 buttons that are accessible via the // joycon-style detachable controllers that we should map to Start // and Select. switch (event.getScanCode()) { case 264: case 266: return KeyEvent.KEYCODE_BUTTON_START; case 265: case 267: return KeyEvent.KEYCODE_BUTTON_SELECT; } } if (context.hatXAxis == -1 && context.hatYAxis == -1 && /* FIXME: There's no good way to know for sure if xpad is bound to this device, so we won't use the name to validate if these scancodes should be mapped to DPAD context.isXboxController && */ event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) { // If there's not a proper Xbox controller mapping, we'll translate the raw d-pad // scan codes into proper key codes switch (event.getScanCode()) { case 704: return KeyEvent.KEYCODE_DPAD_LEFT; case 705: return KeyEvent.KEYCODE_DPAD_RIGHT; case 706: return KeyEvent.KEYCODE_DPAD_UP; case 707: return KeyEvent.KEYCODE_DPAD_DOWN; } } // Past here we can fixup the keycode and potentially trigger // another special case so we need to remember what keycode we're using int keyCode = event.getKeyCode(); // This is a hack for (at least) the "Tablet Remote" app // which sends BACK with META_ALT_ON instead of KEYCODE_BUTTON_B if (keyCode == KeyEvent.KEYCODE_BACK && !event.hasNoModifiers() && (event.getFlags() & KeyEvent.FLAG_SOFT_KEYBOARD) != 0) { keyCode = KeyEvent.KEYCODE_BUTTON_B; } if (keyCode == KeyEvent.KEYCODE_BUTTON_START || keyCode == KeyEvent.KEYCODE_MENU) { // Ensure that we never use back as start if we have a real start context.backIsStart = false; } else if (keyCode == KeyEvent.KEYCODE_BUTTON_SELECT) { // Don't use mode as select if we have a select context.modeIsSelect = false; } else if (context.backIsStart && keyCode == KeyEvent.KEYCODE_BACK) { // Emulate the start button with back return KeyEvent.KEYCODE_BUTTON_START; } else if (context.modeIsSelect && keyCode == KeyEvent.KEYCODE_BUTTON_MODE) { // Emulate the select button with mode return KeyEvent.KEYCODE_BUTTON_SELECT; } else if (context.searchIsMode && keyCode == KeyEvent.KEYCODE_SEARCH) { // Emulate the mode button with search return KeyEvent.KEYCODE_BUTTON_MODE; } return keyCode; } private int handleFlipFaceButtons(int keyCode) { switch (keyCode) { case KeyEvent.KEYCODE_BUTTON_A: return KeyEvent.KEYCODE_BUTTON_B; case KeyEvent.KEYCODE_BUTTON_B: return KeyEvent.KEYCODE_BUTTON_A; case KeyEvent.KEYCODE_BUTTON_X: return KeyEvent.KEYCODE_BUTTON_Y; case KeyEvent.KEYCODE_BUTTON_Y: return KeyEvent.KEYCODE_BUTTON_X; default: return keyCode; } } private Vector2d populateCachedVector(float x, float y) { // Reinitialize our cached Vector2d object inputVector.initialize(x, y); return inputVector; } private void handleDeadZone(Vector2d stickVector, float deadzoneRadius) { if (stickVector.getMagnitude() <= deadzoneRadius) { // Deadzone stickVector.initialize(0, 0); } // We're not normalizing here because we let the computer handle the deadzones. // Normalizing can make the deadzones larger than they should be after the computer also // evaluates the deadzone. } private void handleAxisSet(InputDeviceContext context, float lsX, float lsY, float rsX, float rsY, float lt, float rt, float hatX, float hatY) { if (context.leftStickXAxis != -1 && context.leftStickYAxis != -1) { Vector2d leftStickVector = populateCachedVector(lsX, lsY); handleDeadZone(leftStickVector, context.leftStickDeadzoneRadius); context.leftStickX = (short) (leftStickVector.getX() * 0x7FFE); context.leftStickY = (short) (-leftStickVector.getY() * 0x7FFE); } if (context.rightStickXAxis != -1 && context.rightStickYAxis != -1) { Vector2d rightStickVector = populateCachedVector(rsX, rsY); handleDeadZone(rightStickVector, context.rightStickDeadzoneRadius); context.rightStickX = (short) (rightStickVector.getX() * 0x7FFE); context.rightStickY = (short) (-rightStickVector.getY() * 0x7FFE); } if (context.leftTriggerAxis != -1 && context.rightTriggerAxis != -1) { // Android sends an initial 0 value for trigger axes even if the trigger // should be negative when idle. After the first touch, the axes will go back // to normal behavior, so ignore triggersIdleNegative for each trigger until // first touch. if (lt != 0) { context.leftTriggerAxisUsed = true; } if (rt != 0) { context.rightTriggerAxisUsed = true; } if (context.triggersIdleNegative) { if (context.leftTriggerAxisUsed) { lt = (lt + 1) / 2; } if (context.rightTriggerAxisUsed) { rt = (rt + 1) / 2; } } if (lt <= context.triggerDeadzone) { lt = 0; } if (rt <= context.triggerDeadzone) { rt = 0; } context.leftTrigger = (byte)(lt * 0xFF); context.rightTrigger = (byte)(rt * 0xFF); } if (context.hatXAxis != -1 && context.hatYAxis != -1) { context.inputMap &= ~(ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG); if (hatX < -0.5) { context.inputMap |= ControllerPacket.LEFT_FLAG; context.hatXAxisUsed = true; } else if (hatX > 0.5) { context.inputMap |= ControllerPacket.RIGHT_FLAG; context.hatXAxisUsed = true; } context.inputMap &= ~(ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG); if (hatY < -0.5) { context.inputMap |= ControllerPacket.UP_FLAG; context.hatYAxisUsed = true; } else if (hatY > 0.5) { context.inputMap |= ControllerPacket.DOWN_FLAG; context.hatYAxisUsed = true; } } sendControllerInputPacket(context); } // Normalize the given raw float value into a 0.0-1.0f range private float normalizeRawValueWithRange(float value, InputDevice.MotionRange range) { value = Math.max(value, range.getMin()); value = Math.min(value, range.getMax()); value -= range.getMin(); return value / range.getRange(); } public boolean tryHandleTouchpadEvent(MotionEvent event) { // Bail if this is not a touchpad event if (event.getSource() != InputDevice.SOURCE_TOUCHPAD) { return false; } // Only get a context if one already exists. We want to ensure we don't report non-gamepads. InputDeviceContext context = inputDeviceContexts.get(event.getDeviceId()); if (context == null) { return false; } byte touchType; switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_POINTER_DOWN: touchType = MoonBridge.LI_TOUCH_EVENT_DOWN; break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_POINTER_UP: if ((event.getFlags() & MotionEvent.FLAG_CANCELED) != 0) { touchType = MoonBridge.LI_TOUCH_EVENT_CANCEL; } else { touchType = MoonBridge.LI_TOUCH_EVENT_UP; } break; case MotionEvent.ACTION_MOVE: touchType = MoonBridge.LI_TOUCH_EVENT_MOVE; break; case MotionEvent.ACTION_CANCEL: touchType = MoonBridge.LI_TOUCH_EVENT_CANCEL; break; case MotionEvent.ACTION_BUTTON_PRESS: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && event.getActionButton() == MotionEvent.BUTTON_PRIMARY) { context.inputMap |= ControllerPacket.TOUCHPAD_FLAG; sendControllerInputPacket(context); return true; } return false; case MotionEvent.ACTION_BUTTON_RELEASE: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && event.getActionButton() == MotionEvent.BUTTON_PRIMARY) { context.inputMap &= ~ControllerPacket.TOUCHPAD_FLAG; sendControllerInputPacket(context); return true; } return false; default: return false; } // If we don't have X and Y ranges, we can't process this event if (context.touchpadXRange == null || context.touchpadYRange == null) { return false; } float normalizedX = normalizeRawValueWithRange(event.getX(event.getActionIndex()), context.touchpadXRange); float normalizedY = normalizeRawValueWithRange(event.getY(event.getActionIndex()), context.touchpadYRange); float normalizedPressure = context.touchpadPressureRange != null ? normalizeRawValueWithRange(event.getPressure(event.getActionIndex()), context.touchpadPressureRange) : 0; return conn.sendControllerTouchEvent((byte)context.controllerNumber, touchType, event.getPointerId(event.getActionIndex()), normalizedX, normalizedY, normalizedPressure) != MoonBridge.LI_ERR_UNSUPPORTED; } public boolean handleMotionEvent(MotionEvent event) { InputDeviceContext context = getContextForEvent(event); if (context == null) { return true; } float lsX = 0, lsY = 0, rsX = 0, rsY = 0, rt = 0, lt = 0, hatX = 0, hatY = 0; // We purposefully ignore the historical values in the motion event as it makes // the controller feel sluggish for some users. if (context.leftStickXAxis != -1 && context.leftStickYAxis != -1) { lsX = event.getAxisValue(context.leftStickXAxis); lsY = event.getAxisValue(context.leftStickYAxis); } if (context.rightStickXAxis != -1 && context.rightStickYAxis != -1) { rsX = event.getAxisValue(context.rightStickXAxis); rsY = event.getAxisValue(context.rightStickYAxis); } if (context.leftTriggerAxis != -1 && context.rightTriggerAxis != -1) { lt = event.getAxisValue(context.leftTriggerAxis); rt = event.getAxisValue(context.rightTriggerAxis); } if (context.hatXAxis != -1 && context.hatYAxis != -1) { hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X); hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y); } handleAxisSet(context, lsX, lsY, rsX, rsY, lt, rt, hatX, hatY); return true; } private short scaleRawStickAxis(float stickValue) { return (short)Math.pow(stickValue, 3); } private void sendEmulatedMouseEvent(short x, short y) { Vector2d vector = new Vector2d(); vector.initialize(x, y); vector.scalarMultiply(1 / 32766.0f); vector.scalarMultiply(4); if (vector.getMagnitude() > 0) { // Move faster as the stick is pressed further from center vector.scalarMultiply(Math.pow(vector.getMagnitude(), 2)); if (vector.getMagnitude() >= 1) { conn.sendMouseMove((short)vector.getX(), (short)-vector.getY()); } } } @TargetApi(31) private boolean hasDualAmplitudeControlledRumbleVibrators(VibratorManager vm) { int[] vibratorIds = vm.getVibratorIds(); // There must be exactly 2 vibrators on this device if (vibratorIds.length != 2) { return false; } // Both vibrators must have amplitude control for (int vid : vibratorIds) { if (!vm.getVibrator(vid).hasAmplitudeControl()) { return false; } } return true; } // This must only be called if hasDualAmplitudeControlledRumbleVibrators() is true! @TargetApi(31) private void rumbleDualVibrators(VibratorManager vm, short lowFreqMotor, short highFreqMotor) { // Normalize motor values to 0-255 amplitudes for VibrationManager highFreqMotor = (short)((highFreqMotor >> 8) & 0xFF); lowFreqMotor = (short)((lowFreqMotor >> 8) & 0xFF); // If they're both zero, we can just call cancel(). if (lowFreqMotor == 0 && highFreqMotor == 0) { vm.cancel(); return; } // There's no documentation that states that vibrators for FF_RUMBLE input devices will // always be enumerated in this order, but it seems consistent between Xbox Series X (USB), // PS3 (USB), and PS4 (USB+BT) controllers on Android 12 Beta 3. int[] vibratorIds = vm.getVibratorIds(); int[] vibratorAmplitudes = new int[] { highFreqMotor, lowFreqMotor }; CombinedVibration.ParallelCombination combo = CombinedVibration.startParallel(); for (int i = 0; i < vibratorIds.length; i++) { // It's illegal to create a VibrationEffect with an amplitude of 0. // Simply excluding that vibrator from our ParallelCombination will turn it off. if (vibratorAmplitudes[i] != 0) { combo.addVibrator(vibratorIds[i], VibrationEffect.createOneShot(60000, vibratorAmplitudes[i])); } } VibrationAttributes.Builder vibrationAttributes = new VibrationAttributes.Builder(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { vibrationAttributes.setUsage(VibrationAttributes.USAGE_MEDIA); } vm.vibrate(combo.combine(), vibrationAttributes.build()); } @TargetApi(31) private boolean hasQuadAmplitudeControlledRumbleVibrators(VibratorManager vm) { int[] vibratorIds = vm.getVibratorIds(); // There must be exactly 4 vibrators on this device if (vibratorIds.length != 4) { return false; } // All vibrators must have amplitude control for (int vid : vibratorIds) { if (!vm.getVibrator(vid).hasAmplitudeControl()) { return false; } } return true; } // This must only be called if hasQuadAmplitudeControlledRumbleVibrators() is true! @TargetApi(31) private void rumbleQuadVibrators(VibratorManager vm, short lowFreqMotor, short highFreqMotor, short leftTrigger, short rightTrigger) { // Normalize motor values to 0-255 amplitudes for VibrationManager highFreqMotor = (short)((highFreqMotor >> 8) & 0xFF); lowFreqMotor = (short)((lowFreqMotor >> 8) & 0xFF); leftTrigger = (short)((leftTrigger >> 8) & 0xFF); rightTrigger = (short)((rightTrigger >> 8) & 0xFF); // If they're all zero, we can just call cancel(). if (lowFreqMotor == 0 && highFreqMotor == 0 && leftTrigger == 0 && rightTrigger == 0) { vm.cancel(); return; } // This is a guess based upon the behavior of FF_RUMBLE, but untested due to lack of Linux // support for trigger rumble! int[] vibratorIds = vm.getVibratorIds(); int[] vibratorAmplitudes = new int[] { highFreqMotor, lowFreqMotor, leftTrigger, rightTrigger }; CombinedVibration.ParallelCombination combo = CombinedVibration.startParallel(); for (int i = 0; i < vibratorIds.length; i++) { // It's illegal to create a VibrationEffect with an amplitude of 0. // Simply excluding that vibrator from our ParallelCombination will turn it off. if (vibratorAmplitudes[i] != 0) { combo.addVibrator(vibratorIds[i], VibrationEffect.createOneShot(60000, vibratorAmplitudes[i])); } } VibrationAttributes.Builder vibrationAttributes = new VibrationAttributes.Builder(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { vibrationAttributes.setUsage(VibrationAttributes.USAGE_MEDIA); } vm.vibrate(combo.combine(), vibrationAttributes.build()); } private void rumbleSingleVibrator(Vibrator vibrator, short lowFreqMotor, short highFreqMotor) { // Since we can only use a single amplitude value, compute the desired amplitude // by taking 80% of the big motor and 33% of the small motor, then capping to 255. // NB: This value is now 0-255 as required by VibrationEffect. short lowFreqMotorMSB = (short)((lowFreqMotor >> 8) & 0xFF); short highFreqMotorMSB = (short)((highFreqMotor >> 8) & 0xFF); int simulatedAmplitude = Math.min(255, (int)((lowFreqMotorMSB * 0.80) + (highFreqMotorMSB * 0.33))); if (simulatedAmplitude == 0) { // This case is easy - just cancel the current effect and get out. // NB: We cannot simply check lowFreqMotor == highFreqMotor == 0 // because our simulatedAmplitude could be 0 even though our inputs // are not (ex: lowFreqMotor == 0 && highFreqMotor == 1). vibrator.cancel(); return; } // Attempt to use amplitude-based control if we're on Oreo and the device // supports amplitude-based vibration control. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (vibrator.hasAmplitudeControl()) { VibrationEffect effect = VibrationEffect.createOneShot(60000, simulatedAmplitude); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { VibrationAttributes vibrationAttributes = new VibrationAttributes.Builder() .setUsage(VibrationAttributes.USAGE_MEDIA) .build(); vibrator.vibrate(effect, vibrationAttributes); } else { AudioAttributes audioAttributes = new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_GAME) .build(); vibrator.vibrate(effect, audioAttributes); } return; } } // If we reach this point, we don't have amplitude controls available, so // we must emulate it by PWMing the vibration. Ick. long pwmPeriod = 20; long onTime = (long)((simulatedAmplitude / 255.0) * pwmPeriod); long offTime = pwmPeriod - onTime; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { VibrationAttributes vibrationAttributes = new VibrationAttributes.Builder() .setUsage(VibrationAttributes.USAGE_MEDIA) .build(); vibrator.vibrate(VibrationEffect.createWaveform(new long[]{0, onTime, offTime}, 0), vibrationAttributes); } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { AudioAttributes audioAttributes = new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_GAME) .build(); vibrator.vibrate(new long[]{0, onTime, offTime}, 0, audioAttributes); } else { vibrator.vibrate(new long[]{0, onTime, offTime}, 0); } } public void handleRumble(short controllerNumber, short lowFreqMotor, short highFreqMotor) { boolean foundMatchingDevice = false; boolean vibrated = false; for (int i = 0; i < inputDeviceContexts.size(); i++) { InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); if (deviceContext.controllerNumber == controllerNumber) { foundMatchingDevice = true; deviceContext.lowFreqMotor = lowFreqMotor; deviceContext.highFreqMotor = highFreqMotor; // Prefer the documented Android 12 rumble API which can handle dual vibrators on PS/Xbox controllers if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && deviceContext.vibratorManager != null) { vibrated = true; if (deviceContext.quadVibrators) { rumbleQuadVibrators(deviceContext.vibratorManager, deviceContext.lowFreqMotor, deviceContext.highFreqMotor, deviceContext.leftTriggerMotor, deviceContext.rightTriggerMotor); } else { rumbleDualVibrators(deviceContext.vibratorManager, deviceContext.lowFreqMotor, deviceContext.highFreqMotor); } } // On Shield devices, we can use their special API to rumble Shield controllers else if (sceManager.rumble(deviceContext.inputDevice, deviceContext.lowFreqMotor, deviceContext.highFreqMotor)) { vibrated = true; } // If all else fails, we have to try the old Vibrator API else if (deviceContext.vibrator != null) { vibrated = true; rumbleSingleVibrator(deviceContext.vibrator, deviceContext.lowFreqMotor, deviceContext.highFreqMotor); } } } for (int i = 0; i < usbDeviceContexts.size(); i++) { UsbDeviceContext deviceContext = usbDeviceContexts.valueAt(i); if (deviceContext.controllerNumber == controllerNumber) { foundMatchingDevice = vibrated = true; deviceContext.device.rumble(lowFreqMotor, highFreqMotor); } } // We may decide to rumble the device for player 1 if (controllerNumber == 0) { // If we didn't find a matching device, it must be the on-screen // controls that triggered the rumble. Vibrate the device if // the user has requested that behavior. if (!foundMatchingDevice && prefConfig.onscreenController && !prefConfig.onlyL3R3 && prefConfig.vibrateOsc) { rumbleSingleVibrator(deviceVibrator, lowFreqMotor, highFreqMotor); } else if (foundMatchingDevice && !vibrated && prefConfig.vibrateFallbackToDevice) { // We found a device to vibrate but it didn't have rumble support. The user // has requested us to vibrate the device in this case. rumbleSingleVibrator(deviceVibrator, lowFreqMotor, highFreqMotor); } } } public void handleRumbleTriggers(short controllerNumber, short leftTrigger, short rightTrigger) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { for (int i = 0; i < inputDeviceContexts.size(); i++) { InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); if (deviceContext.controllerNumber == controllerNumber) { deviceContext.leftTriggerMotor = leftTrigger; deviceContext.rightTriggerMotor = rightTrigger; if (deviceContext.quadVibrators) { rumbleQuadVibrators(deviceContext.vibratorManager, deviceContext.lowFreqMotor, deviceContext.highFreqMotor, deviceContext.leftTriggerMotor, deviceContext.rightTriggerMotor); } } } } for (int i = 0; i < usbDeviceContexts.size(); i++) { UsbDeviceContext deviceContext = usbDeviceContexts.valueAt(i); if (deviceContext.controllerNumber == controllerNumber) { deviceContext.device.rumbleTriggers(leftTrigger, rightTrigger); } } } public void handleSetMotionEventState(final short controllerNumber, final byte motionType, short reportRateHz) { // Report rate is restricted to <= 200 Hz without the HIGH_SAMPLING_RATE_SENSORS permission reportRateHz = (short) Math.min(200, reportRateHz); SensorEventListener newSensorListener = new SensorEventListener() { private float[] lastValues = new float[3]; @Override public void onSensorChanged(SensorEvent sensorEvent) { // Android will invoke our callback any time we get a new reading, // even if the values are the same as last time. Don't report a // duplicate set of values to save bandwidth. if (sensorEvent.values[0] == lastValues[0] && sensorEvent.values[1] == lastValues[1] && sensorEvent.values[2] == lastValues[2]) { return; } else { lastValues[0] = sensorEvent.values[0]; lastValues[1] = sensorEvent.values[1]; lastValues[2] = sensorEvent.values[2]; } if (motionType == MoonBridge.LI_MOTION_TYPE_GYRO) { // Convert from rad/s to deg/s conn.sendControllerMotionEvent((byte) controllerNumber, motionType, sensorEvent.values[0] * 57.2957795f, sensorEvent.values[1] * 57.2957795f, sensorEvent.values[2] * 57.2957795f); } else { // Pass m/s^2 directly without conversion conn.sendControllerMotionEvent((byte) controllerNumber, motionType, sensorEvent.values[0], sensorEvent.values[1], sensorEvent.values[2]); } } @Override public void onAccuracyChanged(Sensor sensor, int accuracy) {} }; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { for (int i = 0; i < inputDeviceContexts.size(); i++) { InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); if (deviceContext.controllerNumber == controllerNumber) { SensorManager sm = deviceContext.inputDevice.getSensorManager(); switch (motionType) { case MoonBridge.LI_MOTION_TYPE_ACCEL: if (deviceContext.accelListener != null) { sm.unregisterListener(deviceContext.accelListener); deviceContext.accelListener = null; } // Enable the accelerometer if requested Sensor accelSensor = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER, false); if (reportRateHz != 0 && accelSensor != null) { sm.registerListener(newSensorListener, accelSensor, 1000000 / reportRateHz); deviceContext.accelListener = newSensorListener; } break; case MoonBridge.LI_MOTION_TYPE_GYRO: if (deviceContext.gyroListener != null) { sm.unregisterListener(deviceContext.gyroListener); deviceContext.gyroListener = null; } // Enable the gyroscope if requested Sensor gyroSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE, false); if (reportRateHz != 0 && gyroSensor != null) { sm.registerListener(newSensorListener, gyroSensor,1000000 / reportRateHz); deviceContext.gyroListener = newSensorListener; } break; } break; } } } } public void handleSetControllerLED(short controllerNumber, byte r, byte g, byte b) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { for (int i = 0; i < inputDeviceContexts.size(); i++) { InputDeviceContext deviceContext = inputDeviceContexts.valueAt(i); if (deviceContext.controllerNumber == controllerNumber) { // Create a new light session if one doesn't already exist if (deviceContext.lightsSession == null) { deviceContext.lightsSession = deviceContext.inputDevice.getLightsManager().openSession(); } // Convert the RGB components into the integer value that LightState uses int argbValue = 0xFF000000 | ((r << 16) & 0xFF0000) | ((g << 8) & 0xFF00) | (b & 0xFF); LightState lightState = new LightState.Builder().setColor(argbValue).build(); // Set the RGB value for each RGB-controllable LED on the device LightsRequest.Builder lightsRequestBuilder = new LightsRequest.Builder(); for (Light light : deviceContext.inputDevice.getLightsManager().getLights()) { if (light.hasRgbControl()) { lightsRequestBuilder.addLight(light, lightState); } } // Apply the LED changes deviceContext.lightsSession.requestLights(lightsRequestBuilder.build()); } } } } public boolean handleButtonUp(KeyEvent event) { InputDeviceContext context = getContextForEvent(event); if (context == null) { return true; } int keyCode = handleRemapping(context, event); if (keyCode < 0) { return (keyCode == REMAP_CONSUME); } if (prefConfig.flipFaceButtons) { keyCode = handleFlipFaceButtons(keyCode); } // If the button hasn't been down long enough, sleep for a bit before sending the up event // This allows "instant" button presses (like OUYA's virtual menu button) to work. This // path should not be triggered during normal usage. int buttonDownTime = (int)(event.getEventTime() - event.getDownTime()); if (buttonDownTime < ControllerHandler.MINIMUM_BUTTON_DOWN_TIME_MS) { // Since our sleep time is so short (<= 25 ms), it shouldn't cause a problem doing this // in the UI thread. try { Thread.sleep(ControllerHandler.MINIMUM_BUTTON_DOWN_TIME_MS - buttonDownTime); } catch (InterruptedException e) { e.printStackTrace(); // InterruptedException clears the thread's interrupt status. Since we can't // handle that here, we will re-interrupt the thread to set the interrupt // status back to true. Thread.currentThread().interrupt(); } } switch (keyCode) { case KeyEvent.KEYCODE_BUTTON_MODE: context.inputMap &= ~ControllerPacket.SPECIAL_BUTTON_FLAG; break; case KeyEvent.KEYCODE_BUTTON_START: case KeyEvent.KEYCODE_MENU: // Sometimes we'll get a spurious key up event on controller disconnect. // Make sure it's real by checking that the key is actually down before taking // any action. if ((context.inputMap & ControllerPacket.PLAY_FLAG) != 0 && event.getEventTime() - context.startDownTime > ControllerHandler.START_DOWN_TIME_MOUSE_MODE_MS && prefConfig.mouseEmulation) { context.toggleMouseEmulation(); } context.inputMap &= ~ControllerPacket.PLAY_FLAG; break; case KeyEvent.KEYCODE_BACK: case KeyEvent.KEYCODE_BUTTON_SELECT: context.inputMap &= ~ControllerPacket.BACK_FLAG; break; case KeyEvent.KEYCODE_DPAD_LEFT: if (context.hatXAxisUsed) { // Suppress this duplicate event if we have a hat return true; } context.inputMap &= ~ControllerPacket.LEFT_FLAG; break; case KeyEvent.KEYCODE_DPAD_RIGHT: if (context.hatXAxisUsed) { // Suppress this duplicate event if we have a hat return true; } context.inputMap &= ~ControllerPacket.RIGHT_FLAG; break; case KeyEvent.KEYCODE_DPAD_UP: if (context.hatYAxisUsed) { // Suppress this duplicate event if we have a hat return true; } context.inputMap &= ~ControllerPacket.UP_FLAG; break; case KeyEvent.KEYCODE_DPAD_DOWN: if (context.hatYAxisUsed) { // Suppress this duplicate event if we have a hat return true; } context.inputMap &= ~ControllerPacket.DOWN_FLAG; break; case KeyEvent.KEYCODE_DPAD_UP_LEFT: if (context.hatXAxisUsed && context.hatYAxisUsed) { // Suppress this duplicate event if we have a hat return true; } context.inputMap &= ~(ControllerPacket.UP_FLAG | ControllerPacket.LEFT_FLAG); break; case KeyEvent.KEYCODE_DPAD_UP_RIGHT: if (context.hatXAxisUsed && context.hatYAxisUsed) { // Suppress this duplicate event if we have a hat return true; } context.inputMap &= ~(ControllerPacket.UP_FLAG | ControllerPacket.RIGHT_FLAG); break; case KeyEvent.KEYCODE_DPAD_DOWN_LEFT: if (context.hatXAxisUsed && context.hatYAxisUsed) { // Suppress this duplicate event if we have a hat return true; } context.inputMap &= ~(ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG); break; case KeyEvent.KEYCODE_DPAD_DOWN_RIGHT: if (context.hatXAxisUsed && context.hatYAxisUsed) { // Suppress this duplicate event if we have a hat return true; } context.inputMap &= ~(ControllerPacket.DOWN_FLAG | ControllerPacket.RIGHT_FLAG); break; case KeyEvent.KEYCODE_BUTTON_B: context.inputMap &= ~ControllerPacket.B_FLAG; break; case KeyEvent.KEYCODE_DPAD_CENTER: case KeyEvent.KEYCODE_BUTTON_A: context.inputMap &= ~ControllerPacket.A_FLAG; break; case KeyEvent.KEYCODE_BUTTON_X: context.inputMap &= ~ControllerPacket.X_FLAG; break; case KeyEvent.KEYCODE_BUTTON_Y: context.inputMap &= ~ControllerPacket.Y_FLAG; break; case KeyEvent.KEYCODE_BUTTON_L1: context.inputMap &= ~ControllerPacket.LB_FLAG; context.lastLbUpTime = event.getEventTime(); break; case KeyEvent.KEYCODE_BUTTON_R1: context.inputMap &= ~ControllerPacket.RB_FLAG; context.lastRbUpTime = event.getEventTime(); break; case KeyEvent.KEYCODE_BUTTON_THUMBL: context.inputMap &= ~ControllerPacket.LS_CLK_FLAG; break; case KeyEvent.KEYCODE_BUTTON_THUMBR: context.inputMap &= ~ControllerPacket.RS_CLK_FLAG; break; case KeyEvent.KEYCODE_MEDIA_RECORD: // Xbox Series X Share button context.inputMap &= ~ControllerPacket.MISC_FLAG; break; case KeyEvent.KEYCODE_BUTTON_1: // PS4/PS5 touchpad button (prior to 4.10) context.inputMap &= ~ControllerPacket.TOUCHPAD_FLAG; break; case KeyEvent.KEYCODE_BUTTON_L2: if (context.leftTriggerAxisUsed) { // Suppress this digital event if an analog trigger is active return true; } context.leftTrigger = 0; break; case KeyEvent.KEYCODE_BUTTON_R2: if (context.rightTriggerAxisUsed) { // Suppress this digital event if an analog trigger is active return true; } context.rightTrigger = 0; break; case KeyEvent.KEYCODE_UNKNOWN: // Paddles aren't mapped in any of the Android key layout files, // so we need to handle the evdev key codes directly. if (context.hasPaddles) { switch (event.getScanCode()) { case 0x2c4: // BTN_TRIGGER_HAPPY5 context.inputMap &= ~ControllerPacket.PADDLE1_FLAG; break; case 0x2c5: // BTN_TRIGGER_HAPPY6 context.inputMap &= ~ControllerPacket.PADDLE2_FLAG; break; case 0x2c6: // BTN_TRIGGER_HAPPY7 context.inputMap &= ~ControllerPacket.PADDLE3_FLAG; break; case 0x2c7: // BTN_TRIGGER_HAPPY8 context.inputMap &= ~ControllerPacket.PADDLE4_FLAG; break; default: return false; } } else { return false; } break; default: return false; } // Check if we're emulating the select button if ((context.emulatingButtonFlags & ControllerHandler.EMULATING_SELECT) != 0) { // If either start or LB is up, select comes up too if ((context.inputMap & ControllerPacket.PLAY_FLAG) == 0 || (context.inputMap & ControllerPacket.LB_FLAG) == 0) { context.inputMap &= ~ControllerPacket.BACK_FLAG; context.emulatingButtonFlags &= ~ControllerHandler.EMULATING_SELECT; } } // Check if we're emulating the special button if ((context.emulatingButtonFlags & ControllerHandler.EMULATING_SPECIAL) != 0) { // If either start or select and RB is up, the special button comes up too if ((context.inputMap & ControllerPacket.PLAY_FLAG) == 0 || ((context.inputMap & ControllerPacket.BACK_FLAG) == 0 && (context.inputMap & ControllerPacket.RB_FLAG) == 0)) { context.inputMap &= ~ControllerPacket.SPECIAL_BUTTON_FLAG; context.emulatingButtonFlags &= ~ControllerHandler.EMULATING_SPECIAL; } } sendControllerInputPacket(context); if (context.pendingExit && context.inputMap == 0) { // All buttons from the quit combo are lifted. Finish the activity now. activityContext.finish(); } return true; } public boolean handleButtonDown(KeyEvent event) { InputDeviceContext context = getContextForEvent(event); if (context == null) { return true; } int keyCode = handleRemapping(context, event); if (keyCode < 0) { return (keyCode == REMAP_CONSUME); } if (prefConfig.flipFaceButtons) { keyCode = handleFlipFaceButtons(keyCode); } switch (keyCode) { case KeyEvent.KEYCODE_BUTTON_MODE: context.hasMode = true; context.inputMap |= ControllerPacket.SPECIAL_BUTTON_FLAG; break; case KeyEvent.KEYCODE_BUTTON_START: case KeyEvent.KEYCODE_MENU: if (event.getRepeatCount() == 0) { context.startDownTime = event.getEventTime(); } context.inputMap |= ControllerPacket.PLAY_FLAG; break; case KeyEvent.KEYCODE_BACK: case KeyEvent.KEYCODE_BUTTON_SELECT: context.hasSelect = true; context.inputMap |= ControllerPacket.BACK_FLAG; break; case KeyEvent.KEYCODE_DPAD_LEFT: if (context.hatXAxisUsed) { // Suppress this duplicate event if we have a hat return true; } context.inputMap |= ControllerPacket.LEFT_FLAG; break; case KeyEvent.KEYCODE_DPAD_RIGHT: if (context.hatXAxisUsed) { // Suppress this duplicate event if we have a hat return true; } context.inputMap |= ControllerPacket.RIGHT_FLAG; break; case KeyEvent.KEYCODE_DPAD_UP: if (context.hatYAxisUsed) { // Suppress this duplicate event if we have a hat return true; } context.inputMap |= ControllerPacket.UP_FLAG; break; case KeyEvent.KEYCODE_DPAD_DOWN: if (context.hatYAxisUsed) { // Suppress this duplicate event if we have a hat return true; } context.inputMap |= ControllerPacket.DOWN_FLAG; break; case KeyEvent.KEYCODE_DPAD_UP_LEFT: if (context.hatXAxisUsed && context.hatYAxisUsed) { // Suppress this duplicate event if we have a hat return true; } context.inputMap |= ControllerPacket.UP_FLAG | ControllerPacket.LEFT_FLAG; break; case KeyEvent.KEYCODE_DPAD_UP_RIGHT: if (context.hatXAxisUsed && context.hatYAxisUsed) { // Suppress this duplicate event if we have a hat return true; } context.inputMap |= ControllerPacket.UP_FLAG | ControllerPacket.RIGHT_FLAG; break; case KeyEvent.KEYCODE_DPAD_DOWN_LEFT: if (context.hatXAxisUsed && context.hatYAxisUsed) { // Suppress this duplicate event if we have a hat return true; } context.inputMap |= ControllerPacket.DOWN_FLAG | ControllerPacket.LEFT_FLAG; break; case KeyEvent.KEYCODE_DPAD_DOWN_RIGHT: if (context.hatXAxisUsed && context.hatYAxisUsed) { // Suppress this duplicate event if we have a hat return true; } context.inputMap |= ControllerPacket.DOWN_FLAG | ControllerPacket.RIGHT_FLAG; break; case KeyEvent.KEYCODE_BUTTON_B: context.inputMap |= ControllerPacket.B_FLAG; break; case KeyEvent.KEYCODE_DPAD_CENTER: case KeyEvent.KEYCODE_BUTTON_A: context.inputMap |= ControllerPacket.A_FLAG; break; case KeyEvent.KEYCODE_BUTTON_X: context.inputMap |= ControllerPacket.X_FLAG; break; case KeyEvent.KEYCODE_BUTTON_Y: context.inputMap |= ControllerPacket.Y_FLAG; break; case KeyEvent.KEYCODE_BUTTON_L1: context.inputMap |= ControllerPacket.LB_FLAG; break; case KeyEvent.KEYCODE_BUTTON_R1: context.inputMap |= ControllerPacket.RB_FLAG; break; case KeyEvent.KEYCODE_BUTTON_THUMBL: context.inputMap |= ControllerPacket.LS_CLK_FLAG; break; case KeyEvent.KEYCODE_BUTTON_THUMBR: context.inputMap |= ControllerPacket.RS_CLK_FLAG; break; case KeyEvent.KEYCODE_MEDIA_RECORD: // Xbox Series X Share button context.inputMap |= ControllerPacket.MISC_FLAG; break; case KeyEvent.KEYCODE_BUTTON_1: // PS4/PS5 touchpad button (prior to 4.10) context.inputMap |= ControllerPacket.TOUCHPAD_FLAG; break; case KeyEvent.KEYCODE_BUTTON_L2: if (context.leftTriggerAxisUsed) { // Suppress this digital event if an analog trigger is active return true; } context.leftTrigger = (byte)0xFF; break; case KeyEvent.KEYCODE_BUTTON_R2: if (context.rightTriggerAxisUsed) { // Suppress this digital event if an analog trigger is active return true; } context.rightTrigger = (byte)0xFF; break; case KeyEvent.KEYCODE_UNKNOWN: // Paddles aren't mapped in any of the Android key layout files, // so we need to handle the evdev key codes directly. if (context.hasPaddles) { switch (event.getScanCode()) { case 0x2c4: // BTN_TRIGGER_HAPPY5 context.inputMap |= ControllerPacket.PADDLE1_FLAG; break; case 0x2c5: // BTN_TRIGGER_HAPPY6 context.inputMap |= ControllerPacket.PADDLE2_FLAG; break; case 0x2c6: // BTN_TRIGGER_HAPPY7 context.inputMap |= ControllerPacket.PADDLE3_FLAG; break; case 0x2c7: // BTN_TRIGGER_HAPPY8 context.inputMap |= ControllerPacket.PADDLE4_FLAG; break; default: return false; } } else { return false; } break; default: return false; } // Start+Back+LB+RB is the quit combo if (context.inputMap == (ControllerPacket.BACK_FLAG | ControllerPacket.PLAY_FLAG | ControllerPacket.LB_FLAG | ControllerPacket.RB_FLAG)) { // Wait for the combo to lift and then finish the activity context.pendingExit = true; } // Start+LB acts like select for controllers with one button if (!context.hasSelect) { if (context.inputMap == (ControllerPacket.PLAY_FLAG | ControllerPacket.LB_FLAG) || (context.inputMap == ControllerPacket.PLAY_FLAG && event.getEventTime() - context.lastLbUpTime <= MAXIMUM_BUMPER_UP_DELAY_MS)) { context.inputMap &= ~(ControllerPacket.PLAY_FLAG | ControllerPacket.LB_FLAG); context.inputMap |= ControllerPacket.BACK_FLAG; context.emulatingButtonFlags |= ControllerHandler.EMULATING_SELECT; } } // If there is a physical select button, we'll use Start+Select as the special button combo // otherwise we'll use Start+RB. if (!context.hasMode) { if (context.hasSelect) { if (context.inputMap == (ControllerPacket.PLAY_FLAG | ControllerPacket.BACK_FLAG)) { context.inputMap &= ~(ControllerPacket.PLAY_FLAG | ControllerPacket.BACK_FLAG); context.inputMap |= ControllerPacket.SPECIAL_BUTTON_FLAG; context.emulatingButtonFlags |= ControllerHandler.EMULATING_SPECIAL; } } else { if (context.inputMap == (ControllerPacket.PLAY_FLAG | ControllerPacket.RB_FLAG) || (context.inputMap == ControllerPacket.PLAY_FLAG && event.getEventTime() - context.lastRbUpTime <= MAXIMUM_BUMPER_UP_DELAY_MS)) { context.inputMap &= ~(ControllerPacket.PLAY_FLAG | ControllerPacket.RB_FLAG); context.inputMap |= ControllerPacket.SPECIAL_BUTTON_FLAG; context.emulatingButtonFlags |= ControllerHandler.EMULATING_SPECIAL; } } } // We don't need to send repeat key down events, but the platform // sends us events that claim to be repeats but they're from different // devices, so we just send them all and deal with some duplicates. sendControllerInputPacket(context); return true; } public void reportOscState(int buttonFlags, short leftStickX, short leftStickY, short rightStickX, short rightStickY, byte leftTrigger, byte rightTrigger) { defaultContext.leftStickX = leftStickX; defaultContext.leftStickY = leftStickY; defaultContext.rightStickX = rightStickX; defaultContext.rightStickY = rightStickY; defaultContext.leftTrigger = leftTrigger; defaultContext.rightTrigger = rightTrigger; defaultContext.inputMap = buttonFlags; sendControllerInputPacket(defaultContext); } @Override public void reportControllerState(int controllerId, int buttonFlags, float leftStickX, float leftStickY, float rightStickX, float rightStickY, float leftTrigger, float rightTrigger) { GenericControllerContext context = usbDeviceContexts.get(controllerId); if (context == null) { return; } Vector2d leftStickVector = populateCachedVector(leftStickX, leftStickY); handleDeadZone(leftStickVector, context.leftStickDeadzoneRadius); context.leftStickX = (short) (leftStickVector.getX() * 0x7FFE); context.leftStickY = (short) (-leftStickVector.getY() * 0x7FFE); Vector2d rightStickVector = populateCachedVector(rightStickX, rightStickY); handleDeadZone(rightStickVector, context.rightStickDeadzoneRadius); context.rightStickX = (short) (rightStickVector.getX() * 0x7FFE); context.rightStickY = (short) (-rightStickVector.getY() * 0x7FFE); if (leftTrigger <= context.triggerDeadzone) { leftTrigger = 0; } if (rightTrigger <= context.triggerDeadzone) { rightTrigger = 0; } context.leftTrigger = (byte)(leftTrigger * 0xFF); context.rightTrigger = (byte)(rightTrigger * 0xFF); context.inputMap = buttonFlags; sendControllerInputPacket(context); } @Override public void deviceRemoved(AbstractController controller) { UsbDeviceContext context = usbDeviceContexts.get(controller.getControllerId()); if (context != null) { LimeLog.info("Removed controller: "+controller.getControllerId()); releaseControllerNumber(context); context.destroy(); usbDeviceContexts.remove(controller.getControllerId()); } } @Override public void deviceAdded(AbstractController controller) { UsbDeviceContext context = createUsbDeviceContextForDevice(controller); usbDeviceContexts.put(controller.getControllerId(), context); } class GenericControllerContext { public int id; public boolean external; public int vendorId; public int productId; public float leftStickDeadzoneRadius; public float rightStickDeadzoneRadius; public float triggerDeadzone; public boolean assignedControllerNumber; public boolean reservedControllerNumber; public short controllerNumber; public int inputMap = 0; public byte leftTrigger = 0x00; public byte rightTrigger = 0x00; public short rightStickX = 0x0000; public short rightStickY = 0x0000; public short leftStickX = 0x0000; public short leftStickY = 0x0000; public boolean mouseEmulationActive; public int mouseEmulationLastInputMap; public final int mouseEmulationReportPeriod = 50; public final Runnable mouseEmulationRunnable = new Runnable() { @Override public void run() { if (!mouseEmulationActive) { return; } // Send mouse movement events from analog sticks sendEmulatedMouseEvent(leftStickX, leftStickY); sendEmulatedMouseEvent(rightStickX, rightStickY); // Requeue the callback handler.postDelayed(this, mouseEmulationReportPeriod); } }; public void toggleMouseEmulation() { handler.removeCallbacks(mouseEmulationRunnable); mouseEmulationActive = !mouseEmulationActive; Toast.makeText(activityContext, "Mouse emulation is: " + (mouseEmulationActive ? "ON" : "OFF"), Toast.LENGTH_SHORT).show(); if (mouseEmulationActive) { handler.postDelayed(mouseEmulationRunnable, mouseEmulationReportPeriod); } } public void destroy() { mouseEmulationActive = false; handler.removeCallbacks(mouseEmulationRunnable); } public void sendControllerArrival() {} } class InputDeviceContext extends GenericControllerContext { public String name; public VibratorManager vibratorManager; public Vibrator vibrator; public boolean quadVibrators; public short lowFreqMotor, highFreqMotor; public short leftTriggerMotor, rightTriggerMotor; public SensorEventListener gyroListener; public SensorEventListener accelListener; public InputDevice inputDevice; public LightsManager.LightsSession lightsSession; // These are BatteryState values, not Moonlight values public int lastReportedBatteryStatus; public float lastReportedBatteryCapacity; public int leftStickXAxis = -1; public int leftStickYAxis = -1; public int rightStickXAxis = -1; public int rightStickYAxis = -1; public int leftTriggerAxis = -1; public int rightTriggerAxis = -1; public boolean triggersIdleNegative; public boolean leftTriggerAxisUsed, rightTriggerAxisUsed; public int hatXAxis = -1; public int hatYAxis = -1; public boolean hatXAxisUsed, hatYAxisUsed; InputDevice.MotionRange touchpadXRange; InputDevice.MotionRange touchpadYRange; InputDevice.MotionRange touchpadPressureRange; public boolean isNonStandardDualShock4; public boolean usesLinuxGamepadStandardFaceButtons; public boolean isNonStandardXboxBtController; public boolean isServal; public boolean backIsStart; public boolean modeIsSelect; public boolean searchIsMode; public boolean ignoreBack; public boolean hasJoystickAxes; public boolean pendingExit; public int emulatingButtonFlags = 0; public boolean hasSelect; public boolean hasMode; public boolean hasPaddles; public boolean hasShare; // Used for OUYA bumper state tracking since they force all buttons // up when the OUYA button goes down. We watch the last time we get // a bumper up and compare that to our maximum delay when we receive // a Start button press to see if we should activate one of our // emulated button combos. public long lastLbUpTime = 0; public long lastRbUpTime = 0; public long startDownTime = 0; public final Runnable batteryStateUpdateRunnable = new Runnable() { @Override public void run() { sendControllerBatteryPacket(InputDeviceContext.this); // Requeue the callback handler.postDelayed(this, BATTERY_RECHECK_INTERVAL_MS); } }; @Override public void destroy() { super.destroy(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && vibratorManager != null) { vibratorManager.cancel(); } else if (vibrator != null) { vibrator.cancel(); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (gyroListener != null) { inputDevice.getSensorManager().unregisterListener(gyroListener); } if (accelListener != null) { inputDevice.getSensorManager().unregisterListener(accelListener); } if (lightsSession != null) { lightsSession.close(); } } handler.removeCallbacks(batteryStateUpdateRunnable); } @Override public void sendControllerArrival() { // Below KitKat we can't get enough information to report controller details accurately if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { return; } byte type = MoonBridge.LI_CTYPE_UNKNOWN; switch (inputDevice.getVendorId()) { case 0x045e: // Microsoft type = MoonBridge.LI_CTYPE_XBOX; break; case 0x054c: // Sony type = MoonBridge.LI_CTYPE_PS; break; case 0x057e: // Nintendo type = MoonBridge.LI_CTYPE_NINTENDO; break; default: // Consult SDL's controller type list to see if it knows type = MoonBridge.guessControllerType(inputDevice.getVendorId(), inputDevice.getProductId()); break; } int supportedButtonFlags = 0; for (Map.Entry entry : ANDROID_TO_LI_BUTTON_MAP.entrySet()) { if (inputDevice.hasKeys(entry.getKey())[0]) { supportedButtonFlags |= entry.getValue(); } } // Add non-standard button flags that may not be mapped in the Android kl file if (hasPaddles) { supportedButtonFlags |= ControllerPacket.PADDLE1_FLAG | ControllerPacket.PADDLE2_FLAG | ControllerPacket.PADDLE3_FLAG | ControllerPacket.PADDLE4_FLAG; } if (hasShare) { supportedButtonFlags |= ControllerPacket.MISC_FLAG; } if (getMotionRangeForJoystickAxis(inputDevice, MotionEvent.AXIS_HAT_X) != null) { supportedButtonFlags |= ControllerPacket.LEFT_FLAG | ControllerPacket.RIGHT_FLAG; } if (getMotionRangeForJoystickAxis(inputDevice, MotionEvent.AXIS_HAT_Y) != null) { supportedButtonFlags |= ControllerPacket.UP_FLAG | ControllerPacket.DOWN_FLAG; } short capabilities = 0; // Most of the advanced InputDevice capabilities came in Android S if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (hasQuadAmplitudeControlledRumbleVibrators(inputDevice.getVibratorManager())) { capabilities |= MoonBridge.LI_CCAP_RUMBLE | MoonBridge.LI_CCAP_TRIGGER_RUMBLE; } else if (hasDualAmplitudeControlledRumbleVibrators(inputDevice.getVibratorManager())) { capabilities |= MoonBridge.LI_CCAP_RUMBLE; } if (inputDevice.getSensorManager().getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null) { capabilities |= MoonBridge.LI_CCAP_ACCEL; } if (inputDevice.getSensorManager().getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null) { capabilities |= MoonBridge.LI_CCAP_GYRO; } if (inputDevice.getBatteryState().isPresent()) { capabilities |= MoonBridge.LI_CCAP_BATTERY_STATE; } for (Light light : inputDevice.getLightsManager().getLights()) { if (light.hasRgbControl()) { capabilities |= MoonBridge.LI_CCAP_RGB_LED; } } } // Report analog triggers if we have at least one trigger axis if (leftTriggerAxis != -1 || rightTriggerAxis != -1) { capabilities |= MoonBridge.LI_CCAP_ANALOG_TRIGGERS; } // We can perform basic rumble with any vibrator if (vibrator != null) { capabilities |= MoonBridge.LI_CCAP_RUMBLE; } // Shield controllers use special APIs for rumble and battery state if (sceManager.isRecognizedDevice(inputDevice)) { capabilities |= MoonBridge.LI_CCAP_RUMBLE | MoonBridge.LI_CCAP_BATTERY_STATE; } if ((inputDevice.getSources() & InputDevice.SOURCE_TOUCHPAD) == InputDevice.SOURCE_TOUCHPAD) { capabilities |= MoonBridge.LI_CCAP_TOUCHPAD; // If this is a PlayStation controller with a touchpad, we know it has a clickpad. // FIXME: Can we actually tell a clickpad from a touchpad using Android APIs? if (type == MoonBridge.LI_CTYPE_PS) { supportedButtonFlags |= ControllerPacket.TOUCHPAD_FLAG; } } conn.sendControllerArrivalEvent((byte)controllerNumber, getActiveControllerMask(), type, supportedButtonFlags, capabilities); // After reporting arrival to the host, send initial battery state and begin monitoring sendControllerBatteryPacket(this); handler.postDelayed(batteryStateUpdateRunnable, BATTERY_RECHECK_INTERVAL_MS); } } class UsbDeviceContext extends GenericControllerContext { public AbstractController device; @Override public void destroy() { super.destroy(); // Nothing for now } @Override public void sendControllerArrival() { conn.sendControllerArrivalEvent((byte)controllerNumber, getActiveControllerMask(), device.getType(), device.getSupportedButtonFlags(), device.getCapabilities()); } } }