Maelstrom: Added android-project template from SDL

From 7b82d4508082d6a20a982a017a2fc3ec021d03dc Mon Sep 17 00:00:00 2001
From: Sam Lantinga <[EMAIL REDACTED]>
Date: Sat, 21 Mar 2026 15:37:07 -0700
Subject: [PATCH] Added android-project template from SDL

---
 android-project/app/build.gradle              |   63 +
 android-project/app/jni/Android.mk            |    1 +
 android-project/app/jni/Application.mk        |   10 +
 android-project/app/jni/CMakeLists.txt        |   15 +
 android-project/app/jni/src/Android.mk        |   19 +
 android-project/app/jni/src/CMakeLists.txt    |   12 +
 android-project/app/jni/src/YourSourceHere.c  |   26 +
 android-project/app/proguard-rules.pro        |   77 +
 .../app/src/main/AndroidManifest.xml          |  108 +
 .../main/java/org/libsdl/app/HIDDevice.java   |   21 +
 .../app/HIDDeviceBLESteamController.java      |  655 +++++
 .../java/org/libsdl/app/HIDDeviceManager.java |  691 +++++
 .../java/org/libsdl/app/HIDDeviceUSB.java     |  313 +++
 .../app/src/main/java/org/libsdl/app/SDL.java |   90 +
 .../main/java/org/libsdl/app/SDLActivity.java | 2230 +++++++++++++++++
 .../java/org/libsdl/app/SDLAudioManager.java  |  126 +
 .../org/libsdl/app/SDLControllerManager.java  |  935 +++++++
 .../java/org/libsdl/app/SDLDummyEdit.java     |   66 +
 .../org/libsdl/app/SDLInputConnection.java    |  136 +
 .../main/java/org/libsdl/app/SDLSurface.java  |  449 ++++
 .../src/main/res/mipmap-hdpi/ic_launcher.png  |  Bin 0 -> 2683 bytes
 .../src/main/res/mipmap-mdpi/ic_launcher.png  |  Bin 0 -> 1698 bytes
 .../src/main/res/mipmap-xhdpi/ic_launcher.png |  Bin 0 -> 3872 bytes
 .../main/res/mipmap-xxhdpi/ic_launcher.png    |  Bin 0 -> 6874 bytes
 .../main/res/mipmap-xxxhdpi/ic_launcher.png   |  Bin 0 -> 14526 bytes
 .../app/src/main/res/values/colors.xml        |    6 +
 .../app/src/main/res/values/strings.xml       |    3 +
 .../app/src/main/res/values/styles.xml        |    7 +
 android-project/build.gradle                  |   25 +
 android-project/gradle.properties             |   17 +
 .../gradle/wrapper/gradle-wrapper.jar         |  Bin 0 -> 54213 bytes
 .../gradle/wrapper/gradle-wrapper.properties  |    6 +
 android-project/gradlew                       |  160 ++
 android-project/gradlew.bat                   |   90 +
 android-project/settings.gradle               |    1 +
 35 files changed, 6358 insertions(+)
 create mode 100644 android-project/app/build.gradle
 create mode 100644 android-project/app/jni/Android.mk
 create mode 100644 android-project/app/jni/Application.mk
 create mode 100644 android-project/app/jni/CMakeLists.txt
 create mode 100644 android-project/app/jni/src/Android.mk
 create mode 100644 android-project/app/jni/src/CMakeLists.txt
 create mode 100644 android-project/app/jni/src/YourSourceHere.c
 create mode 100644 android-project/app/proguard-rules.pro
 create mode 100644 android-project/app/src/main/AndroidManifest.xml
 create mode 100644 android-project/app/src/main/java/org/libsdl/app/HIDDevice.java
 create mode 100644 android-project/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java
 create mode 100644 android-project/app/src/main/java/org/libsdl/app/HIDDeviceManager.java
 create mode 100644 android-project/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java
 create mode 100644 android-project/app/src/main/java/org/libsdl/app/SDL.java
 create mode 100644 android-project/app/src/main/java/org/libsdl/app/SDLActivity.java
 create mode 100644 android-project/app/src/main/java/org/libsdl/app/SDLAudioManager.java
 create mode 100644 android-project/app/src/main/java/org/libsdl/app/SDLControllerManager.java
 create mode 100644 android-project/app/src/main/java/org/libsdl/app/SDLDummyEdit.java
 create mode 100644 android-project/app/src/main/java/org/libsdl/app/SDLInputConnection.java
 create mode 100644 android-project/app/src/main/java/org/libsdl/app/SDLSurface.java
 create mode 100644 android-project/app/src/main/res/mipmap-hdpi/ic_launcher.png
 create mode 100644 android-project/app/src/main/res/mipmap-mdpi/ic_launcher.png
 create mode 100644 android-project/app/src/main/res/mipmap-xhdpi/ic_launcher.png
 create mode 100644 android-project/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
 create mode 100644 android-project/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
 create mode 100644 android-project/app/src/main/res/values/colors.xml
 create mode 100644 android-project/app/src/main/res/values/strings.xml
 create mode 100644 android-project/app/src/main/res/values/styles.xml
 create mode 100644 android-project/build.gradle
 create mode 100644 android-project/gradle.properties
 create mode 100644 android-project/gradle/wrapper/gradle-wrapper.jar
 create mode 100644 android-project/gradle/wrapper/gradle-wrapper.properties
 create mode 100755 android-project/gradlew
 create mode 100644 android-project/gradlew.bat
 create mode 100644 android-project/settings.gradle

