From 9837caed1ab16f89536b31da3d2d0c4e99de6502 Mon Sep 17 00:00:00 2001
From: aalexx-S <6303815+aalexx-S@users.noreply.github.com>
Date: Tue, 3 Nov 2026 00:01:15 -0800
Subject: [PATCH] feat(input): add Hold for Cursor Mode with mouse &
touchscreen simulation
- Add XboxTarget.CURSOR_HOLD ('Hold for Cursor Mode', default L4), MOUSE_LEFT, and MOUSE_RIGHT targets
- Add CursorHoldConfigDialog accessible via the '...' button next to Hold for Cursor Mode:
- Toggle between System Mouse Cursor (uinput SOURCE_MOUSE) and Simulate Finger Touch (Shizuku IInputManager SOURCE_TOUCHSCREEN + floating TouchCursorOverlay reticle)
- Separate Cursor sensitivity (0.1x..5.0x) and Scroll speed (0.1x..5.0x) sliders
- Multi-select Click/Confirm button bindings (default A + RT)
- Hide system mouse cursor immediately on modifier release by destroying/recreating the sidecar uinput mouse device
- Support Right Stick touch-swipe scrolling in Touch Simulation mode, returning the cursor to the scroll start position on release
- Persist settings across NamedProfile and BackupManager
---
app/src/main/AndroidManifest.xml | 3 +
.../android/ipc/IUInputService.aidl | 6 +
app/src/main/cpp/uinput_jni.cpp | 49 ++-
.../java/com/steamcontroller/android/Prefs.kt | 72 ++++-
.../android/backup/BackupManager.kt | 8 +
.../android/input/ButtonMapping.kt | 15 +-
.../android/input/NamedProfile.kt | 17 +
.../android/ui/CalibrationScreen.kt | 2 +-
.../android/ui/MappingScreen.kt | 271 +++++++++++++++-
.../android/ui/TouchCursorOverlay.kt | 178 +++++++++++
.../android/uinput/UInputGamepad.kt | 294 +++++++++++++++++-
.../android/uinput/UInputNative.kt | 1 +
.../android/uinput/UInputService.kt | 72 +++++
13 files changed, 948 insertions(+), 40 deletions(-)
create mode 100644 app/src/main/java/com/steamcontroller/android/ui/TouchCursorOverlay.kt
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c8333dc..0558ee9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -36,6 +36,9 @@
+
+
+
diff --git a/app/src/main/aidl/com/steamcontroller/android/ipc/IUInputService.aidl b/app/src/main/aidl/com/steamcontroller/android/ipc/IUInputService.aidl
index 65ce34a..abe8097 100644
--- a/app/src/main/aidl/com/steamcontroller/android/ipc/IUInputService.aidl
+++ b/app/src/main/aidl/com/steamcontroller/android/ipc/IUInputService.aidl
@@ -23,6 +23,12 @@ interface IUInputService {
// Desktop / mouse-mode frame. `keys` is a MouseTarget bitmask.
void sendMouseFrame(int relX, int relY, int scrollY, int keys);
+ // Connect or disconnect the sidecar uinput mouse device so Android immediately hides the pointer.
+ void setMouseConnected(boolean connected);
+
+ // Inject a finger touch event (ACTION_DOWN=0, ACTION_UP=1, ACTION_MOVE=2) at screen (x, y) via InputManager.
+ void injectTouch(int action, float x, float y);
+
// Returns [strongMagnitude, weakMagnitude] in 0..65535 if a game triggered rumble,
// or null if nothing happened since the last poll.
int[] pollForceFeedback();
diff --git a/app/src/main/cpp/uinput_jni.cpp b/app/src/main/cpp/uinput_jni.cpp
index 227c570..28dcd4e 100644
--- a/app/src/main/cpp/uinput_jni.cpp
+++ b/app/src/main/cpp/uinput_jni.cpp
@@ -74,6 +74,7 @@ static const gamepad_profile* find_profile(int id) {
static int g_fd_gamepad = -1;
static int g_fd_mouse = -1;
static int g_fd_kbd = -1;
+static int g_active_profile_id = 0;
// Cached "last frame" state for delta encoding in sendMouseFrame.
// Reset on every destroy_devices() so a new device starts from a clean slate.
@@ -269,6 +270,7 @@ static void destroy_devices() {
extern "C" JNIEXPORT jboolean JNICALL
Java_com_steamcontroller_android_uinput_UInputNative_createDevice(JNIEnv*, jclass, jint profileId) {
const gamepad_profile* prof = find_profile(profileId);
+ g_active_profile_id = prof->id;
LOGI("createDevice: profile=%d (VID=0x%04X PID=0x%04X name=\"%s\")",
prof->id, prof->vid, prof->pid, prof->name);
@@ -337,11 +339,10 @@ Java_com_steamcontroller_android_uinput_UInputNative_createDevice(JNIEnv*, jclas
LOGI("Virtual gamepad created (profile=%d), fd=%d", prof->id, fd);
g_fd_gamepad = fd;
- // Sidecar mouse + keyboard — non-fatal if either fails (gamepad still works).
- g_fd_mouse = create_mouse_fd(prof->vid, (uint16_t)(prof->pid + 0x100));
- if (g_fd_mouse < 0) {
- LOGE("Sidecar mouse creation failed — trackpad-as-cursor will be unavailable");
- }
+ // Sidecar keyboard — non-fatal if it fails (gamepad still works).
+ // Note: Sidecar mouse (g_fd_mouse) is created on-demand when cursor events occur,
+ // or destroyed via setMouseConnected(false) so Android immediately hides the pointer
+ // when Hold for Cursor Mode is released.
g_fd_kbd = create_keyboard_fd(prof->vid, (uint16_t)(prof->pid + 0x200), /*full_alpha=*/false);
if (g_fd_kbd < 0) {
LOGE("Sidecar keyboard creation failed — key mappings on back paddles will be unavailable");
@@ -485,6 +486,15 @@ Java_com_steamcontroller_android_uinput_UInputNative_sendMouseFrame(
const int mouse_bits = keys & ((1 << 16) | (1 << 17) | (1 << 18));
const int kbd_bits = keys & ((1 << MOUSE_KEY_COUNT) - 1);
+ // Lazily create the sidecar mouse fd if a mouse event is being sent while it's disconnected
+ if (g_fd_mouse < 0 && (relX != 0 || relY != 0 || scrollY != 0 || mouse_bits != 0)) {
+ const gamepad_profile* prof = find_profile(g_active_profile_id);
+ uint16_t mouse_pid = prof->mouse_mode ? prof->pid : (uint16_t)(prof->pid + 0x100);
+ g_fd_mouse = create_mouse_fd(prof->vid, mouse_pid);
+ g_last_mouse_buttons = 0;
+ LOGI("Lazy-created sidecar mouse fd=%d", g_fd_mouse);
+ }
+
// Mouse fd: motion + button edges
if (g_fd_mouse >= 0) {
bool any_event = false;
@@ -516,6 +526,35 @@ Java_com_steamcontroller_android_uinput_UInputNative_sendMouseFrame(
}
}
+extern "C" JNIEXPORT void JNICALL
+Java_com_steamcontroller_android_uinput_UInputNative_setMouseConnected(
+ JNIEnv*, jclass, jboolean connected) {
+ if (connected) {
+ if (g_fd_mouse < 0) {
+ const gamepad_profile* prof = find_profile(g_active_profile_id);
+ uint16_t mouse_pid = prof->mouse_mode ? prof->pid : (uint16_t)(prof->pid + 0x100);
+ g_fd_mouse = create_mouse_fd(prof->vid, mouse_pid);
+ g_last_mouse_buttons = 0;
+ LOGI("setMouseConnected(true) → fd=%d", g_fd_mouse);
+ }
+ } else {
+ if (g_fd_mouse >= 0) {
+ // Release any held mouse buttons before destroying the device
+ if (g_last_mouse_buttons != 0) {
+ write_event(g_fd_mouse, EV_KEY, BTN_LEFT, 0);
+ write_event(g_fd_mouse, EV_KEY, BTN_RIGHT, 0);
+ write_event(g_fd_mouse, EV_KEY, BTN_MIDDLE, 0);
+ write_event(g_fd_mouse, EV_SYN, SYN_REPORT, 0);
+ g_last_mouse_buttons = 0;
+ }
+ ioctl(g_fd_mouse, UI_DEV_DESTROY);
+ close(g_fd_mouse);
+ g_fd_mouse = -1;
+ LOGI("setMouseConnected(false) → destroyed sidecar mouse fd (cursor hidden)");
+ }
+ }
+}
+
extern "C" JNIEXPORT void JNICALL
Java_com_steamcontroller_android_uinput_UInputNative_destroy(JNIEnv*, jclass) {
destroy_devices();
diff --git a/app/src/main/java/com/steamcontroller/android/Prefs.kt b/app/src/main/java/com/steamcontroller/android/Prefs.kt
index 2e0ced6..e8c8878 100644
--- a/app/src/main/java/com/steamcontroller/android/Prefs.kt
+++ b/app/src/main/java/com/steamcontroller/android/Prefs.kt
@@ -51,8 +51,12 @@ object Prefs {
private const val KEY_R_INVERT_Y = "calib_r_invy"
private const val KEY_RUMBLE_INTENSITY = "rumble_intensity" // 0..100
- private const val KEY_MOUSE_SENSITIVITY = "mouse_sensitivity_x10" // 1..30 → 0.1x..3.0x
+ private const val KEY_MOUSE_SENSITIVITY = "mouse_sensitivity_x10" // 1..50 → 0.1x..5.0x
private const val KEY_TRACKPAD_AS_MOUSE = "trackpad_as_mouse_gamepad" // sidecar mouse while in Xbox/PS profiles
+ private const val KEY_CURSOR_HOLD_SENSITIVITY = "cursor_hold_sensitivity_x10" // 1..50 → 0.1x..5.0x
+ private const val KEY_CURSOR_HOLD_SCROLL_SPEED = "cursor_hold_scroll_speed_x10" // 1..50 → 0.1x..5.0x
+ private const val KEY_CURSOR_HOLD_CLICK_MASK = "cursor_hold_click_mask" // bitmask of SteamButton.mask for Left Click in Cursor Hold Mode
+ private const val KEY_CURSOR_HOLD_TOUCH_MODE = "cursor_hold_touch_mode" // true = Touch Simulation, false = Mouse Cursor
private const val KEY_NAMED_PROFILES = "named_profiles_json"
private const val KEY_ACTIVE_NAMED_PROFILE_ID = "active_named_profile_id"
private const val KEY_THEME_MODE = "theme_mode"
@@ -190,12 +194,12 @@ object Prefs {
prefs(context).edit().putInt(KEY_RUMBLE_INTENSITY, percent.coerceIn(0, 100)).apply()
}
- /** Mouse cursor sensitivity multiplier, 0.1x..3.0x. */
+ /** Mouse cursor sensitivity multiplier, 0.1x..5.0x. */
fun getMouseSensitivity(context: Context): Float =
- (prefs(context).getInt(KEY_MOUSE_SENSITIVITY, 10).coerceIn(1, 30)) / 10f
+ (prefs(context).getInt(KEY_MOUSE_SENSITIVITY, 10).coerceIn(1, 50)) / 10f
fun setMouseSensitivity(context: Context, multiplier: Float) {
- val v = (multiplier * 10f).toInt().coerceIn(1, 30)
+ val v = (multiplier * 10f).toInt().coerceIn(1, 50)
prefs(context).edit().putInt(KEY_MOUSE_SENSITIVITY, v).apply()
}
@@ -207,6 +211,54 @@ object Prefs {
prefs(context).edit().putBoolean(KEY_TRACKPAD_AS_MOUSE, enabled).apply()
}
+ // ─── Hold for Cursor Mode settings ───────────────────────────────────────
+ val DEFAULT_CURSOR_HOLD_CLICK_MASK: Int = SteamButton.A.mask or SteamButton.RT.mask
+
+ /** Joystick cursor sensitivity multiplier in Hold for Cursor Mode, 0.1x..5.0x (default 1.0x). */
+ fun getCursorHoldSensitivity(context: Context): Float =
+ (prefs(context).getInt(KEY_CURSOR_HOLD_SENSITIVITY, 10).coerceIn(1, 50)) / 10f
+
+ fun setCursorHoldSensitivity(context: Context, multiplier: Float) {
+ val v = (multiplier * 10f).toInt().coerceIn(1, 50)
+ prefs(context).edit().putInt(KEY_CURSOR_HOLD_SENSITIVITY, v).apply()
+ }
+
+ /** Joystick scroll speed multiplier in Hold for Cursor Mode, 0.1x..5.0x (default 1.0x). */
+ fun getCursorHoldScrollSpeed(context: Context): Float =
+ (prefs(context).getInt(KEY_CURSOR_HOLD_SCROLL_SPEED, 10).coerceIn(1, 50)) / 10f
+
+ fun setCursorHoldScrollSpeed(context: Context, multiplier: Float) {
+ val v = (multiplier * 10f).toInt().coerceIn(1, 50)
+ prefs(context).edit().putInt(KEY_CURSOR_HOLD_SCROLL_SPEED, v).apply()
+ }
+
+ /** Bitmask of SteamButton.mask values that act as Left Click / Confirm while Hold for Cursor Mode is active. */
+ fun getCursorHoldClickMask(context: Context): Int =
+ prefs(context).getInt(KEY_CURSOR_HOLD_CLICK_MASK, DEFAULT_CURSOR_HOLD_CLICK_MASK)
+
+ fun setCursorHoldClickMask(context: Context, mask: Int) {
+ prefs(context).edit().putInt(KEY_CURSOR_HOLD_CLICK_MASK, mask).apply()
+ }
+
+ fun getCursorHoldClickButtons(context: Context): Set {
+ val mask = getCursorHoldClickMask(context)
+ return SteamButton.entries.filterTo( linkedSetOf() ) { (mask and it.mask) != 0 }
+ }
+
+ fun setCursorHoldClickButtons(context: Context, buttons: Set) {
+ var mask = 0
+ buttons.forEach { mask = mask or it.mask }
+ setCursorHoldClickMask(context, mask)
+ }
+
+ /** True when Hold for Cursor Mode simulates finger touch (`SOURCE_TOUCHSCREEN`) instead of hardware mouse (`SOURCE_MOUSE`). */
+ fun getCursorHoldTouchMode(context: Context): Boolean =
+ prefs(context).getBoolean(KEY_CURSOR_HOLD_TOUCH_MODE, false)
+
+ fun setCursorHoldTouchMode(context: Context, touchMode: Boolean) {
+ prefs(context).edit().putBoolean(KEY_CURSOR_HOLD_TOUCH_MODE, touchMode).apply()
+ }
+
// ─── Button mapping ──────────────────────────────────────────────────────
private fun mapKey(source: SteamButton) = "map_${source.name}"
@@ -226,6 +278,10 @@ object Prefs {
fun resetMappings(context: Context) {
val edit = prefs(context).edit()
SteamButton.values().forEach { edit.remove(mapKey(it)) }
+ edit.remove(KEY_CURSOR_HOLD_SENSITIVITY)
+ edit.remove(KEY_CURSOR_HOLD_SCROLL_SPEED)
+ edit.remove(KEY_CURSOR_HOLD_CLICK_MASK)
+ edit.remove(KEY_CURSOR_HOLD_TOUCH_MODE)
edit.apply()
}
@@ -289,6 +345,10 @@ object Prefs {
rumbleIntensity = getRumbleIntensity(context),
mapping = mappingMap,
boundPackages = boundPkgs,
+ cursorHoldSensitivity = getCursorHoldSensitivity(context),
+ cursorHoldScrollSpeed = getCursorHoldScrollSpeed(context),
+ cursorHoldClickMask = getCursorHoldClickMask(context),
+ cursorHoldTouchMode = getCursorHoldTouchMode(context),
)
}
@@ -312,6 +372,10 @@ object Prefs {
setMouseSensitivity(context, profile.mouseSensitivity)
setTrackpadAsMouseInGamepad(context, profile.trackpadAsMouse)
setRumbleIntensity(context, profile.rumbleIntensity)
+ setCursorHoldSensitivity(context, profile.cursorHoldSensitivity)
+ setCursorHoldScrollSpeed(context, profile.cursorHoldScrollSpeed)
+ setCursorHoldClickMask(context, profile.cursorHoldClickMask)
+ setCursorHoldTouchMode(context, profile.cursorHoldTouchMode)
// Mapping — ordinals stored against XboxTarget. Out-of-range values fall back to NONE.
val targets = XboxTarget.values()
SteamButton.values().forEach { btn ->
diff --git a/app/src/main/java/com/steamcontroller/android/backup/BackupManager.kt b/app/src/main/java/com/steamcontroller/android/backup/BackupManager.kt
index 7302aa7..4e16e67 100644
--- a/app/src/main/java/com/steamcontroller/android/backup/BackupManager.kt
+++ b/app/src/main/java/com/steamcontroller/android/backup/BackupManager.kt
@@ -35,6 +35,10 @@ object BackupManager {
put("rumbleIntensity", Prefs.getRumbleIntensity(context))
put("mouseSensitivity", Prefs.getMouseSensitivity(context).toDouble())
put("trackpadAsMouse", Prefs.getTrackpadAsMouseInGamepad(context))
+ put("cursorHoldSensitivity", Prefs.getCursorHoldSensitivity(context).toDouble())
+ put("cursorHoldScrollSpeed", Prefs.getCursorHoldScrollSpeed(context).toDouble())
+ put("cursorHoldClickMask", Prefs.getCursorHoldClickMask(context))
+ put("cursorHoldTouchMode", Prefs.getCursorHoldTouchMode(context))
put("mapping", JSONObject().apply {
Prefs.getAllMappings(context).forEach { (btn, target) -> put(btn.name, target.ordinal) }
})
@@ -66,6 +70,10 @@ object BackupManager {
if (live.has("rumbleIntensity")) Prefs.setRumbleIntensity(context, live.optInt("rumbleIntensity", 100))
if (live.has("mouseSensitivity")) Prefs.setMouseSensitivity(context, live.optDouble("mouseSensitivity", 1.0).toFloat())
if (live.has("trackpadAsMouse")) Prefs.setTrackpadAsMouseInGamepad(context, live.optBoolean("trackpadAsMouse", true))
+ if (live.has("cursorHoldSensitivity")) Prefs.setCursorHoldSensitivity(context, live.optDouble("cursorHoldSensitivity", 1.0).toFloat())
+ if (live.has("cursorHoldScrollSpeed")) Prefs.setCursorHoldScrollSpeed(context, live.optDouble("cursorHoldScrollSpeed", 1.0).toFloat())
+ if (live.has("cursorHoldClickMask")) Prefs.setCursorHoldClickMask(context, live.optInt("cursorHoldClickMask", Prefs.DEFAULT_CURSOR_HOLD_CLICK_MASK))
+ if (live.has("cursorHoldTouchMode")) Prefs.setCursorHoldTouchMode(context, live.optBoolean("cursorHoldTouchMode", false))
live.optJSONObject("mapping")?.let { mapObj ->
val targets = XboxTarget.values()
SteamButton.values().forEach { btn ->
diff --git a/app/src/main/java/com/steamcontroller/android/input/ButtonMapping.kt b/app/src/main/java/com/steamcontroller/android/input/ButtonMapping.kt
index 99cae7b..c3d03a7 100644
--- a/app/src/main/java/com/steamcontroller/android/input/ButtonMapping.kt
+++ b/app/src/main/java/com/steamcontroller/android/input/ButtonMapping.kt
@@ -116,11 +116,16 @@ enum class XboxTarget(val mask: Int, val displayName: String, val keyBit: Int =
KB_BACKSPACE (-20, "Backspace", keyBit = 14),
KB_MENU (-21, "Menu (context)", keyBit = 13),
- // Special actions (mask < 0, no keyBit). Edge-triggered on press in Kotlin.
- SCREENSHOT(-1, "Take screenshot"),
+ // Special actions (mask < 0, no keyBit). Edge-triggered on press in Kotlin,
+ // except CURSOR_HOLD which acts as a hold-modifier for joystick-driven mouse cursor.
+ SCREENSHOT (-1, "Take screenshot"),
+ CURSOR_HOLD(-2, "Hold for Cursor Mode", keyBit = -2),
+ MOUSE_LEFT (-3, "🖱 Left click", keyBit = MouseTarget.BTN_LEFT.bit),
+ MOUSE_RIGHT(-4, "🖱 Right click", keyBit = MouseTarget.BTN_RIGHT.bit),
}
-/** Default mapping — reproduces the hardcoded mapping that existed before this feature. */
+/** Default mapping — reproduces the hardcoded mapping that existed before this feature,
+ * with L4 defaulted to Hold for Cursor Mode. */
val DEFAULT_MAPPING: Map = mapOf(
SteamButton.A to XboxTarget.A,
SteamButton.B to XboxTarget.B,
@@ -138,8 +143,8 @@ val DEFAULT_MAPPING: Map = mapOf(
SteamButton.VIEW to XboxTarget.SELECT,
SteamButton.STEAM to XboxTarget.MODE,
SteamButton.QUICK_ACCESS to XboxTarget.NONE, // No default — user assigns
- // Extras have no default assignment — user must pick one
- SteamButton.L4 to XboxTarget.NONE,
+ // L4 defaults to Hold for Cursor Mode so joystick cursor works out of the box
+ SteamButton.L4 to XboxTarget.CURSOR_HOLD,
SteamButton.L5 to XboxTarget.NONE,
SteamButton.R4 to XboxTarget.NONE,
SteamButton.R5 to XboxTarget.NONE,
diff --git a/app/src/main/java/com/steamcontroller/android/input/NamedProfile.kt b/app/src/main/java/com/steamcontroller/android/input/NamedProfile.kt
index 7cbe94f..dc71c15 100644
--- a/app/src/main/java/com/steamcontroller/android/input/NamedProfile.kt
+++ b/app/src/main/java/com/steamcontroller/android/input/NamedProfile.kt
@@ -1,5 +1,6 @@
package com.steamcontroller.android.input
+import com.steamcontroller.android.parser.Buttons
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
@@ -33,6 +34,10 @@ data class NamedProfile(
/** Serialised as a JSON object mapping `SteamButton.name` → `XboxTarget.ordinal`. */
val mapping: Map,
val boundPackages: List = emptyList(),
+ val cursorHoldSensitivity: Float = 1.0f,
+ val cursorHoldScrollSpeed: Float = 1.0f,
+ val cursorHoldClickMask: Int = Buttons.A or Buttons.RT_FULL,
+ val cursorHoldTouchMode: Boolean = false,
) {
fun toJson(): JSONObject = JSONObject().apply {
put(KEY_ID, id)
@@ -48,6 +53,10 @@ data class NamedProfile(
put(KEY_RI, rumbleIntensity)
put(KEY_MAPPING, JSONObject(mapping as Map<*, *>))
put(KEY_BOUND, JSONArray(boundPackages))
+ put(KEY_CHS, cursorHoldSensitivity.toDouble())
+ put(KEY_CHSS, cursorHoldScrollSpeed.toDouble())
+ put(KEY_CHCM, cursorHoldClickMask)
+ put(KEY_CHTM, cursorHoldTouchMode)
}
companion object {
@@ -64,6 +73,10 @@ data class NamedProfile(
private const val KEY_RI = "rumbleIntensity"
private const val KEY_MAPPING = "mapping"
private const val KEY_BOUND = "boundPackages"
+ private const val KEY_CHS = "cursorHoldSens"
+ private const val KEY_CHSS = "cursorHoldScrollSpeed"
+ private const val KEY_CHCM = "cursorHoldClickMask"
+ private const val KEY_CHTM = "cursorHoldTouchMode"
fun fromJson(obj: JSONObject): NamedProfile {
val mapObj = obj.optJSONObject(KEY_MAPPING) ?: JSONObject()
@@ -91,6 +104,10 @@ data class NamedProfile(
rumbleIntensity = obj.optInt(KEY_RI, 100),
mapping = mapping,
boundPackages = bound,
+ cursorHoldSensitivity = obj.optDouble(KEY_CHS, 1.0).toFloat(),
+ cursorHoldScrollSpeed = obj.optDouble(KEY_CHSS, 1.0).toFloat(),
+ cursorHoldClickMask = obj.optInt(KEY_CHCM, Buttons.A or Buttons.RT_FULL),
+ cursorHoldTouchMode = obj.optBoolean(KEY_CHTM, false),
)
}
diff --git a/app/src/main/java/com/steamcontroller/android/ui/CalibrationScreen.kt b/app/src/main/java/com/steamcontroller/android/ui/CalibrationScreen.kt
index a008be6..79dd844 100644
--- a/app/src/main/java/com/steamcontroller/android/ui/CalibrationScreen.kt
+++ b/app/src/main/java/com/steamcontroller/android/ui/CalibrationScreen.kt
@@ -140,7 +140,7 @@ fun CalibrationScreen(onBack: () -> Unit) {
Column(Modifier.padding(16.dp)) {
Text("${stringResource(R.string.calib_mouse_sensitivity)}: %.1f×".format(sensitivity), style = MaterialTheme.typography.titleMedium)
StepSlider(
- value = sensitivity, valueRange = 0.1f..3f, step = 0.1f,
+ value = sensitivity, valueRange = 0.1f..5f, step = 0.1f,
onValueChange = { sensitivity = Math.round(it * 10) / 10f; Prefs.setMouseSensitivity(context, sensitivity) },
)
}
diff --git a/app/src/main/java/com/steamcontroller/android/ui/MappingScreen.kt b/app/src/main/java/com/steamcontroller/android/ui/MappingScreen.kt
index fa62dba..1d2435d 100644
--- a/app/src/main/java/com/steamcontroller/android/ui/MappingScreen.kt
+++ b/app/src/main/java/com/steamcontroller/android/ui/MappingScreen.kt
@@ -1,5 +1,9 @@
package com.steamcontroller.android.ui
+import android.content.Intent
+import android.net.Uri
+import android.provider.Settings
+import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -10,12 +14,19 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -39,13 +50,29 @@ fun MappingScreen(onBack: () -> Unit, onOpenProfiles: () -> Unit) {
mutableStateMapOf().apply { SteamButton.entries.forEach { put(it, Prefs.getMapping(context, it)) } }
}
var editing by remember { mutableStateOf(null) }
+ var configuringCursorHold by remember { mutableStateOf(false) }
editing?.let { source ->
- RemapDialog(source, mappings.getValue(source), onDismiss = { editing = null }) { picked ->
- Prefs.setMapping(context, source, picked)
- mappings[source] = picked
- editing = null
- }
+ RemapDialog(
+ source = source,
+ current = mappings.getValue(source),
+ onDismiss = { editing = null },
+ onConfigureCursorHold = {
+ Prefs.setMapping(context, source, XboxTarget.CURSOR_HOLD)
+ mappings[source] = XboxTarget.CURSOR_HOLD
+ editing = null
+ configuringCursorHold = true
+ },
+ onPick = { picked ->
+ Prefs.setMapping(context, source, picked)
+ mappings[source] = picked
+ editing = null
+ }
+ )
+ }
+
+ if (configuringCursorHold) {
+ CursorHoldConfigDialog(onDismiss = { configuringCursorHold = false })
}
Column(
@@ -71,7 +98,16 @@ fun MappingScreen(onBack: () -> Unit, onOpenProfiles: () -> Unit) {
val buttons = grouped[category] ?: continue
SectionTitle(category.title)
DividedCard(buttons.map { source ->
- { MappingRow(source, mappings.getValue(source)) { editing = source } }
+ {
+ MappingRow(
+ source = source,
+ target = mappings.getValue(source),
+ onClick = { editing = source },
+ onConfigure = if (mappings.getValue(source) == XboxTarget.CURSOR_HOLD) {
+ { configuringCursorHold = true }
+ } else null
+ )
+ }
})
}
Spacer(Modifier.height(16.dp))
@@ -79,7 +115,12 @@ fun MappingScreen(onBack: () -> Unit, onOpenProfiles: () -> Unit) {
}
@Composable
-private fun MappingRow(source: SteamButton, target: XboxTarget, onClick: () -> Unit) {
+private fun MappingRow(
+ source: SteamButton,
+ target: XboxTarget,
+ onClick: () -> Unit,
+ onConfigure: (() -> Unit)? = null,
+) {
Row(
Modifier.fillMaxWidth().focusRing(RectangleShape).clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -93,6 +134,18 @@ private fun MappingRow(source: SteamButton, target: XboxTarget, onClick: () -> U
style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold,
color = if (target == XboxTarget.NONE) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary,
)
+ if (onConfigure != null) {
+ Surface(
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.size(36.dp).focusRing(CircleShape),
+ ) {
+ IconButton(onClick = onConfigure) {
+ Icon(Icons.Filled.MoreVert, contentDescription = "Configure Hold for Cursor Mode", modifier = Modifier.size(20.dp))
+ }
+ }
+ }
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null, tint = MaterialTheme.colorScheme.primary)
}
}
@@ -112,7 +165,13 @@ private fun ButtonGlyph(iconRes: Int?, label: String) {
}
@Composable
-private fun RemapDialog(source: SteamButton, current: XboxTarget, onDismiss: () -> Unit, onPick: (XboxTarget) -> Unit) {
+private fun RemapDialog(
+ source: SteamButton,
+ current: XboxTarget,
+ onDismiss: () -> Unit,
+ onConfigureCursorHold: () -> Unit,
+ onPick: (XboxTarget) -> Unit,
+) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Remap ${source.displayName}") },
@@ -122,6 +181,20 @@ private fun RemapDialog(source: SteamButton, current: XboxTarget, onDismiss: ()
ChoiceRow(
target.displayName, target == current, { onPick(target) },
leading = if (target == current) null else { { ButtonGlyph(targetIconFor(target), shortLabelFor(target)) } },
+ badge = if (target == XboxTarget.CURSOR_HOLD) {
+ {
+ Surface(
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.size(36.dp).focusRing(CircleShape),
+ ) {
+ IconButton(onClick = onConfigureCursorHold) {
+ Icon(Icons.Filled.MoreVert, contentDescription = "Configure Hold for Cursor Mode", modifier = Modifier.size(20.dp))
+ }
+ }
+ }
+ } else null,
)
}
}
@@ -131,6 +204,185 @@ private fun RemapDialog(source: SteamButton, current: XboxTarget, onDismiss: ()
)
}
+@Composable
+private fun CursorHoldConfigDialog(onDismiss: () -> Unit) {
+ val context = LocalContext.current
+ var sensitivity by remember { mutableFloatStateOf(Prefs.getCursorHoldSensitivity(context)) }
+ var scrollSpeed by remember { mutableFloatStateOf(Prefs.getCursorHoldScrollSpeed(context)) }
+ var touchMode by remember { mutableStateOf(Prefs.getCursorHoldTouchMode(context)) }
+ val selectedButtons = remember {
+ mutableStateListOf().apply { addAll(Prefs.getCursorHoldClickButtons(context)) }
+ }
+
+ fun toggleButton(btn: SteamButton) {
+ if (btn in selectedButtons) {
+ selectedButtons.remove(btn)
+ } else {
+ selectedButtons.add(btn)
+ }
+ Prefs.setCursorHoldClickButtons(context, selectedButtons.toSet())
+ }
+
+ fun setTouchModeEnabled(enabled: Boolean) {
+ touchMode = enabled
+ Prefs.setCursorHoldTouchMode(context, enabled)
+ if (enabled && !Settings.canDrawOverlays(context)) {
+ Toast.makeText(
+ context,
+ "Please allow 'Display over other apps' so the touch cursor reticle can be shown",
+ Toast.LENGTH_LONG
+ ).show()
+ try {
+ context.startActivity(
+ Intent(
+ Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
+ Uri.parse("package:${context.packageName}")
+ ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ )
+ } catch (_: Throwable) {}
+ }
+ }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Configure Hold for Cursor Mode") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRing(RectangleShape)
+ .clickable { setTouchModeEnabled(!touchMode) }
+ .padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ "Simulate Finger Touch",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ if (touchMode) "Injects real touchscreen taps & drags (works in games that ignore mouse clicks)"
+ else "Uses Android system mouse cursor (standard pointer)",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Switch(
+ checked = touchMode,
+ onCheckedChange = { setTouchModeEnabled(it) },
+ )
+ }
+ HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
+ Column {
+ Text(
+ "Cursor sensitivity: %.1f×".format(sensitivity),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Slider(
+ value = sensitivity,
+ valueRange = 0.1f..5.0f,
+ onValueChange = {
+ sensitivity = Math.round(it * 10) / 10f
+ Prefs.setCursorHoldSensitivity(context, sensitivity)
+ },
+ modifier = Modifier.focusRing(CircleShape).onPreviewKeyEvent { event ->
+ val direction = when (event.key) {
+ Key.DirectionRight -> 1
+ Key.DirectionLeft -> -1
+ else -> return@onPreviewKeyEvent false
+ }
+ if (event.type == KeyEventType.KeyDown) {
+ val next = (sensitivity + direction * 0.1f).coerceIn(0.1f..5.0f)
+ sensitivity = Math.round(next * 10) / 10f
+ Prefs.setCursorHoldSensitivity(context, sensitivity)
+ }
+ true
+ },
+ )
+ }
+ Column {
+ Text(
+ "Scroll speed: %.1f×".format(scrollSpeed),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Slider(
+ value = scrollSpeed,
+ valueRange = 0.1f..5.0f,
+ onValueChange = {
+ scrollSpeed = Math.round(it * 10) / 10f
+ Prefs.setCursorHoldScrollSpeed(context, scrollSpeed)
+ },
+ modifier = Modifier.focusRing(CircleShape).onPreviewKeyEvent { event ->
+ val direction = when (event.key) {
+ Key.DirectionRight -> 1
+ Key.DirectionLeft -> -1
+ else -> return@onPreviewKeyEvent false
+ }
+ if (event.type == KeyEventType.KeyDown) {
+ val next = (scrollSpeed + direction * 0.1f).coerceIn(0.1f..5.0f)
+ scrollSpeed = Math.round(next * 10) / 10f
+ Prefs.setCursorHoldScrollSpeed(context, scrollSpeed)
+ }
+ true
+ },
+ )
+ }
+ HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
+ Text(
+ "Click / Confirm buttons (multi-select)",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ if (touchMode) "Selected buttons tap/drag at the cursor position while the modifier is held."
+ else "Selected buttons simulate a mouse Left Click while the cursor modifier is held.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ LazyColumn(Modifier.heightIn(max = 220.dp)) {
+ items(SteamButton.entries) { btn ->
+ val isSelected = btn in selectedButtons
+ ChoiceRow(
+ title = btn.displayName,
+ selected = isSelected,
+ onClick = { toggleButton(btn) },
+ leading = { ButtonGlyph(sourceIconFor(btn), btn.shortLabel) },
+ badge = {
+ Checkbox(
+ checked = isSelected,
+ onCheckedChange = null,
+ )
+ },
+ )
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) { Text("Done") }
+ },
+ dismissButton = {
+ TextButton(onClick = {
+ sensitivity = 1.0f
+ scrollSpeed = 1.0f
+ touchMode = false
+ Prefs.setCursorHoldSensitivity(context, 1.0f)
+ Prefs.setCursorHoldScrollSpeed(context, 1.0f)
+ Prefs.setCursorHoldTouchMode(context, false)
+ Prefs.setCursorHoldClickMask(context, Prefs.DEFAULT_CURSOR_HOLD_CLICK_MASK)
+ selectedButtons.clear()
+ selectedButtons.addAll(Prefs.getCursorHoldClickButtons(context))
+ }) { Text("Reset") }
+ },
+ )
+}
+
/** Kenney SC icon for each source button. */
private fun sourceIconFor(b: SteamButton): Int = when (b) {
SteamButton.A -> R.drawable.sc_btn_a
@@ -193,4 +445,7 @@ private fun shortLabelFor(t: XboxTarget): String = when (t) {
XboxTarget.KB_SPACE -> "␣"
XboxTarget.KB_BACKSPACE -> "⌫"
XboxTarget.KB_MENU -> "☰"
+ XboxTarget.CURSOR_HOLD -> "🖱"
+ XboxTarget.MOUSE_LEFT -> "L🖱"
+ XboxTarget.MOUSE_RIGHT -> "R🖱"
}
diff --git a/app/src/main/java/com/steamcontroller/android/ui/TouchCursorOverlay.kt b/app/src/main/java/com/steamcontroller/android/ui/TouchCursorOverlay.kt
new file mode 100644
index 0000000..adbcc6c
--- /dev/null
+++ b/app/src/main/java/com/steamcontroller/android/ui/TouchCursorOverlay.kt
@@ -0,0 +1,178 @@
+package com.steamcontroller.android.ui
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.PixelFormat
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.provider.Settings
+import android.util.Log
+import android.view.Gravity
+import android.view.View
+import android.view.WindowManager
+
+/**
+ * Draws a floating, non-touchable cursor reticle over all apps using WindowManager
+ * (`TYPE_APPLICATION_OVERLAY`) when Hold for Cursor Mode is in Touch Simulation mode.
+ *
+ * Appears immediately when the hold button is pressed, moves smoothly with the joystick,
+ * highlights when clicking/dragging, and disappears immediately on release.
+ */
+class TouchCursorOverlay(private val context: Context) {
+
+ private val TAG = "TouchCursorOverlay"
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
+
+ private val sizePx = (28 * context.resources.displayMetrics.density).toInt().coerceAtLeast(32)
+ private val halfPx = sizePx / 2
+
+ private var cursorView: CursorReticleView? = null
+ private var params: WindowManager.LayoutParams? = null
+ private var attached = false
+ private var currentVisible = false
+ private var currentClicking = false
+
+ @Volatile private var pendingVisible = false
+ @Volatile private var pendingX = 0f
+ @Volatile private var pendingY = 0f
+ @Volatile private var pendingClicking = false
+ @Volatile private var updateScheduled = false
+
+ private val applyUpdateRunnable = Runnable {
+ updateScheduled = false
+ val visible = pendingVisible
+ val x = pendingX
+ val y = pendingY
+ val clicking = pendingClicking
+
+ if (!visible) {
+ if (attached && currentVisible) {
+ cursorView?.visibility = View.GONE
+ currentVisible = false
+ }
+ return@Runnable
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(context)) {
+ return@Runnable
+ }
+
+ if (!attached) {
+ try {
+ val view = CursorReticleView(context)
+ val lp = WindowManager.LayoutParams(
+ sizePx,
+ sizePx,
+ WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
+ WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
+ WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
+ WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
+ PixelFormat.TRANSLUCENT
+ ).apply {
+ gravity = Gravity.TOP or Gravity.START
+ this.x = x.toInt() - halfPx
+ this.y = y.toInt() - halfPx
+ }
+ windowManager.addView(view, lp)
+ cursorView = view
+ params = lp
+ attached = true
+ currentVisible = true
+ } catch (t: Throwable) {
+ Log.w(TAG, "Failed to attach overlay: ${t.message}")
+ return@Runnable
+ }
+ }
+
+ val view = cursorView ?: return@Runnable
+ val lp = params ?: return@Runnable
+ if (!currentVisible) {
+ view.visibility = View.VISIBLE
+ currentVisible = true
+ }
+ view.clicking = clicking
+ val targetX = x.toInt() - halfPx
+ val targetY = y.toInt() - halfPx
+ if (lp.x != targetX || lp.y != targetY) {
+ lp.x = targetX
+ lp.y = targetY
+ try {
+ windowManager.updateViewLayout(view, lp)
+ } catch (_: Throwable) {}
+ }
+ }
+
+ private class CursorReticleView(context: Context) : View(context) {
+ var clicking: Boolean = false
+ set(value) {
+ if (field != value) {
+ field = value
+ invalidate()
+ }
+ }
+
+ private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.FILL
+ color = Color.argb(140, 26, 159, 255) // Steam blue translucent fill
+ }
+ private val activeFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.FILL
+ color = Color.argb(210, 60, 220, 130) // Greenish highlight when pressing
+ }
+ private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = 2.5f * context.resources.displayMetrics.density
+ color = Color.WHITE
+ }
+ private val shadowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = 4.0f * context.resources.displayMetrics.density
+ color = Color.argb(160, 0, 0, 0)
+ }
+ private val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.FILL
+ color = Color.WHITE
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+ val cx = width / 2f
+ val cy = height / 2f
+ val radius = (width.coerceAtMost(height) / 2f) * (if (clicking) 0.72f else 0.82f)
+ canvas.drawCircle(cx, cy, radius, if (clicking) activeFillPaint else fillPaint)
+ canvas.drawCircle(cx, cy, radius, shadowPaint)
+ canvas.drawCircle(cx, cy, radius, strokePaint)
+ canvas.drawCircle(cx, cy, 2.5f * resources.displayMetrics.density, dotPaint)
+ }
+ }
+
+ fun update(visible: Boolean, x: Float, y: Float, clicking: Boolean) {
+ pendingVisible = visible
+ pendingX = x
+ pendingY = y
+ pendingClicking = clicking
+ if (!updateScheduled) {
+ updateScheduled = true
+ mainHandler.post(applyUpdateRunnable)
+ }
+ }
+
+ fun destroy() {
+ mainHandler.post {
+ if (attached) {
+ try {
+ cursorView?.let { windowManager.removeView(it) }
+ } catch (_: Throwable) {}
+ cursorView = null
+ params = null
+ attached = false
+ currentVisible = false
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/steamcontroller/android/uinput/UInputGamepad.kt b/app/src/main/java/com/steamcontroller/android/uinput/UInputGamepad.kt
index 83af062..ad378e5 100644
--- a/app/src/main/java/com/steamcontroller/android/uinput/UInputGamepad.kt
+++ b/app/src/main/java/com/steamcontroller/android/uinput/UInputGamepad.kt
@@ -7,6 +7,7 @@ import android.content.Context
import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log
+import android.view.MotionEvent
import com.steamcontroller.android.Prefs
import com.steamcontroller.android.input.DEFAULT_MOUSE_MAPPING
import com.steamcontroller.android.input.MOUSE_LEFT_PAD_CLICK_BIT
@@ -18,6 +19,7 @@ import com.steamcontroller.android.input.SystemActions
import com.steamcontroller.android.input.XboxTarget
import com.steamcontroller.android.parser.Buttons
import com.steamcontroller.android.parser.SteamControllerState
+import com.steamcontroller.android.ui.TouchCursorOverlay
import kotlin.math.abs
import rikka.shizuku.Shizuku
@@ -71,9 +73,33 @@ class UInputGamepad(private val context: Context, initialProfile: GamepadProfile
private var leftPadHadContact: Boolean = false
@Volatile private var cachedMouseSensitivity: Float = 1f
@Volatile private var cachedTrackpadAsMouse: Boolean = true
+ @Volatile private var cachedCursorHoldSens: Float = 1f
+ @Volatile private var cachedCursorHoldScrollSpeed: Float = 1f
+ @Volatile private var cachedCursorHoldClickMask: Int = Buttons.A or Buttons.RT_FULL
+ @Volatile private var cachedCursorHoldTouchMode: Boolean = false
// Trigger / pad scroll: accumulator so we can convert continuous 0..32767 deltas into discrete wheel ticks
private var scrollAccumulator: Int = 0
+ // Joystick-driven Cursor Hold Mode state: fractional sub-pixel & scroll accumulators
+ private var joyMouseAccX: Float = 0f
+ private var joyMouseAccY: Float = 0f
+ private var joyScrollAcc: Float = 0f
+ private var wasCursorHoldActive: Boolean = false
+ private var lastSidecarKeys: Int = 0
+ // Base cursor speed in pixels per frame at full stick deflection (1.0x sensitivity).
+ // At ~150Hz (BLE) or ~240Hz (USB), 8f * sens gives smooth, responsive pointer travel.
+ private val joyCursorBaseSpeed = 8f
+
+ // Touch Simulation mode state: screen coordinates, touch down state, and floating overlay
+ private val touchOverlay = TouchCursorOverlay(context)
+ private var touchCursorInitialized = false
+ private var touchCursorX: Float = 0f
+ private var touchCursorY: Float = 0f
+ private var wasTouchClicking: Boolean = false
+ private var wasTouchScrolling: Boolean = false
+ private var touchScrollOriginX: Float = 0f
+ private var touchScrollOriginY: Float = 0f
+
private val args = Shizuku.UserServiceArgs(
ComponentName(context.packageName, UInputService::class.java.name)
)
@@ -82,7 +108,7 @@ class UInputGamepad(private val context: Context, initialProfile: GamepadProfile
.debuggable(false)
// Bumped with every IUInputService change: Shizuku reuses a running service of the
// same version, and an old one would not know the newer methods.
- .version(2)
+ .version(4)
@Volatile private var deviceReady = false
@@ -124,6 +150,7 @@ class UInputGamepad(private val context: Context, initialProfile: GamepadProfile
fun unbind() {
if (!bound) return
stopRumbleThread()
+ touchOverlay.destroy()
try { service?.let { restoreShowImeOverride(it) } } catch (_: Throwable) {}
try { service?.destroy() } catch (_: Throwable) {}
Shizuku.unbindUserService(args, connection, true)
@@ -148,7 +175,8 @@ class UInputGamepad(private val context: Context, initialProfile: GamepadProfile
Prefs.setSavedShowImeHardKeyboard(context, toSave)
}
svc.runShellCommand(arrayOf("settings", "put", "secure", "show_ime_with_hard_keyboard", "1"))
- Log.i(TAG, "show_ime_with_hard_keyboard forced on")
+ svc.runShellCommand(arrayOf("appops", "set", context.packageName, "SYSTEM_ALERT_WINDOW", "allow"))
+ Log.i(TAG, "show_ime_with_hard_keyboard forced on & SYSTEM_ALERT_WINDOW allowed")
} catch (t: Throwable) {
Log.w(TAG, "applyShowImeOverride failed: ${t.message}")
}
@@ -212,6 +240,8 @@ class UInputGamepad(private val context: Context, initialProfile: GamepadProfile
val ok = svc.createGamepad(newProfile.id)
if (ok) {
profile = newProfile
+ lastSidecarKeys = 0
+ wasCursorHoldActive = false
Log.i(TAG, "Switched profile to ${newProfile.displayName}")
} else {
Log.e(TAG, "createGamepad(${newProfile.displayName}) returned false")
@@ -235,6 +265,10 @@ class UInputGamepad(private val context: Context, initialProfile: GamepadProfile
cachedMapping = Prefs.getAllMappings(context)
cachedMouseSensitivity = Prefs.getMouseSensitivity(context)
cachedTrackpadAsMouse = Prefs.getTrackpadAsMouseInGamepad(context)
+ cachedCursorHoldSens = Prefs.getCursorHoldSensitivity(context)
+ cachedCursorHoldScrollSpeed = Prefs.getCursorHoldScrollSpeed(context)
+ cachedCursorHoldClickMask = Prefs.getCursorHoldClickMask(context)
+ cachedCursorHoldTouchMode = Prefs.getCursorHoldTouchMode(context)
lastCalRefresh = now
}
@@ -246,9 +280,22 @@ class UInputGamepad(private val context: Context, initialProfile: GamepadProfile
val leftCal = cachedLeftCal
val rightCal = cachedRightCal
+ // Check if any button mapped to CURSOR_HOLD is currently held down.
+ var cursorHoldActive = false
+ for ((source, target) in cachedMapping) {
+ if (target == XboxTarget.CURSOR_HOLD && state.isButtonPressed(source.mask)) {
+ cursorHoldActive = true
+ break
+ }
+ }
+
+ val clickMask = cachedCursorHoldClickMask
+ val touchMode = cachedCursorHoldTouchMode
+ var cursorClickPressed = false
+
// Apply the user-configurable button mapping.
// target.mask > 0 → regular Xbox button bit (OR into the bitmask).
- // target.keyBit >= 0 → sidecar keyboard key (OR into the sidecar key bitmask).
+ // target.keyBit >= 0 → sidecar keyboard/mouse key (OR into the sidecar key bitmask).
// target.triggerSide!=0 → force LT (1) / RT (2) axis to max.
// target.mask < 0 → special action, edge-triggered on press.
var xboxButtons = 0
@@ -257,6 +304,21 @@ class UInputGamepad(private val context: Context, initialProfile: GamepadProfile
var rtOverride = 0
for ((source, target) in cachedMapping) {
val pressed = state.isButtonPressed(source.mask)
+ if (target == XboxTarget.CURSOR_HOLD) continue
+
+ // When Cursor Hold Mode is active, any button in `clickMask` acts as Click/Confirm
+ // and is suppressed from reaching the virtual gamepad so the game doesn't react.
+ if (cursorHoldActive && (clickMask and source.mask) != 0) {
+ if (pressed) {
+ if (touchMode) {
+ cursorClickPressed = true
+ } else {
+ sidecarMappedKeys = sidecarMappedKeys or (1 shl MouseTarget.BTN_LEFT.bit)
+ }
+ }
+ continue
+ }
+
when {
target.mask > 0 && pressed -> {
xboxButtons = xboxButtons or target.mask
@@ -277,8 +339,19 @@ class UInputGamepad(private val context: Context, initialProfile: GamepadProfile
// SC2026 sticks are already in Int16 range — direct passthrough
// SC2026 triggers are 0-32767 → scale down to Xbox 0-255.
// ltOverride/rtOverride bump the axis to max when a remapped source is pressed.
- val ltAnalog = (state.leftTrigger * 255 / 32767).coerceIn(0, 255)
- val rtAnalog = (state.rightTrigger * 255 / 32767).coerceIn(0, 255)
+ // In Cursor Hold Mode, if LT or RT is bound as a click button, its analog axis is suppressed
+ // and pulling past 50% (>16384) also triggers a Click.
+ val ltIsClick = cursorHoldActive && (clickMask and Buttons.LT_FULL) != 0
+ val rtIsClick = cursorHoldActive && (clickMask and Buttons.RT_FULL) != 0
+ val ltAnalog = if (ltIsClick) 0 else (state.leftTrigger * 255 / 32767).coerceIn(0, 255)
+ val rtAnalog = if (rtIsClick) 0 else (state.rightTrigger * 255 / 32767).coerceIn(0, 255)
+ if ((rtIsClick && state.rightTrigger > 16384) || (ltIsClick && state.leftTrigger > 16384)) {
+ if (touchMode) {
+ cursorClickPressed = true
+ } else {
+ sidecarMappedKeys = sidecarMappedKeys or (1 shl MouseTarget.BTN_LEFT.bit)
+ }
+ }
val lt = maxOf(ltAnalog, ltOverride)
val rt = maxOf(rtAnalog, rtOverride)
@@ -299,11 +372,17 @@ class UInputGamepad(private val context: Context, initialProfile: GamepadProfile
val ly = -lyCalRaw.coerceAtLeast(-32767)
val ry = -ryCalRaw.coerceAtLeast(-32767)
+ // When Cursor Hold Mode is active, suppress stick deflection from reaching the gamepad
+ val padLx = if (cursorHoldActive) 0 else lxCal
+ val padLy = if (cursorHoldActive) 0 else ly
+ val padRx = if (cursorHoldActive) 0 else rxCal
+ val padRy = if (cursorHoldActive) 0 else ry
+
try {
svc.sendFrame(
xboxButtons,
- lxCal, ly,
- rxCal, ry,
+ padLx, padLy,
+ padRx, padRy,
lt, rt,
dpadX, dpadY
)
@@ -313,19 +392,35 @@ class UInputGamepad(private val context: Context, initialProfile: GamepadProfile
// Sidecar mouse + keyboard while a gamepad profile is active.
// - cachedTrackpadAsMouse gates trackpad-driven cursor + scroll
- // - mapped keyboard keys (sidecarMappedKeys) always flow through, even when
- // the trackpad-as-mouse toggle is off, so back-paddle keyboard mappings work.
- pushSidecarFrame(svc, state, sidecarMappedKeys)
+ // - cursorHoldActive drives cursor with Left Stick and scroll with Right Stick
+ // - mapped keyboard/mouse keys (sidecarMappedKeys) always flow through
+ pushSidecarFrame(
+ svc, state, sidecarMappedKeys,
+ cursorHoldActive, touchMode, cursorClickPressed,
+ lxCal, ly, rxCal, ry, ryCalRaw
+ )
}
/**
- * Sidecar frame for gamepad mode: trackpad-as-mouse + keyboard targets for
- * back paddles. Skips emitting anything when nothing happens this frame —
- * keeping the mouse fd idle is critical so Android IME focus isn't stolen by
- * a phantom cursor (same rationale as in Desktop mode).
+ * Sidecar frame for gamepad mode: trackpad-as-mouse, joystick cursor-hold mode,
+ * and keyboard/mouse targets for back paddles. Skips emitting anything when nothing
+ * happens this frame — keeping the mouse fd idle is critical so Android IME focus
+ * isn't stolen by a phantom cursor (same rationale as in Desktop mode).
*/
- private fun pushSidecarFrame(svc: IUInputService, state: SteamControllerState, mappedKeys: Int) {
- val (relX, relY, scrollTicks) = if (cachedTrackpadAsMouse) {
+ private fun pushSidecarFrame(
+ svc: IUInputService,
+ state: SteamControllerState,
+ mappedKeys: Int,
+ cursorHoldActive: Boolean,
+ touchMode: Boolean,
+ cursorClickPressed: Boolean,
+ joyLx: Int,
+ joyLyDown: Int,
+ joyRx: Int,
+ joyRyDown: Int,
+ joyRyUp: Int,
+ ) {
+ val (padRelX, padRelY, padScrollTicks) = if (cachedTrackpadAsMouse) {
val (rx, ry) = computeRightPadDelta(state)
Triple(rx, ry, computeLeftPadScroll(state))
} else {
@@ -337,12 +432,177 @@ class UInputGamepad(private val context: Context, initialProfile: GamepadProfile
Triple(0, 0, 0)
}
+ var joyRelX = 0
+ var joyRelY = 0
+ var joyScrollTicks = 0
+ var forceNudge = false
+
+ if (cursorHoldActive) {
+ val sens = cachedCursorHoldSens
+ val scrollSpeed = cachedCursorHoldScrollSpeed
+ // Left Stick → cursor delta (with quadratic response curve so fine adjustments are easy)
+ val normX = (joyLx / 32767f).coerceIn(-1f, 1f)
+ val normY = (joyLyDown / 32767f).coerceIn(-1f, 1f)
+ val curvedX = normX * (0.4f + 0.6f * abs(normX))
+ val curvedY = normY * (0.4f + 0.6f * abs(normY))
+
+ if (touchMode) {
+ val dm = context.resources.displayMetrics
+ val screenW = dm.widthPixels.toFloat().coerceAtLeast(1f)
+ val screenH = dm.heightPixels.toFloat().coerceAtLeast(1f)
+ // Scale by display density so 1.0x sensitivity matches the physical screen speed
+ // of Android's DPI-scaled + velocity-accelerated hardware mouse pointer.
+ val densityScale = dm.density.coerceIn(1.5f, 3.5f)
+ if (!touchCursorInitialized) {
+ touchCursorX = screenW / 2f
+ touchCursorY = screenH / 2f
+ touchCursorInitialized = true
+ }
+
+ val scrollNormX = (joyRx / 32767f).coerceIn(-1f, 1f)
+ val scrollNormY = (joyRyDown / 32767f).coerceIn(-1f, 1f)
+ val isTouchScrolling = !cursorClickPressed &&
+ (abs(scrollNormX) > 0.12f || abs(scrollNormY) > 0.12f)
+
+ if (isTouchScrolling) {
+ if (wasTouchClicking) {
+ try { svc.injectTouch(MotionEvent.ACTION_UP, touchCursorX, touchCursorY) } catch (_: Throwable) {}
+ wasTouchClicking = false
+ }
+ if (!wasTouchScrolling) {
+ touchScrollOriginX = touchCursorX
+ touchScrollOriginY = touchCursorY
+ try { svc.injectTouch(MotionEvent.ACTION_DOWN, touchCursorX, touchCursorY) } catch (_: Throwable) {}
+ wasTouchScrolling = true
+ }
+ val scrollCurvedX = scrollNormX * (0.4f + 0.6f * abs(scrollNormX))
+ val scrollCurvedY = scrollNormY * (0.4f + 0.6f * abs(scrollNormY))
+ val stepX = scrollCurvedX * joyCursorBaseSpeed * densityScale * scrollSpeed
+ val stepY = scrollCurvedY * joyCursorBaseSpeed * densityScale * scrollSpeed
+ val nextX = touchCursorX + stepX
+ val nextY = touchCursorY + stepY
+ val edgeMargin = 32f * densityScale
+ val maxStroke = minOf(screenW, screenH) * 0.40f
+ val hitEdge = nextX <= edgeMargin || nextX >= screenW - edgeMargin ||
+ nextY <= edgeMargin || nextY >= screenH - edgeMargin
+ val hitMaxStroke = abs(nextX - touchScrollOriginX) > maxStroke ||
+ abs(nextY - touchScrollOriginY) > maxStroke
+
+ if (hitEdge || hitMaxStroke) {
+ try {
+ svc.injectTouch(MotionEvent.ACTION_UP, touchCursorX, touchCursorY)
+ touchCursorX = touchScrollOriginX
+ touchCursorY = touchScrollOriginY
+ svc.injectTouch(MotionEvent.ACTION_DOWN, touchCursorX, touchCursorY)
+ } catch (_: Throwable) {}
+ } else {
+ touchCursorX = nextX.coerceIn(0f, screenW - 1f)
+ touchCursorY = nextY.coerceIn(0f, screenH - 1f)
+ try {
+ svc.injectTouch(MotionEvent.ACTION_MOVE, touchCursorX, touchCursorY)
+ } catch (_: Throwable) {}
+ }
+ touchOverlay.update(true, touchCursorX, touchCursorY, true)
+ } else {
+ if (wasTouchScrolling) {
+ try { svc.injectTouch(MotionEvent.ACTION_UP, touchCursorX, touchCursorY) } catch (_: Throwable) {}
+ touchCursorX = touchScrollOriginX
+ touchCursorY = touchScrollOriginY
+ wasTouchScrolling = false
+ }
+ val prevX = touchCursorX
+ val prevY = touchCursorY
+ touchCursorX = (touchCursorX + curvedX * joyCursorBaseSpeed * densityScale * sens).coerceIn(0f, screenW - 1f)
+ touchCursorY = (touchCursorY + curvedY * joyCursorBaseSpeed * densityScale * sens).coerceIn(0f, screenH - 1f)
+ touchOverlay.update(true, touchCursorX, touchCursorY, cursorClickPressed)
+
+ try {
+ if (cursorClickPressed && !wasTouchClicking) {
+ svc.injectTouch(MotionEvent.ACTION_DOWN, touchCursorX, touchCursorY)
+ } else if (cursorClickPressed && wasTouchClicking) {
+ if (abs(touchCursorX - prevX) >= 0.5f || abs(touchCursorY - prevY) >= 0.5f) {
+ svc.injectTouch(MotionEvent.ACTION_MOVE, touchCursorX, touchCursorY)
+ }
+ } else if (!cursorClickPressed && wasTouchClicking) {
+ svc.injectTouch(MotionEvent.ACTION_UP, touchCursorX, touchCursorY)
+ }
+ } catch (t: Throwable) {
+ Log.e(TAG, "injectTouch IPC failed: ${t.message}")
+ }
+ wasTouchClicking = cursorClickPressed
+ }
+ } else {
+ if (wasTouchScrolling) {
+ try { svc.injectTouch(MotionEvent.ACTION_UP, touchCursorX, touchCursorY) } catch (_: Throwable) {}
+ touchCursorX = touchScrollOriginX
+ touchCursorY = touchScrollOriginY
+ wasTouchScrolling = false
+ }
+ if (wasTouchClicking) {
+ try { svc.injectTouch(MotionEvent.ACTION_UP, touchCursorX, touchCursorY) } catch (_: Throwable) {}
+ wasTouchClicking = false
+ }
+ touchOverlay.update(false, touchCursorX, touchCursorY, false)
+ if (!wasCursorHoldActive) {
+ try { svc.setMouseConnected(true) } catch (_: Throwable) {}
+ }
+ joyMouseAccX += curvedX * joyCursorBaseSpeed * sens
+ joyMouseAccY += curvedY * joyCursorBaseSpeed * sens
+ joyRelX = joyMouseAccX.toInt()
+ joyRelY = joyMouseAccY.toInt()
+ joyMouseAccX -= joyRelX
+ joyMouseAccY -= joyRelY
+
+ // Right Stick vertical → scroll wheel ticks
+ val normScroll = (joyRyUp / 32767f).coerceIn(-1f, 1f)
+ joyScrollAcc += normScroll * 0.25f * scrollSpeed
+ if (abs(joyScrollAcc) >= 1f) {
+ joyScrollTicks = joyScrollAcc.toInt()
+ joyScrollAcc -= joyScrollTicks
+ }
+
+ // On the initial press frame of Cursor Hold Mode, nudge by 1px if stationary
+ // so Android's system pointer immediately appears on screen.
+ if (!wasCursorHoldActive && joyRelX == 0 && joyRelY == 0 && padRelX == 0 && padRelY == 0) {
+ joyRelX = 1
+ forceNudge = true
+ }
+ }
+ } else {
+ joyMouseAccX = 0f
+ joyMouseAccY = 0f
+ joyScrollAcc = 0f
+ if (wasTouchScrolling) {
+ try { svc.injectTouch(MotionEvent.ACTION_UP, touchCursorX, touchCursorY) } catch (_: Throwable) {}
+ touchCursorX = touchScrollOriginX
+ touchCursorY = touchScrollOriginY
+ wasTouchScrolling = false
+ }
+ if (wasTouchClicking) {
+ try { svc.injectTouch(MotionEvent.ACTION_UP, touchCursorX, touchCursorY) } catch (_: Throwable) {}
+ wasTouchClicking = false
+ }
+ if (wasCursorHoldActive) {
+ touchOverlay.update(false, touchCursorX, touchCursorY, false)
+ // Destroy the sidecar mouse uinput device when the hold button is released
+ // so Android's PointerController immediately hides the on-screen cursor.
+ try { svc.setMouseConnected(false) } catch (_: Throwable) {}
+ }
+ }
+ wasCursorHoldActive = cursorHoldActive
+
+ val relX = padRelX + joyRelX
+ val relY = padRelY + joyRelY
+ val scrollTicks = padScrollTicks + joyScrollTicks
+
var keys = mappedKeys
// Left trackpad click → left mouse click (only active when sidecar mouse is on).
if (cachedTrackpadAsMouse && state.isButtonPressed(MOUSE_LEFT_PAD_CLICK_BIT)) {
keys = keys or (1 shl MouseTarget.BTN_LEFT.bit)
}
- if (relX == 0 && relY == 0 && scrollTicks == 0 && keys == 0) return
+ // Always emit if keys changed (e.g. releasing a mouse click when cursorHoldActive ends)
+ if (relX == 0 && relY == 0 && scrollTicks == 0 && keys == lastSidecarKeys && !forceNudge) return
+ lastSidecarKeys = keys
try {
svc.sendMouseFrame(relX, relY, scrollTicks, keys)
} catch (t: Throwable) {
diff --git a/app/src/main/java/com/steamcontroller/android/uinput/UInputNative.kt b/app/src/main/java/com/steamcontroller/android/uinput/UInputNative.kt
index 3c3845f..0f32c27 100644
--- a/app/src/main/java/com/steamcontroller/android/uinput/UInputNative.kt
+++ b/app/src/main/java/com/steamcontroller/android/uinput/UInputNative.kt
@@ -21,5 +21,6 @@ object UInputNative {
)
/** Desktop mode frame. `keys` is a MouseTarget bitmask. */
external fun sendMouseFrame(relX: Int, relY: Int, scrollY: Int, keys: Int)
+ external fun setMouseConnected(connected: Boolean)
external fun destroy()
}
diff --git a/app/src/main/java/com/steamcontroller/android/uinput/UInputService.kt b/app/src/main/java/com/steamcontroller/android/uinput/UInputService.kt
index c5880f6..bfce076 100644
--- a/app/src/main/java/com/steamcontroller/android/uinput/UInputService.kt
+++ b/app/src/main/java/com/steamcontroller/android/uinput/UInputService.kt
@@ -3,7 +3,11 @@ package com.steamcontroller.android.uinput
import com.steamcontroller.android.ipc.IUInputService
import android.content.Context
+import android.os.SystemClock
import android.util.Log
+import android.view.InputDevice
+import android.view.InputEvent
+import android.view.MotionEvent
import java.io.File
// Bound by Shizuku.bindUserService() — this code runs in a separate process
@@ -105,6 +109,74 @@ class UInputService : IUInputService.Stub {
}
}
+ override fun setMouseConnected(connected: Boolean) {
+ try {
+ UInputNative.setMouseConnected(connected)
+ } catch (t: Throwable) {
+ Log.e(TAG, "setMouseConnected failed: ${t.message}")
+ }
+ }
+
+ private var inputManagerInstance: Any? = null
+ private var injectInputEventMethod: java.lang.reflect.Method? = null
+ private var touchDownTime: Long = 0L
+
+ private fun ensureInputManager(): Boolean {
+ if (inputManagerInstance != null && injectInputEventMethod != null) return true
+ return try {
+ val smClass = Class.forName("android.os.ServiceManager")
+ val getService = smClass.getMethod("getService", String::class.java)
+ val binder = getService.invoke(null, "input") as? android.os.IBinder ?: return false
+ val stubClass = Class.forName("android.hardware.input.IInputManager\$Stub")
+ val asInterface = stubClass.getMethod("asInterface", android.os.IBinder::class.java)
+ inputManagerInstance = asInterface.invoke(null, binder)
+ injectInputEventMethod = inputManagerInstance!!.javaClass.getMethod(
+ "injectInputEvent",
+ InputEvent::class.java,
+ Int::class.javaPrimitiveType
+ )
+ true
+ } catch (t: Throwable) {
+ Log.e(TAG, "ensureInputManager failed: ${t.message}")
+ false
+ }
+ }
+
+ override fun injectTouch(action: Int, x: Float, y: Float) {
+ try {
+ if (!ensureInputManager()) return
+ val now = SystemClock.uptimeMillis()
+ if (action == MotionEvent.ACTION_DOWN) {
+ touchDownTime = now
+ }
+ val downTime = if (touchDownTime > 0L) touchDownTime else now
+
+ val prop = MotionEvent.PointerProperties().apply {
+ id = 0
+ toolType = MotionEvent.TOOL_TYPE_FINGER
+ }
+ val coord = MotionEvent.PointerCoords().apply {
+ this.x = x
+ this.y = y
+ pressure = if (action == MotionEvent.ACTION_UP) 0f else 1f
+ size = 1f
+ }
+ val ev = MotionEvent.obtain(
+ downTime, now,
+ action,
+ 1, arrayOf(prop), arrayOf(coord),
+ 0, 0,
+ 1f, 1f,
+ 0, 0,
+ InputDevice.SOURCE_TOUCHSCREEN, 0
+ )
+ injectInputEventMethod?.invoke(inputManagerInstance, ev, 0)
+ ev.recycle()
+ } catch (t: Throwable) {
+ Log.e(TAG, "injectTouch failed: ${t.message}")
+ }
+ }
+
override fun pollForceFeedback(): IntArray? {
return try {
UInputNative.pollFFEvent()