SDL: Ensure we release exclusive USB access to controllers when backgrounded. #15694 (a97d8)

From a97d8b3848be7acb1e10442531a8f1db0aec0622 Mon Sep 17 00:00:00 2001
From: Rachel Blackman <[EMAIL REDACTED]>
Date: Tue, 26 May 2026 17:40:52 -0700
Subject: [PATCH] Ensure we release exclusive USB access to controllers when
 backgrounded. #15694

(cherry picked from commit ac177763aadd4c84bf5b96ea93bb697b4fc85aa0)
---
 .../java/org/libsdl/app/HIDDeviceUSB.java     | 38 ++++++++++++++++++-
 .../main/java/org/libsdl/app/SDLActivity.java | 14 ++++++-
 2 files changed, 48 insertions(+), 4 deletions(-)

diff --git a/android-project/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java
index 8ec1cd1bc825c..895463973341a 100644
--- a/android-project/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java
+++ b/android-project/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java
@@ -21,6 +21,7 @@ class HIDDeviceUSB implements HIDDevice {
     protected InputThread mInputThread;
     protected boolean mRunning;
     protected boolean mFrozen;
+    protected boolean mClaimed;
 
     public HIDDeviceUSB(HIDDeviceManager manager, UsbDevice usbDevice, int interface_index) {
         mManager = manager;
@@ -29,6 +30,7 @@ public HIDDeviceUSB(HIDDeviceManager manager, UsbDevice usbDevice, int interface
         mInterface = mDevice.getInterface(mInterfaceIndex).getId();
         mDeviceId = manager.getDeviceIDForIdentifier(getIdentifier());
         mRunning = false;
+        mClaimed = false;
     }
 
     String getIdentifier() {
@@ -114,6 +116,7 @@ public boolean open() {
             close();
             return false;
         }
+        mClaimed = true;
 
         // Find the endpoints
         for (int j = 0; j < iface.getEndpointCount(); j++) {
@@ -137,6 +140,7 @@ public boolean open() {
         // back to the Android system gamepad functionality (and lose our paddles et al).
         if (mInputEndpoint == null) {
             Log.w(TAG, "Missing required endpoint on USB device " + getDeviceName());
+            mConnection.releaseInterface(iface);
             close();
             return false;
         }
@@ -156,6 +160,11 @@ public int writeReport(byte[] report, boolean feature) {
             return -1;
         }
 
+        if (!mClaimed) {
+            Log.w(TAG, "writeReport() called but some other process currently owns the USB device");
+            return -1;
+        }
+
         if (feature) {
             int res = -1;
             int offset = 0;
@@ -212,6 +221,12 @@ public boolean readReport(byte[] report, boolean feature) {
             Log.w(TAG, "readReport() called with no device connection");
             return false;
         }
+        if (!mClaimed) {
+            if (feature) {
+                return false;
+            }
+            return true;            
+        }
 
         if (report_number == 0x0) {
             /* Offset the return buffer by 1, so that the report ID
@@ -265,10 +280,13 @@ public void close() {
             mInputThread = null;
         }
         if (mConnection != null) {
-            UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
-            mConnection.releaseInterface(iface);
+            if (mClaimed) {
+                UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
+                mConnection.releaseInterface(iface);                
+            }
             mConnection.close();
             mConnection = null;
+            mClaimed = false;
         }
     }
 
@@ -281,6 +299,22 @@ public void shutdown() {
     @Override
     public void setFrozen(boolean frozen) {
         mFrozen = frozen;
+
+        /* If we have a valid device connection and the claim state doesn't match what we want, try to correct that. */
+        if (mConnection != null && mClaimed == mFrozen) {
+            UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
+            if (frozen) {
+                mClaimed = !mConnection.releaseInterface(iface);
+                if (mClaimed) {
+                    Log.e(TAG, "Tried to release claim on USB device, but failed!");
+                }
+            } else {
+                mClaimed = mConnection.claimInterface(iface, true);
+                if (!mClaimed) {
+                    Log.e(TAG, "Tried to regain claim on USB device, but failed!");
+                }
+            }
+        }
     }
 
     protected class InputThread extends Thread {
diff --git a/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java b/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java
index be775dba3a6ad..202cf20948c6a 100644
--- a/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java
+++ b/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java
@@ -530,7 +530,8 @@ protected void onPause() {
 
         if (mHIDDeviceManager != null) {
             mHIDDeviceManager.setFrozen(true);
-        }
+        }        
+
         if (!mHasMultiWindow) {
             pauseNativeThread();
         }
@@ -543,7 +544,8 @@ protected void onResume() {
 
         if (mHIDDeviceManager != null) {
             mHIDDeviceManager.setFrozen(false);
-        }
+        }        
+
         if (!mHasMultiWindow) {
             resumeNativeThread();
         }
@@ -616,6 +618,14 @@ public void onWindowFocusChanged(boolean hasFocus) {
         super.onWindowFocusChanged(hasFocus);
         Log.v(TAG, "onWindowFocusChanged(): " + hasFocus);
 
+        // If we are gaining focus, we can always try to restore our USB devices. If we are losing focus,
+        // only try to relinquish them if we don't have background events allowed (for multi-window Android setups).
+        if (hasFocus || !SDLActivity.nativeGetHintBoolean("SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS", false)) {
+            if (mHIDDeviceManager != null) {
+                mHIDDeviceManager.setFrozen(!hasFocus);
+            }            
+        }
+
         if (SDLActivity.mBrokenLibraries) {
            return;
         }