changed build system for Android from Ant to Gradle
[rocksndiamonds.git] / build-projects / android / app / src / main / java / org / libsdl / app / HIDDeviceBLESteamController.java
diff --git a/build-projects/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java b/build-projects/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java
new file mode 100644 (file)
index 0000000..94a2818
--- /dev/null
@@ -0,0 +1,650 @@
+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();
+    }
+
+}
+