diff --git a/android-project/app/build.gradle b/android-project/app/build.gradle
new file mode 100644
index 00000000..b9d75b14
--- /dev/null
+++ b/android-project/app/build.gradle
@@ -0,0 +1,63 @@
+plugins {
+     id 'com.android.application'
+}
+
+def buildWithCMake = project.hasProperty('BUILD_WITH_CMAKE');
+
+android {
+    namespace = "org.libsdl.app"
+    compileSdkVersion 35
+    ndkVersion = "28.2.13676358"
+    defaultConfig {
+        minSdkVersion 21
+        targetSdkVersion 35
+        versionCode 1
+        versionName "1.0"
+        externalNativeBuild {
+            ndkBuild {
+                arguments "APP_PLATFORM=android-21"
+                // abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
+                abiFilters 'arm64-v8a'
+            }
+            cmake {
+                arguments "-DANDROID_PLATFORM=android-21", "-DANDROID_STL=c++_static"
+                // abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
+                abiFilters 'arm64-v8a'
+            }
+        }
+    }
+    buildTypes {
+        release {
+            minifyEnabled false
+            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+        }
+    }
+    applicationVariants.all { variant ->
+        tasks["merge${variant.name.capitalize()}Assets"]
+            .dependsOn("externalNativeBuild${variant.name.capitalize()}")
+    }
+    if (!project.hasProperty('EXCLUDE_NATIVE_LIBS')) {
+        sourceSets.main {
+            jniLibs.srcDir 'libs'
+        }
+        externalNativeBuild {
+            if (buildWithCMake) {
+                cmake {
+                    path 'jni/CMakeLists.txt'
+                }
+            } else {
+                ndkBuild {
+                    path 'jni/Android.mk'
+                }
+            }
+        }
+
+    }
+    lint {
+        abortOnError = false
+    }
+}
+
+dependencies {
+    implementation fileTree(include: ['*.jar'], dir: 'libs')
+}
diff --git a/android-project/app/jni/Android.mk b/android-project/app/jni/Android.mk
new file mode 100644
index 00000000..5053e7d6
--- /dev/null
+++ b/android-project/app/jni/Android.mk
@@ -0,0 +1 @@
+include $(call all-subdir-makefiles)
diff --git a/android-project/app/jni/Application.mk b/android-project/app/jni/Application.mk
new file mode 100644
index 00000000..1f7c0c10
--- /dev/null
+++ b/android-project/app/jni/Application.mk
@@ -0,0 +1,10 @@
+
+# Uncomment this if you're using STL in your project
+# You can find more information here:
+# https://developer.android.com/ndk/guides/cpp-support
+# APP_STL := c++_shared
+
+APP_ABI := armeabi-v7a arm64-v8a x86 x86_64
+
+# Min runtime API level
+APP_PLATFORM=android-21
diff --git a/android-project/app/jni/CMakeLists.txt b/android-project/app/jni/CMakeLists.txt
new file mode 100644
index 00000000..404b87b3
--- /dev/null
+++ b/android-project/app/jni/CMakeLists.txt
@@ -0,0 +1,15 @@
+cmake_minimum_required(VERSION 3.6)
+
+project(GAME)
+
+# SDL sources are in a subfolder named "SDL"
+add_subdirectory(SDL)
+
+# Compilation of companion libraries
+#add_subdirectory(SDL_image)
+#add_subdirectory(SDL_mixer)
+#add_subdirectory(SDL_ttf)
+
+# Your game and its CMakeLists.txt are in a subfolder named "src"
+add_subdirectory(src)
+
diff --git a/android-project/app/jni/src/Android.mk b/android-project/app/jni/src/Android.mk
new file mode 100644
index 00000000..61672d4f
--- /dev/null
+++ b/android-project/app/jni/src/Android.mk
@@ -0,0 +1,19 @@
+LOCAL_PATH := $(call my-dir)
+
+include $(CLEAR_VARS)
+
+LOCAL_MODULE := main
+
+# Add your application source files here...
+LOCAL_SRC_FILES := \
+    YourSourceHere.c
+
+SDL_PATH := ../SDL  # SDL
+
+LOCAL_C_INCLUDES := $(LOCAL_PATH)/$(SDL_PATH)/include  # SDL
+
+LOCAL_SHARED_LIBRARIES := SDL3
+
+LOCAL_LDLIBS := -lGLESv1_CM -lGLESv2 -lOpenSLES -llog -landroid  # SDL
+
+include $(BUILD_SHARED_LIBRARY)
diff --git a/android-project/app/jni/src/CMakeLists.txt b/android-project/app/jni/src/CMakeLists.txt
new file mode 100644
index 00000000..02128376
--- /dev/null
+++ b/android-project/app/jni/src/CMakeLists.txt
@@ -0,0 +1,12 @@
+cmake_minimum_required(VERSION 3.6)
+
+project(my_app)
+
+if(NOT TARGET SDL3::SDL3)
+    find_package(SDL3 CONFIG REQUIRED)
+endif()
+
+add_library(main SHARED
+    YourSourceHere.c
+)
+target_link_libraries(main PRIVATE SDL3::SDL3)
diff --git a/android-project/app/jni/src/YourSourceHere.c b/android-project/app/jni/src/YourSourceHere.c
new file mode 100644
index 00000000..87b82973
--- /dev/null
+++ b/android-project/app/jni/src/YourSourceHere.c
@@ -0,0 +1,26 @@
+#include <SDL3/SDL.h>
+#include <SDL3/SDL_main.h>
+
+/* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! */
+/*                                                       */
+/* Remove this source, and replace with your SDL sources */
+/*                                                       */
+/* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! */
+
+int main(int argc, char *argv[]) {
+    (void)argc;
+    (void)argv;
+    if (!SDL_Init(SDL_INIT_EVENTS | SDL_INIT_VIDEO)) {
+        SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_Init failed (%s)", SDL_GetError());
+        return 1;
+    }
+
+    if (!SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_INFORMATION, "Hello World",
+                                 "!! Your SDL project successfully runs on Android !!", NULL)) {
+        SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "SDL_ShowSimpleMessageBox failed (%s)", SDL_GetError());
+        return 1;
+    }
+
+    SDL_Quit();
+    return 0;
+}
diff --git a/android-project/app/proguard-rules.pro b/android-project/app/proguard-rules.pro
new file mode 100644
index 00000000..0fb7ae09
--- /dev/null
+++ b/android-project/app/proguard-rules.pro
@@ -0,0 +1,77 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in [sdk]/tools/proguard/proguard-android.txt
+# You can edit the include path and order by changing the proguardFiles
+# directive in build.gradle.
+#
+# For more details, see
+#   https://developer.android.com/build/shrink-code
+
+# Add any project specific keep options here:
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}
+
+-keep,includedescriptorclasses,allowoptimization class org.libsdl.app.SDLActivity {
+    java.lang.String nativeGetHint(java.lang.String); # Java-side doesn't use this, so it gets minified, but C-side still tries to register it
+    java.lang.String clipboardGetText();
+    boolean clipboardHasText();
+    void clipboardSetText(java.lang.String);
+    int createCustomCursor(int[], int, int, int, int);
+    void destroyCustomCursor(int);
+    android.app.Activity getContext();
+    boolean getManifestEnvironmentVariables();
+    android.view.Surface getNativeSurface();
+    void initTouch();
+    boolean isAndroidTV();
+    boolean isChromebook();
+    boolean isDeXMode();
+    boolean isTablet();
+    void manualBackButton();
+    int messageboxShowMessageBox(int, java.lang.String, java.lang.String, int[], int[], java.lang.String[], int[]);
+    void minimizeWindow();
+    boolean openURL(java.lang.String);
+    void requestPermission(java.lang.String, int);
+    boolean showToast(java.lang.String, int, int, int, int);
+    boolean sendMessage(int, int);
+    boolean setActivityTitle(java.lang.String);
+    boolean setCustomCursor(int);
+    void setOrientation(int, int, boolean, java.lang.String);
+    boolean setRelativeMouseEnabled(boolean);
+    boolean setSystemCursor(int);
+    void setWindowStyle(boolean);
+    boolean shouldMinimizeOnFocusLoss();
+    boolean showTextInput(int, int, int, int, int);
+    boolean supportsRelativeMouse();
+    int openFileDescriptor(java.lang.String, java.lang.String);
+    boolean showFileDialog(java.lang.String[], boolean, boolean, int);
+    java.lang.String getPreferredLocales();
+    java.lang.String formatLocale(java.util.Locale);
+}
+
+-keep,includedescriptorclasses,allowoptimization class org.libsdl.app.HIDDeviceManager {
+    void closeDevice(int);
+    boolean initialize(boolean, boolean);
+    boolean openDevice(int);
+    boolean readReport(int, byte[], boolean);
+    int writeReport(int, byte[], boolean);
+}
+
+-keep,includedescriptorclasses,allowoptimization class org.libsdl.app.SDLAudioManager {
+    void registerAudioDeviceCallback();
+    void unregisterAudioDeviceCallback();
+    void audioSetThreadPriority(boolean, int);
+}
+
+-keep,includedescriptorclasses,allowoptimization class org.libsdl.app.SDLControllerManager {
+    void pollInputDevices();
+    void joystickSetLED(int, int, int, int);
+    void pollHapticDevices();
+    void hapticRun(int, float, int);
+    void hapticRumble(int, float, float, int);
+    void hapticStop(int);
+}
diff --git a/android-project/app/src/main/AndroidManifest.xml b/android-project/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..ab43f76a
--- /dev/null
+++ b/android-project/app/src/main/AndroidManifest.xml
@@ -0,0 +1,108 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    android:versionCode="1"
+    android:versionName="1.0"
+    android:installLocation="auto">
+
+    <!-- OpenGL ES 2.0 -->
+    <uses-feature android:glEsVersion="0x00020000" />
+
+    <!-- Touchscreen support -->
+    <uses-feature
+        android:name="android.hardware.touchscreen"
+        android:required="false" />
+
+    <!-- Game controller support -->
+    <uses-feature
+        android:name="android.hardware.bluetooth"
+        android:required="false" />
+    <uses-feature
+        android:name="android.hardware.gamepad"
+        android:required="false" />
+    <uses-feature
+        android:name="android.hardware.usb.host"
+        android:required="false" />
+
+    <!-- External mouse input events -->
+    <uses-feature
+        android:name="android.hardware.type.pc"
+        android:required="false" />
+
+    <!-- Audio recording support -->
+    <!-- if you want to record audio, uncomment this. -->
+    <!-- <uses-permission android:name="android.permission.RECORD_AUDIO" /> -->
+    <!-- <uses-feature
+        android:name="android.hardware.microphone"
+        android:required="false" /> -->
+
+    <!-- Camera support -->
+    <!-- if you want to record video, uncomment this. -->
+    <!--
+    <uses-permission android:name="android.permission.CAMERA" />
+    <uses-feature android:name="android.hardware.camera" />
+    -->
+
+    <!-- Allow downloading to the external storage on Android 5.1 and older -->
+    <!-- <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="22" /> -->
+
+    <!-- Allow access to Bluetooth devices -->
+    <!-- Currently this is just for Steam Controller support and requires setting SDL_HINT_JOYSTICK_HIDAPI_STEAM -->
+    <!-- <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> -->
+    <!-- <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> -->
+
+    <!-- Allow access to the vibrator -->
+    <uses-permission android:name="android.permission.VIBRATE" />
+
+    <!-- Allow access to Internet -->
+    <!-- if you want to connect to the network or internet, uncomment this. -->
+    <!--
+    <uses-permission android:name="android.permission.INTERNET" />
+    -->
+
+    <!-- Create a Java class extending SDLActivity and place it in a
+         directory under app/src/main/java matching the package, e.g. app/src/main/java/com/gamemaker/game/MyGame.java
+
+         then replace "SDLActivity" with the name of your class (e.g. "MyGame")
+         in the XML below.
+
+         An example Java class can be found in README-android.md
+    -->
+    <application android:label="@string/app_name"
+        android:icon="@mipmap/ic_launcher"
+        android:allowBackup="true"
+        android:theme="@style/AppTheme"
+        android:enableOnBackInvokedCallback="false"
+        android:hardwareAccelerated="true" >
+
+        <!-- Example of setting SDL hints from AndroidManifest.xml:
+        <meta-data android:name="SDL_ENV.SDL_ANDROID_TRAP_BACK_BUTTON" android:value="0"/>
+         -->
+
+        <activity android:name="SDLActivity"
+            android:label="@string/app_name"
+            android:alwaysRetainTaskState="true"
+            android:launchMode="singleInstance"
+            android:configChanges="layoutDirection|locale|grammaticalGender|fontScale|fontWeightAdjustment|orientation|uiMode|screenLayout|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation"
+            android:preferMinimalPostProcessing="true"
+            android:exported="true"
+            >
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+            <!-- Let Android know that we can handle some USB devices and should receive this event -->
+            <intent-filter>
+                <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
+            </intent-filter>
+            <!-- Drop file event -->
+            <!--
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <data android:mimeType="*/*" />
+            </intent-filter>
+            -->
+        </activity>
+    </application>
+
+</manifest>
diff --git a/android-project/app/src/main/java/org/libsdl/app/HIDDevice.java b/android-project/app/src/main/java/org/libsdl/app/HIDDevice.java
new file mode 100644
index 00000000..f9609532
--- /dev/null
+++ b/android-project/app/src/main/java/org/libsdl/app/HIDDevice.java
@@ -0,0 +1,21 @@
+package org.libsdl.app;
+
+import android.hardware.usb.UsbDevice;
+
+interface HIDDevice
+{
+    public int getId();
+    public int getVendorId();
+    public int getProductId();
+    public String getSerialNumber();
+    public int getVersion();
+    public String getManufacturerName();
+    public String getProductName();
+    public UsbDevice getDevice();
+    public boolean open();
+    public int writeReport(byte[] report, boolean feature);
+    public boolean readReport(byte[] report, boolean feature);
+    public void setFrozen(boolean frozen);
+    public void close();
+    public void shutdown();
+}
diff --git a/android-project/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java
new file mode 100644
index 00000000..bf1ca214
--- /dev/null
+++ b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java
@@ -0,0 +1,655 @@
+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 final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3");
+    static final UUID inputCharacteristic = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3");
+    static 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);
+        }
+    }
+
+    HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) {
+        mManager = manager;
+        mDevice = device;
+        mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier());
+        mIsRegistered = false;
+        mIsChromebook = SDLActivity.isChromebook();
+        mOperations = new LinkedList<GattOperation>();
+        mHandler = new Handler(Looper.getMainLooper());
+
+        mGatt = connectGatt();
+        // final HIDDeviceBLESteamController finalThis = this;
+        // mHandler.postDelayed(new Runnable() {
+        //     @Override
+        //     void run() {
+        //         finalThis.checkConnectionForChromebookIssue();
+        //     }
+        // }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL);
+    }
+
+    String getIdentifier() {
+        return String.format("SteamController.%s", mDevice.getAddress());
+    }
+
+    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 /* Android 6.0 (M) */) {
+            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);
+    }
+
+    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

(Patch may be truncated, please check the link at the top of this post.)