SDL: Add support for new Steam Controller on Android (#15044)

From 46e553a44c145abe4f0453c6acd6d125a28eb7e0 Mon Sep 17 00:00:00 2001
From: Rachel Blackman <[EMAIL REDACTED]>
Date: Thu, 12 Feb 2026 17:39:15 -0800
Subject: [PATCH] Add support for new Steam Controller on Android (#15044)

---
 .../app/HIDDeviceBLESteamController.java      | 128 +++++++++++++++---
 .../java/org/libsdl/app/HIDDeviceManager.java |   8 +-
 src/hidapi/android/hid.cpp                    |  15 +-
 3 files changed, 131 insertions(+), 20 deletions(-)

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
index bf1ca2149defa..97b4c127f43f7 100644
--- a/android-project/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java
+++ b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java
@@ -19,9 +19,13 @@
 
 import java.lang.Runnable;
 import java.util.Arrays;
+import java.util.HashMap;
 import java.util.LinkedList;
 import java.util.UUID;
 
+import java.util.regex.Pattern;
+import java.util.regex.Matcher;
+
 class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice {
 
     private static final String TAG = "hidapi";
@@ -37,6 +41,11 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe
     private LinkedList<GattOperation> mOperations;
     GattOperation mCurrentOperation = null;
     private Handler mHandler;
+    private int mProductId = -1;
+
+    private static final int D0G_BLE2_PID = 0x1106;
+    private static final int TRITON_BLE_PID = 0x1303;
+
 
     private static final int TRANSPORT_AUTO = 0;
     private static final int TRANSPORT_BREDR = 1;
@@ -45,10 +54,13 @@ class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDe
     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 inputCharacteristicD0G = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3");
+    static final UUID inputCharacteristicTriton = UUID.fromString("100F6C7A-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 };
 
+    private HashMap<Integer, BluetoothGattCharacteristic> mOutputReportChars = new HashMap<Integer, BluetoothGattCharacteristic>();
+
     static class GattOperation {
         private enum Operation {
             CHR_READ,
@@ -314,8 +326,38 @@ private boolean probeService(HIDDeviceBLESteamController controller) {
                 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");
+                    boolean bShouldStartNotifications = false;
+
+                    if (chr.getUuid().equals(inputCharacteristicTriton)) {
+                        Log.v(TAG, "Found Triton input characteristic");
+                        mProductId = TRITON_BLE_PID;
+                        bShouldStartNotifications = true;
+                    } else if (chr.getUuid().equals(inputCharacteristicD0G)) {
+                        Log.v(TAG, "Found D0G input characteristic");
+                        mProductId = D0G_BLE2_PID;
+                        bShouldStartNotifications = true;
+                    } else {
+                        Pattern reportPattern = Pattern.compile("100F6C([0-9A-Z]{2})", Pattern.CASE_INSENSITIVE);
+                        Matcher matcher = reportPattern.matcher(chr.getUuid().toString());
+
+                        if (matcher.find()) {
+                            try {
+                                int reportId = Integer.parseInt(matcher.group(1), 16);
+
+                                reportId -= 0x35;
+                                if (reportId >= 0x80) {
+                                    // This is a Triton output report characteristic that we need to care about.
+                                    Log.v(TAG, "Found Triton output report 0x" + Integer.toString(reportId, 16));
+                                    mOutputReportChars.put(reportId, chr);
+                                }
+                            }
+                            catch (NumberFormatException nfe) {
+                                Log.w(TAG, "Could not parse report characteristic " + chr.getUuid().toString() + ": " + nfe.toString());
+                            }
+                        }
+                    }
+
+                    if (bShouldStartNotifications) {
                         // Start notifications
                         BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
                         if (cccd != null) {
@@ -448,8 +490,16 @@ public void onServicesDiscovered(BluetoothGatt gatt, int status) {
                 mIsConnected = false;
                 gatt.disconnect();
                 mGatt = connectGatt(false);
-            }
-            else {
+            } else {
+                if (getProductId() == TRITON_BLE_PID) {
+                    // Android will not properly play well with Data Length Extensions without manually requesting a large MTU,
+                    // and Triton controllers require DLE support.
+                    //
+                    // 517 is basically a "magic number" as far as Android's bluetooth code is concerned, so do not change
+                    // this value. It is functionally "please enable data length extensions" on some Android builds.
+                    mGatt.requestMtu(517);
+                }
+
                 probeService(this);
             }
         }
@@ -487,7 +537,7 @@ public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteris
     // 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) {
+        if (characteristic.getUuid().equals(getInputCharacteristic()) && !mFrozen) {
             mManager.HIDDeviceInputReport(getId(), characteristic.getValue());
         }
     }
@@ -502,13 +552,21 @@ public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descri
         BluetoothGattCharacteristic chr = descriptor.getCharacteristic();
         //Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid());
 
-        if (chr.getUuid().equals(inputCharacteristic)) {
+        if (chr.getUuid().equals(getInputCharacteristic())) {
             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);
+                if (getProductId() == TRITON_BLE_PID) {
+                    // For Triton we just mark things registered.
+                    Log.v(TAG, "Registering Triton Steam Controller with ID: " + getId());
+                    mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true);
+                    setRegistered();
+                } else {
+                    // For the original controller, we need to manually enter Valve mode.
+                    Log.v(TAG, "Writing report characteristic to enter valve mode");
+                    reportChr.setValue(enterValveMode);
+                    gatt.writeCharacteristic(reportChr);
+                }
             }
         }
 
@@ -548,9 +606,28 @@ public int getVendorId() {
 
     @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;
+        if (mProductId > 0) {
+            // We've already set a product ID.
+            return mProductId;
+        }
+
+        if (mDevice.getName().startsWith("Steam Ctrl")) {
+            // We're a newer Triton device
+            mProductId = TRITON_BLE_PID;
+        } else {
+            // We're an OG Steam Controller
+            mProductId = D0G_BLE2_PID;
+        }
+
+        return mProductId;
+    }
+
+    private UUID getInputCharacteristic() {
+        if (getProductId() == TRITON_BLE_PID) {
+            return inputCharacteristicTriton;
+        } else {
+            return inputCharacteristicD0G;
+        }
     }
 
     @Override
@@ -601,10 +678,29 @@ public int writeReport(byte[] report, boolean feature) {
             writeCharacteristic(reportCharacteristic, actual_report);
             return report.length;
         } else {
-            //Log.v(TAG, "writeOutputReport " + HexDump.dumpHexString(report));
-            writeCharacteristic(reportCharacteristic, report);
-            return report.length;
+            // If we're an original-recipe Steam Controller we just write to the characteristic directly.
+            if (getProductId() == D0G_BLE2_PID) {
+                //Log.v(TAG, "writeOutputReport " + HexDump.dumpHexString(report));
+                writeCharacteristic(reportCharacteristic, report);
+                return report.length;                
+            }
+
+            // If we're a Triton, we need to find the correct report characteristic.
+            if (report.length > 0) {
+                int reportId = report[0];
+                BluetoothGattCharacteristic targetedReportCharacteristic = mOutputReportChars.get(reportId);
+                if (targetedReportCharacteristic != null) {
+                    byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1);
+                    //Log.v(TAG, "writeOutputReport 0x" + Integer.toString(reportId, 16) + " " + HexDump.dumpHexString(report));
+                    writeCharacteristic(targetedReportCharacteristic.getUuid(), actual_report);
+                    return report.length;
+                } else {
+                    Log.w(TAG, "Got report write request for unknown report type 0x" + Integer.toString(reportId, 16));
+                }
+            }
         }
+
+        return -1;
     }
 
     @Override
diff --git a/android-project/app/src/main/java/org/libsdl/app/HIDDeviceManager.java b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceManager.java
index b91a8211b1bb8..6476d4f9c49ff 100644
--- a/android-project/app/src/main/java/org/libsdl/app/HIDDeviceManager.java
+++ b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceManager.java
@@ -528,7 +528,13 @@ boolean isSteamController(BluetoothDevice bluetoothDevice) {
             return false;
         }
 
-        return bluetoothDevice.getName().equals("SteamController") && ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) != 0);
+        // Steam Controllers will always support Bluetooth Low Energy
+        if ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) == 0) {
+            return false;
+        }
+
+        // Match on the name either the original Steam Controller or the new second-generation one advertise with.
+        return bluetoothDevice.getName().equals("SteamController") || bluetoothDevice.getName().startsWith("Steam Ctrl");
     }
 
     private void close() {
diff --git a/src/hidapi/android/hid.cpp b/src/hidapi/android/hid.cpp
index 934912962a78a..e1aa7e13d72f7 100644
--- a/src/hidapi/android/hid.cpp
+++ b/src/hidapi/android/hid.cpp
@@ -91,6 +91,9 @@ extern "C" {
 typedef uint32_t uint32;
 typedef uint64_t uint64;
 
+#define D0G_BLE2_PID        0x1106
+#define TRITON_BLE_PID      0x1303
+
 
 struct hid_device_
 {
@@ -438,8 +441,7 @@ class CHIDDevice
 
 		// The Bluetooth Steam Controller needs special handling
 		const int VALVE_USB_VID	= 0x28DE;
-		const int D0G_BLE2_PID = 0x1106;
-		if ( pInfo->vendor_id == VALVE_USB_VID && pInfo->product_id == D0G_BLE2_PID )
+		if ( pInfo->vendor_id == VALVE_USB_VID && ( pInfo->product_id == D0G_BLE2_PID || pInfo->product_id == TRITON_BLE_PID ) )
 		{
 			m_bIsBLESteamController = true;
 		}
@@ -604,7 +606,14 @@ class CHIDDevice
 		size_t nDataLen = buffer.size() > length ? length : buffer.size();
 		if ( m_bIsBLESteamController )
 		{
-			data[0] = 0x03;
+			if ( m_pInfo->product_id == TRITON_BLE_PID )
+			{
+				data[0] = 0x45;
+			}
+			else
+			{
+				data[0] = 0x03;
+			}
 			SDL_memcpy( data + 1, buffer.data(), nDataLen );
 			++nDataLen;
 		}