From 516c1314d2e08a81aa707566265597d82591e22c Mon Sep 17 00:00:00 2001
From: JosJuice <josjuice@gmail.com>
Date: Sun, 16 Mar 2025 11:05:06 +0100
Subject: [PATCH] Android: Don't use separate thread for MotionAlertDialog

This is an Android continuation of bc95c00. We now call
InputDetector::Update immediately after receiving an input event from
Android instead of periodically calling it in a sleep loop. This
improves detection of very short inputs, which are especially likely to
occur for volume buttons on phones (or at least on my phone) if you
don't intentionally keep them held down.
---
 .../features/input/model/InputDetector.kt     | 68 ++++++++++++++++
 .../features/input/model/MappingCommon.kt     | 16 ----
 .../features/input/ui/MotionAlertDialog.kt    | 43 +++++++---
 Source/Android/jni/AndroidCommon/IDCache.cpp  | 22 +++++-
 Source/Android/jni/AndroidCommon/IDCache.h    |  3 +
 Source/Android/jni/CMakeLists.txt             |  1 +
 Source/Android/jni/Input/InputDetector.cpp    | 79 +++++++++++++++++++
 Source/Android/jni/Input/MappingCommon.cpp    | 33 --------
 .../ControllerInterface/CoreDevice.cpp        | 23 ------
 .../ControllerInterface/CoreDevice.h          | 12 +--
 10 files changed, 210 insertions(+), 90 deletions(-)
 create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/InputDetector.kt
 create mode 100644 Source/Android/jni/Input/InputDetector.cpp

diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/InputDetector.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/InputDetector.kt
new file mode 100644
index 0000000000..e180814a58
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/InputDetector.kt
@@ -0,0 +1,68 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.input.model
+
+import androidx.annotation.Keep
+
+/**
+ * Waits for the user to press inputs, and reports which inputs were pressed.
+ *
+ * The caller is responsible for forwarding input events from Android to ControllerInterface
+ * and then calling [update].
+ */
+class InputDetector {
+    @Keep
+    private val pointer: Long
+
+    constructor() {
+        pointer = createNew()
+    }
+
+    @Keep
+    private constructor(pointer: Long) {
+        this.pointer = pointer
+    }
+
+    external fun finalize()
+
+    private external fun createNew(): Long
+
+    /**
+     * Starts a detection session.
+     *
+     * @param defaultDevice The device to detect inputs from.
+     * @param allDevices Whether to also detect inputs from devices other than the specified one.
+     */
+    external fun start(defaultDevice: String, allDevices: Boolean)
+
+    /**
+     * Checks what inputs are currently pressed and updates internal state.
+     *
+     * During a detection session, this should be called after each call to
+     * [ControllerInterface.dispatchKeyEvent] and [ControllerInterface#dispatchGenericMotionEvent].
+     */
+    external fun update()
+
+    /**
+     * Returns whether a detection session has finished.
+     *
+     * A detection session can end once the user has pressed and released an input or once a timeout
+     * has been reached.
+     */
+    external fun isComplete(): Boolean
+
+    /**
+     * Returns the result of a detection session.
+     *
+     * The result of each detection session is only returned once. If this method is called more
+     * than once without starting a new detection session, the second call onwards will return an
+     * empty string.
+     *
+     * @param defaultDevice The device to detect inputs from. Should normally be the same as the one
+     *                      passed to [start].
+     *
+     * @return The input(s) pressed by the user in the form of an InputCommon expression,
+     *         or an empty string if there were no inputs.
+     */
+    external fun takeResults(defaultDevice: String): String
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/MappingCommon.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/MappingCommon.kt
index 32775a7ebc..622f4f5515 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/MappingCommon.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/MappingCommon.kt
@@ -2,23 +2,7 @@
 
 package org.dolphinemu.dolphinemu.features.input.model
 
-import org.dolphinemu.dolphinemu.features.input.model.controlleremu.EmulatedController
-
 object MappingCommon {
-    /**
-     * Waits until the user presses one or more inputs or until a timeout,
-     * then returns the pressed inputs.
-     *
-     * When this is being called, a separate thread must be calling ControllerInterface's
-     * dispatchKeyEvent and dispatchGenericMotionEvent, otherwise no inputs will be registered.
-     *
-     * @param controller The device to detect inputs from.
-     * @param allDevices Whether to also detect inputs from devices other than the specified one.
-     * @return The input(s) pressed by the user in the form of an InputCommon expression,
-     * or an empty string if there were no inputs.
-     */
-    external fun detectInput(controller: EmulatedController, allDevices: Boolean): String
-
     external fun getExpressionForControl(
         control: String,
         device: String,
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/MotionAlertDialog.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/MotionAlertDialog.kt
index ff4a278f4c..c3b471899e 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/MotionAlertDialog.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/MotionAlertDialog.kt
@@ -3,12 +3,14 @@
 package org.dolphinemu.dolphinemu.features.input.ui
 
 import android.app.Activity
+import android.os.Looper
+import android.os.Handler
 import android.view.InputDevice
 import android.view.KeyEvent
 import android.view.MotionEvent
 import androidx.appcompat.app.AlertDialog
 import org.dolphinemu.dolphinemu.features.input.model.ControllerInterface
-import org.dolphinemu.dolphinemu.features.input.model.MappingCommon
+import org.dolphinemu.dolphinemu.features.input.model.InputDetector
 import org.dolphinemu.dolphinemu.features.input.model.view.InputMappingControlSetting
 
 /**
@@ -24,21 +26,15 @@ class MotionAlertDialog(
     private val setting: InputMappingControlSetting,
     private val allDevices: Boolean
 ) : AlertDialog(activity) {
+    private val handler = Handler(Looper.getMainLooper())
+    private val inputDetector: InputDetector = InputDetector()
     private var running = false
 
     override fun onStart() {
         super.onStart()
-
         running = true
-        Thread {
-            val result = MappingCommon.detectInput(setting.controller, allDevices)
-            activity.runOnUiThread {
-                if (running) {
-                    setting.value = result
-                    dismiss()
-                }
-            }
-        }.start()
+        inputDetector.start(setting.controller.getDefaultDevice(), allDevices)
+        periodicUpdate()
     }
 
     override fun onStop() {
@@ -48,9 +44,11 @@ class MotionAlertDialog(
 
     override fun dispatchKeyEvent(event: KeyEvent): Boolean {
         ControllerInterface.dispatchKeyEvent(event)
+        updateInputDetector()
         if (event.keyCode == KeyEvent.KEYCODE_BACK && event.isLongPress) {
             // Special case: Let the user cancel by long-pressing Back (intended for non-touch devices)
             setting.clearValue()
+            running = false
             dismiss()
         }
         return true
@@ -63,6 +61,29 @@ class MotionAlertDialog(
         }
 
         ControllerInterface.dispatchGenericMotionEvent(event)
+        updateInputDetector()
         return true
     }
+
+    private fun updateInputDetector() {
+        if (running) {
+            if (inputDetector.isComplete()) {
+                setting.value = inputDetector.takeResults(setting.controller.getDefaultDevice())
+                running = false
+
+                // Quirk: If this method has been called from onStart, calling dismiss directly
+                // doesn't seem to do anything. As a workaround, post a call to dismiss instead.
+                handler.post(this::dismiss)
+            } else {
+                inputDetector.update()
+            }
+        }
+    }
+
+    private fun periodicUpdate() {
+        updateInputDetector()
+        if (running) {
+            handler.postDelayed(this::periodicUpdate, 10)
+        }
+    }
 }
diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp
index 33a679bf55..ed382745c0 100644
--- a/Source/Android/jni/AndroidCommon/IDCache.cpp
+++ b/Source/Android/jni/AndroidCommon/IDCache.cpp
@@ -113,6 +113,9 @@ static jclass s_core_device_control_class;
 static jfieldID s_core_device_control_pointer;
 static jmethodID s_core_device_control_constructor;
 
+static jclass s_input_detector_class;
+static jfieldID s_input_detector_pointer;
+
 static jmethodID s_runnable_run;
 
 namespace IDCache
@@ -525,6 +528,16 @@ jmethodID GetCoreDeviceControlConstructor()
   return s_core_device_control_constructor;
 }
 
+jclass GetInputDetectorClass()
+{
+  return s_input_detector_class;
+}
+
+jfieldID GetInputDetectorPointer()
+{
+  return s_input_detector_pointer;
+}
+
 jmethodID GetRunnableRun()
 {
   return s_runnable_run;
@@ -746,6 +759,12 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved)
                        "(Lorg/dolphinemu/dolphinemu/features/input/model/CoreDevice;J)V");
   env->DeleteLocalRef(core_device_control_class);
 
+  const jclass input_detector_class =
+      env->FindClass("org/dolphinemu/dolphinemu/features/input/model/InputDetector");
+  s_input_detector_class = reinterpret_cast<jclass>(env->NewGlobalRef(input_detector_class));
+  s_input_detector_pointer = env->GetFieldID(input_detector_class, "pointer", "J");
+  env->DeleteLocalRef(input_detector_class);
+
   const jclass runnable_class = env->FindClass("java/lang/Runnable");
   s_runnable_run = env->GetMethodID(runnable_class, "run", "()V");
   env->DeleteLocalRef(runnable_class);
@@ -779,10 +798,11 @@ JNIEXPORT void JNI_OnUnload(JavaVM* vm, void* reserved)
   env->DeleteGlobalRef(s_control_class);
   env->DeleteGlobalRef(s_control_group_class);
   env->DeleteGlobalRef(s_control_reference_class);
+  env->DeleteGlobalRef(s_control_group_container_class);
   env->DeleteGlobalRef(s_emulated_controller_class);
   env->DeleteGlobalRef(s_numeric_setting_class);
   env->DeleteGlobalRef(s_core_device_class);
   env->DeleteGlobalRef(s_core_device_control_class);
-  env->DeleteGlobalRef(s_control_group_container_class);
+  env->DeleteGlobalRef(s_input_detector_class);
 }
 }
diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h
index d28b493a5b..0b01d14b42 100644
--- a/Source/Android/jni/AndroidCommon/IDCache.h
+++ b/Source/Android/jni/AndroidCommon/IDCache.h
@@ -112,6 +112,9 @@ jclass GetCoreDeviceControlClass();
 jfieldID GetCoreDeviceControlPointer();
 jmethodID GetCoreDeviceControlConstructor();
 
+jclass GetInputDetectorClass();
+jfieldID GetInputDetectorPointer();
+
 jmethodID GetRunnableRun();
 
 }  // namespace IDCache
diff --git a/Source/Android/jni/CMakeLists.txt b/Source/Android/jni/CMakeLists.txt
index 7ddae94c07..be200affa9 100644
--- a/Source/Android/jni/CMakeLists.txt
+++ b/Source/Android/jni/CMakeLists.txt
@@ -26,6 +26,7 @@ add_library(main SHARED
   Input/CoreDevice.h
   Input/EmulatedController.cpp
   Input/EmulatedController.h
+  Input/InputDetector.cpp
   Input/InputOverrider.cpp
   Input/MappingCommon.cpp
   Input/NumericSetting.cpp
diff --git a/Source/Android/jni/Input/InputDetector.cpp b/Source/Android/jni/Input/InputDetector.cpp
new file mode 100644
index 0000000000..03ad75ba1e
--- /dev/null
+++ b/Source/Android/jni/Input/InputDetector.cpp
@@ -0,0 +1,79 @@
+// Copyright 2025 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include <jni.h>
+
+#include "InputCommon/ControllerInterface/ControllerInterface.h"
+#include "InputCommon/ControllerInterface/CoreDevice.h"
+#include "InputCommon/ControllerInterface/MappingCommon.h"
+#include "jni/AndroidCommon/AndroidCommon.h"
+#include "jni/AndroidCommon/IDCache.h"
+
+namespace
+{
+constexpr auto INPUT_DETECT_INITIAL_TIME = std::chrono::seconds(3);
+constexpr auto INPUT_DETECT_CONFIRMATION_TIME = std::chrono::milliseconds(0);
+constexpr auto INPUT_DETECT_MAXIMUM_TIME = std::chrono::seconds(5);
+}  // namespace
+
+static ciface::Core::InputDetector* GetPointer(JNIEnv* env, jobject obj)
+{
+  return reinterpret_cast<ciface::Core::InputDetector*>(
+      env->GetLongField(obj, IDCache::GetInputDetectorPointer()));
+}
+
+extern "C" {
+
+JNIEXPORT void JNICALL
+Java_org_dolphinemu_dolphinemu_features_input_model_InputDetector_finalize(JNIEnv* env, jobject obj)
+{
+  delete GetPointer(env, obj);
+}
+
+JNIEXPORT jlong JNICALL
+Java_org_dolphinemu_dolphinemu_features_input_model_InputDetector_createNew(JNIEnv*, jobject)
+{
+  return reinterpret_cast<jlong>(new ciface::Core::InputDetector);
+}
+
+JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_features_input_model_InputDetector_start(
+    JNIEnv* env, jobject obj, jstring j_default_device, jboolean all_devices)
+{
+  std::vector<std::string> device_strings;
+  if (all_devices)
+    device_strings = g_controller_interface.GetAllDeviceStrings();
+  else
+    device_strings = {GetJString(env, j_default_device)};
+
+  GetPointer(env, obj)->Start(g_controller_interface, device_strings);
+}
+
+JNIEXPORT void JNICALL
+Java_org_dolphinemu_dolphinemu_features_input_model_InputDetector_update(JNIEnv* env, jobject obj)
+{
+  GetPointer(env, obj)->Update(INPUT_DETECT_INITIAL_TIME, INPUT_DETECT_CONFIRMATION_TIME,
+                               INPUT_DETECT_MAXIMUM_TIME);
+}
+
+JNIEXPORT jboolean JNICALL
+Java_org_dolphinemu_dolphinemu_features_input_model_InputDetector_isComplete(JNIEnv* env,
+                                                                             jobject obj)
+{
+  return GetPointer(env, obj)->IsComplete();
+}
+
+JNIEXPORT jstring JNICALL
+Java_org_dolphinemu_dolphinemu_features_input_model_InputDetector_takeResults(
+    JNIEnv* env, jobject obj, jstring j_default_device)
+{
+  ciface::Core::DeviceQualifier default_device;
+  default_device.FromString(GetJString(env, j_default_device));
+
+  auto detections = GetPointer(env, obj)->TakeResults();
+
+  ciface::MappingCommon::RemoveSpuriousTriggerCombinations(&detections);
+
+  return ToJString(env, ciface::MappingCommon::BuildExpression(detections, default_device,
+                                                               ciface::MappingCommon::Quote::On));
+}
+}
diff --git a/Source/Android/jni/Input/MappingCommon.cpp b/Source/Android/jni/Input/MappingCommon.cpp
index 1cc55df2ae..2017c892db 100644
--- a/Source/Android/jni/Input/MappingCommon.cpp
+++ b/Source/Android/jni/Input/MappingCommon.cpp
@@ -17,42 +17,9 @@
 #include "InputCommon/ControllerInterface/ControllerInterface.h"
 #include "InputCommon/ControllerInterface/MappingCommon.h"
 #include "jni/AndroidCommon/AndroidCommon.h"
-#include "jni/Input/EmulatedController.h"
-
-namespace
-{
-constexpr auto INPUT_DETECT_INITIAL_TIME = std::chrono::seconds(3);
-constexpr auto INPUT_DETECT_CONFIRMATION_TIME = std::chrono::milliseconds(0);
-constexpr auto INPUT_DETECT_MAXIMUM_TIME = std::chrono::seconds(5);
-}  // namespace
 
 extern "C" {
 
-JNIEXPORT jstring JNICALL
-Java_org_dolphinemu_dolphinemu_features_input_model_MappingCommon_detectInput(
-    JNIEnv* env, jclass, jobject j_emulated_controller, jboolean all_devices)
-{
-  ControllerEmu::EmulatedController* emulated_controller =
-      EmulatedControllerFromJava(env, j_emulated_controller);
-
-  const ciface::Core::DeviceQualifier default_device = emulated_controller->GetDefaultDevice();
-
-  std::vector<std::string> device_strings;
-  if (all_devices)
-    device_strings = g_controller_interface.GetAllDeviceStrings();
-  else
-    device_strings = {default_device.ToString()};
-
-  auto detections =
-      g_controller_interface.DetectInput(device_strings, INPUT_DETECT_INITIAL_TIME,
-                                         INPUT_DETECT_CONFIRMATION_TIME, INPUT_DETECT_MAXIMUM_TIME);
-
-  ciface::MappingCommon::RemoveSpuriousTriggerCombinations(&detections);
-
-  return ToJString(env, ciface::MappingCommon::BuildExpression(detections, default_device,
-                                                               ciface::MappingCommon::Quote::On));
-}
-
 JNIEXPORT jstring JNICALL
 Java_org_dolphinemu_dolphinemu_features_input_model_MappingCommon_getExpressionForControl(
     JNIEnv* env, jclass, jstring j_control, jstring j_device, jstring j_default_device)
diff --git a/Source/Core/InputCommon/ControllerInterface/CoreDevice.cpp b/Source/Core/InputCommon/ControllerInterface/CoreDevice.cpp
index 3a29d4c98f..5a370cadc8 100644
--- a/Source/Core/InputCommon/ControllerInterface/CoreDevice.cpp
+++ b/Source/Core/InputCommon/ControllerInterface/CoreDevice.cpp
@@ -335,29 +335,6 @@ bool DeviceContainer::HasConnectedDevice(const DeviceQualifier& qualifier) const
   return device != nullptr && device->IsValid();
 }
 
-// Wait for inputs on supplied devices.
-// Inputs are only considered if they are first seen in a neutral state.
-// This is useful for crazy flightsticks that have certain buttons that are always held down
-// and also properly handles detection when using "FullAnalogSurface" inputs.
-// Multiple detections are returned until the various timeouts have been reached.
-auto DeviceContainer::DetectInput(const std::vector<std::string>& device_strings,
-                                  std::chrono::milliseconds initial_wait,
-                                  std::chrono::milliseconds confirmation_wait,
-                                  std::chrono::milliseconds maximum_wait) const
-    -> std::vector<InputDetection>
-{
-  InputDetector input_detector;
-  input_detector.Start(*this, device_strings);
-
-  while (!input_detector.IsComplete())
-  {
-    Common::SleepCurrentThread(10);
-    input_detector.Update(initial_wait, confirmation_wait, maximum_wait);
-  }
-
-  return input_detector.TakeResults();
-}
-
 struct InputDetector::Impl
 {
   struct InputState
diff --git a/Source/Core/InputCommon/ControllerInterface/CoreDevice.h b/Source/Core/InputCommon/ControllerInterface/CoreDevice.h
index 54d7b46110..f30dd4f405 100644
--- a/Source/Core/InputCommon/ControllerInterface/CoreDevice.h
+++ b/Source/Core/InputCommon/ControllerInterface/CoreDevice.h
@@ -230,20 +230,20 @@ public:
 
   bool HasConnectedDevice(const DeviceQualifier& qualifier) const;
 
-  std::vector<InputDetection> DetectInput(const std::vector<std::string>& device_strings,
-                                          std::chrono::milliseconds initial_wait,
-                                          std::chrono::milliseconds confirmation_wait,
-                                          std::chrono::milliseconds maximum_wait) const;
-
   std::recursive_mutex& GetDevicesMutex() const { return m_devices_mutex; }
 
 protected:
   // Exclusively needed when reading/writing the "m_devices" array.
-  // Not needed when individually readring/writing a single device ptr.
+  // Not needed when individually reading/writing a single device ptr.
   mutable std::recursive_mutex m_devices_mutex;
   std::vector<std::shared_ptr<Device>> m_devices;
 };
 
+// Wait for inputs on supplied devices.
+// Inputs are only considered if they are first seen in a neutral state.
+// This is useful for wacky flight sticks that have certain buttons that are always held down
+// and also properly handles detection when using "FullAnalogSurface" inputs.
+// Multiple detections are returned until the various timeouts have been reached.
 class InputDetector
 {
 public: