Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions" />

<!-- Floating cursor overlay for Touch Simulation mode in Hold for Cursor Mode -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

<!-- Needed so installed-apps query (AppPickerActivity) sees launcher apps on Android 11+. -->
<queries>
<intent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
49 changes: 44 additions & 5 deletions app/src/main/cpp/uinput_jni.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
72 changes: 68 additions & 4 deletions app/src/main/java/com/steamcontroller/android/Prefs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
}

Expand All @@ -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<SteamButton> {
val mask = getCursorHoldClickMask(context)
return SteamButton.entries.filterTo( linkedSetOf() ) { (mask and it.mask) != 0 }
}

fun setCursorHoldClickButtons(context: Context, buttons: Set<SteamButton>) {
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}"

Expand All @@ -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()
}

Expand Down Expand Up @@ -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),
)
}

Expand All @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
})
Expand Down Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SteamButton, XboxTarget> = mapOf(
SteamButton.A to XboxTarget.A,
SteamButton.B to XboxTarget.B,
Expand All @@ -138,8 +143,8 @@ val DEFAULT_MAPPING: Map<SteamButton, XboxTarget> = 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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -33,6 +34,10 @@ data class NamedProfile(
/** Serialised as a JSON object mapping `SteamButton.name` → `XboxTarget.ordinal`. */
val mapping: Map<String, Int>,
val boundPackages: List<String> = 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)
Expand All @@ -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 {
Expand All @@ -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()
Expand Down Expand Up @@ -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),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
)
}
Expand Down
Loading