Skip to content
Merged
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
77 changes: 52 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Use the **Steam Controller 2026** (Valve, codename *Ibex*) as a standard Android gamepad — no root required. Connect via USB OTG / wireless Puck, or directly via Bluetooth.

The app reads the controller's proprietary HID protocol and exposes it to Android as a virtual gamepad through Linux `uinput` (accessed via Shizuku), so any game that supports controllers sees a real input device — Xbox 360, Xbox One, DualShock 4 or DualSense, your choice.
The app reads the controller's proprietary HID protocol and exposes it to Android as a virtual gamepad through Linux `uinput` (accessed via Shizuku), so any game that supports controllers sees a real input device — Xbox 360, Xbox One, DualShock 4 or DualSense, your choice. A **Desktop mode** turns the same controller into a virtual mouse + keyboard, ideal for Android TV boxes.

## Features

Expand All @@ -14,29 +14,47 @@ The app reads the controller's proprietary HID protocol and exposes it to Androi
- **In-app help dialog** explaining the controller's wireless mode combos (Steam+A+R1, Steam+B+R1, etc.)

### Emulation
- **Four virtual gamepad profiles**: Xbox 360 (default), Xbox One, Sony DualShock 4, Sony DualSense
- **Cycle profiles directly from the notification** (`↻ → next profile`) without opening the app
- **Five virtual profiles**: Xbox 360 (default), Xbox One, Sony DualShock 4, Sony DualSense, and **Desktop** (mouse + keyboard)
- **Cycle gamepad profiles directly from the notification** (`↻ → next profile`) without opening the app
- **Real `InputDevice`** via Linux `uinput` (UID shell via Shizuku UserService) — recognised by games as a real gamepad, not filtered like injected events
- **Automatic fallback** to `IInputManager.injectInputEvent` if `/dev/uinput` is denied (less compatible, kept as safety net)

### Desktop mode
- Turns the controller into a virtual **mouse + keyboard** — right trackpad drives the cursor, left trackpad scrolls, buttons map to common keys (volume, play/pause, back, home, enter, escape, tab, space, etc.)
- Even while a gamepad profile is active, the trackpads can double as a mouse sidecar (toggle in Calibration) so you can still navigate menus without switching profiles
- Full **Android TV** support: dedicated banner/leanback UI, D-pad focus navigation, on-screen keyboard shows up correctly when a text field is focused

### Game Profiles
- Save the current calibration, button mapping, rumble intensity and mouse sensitivity as a **named preset**
- Load, rename, duplicate or delete presets from a dedicated screen
- **Bind a preset to one or more apps** — the service automatically switches profile when you launch a bound game (foreground-app detection, no manual step)

### Tuning
- **Per-stick calibration** — radial dead zone (0–30%), center offset capture, Y-axis inversion, live 2D preview
- **Custom button mapping** — categorised list (Face / Bumpers / Stick clicks / System / Back paddles / Grips). Any source button to any target including back paddles L4/L5/R4/R5 and the Quick Access Menu button
- **Custom button mapping** — categorised list (Face / Bumpers / Triggers full-press / Stick clicks / System / Back paddles / Grips), using the official Steam Input button icons. Any source button to any target, including back paddles L4/L5/R4/R5, the Quick Access Menu button, and forcing a trigger to "fully pulled"
- **Special actions** — map any button to **📸 Take screenshot** (saved in Pictures/Screenshots, visible immediately in the gallery)
- **Rumble forwarding pipeline** with adjustable intensity (0–100%) and a manual "Test rumble" button
- **Rumble forwarding pipeline** with adjustable intensity (0–100%) and a manual "Test rumble" button (Bluetooth only for now — see Known limitations)

### Backup & restore
- **Export** every live setting and all Game Profiles to a single JSON file via the system file picker
- **Import** that file back at any time — handy after reinstalling the app or moving to a new phone

### Auto-update
- Checks GitHub Releases for a newer version at launch (once every 24h) or on demand
- Shows the release notes and downloads the signed APK straight from GitHub, then hands off to the system installer

### Debug & status
- **HID debug view** — every button, stick, trigger, trackpad, IMU quaternion and raw hex dump, updated at the controller's ~300 Hz
- **Battery indicator** in the status card and in the notification (USB only — BLE battery report parsing is post-V1 work)
- **Battery indicator** in the status card and in the notification — works over both USB and Bluetooth
- **Persistent foreground service notification** with the active emulation profile, battery, profile-cycle action, and stop action

### Platform
- **Material 3** design with Steam blue accents
- **Adaptive layouts** — phone (max-width 520dp) and tablet (`sw600dp` with two-column layouts)
- **Adaptive layouts** — phone (max-width 520dp), tablet (`sw600dp`, two-column layouts) and Android TV (`television`, leanback navigation)

## Requirements

