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()