--- /dev/null
+package org.libsdl.app;
+
+import android.content.Context;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCallback;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.BluetoothGattService;
+import android.hardware.usb.UsbDevice;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.os.*;
+
+//import com.android.internal.util.HexDump;
+
+import java.lang.Runnable;
+import java.util.Arrays;
+import java.util.LinkedList;
+import java.util.UUID;
+
+class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice {
+
+ private static final String TAG = "hidapi";
+ private HIDDeviceManager mManager;
+ private BluetoothDevice mDevice;
+ private int mDeviceId;
+ private BluetoothGatt mGatt;
+ private boolean mIsRegistered = false;
+ private boolean mIsConnected = false;
+ private boolean mIsChromebook = false;
+ private boolean mIsReconnecting = false;
+ private boolean mFrozen = false;
+ private LinkedList<GattOperation> mOperations;
+ GattOperation mCurrentOperation = null;
+ private Handler mHandler;
+
+ private static final int TRANSPORT_AUTO = 0;
+ private static final int TRANSPORT_BREDR = 1;
+ private static final int TRANSPORT_LE = 2;
+
+ private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000;
+
+ static public final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3");
+ static public final UUID inputCharacteristic = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3");
+ static public final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3");
+ static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 };
+
+ static class GattOperation {
+ private enum Operation {
+ CHR_READ,
+ CHR_WRITE,
+ ENABLE_NOTIFICATION
+ }
+
+ Operation mOp;
+ UUID mUuid;
+ byte[] mValue;
+ BluetoothGatt mGatt;
+ boolean mResult = true;
+
+ private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) {
+ mGatt = gatt;
+ mOp = operation;
+ mUuid = uuid;
+ }
+
+ private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) {
+ mGatt = gatt;
+ mOp = operation;
+ mUuid = uuid;
+ mValue = value;
+ }
+
+ public void run() {
+ // This is executed in main thread
+ BluetoothGattCharacteristic chr;
+
+ switch (mOp) {
+ case CHR_READ:
+ chr = getCharacteristic(mUuid);
+ //Log.v(TAG, "Reading characteristic " + chr.getUuid());
+ if (!mGatt.readCharacteristic(chr)) {
+ Log.e(TAG, "Unable to read characteristic " + mUuid.toString());
+ mResult = false;
+ break;
+ }
+ mResult = true;
+ break;
+ case CHR_WRITE:
+ chr = getCharacteristic(mUuid);
+ //Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value));
+ chr.setValue(mValue);
+ if (!mGatt.writeCharacteristic(chr)) {
+ Log.e(TAG, "Unable to write characteristic " + mUuid.toString());
+ mResult = false;
+ break;
+ }
+ mResult = true;
+ break;
+ case ENABLE_NOTIFICATION:
+ chr = getCharacteristic(mUuid);
+ //Log.v(TAG, "Writing descriptor of " + chr.getUuid());
+ if (chr != null) {
+ BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
+ if (cccd != null) {
+ int properties = chr.getProperties();
+ byte[] value;
+ if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == BluetoothGattCharacteristic.PROPERTY_NOTIFY) {
+ value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE;
+ } else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == BluetoothGattCharacteristic.PROPERTY_INDICATE) {
+ value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE;
+ } else {
+ Log.e(TAG, "Unable to start notifications on input characteristic");
+ mResult = false;
+ return;
+ }
+
+ mGatt.setCharacteristicNotification(chr, true);
+ cccd.setValue(value);
+ if (!mGatt.writeDescriptor(cccd)) {
+ Log.e(TAG, "Unable to write descriptor " + mUuid.toString());
+ mResult = false;
+ return;
+ }
+ mResult = true;
+ }
+ }
+ }
+ }
+
+ public boolean finish() {
+ return mResult;
+ }
+
+ private BluetoothGattCharacteristic getCharacteristic(UUID uuid) {
+ BluetoothGattService valveService = mGatt.getService(steamControllerService);
+ if (valveService == null)
+ return null;
+ return valveService.getCharacteristic(uuid);
+ }
+
+ static public GattOperation readCharacteristic(BluetoothGatt gatt, UUID uuid) {
+ return new GattOperation(gatt, Operation.CHR_READ, uuid);
+ }
+
+ static public GattOperation writeCharacteristic(BluetoothGatt gatt, UUID uuid, byte[] value) {
+ return new GattOperation(gatt, Operation.CHR_WRITE, uuid, value);
+ }
+
+ static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid) {
+ return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid);
+ }
+ }
+
+ public HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) {
+ mManager = manager;
+ mDevice = device;
+ mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier());
+ mIsRegistered = false;
+ mIsChromebook = mManager.getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management");
+ mOperations = new LinkedList<GattOperation>();
+ mHandler = new Handler(Looper.getMainLooper());
+
+ mGatt = connectGatt();
+ // final HIDDeviceBLESteamController finalThis = this;
+ // mHandler.postDelayed(new Runnable() {
+ // @Override
+ // public void run() {
+ // finalThis.checkConnectionForChromebookIssue();
+ // }
+ // }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL);
+ }
+
+ public String getIdentifier() {
+ return String.format("SteamController.%s", mDevice.getAddress());
+ }
+
+ public BluetoothGatt getGatt() {
+ return mGatt;
+ }
+
+ // Because on Chromebooks we show up as a dual-mode device, it will attempt to connect TRANSPORT_AUTO, which will use TRANSPORT_BREDR instead
+ // of TRANSPORT_LE. Let's force ourselves to connect low energy.
+ private BluetoothGatt connectGatt(boolean managed) {
+ if (Build.VERSION.SDK_INT >= 23) {
+ try {
+ return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE);
+ } catch (Exception e) {
+ return mDevice.connectGatt(mManager.getContext(), managed, this);
+ }
+ } else {
+ return mDevice.connectGatt(mManager.getContext(), managed, this);
+ }
+ }
+
+ private BluetoothGatt connectGatt() {
+ return connectGatt(false);
+ }
+
+ protected int getConnectionState() {
+
+ Context context = mManager.getContext();
+ if (context == null) {
+ // We are lacking any context to get our Bluetooth information. We'll just assume disconnected.
+ return BluetoothProfile.STATE_DISCONNECTED;
+ }
+
+ BluetoothManager btManager = (BluetoothManager)context.getSystemService(Context.BLUETOOTH_SERVICE);
+ if (btManager == null) {
+ // This device doesn't support Bluetooth. We should never be here, because how did
+ // we instantiate a device to start with?
+ return BluetoothProfile.STATE_DISCONNECTED;
+ }
+
+ return btManager.getConnectionState(mDevice, BluetoothProfile.GATT);
+ }
+
+ public void reconnect() {
+
+ if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) {
+ mGatt.disconnect();
+ mGatt = connectGatt();
+ }
+
+ }
+
+ protected void checkConnectionForChromebookIssue() {
+ if (!mIsChromebook) {
+ // We only do this on Chromebooks, because otherwise it's really annoying to just attempt
+ // over and over.
+ return;
+ }
+
+ int connectionState = getConnectionState();
+
+ switch (connectionState) {
+ case BluetoothProfile.STATE_CONNECTED:
+ if (!mIsConnected) {
+ // We are in the Bad Chromebook Place. We can force a disconnect
+ // to try to recover.
+ Log.v(TAG, "Chromebook: We are in a very bad state; the controller shows as connected in the underlying Bluetooth layer, but we never received a callback. Forcing a reconnect.");
+ mIsReconnecting = true;
+ mGatt.disconnect();
+ mGatt = connectGatt(false);
+ break;
+ }
+ else if (!isRegistered()) {
+ if (mGatt.getServices().size() > 0) {
+ Log.v(TAG, "Chromebook: We are connected to a controller, but never got our registration. Trying to recover.");
+ probeService(this);
+ }
+ else {
+ Log.v(TAG, "Chromebook: We are connected to a controller, but never discovered services. Trying to recover.");
+ mIsReconnecting = true;
+ mGatt.disconnect();
+ mGatt = connectGatt(false);
+ break;
+ }
+ }
+ else {
+ Log.v(TAG, "Chromebook: We are connected, and registered. Everything's good!");
+ return;
+ }
+ break;
+
+ case BluetoothProfile.STATE_DISCONNECTED:
+ Log.v(TAG, "Chromebook: We have either been disconnected, or the Chromebook BtGatt.ContextMap bug has bitten us. Attempting a disconnect/reconnect, but we may not be able to recover.");
+
+ mIsReconnecting = true;
+ mGatt.disconnect();
+ mGatt = connectGatt(false);
+ break;
+
+ case BluetoothProfile.STATE_CONNECTING:
+ Log.v(TAG, "Chromebook: We're still trying to connect. Waiting a bit longer.");
+ break;
+ }
+
+ final HIDDeviceBLESteamController finalThis = this;
+ mHandler.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ finalThis.checkConnectionForChromebookIssue();
+ }
+ }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL);
+ }
+
+ private boolean isRegistered() {
+ return mIsRegistered;
+ }
+
+ private void setRegistered() {
+ mIsRegistered = true;
+ }
+
+ private boolean probeService(HIDDeviceBLESteamController controller) {
+
+ if (isRegistered()) {
+ return true;
+ }
+
+ if (!mIsConnected) {
+ return false;
+ }
+
+ Log.v(TAG, "probeService controller=" + controller);
+
+ for (BluetoothGattService service : mGatt.getServices()) {
+ if (service.getUuid().equals(steamControllerService)) {
+ Log.v(TAG, "Found Valve steam controller service " + service.getUuid());
+
+ for (BluetoothGattCharacteristic chr : service.getCharacteristics()) {
+ if (chr.getUuid().equals(inputCharacteristic)) {
+ Log.v(TAG, "Found input characteristic");
+ // Start notifications
+ BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
+ if (cccd != null) {
+ enableNotification(chr.getUuid());
+ }
+ }
+ }
+ return true;
+ }
+ }
+
+ if ((mGatt.getServices().size() == 0) && mIsChromebook && !mIsReconnecting) {
+ Log.e(TAG, "Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us.");
+ mIsConnected = false;
+ mIsReconnecting = true;
+ mGatt.disconnect();
+ mGatt = connectGatt(false);
+ }
+
+ return false;
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ private void finishCurrentGattOperation() {
+ GattOperation op = null;
+ synchronized (mOperations) {
+ if (mCurrentOperation != null) {
+ op = mCurrentOperation;
+ mCurrentOperation = null;
+ }
+ }
+ if (op != null) {
+ boolean result = op.finish(); // TODO: Maybe in main thread as well?
+
+ // Our operation failed, let's add it back to the beginning of our queue.
+ if (!result) {
+ mOperations.addFirst(op);
+ }
+ }
+ executeNextGattOperation();
+ }
+
+ private void executeNextGattOperation() {
+ synchronized (mOperations) {
+ if (mCurrentOperation != null)
+ return;
+
+ if (mOperations.isEmpty())
+ return;
+
+ mCurrentOperation = mOperations.removeFirst();
+ }
+
+ // Run in main thread
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ synchronized (mOperations) {
+ if (mCurrentOperation == null) {
+ Log.e(TAG, "Current operation null in executor?");
+ return;
+ }
+
+ mCurrentOperation.run();
+ // now wait for the GATT callback and when it comes, finish this operation
+ }
+ }
+ });
+ }
+
+ private void queueGattOperation(GattOperation op) {
+ synchronized (mOperations) {
+ mOperations.add(op);
+ }
+ executeNextGattOperation();
+ }
+
+ private void enableNotification(UUID chrUuid) {
+ GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid);
+ queueGattOperation(op);
+ }
+
+ public void writeCharacteristic(UUID uuid, byte[] value) {
+ GattOperation op = HIDDeviceBLESteamController.GattOperation.writeCharacteristic(mGatt, uuid, value);
+ queueGattOperation(op);
+ }
+
+ public void readCharacteristic(UUID uuid) {
+ GattOperation op = HIDDeviceBLESteamController.GattOperation.readCharacteristic(mGatt, uuid);
+ queueGattOperation(op);
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ ////////////// BluetoothGattCallback overridden methods
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
+ //Log.v(TAG, "onConnectionStateChange status=" + status + " newState=" + newState);
+ mIsReconnecting = false;
+ if (newState == 2) {
+ mIsConnected = true;
+ // Run directly, without GattOperation
+ if (!isRegistered()) {
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ mGatt.discoverServices();
+ }
+ });
+ }
+ }
+ else if (newState == 0) {
+ mIsConnected = false;
+ }
+
+ // Disconnection is handled in SteamLink using the ACTION_ACL_DISCONNECTED Intent.
+ }
+
+ public void onServicesDiscovered(BluetoothGatt gatt, int status) {
+ //Log.v(TAG, "onServicesDiscovered status=" + status);
+ if (status == 0) {
+ if (gatt.getServices().size() == 0) {
+ Log.v(TAG, "onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack.");
+ mIsReconnecting = true;
+ mIsConnected = false;
+ gatt.disconnect();
+ mGatt = connectGatt(false);
+ }
+ else {
+ probeService(this);
+ }
+ }
+ }
+
+ public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+ //Log.v(TAG, "onCharacteristicRead status=" + status + " uuid=" + characteristic.getUuid());
+
+ if (characteristic.getUuid().equals(reportCharacteristic) && !mFrozen) {
+ mManager.HIDDeviceFeatureReport(getId(), characteristic.getValue());
+ }
+
+ finishCurrentGattOperation();
+ }
+
+ public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+ //Log.v(TAG, "onCharacteristicWrite status=" + status + " uuid=" + characteristic.getUuid());
+
+ if (characteristic.getUuid().equals(reportCharacteristic)) {
+ // Only register controller with the native side once it has been fully configured
+ if (!isRegistered()) {
+ Log.v(TAG, "Registering Steam Controller with ID: " + getId());
+ mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0);
+ setRegistered();
+ }
+ }
+
+ finishCurrentGattOperation();
+ }
+
+ public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
+ // Enable this for verbose logging of controller input reports
+ //Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue()));
+
+ if (characteristic.getUuid().equals(inputCharacteristic) && !mFrozen) {
+ mManager.HIDDeviceInputReport(getId(), characteristic.getValue());
+ }
+ }
+
+ public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+ //Log.v(TAG, "onDescriptorRead status=" + status);
+ }
+
+ public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+ BluetoothGattCharacteristic chr = descriptor.getCharacteristic();
+ //Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid());
+
+ if (chr.getUuid().equals(inputCharacteristic)) {
+ boolean hasWrittenInputDescriptor = true;
+ BluetoothGattCharacteristic reportChr = chr.getService().getCharacteristic(reportCharacteristic);
+ if (reportChr != null) {
+ Log.v(TAG, "Writing report characteristic to enter valve mode");
+ reportChr.setValue(enterValveMode);
+ gatt.writeCharacteristic(reportChr);
+ }
+ }
+
+ finishCurrentGattOperation();
+ }
+
+ public void onReliableWriteCompleted(BluetoothGatt gatt, int status) {
+ //Log.v(TAG, "onReliableWriteCompleted status=" + status);
+ }
+
+ public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) {
+ //Log.v(TAG, "onReadRemoteRssi status=" + status);
+ }
+
+ public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
+ //Log.v(TAG, "onMtuChanged status=" + status);
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ //////// Public API
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ @Override
+ public int getId() {
+ return mDeviceId;
+ }
+
+ @Override
+ public int getVendorId() {
+ // Valve Corporation
+ final int VALVE_USB_VID = 0x28DE;
+ return VALVE_USB_VID;
+ }
+
+ @Override
+ public int getProductId() {
+ // We don't have an easy way to query from the Bluetooth device, but we know what it is
+ final int D0G_BLE2_PID = 0x1106;
+ return D0G_BLE2_PID;
+ }
+
+ @Override
+ public String getSerialNumber() {
+ // This will be read later via feature report by Steam
+ return "12345";
+ }
+
+ @Override
+ public int getVersion() {
+ return 0;
+ }
+
+ @Override
+ public String getManufacturerName() {
+ return "Valve Corporation";
+ }
+
+ @Override
+ public String getProductName() {
+ return "Steam Controller";
+ }
+
+ @Override
+ public UsbDevice getDevice() {
+ return null;
+ }
+
+ @Override
+ public boolean open() {
+ return true;
+ }
+
+ @Override
+ public int sendFeatureReport(byte[] report) {
+ if (!isRegistered()) {
+ Log.e(TAG, "Attempted sendFeatureReport before Steam Controller is registered!");
+ if (mIsConnected) {
+ probeService(this);
+ }
+ return -1;
+ }
+
+ // We need to skip the first byte, as that doesn't go over the air
+ byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1);
+ //Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(actual_report));
+ writeCharacteristic(reportCharacteristic, actual_report);
+ return report.length;
+ }
+
+ @Override
+ public int sendOutputReport(byte[] report) {
+ if (!isRegistered()) {
+ Log.e(TAG, "Attempted sendOutputReport before Steam Controller is registered!");
+ if (mIsConnected) {
+ probeService(this);
+ }
+ return -1;
+ }
+
+ //Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(report));
+ writeCharacteristic(reportCharacteristic, report);
+ return report.length;
+ }
+
+ @Override
+ public boolean getFeatureReport(byte[] report) {
+ if (!isRegistered()) {
+ Log.e(TAG, "Attempted getFeatureReport before Steam Controller is registered!");
+ if (mIsConnected) {
+ probeService(this);
+ }
+ return false;
+ }
+
+ //Log.v(TAG, "getFeatureReport");
+ readCharacteristic(reportCharacteristic);
+ return true;
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public void setFrozen(boolean frozen) {
+ mFrozen = frozen;
+ }
+
+ @Override
+ public void shutdown() {
+ close();
+
+ BluetoothGatt g = mGatt;
+ if (g != null) {
+ g.disconnect();
+ g.close();
+ mGatt = null;
+ }
+ mManager = null;
+ mIsRegistered = false;
+ mIsConnected = false;
+ mOperations.clear();
+ }
+
+}
+