- Android 8.0+ (API 26)
- Android 8.0+ (API 26) — phone, tablet, or Android TV
- [Shizuku](https://shizuku.rikka.app/) installed and running
- For USB: USB Host (OTG) support on the phone/tablet
- For Bluetooth: standard BLE (available on every modern Android)
Expand All @@ -49,8 +67,9 @@ The app reads the controller's proprietary HID protocol and exposes it to Androi
3. Grant the **POST_NOTIFICATIONS** permission when asked (Android 13+) so the foreground status notification shows up.
4. **For USB**: plug the Puck (or the controller directly) into the OTG port. Android will ask for USB permission.
5. **For Bluetooth**: pair the controller via Android Settings → Bluetooth first, then select it from the Bluetooth device dropdown in the app. Use the `↻` refresh button if you just paired it.
6. Pick the emulated controller profile (Xbox 360 is the safest default — broadest compatibility).
6. Pick the emulated controller profile (Xbox 360 is the safest default for games — broadest compatibility. Pick Desktop for mouse + keyboard, e.g. on Android TV).
7. Hit **Start Service**. The status card shows `Mode: <profile> (uinput) ✓` when everything is up.
8. Optional: tune everything to your liking (calibration, mapping, rumble) and save it as a **Game Profile** — bind it to a game so it auto-loads next time you launch it.

If `uinput` is blocked by SELinux on your device (rare on stock Android, possible on some hardened ROMs), the app falls back to `injectInputEvent`, which works in most apps but is filtered by many games.

Expand All @@ -60,28 +79,37 @@ If `uinput` is blocked by SELinux on your device (rare on stock Android, possibl
Steam Controller (USB or BT)
HID report parser (report 0x45, 53 bytes USB / 45 bytes BLE)
│ validated against SteamlessController.h
HID report parser (report 0x45 state, 53B USB / 45B BLE — plus a
dedicated 0x43 battery status report)
│ validated against SteamlessController.h + hardware capture
ControllerService
• debounce (15-bit injectable mask, 3 frames)
• baseline state (ignore buttons held at startup)
• mapping (Steam buttons → Xbox buttons or special actions)
• per-stick calibration
• rumble intensity scaling
• foreground-app polling → Game Profile auto-switch
UInputGamepad → AIDL/Binder → UInputService (UID shell via Shizuku)
JNI uinput_jni.cpp
┌────────────┴────────────┐
▼ ▼
gamepad device mouse+keyboard sidecar
(Xbox/DS4/DualSense) (Desktop mode, or trackpad-
as-mouse alongside a gamepad)
│ │
└────────────┬─────────────┘
/dev/uinput → kernel
Android sees a real "Microsoft
X-Box 360 pad" (or DS4, etc.)
Android sees a real "Microsoft
X-Box 360 pad" (or DS4, mouse, etc.)
```

The HID protocol is parsed natively and translated into the chosen profile's button/axis layout before being written to a virtual gamepad created via Linux `uinput`. The Shizuku `UserService` runs as the `shell` user (UID 2000) which has access to `/dev/uinput` on most Android builds.
Expand Down Expand Up @@ -110,35 +138,34 @@ For signed release builds and publishing to GitHub Releases, see [RELEASING.md](

## Configuration

Settings are persisted in `SharedPreferences`:
Live settings and Game Profiles are persisted in `SharedPreferences`:

- Selected transport (USB or Bluetooth) and paired BT device address
- Emulated controller profile (Xbox 360 / Xbox One / DS4 / DualSense)
- Emulated profile (Xbox 360 / Xbox One / DS4 / DualSense / Desktop)
- Per-stick calibration (dead zone, center offset, invert Y)
- Per-button mapping (17 source buttons → 12 Xbox targets + special actions like screenshot)
- Rumble intensity (0–100%)
- Per-button mapping (source buttons → Xbox targets, keyboard keys, or special actions like screenshot)
- Rumble intensity (0–100%) and mouse sensitivity (Desktop mode / trackpad sidecar)
- Named Game Profiles, each with its own copy of the settings above plus the list of apps it auto-switches on

You can tweak everything live — most changes apply within ~250 ms (next mapping cache refresh) without restarting the service. Changing transport or emulated profile requires restarting the service (or use the notification's profile cycle action).
You can tweak everything live — most changes apply within ~250 ms (next mapping cache refresh) without restarting the service. Changing transport or emulated profile requires restarting the service (or use the notification's profile cycle action). Use **Export backup** / **Import backup** in the Game Profiles screen to move all of this to a JSON file — useful before uninstalling the app or when setting it up on a new device.

## Known limitations

- **`shell` UID access to `/dev/uinput`** depends on the device's SELinux policy. Most stock Android builds allow it; some hardened ROMs may not. The app falls back to `injectInputEvent` automatically in that case.
- **Steam button** passes through as `KEYCODE_BUTTON_MODE`. Android handles it as the system "Guide" key which may open the launcher in some setups.
- **Rumble byte format** is an empirically-tuned best guess based on the Linux `hid-steam` driver — works for the BT transport but the exact command id and write characteristic may need adjusting for your firmware. USB rumble is not implemented yet.
- **Bluetooth battery level** is not parsed yet — the value comes from a separate 5-byte status report on a different characteristic that the V1 parser ignores. The UI shows `—` over BT.
- **Trackpads** (left and right) are parsed but not currently routed to any output. Future work could expose them as the DualShock 4 touchpad or as a virtual mouse.
- **Gyroscope** (quaternion IMU) is parsed but not yet routed. Gyro aiming is planned for a future release, fits best with the DualShock 4 / DualSense profiles.
- **Rumble byte format** is an empirically-tuned best guess based on the Linux `hid-steam` driver. Works over Bluetooth. **USB rumble is not implemented yet** — the controller only vibrates when connected over BT.
- **Trackpads**: usable as a mouse (Desktop mode, or as an optional sidecar cursor alongside a gamepad profile). Not yet exposed as a DualShock 4/DualSense touchpad to games that support one natively.
- **Gyroscope** (quaternion IMU) is parsed but not yet routed anywhere. Gyro aiming is planned for a future release, fits best with the DualShock 4 / DualSense profiles.
- **Bluetooth auto-reconnect**: if the controller powers off, the app does not retry the GATT connection.
- **Shizuku at reboot**: the user must restart Shizuku after each reboot of the device (an Android limitation, not the app's).

## Roadmap

- **V1.1 — Desktop Mode** (mouse + keyboard emulation, for Android TV boxes)
- Bluetooth battery parsing (separate status report)
- USB rumble implementation
- Trackpad as touchpad (DS4 profile) or mouse (Desktop mode)
- Trackpad as a real touchpad input (DS4/DualSense profile) for games that support it
- Gyro aiming for DS4 / DualSense profiles
- USB / BT auto-reconnect
- HID debug log export ("Log to File")

## Credits

Expand Down
5 changes: 3 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ android {
applicationId = "com.steamcontroller.android"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
versionCode = 2
versionName = "2.0"

ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
Expand All @@ -50,6 +50,7 @@ android {
buildFeatures {
viewBinding = true
aidl = true
buildConfig = true
}

signingConfigs {
Expand Down
56 changes: 53 additions & 3 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-feature android:name="android.hardware.usb.host" android:required="false" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />

<!-- Android TV: touchscreen optional so the app installs on TVs / set-top boxes,
leanback declared (non-required) so it's also visible on phones/tablets. -->
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.software.leanback" android:required="false" />

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<!-- Update checker: fetch latest GitHub release + download/install the signed APK. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

<!-- Android 12+ runtime permissions -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
Expand All @@ -18,11 +28,29 @@
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />

<!-- Auto-switch profile when the foreground app changes (V1.2 Phase 2b).
Special-access permission, granted via Settings → Usage data access. -->
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions" />

<!-- Needed so installed-apps query (AppPickerActivity) sees launcher apps on Android 11+. -->
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent>
</queries>

<application
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:banner="@drawable/tv_banner"
android:icon="@mipmap/ic_launcher"
android:label="Steam Controller"
android:roundIcon="@drawable/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.SteamController">

Expand All @@ -33,6 +61,8 @@
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- Show as a native Leanback (Android TV) launcher tile, not a stretched phone icon -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
Expand All @@ -57,6 +87,16 @@
android:exported="false"
android:parentActivityName=".MainActivity" />

<activity
android:name=".ProfilesActivity"
android:exported="false"
android:parentActivityName=".MainActivity" />

<activity
android:name=".AppPickerActivity"
android:exported="false"
android:parentActivityName=".ProfilesActivity" />

<service
android:name=".service.ControllerService"
android:exported="false"
Expand All @@ -70,6 +110,16 @@
android:enabled="true"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>

</application>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ interface IUInputService {
int leftTrigger, int rightTrigger,
int dpadX, int dpadY);

// Desktop / mouse-mode frame. `keys` is a MouseTarget bitmask.
void sendMouseFrame(int relX, int relY, int scrollY, int keys);

// Returns [strongMagnitude, weakMagnitude] in 0..65535 if a game triggered rumble,
// or null if nothing happened since the last poll.
int[] pollForceFeedback();
Expand All @@ -21,5 +24,9 @@ interface IUInputService {
// Used for screenshot (screencap), and as a general escape hatch for future system actions.
int runShellCommand(in String[] cmd);

// Same, but returns captured stdout (trimmed) instead of the exit code, or null on failure.
// Used to read a Settings value before overriding it, so it can be restored later.
String runShellCommandForOutput(in String[] cmd);

void destroy();
}
Loading
Loading