diff --git a/README.md b/README.md
index d36001f..2f671ba 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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)
@@ -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: (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.
@@ -60,8 +79,9 @@ 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)
@@ -69,6 +89,7 @@ ControllerService
• 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)
@@ -76,12 +97,19 @@ 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.
@@ -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
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 9e6eb83..4d1b21a 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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")
@@ -50,6 +50,7 @@ android {
buildFeatures {
viewBinding = true
aidl = true
+ buildConfig = true
}
signingConfigs {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index da8c938..83e7710 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,13 +1,23 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -33,6 +61,8 @@
+
+
@@ -57,6 +87,16 @@
android:exported="false"
android:parentActivityName=".MainActivity" />
+
+
+
+
+
+
+
+
diff --git a/app/src/main/aidl/com/steamcontroller/android/uinput/IUInputService.aidl b/app/src/main/aidl/com/steamcontroller/android/uinput/IUInputService.aidl
index de56947..74cb26a 100644
--- a/app/src/main/aidl/com/steamcontroller/android/uinput/IUInputService.aidl
+++ b/app/src/main/aidl/com/steamcontroller/android/uinput/IUInputService.aidl
@@ -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();
@@ -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();
}
diff --git a/app/src/main/cpp/uinput_jni.cpp b/app/src/main/cpp/uinput_jni.cpp
index b4d9af3..227c570 100644
--- a/app/src/main/cpp/uinput_jni.cpp
+++ b/app/src/main/cpp/uinput_jni.cpp
@@ -11,6 +11,7 @@
#include
#include
#include
+#include
#define LOG_TAG "uinput_jni"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
@@ -22,15 +23,30 @@ struct gamepad_profile {
uint16_t vid;
uint16_t pid;
const char* name;
+ bool mouse_mode;
};
static const gamepad_profile PROFILES[] = {
- { 0, 0x045E, 0x028E, "Microsoft X-Box 360 pad" }, // XBOX_360
- { 1, 0x045E, 0x02EA, "Microsoft Xbox One Controller" }, // XBOX_ONE
- { 2, 0x054C, 0x05C4, "Sony Interactive Entertainment Wireless Controller" }, // DS4
- { 3, 0x054C, 0x0CE6, "Sony Interactive Entertainment DualSense Wireless Controller" }, // DualSense
+ { 0, 0x045E, 0x028E, "Microsoft X-Box 360 pad", false }, // XBOX_360
+ { 1, 0x045E, 0x02EA, "Microsoft Xbox One Controller", false }, // XBOX_ONE
+ { 2, 0x054C, 0x05C4, "Sony Interactive Entertainment Wireless Controller", false }, // DS4
+ { 3, 0x054C, 0x0CE6, "Sony Interactive Entertainment DualSense Wireless Controller", false }, // DualSense
+ { 4, 0x046D, 0xC077, "Steam Controller Desktop", true }, // MOUSE
};
+// Keyboard keys exposed in mouse mode. Order MUST match MouseKeyBit in MouseTarget.kt.
+static const int MOUSE_KEYS[] = {
+ KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT, // 0..3
+ KEY_ENTER, KEY_BACK, KEY_TAB, KEY_SPACE, // 4..7
+ KEY_HOME, KEY_ESC, // 8..9
+ KEY_VOLUMEUP, KEY_VOLUMEDOWN, // 10..11
+ KEY_PLAYPAUSE, KEY_MENU, // 12..13
+ KEY_BACKSPACE, // 14
+ KEY_SELECT, // 15 → AKEYCODE_DPAD_CENTER, required to "click" on Leanback IME keys
+};
+static constexpr int MOUSE_KEY_COUNT = sizeof(MOUSE_KEYS) / sizeof(MOUSE_KEYS[0]);
+// Bits 16,17,18 = BTN_LEFT, BTN_RIGHT, BTN_MIDDLE (handled separately).
+
static const gamepad_profile* find_profile(int id) {
for (const auto& p : PROFILES) if (p.id == id) return &p;
return &PROFILES[0]; // fallback Xbox 360
@@ -44,7 +60,25 @@ static const gamepad_profile* find_profile(int id) {
#define HAT_MIN -1
#define HAT_MAX 1
-static int g_fd = -1;
+// Up to three uinput devices live in parallel:
+// - g_fd_gamepad: emulated controller (Xbox/PS profiles), opened R/W for FF rumble.
+// - g_fd_mouse: pure mouse (EV_REL + 3 mouse buttons). Always present alongside
+// the gamepad as a "sidecar" so the right trackpad can drive a
+// real cursor while games still see a gamepad. In Desktop mode
+// this IS the primary device.
+// - g_fd_kbd: pure keyboard (EV_KEY). Sidecar in gamepad mode (enables
+// keyboard-key mappings on back paddles etc.); primary in Desktop.
+// A single device declaring EV_REL+EV_KEY together gets classified SOURCE_MOUSE
+// by Android, which makes IMEs ignore its key events — splitting into separate
+// fds mirrors how a real composite USB keyboard+mouse combo looks.
+static int g_fd_gamepad = -1;
+static int g_fd_mouse = -1;
+static int g_fd_kbd = -1;
+
+// Cached "last frame" state for delta encoding in sendMouseFrame.
+// Reset on every destroy_devices() so a new device starts from a clean slate.
+static int g_last_mouse_buttons = 0;
+static int g_last_kbd_keys = 0;
static int set_bit_or_log(int fd, unsigned long req, int bit, const char* what) {
if (ioctl(fd, req, bit) < 0) {
@@ -108,36 +142,171 @@ static ff_slot g_ff_effects[MAX_FF_EFFECTS] = {};
static int32_t g_pending_strong = -1;
static int32_t g_pending_weak = -1;
+// Helper: finalise a uinput device with the given identity strings and create it.
+static int finalize_device(int fd, uint16_t vid, uint16_t pid, const char* name, uint32_t ff_effects_max) {
+ struct uinput_setup us;
+ memset(&us, 0, sizeof(us));
+ us.id.bustype = BUS_USB;
+ us.id.vendor = vid;
+ us.id.product = pid;
+ us.id.version = 0x0114;
+ us.ff_effects_max = ff_effects_max;
+ strncpy(us.name, name, UINPUT_MAX_NAME_SIZE - 1);
+ if (ioctl(fd, UI_DEV_SETUP, &us) < 0) {
+ LOGE("UI_DEV_SETUP failed: %s", strerror(errno));
+ return -1;
+ }
+ if (ioctl(fd, UI_DEV_CREATE) < 0) {
+ LOGE("UI_DEV_CREATE failed: %s", strerror(errno));
+ return -1;
+ }
+ return 0;
+}
+
+// Set up a pure mouse device (EV_REL + 3 mouse buttons). Returns fd or -1.
+static int create_mouse_fd(uint16_t vid, uint16_t pid) {
+ int fd = open("/dev/uinput", O_WRONLY | O_NONBLOCK);
+ if (fd < 0) { LOGE("open /dev/uinput (mouse) failed: %s", strerror(errno)); return -1; }
+ if (set_bit_or_log(fd, UI_SET_EVBIT, EV_KEY, "mouse EV_KEY") < 0) goto fail;
+ if (set_bit_or_log(fd, UI_SET_EVBIT, EV_REL, "mouse EV_REL") < 0) goto fail;
+ if (set_bit_or_log(fd, UI_SET_EVBIT, EV_SYN, "mouse EV_SYN") < 0) goto fail;
+ if (set_bit_or_log(fd, UI_SET_RELBIT, REL_X, "REL_X") < 0) goto fail;
+ if (set_bit_or_log(fd, UI_SET_RELBIT, REL_Y, "REL_Y") < 0) goto fail;
+ if (set_bit_or_log(fd, UI_SET_RELBIT, REL_WHEEL, "REL_WHEEL") < 0) goto fail;
+ if (set_bit_or_log(fd, UI_SET_KEYBIT, BTN_LEFT, "BTN_LEFT") < 0) goto fail;
+ if (set_bit_or_log(fd, UI_SET_KEYBIT, BTN_RIGHT, "BTN_RIGHT") < 0) goto fail;
+ if (set_bit_or_log(fd, UI_SET_KEYBIT, BTN_MIDDLE, "BTN_MIDDLE") < 0) goto fail;
+ if (finalize_device(fd, vid, pid, "Steam Controller Mouse", 0) < 0) goto fail;
+ return fd;
+fail:
+ close(fd);
+ return -1;
+}
+
+// Set up a pure keyboard device (EV_KEY only). Returns fd or -1.
+//
+// `full_alpha` declares the full A-Z/0-9 alphabet so Android's EventHub classifies
+// this device as INPUT_DEVICE_CLASS_ALPHAKEY + DPAD (needed on Android TV so the
+// Leanback IME routes DPAD navigation correctly — see comment below). ONLY pass
+// true for the Desktop-mode keyboard. Passing true for the gamepad-mode sidecar
+// makes Android believe a real hardware QWERTY keyboard is attached at all times,
+// which suppresses the on-screen keyboard for every text field system-wide while
+// the controller is connected — the gamepad already exposes its own ABS_HAT dpad,
+// so the sidecar doesn't need this trick, only the small MOUSE_KEYS set used by
+// back-paddle key mappings.
+static int create_keyboard_fd(uint16_t vid, uint16_t pid, bool full_alpha) {
+ int fd = open("/dev/uinput", O_WRONLY | O_NONBLOCK);
+ if (fd < 0) { LOGE("open /dev/uinput (kbd) failed: %s", strerror(errno)); return -1; }
+ if (set_bit_or_log(fd, UI_SET_EVBIT, EV_KEY, "kbd EV_KEY") < 0) goto fail;
+ if (set_bit_or_log(fd, UI_SET_EVBIT, EV_SYN, "kbd EV_SYN") < 0) goto fail;
+ for (int i = 0; i < MOUSE_KEY_COUNT; i++) {
+ if (set_bit_or_log(fd, UI_SET_KEYBIT, MOUSE_KEYS[i], "MOUSE_KEY") < 0) goto fail;
+ }
+ if (full_alpha) {
+ // Declare the full alphabet + digits so Android's EventHub classifies this
+ // as INPUT_DEVICE_CLASS_ALPHAKEY (cheap test: KEY_Q present).
+ //
+ // Also declare KEY_SELECT (Linux 353) — Generic.kl maps it to DPAD_CENTER,
+ // which is the missing 5th key needed for INPUT_DEVICE_CLASS_DPAD. Without
+ // DPAD class the source is SOURCE_KEYBOARD only, and the Leanback IME on
+ // Android TV source-filters DPAD navigation to SOURCE_DPAD — that's why
+ // arrow presses worked outside the IME but not on the soft keyboard.
+ const int alpha_keys[] = {
+ KEY_A, KEY_B, KEY_C, KEY_D, KEY_E, KEY_F, KEY_G, KEY_H, KEY_I, KEY_J,
+ KEY_K, KEY_L, KEY_M, KEY_N, KEY_O, KEY_P, KEY_Q, KEY_R, KEY_S, KEY_T,
+ KEY_U, KEY_V, KEY_W, KEY_X, KEY_Y, KEY_Z,
+ KEY_0, KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9,
+ KEY_LEFTSHIFT, KEY_RIGHTSHIFT, KEY_LEFTCTRL, KEY_LEFTALT, KEY_CAPSLOCK,
+ KEY_COMMA, KEY_DOT, KEY_SLASH, KEY_SEMICOLON, KEY_APOSTROPHE,
+ KEY_MINUS, KEY_EQUAL,
+ KEY_SELECT, // → AKEYCODE_DPAD_CENTER, completes DPAD classification
+ };
+ for (int k : alpha_keys) {
+ if (set_bit_or_log(fd, UI_SET_KEYBIT, k, "alpha KEY") < 0) goto fail;
+ }
+ }
+ // PID +1 keeps a stable, distinct identity vs the mouse half
+ if (finalize_device(fd, vid, (uint16_t)(pid + 1), "Steam Controller Keyboard", 0) < 0) goto fail;
+ return fd;
+fail:
+ close(fd);
+ return -1;
+}
+
+static void destroy_devices() {
+ bool destroyed = false;
+ if (g_fd_gamepad >= 0) {
+ ioctl(g_fd_gamepad, UI_DEV_DESTROY);
+ close(g_fd_gamepad);
+ g_fd_gamepad = -1;
+ destroyed = true;
+ }
+ if (g_fd_mouse >= 0) {
+ ioctl(g_fd_mouse, UI_DEV_DESTROY);
+ close(g_fd_mouse);
+ g_fd_mouse = -1;
+ destroyed = true;
+ }
+ if (g_fd_kbd >= 0) {
+ ioctl(g_fd_kbd, UI_DEV_DESTROY);
+ close(g_fd_kbd);
+ g_fd_kbd = -1;
+ destroyed = true;
+ }
+ // Any cached delta-state belonged to the destroyed device — reset.
+ g_last_mouse_buttons = 0;
+ g_last_kbd_keys = 0;
+ if (destroyed) {
+ // Give the kernel + InputReader a moment to fully release the input
+ // device records. Without this, rapid profile switching can hit transient
+ // failures (UI_DEV_CREATE returns success but Android never sees the new
+ // device, leaving the UI thinking it's healthy while no events flow).
+ struct timespec ts = { 0, 80 * 1000 * 1000 }; // 80ms
+ nanosleep(&ts, nullptr);
+ }
+}
+
extern "C" JNIEXPORT jboolean JNICALL
Java_com_steamcontroller_android_uinput_UInputNative_createDevice(JNIEnv*, jclass, jint profileId) {
const gamepad_profile* prof = find_profile(profileId);
LOGI("createDevice: profile=%d (VID=0x%04X PID=0x%04X name=\"%s\")",
prof->id, prof->vid, prof->pid, prof->name);
- if (g_fd >= 0) {
- // Already created — destroy and recreate
- ioctl(g_fd, UI_DEV_DESTROY);
- close(g_fd);
- g_fd = -1;
+
+ destroy_devices();
+
+ if (prof->mouse_mode) {
+ // Desktop: just mouse + keyboard, no gamepad.
+ int mouse_fd = create_mouse_fd(prof->vid, prof->pid);
+ if (mouse_fd < 0) return JNI_FALSE;
+ int kbd_fd = create_keyboard_fd(prof->vid, prof->pid, /*full_alpha=*/true);
+ if (kbd_fd < 0) {
+ ioctl(mouse_fd, UI_DEV_DESTROY);
+ close(mouse_fd);
+ return JNI_FALSE;
+ }
+ g_fd_mouse = mouse_fd;
+ g_fd_kbd = kbd_fd;
+ LOGI("Desktop devices created — mouse fd=%d, kbd fd=%d", mouse_fd, kbd_fd);
+ return JNI_TRUE;
}
- // Open R/W: we need to read from the fd to receive FF (rumble) commands from games.
+ // Gamepad path — gamepad device + sidecar mouse + sidecar keyboard.
+ // The sidecar pair lets the right trackpad drive a cursor and lets back-paddle
+ // mappings hit keyboard keys, while games still see a proper gamepad.
int fd = open("/dev/uinput", O_RDWR | O_NONBLOCK);
if (fd < 0) {
LOGE("open /dev/uinput failed: %s", strerror(errno));
return JNI_FALSE;
}
- // Enable event types
if (set_bit_or_log(fd, UI_SET_EVBIT, EV_KEY, "EV_KEY") < 0) goto fail;
if (set_bit_or_log(fd, UI_SET_EVBIT, EV_ABS, "EV_ABS") < 0) goto fail;
if (set_bit_or_log(fd, UI_SET_EVBIT, EV_SYN, "EV_SYN") < 0) goto fail;
if (set_bit_or_log(fd, UI_SET_EVBIT, EV_FF, "EV_FF") < 0) goto fail;
- // Supported FF effects — rumble is the universal one
if (set_bit_or_log(fd, UI_SET_FFBIT, FF_RUMBLE, "FF_RUMBLE") < 0) goto fail;
if (set_bit_or_log(fd, UI_SET_FFBIT, FF_PERIODIC, "FF_PERIODIC") < 0) goto fail;
- // Buttons — match Xbox 360 controller layout exactly
{
const int btns[] = {
BTN_A, BTN_B, BTN_X, BTN_Y,
@@ -150,7 +319,6 @@ Java_com_steamcontroller_android_uinput_UInputNative_createDevice(JNIEnv*, jclas
}
}
- // Axes
if (setup_abs(fd, ABS_X, STICK_MIN, STICK_MAX, 16, 128) < 0) goto fail;
if (setup_abs(fd, ABS_Y, STICK_MIN, STICK_MAX, 16, 128) < 0) goto fail;
if (setup_abs(fd, ABS_RX, STICK_MIN, STICK_MAX, 16, 128) < 0) goto fail;
@@ -160,34 +328,25 @@ Java_com_steamcontroller_android_uinput_UInputNative_createDevice(JNIEnv*, jclas
if (setup_abs(fd, ABS_HAT0X, HAT_MIN, HAT_MAX, 0, 0) < 0) goto fail;
if (setup_abs(fd, ABS_HAT0Y, HAT_MIN, HAT_MAX, 0, 0) < 0) goto fail;
- // Device identity
- {
- struct uinput_setup us;
- memset(&us, 0, sizeof(us));
- us.id.bustype = BUS_USB;
- us.id.vendor = prof->vid;
- us.id.product = prof->pid;
- us.id.version = 0x0114;
- us.ff_effects_max = MAX_FF_EFFECTS;
- strncpy(us.name, prof->name, UINPUT_MAX_NAME_SIZE - 1);
- if (ioctl(fd, UI_DEV_SETUP, &us) < 0) {
- LOGE("UI_DEV_SETUP failed: %s", strerror(errno));
- goto fail;
- }
- }
+ if (finalize_device(fd, prof->vid, prof->pid, prof->name, MAX_FF_EFFECTS) < 0) goto fail;
- // Reset FF state
memset(g_ff_effects, 0, sizeof(g_ff_effects));
g_pending_strong = -1;
g_pending_weak = -1;
- if (ioctl(fd, UI_DEV_CREATE) < 0) {
- LOGE("UI_DEV_CREATE failed: %s", strerror(errno));
- goto fail;
- }
-
LOGI("Virtual gamepad created (profile=%d), fd=%d", prof->id, fd);
- g_fd = 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");
+ }
+ 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");
+ }
+ LOGI("Gamepad devices ready — gamepad=%d, mouse=%d, kbd=%d", g_fd_gamepad, g_fd_mouse, g_fd_kbd);
return JNI_TRUE;
fail:
@@ -202,7 +361,7 @@ Java_com_steamcontroller_android_uinput_UInputNative_sendFrame(
jint lx, jint ly, jint rx, jint ry,
jint lt, jint rt,
jint dpadX, jint dpadY) {
- if (g_fd < 0) return;
+ if (g_fd_gamepad < 0) return;
// Bit-to-keycode pairs — order must match XboxDescriptor.kt bit positions.
// bit 0 = A, 1 = B, 2 = X, 3 = Y, 4 = LB, 5 = RB,
@@ -216,19 +375,19 @@ Java_com_steamcontroller_android_uinput_UInputNative_sendFrame(
const int n = sizeof(bit_to_key) / sizeof(bit_to_key[0]);
for (int i = 0; i < n; i++) {
int pressed = (buttons >> i) & 1;
- write_event(g_fd, EV_KEY, bit_to_key[i], pressed);
+ write_event(g_fd_gamepad, EV_KEY, bit_to_key[i], pressed);
}
- write_event(g_fd, EV_ABS, ABS_X, lx);
- write_event(g_fd, EV_ABS, ABS_Y, ly);
- write_event(g_fd, EV_ABS, ABS_RX, rx);
- write_event(g_fd, EV_ABS, ABS_RY, ry);
- write_event(g_fd, EV_ABS, ABS_Z, lt);
- write_event(g_fd, EV_ABS, ABS_RZ, rt);
- write_event(g_fd, EV_ABS, ABS_HAT0X, dpadX);
- write_event(g_fd, EV_ABS, ABS_HAT0Y, dpadY);
+ write_event(g_fd_gamepad, EV_ABS, ABS_X, lx);
+ write_event(g_fd_gamepad, EV_ABS, ABS_Y, ly);
+ write_event(g_fd_gamepad, EV_ABS, ABS_RX, rx);
+ write_event(g_fd_gamepad, EV_ABS, ABS_RY, ry);
+ write_event(g_fd_gamepad, EV_ABS, ABS_Z, lt);
+ write_event(g_fd_gamepad, EV_ABS, ABS_RZ, rt);
+ write_event(g_fd_gamepad, EV_ABS, ABS_HAT0X, dpadX);
+ write_event(g_fd_gamepad, EV_ABS, ABS_HAT0Y, dpadY);
- write_event(g_fd, EV_SYN, SYN_REPORT, 0);
+ write_event(g_fd_gamepad, EV_SYN, SYN_REPORT, 0);
}
// Drain any pending events from the uinput fd. We care about:
@@ -239,16 +398,16 @@ Java_com_steamcontroller_android_uinput_UInputNative_sendFrame(
// or null if no rumble event is pending.
extern "C" JNIEXPORT jintArray JNICALL
Java_com_steamcontroller_android_uinput_UInputNative_pollFFEvent(JNIEnv* env, jclass) {
- if (g_fd < 0) return nullptr;
+ if (g_fd_gamepad < 0) return nullptr;
struct input_event ev;
// Drain everything available — there may be multiple events in flight
- while (read(g_fd, &ev, sizeof(ev)) == (ssize_t)sizeof(ev)) {
+ while (read(g_fd_gamepad, &ev, sizeof(ev)) == (ssize_t)sizeof(ev)) {
if (ev.type == EV_UINPUT && ev.code == UI_FF_UPLOAD) {
struct uinput_ff_upload upload;
memset(&upload, 0, sizeof(upload));
upload.request_id = ev.value;
- if (ioctl(g_fd, UI_BEGIN_FF_UPLOAD, &upload) >= 0) {
+ if (ioctl(g_fd_gamepad, UI_BEGIN_FF_UPLOAD, &upload) >= 0) {
if (upload.effect.type == FF_RUMBLE) {
// Find/use slot matching effect.id (or first free)
int slot = -1;
@@ -267,13 +426,13 @@ Java_com_steamcontroller_android_uinput_UInputNative_pollFFEvent(JNIEnv* env, jc
}
}
upload.retval = 0;
- ioctl(g_fd, UI_END_FF_UPLOAD, &upload);
+ ioctl(g_fd_gamepad, UI_END_FF_UPLOAD, &upload);
}
} else if (ev.type == EV_UINPUT && ev.code == UI_FF_ERASE) {
struct uinput_ff_erase erase;
memset(&erase, 0, sizeof(erase));
erase.request_id = ev.value;
- if (ioctl(g_fd, UI_BEGIN_FF_ERASE, &erase) >= 0) {
+ if (ioctl(g_fd_gamepad, UI_BEGIN_FF_ERASE, &erase) >= 0) {
for (int i = 0; i < MAX_FF_EFFECTS; i++) {
if (g_ff_effects[i].id == (int)erase.effect_id) {
g_ff_effects[i] = {};
@@ -281,7 +440,7 @@ Java_com_steamcontroller_android_uinput_UInputNative_pollFFEvent(JNIEnv* env, jc
}
}
erase.retval = 0;
- ioctl(g_fd, UI_END_FF_ERASE, &erase);
+ ioctl(g_fd_gamepad, UI_END_FF_ERASE, &erase);
}
} else if (ev.type == EV_FF) {
// Play or stop. ev.code = effect id, ev.value = play count (0 = stop)
@@ -311,12 +470,54 @@ Java_com_steamcontroller_android_uinput_UInputNative_pollFFEvent(JNIEnv* env, jc
return result;
}
+// Mouse + keyboard frame. `relX`/`relY` are mouse motion deltas, `scrollY` is wheel ticks,
+// `keys` is a bitmask: bits 0..MOUSE_KEY_COUNT-1 = MOUSE_KEYS entries (routed to the
+// keyboard fd), bits 15/16/17 = BTN_LEFT/RIGHT/MIDDLE (routed to the mouse fd).
+//
+// Only writes EV_KEY events when the bit actually changes, and only writes SYN_REPORT
+// on a fd that emitted at least one event this frame. This is required for IME focus:
+// if the mouse fd reports every frame (300Hz), Android keeps the cursor "active" and
+// routes DPAD events to it instead of the focused IME view.
extern "C" JNIEXPORT void JNICALL
-Java_com_steamcontroller_android_uinput_UInputNative_destroy(JNIEnv*, jclass) {
- if (g_fd >= 0) {
- ioctl(g_fd, UI_DEV_DESTROY);
- close(g_fd);
- g_fd = -1;
- LOGI("Virtual gamepad destroyed");
+Java_com_steamcontroller_android_uinput_UInputNative_sendMouseFrame(
+ JNIEnv*, jclass, jint relX, jint relY, jint scrollY, jint keys) {
+ // Bit 16/17/18 of `keys` = BTN_LEFT/RIGHT/MIDDLE; bits 0..MOUSE_KEY_COUNT-1 = keyboard keys.
+ const int mouse_bits = keys & ((1 << 16) | (1 << 17) | (1 << 18));
+ const int kbd_bits = keys & ((1 << MOUSE_KEY_COUNT) - 1);
+
+ // Mouse fd: motion + button edges
+ if (g_fd_mouse >= 0) {
+ bool any_event = false;
+ const int btn_changed = mouse_bits ^ g_last_mouse_buttons;
+ if (btn_changed & (1 << 16)) { write_event(g_fd_mouse, EV_KEY, BTN_LEFT, (mouse_bits >> 16) & 1); any_event = true; }
+ if (btn_changed & (1 << 17)) { write_event(g_fd_mouse, EV_KEY, BTN_RIGHT, (mouse_bits >> 17) & 1); any_event = true; }
+ if (btn_changed & (1 << 18)) { write_event(g_fd_mouse, EV_KEY, BTN_MIDDLE, (mouse_bits >> 18) & 1); any_event = true; }
+ if (relX != 0) { write_event(g_fd_mouse, EV_REL, REL_X, relX); any_event = true; }
+ if (relY != 0) { write_event(g_fd_mouse, EV_REL, REL_Y, relY); any_event = true; }
+ if (scrollY != 0) { write_event(g_fd_mouse, EV_REL, REL_WHEEL, scrollY); any_event = true; }
+ if (any_event) {
+ write_event(g_fd_mouse, EV_SYN, SYN_REPORT, 0);
+ g_last_mouse_buttons = mouse_bits;
+ }
}
+
+ // Keyboard fd: only emit on bit change
+ if (g_fd_kbd >= 0) {
+ const int kbd_changed = kbd_bits ^ g_last_kbd_keys;
+ if (kbd_changed != 0) {
+ for (int i = 0; i < MOUSE_KEY_COUNT; i++) {
+ if (kbd_changed & (1 << i)) {
+ write_event(g_fd_kbd, EV_KEY, MOUSE_KEYS[i], (kbd_bits >> i) & 1);
+ }
+ }
+ write_event(g_fd_kbd, EV_SYN, SYN_REPORT, 0);
+ g_last_kbd_keys = kbd_bits;
+ }
+ }
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_steamcontroller_android_uinput_UInputNative_destroy(JNIEnv*, jclass) {
+ destroy_devices();
+ LOGI("Virtual devices destroyed");
}
diff --git a/app/src/main/java/com/steamcontroller/android/AppPickerActivity.kt b/app/src/main/java/com/steamcontroller/android/AppPickerActivity.kt
new file mode 100644
index 0000000..e077191
--- /dev/null
+++ b/app/src/main/java/com/steamcontroller/android/AppPickerActivity.kt
@@ -0,0 +1,138 @@
+package com.steamcontroller.android
+
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.drawable.Drawable
+import android.os.Bundle
+import android.widget.ImageView
+import android.widget.TextView
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.materialswitch.MaterialSwitch
+import com.steamcontroller.android.databinding.ActivityAppPickerBinding
+import com.steamcontroller.android.service.UsageStatsHelper
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * Lets the user tick installed launcher apps that should auto-switch to a given
+ * NamedProfile. The selection is saved back onto the profile's `boundPackages` list.
+ */
+class AppPickerActivity : AppCompatActivity() {
+
+ companion object {
+ const val EXTRA_PROFILE_ID = "profile_id"
+ const val EXTRA_PRESELECTED = "preselected_packages"
+ }
+
+ private lateinit var binding: ActivityAppPickerBinding
+ private var profileId: String = ""
+ private val checkedPackages = mutableSetOf()
+
+ private data class AppEntry(val packageName: String, val label: String, val icon: Drawable)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityAppPickerBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ profileId = intent.getStringExtra(EXTRA_PROFILE_ID).orEmpty()
+ intent.getStringArrayListExtra(EXTRA_PRESELECTED)?.let { checkedPackages.addAll(it) }
+
+ binding.toolbar.setNavigationOnClickListener { finish() }
+ binding.toolbar.setOnMenuItemClickListener { item ->
+ if (item.itemId == R.id.action_save) {
+ persistAndFinish()
+ true
+ } else false
+ }
+
+ loadAppsAsync()
+ maybePromptUsageAccess()
+ }
+
+ /**
+ * Auto-switch only works once the user grants PACKAGE_USAGE_STATS access in Settings.
+ * If we're about to bind apps without that, surface a one-tap path to the system screen.
+ */
+ private fun maybePromptUsageAccess() {
+ if (UsageStatsHelper.hasPermission(this)) return
+ MaterialAlertDialogBuilder(this)
+ .setTitle("Enable auto-switch?")
+ .setMessage(
+ "To swap profiles automatically when you launch a bound app, allow Steam Controller " +
+ "to read \"Usage data access\" in Android Settings. Without it, your bindings are saved " +
+ "but no automatic switching will happen."
+ )
+ .setPositiveButton("Open Settings") { d, _ -> UsageStatsHelper.openSettingsScreen(this); d.dismiss() }
+ .setNegativeButton("Skip", null)
+ .show()
+ }
+
+ private fun loadAppsAsync() {
+ lifecycleScope.launch {
+ val entries = withContext(Dispatchers.IO) { queryLauncherApps() }
+ renderRows(entries)
+ }
+ }
+
+ /**
+ * Returns every app the launcher would normally show. Filters by intent CATEGORY_LAUNCHER
+ * (or LEANBACK_LAUNCHER on Android TV) so the list is the same one the user sees on their
+ * home screen — no system services or background-only packages.
+ */
+ private fun queryLauncherApps(): List {
+ val pm = packageManager
+ val mainIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
+ val leanbackIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LEANBACK_LAUNCHER)
+
+ val combined = pm.queryIntentActivities(mainIntent, 0) + pm.queryIntentActivities(leanbackIntent, 0)
+ val seen = HashSet()
+ return combined.mapNotNull { ri ->
+ val pkg = ri.activityInfo?.packageName ?: return@mapNotNull null
+ if (!seen.add(pkg)) return@mapNotNull null
+ try {
+ AppEntry(
+ packageName = pkg,
+ label = ri.loadLabel(pm).toString(),
+ icon = ri.loadIcon(pm),
+ )
+ } catch (_: Throwable) { null }
+ }.sortedBy { it.label.lowercase() }
+ }
+
+ private fun renderRows(entries: List) {
+ binding.appsContainer.removeAllViews()
+ for (entry in entries) {
+ val row = layoutInflater.inflate(R.layout.item_app_row, binding.appsContainer, false)
+ row.findViewById(R.id.ivAppIcon).setImageDrawable(entry.icon)
+ row.findViewById(R.id.tvAppName).text = entry.label
+
+ val sw = row.findViewById(R.id.switchAppBound)
+ sw.isChecked = entry.packageName in checkedPackages
+ sw.setOnCheckedChangeListener { _, checked ->
+ if (checked) checkedPackages.add(entry.packageName)
+ else checkedPackages.remove(entry.packageName)
+ }
+ row.setOnClickListener { sw.toggle() }
+
+ binding.appsContainer.addView(row)
+ }
+ }
+
+ private fun persistAndFinish() {
+ if (profileId.isEmpty()) { finish(); return }
+ val existing = Prefs.listNamedProfiles(this).firstOrNull { it.id == profileId }
+ if (existing == null) {
+ Toast.makeText(this, "Profile no longer exists", Toast.LENGTH_SHORT).show()
+ finish()
+ return
+ }
+ Prefs.saveNamedProfile(this, existing.copy(boundPackages = checkedPackages.toList()))
+ Toast.makeText(this, "Bindings saved (${checkedPackages.size})", Toast.LENGTH_SHORT).show()
+ finish()
+ }
+}
diff --git a/app/src/main/java/com/steamcontroller/android/CalibrationActivity.kt b/app/src/main/java/com/steamcontroller/android/CalibrationActivity.kt
index 4dd65c5..4fb712e 100644
--- a/app/src/main/java/com/steamcontroller/android/CalibrationActivity.kt
+++ b/app/src/main/java/com/steamcontroller/android/CalibrationActivity.kt
@@ -62,6 +62,23 @@ class CalibrationActivity : AppCompatActivity() {
Prefs.setRumbleIntensity(this, pct)
})
+ // Mouse sensitivity slider — only relevant in Desktop profile but always visible
+ val savedSens = Prefs.getMouseSensitivity(this)
+ binding.sliderMouseSensitivity.value = savedSens
+ binding.tvMouseSensitivity.text = "%.1f×".format(savedSens)
+ binding.sliderMouseSensitivity.addOnChangeListener(Slider.OnChangeListener { _, value, _ ->
+ binding.tvMouseSensitivity.text = "%.1f×".format(value)
+ Prefs.setMouseSensitivity(this, value)
+ })
+
+ // Trackpads-as-mouse toggle (active alongside Xbox/PS profiles only).
+ // UInputGamepad re-reads the pref at most every 250ms so flipping it is
+ // effectively live without restarting the service.
+ binding.switchTrackpadAsMouse.isChecked = Prefs.getTrackpadAsMouseInGamepad(this)
+ binding.switchTrackpadAsMouse.setOnCheckedChangeListener { _, checked ->
+ Prefs.setTrackpadAsMouseInGamepad(this, checked)
+ }
+
// Live preview from the running service
lifecycleScope.launch {
ControllerService.stateFlow.filterNotNull().collect { state ->
@@ -122,13 +139,14 @@ class CalibrationActivity : AppCompatActivity() {
saveRight()
}
- binding.btnLeftSetCenter.setOnClickListener {
- leftCal = leftCal.copy(centerX = lastLeftRawX, centerY = lastLeftRawY)
- saveLeft()
- }
- binding.btnRightSetCenter.setOnClickListener {
+ // V1.2: every layout (phone, sw600dp, TV) exposes a single Calibrate All button.
+ // The old per-stick Set center buttons were removed; their bindings would NPE.
+ binding.btnCalibrateAll.setOnClickListener {
+ leftCal = leftCal.copy(centerX = lastLeftRawX, centerY = lastLeftRawY)
rightCal = rightCal.copy(centerX = lastRightRawX, centerY = lastRightRawY)
+ saveLeft()
saveRight()
+ android.widget.Toast.makeText(this, "Sticks calibrated", android.widget.Toast.LENGTH_SHORT).show()
}
}
diff --git a/app/src/main/java/com/steamcontroller/android/DebugActivity.kt b/app/src/main/java/com/steamcontroller/android/DebugActivity.kt
index 239a8cc..e5f2b45 100644
--- a/app/src/main/java/com/steamcontroller/android/DebugActivity.kt
+++ b/app/src/main/java/com/steamcontroller/android/DebugActivity.kt
@@ -2,6 +2,7 @@ package com.steamcontroller.android
import android.os.Bundle
import android.widget.TextView
+import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.steamcontroller.android.databinding.ActivityDebugBinding
@@ -25,6 +26,12 @@ class DebugActivity : AppCompatActivity() {
binding = ActivityDebugBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.toolbar.setNavigationOnClickListener { finish() }
+ binding.toolbar.setOnMenuItemClickListener { item ->
+ if (item.itemId == R.id.action_log_to_file) {
+ Toast.makeText(this, "Log to File coming in V1.2.x", Toast.LENGTH_SHORT).show()
+ true
+ } else false
+ }
lifecycleScope.launch {
ControllerService.stateFlow.filterNotNull().collect { state ->
@@ -42,27 +49,39 @@ class DebugActivity : AppCompatActivity() {
}
private fun updateButtons(s: SteamControllerState) {
- fun chip(tv: TextView, mask: Int) {
+ /**
+ * Toggles a chip's background + text colour to reflect button state.
+ * V1.2 chips come in two shapes — rectangular pill (system/grips/back) and
+ * circular (face buttons, DPAD, stick clicks). We pick the right drawable
+ * pair based on `circular`.
+ */
+ fun chip(tv: TextView, mask: Int, circular: Boolean = false) {
val active = s.isButtonPressed(mask)
- tv.setBackgroundResource(if (active) R.drawable.chip_bg_active else R.drawable.chip_bg)
+ val bg = when {
+ circular && active -> R.drawable.chip_circle_bg_active
+ circular -> R.drawable.chip_circle_bg
+ !circular && active -> R.drawable.chip_bg_active
+ else -> R.drawable.chip_bg
+ }
+ tv.setBackgroundResource(bg)
tv.setTextColor(getColor(if (active) android.R.color.black else R.color.chip_inactive))
}
- chip(binding.btnA, Buttons.A)
- chip(binding.btnB, Buttons.B)
- chip(binding.btnX, Buttons.X)
- chip(binding.btnY, Buttons.Y)
+ chip(binding.btnA, Buttons.A, circular = true)
+ chip(binding.btnB, Buttons.B, circular = true)
+ chip(binding.btnX, Buttons.X, circular = true)
+ chip(binding.btnY, Buttons.Y, circular = true)
chip(binding.btnLB, Buttons.LB)
chip(binding.btnRB, Buttons.RB)
chip(binding.btnSelect, Buttons.VIEW)
chip(binding.btnSteam, Buttons.STEAM)
chip(binding.btnStart, Buttons.MENU)
chip(binding.btnQA, Buttons.QUICK_ACCESS)
- chip(binding.btnDU, Buttons.DPAD_UP)
- chip(binding.btnDD, Buttons.DPAD_DOWN)
- chip(binding.btnDL, Buttons.DPAD_LEFT)
- chip(binding.btnDR, Buttons.DPAD_RIGHT)
- chip(binding.btnLS, Buttons.LS)
- chip(binding.btnRS, Buttons.RS)
+ chip(binding.btnDU, Buttons.DPAD_UP, circular = true)
+ chip(binding.btnDD, Buttons.DPAD_DOWN, circular = true)
+ chip(binding.btnDL, Buttons.DPAD_LEFT, circular = true)
+ chip(binding.btnDR, Buttons.DPAD_RIGHT, circular = true)
+ chip(binding.btnLS, Buttons.LS, circular = true)
+ chip(binding.btnRS, Buttons.RS, circular = true)
chip(binding.btnLGrip, Buttons.GRIP_LT)
chip(binding.btnRGrip, Buttons.GRIP_RT)
chip(binding.btnL4, Buttons.L4)
@@ -72,16 +91,32 @@ class DebugActivity : AppCompatActivity() {
}
private fun updateAxes(s: SteamControllerState) {
- binding.pbLT.progress = s.leftTrigger / 128 // 0-32767 → 0-255 for progress bar
+ // Triggers — already 0-32767, scale to 0-255 for ProgressBar.
+ binding.pbLT.progress = s.leftTrigger / 128
binding.tvLT.text = s.leftTrigger.toString()
binding.pbRT.progress = s.rightTrigger / 128
binding.tvRT.text = s.rightTrigger.toString()
+ // Sticks — raw is ±32767 (Int16). Centre the bar by offsetting +32768 against max=65535.
+ // ProgressBars are phone-layout-only; sw600dp / TV variants don't have them yet, so
+ // the binding fields are nullable — use safe calls.
+ fun setSignedBar(progressView: android.widget.ProgressBar?, raw: Int) {
+ progressView?.progress = (raw + 32768).coerceIn(0, 65535)
+ }
+ setSignedBar(binding.pbLSX, s.leftJoyX.toInt())
+ setSignedBar(binding.pbLSY, s.leftJoyY.toInt())
+ setSignedBar(binding.pbRSX, s.rightJoyX.toInt())
+ setSignedBar(binding.pbRSY, s.rightJoyY.toInt())
binding.tvLSX.text = "X: %6d".format(s.leftJoyX.toInt())
binding.tvLSY.text = "Y: %6d".format(s.leftJoyY.toInt())
binding.tvRSX.text = "X: %6d".format(s.rightJoyX.toInt())
binding.tvRSY.text = "Y: %6d".format(s.rightJoyY.toInt())
+ // Trackpads — same signed range, same bar trick.
+ setSignedBar(binding.pbLPX, s.leftPadX.toInt())
+ setSignedBar(binding.pbLPY, s.leftPadY.toInt())
+ setSignedBar(binding.pbRPX, s.rightPadX.toInt())
+ setSignedBar(binding.pbRPY, s.rightPadY.toInt())
binding.tvLPX.text = "X: %6d".format(s.leftPadX.toInt())
binding.tvLPY.text = "Y: %6d".format(s.leftPadY.toInt())
binding.tvRPX.text = "X: %6d".format(s.rightPadX.toInt())
@@ -117,5 +152,4 @@ class DebugActivity : AppCompatActivity() {
}
return sb.toString().trimEnd()
}
-
}
diff --git a/app/src/main/java/com/steamcontroller/android/MainActivity.kt b/app/src/main/java/com/steamcontroller/android/MainActivity.kt
index b3b0b9c..140ba95 100644
--- a/app/src/main/java/com/steamcontroller/android/MainActivity.kt
+++ b/app/src/main/java/com/steamcontroller/android/MainActivity.kt
@@ -1,5 +1,6 @@
package com.steamcontroller.android
+import android.app.DownloadManager
import android.app.PendingIntent
import android.content.*
import android.hardware.usb.UsbDevice
@@ -8,12 +9,15 @@ import android.Manifest
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.content.pm.PackageManager
+import android.net.Uri
import android.os.Build
import android.os.Bundle
+import android.os.Environment
import android.util.Log
import android.view.View
import android.widget.ArrayAdapter
import android.widget.Filter
+import android.widget.RadioGroup
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
@@ -24,9 +28,13 @@ import com.steamcontroller.android.bt.BluetoothHidManager
import com.steamcontroller.android.databinding.ActivityMainBinding
import com.steamcontroller.android.service.ControllerService
import com.steamcontroller.android.uinput.GamepadProfile
+import com.steamcontroller.android.update.UpdateChecker
+import com.steamcontroller.android.update.UpdateInstaller
import com.steamcontroller.android.usb.UsbConnectionManager
import kotlinx.coroutines.launch
import rikka.shizuku.Shizuku
+import java.io.File
+import java.util.concurrent.TimeUnit
class MainActivity : AppCompatActivity() {
@@ -34,8 +42,16 @@ class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private var serviceRunning = false
private var pairedBtDevices: List = emptyList()
+ // Tracks whether the current "started" session has reached a working injection mode.
+ // Used so the modeFlow observer doesn't mistake StateFlow's initial NONE replay for
+ // an external service stop right after the user pressed Start.
+ private var hasSeenActiveMode = false
private val usbPermissionAction = "com.steamcontroller.android.USB_PERMISSION"
+ private val githubRepoUrl = "https://github.com/SonicDX12/SteamController-Android"
+
+ private var pendingUpdateDownloadId: Long = -1L
+ private var pendingUpdateApkFile: File? = null
private val usbReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
@@ -63,6 +79,17 @@ class MainActivity : AppCompatActivity() {
}
}
+ private val downloadReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L)
+ if (id == -1L || id != pendingUpdateDownloadId) return
+ val apkFile = pendingUpdateApkFile ?: return
+ pendingUpdateDownloadId = -1L
+ pendingUpdateApkFile = null
+ UpdateInstaller.install(this@MainActivity, apkFile)
+ }
+ }
+
private val shizukuRequestCode = 1001
private val shizukuPermissionListener = Shizuku.OnRequestPermissionResultListener { _, grantResult ->
@@ -81,6 +108,8 @@ class MainActivity : AppCompatActivity() {
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
+ binding.tvSubtitle.text = "${binding.tvSubtitle.text} · v${BuildConfig.VERSION_NAME}"
+
Shizuku.addRequestPermissionResultListener(shizukuPermissionListener)
val filter = IntentFilter().apply {
@@ -89,6 +118,10 @@ class MainActivity : AppCompatActivity() {
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
}
ContextCompat.registerReceiver(this, usbReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
+ ContextCompat.registerReceiver(
+ this, downloadReceiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
+ ContextCompat.RECEIVER_NOT_EXPORTED,
+ )
binding.btnToggleService.setOnClickListener {
if (serviceRunning) {
@@ -110,8 +143,13 @@ class MainActivity : AppCompatActivity() {
startActivity(Intent(this, MappingActivity::class.java))
}
+ binding.btnGameProfiles.setOnClickListener {
+ startActivity(Intent(this, ProfilesActivity::class.java))
+ }
+
setupTransportDropdown()
- setupProfileDropdown()
+ setupControlModeToggle()
+ setupGamepadVariantRadios()
requestNotificationPermissionIfNeeded()
binding.btnRefreshBt.setOnClickListener {
@@ -120,16 +158,36 @@ class MainActivity : AppCompatActivity() {
}
binding.btnHelp.setOnClickListener { showConnectionHelpDialog() }
+ binding.btnGithub.setOnClickListener { openGithubRepo() }
+ binding.btnCheckUpdate.setOnClickListener { checkForUpdates(manual = true) }
+
+ maybeAutoCheckForUpdates()
// Observe injection mode changes from the service.
// Also detect the service being stopped externally (e.g. via the notification action)
// and re-sync MainActivity's UI state so the button flips back to "Start".
+ //
+ // Subtlety: a fresh `collect` on a StateFlow immediately replays its current value,
+ // which is `NONE` when no service ever ran. If the user taps Start *before* that
+ // initial replay runs on the Main thread, we'd see `mode==NONE && serviceRunning==true`
+ // and incorrectly reset the button back to "Start". `hasSeenActiveMode` defends
+ // against that — we only treat a NONE as "service stopped" once we've previously
+ // observed a working mode in this session.
lifecycleScope.launch {
ControllerService.modeFlow.collect { mode ->
refreshModeLabel()
- if (mode == ControllerService.InjectionMode.NONE && serviceRunning) {
+ if (mode != ControllerService.InjectionMode.NONE) {
+ hasSeenActiveMode = true
+ // Catch-up sync: activity re-entered while service was already running.
+ // Without this, the toggle button stays "Start" even though the service is live.
+ if (!serviceRunning) {
+ serviceRunning = true
+ binding.btnToggleService.text = getString(R.string.btn_stop)
+ }
+ } else if (serviceRunning && hasSeenActiveMode) {
serviceRunning = false
+ hasSeenActiveMode = false
updateStatus(connected = false)
binding.btnToggleService.text = getString(R.string.btn_start)
log("Service stopped")
@@ -143,7 +201,7 @@ class MainActivity : AppCompatActivity() {
ControllerService.profileFlow.collect { profileId ->
if (profileId == null) return@collect
val profile = com.steamcontroller.android.uinput.GamepadProfile.fromId(profileId)
- binding.dropdownProfile.setText(profile.displayName, false)
+ syncControlModeToggle(profile)
refreshModeLabel()
}
}
@@ -155,6 +213,15 @@ class MainActivity : AppCompatActivity() {
}
}
+ // Observe real connection state — `stateFlow` only carries a non-null value
+ // once at least one HID frame has been parsed from the controller. That's the
+ // signal we trust for "controller actually plugged in / paired and streaming".
+ lifecycleScope.launch {
+ ControllerService.stateFlow.collect { state ->
+ updateStatus(connected = state != null)
+ }
+ }
+
// Handle intent if launched by USB attach event
intent?.let { handleIntent(it) }
}
@@ -191,6 +258,11 @@ class MainActivity : AppCompatActivity() {
if (picked == Prefs.getTransport(this)) return@addOnButtonCheckedListener // no-op
Prefs.setTransport(this, picked)
updateBtPickerVisibility(picked)
+ // Status pill shows the active transport — refresh on change
+ updateShizukuStatus(
+ Shizuku.pingBinder() &&
+ Shizuku.checkSelfPermission() == android.content.pm.PackageManager.PERMISSION_GRANTED
+ )
log("Transport set: ${picked.displayName}")
if (serviceRunning) {
Toast.makeText(this, "Restart the service to apply", Toast.LENGTH_SHORT).show()
@@ -245,9 +317,10 @@ class MainActivity : AppCompatActivity() {
log("No Steam Controller paired — pair via Android Bluetooth settings first")
return
}
+ // Show just the friendly name — the MAC address took an extra wrapped line
+ // and the user never types it manually. Address is still saved to Prefs.
val labels = pairedBtDevices.map { dev ->
- val name = try { dev.name } catch (_: SecurityException) { null } ?: "Unknown"
- "$name • ${dev.address}"
+ try { dev.name } catch (_: SecurityException) { null } ?: "Unknown"
}
binding.dropdownBtDevice.setAdapter(nonFilteringAdapter(labels))
binding.dropdownBtDevice.threshold = 0
@@ -325,24 +398,167 @@ class MainActivity : AppCompatActivity() {
}
}
- private fun setupProfileDropdown() {
- val profiles = GamepadProfile.values()
- val names = profiles.map { it.displayName }
+ /**
+ * Control Mode toggle (Gamepad ↔ Desktop). Replaces the 5-profile dropdown.
+ * Desktop ⇔ GamepadProfile.MOUSE. Gamepad ⇔ the user's last-chosen gamepad
+ * profile (defaults to Xbox 360 on first run). Fine-grained sub-choice
+ * (Xbox 360 vs One vs DS4 vs DualSense) lands in the Profiles panel (Phase 2).
+ */
+ private fun setupControlModeToggle() {
+ syncControlModeToggle(Prefs.getProfile(this))
- binding.dropdownProfile.setAdapter(nonFilteringAdapter(names))
- binding.dropdownProfile.threshold = 0
+ binding.toggleControlMode.addOnButtonCheckedListener { _, checkedId, isChecked ->
+ if (!isChecked) return@addOnButtonCheckedListener
+ val picked = when (checkedId) {
+ R.id.btnModeGamepad -> Prefs.getLastGamepadProfile(this)
+ R.id.btnModeDesktop -> GamepadProfile.MOUSE
+ else -> return@addOnButtonCheckedListener
+ }
+ if (picked.id == Prefs.getProfile(this).id) {
+ // No profile change, but the user still clicked — re-sync visibility
+ // in case the section was out of sync (e.g. service not running so
+ // profileFlow won't re-emit).
+ syncControlModeToggle(picked)
+ return@addOnButtonCheckedListener
+ }
+ Prefs.setProfile(this, picked)
+ // Drive visibility + radio sync directly so the gamepad-variant section
+ // hides immediately when the user picks Desktop, even when no service
+ // is running (profileFlow only emits with the service alive).
+ syncControlModeToggle(picked)
+ log("Control mode → ${picked.displayName}")
+ if (serviceRunning) {
+ Toast.makeText(this, "Restart the service to apply", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
- val current = Prefs.getProfile(this)
- binding.dropdownProfile.setText(current.displayName, false)
+ /** Programmatically sync the Gamepad/Desktop toggle to the given profile (no re-emit feedback loop). */
+ private fun syncControlModeToggle(profile: GamepadProfile) {
+ val targetId = if (profile.isMouseMode) R.id.btnModeDesktop else R.id.btnModeGamepad
+ if (binding.toggleControlMode.checkedButtonId != targetId) {
+ binding.toggleControlMode.check(targetId)
+ }
+ // Show/hide the gamepad-variant radios and select the right one.
+ binding.gamepadVariantSection?.visibility =
+ if (profile.isMouseMode) View.GONE else View.VISIBLE
+ if (!profile.isMouseMode) syncGamepadVariant(profile)
+ }
- binding.dropdownProfile.setOnItemClickListener { _, _, position, _ ->
- val picked = profiles[position]
+ /**
+ * Wires the 4 Xbox/PS radio buttons, split across two RadioGroups (2 per row)
+ * since a single RadioGroup can't lay out a 2×2 grid — it only auto-manages
+ * mutual exclusion among its own *direct* children (nested ViewGroups don't
+ * count), so a flat 2-column arrangement forces two separate groups. Each
+ * group still gets native exclusion + accessibility semantics ("radio button
+ * 1 of 2") within its row; the two rows are cross-cleared manually so only
+ * one of the 4 is ever checked at a time.
+ */
+ private fun setupGamepadVariantRadios() {
+ val radioToProfile = mapOf(
+ R.id.rbXbox360 to GamepadProfile.XBOX_360,
+ R.id.rbXboxOne to GamepadProfile.XBOX_ONE,
+ R.id.rbDualShock4 to GamepadProfile.DUALSHOCK_4,
+ R.id.rbDualSense to GamepadProfile.DUALSENSE,
+ )
+ val row1 = binding.radioGroupGamepadRow1
+ val row2 = binding.radioGroupGamepadRow2
+
+ fun onRowChecked(checkedId: Int, otherRow: RadioGroup?) {
+ val picked = radioToProfile[checkedId] ?: return
+ otherRow?.clearCheck()
+ if (picked.id == Prefs.getProfile(this).id) return
Prefs.setProfile(this, picked)
- log("Profile set: ${picked.displayName}")
+ log("Emulated controller → ${picked.displayName}")
if (serviceRunning) {
Toast.makeText(this, "Restart the service to apply", Toast.LENGTH_SHORT).show()
}
}
+
+ row1?.setOnCheckedChangeListener { _, checkedId ->
+ if (checkedId != View.NO_ID) onRowChecked(checkedId, row2)
+ }
+ row2?.setOnCheckedChangeListener { _, checkedId ->
+ if (checkedId != View.NO_ID) onRowChecked(checkedId, row1)
+ }
+ // Initial check based on current pref.
+ syncGamepadVariant(Prefs.getProfile(this).takeUnless { it.isMouseMode } ?: Prefs.getLastGamepadProfile(this))
+ }
+
+ /** Set the right radio to `checked = true` without triggering its listener side-effects. */
+ private fun syncGamepadVariant(profile: GamepadProfile) {
+ binding.rbXbox360?.isChecked = (profile == GamepadProfile.XBOX_360)
+ binding.rbXboxOne?.isChecked = (profile == GamepadProfile.XBOX_ONE)
+ binding.rbDualShock4?.isChecked = (profile == GamepadProfile.DUALSHOCK_4)
+ binding.rbDualSense?.isChecked = (profile == GamepadProfile.DUALSENSE)
+ }
+
+ private fun openGithubRepo() {
+ try {
+ startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(githubRepoUrl)))
+ } catch (t: Throwable) {
+ Toast.makeText(this, "No browser app found", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun maybeAutoCheckForUpdates() {
+ val elapsed = System.currentTimeMillis() - Prefs.getLastUpdateCheckAt(this)
+ if (elapsed < TimeUnit.HOURS.toMillis(24)) return
+ checkForUpdates(manual = false)
+ }
+
+ private fun checkForUpdates(manual: Boolean) {
+ lifecycleScope.launch {
+ Prefs.setLastUpdateCheckAt(this@MainActivity, System.currentTimeMillis())
+ val release = UpdateChecker.fetchLatestRelease()
+ if (release == null) {
+ if (manual) {
+ Toast.makeText(this@MainActivity, R.string.update_toast_check_failed, Toast.LENGTH_SHORT).show()
+ }
+ return@launch
+ }
+ if (!UpdateChecker.isNewer(release.versionName, BuildConfig.VERSION_NAME)) {
+ if (manual) {
+ Toast.makeText(
+ this@MainActivity,
+ getString(R.string.update_toast_up_to_date, BuildConfig.VERSION_NAME),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ return@launch
+ }
+ if (!manual && Prefs.getSkippedUpdateVersion(this@MainActivity) == release.versionName) return@launch
+ showUpdateAvailableDialog(release)
+ }
+ }
+
+ private fun showUpdateAvailableDialog(release: UpdateChecker.ReleaseInfo) {
+ MaterialAlertDialogBuilder(this)
+ .setTitle(getString(R.string.update_dialog_title))
+ .setMessage(release.notes.ifBlank { release.tagName })
+ .setPositiveButton(R.string.update_dialog_button_update) { _, _ -> downloadAndInstall(release) }
+ .setNeutralButton(R.string.update_dialog_button_skip) { _, _ ->
+ Prefs.setSkippedUpdateVersion(this, release.versionName)
+ }
+ .setNegativeButton(R.string.update_dialog_button_later, null)
+ .show()
+ }
+
+ private fun downloadAndInstall(release: UpdateChecker.ReleaseInfo) {
+ if (!UpdateInstaller.canInstall(this)) {
+ Toast.makeText(this, R.string.update_toast_grant_install_permission, Toast.LENGTH_LONG).show()
+ UpdateInstaller.requestInstallPermission(this)
+ return
+ }
+ val downloadManager = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
+ val request = DownloadManager.Request(Uri.parse(release.apkUrl))
+ .setTitle("Steam Controller ${release.versionName}")
+ .setDestinationInExternalFilesDir(this, Environment.DIRECTORY_DOWNLOADS, release.apkName)
+ .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
+
+ pendingUpdateApkFile = File(getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), release.apkName)
+ pendingUpdateDownloadId = downloadManager.enqueue(request)
+ Toast.makeText(this, R.string.update_toast_downloading, Toast.LENGTH_SHORT).show()
}
private fun checkPermissionsAndStart() {
@@ -372,7 +588,7 @@ class MainActivity : AppCompatActivity() {
val intent = Intent(this, ControllerService::class.java)
startForegroundService(intent)
serviceRunning = true
- updateStatus(connected = true)
+ // Don't fake "connected" here — stateFlow will flip it once a real HID frame lands.
binding.btnToggleService.text = getString(R.string.btn_stop)
log("Service started (Bluetooth)")
}
@@ -414,7 +630,7 @@ class MainActivity : AppCompatActivity() {
}
startForegroundService(intent)
serviceRunning = true
- updateStatus(connected = true)
+ // Pill colour + status text flip when stateFlow emits the first parsed HID frame.
binding.btnToggleService.text = getString(R.string.btn_stop)
log("Service started")
}
@@ -425,17 +641,40 @@ class MainActivity : AppCompatActivity() {
}
startService(intent)
serviceRunning = false
+ hasSeenActiveMode = false
updateStatus(connected = false)
binding.btnToggleService.text = getString(R.string.btn_start)
log("Service stopped")
}
private fun updateShizukuStatus(ok: Boolean) {
- binding.tvShizukuStatus.text = if (ok) "Shizuku: ready" else "Shizuku: not ready"
+ val transport = Prefs.getTransport(this).displayName
+ binding.tvShizukuStatus.text = if (ok)
+ "Shizuku: ready • $transport"
+ else
+ "Shizuku: not ready • $transport"
}
+ /**
+ * Drives both the bottom-of-card "Controller: ..." label AND the top status pill colour.
+ * The pill flips to a green tint as soon as HID frames are actually flowing, which is
+ * a much more honest signal than "the user pressed Start".
+ */
private fun updateStatus(connected: Boolean) {
- binding.tvControllerStatus.text = if (connected) "Controller: active" else "Controller: disconnected"
+ binding.tvControllerStatus.text = if (connected) "Controller: Ready" else "Controller: disconnected"
+
+ val containerColor = if (connected)
+ ContextCompat.getColor(this, R.color.status_connected_container)
+ else
+ ContextCompat.getColor(this, R.color.status_idle_container)
+ val textColor = if (connected)
+ ContextCompat.getColor(this, R.color.status_connected_on_container)
+ else
+ ContextCompat.getColor(this, R.color.status_idle_on_container)
+
+ // statusPillCard only exists in the phone layout; sw600dp/TV use a different layout.
+ binding.statusPillCard?.setCardBackgroundColor(containerColor)
+ binding.tvShizukuStatus.setTextColor(textColor)
}
private fun log(msg: String) {
@@ -450,13 +689,16 @@ class MainActivity : AppCompatActivity() {
updateShizukuStatus(Shizuku.pingBinder() &&
Shizuku.checkSelfPermission() == android.content.pm.PackageManager.PERMISSION_GRANTED)
- // Sync the profile dropdown — it may have been changed from the notification while paused
- binding.dropdownProfile.setText(Prefs.getProfile(this).displayName, false)
+ // Sync Control Mode toggle — profile may have changed from the notification while paused
+ syncControlModeToggle(Prefs.getProfile(this))
+
+ maybeAutoCheckForUpdates()
}
override fun onDestroy() {
Shizuku.removeRequestPermissionResultListener(shizukuPermissionListener)
unregisterReceiver(usbReceiver)
+ unregisterReceiver(downloadReceiver)
super.onDestroy()
}
}
diff --git a/app/src/main/java/com/steamcontroller/android/MappingActivity.kt b/app/src/main/java/com/steamcontroller/android/MappingActivity.kt
index 4d23e67..cbd8a39 100644
--- a/app/src/main/java/com/steamcontroller/android/MappingActivity.kt
+++ b/app/src/main/java/com/steamcontroller/android/MappingActivity.kt
@@ -1,22 +1,38 @@
package com.steamcontroller.android
+import android.content.Intent
+import android.content.res.ColorStateList
import android.os.Bundle
-import android.widget.ArrayAdapter
-import android.widget.Filter
+import android.view.View
+import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
-import com.google.android.material.textfield.MaterialAutoCompleteTextView
+import androidx.core.content.ContextCompat
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.steamcontroller.android.databinding.ActivityMappingBinding
import com.steamcontroller.android.input.ButtonCategory
import com.steamcontroller.android.input.SteamButton
import com.steamcontroller.android.input.XboxTarget
+/**
+ * V1.2 — each row shows a Kenney input-prompts icon when one exists, falling back
+ * to a colored letter chip otherwise. Source uses the white SC icon; target uses
+ * the official Steam Controller colored A/B/X/Y or a neutral letter for everything
+ * else. The entire row is clickable and opens a single-choice remap dialog.
+ */
class MappingActivity : AppCompatActivity() {
private lateinit var binding: ActivityMappingBinding
private val targets = XboxTarget.values()
private val targetNames = targets.map { it.displayName }
+ private data class RowViews(
+ val targetIcon: ImageView,
+ val targetBadge: TextView,
+ val targetName: TextView,
+ )
+ private val rowsBySource = mutableMapOf()
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMappingBinding.inflate(layoutInflater)
@@ -24,19 +40,27 @@ class MappingActivity : AppCompatActivity() {
binding.toolbar.setNavigationOnClickListener { finish() }
binding.toolbar.setOnMenuItemClickListener { item ->
- if (item.itemId == R.id.action_reset) {
- Prefs.resetMappings(this)
- binding.mappingContainer.removeAllViews()
- buildSections()
- true
- } else false
+ when (item.itemId) {
+ R.id.action_reset -> {
+ Prefs.resetMappings(this)
+ rebuild()
+ true
+ }
+ R.id.action_profiles -> {
+ startActivity(Intent(this, ProfilesActivity::class.java))
+ true
+ }
+ else -> false
+ }
}
- buildSections()
+ rebuild()
}
- private fun buildSections() {
- // Group buttons by category and render section header + rows
+ private fun rebuild() {
+ binding.mappingContainer.removeAllViews()
+ rowsBySource.clear()
+
val grouped = SteamButton.values().groupBy { it.category }
for (category in ButtonCategory.values()) {
val buttons = grouped[category] ?: continue
@@ -53,30 +77,139 @@ class MappingActivity : AppCompatActivity() {
private fun addRow(source: SteamButton) {
val row = layoutInflater.inflate(R.layout.item_mapping_row, binding.mappingContainer, false)
- row.findViewById(R.id.tvSourceBadge).text = source.shortLabel
+
+ applySourceChip(
+ row.findViewById(R.id.ivSourceIcon),
+ row.findViewById(R.id.tvSourceBadge),
+ source,
+ )
row.findViewById(R.id.tvSourceName).text = source.displayName
- val dropdown = row.findViewById(R.id.dropdownTarget)
- dropdown.setAdapter(nonFilteringAdapter(targetNames))
- dropdown.threshold = 0
- val current = Prefs.getMapping(this, source)
- dropdown.setText(current.displayName, false)
- dropdown.setOnItemClickListener { _, _, position, _ ->
- Prefs.setMapping(this, source, targets[position])
- }
+ val targetIcon = row.findViewById(R.id.ivTargetIcon)
+ val targetBadge = row.findViewById(R.id.tvTargetBadge)
+ val targetName = row.findViewById(R.id.tvTargetName)
+
+ applyTargetChip(targetIcon, targetBadge, targetName, Prefs.getMapping(this, source))
+ row.setOnClickListener { showRemapDialog(source) }
+
+ rowsBySource[source] = RowViews(targetIcon, targetBadge, targetName)
binding.mappingContainer.addView(row)
}
- private fun nonFilteringAdapter(items: List): ArrayAdapter =
- object : ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, items) {
- private val noFilter = object : Filter() {
- override fun performFiltering(constraint: CharSequence?): FilterResults =
- FilterResults().apply { values = items; count = items.size }
- override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
- notifyDataSetChanged()
- }
- }
- override fun getFilter(): Filter = noFilter
+ private fun applySourceChip(icon: ImageView, badge: TextView, source: SteamButton) {
+ val iconRes = sourceIconFor(source)
+ if (iconRes != null) {
+ icon.setImageResource(iconRes)
+ icon.visibility = View.VISIBLE
+ badge.visibility = View.GONE
+ } else {
+ badge.text = source.shortLabel
+ badge.backgroundTintList =
+ ColorStateList.valueOf(ContextCompat.getColor(this, R.color.btn_neutral))
+ badge.visibility = View.VISIBLE
+ icon.visibility = View.GONE
+ }
+ }
+
+ private fun applyTargetChip(icon: ImageView, badge: TextView, label: TextView, target: XboxTarget) {
+ val iconRes = targetIconFor(target)
+ if (iconRes != null) {
+ icon.setImageResource(iconRes)
+ icon.visibility = View.VISIBLE
+ badge.visibility = View.GONE
+ } else {
+ badge.text = shortLabelFor(target)
+ badge.backgroundTintList = ColorStateList.valueOf(colorFor(target))
+ badge.visibility = View.VISIBLE
+ icon.visibility = View.GONE
}
+ label.text = target.displayName
+ }
+
+ private fun showRemapDialog(source: SteamButton) {
+ val current = Prefs.getMapping(this, source)
+ val currentIndex = targets.indexOf(current).coerceAtLeast(0)
+
+ MaterialAlertDialogBuilder(this)
+ .setTitle("Remap ${source.displayName}")
+ .setSingleChoiceItems(targetNames.toTypedArray(), currentIndex) { dialog, which ->
+ val picked = targets[which]
+ Prefs.setMapping(this, source, picked)
+ rowsBySource[source]?.let { applyTargetChip(it.targetIcon, it.targetBadge, it.targetName, picked) }
+ dialog.dismiss()
+ }
+ .setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
+ .show()
+ }
+
+ /** Kenney SC icon for each source button. null → fall back to letter chip. */
+ private fun sourceIconFor(b: SteamButton): Int? = when (b) {
+ SteamButton.A -> R.drawable.sc_btn_a
+ SteamButton.B -> R.drawable.sc_btn_b
+ SteamButton.X -> R.drawable.sc_btn_x
+ SteamButton.Y -> R.drawable.sc_btn_y
+ SteamButton.LB -> R.drawable.sc_btn_lb
+ SteamButton.RB -> R.drawable.sc_btn_rb
+ SteamButton.LT -> R.drawable.sc_btn_lt
+ SteamButton.RT -> R.drawable.sc_btn_rt
+ SteamButton.MENU -> R.drawable.sc_btn_menu
+ SteamButton.VIEW -> R.drawable.sc_btn_view
+ SteamButton.STEAM -> R.drawable.sc_btn_steam
+ SteamButton.QUICK_ACCESS -> R.drawable.sc_btn_qa
+ SteamButton.L4 -> R.drawable.sc_btn_l4
+ SteamButton.L5 -> R.drawable.sc_btn_l5
+ SteamButton.R4 -> R.drawable.sc_btn_r4
+ SteamButton.R5 -> R.drawable.sc_btn_r5
+ SteamButton.GRIP_LT -> R.drawable.sc_btn_lg
+ SteamButton.GRIP_RT -> R.drawable.sc_btn_rg
+ SteamButton.LS, SteamButton.RS -> R.drawable.sc_btn_stick_press
+ }
+
+ /** Steam Input icons for every Xbox-native target. Keyboard / screenshot / NONE
+ * fall through to the colored letter chip. */
+ private fun targetIconFor(t: XboxTarget): Int? = when (t) {
+ XboxTarget.A -> R.drawable.sc_btn_a_color
+ XboxTarget.B -> R.drawable.sc_btn_b_color
+ XboxTarget.X -> R.drawable.sc_btn_x_color
+ XboxTarget.Y -> R.drawable.sc_btn_y_color
+ XboxTarget.LB -> R.drawable.xbox_btn_lb
+ XboxTarget.RB -> R.drawable.xbox_btn_rb
+ XboxTarget.LT_TRIGGER -> R.drawable.sc_btn_lt
+ XboxTarget.RT_TRIGGER -> R.drawable.sc_btn_rt
+ XboxTarget.SELECT -> R.drawable.xbox_btn_select
+ XboxTarget.START -> R.drawable.xbox_btn_start
+ XboxTarget.MODE -> R.drawable.xbox_btn_logo
+ XboxTarget.THUMBL -> R.drawable.xbox_btn_l3
+ XboxTarget.THUMBR -> R.drawable.xbox_btn_r3
+ else -> null
+ }
+
+ /** Compact label for chip fallback. */
+ private fun shortLabelFor(t: XboxTarget): String = when (t) {
+ XboxTarget.NONE -> "—"
+ XboxTarget.A -> "A"; XboxTarget.B -> "B"; XboxTarget.X -> "X"; XboxTarget.Y -> "Y"
+ XboxTarget.LB -> "L1"; XboxTarget.RB -> "R1"
+ XboxTarget.LT_TRIGGER -> "L2"; XboxTarget.RT_TRIGGER -> "R2"
+ XboxTarget.SELECT -> "···"; XboxTarget.START -> "≡"; XboxTarget.MODE -> "◆"
+ XboxTarget.THUMBL -> "L3"; XboxTarget.THUMBR -> "R3"
+ XboxTarget.SCREENSHOT -> "📸"
+ XboxTarget.KB_VOLUME_UP -> "V+"
+ XboxTarget.KB_VOLUME_DOWN -> "V-"
+ XboxTarget.KB_PLAY_PAUSE -> "⏯"
+ XboxTarget.KB_BACK -> "⮌"
+ XboxTarget.KB_HOME -> "🏠"
+ XboxTarget.KB_ENTER -> "↵"
+ XboxTarget.KB_DPAD_CENTER -> "●"
+ XboxTarget.KB_ESCAPE -> "ESC"
+ XboxTarget.KB_TAB -> "⇥"
+ XboxTarget.KB_SPACE -> "␣"
+ XboxTarget.KB_BACKSPACE -> "⌫"
+ XboxTarget.KB_MENU -> "☰"
+ }
+
+ private fun colorFor(t: XboxTarget): Int = ContextCompat.getColor(this, when (t) {
+ XboxTarget.NONE -> R.color.chip_inactive
+ else -> R.color.btn_neutral
+ })
}
diff --git a/app/src/main/java/com/steamcontroller/android/Prefs.kt b/app/src/main/java/com/steamcontroller/android/Prefs.kt
index 14dcf75..4593729 100644
--- a/app/src/main/java/com/steamcontroller/android/Prefs.kt
+++ b/app/src/main/java/com/steamcontroller/android/Prefs.kt
@@ -2,6 +2,7 @@ package com.steamcontroller.android
import android.content.Context
import com.steamcontroller.android.input.DEFAULT_MAPPING
+import com.steamcontroller.android.input.NamedProfile
import com.steamcontroller.android.input.SteamButton
import com.steamcontroller.android.input.StickCalibration
import com.steamcontroller.android.input.XboxTarget
@@ -18,6 +19,7 @@ enum class Transport(val id: Int, val displayName: String) {
object Prefs {
private const val NAME = "steam_controller_prefs"
private const val KEY_PROFILE_ID = "gamepad_profile_id"
+ private const val KEY_LAST_GAMEPAD_PROFILE_ID = "last_gamepad_profile_id"
private const val KEY_TRANSPORT = "transport"
private const val KEY_BT_ADDRESS = "bt_device_address"
@@ -32,6 +34,15 @@ 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_TRACKPAD_AS_MOUSE = "trackpad_as_mouse_gamepad" // sidecar mouse while in Xbox/PS profiles
+ 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_LAST_UPDATE_CHECK = "last_update_check_at"
+ private const val KEY_SKIPPED_UPDATE_VERSION = "skipped_update_version"
+
+ private const val KEY_SAVED_SHOW_IME_HARD_KB = "saved_show_ime_with_hard_keyboard"
private fun prefs(context: Context) =
context.getSharedPreferences(NAME, Context.MODE_PRIVATE)
@@ -42,7 +53,20 @@ object Prefs {
}
fun setProfile(context: Context, profile: GamepadProfile) {
- prefs(context).edit().putInt(KEY_PROFILE_ID, profile.id).apply()
+ val edit = prefs(context).edit().putInt(KEY_PROFILE_ID, profile.id)
+ // Remember the last *non-Mouse* profile so the Gamepad/Desktop toggle on
+ // the new UI can revert to it when the user flips back to Gamepad mode.
+ if (!profile.isMouseMode) {
+ edit.putInt(KEY_LAST_GAMEPAD_PROFILE_ID, profile.id)
+ }
+ edit.apply()
+ }
+
+ /** The last gamepad profile the user actively chose (defaults to Xbox 360). */
+ fun getLastGamepadProfile(context: Context): GamepadProfile {
+ val id = prefs(context).getInt(KEY_LAST_GAMEPAD_PROFILE_ID, GamepadProfile.XBOX_360.id)
+ val p = GamepadProfile.fromId(id)
+ return if (p.isMouseMode) GamepadProfile.XBOX_360 else p
}
fun getTransport(context: Context): Transport =
@@ -103,6 +127,23 @@ object Prefs {
prefs(context).edit().putInt(KEY_RUMBLE_INTENSITY, percent.coerceIn(0, 100)).apply()
}
+ /** Mouse cursor sensitivity multiplier, 0.1x..3.0x. */
+ fun getMouseSensitivity(context: Context): Float =
+ (prefs(context).getInt(KEY_MOUSE_SENSITIVITY, 10).coerceIn(1, 30)) / 10f
+
+ fun setMouseSensitivity(context: Context, multiplier: Float) {
+ val v = (multiplier * 10f).toInt().coerceIn(1, 30)
+ prefs(context).edit().putInt(KEY_MOUSE_SENSITIVITY, v).apply()
+ }
+
+ /** Whether the right trackpad / left trackpad drive a sidecar mouse cursor while a gamepad profile is active. */
+ fun getTrackpadAsMouseInGamepad(context: Context): Boolean =
+ prefs(context).getBoolean(KEY_TRACKPAD_AS_MOUSE, true)
+
+ fun setTrackpadAsMouseInGamepad(context: Context, enabled: Boolean) {
+ prefs(context).edit().putBoolean(KEY_TRACKPAD_AS_MOUSE, enabled).apply()
+ }
+
// ─── Button mapping ──────────────────────────────────────────────────────
private fun mapKey(source: SteamButton) = "map_${source.name}"
@@ -124,4 +165,129 @@ object Prefs {
SteamButton.values().forEach { edit.remove(mapKey(it)) }
edit.apply()
}
+
+ // ─── Named profiles ──────────────────────────────────────────────────────
+ // Profiles are stored as a single JSON array under KEY_NAMED_PROFILES.
+ // Loading a profile overwrites every "live" preference key it captures.
+
+ fun listNamedProfiles(context: Context): List =
+ NamedProfile.listFromJson(prefs(context).getString(KEY_NAMED_PROFILES, "") ?: "")
+
+ fun saveNamedProfile(context: Context, profile: NamedProfile) {
+ val existing = listNamedProfiles(context).toMutableList()
+ val idx = existing.indexOfFirst { it.id == profile.id }
+ if (idx >= 0) existing[idx] = profile else existing.add(profile)
+ persistNamedProfiles(context, existing)
+ }
+
+ fun deleteNamedProfile(context: Context, id: String) {
+ val existing = listNamedProfiles(context).filter { it.id != id }
+ persistNamedProfiles(context, existing)
+ if (getActiveNamedProfileId(context) == id) setActiveNamedProfileId(context, null)
+ }
+
+ private fun persistNamedProfiles(context: Context, profiles: List) {
+ prefs(context).edit().putString(KEY_NAMED_PROFILES, NamedProfile.listToJson(profiles)).apply()
+ }
+
+ fun getActiveNamedProfileId(context: Context): String? =
+ prefs(context).getString(KEY_ACTIVE_NAMED_PROFILE_ID, null)
+
+ fun setActiveNamedProfileId(context: Context, id: String?) {
+ prefs(context).edit().putString(KEY_ACTIVE_NAMED_PROFILE_ID, id).apply()
+ }
+
+ /** Snapshot every live preference into a new NamedProfile under `name`.
+ * Note: transport (USB/BT) is intentionally NOT captured — it's a physical-link
+ * choice, not a game-tuning one, so loading a profile shouldn't drag the user
+ * back to USB when they're docked over BT. The field stays in NamedProfile
+ * schema for backward compat with V1.2 saves but isn't written or applied. */
+ fun captureCurrentAsProfile(context: Context, name: String, existingId: String? = null): NamedProfile {
+ val leftCal = getLeftCalibration(context)
+ val rightCal = getRightCalibration(context)
+ val mappingMap = SteamButton.values().associate { it.name to getMapping(context, it).ordinal }
+ val boundPkgs = existingId?.let { id -> listNamedProfiles(context).firstOrNull { it.id == id }?.boundPackages }
+ ?: emptyList()
+ return NamedProfile(
+ id = existingId ?: java.util.UUID.randomUUID().toString(),
+ name = name,
+ profileId = getProfile(context).id,
+ transport = 0, // unused — see captureCurrentAsProfile kdoc
+ leftCenterX = leftCal.centerX,
+ leftCenterY = leftCal.centerY,
+ leftDeadzone = leftCal.deadzonePercent,
+ leftInvertY = leftCal.invertY,
+ rightCenterX = rightCal.centerX,
+ rightCenterY = rightCal.centerY,
+ rightDeadzone = rightCal.deadzonePercent,
+ rightInvertY = rightCal.invertY,
+ mouseSensitivity = getMouseSensitivity(context),
+ trackpadAsMouse = getTrackpadAsMouseInGamepad(context),
+ rumbleIntensity = getRumbleIntensity(context),
+ mapping = mappingMap,
+ boundPackages = boundPkgs,
+ )
+ }
+
+ /** Apply a stored profile to the live preferences. Caller should restart the service.
+ * Transport (USB/BT) is intentionally NOT touched — the user picks the link in
+ * the main UI, profiles only configure the controller behaviour. */
+ fun applyNamedProfile(context: Context, profile: NamedProfile) {
+ setProfile(context, GamepadProfile.fromId(profile.profileId))
+ setLeftCalibration(context, StickCalibration(
+ centerX = profile.leftCenterX,
+ centerY = profile.leftCenterY,
+ deadzonePercent = profile.leftDeadzone,
+ invertY = profile.leftInvertY,
+ ))
+ setRightCalibration(context, StickCalibration(
+ centerX = profile.rightCenterX,
+ centerY = profile.rightCenterY,
+ deadzonePercent = profile.rightDeadzone,
+ invertY = profile.rightInvertY,
+ ))
+ setMouseSensitivity(context, profile.mouseSensitivity)
+ setTrackpadAsMouseInGamepad(context, profile.trackpadAsMouse)
+ setRumbleIntensity(context, profile.rumbleIntensity)
+ // Mapping — ordinals stored against XboxTarget. Out-of-range values fall back to NONE.
+ val targets = XboxTarget.values()
+ SteamButton.values().forEach { btn ->
+ val ord = profile.mapping[btn.name] ?: return@forEach
+ val target = targets.getOrNull(ord) ?: XboxTarget.NONE
+ setMapping(context, btn, target)
+ }
+ setActiveNamedProfileId(context, profile.id)
+ }
+
+ // ─── Update checker ──────────────────────────────────────────────────────
+ fun getLastUpdateCheckAt(context: Context): Long =
+ prefs(context).getLong(KEY_LAST_UPDATE_CHECK, 0L)
+
+ fun setLastUpdateCheckAt(context: Context, timestampMillis: Long) {
+ prefs(context).edit().putLong(KEY_LAST_UPDATE_CHECK, timestampMillis).apply()
+ }
+
+ /** Version the user chose to skip via "Skip this version" — suppresses the auto-check dialog only. */
+ fun getSkippedUpdateVersion(context: Context): String? =
+ prefs(context).getString(KEY_SKIPPED_UPDATE_VERSION, null)
+
+ fun setSkippedUpdateVersion(context: Context, version: String) {
+ prefs(context).edit().putString(KEY_SKIPPED_UPDATE_VERSION, version).apply()
+ }
+
+ // ─── show_ime_with_hard_keyboard override ──────────────────────────────────
+ // Stores the pre-override value of the Settings.Secure key so it can be restored
+ // when the controller disconnects. Written once per override (not overwritten
+ // while an override is already pending), so a crash-without-restore doesn't
+ // clobber the true original on the next connect/disconnect cycle.
+ fun getSavedShowImeHardKeyboard(context: Context): String? =
+ prefs(context).getString(KEY_SAVED_SHOW_IME_HARD_KB, null)
+
+ fun setSavedShowImeHardKeyboard(context: Context, value: String) {
+ prefs(context).edit().putString(KEY_SAVED_SHOW_IME_HARD_KB, value).apply()
+ }
+
+ fun clearSavedShowImeHardKeyboard(context: Context) {
+ prefs(context).edit().remove(KEY_SAVED_SHOW_IME_HARD_KB).apply()
+ }
}
diff --git a/app/src/main/java/com/steamcontroller/android/ProfilesActivity.kt b/app/src/main/java/com/steamcontroller/android/ProfilesActivity.kt
new file mode 100644
index 0000000..4382f0c
--- /dev/null
+++ b/app/src/main/java/com/steamcontroller/android/ProfilesActivity.kt
@@ -0,0 +1,235 @@
+package com.steamcontroller.android
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import android.view.inputmethod.InputMethodManager
+import android.widget.EditText
+import android.widget.PopupMenu
+import android.widget.TextView
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.steamcontroller.android.backup.BackupManager
+import com.steamcontroller.android.databinding.ActivityProfilesBinding
+import com.steamcontroller.android.input.NamedProfile
+import com.steamcontroller.android.service.ControllerService
+import com.steamcontroller.android.uinput.GamepadProfile
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+class ProfilesActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityProfilesBinding
+
+ private val exportBackupLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri ->
+ if (uri == null) return@registerForActivityResult
+ try {
+ contentResolver.openOutputStream(uri)?.use { it.write(BackupManager.export(this).toByteArray()) }
+ Toast.makeText(this, R.string.backup_export_toast, Toast.LENGTH_SHORT).show()
+ } catch (t: Throwable) {
+ Toast.makeText(this, getString(R.string.backup_export_failed_toast, t.message), Toast.LENGTH_LONG).show()
+ }
+ }
+
+ private val importBackupLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri == null) return@registerForActivityResult
+ try {
+ val json = contentResolver.openInputStream(uri)?.use { it.bufferedReader().readText() }
+ ?: throw IllegalStateException("empty file")
+ val count = BackupManager.import(this, json)
+ renderList()
+ Toast.makeText(this, getString(R.string.backup_import_toast, count), Toast.LENGTH_LONG).show()
+ } catch (t: Throwable) {
+ Toast.makeText(this, getString(R.string.backup_import_failed_toast, t.message), Toast.LENGTH_LONG).show()
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityProfilesBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ binding.toolbar.setNavigationOnClickListener { finish() }
+ binding.toolbar.setOnMenuItemClickListener { item ->
+ when (item.itemId) {
+ R.id.action_export_backup -> {
+ val stamp = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(java.util.Date())
+ exportBackupLauncher.launch("steamcontroller_backup_$stamp.json")
+ true
+ }
+ R.id.action_import_backup -> {
+ importBackupLauncher.launch(arrayOf("application/json"))
+ true
+ }
+ else -> false
+ }
+ }
+ binding.fabSaveCurrent.setOnClickListener { promptSaveCurrent() }
+
+ renderList()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ renderList() // re-render in case AppPickerActivity changed bindings
+ }
+
+ private fun renderList() {
+ val profiles = Prefs.listNamedProfiles(this)
+ val activeId = Prefs.getActiveNamedProfileId(this)
+
+ binding.profilesContainer.removeAllViews()
+ binding.emptyState.visibility = if (profiles.isEmpty()) View.VISIBLE else View.GONE
+
+ for (profile in profiles) addRow(profile, isActive = (profile.id == activeId))
+ }
+
+ private fun addRow(profile: NamedProfile, isActive: Boolean) {
+ val row = layoutInflater.inflate(R.layout.item_profile_row, binding.profilesContainer, false)
+ row.findViewById(R.id.tvProfileName).text = profile.name
+
+ val gp = GamepadProfile.fromId(profile.profileId)
+ row.findViewById(R.id.tvProfileDetails).text = gp.displayName
+
+ val tvBound = row.findViewById(R.id.tvProfileBound)
+ if (profile.boundPackages.isNotEmpty()) {
+ tvBound.visibility = View.VISIBLE
+ tvBound.text = "🎯 Auto-switch on ${profile.boundPackages.size} app${if (profile.boundPackages.size > 1) "s" else ""}"
+ } else {
+ tvBound.visibility = View.GONE
+ }
+
+ val badge = row.findViewById(R.id.tvActiveBadge)
+ badge.visibility = if (isActive) View.VISIBLE else View.GONE
+
+ val btnLoad = row.findViewById(R.id.btnLoadProfile)
+ btnLoad.text = if (isActive) "Loaded" else "Load"
+ btnLoad.isEnabled = !isActive
+ btnLoad.setOnClickListener { applyProfile(profile) }
+
+ row.findViewById(R.id.btnProfileMenu).setOnClickListener { anchor ->
+ showRowMenu(anchor, profile)
+ }
+
+ binding.profilesContainer.addView(row)
+ }
+
+ private fun showRowMenu(anchor: View, profile: NamedProfile) {
+ val popup = PopupMenu(this, anchor)
+ popup.menu.add(0, 1, 0, "Rename")
+ popup.menu.add(0, 2, 1, "Duplicate")
+ popup.menu.add(0, 3, 2, "Bind to apps…")
+ popup.menu.add(0, 4, 3, "Delete")
+ popup.setOnMenuItemClickListener { item ->
+ when (item.itemId) {
+ 1 -> promptRename(profile)
+ 2 -> duplicateProfile(profile)
+ 3 -> launchAppBinding(profile)
+ 4 -> confirmDelete(profile)
+ }
+ true
+ }
+ popup.show()
+ }
+
+ private fun promptSaveCurrent() {
+ val input = EditText(this).apply {
+ hint = "Game profile name"
+ setText(suggestNextName())
+ setSelection(text.length)
+ }
+ MaterialAlertDialogBuilder(this)
+ .setTitle("Save current settings as game profile")
+ .setView(input)
+ .setPositiveButton("Save") { d, _ ->
+ val name = input.text.toString().trim().ifEmpty { suggestNextName() }
+ val newProfile = Prefs.captureCurrentAsProfile(this, name)
+ Prefs.saveNamedProfile(this, newProfile)
+ Prefs.setActiveNamedProfileId(this, newProfile.id)
+ renderList()
+ d.dismiss()
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ showKeyboardFor(input)
+ }
+
+ /**
+ * Explicitly force-shows the IME instead of relying on Android's auto-show-on-focus
+ * heuristic. While the emulated gamepad is connected (esp. over Bluetooth), Android/OEM
+ * skins can classify the physical controller as a hardware keyboard/mouse and suppress
+ * auto-show entirely — SHOW_FORCED bypasses that heuristic instead of fighting it via
+ * system Settings, which is fragile and OEM-dependent.
+ */
+ private fun showKeyboardFor(input: EditText) {
+ input.requestFocus()
+ input.post {
+ val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ @Suppress("DEPRECATION")
+ imm.showSoftInput(input, InputMethodManager.SHOW_FORCED)
+ }
+ }
+
+ private fun promptRename(profile: NamedProfile) {
+ val input = EditText(this).apply { setText(profile.name); setSelection(text.length) }
+ MaterialAlertDialogBuilder(this)
+ .setTitle("Rename profile")
+ .setView(input)
+ .setPositiveButton("Save") { d, _ ->
+ val newName = input.text.toString().trim().ifEmpty { profile.name }
+ Prefs.saveNamedProfile(this, profile.copy(name = newName))
+ renderList()
+ d.dismiss()
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ showKeyboardFor(input)
+ }
+
+ private fun duplicateProfile(profile: NamedProfile) {
+ val copy = profile.copy(
+ id = java.util.UUID.randomUUID().toString(),
+ name = "${profile.name} (copy)",
+ boundPackages = emptyList(), // duplicates start unbound
+ )
+ Prefs.saveNamedProfile(this, copy)
+ renderList()
+ }
+
+ private fun confirmDelete(profile: NamedProfile) {
+ MaterialAlertDialogBuilder(this)
+ .setTitle("Delete \"${profile.name}\"?")
+ .setMessage("This profile will be removed permanently. Live settings stay as they are.")
+ .setPositiveButton("Delete") { d, _ ->
+ Prefs.deleteNamedProfile(this, profile.id)
+ renderList()
+ d.dismiss()
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ }
+
+ private fun applyProfile(profile: NamedProfile) {
+ Prefs.applyNamedProfile(this, profile)
+ Toast.makeText(this, "Loaded \"${profile.name}\". Restart the service for full effect.", Toast.LENGTH_LONG).show()
+ renderList()
+ }
+
+ private fun launchAppBinding(profile: NamedProfile) {
+ val intent = Intent(this, AppPickerActivity::class.java)
+ .putExtra(AppPickerActivity.EXTRA_PROFILE_ID, profile.id)
+ .putStringArrayListExtra(AppPickerActivity.EXTRA_PRESELECTED, ArrayList(profile.boundPackages))
+ startActivity(intent)
+ }
+
+ private fun suggestNextName(): String {
+ val existing = Prefs.listNamedProfiles(this).map { it.name }.toSet()
+ var n = 1
+ while ("Profile $n" in existing) n++
+ return "Profile $n"
+ }
+}
diff --git a/app/src/main/java/com/steamcontroller/android/backup/BackupManager.kt b/app/src/main/java/com/steamcontroller/android/backup/BackupManager.kt
new file mode 100644
index 0000000..7302aa7
--- /dev/null
+++ b/app/src/main/java/com/steamcontroller/android/backup/BackupManager.kt
@@ -0,0 +1,109 @@
+package com.steamcontroller.android.backup
+
+import android.content.Context
+import com.steamcontroller.android.Prefs
+import com.steamcontroller.android.input.NamedProfile
+import com.steamcontroller.android.input.SteamButton
+import com.steamcontroller.android.input.StickCalibration
+import com.steamcontroller.android.input.XboxTarget
+import com.steamcontroller.android.uinput.GamepadProfile
+import org.json.JSONArray
+import org.json.JSONObject
+
+/**
+ * Exports/imports every user-tunable setting (live prefs + Game Profiles) as a single JSON
+ * file, so nothing is lost across an app uninstall/reinstall (this app isn't on Play Store,
+ * so there's no automatic Android Backup Service coverage to rely on).
+ *
+ * Import is additive for Game Profiles (upsert by id via Prefs.saveNamedProfile) rather than
+ * a destructive replace — restoring onto a fresh install behaves like a full restore, while
+ * restoring onto an existing install just merges in whatever the backup had.
+ */
+object BackupManager {
+
+ private const val SCHEMA_VERSION = 1
+
+ fun export(context: Context): String {
+ val leftCal = Prefs.getLeftCalibration(context)
+ val rightCal = Prefs.getRightCalibration(context)
+
+ val live = JSONObject().apply {
+ put("gamepadProfileId", Prefs.getProfile(context).id)
+ put("lastGamepadProfileId", Prefs.getLastGamepadProfile(context).id)
+ put("leftCal", calToJson(leftCal))
+ put("rightCal", calToJson(rightCal))
+ put("rumbleIntensity", Prefs.getRumbleIntensity(context))
+ put("mouseSensitivity", Prefs.getMouseSensitivity(context).toDouble())
+ put("trackpadAsMouse", Prefs.getTrackpadAsMouseInGamepad(context))
+ put("mapping", JSONObject().apply {
+ Prefs.getAllMappings(context).forEach { (btn, target) -> put(btn.name, target.ordinal) }
+ })
+ }
+
+ val profiles = JSONArray().apply {
+ Prefs.listNamedProfiles(context).forEach { put(it.toJson()) }
+ }
+
+ return JSONObject().apply {
+ put("schemaVersion", SCHEMA_VERSION)
+ put("exportedAt", System.currentTimeMillis())
+ put("liveSettings", live)
+ put("namedProfiles", profiles)
+ put("activeNamedProfileId", Prefs.getActiveNamedProfileId(context))
+ }.toString(2)
+ }
+
+ /** Returns the number of Game Profiles restored, or throws on malformed JSON. */
+ fun import(context: Context, json: String): Int {
+ val root = JSONObject(json)
+
+ root.optJSONObject("liveSettings")?.let { live ->
+ live.optInt("gamepadProfileId", -1).takeIf { it >= 0 }?.let {
+ Prefs.setProfile(context, GamepadProfile.fromId(it))
+ }
+ live.optJSONObject("leftCal")?.let { Prefs.setLeftCalibration(context, calFromJson(it)) }
+ live.optJSONObject("rightCal")?.let { Prefs.setRightCalibration(context, calFromJson(it)) }
+ 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))
+ live.optJSONObject("mapping")?.let { mapObj ->
+ val targets = XboxTarget.values()
+ SteamButton.values().forEach { btn ->
+ if (mapObj.has(btn.name)) {
+ val target = targets.getOrNull(mapObj.optInt(btn.name, -1)) ?: XboxTarget.NONE
+ Prefs.setMapping(context, btn, target)
+ }
+ }
+ }
+ }
+
+ val profilesArr = root.optJSONArray("namedProfiles")
+ var restoredCount = 0
+ if (profilesArr != null) {
+ for (i in 0 until profilesArr.length()) {
+ Prefs.saveNamedProfile(context, NamedProfile.fromJson(profilesArr.getJSONObject(i)))
+ restoredCount++
+ }
+ }
+
+ if (!root.isNull("activeNamedProfileId") && root.has("activeNamedProfileId")) {
+ Prefs.setActiveNamedProfileId(context, root.optString("activeNamedProfileId", null))
+ }
+
+ return restoredCount
+ }
+
+ private fun calToJson(c: StickCalibration): JSONObject = JSONObject().apply {
+ put("cx", c.centerX)
+ put("cy", c.centerY)
+ put("dz", c.deadzonePercent)
+ put("invY", c.invertY)
+ }
+
+ private fun calFromJson(o: JSONObject): StickCalibration = StickCalibration(
+ centerX = o.optInt("cx", 0),
+ centerY = o.optInt("cy", 0),
+ deadzonePercent = o.optInt("dz", 8),
+ invertY = o.optBoolean("invY", false),
+ )
+}
diff --git a/app/src/main/java/com/steamcontroller/android/bt/BluetoothHidManager.kt b/app/src/main/java/com/steamcontroller/android/bt/BluetoothHidManager.kt
index 0166b04..16fcaa6 100644
--- a/app/src/main/java/com/steamcontroller/android/bt/BluetoothHidManager.kt
+++ b/app/src/main/java/com/steamcontroller/android/bt/BluetoothHidManager.kt
@@ -27,6 +27,7 @@ class BluetoothHidManager(private val context: Context) {
private const val VALVE_NOTIFY_HIGH: Long = 0x100f6c7aL
private const val VALVE_WRITE_LOW: Long = 0x100f6cb5L
private const val VALVE_WRITE_HIGH: Long = 0x100f6cbeL
+ private const val BATTERY_CHAR_SHORT: Long = 0x100f6c78L
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
@@ -43,6 +44,8 @@ class BluetoothHidManager(private val context: Context) {
private var gatt: BluetoothGatt? = null
private var featureWriteChar: BluetoothGattCharacteristic? = null
+ private var batteryChar: BluetoothGattCharacteristic? = null
+ private var pendingBatteryRead = false
private val pendingSubs = mutableListOf()
private var subsIndex = 0
@@ -89,6 +92,8 @@ class BluetoothHidManager(private val context: Context) {
} finally {
gatt = null
featureWriteChar = null
+ batteryChar = null
+ pendingBatteryRead = false
pendingSubs.clear()
subsIndex = 0
state = State.IDLE
@@ -96,33 +101,40 @@ class BluetoothHidManager(private val context: Context) {
}
}
- /** Heartbeat: send 0x85 to feature write char. Skip if a write is already in flight. */
/**
* Send a rumble command to the controller.
* Magnitudes are Android FF values (0..65535) — strong = left motor, weak = right.
*
+ * REVERTED (2026-07-12): tried switching to OUT_HAPTIC_RUMBLE (0x80), the "modern"
+ * continuous-haptic output report documented by github.com/ddeverill/SteamlessController
+ * (SteamController.cpp SendRumbleOutput). On real SC2026 hardware over this BLE
+ * characteristic it produced NO vibration at all, while this 0x8F pulse format DOES
+ * (confirmed on hardware, feel not yet tuned). Likely explanation: 0x80/0x81/0x82 are
+ * only valid as genuine USB HID *output* reports (a separate report channel from the
+ * feature-report/raw-command scheme), which doesn't necessarily exist as a raw
+ * writable command over this vendor GATT characteristic. 0x8F is the older SC1-style
+ * direct command (hid-steam.c HAPTIC_PULSE) which the SC2026 firmware apparently still
+ * honors here. Do not retry 0x80 without first confirming (via hardware log/sniff)
+ * that a *different* characteristic in the 100f6cb5-be write range is meant for it.
+ *
* Payload format inspired by the Linux `hid-steam` driver (steam_haptic_pulse):
- * byte 0: command id (0x8F = HAPTIC_PULSE on older Steam Controllers — likely
- * different on SC2026, to be validated empirically)
+ * byte 0: command id (0x8F = HAPTIC_PULSE)
* byte 1: pad id (0 = left, 1 = right)
* bytes 2-3: high period (u16 LE, microseconds — actuator ON time per cycle)
* bytes 4-5: low period (u16 LE, microseconds — actuator OFF time per cycle)
* bytes 6-7: repeat count (u16 LE, 0xFFFF for continuous)
*
- * Magnitude is encoded by the ratio high/low. Mapping used here:
- * magnitude 0xFFFF → high=1000us, low=1000us (50% duty, full strength)
- * magnitude 0x0000 → no command sent (treated as stop)
+ * Magnitude is encoded by the ratio high/low, repeated continuously (repeat=0xFFFF):
+ * magnitude 0xFFFF → high=2000us, low=1000us (~66% duty, full strength)
+ * magnitude 0x0000 → explicit stop (repeat=0)
*/
fun sendRumble(strong: Int, weak: Int) {
if (state != State.READY) return
val ch = featureWriteChar ?: return
val g = gatt ?: return
- val left = magnitudeToPayload(0, strong) ?: return
- val right = magnitudeToPayload(1, weak) ?: return
-
// Send left then right. WRITE_NO_RESPONSE so they don't queue up acks.
- for (payload in listOf(left, right)) {
+ for (payload in listOf(magnitudeToPayload(0, strong), magnitudeToPayload(1, weak))) {
try {
ch.value = payload
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
@@ -133,18 +145,51 @@ class BluetoothHidManager(private val context: Context) {
}
}
- private fun magnitudeToPayload(padId: Int, magnitude: Int): ByteArray? {
- // 0 magnitude → don't bother sending (the controller will stop on its own
- // once the previous pulse's repeat count expires)
+ private fun magnitudeToPayload(padId: Int, magnitude: Int): ByteArray {
val mag = magnitude.coerceIn(0, 0xFFFF)
- if (mag == 0) return null
- // Map magnitude 0..65535 to high period 100..2000 microseconds (inverse: stronger = shorter? we try direct).
- // Empirical: try direct mapping first. If feels backwards, invert.
- val highPeriod = (mag * 2000 / 0xFFFF).coerceIn(100, 2000)
- val lowPeriod = 1000
- val repeat = 1
+ if (mag == 0) {
+ // BUG FIX (2026-07-12): this used to return null here and sendRumble() would
+ // bail out without writing anything at all, on the assumption that "the
+ // controller will stop on its own once the previous pulse's repeat count
+ // expires". Confirmed wrong on hardware — a repeat=1 pulse from the branch
+ // below just buzzes continuously, and the calibration screen's "Test rumble"
+ // button had no way to ever stop it (magnitude 0 was silently skipped).
+ // Explicit stop: repeat=0 to cancel any in-flight pulse train.
+ return byteArrayOf(0x8F.toByte(), padId.toByte(), 0, 0, 0, 0, 0, 0)
+ }
+ // FIX (2026-07-12): the previous mapping kept lowPeriod fixed at 1000us and only
+ // scaled highPeriod (100-2000us), which changes the pulse *frequency* across the
+ // magnitude range (from ~1/(100+1000)=909Hz at low magnitude down to
+ // 1/(2000+1000)=333Hz at max). LRA actuators (used in the Steam Controller's
+ // haptics) only move significantly near their mechanical resonant frequency —
+ // typically ~170-200Hz for this class of actuator — so most of that range was
+ // driving well off-resonance, which loses amplitude independently of duty cycle.
+ // Now the cycle period is held ~constant near resonance and only the duty cycle
+ // (high/low ratio) varies with magnitude, which is the correct lever for perceived
+ // intensity on a fixed-frequency drive. Exact resonant frequency is unconfirmed for
+ // the SC2026 (no datasheet) — retune totalPeriodUs if this still feels off.
+ //
+ // ROUND 2 (2026-07-12): still too weak at 182Hz/88% max duty. Two changes together
+ // (confounds the next test, but each is independently well-motivated and cheap to
+ // back out if needed): nudged the frequency down to ~160Hz (period 6250us — some
+ // LRAs used in game controllers resonate lower than 182Hz), and pushed max duty
+ // from 88% to 97% (near-continuous drive at full magnitude — 0x8F's on/off pulse
+ // model may just have a firmness ceiling below what a "big motor spins" rumble
+ // feels like; 97% duty is close to that ceiling for this command).
+ val totalPeriodUs = 6250 // ~160Hz
+ val minDutyPct = 25
+ val maxDutyPct = 97
+ val dutyPct = minDutyPct + (mag * (maxDutyPct - minDutyPct) / 0xFFFF)
+ val highPeriod = (totalPeriodUs * dutyPct / 100).coerceIn(1, totalPeriodUs - 1)
+ val lowPeriod = totalPeriodUs - highPeriod
+ // FIX (2026-07-12): was hardcoded to 1 — a single ~2-3ms pulse per send, repeated
+ // only every 50-200ms by ControllerService.forwardRumble's throttle, so the motor
+ // sat idle >95% of the time. Confirmed on hardware: felt too weak. 0xFFFF matches
+ // our own documented protocol ("repeat count, 0xFFFF for continuous") — the pulse
+ // now cycles continuously between sends instead of firing one brief blip.
+ val repeat = 0xFFFF
return byteArrayOf(
- 0x8F.toByte(), // command id (HAPTIC_PULSE — guess)
+ 0x8F.toByte(), // command id (HAPTIC_PULSE)
padId.toByte(),
(highPeriod and 0xFF).toByte(), (highPeriod shr 8 and 0xFF).toByte(),
(lowPeriod and 0xFF).toByte(), (lowPeriod shr 8 and 0xFF).toByte(),
@@ -238,6 +283,7 @@ class BluetoothHidManager(private val context: Context) {
pendingSubs.clear()
featureWriteChar = null
+ batteryChar = null
for (ch in valve.characteristics) {
val short = shortUuid(ch.uuid) ?: continue
val canNotify = (ch.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0
@@ -248,6 +294,9 @@ class BluetoothHidManager(private val context: Context) {
if (canNotify && short in VALVE_NOTIFY_LOW..VALVE_NOTIFY_HIGH) {
pendingSubs.add(ch)
}
+ if (short == BATTERY_CHAR_SHORT) {
+ batteryChar = ch
+ }
if (canWrite && short in VALVE_WRITE_LOW..VALVE_WRITE_HIGH && featureWriteChar == null) {
featureWriteChar = ch
}
@@ -275,6 +324,30 @@ class BluetoothHidManager(private val context: Context) {
) {
heartbeatBusy.set(false)
if (status != 0) Log.w(TAG, "Write ${ch.uuid} failed: status=$status")
+ // GATT ops are serialized (only one in flight) — chain the seed battery read
+ // right after the disable-lizard write that follows subscription setup finishes.
+ if (pendingBatteryRead) {
+ pendingBatteryRead = false
+ batteryChar?.let { g.readCharacteristic(it) }
+ }
+ }
+
+ // Deprecated 3-arg overload (not the API 33+ byte[]-carrying one) — minSdk 26 means
+ // the OS-side BluetoothGatt implementation on most devices only ever calls this one.
+ @Suppress("DEPRECATION")
+ override fun onCharacteristicRead(
+ g: BluetoothGatt,
+ ch: BluetoothGattCharacteristic,
+ status: Int
+ ) {
+ if (status != BluetoothGatt.GATT_SUCCESS) {
+ Log.w(TAG, "Battery seed read failed: status=$status")
+ return
+ }
+ val data = ch.value ?: return
+ if (shortUuid(ch.uuid) == BATTERY_CHAR_SHORT && data.size == 14) {
+ onReport?.invoke(byteArrayOf(0x43.toByte(), data[1], 0x00))
+ }
}
private var reportCounter = 0
@@ -284,15 +357,32 @@ class BluetoothHidManager(private val context: Context) {
) {
val data = ch.value ?: return
reportCounter++
- // BLE strips the HID Report ID prefix; prepend 0x45 to reuse the USB parser.
- // Only state reports (>=40 bytes) get the prefix; short reports (battery/status) are forwarded as-is.
- val toForward: ByteArray = if (data.size >= 40) {
- val withId = ByteArray(data.size + 1)
- withId[0] = 0x45
- System.arraycopy(data, 0, withId, 1, data.size)
- withId
- } else {
- data
+ val short = shortUuid(ch.uuid)
+ // BLE strips the HID Report ID prefix; prepend it back to reuse the USB parser.
+ // State reports (>=40 bytes) get 0x45.
+ //
+ // Confirmed on hardware (2026-07-12 logcat capture) — two distinct short reports,
+ // neither matching the 2-byte guess originally assumed here:
+ // - 100f6c78, 14 bytes, e.g. "01 5d ff 0f 2c 10 00 00 00 00 00 00 14 75": byte[1]
+ // (0x5d = 93) stays constant across samples seconds apart while every other
+ // byte fluctuates (counter/checksum) — almost certainly the battery percent.
+ // Re-prefixed as 0x43 to reuse SteamReportParser.parseBatteryStatus.
+ // - 100f6c79, 5 bytes, alternating "01 02 00 00 00" / "00 02 00 00 00" in
+ // lockstep with our 800ms heartbeat write — an ack/status ping-pong tied to
+ // writes, NOT battery. Forwarded as-is (unparsed).
+ val toForward: ByteArray = when {
+ data.size >= 40 -> {
+ val withId = ByteArray(data.size + 1)
+ withId[0] = 0x45
+ System.arraycopy(data, 0, withId, 1, data.size)
+ withId
+ }
+ short == BATTERY_CHAR_SHORT && data.size == 14 -> byteArrayOf(0x43.toByte(), data[1], 0x00)
+ else -> {
+ Log.v(TAG, "Unrecognized short report (${data.size}B) from ${ch.uuid}: " +
+ data.joinToString(" ") { "%02x".format(it) })
+ data
+ }
}
// Log only the first report and one every 1000 (Hz check) — way less spammy
if (reportCounter == 1 || reportCounter % 1000 == 0) {
@@ -310,10 +400,15 @@ class BluetoothHidManager(private val context: Context) {
state = State.READY
if (ch == null) {
Log.w(TAG, "No feature write char; skipping disable lizard")
+ // Notify-only battery char never pushes until its value changes on the
+ // firmware side — seed it with an explicit read so the UI isn't stuck on
+ // "—" for controllers whose battery % doesn't tick during the session.
+ batteryChar?.let { g.readCharacteristic(it) }
return
}
ch.value = DISABLE_LIZARD
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
+ pendingBatteryRead = batteryChar != null
val ok = g.writeCharacteristic(ch)
Log.i(TAG, "Disable lizard write: $ok")
return
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 70a2d62..0652a2e 100644
--- a/app/src/main/java/com/steamcontroller/android/input/ButtonMapping.kt
+++ b/app/src/main/java/com/steamcontroller/android/input/ButtonMapping.kt
@@ -6,6 +6,7 @@ import com.steamcontroller.android.uinput.XboxButtons
enum class ButtonCategory(val title: String) {
FACE("Face buttons"),
BUMPERS("Bumpers"),
+ TRIGGERS("Triggers (full press)"),
STICKS("Stick clicks"),
SYSTEM("System"),
BACK_PADDLES("Back paddles"),
@@ -30,6 +31,12 @@ enum class SteamButton(
Y (Buttons.Y, "Y", ButtonCategory.FACE),
LB (Buttons.LB, "L1 / LB", ButtonCategory.BUMPERS),
RB (Buttons.RB, "R1 / RB", ButtonCategory.BUMPERS),
+ // Trigger full-press = digital bit fired when the analog trigger reaches max travel.
+ // The analog 0..32767 value still routes automatically to LT/RT axes on the
+ // emulated gamepad — these entries only matter if you remap "fully pulled" to a
+ // different action (e.g. RT-full → A button). LT_FULL bit at 0x400000 is V1.2-empirical.
+ LT (Buttons.LT_FULL, "L2 / LT (full press)", ButtonCategory.TRIGGERS),
+ RT (Buttons.RT_FULL, "R2 / RT (full press)", ButtonCategory.TRIGGERS),
LS (Buttons.LS, "L3 (stick click)", ButtonCategory.STICKS),
RS (Buttons.RS, "R3 (stick click)", ButtonCategory.STICKS),
MENU (Buttons.MENU, "Menu (Start)", ButtonCategory.SYSTEM),
@@ -47,6 +54,7 @@ enum class SteamButton(
val shortLabel: String get() = when (this) {
A -> "A"; B -> "B"; X -> "X"; Y -> "Y"
LB -> "L1"; RB -> "R1"
+ LT -> "L2"; RT -> "R2"
LS -> "L3"; RS -> "R3"
MENU -> "≡"; VIEW -> "···"; STEAM -> "◆"
L4 -> "L4"; R4 -> "R4"; L5 -> "L5"; R5 -> "R5"
@@ -58,10 +66,19 @@ enum class SteamButton(
/**
* Target buttons exposed by the virtual gamepad (Xbox layout — Android maps
* these consistently across all profiles).
- * `mask` is the bit position in the IPC payload (see XboxButtons).
- * NONE (mask=0) means "disabled" — the source bit is ignored.
+ *
+ * Four flavours coexist:
+ * - `mask > 0` : regular Xbox button (OR into the gamepad button mask).
+ * - `keyBit >= 0` : sidecar keyboard key — emitted via the mouse+kbd sidecar
+ * device that always runs alongside a gamepad profile.
+ * Bit value matches MouseTarget so we reuse the same JNI path.
+ * - `triggerSide != 0` : forces an analog trigger axis to max (1=LT, 2=RT) when the
+ * source is pressed. OR'd with the real analog reading via max().
+ * - `mask < 0` (no other) : special action handled in Kotlin (e.g. SCREENSHOT).
+ *
+ * NONE (mask=0, keyBit=-1, triggerSide=0) means "disabled".
*/
-enum class XboxTarget(val mask: Int, val displayName: String) {
+enum class XboxTarget(val mask: Int, val displayName: String, val keyBit: Int = -1, val triggerSide: Int = 0) {
NONE (0, "(none)"),
A (XboxButtons.A, "A"),
B (XboxButtons.B, "B"),
@@ -75,8 +92,29 @@ enum class XboxTarget(val mask: Int, val displayName: String) {
THUMBL(XboxButtons.THUMBL, "L3"),
THUMBR(XboxButtons.THUMBR, "R3"),
- // Special actions (mask < 0). Don't OR into the Xbox button bitmask; they trigger
- // a Kotlin-side function instead, edge-triggered on press.
+ // ── Trigger axes (digital → forced max) ──────────────────────────────────
+ // Lets the user remap any button (face, paddle, bumper…) to "trigger fully
+ // pulled". Useful e.g. to remap a back paddle to LT so you can drive + sip a
+ // potion at the same time. Mask is negative so it doesn't OR into face buttons.
+ LT_TRIGGER(-30, "L2 / LT (full press)", triggerSide = 1),
+ RT_TRIGGER(-31, "R2 / RT (full press)", triggerSide = 2),
+
+ // ── Sidecar keyboard keys (great for back paddles in gamepad mode) ───────
+ // keyBit values must match MouseTarget.bit positions (see MouseTarget.kt).
+ KB_VOLUME_UP (-10, "🔊 Volume +", keyBit = 10),
+ KB_VOLUME_DOWN(-11, "🔉 Volume -", keyBit = 11),
+ KB_PLAY_PAUSE (-12, "⏯ Play / Pause", keyBit = 12),
+ KB_BACK (-13, "⮌ Back", keyBit = 5),
+ KB_HOME (-14, "🏠 Home", keyBit = 8),
+ KB_ENTER (-15, "↵ Enter", keyBit = 4),
+ KB_DPAD_CENTER(-16, "● DPAD Center", keyBit = 15),
+ KB_ESCAPE (-17, "ESC", keyBit = 9),
+ KB_TAB (-18, "⇥ Tab", keyBit = 6),
+ KB_SPACE (-19, "␣ Space", keyBit = 7),
+ 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"),
}
@@ -88,6 +126,10 @@ val DEFAULT_MAPPING: Map = mapOf(
SteamButton.Y to XboxTarget.Y,
SteamButton.LB to XboxTarget.LB,
SteamButton.RB to XboxTarget.RB,
+ // Full-press trigger → itself by default (forces the axis fully to max on a hard
+ // pull, on top of the analog 0..32767 that already routes to LT/RT via sendFrame()).
+ SteamButton.LT to XboxTarget.LT_TRIGGER,
+ SteamButton.RT to XboxTarget.RT_TRIGGER,
SteamButton.LS to XboxTarget.THUMBL,
SteamButton.RS to XboxTarget.THUMBR,
SteamButton.MENU to XboxTarget.START,
diff --git a/app/src/main/java/com/steamcontroller/android/input/InteractiveControllerView.kt b/app/src/main/java/com/steamcontroller/android/input/InteractiveControllerView.kt
new file mode 100644
index 0000000..84a0450
--- /dev/null
+++ b/app/src/main/java/com/steamcontroller/android/input/InteractiveControllerView.kt
@@ -0,0 +1,160 @@
+package com.steamcontroller.android.input
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.util.AttributeSet
+import android.view.MotionEvent
+import android.view.View
+import androidx.core.content.ContextCompat
+import androidx.core.graphics.drawable.DrawableCompat
+import com.steamcontroller.android.R
+import kotlin.math.hypot
+
+/**
+ * Top-view illustration of the SC2026 controller with tap-to-remap hitspots over
+ * each remappable button. Drawn on top of the official SC2026 silhouette.
+ *
+ * The hitspot positions are normalised against the source SVG viewBox (503.84×364.21)
+ * so they scale with the view regardless of size. Positions are approximate and may
+ * need small tweaks once viewed on-device.
+ */
+class InteractiveControllerView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyle: Int = 0
+) : View(context, attrs, defStyle) {
+
+ /** A circular tap target over one button on the controller image. */
+ data class Hitspot(
+ val button: SteamButton,
+ /** Normalised X position in [0,1] over the source SVG. */
+ val nx: Float,
+ /** Normalised Y position in [0,1] over the source SVG. */
+ val ny: Float,
+ /** Radius in dp. */
+ val radiusDp: Float = 16f,
+ )
+
+ /**
+ * Hitspot map approximated from the SVG paths.
+ * Tweak coordinates here if a button feels off-target on real screens.
+ */
+ private val hitspots: List = listOf(
+ // Bumpers (top edge)
+ Hitspot(SteamButton.LB, nx = 0.18f, ny = 0.10f),
+ Hitspot(SteamButton.RB, nx = 0.82f, ny = 0.10f),
+ // Face buttons (right cluster — diamond)
+ Hitspot(SteamButton.Y, nx = 0.83f, ny = 0.22f),
+ Hitspot(SteamButton.X, nx = 0.76f, ny = 0.30f),
+ Hitspot(SteamButton.B, nx = 0.90f, ny = 0.30f),
+ Hitspot(SteamButton.A, nx = 0.83f, ny = 0.38f),
+ // Stick clicks
+ Hitspot(SteamButton.LS, nx = 0.33f, ny = 0.45f, radiusDp = 22f),
+ Hitspot(SteamButton.RS, nx = 0.67f, ny = 0.45f, radiusDp = 22f),
+ // System cluster (center)
+ Hitspot(SteamButton.STEAM, nx = 0.50f, ny = 0.25f),
+ Hitspot(SteamButton.QUICK_ACCESS, nx = 0.50f, ny = 0.42f),
+ Hitspot(SteamButton.VIEW, nx = 0.42f, ny = 0.55f),
+ Hitspot(SteamButton.MENU, nx = 0.58f, ny = 0.55f),
+ // Grips (sides)
+ Hitspot(SteamButton.GRIP_LT, nx = 0.08f, ny = 0.62f),
+ Hitspot(SteamButton.GRIP_RT, nx = 0.92f, ny = 0.62f),
+ // Back paddles (bottom corners)
+ Hitspot(SteamButton.L4, nx = 0.20f, ny = 0.82f),
+ Hitspot(SteamButton.L5, nx = 0.30f, ny = 0.85f),
+ Hitspot(SteamButton.R4, nx = 0.80f, ny = 0.82f),
+ Hitspot(SteamButton.R5, nx = 0.70f, ny = 0.85f),
+ )
+
+ private val controllerDrawable = ContextCompat.getDrawable(context, R.drawable.sc2026_controller_black)!!.also {
+ DrawableCompat.setTint(DrawableCompat.wrap(it).mutate(),
+ ContextCompat.getColor(context, android.R.color.holo_blue_light))
+ }
+
+ private val ringPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = dp(1.5f)
+ color = Color.argb(0x60, 0xFF, 0xFF, 0xFF)
+ }
+ private val highlightRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = dp(2.5f)
+ color = ContextCompat.getColor(context, android.R.color.holo_blue_light)
+ }
+
+ private var highlightedButton: SteamButton? = null
+ private var onButtonTapped: ((SteamButton) -> Unit)? = null
+
+ /** Match the SVG's aspect ratio so hitspot normalisation stays accurate. */
+ private val sourceAspect: Float = 503.84f / 364.21f
+
+ init {
+ // No background — the drawable is the visual.
+ isClickable = true
+ isFocusable = true
+ }
+
+ fun setOnButtonTappedListener(listener: (SteamButton) -> Unit) {
+ onButtonTapped = listener
+ }
+
+ /** Optional: highlight a row's button to indicate it on the diagram. */
+ fun highlight(button: SteamButton?) {
+ if (highlightedButton == button) return
+ highlightedButton = button
+ invalidate()
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ // Lock the height to match the SVG aspect ratio against measured width.
+ val w = MeasureSpec.getSize(widthMeasureSpec)
+ val h = (w / sourceAspect).toInt()
+ setMeasuredDimension(w, h)
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ controllerDrawable.setBounds(0, 0, width, height)
+ controllerDrawable.draw(canvas)
+
+ for (hs in hitspots) {
+ val cx = hs.nx * width
+ val cy = hs.ny * height
+ val r = dp(hs.radiusDp)
+ val paint = if (hs.button == highlightedButton) highlightRingPaint else ringPaint
+ canvas.drawCircle(cx, cy, r, paint)
+ }
+ }
+
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ if (event.action != MotionEvent.ACTION_UP) return super.onTouchEvent(event)
+ val tx = event.x
+ val ty = event.y
+ // Find the closest hitspot within its own radius.
+ var best: Hitspot? = null
+ var bestDist = Float.MAX_VALUE
+ for (hs in hitspots) {
+ val cx = hs.nx * width
+ val cy = hs.ny * height
+ val d = hypot(cx - tx, cy - ty)
+ if (d < dp(hs.radiusDp) && d < bestDist) {
+ best = hs
+ bestDist = d
+ }
+ }
+ if (best != null) {
+ performClick()
+ onButtonTapped?.invoke(best.button)
+ return true
+ }
+ return false
+ }
+
+ override fun performClick(): Boolean {
+ super.performClick()
+ return true
+ }
+
+ private fun dp(value: Float): Float = value * resources.displayMetrics.density
+}
diff --git a/app/src/main/java/com/steamcontroller/android/input/MouseTarget.kt b/app/src/main/java/com/steamcontroller/android/input/MouseTarget.kt
new file mode 100644
index 0000000..1ecb842
--- /dev/null
+++ b/app/src/main/java/com/steamcontroller/android/input/MouseTarget.kt
@@ -0,0 +1,85 @@
+package com.steamcontroller.android.input
+
+import com.steamcontroller.android.parser.Buttons
+
+/**
+ * Targets available in Desktop / Mouse mode. The `bit` field MUST match the order
+ * of the `MOUSE_KEYS` array in `uinput_jni.cpp`. Bits 15/16/17 are mouse buttons.
+ *
+ * NONE (bit = -1) means "no event emitted for this source".
+ */
+enum class MouseTarget(val bit: Int, val displayName: String) {
+ NONE(-1, "(none)"),
+
+ // Navigation keys
+ KEY_UP (0, "↑ Up"),
+ KEY_DOWN (1, "↓ Down"),
+ KEY_LEFT (2, "← Left"),
+ KEY_RIGHT(3, "→ Right"),
+
+ KEY_ENTER(4, "Enter / OK"),
+ KEY_BACK (5, "Back (Android)"),
+ KEY_TAB (6, "Tab"),
+ KEY_SPACE(7, "Space"),
+
+ KEY_HOME(8, "Home"),
+ KEY_ESC (9, "Escape"),
+
+ KEY_VOLUME_UP (10, "Volume +"),
+ KEY_VOLUME_DOWN(11, "Volume -"),
+ KEY_PLAY_PAUSE (12, "Play/Pause"),
+ KEY_MENU (13, "Menu (context)"),
+ KEY_BACKSPACE (14, "Backspace"),
+
+ // bit 15 → Linux KEY_SELECT → AKEYCODE_DPAD_CENTER. Required to "click" a
+ // focused key on the Android TV Leanback soft keyboard (ENTER alone is not
+ // enough — the IME source-filters selection to DPAD_CENTER events).
+ KEY_DPAD_CENTER(15, "DPAD Center / select"),
+
+ // Mouse buttons (bits 16-18 in the native frame)
+ BTN_LEFT (16, "🖱 Left click"),
+ BTN_RIGHT (17, "🖱 Right click"),
+ BTN_MIDDLE(18, "🖱 Middle click"),
+}
+
+/** Hardcoded V1.1 mapping. Customisable mapping UI will land later. */
+val DEFAULT_MOUSE_MAPPING: Map = mapOf(
+ // A = DPAD_CENTER so it acts like an Android remote "OK": selects letters on the
+ // Leanback IME, clicks focused UI buttons, and on text fields most apps treat it
+ // the same as ENTER. The dedicated ENTER key is still reachable via remap if needed.
+ SteamButton.A to MouseTarget.KEY_DPAD_CENTER,
+ SteamButton.B to MouseTarget.KEY_BACK,
+ SteamButton.X to MouseTarget.KEY_SPACE,
+ SteamButton.Y to MouseTarget.KEY_TAB,
+ SteamButton.LB to MouseTarget.BTN_RIGHT,
+ SteamButton.RB to MouseTarget.BTN_LEFT,
+ // Triggers default to NONE in Desktop too — scroll is already on the left trackpad.
+ SteamButton.LT to MouseTarget.NONE,
+ SteamButton.RT to MouseTarget.NONE,
+ SteamButton.LS to MouseTarget.KEY_HOME,
+ SteamButton.RS to MouseTarget.BTN_MIDDLE,
+ SteamButton.MENU to MouseTarget.KEY_MENU,
+ SteamButton.VIEW to MouseTarget.KEY_ESC,
+ SteamButton.STEAM to MouseTarget.KEY_HOME,
+ SteamButton.QUICK_ACCESS to MouseTarget.KEY_PLAY_PAUSE,
+ SteamButton.L4 to MouseTarget.KEY_VOLUME_DOWN,
+ SteamButton.R4 to MouseTarget.KEY_VOLUME_UP,
+ SteamButton.L5 to MouseTarget.KEY_BACKSPACE,
+ SteamButton.R5 to MouseTarget.NONE,
+ SteamButton.GRIP_LT to MouseTarget.NONE,
+ SteamButton.GRIP_RT to MouseTarget.NONE,
+)
+
+/**
+ * Source bits that route to *fixed* targets in mouse mode (not customisable in V1.1):
+ * - DPAD → arrow keys
+ * - Left trackpad click → right mouse click
+ * - Right trackpad motion → cursor delta (handled separately, not a button)
+ */
+val MOUSE_MODE_FIXED_DPAD = mapOf(
+ Buttons.DPAD_UP to MouseTarget.KEY_UP,
+ Buttons.DPAD_DOWN to MouseTarget.KEY_DOWN,
+ Buttons.DPAD_LEFT to MouseTarget.KEY_LEFT,
+ Buttons.DPAD_RIGHT to MouseTarget.KEY_RIGHT,
+)
+const val MOUSE_LEFT_PAD_CLICK_BIT = Buttons.TP_LT_CLICK
diff --git a/app/src/main/java/com/steamcontroller/android/input/NamedProfile.kt b/app/src/main/java/com/steamcontroller/android/input/NamedProfile.kt
new file mode 100644
index 0000000..7cbe94f
--- /dev/null
+++ b/app/src/main/java/com/steamcontroller/android/input/NamedProfile.kt
@@ -0,0 +1,110 @@
+package com.steamcontroller.android.input
+
+import org.json.JSONArray
+import org.json.JSONObject
+import java.util.UUID
+
+/**
+ * A named profile snapshot — everything the user can tune, captured under a single name.
+ *
+ * Profiles live in SharedPreferences as a JSON array. Loading a profile overwrites the
+ * matching live preference keys (profileId, transport, sticks, mappings, etc.) so the
+ * very next service start uses the profile's settings.
+ *
+ * `boundPackages` powers the foreground-app auto-switch in Phase 2b: if the currently
+ * focused Android app matches any package in this profile's list, the service swaps in.
+ */
+data class NamedProfile(
+ val id: String = UUID.randomUUID().toString(),
+ val name: String,
+ val profileId: Int,
+ val transport: Int,
+ val leftCenterX: Int,
+ val leftCenterY: Int,
+ val leftDeadzone: Int,
+ val leftInvertY: Boolean,
+ val rightCenterX: Int,
+ val rightCenterY: Int,
+ val rightDeadzone: Int,
+ val rightInvertY: Boolean,
+ val mouseSensitivity: Float,
+ val trackpadAsMouse: Boolean,
+ val rumbleIntensity: Int,
+ /** Serialised as a JSON object mapping `SteamButton.name` → `XboxTarget.ordinal`. */
+ val mapping: Map,
+ val boundPackages: List = emptyList(),
+) {
+ fun toJson(): JSONObject = JSONObject().apply {
+ put(KEY_ID, id)
+ put(KEY_NAME, name)
+ put(KEY_PROFILE_ID, profileId)
+ put(KEY_TRANSPORT, transport)
+ put(KEY_LCX, leftCenterX); put(KEY_LCY, leftCenterY)
+ put(KEY_LDZ, leftDeadzone); put(KEY_LIY, leftInvertY)
+ put(KEY_RCX, rightCenterX); put(KEY_RCY, rightCenterY)
+ put(KEY_RDZ, rightDeadzone); put(KEY_RIY, rightInvertY)
+ put(KEY_MS, mouseSensitivity.toDouble())
+ put(KEY_TAM, trackpadAsMouse)
+ put(KEY_RI, rumbleIntensity)
+ put(KEY_MAPPING, JSONObject(mapping as Map<*, *>))
+ put(KEY_BOUND, JSONArray(boundPackages))
+ }
+
+ companion object {
+ private const val KEY_ID = "id"
+ private const val KEY_NAME = "name"
+ private const val KEY_PROFILE_ID = "profileId"
+ private const val KEY_TRANSPORT = "transport"
+ private const val KEY_LCX = "lcx"; private const val KEY_LCY = "lcy"
+ private const val KEY_LDZ = "ldz"; private const val KEY_LIY = "liy"
+ private const val KEY_RCX = "rcx"; private const val KEY_RCY = "rcy"
+ private const val KEY_RDZ = "rdz"; private const val KEY_RIY = "riy"
+ private const val KEY_MS = "mouseSens"
+ private const val KEY_TAM = "trackpadAsMouse"
+ private const val KEY_RI = "rumbleIntensity"
+ private const val KEY_MAPPING = "mapping"
+ private const val KEY_BOUND = "boundPackages"
+
+ fun fromJson(obj: JSONObject): NamedProfile {
+ val mapObj = obj.optJSONObject(KEY_MAPPING) ?: JSONObject()
+ val mapping = mutableMapOf()
+ mapObj.keys().forEach { k -> mapping[k] = mapObj.optInt(k, 0) }
+
+ val boundArr = obj.optJSONArray(KEY_BOUND)
+ val bound = if (boundArr != null) (0 until boundArr.length()).map { boundArr.getString(it) } else emptyList()
+
+ return NamedProfile(
+ id = obj.optString(KEY_ID, UUID.randomUUID().toString()),
+ name = obj.optString(KEY_NAME, "Profile"),
+ profileId = obj.optInt(KEY_PROFILE_ID, 0),
+ transport = obj.optInt(KEY_TRANSPORT, 0),
+ leftCenterX = obj.optInt(KEY_LCX, 0),
+ leftCenterY = obj.optInt(KEY_LCY, 0),
+ leftDeadzone = obj.optInt(KEY_LDZ, 8),
+ leftInvertY = obj.optBoolean(KEY_LIY, false),
+ rightCenterX = obj.optInt(KEY_RCX, 0),
+ rightCenterY = obj.optInt(KEY_RCY, 0),
+ rightDeadzone = obj.optInt(KEY_RDZ, 8),
+ rightInvertY = obj.optBoolean(KEY_RIY, false),
+ mouseSensitivity = obj.optDouble(KEY_MS, 1.0).toFloat(),
+ trackpadAsMouse = obj.optBoolean(KEY_TAM, true),
+ rumbleIntensity = obj.optInt(KEY_RI, 100),
+ mapping = mapping,
+ boundPackages = bound,
+ )
+ }
+
+ fun listToJson(profiles: List): String =
+ JSONArray().apply { profiles.forEach { put(it.toJson()) } }.toString()
+
+ fun listFromJson(json: String): List {
+ if (json.isBlank()) return emptyList()
+ return try {
+ val arr = JSONArray(json)
+ (0 until arr.length()).map { fromJson(arr.getJSONObject(it)) }
+ } catch (_: Throwable) {
+ emptyList()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/steamcontroller/android/parser/SteamControllerState.kt b/app/src/main/java/com/steamcontroller/android/parser/SteamControllerState.kt
index e645a51..2581a19 100644
--- a/app/src/main/java/com/steamcontroller/android/parser/SteamControllerState.kt
+++ b/app/src/main/java/com/steamcontroller/android/parser/SteamControllerState.kt
@@ -23,14 +23,8 @@ data class SteamControllerState(
val quatX: Short = 0, // bytes 34-35
val quatY: Short = 0, // bytes 36-37
val quatZ: Short = 0, // bytes 38-39
-
- val battery: Int = -1 // bytes 44-45 UInt16 LE (0xFFFF = full). -1 = unknown
) {
fun isButtonPressed(mask: Int) = (buttons and mask) != 0
-
- /** Battery as 0..100, or null if unknown. 0xFFFF raw = 100%. */
- val batteryPercent: Int?
- get() = if (battery < 0) null else (battery * 100 / 0xFFFF).coerceIn(0, 100)
}
// Source: github.com/ddeverill/SteamlessController/blob/main/src/steam/SteamController.h
@@ -63,7 +57,7 @@ object Buttons {
const val LB = 0x00080000
const val RS_TOUCH = 0x00100000
const val TP_RT = 0x00200000 // right trackpad touch
- // bit 0x00400000 = unused / unknown
+ const val LT_FULL = 0x00400000 // LT full digital press (empirically suspected — V1.2)
const val RT_FULL = 0x00800000 // RT full digital press
// byte5 (flags)
diff --git a/app/src/main/java/com/steamcontroller/android/parser/SteamReportParser.kt b/app/src/main/java/com/steamcontroller/android/parser/SteamReportParser.kt
index 53b2500..d1f5a9b 100644
--- a/app/src/main/java/com/steamcontroller/android/parser/SteamReportParser.kt
+++ b/app/src/main/java/com/steamcontroller/android/parser/SteamReportParser.kt
@@ -2,11 +2,38 @@ package com.steamcontroller.android.parser
import android.util.Log
+/**
+ * Dedicated battery/charge report (id 0x43). Two distinct physical sources feed this,
+ * with different layouts — NOT the single [id, percent, chargeState] layout documented
+ * for the older Steam Controller in SteamlessController's ControllerManager.cpp:
+ * - USB: a genuine 15-byte 0x43 report on the bulk endpoint, sent roughly every 3.5s.
+ * Empirically decoded from a real capture (percent pinned at 100 while charging over
+ * USB, byte[2] constant at 0x64=100 across all samples): percent is at byte[2].
+ * - BLE: BluetoothHidManager already extracts percent from the 14-byte 100f6c78
+ * characteristic and re-prefixes a compact synthetic [0x43, percent, 0x00] (3 bytes)
+ * before it reaches this parser — see its onCharacteristicChanged.
+ * chargeState's real offset/encoding is unconfirmed on either path (unused elsewhere in
+ * the app) — kept as a best-effort placeholder, 0 on the BLE synthetic report.
+ */
+data class BatteryStatus(val percent: Int, val chargeState: Int)
+
object SteamReportParser {
private const val TAG = "SteamReportParser"
private const val EXPECTED_REPORT_ID = 0x45
private const val MIN_REPORT_LEN = 40
+ private const val REPORT_BATTERY_STATUS = 0x43
+ private const val USB_BATTERY_REPORT_LEN = 15
+
+ fun parseBatteryStatus(report: ByteArray): BatteryStatus? {
+ if (report.size < 3) return null
+ if ((report[0].toInt() and 0xFF) != REPORT_BATTERY_STATUS) return null
+ return if (report.size >= USB_BATTERY_REPORT_LEN) {
+ BatteryStatus(percent = report[2].toInt() and 0xFF, chargeState = report[3].toInt() and 0xFF)
+ } else {
+ BatteryStatus(percent = report[1].toInt() and 0xFF, chargeState = 0)
+ }
+ }
fun parse(report: ByteArray): SteamControllerState? {
if (report.size < MIN_REPORT_LEN) return null
@@ -48,11 +75,6 @@ object SteamReportParser {
quatX = readInt16LE(report, 34),
quatY = readInt16LE(report, 36),
quatZ = readInt16LE(report, 38),
- // Battery at bytes 44-45 is only valid on FULL USB state reports (≥54 bytes incl. 0x45 prefix).
- // On BLE the report is truncated to ~46 bytes (after prefix) and bytes 44-45 fall in
- // the gyro-rest / pad-contact zone instead — reading there gives spurious 0/0xFFFF.
- // The actual BLE battery is in a separate 5-byte status report from char 100f6c79 (TODO).
- battery = if (report.size >= 54) readUInt16LE(report, 44) else -1
)
}
diff --git a/app/src/main/java/com/steamcontroller/android/service/ControllerService.kt b/app/src/main/java/com/steamcontroller/android/service/ControllerService.kt
index c853943..d996f21 100644
--- a/app/src/main/java/com/steamcontroller/android/service/ControllerService.kt
+++ b/app/src/main/java/com/steamcontroller/android/service/ControllerService.kt
@@ -23,6 +23,7 @@ import com.steamcontroller.android.uinput.UInputGamepad
import com.steamcontroller.android.usb.HidReportReader
import com.steamcontroller.android.usb.SteamHidProtocol
import com.steamcontroller.android.usb.UsbConnectionManager
+import com.steamcontroller.android.service.UsageStatsHelper
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -40,6 +41,7 @@ class ControllerService : Service() {
const val ACTION_STOP = "com.steamcontroller.android.STOP"
const val ACTION_NEXT_PROFILE = "com.steamcontroller.android.NEXT_PROFILE"
const val ACTION_TEST_RUMBLE = "com.steamcontroller.android.TEST_RUMBLE"
+ private const val TEST_RUMBLE_DURATION_MS = 5000L
// Observed by DebugActivity / MainActivity for live display
private val _stateFlow = MutableStateFlow(null)
@@ -78,6 +80,7 @@ class ControllerService : Service() {
private val INJECTABLE_MASK =
Buttons.A or Buttons.B or Buttons.X or Buttons.Y or
Buttons.LB or Buttons.RB or
+ Buttons.LT_FULL or Buttons.RT_FULL or
Buttons.MENU or Buttons.VIEW or Buttons.STEAM or Buttons.QUICK_ACCESS or
Buttons.LS or Buttons.RS or
Buttons.L4 or Buttons.L5 or Buttons.R4 or Buttons.R5 or
@@ -104,6 +107,111 @@ class ControllerService : Service() {
scope.launch {
_batteryFlow.collect { refreshNotification() }
}
+
+ startForegroundAppMonitor()
+ }
+
+ // ─── Foreground-app auto-switch (V1.2 Phase 2b) ────────────────────────────
+ private var foregroundAppMonitorJob: Job? = null
+ private var lastForegroundPackage: String? = null
+
+ /**
+ * Poll UsageStatsManager every 1.5s for the focused app. On change, look up
+ * a matching named-profile binding and live-switch the gamepad profile.
+ * Silently inert if the user hasn't granted PACKAGE_USAGE_STATS.
+ */
+ private fun startForegroundAppMonitor() {
+ foregroundAppMonitorJob?.cancel()
+ foregroundAppMonitorJob = scope.launch {
+ while (isActive) {
+ try { tickForegroundAppMonitor() } catch (t: Throwable) {
+ Log.w(TAG, "Foreground monitor tick failed: ${t.message}")
+ }
+ delay(1500)
+ }
+ }
+ }
+
+ private var loggedNoUsagePerm = false
+
+ private fun tickForegroundAppMonitor() {
+ if (!UsageStatsHelper.hasPermission(this)) {
+ if (!loggedNoUsagePerm) {
+ Log.w(TAG, "Auto-switch inert: PACKAGE_USAGE_STATS not granted")
+ loggedNoUsagePerm = true
+ }
+ return
+ }
+ loggedNoUsagePerm = false
+
+ val current = UsageStatsHelper.getCurrentForegroundApp(this) ?: return
+ if (current == lastForegroundPackage) return
+ Log.v(TAG, "Foreground changed: $lastForegroundPackage → $current")
+ lastForegroundPackage = current
+ // Ignore self — opening our own UI shouldn't trigger anything.
+ if (current == packageName) return
+
+ val bound = Prefs.listNamedProfiles(this)
+ .firstOrNull { current in it.boundPackages }
+ if (bound == null) {
+ Log.v(TAG, " no profile bound to $current")
+ return
+ }
+
+ // Gate: skip only if BOTH the active named-profile id AND the live emulated
+ // gamepad already match the binding. Without the live-profile check we'd skip
+ // when the user has manually picked a different gamepad variant via the radios
+ // (which doesn't clear activeNamedProfileId).
+ val liveProfileMatches = Prefs.getProfile(this).id == bound.profileId
+ val activeMatches = Prefs.getActiveNamedProfileId(this) == bound.id
+ if (activeMatches && liveProfileMatches) {
+ Log.v(TAG, " '${bound.name}' already applied — skipping")
+ return
+ }
+
+ Log.i(TAG, "Auto-switch → '${bound.name}' (foreground=$current, activeMatches=$activeMatches, liveMatches=$liveProfileMatches)")
+ Prefs.applyNamedProfile(this, bound)
+ announceProfileLoaded(bound.name)
+
+ // Live-swap the gamepad profile (skip if not in uinput mode).
+ if (mode == InjectionMode.UINPUT && !profileSwitchInFlight) {
+ profileSwitchInFlight = true
+ scope.launch {
+ try {
+ val gp = com.steamcontroller.android.uinput.GamepadProfile.fromId(bound.profileId)
+ uinput.switchProfile(gp)
+ _profileFlow.value = bound.profileId
+ refreshNotification()
+ } finally {
+ profileSwitchInFlight = false
+ }
+ }
+ } else {
+ _profileFlow.value = bound.profileId
+ refreshNotification()
+ }
+ }
+
+ /**
+ * Surface the auto-switch to the user via:
+ * 1. A LENGTH_LONG Toast on the main thread (cheapest signal, shows over the
+ * launching app — might be missed if the user is head-down, hence #2).
+ * 2. The foreground-service notification text gets the profile name appended
+ * (persistent until the next swap), so the user can always pull the shade
+ * to confirm which preset is live.
+ */
+ private fun announceProfileLoaded(profileName: String) {
+ Log.i(TAG, "announceProfileLoaded: $profileName")
+ val handler = android.os.Handler(android.os.Looper.getMainLooper())
+ handler.post {
+ android.widget.Toast.makeText(
+ applicationContext,
+ "Game Profile loaded: $profileName",
+ android.widget.Toast.LENGTH_LONG,
+ ).show()
+ }
+ // Notification refresh happens via refreshNotification() in the caller
+ // after _profileFlow.value is updated.
}
private var lastRumbleStrong = -1
@@ -111,8 +219,8 @@ class ControllerService : Service() {
private var lastRumbleSentAt = 0L
/**
- * Manual rumble test: pulse both motors at full strength for 500ms, then stop.
- * Triggered by the "Test rumble" button in CalibrationActivity.
+ * Manual rumble test: pulse both motors at full strength for TEST_RUMBLE_DURATION_MS,
+ * then stop. Triggered by the "Test rumble" button in CalibrationActivity.
*/
private fun testRumble() {
Log.i(TAG, "Test rumble requested")
@@ -120,7 +228,7 @@ class ControllerService : Service() {
lastRumbleSentAt = 0
forwardRumble(0xFFFF, 0xFFFF)
scope.launch {
- delay(500)
+ delay(TEST_RUMBLE_DURATION_MS)
lastRumbleSentAt = 0
forwardRumble(0, 0)
}
@@ -285,10 +393,15 @@ class ControllerService : Service() {
private fun onHidFrame(state: SteamControllerState, raw: ByteArray) {
_stateFlow.value = state
_rawReportFlow.value = raw
- // Battery only ships in the full state report; null when unknown
- state.batteryPercent?.let { pct ->
- if (_batteryFlow.value != pct) _batteryFlow.value = pct
+
+ // Dedicated battery/charge report (id 0x43) — works on both USB and BT,
+ // percent is already 0-100. Sole battery source: bytes 44-45 of the 0x45 state
+ // report were assumed to be a static battery field but turned out to be live,
+ // fast-changing data (empirically: flickers 0%/99% on USB), so that guess isn't used.
+ SteamReportParser.parseBatteryStatus(raw)?.let { status ->
+ if (_batteryFlow.value != status.percent) _batteryFlow.value = status.percent
}
+
if (raw.isNotEmpty() && (raw[0].toInt() and 0xFF) == 0x45) {
handleState(state)
}
@@ -339,25 +452,45 @@ class ControllerService : Service() {
* Triggered by the notification action: cycle to the next profile (Xbox360 → XboxOne → DS4 → DualSense → ...).
* Only meaningful in uinput mode; in fallback inject mode the profile is ignored.
*/
+ // Guards against re-entrant taps on the "Switch profile" notification action while
+ // a previous switch is still tearing down / recreating uinput devices.
+ @Volatile private var profileSwitchInFlight = false
+
private fun cycleProfile() {
+ if (profileSwitchInFlight) {
+ Log.w(TAG, "Cycle profile ignored: a switch is already in progress")
+ return
+ }
+ profileSwitchInFlight = true
+
val profiles = com.steamcontroller.android.uinput.GamepadProfile.values()
val current = Prefs.getProfile(this)
val next = profiles[(current.ordinal + 1) % profiles.size]
Prefs.setProfile(this, next)
Log.i(TAG, "Cycle profile: ${current.displayName} → ${next.displayName}")
- if (mode == InjectionMode.UINPUT) {
- val ok = uinput.switchProfile(next)
- if (!ok) Log.w(TAG, "switchProfile failed; the gamepad may need a service restart")
- }
- // Reset baseline state so any buttons "held" during the swap don't get injected
+ // Reset baseline state immediately so any buttons "held" during the swap
+ // don't get injected via the now-defunct device.
confirmedState = null
pendingButtons = 0
pendingFrames = 0
- refreshNotification()
- // Tell MainActivity about the new profile so the dropdown and "Mode:" label update.
- _profileFlow.value = next.id
+ // Run the actual device teardown/recreate off the service main thread —
+ // it's a blocking binder + native ioctl pair that can take 100ms+.
+ // Doing it on the main thread risks ANR / lost broadcast intents and was
+ // the most likely cause of the "I can't change profile until I reboot" bug.
+ scope.launch {
+ try {
+ if (mode == InjectionMode.UINPUT) {
+ val ok = uinput.switchProfile(next)
+ if (!ok) Log.w(TAG, "switchProfile failed; the gamepad may need a service restart")
+ }
+ _profileFlow.value = next.id
+ refreshNotification()
+ } finally {
+ profileSwitchInFlight = false
+ }
+ }
}
private fun handleState(state: SteamControllerState) {
@@ -390,9 +523,16 @@ class ControllerService : Service() {
when (mode) {
InjectionMode.UINPUT -> {
- // Combine confirmed buttons with current raw axes — uinput frame is atomic
- val merged = state.copy(buttons = confirmedState!!.buttons)
- uinput.pushFrame(merged)
+ // Combine confirmed buttons with current raw axes — uinput frame is atomic.
+ // Desktop / mouse mode bypasses the gamepad debounce: the trackpad touch flag
+ // (TP_RT) is capacitive and excluded from the debounce, so using confirmed
+ // buttons would freeze the cursor whenever the touch flag couldn't propagate.
+ val frameToSend = if (Prefs.getProfile(this).isMouseMode) {
+ state
+ } else {
+ state.copy(buttons = confirmedState!!.buttons)
+ }
+ uinput.pushFrame(frameToSend)
}
InjectionMode.SHIZUKU_INJECT -> {
// Axes every frame for smoothness, with live-reloaded calibration
@@ -419,6 +559,10 @@ class ControllerService : Service() {
usbManager.disconnect()
try { btManager.disconnect() } catch (_: Throwable) {}
_modeFlow.value = InjectionMode.NONE
+ // Reset state + battery so MainActivity's "is the controller actually here?"
+ // observer flips back to disconnected on stop.
+ _stateFlow.value = null
+ _batteryFlow.value = null
super.onDestroy()
}
@@ -448,7 +592,13 @@ class ControllerService : Service() {
InjectionMode.NONE -> getString(R.string.notif_starting)
}
val title = getString(R.string.notification_title)
- val text = if (battery != null) "$modeText • 🔋 $battery%" else modeText
+ // Append the active named profile name so the user can pull the shade and
+ // see which preset auto-switch loaded for them.
+ val activeProfileName = Prefs.getActiveNamedProfileId(this)?.let { id ->
+ Prefs.listNamedProfiles(this).firstOrNull { it.id == id }?.name
+ }
+ val baseLine = if (battery != null) "$modeText • 🔋 $battery%" else modeText
+ val text = if (activeProfileName != null) "$baseLine\n🎯 $activeProfileName" else baseLine
// Tap on the notification → open MainActivity
val openIntent = Intent(this, com.steamcontroller.android.MainActivity::class.java).apply {
diff --git a/app/src/main/java/com/steamcontroller/android/service/UsageStatsHelper.kt b/app/src/main/java/com/steamcontroller/android/service/UsageStatsHelper.kt
new file mode 100644
index 0000000..6ad8edc
--- /dev/null
+++ b/app/src/main/java/com/steamcontroller/android/service/UsageStatsHelper.kt
@@ -0,0 +1,72 @@
+package com.steamcontroller.android.service
+
+import android.app.AppOpsManager
+import android.app.usage.UsageEvents
+import android.app.usage.UsageStatsManager
+import android.content.Context
+import android.content.Intent
+import android.os.Process
+import android.provider.Settings
+
+/**
+ * Thin wrapper around the platform `UsageStatsManager` for the foreground-app
+ * auto-switch feature. Requires `PACKAGE_USAGE_STATS` which is a special-access
+ * permission the user grants from Settings.
+ */
+object UsageStatsHelper {
+
+ /** True if the user has already granted "Usage data access" for this app. */
+ fun hasPermission(context: Context): Boolean {
+ val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager ?: return false
+ val mode = try {
+ appOps.unsafeCheckOpNoThrow(
+ AppOpsManager.OPSTR_GET_USAGE_STATS,
+ Process.myUid(),
+ context.packageName,
+ )
+ } catch (_: Throwable) {
+ return false
+ }
+ return mode == AppOpsManager.MODE_ALLOWED
+ }
+
+ /** Open the system Settings screen where the user can grant access. */
+ fun openSettingsScreen(context: Context) {
+ val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ try {
+ context.startActivity(intent)
+ } catch (_: Throwable) {
+ // Fallback to app details if the device hides the dedicated screen.
+ context.startActivity(
+ Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+ .setData(android.net.Uri.parse("package:${context.packageName}"))
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ )
+ }
+ }
+
+ /**
+ * Look at the last few seconds of activity events and return whichever package
+ * is currently focused, or null if the OS didn't surface one yet (rare during
+ * boot or right after a screen-off).
+ */
+ fun getCurrentForegroundApp(context: Context): String? {
+ if (!hasPermission(context)) return null
+ val usm = context.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager
+ ?: return null
+ val now = System.currentTimeMillis()
+ val events = usm.queryEvents(now - 5_000, now)
+ var topPackage: String? = null
+ val ev = UsageEvents.Event()
+ while (events.hasNextEvent()) {
+ events.getNextEvent(ev)
+ if (ev.eventType == UsageEvents.Event.ACTIVITY_RESUMED ||
+ ev.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND) {
+ topPackage = ev.packageName
+ }
+ }
+ return topPackage
+ }
+}
diff --git a/app/src/main/java/com/steamcontroller/android/uinput/GamepadProfile.kt b/app/src/main/java/com/steamcontroller/android/uinput/GamepadProfile.kt
index 30fe6c6..7f74fd2 100644
--- a/app/src/main/java/com/steamcontroller/android/uinput/GamepadProfile.kt
+++ b/app/src/main/java/com/steamcontroller/android/uinput/GamepadProfile.kt
@@ -4,12 +4,14 @@ enum class GamepadProfile(
val id: Int,
val displayName: String,
val vid: Int,
- val pid: Int
+ val pid: Int,
+ val isMouseMode: Boolean = false
) {
XBOX_360 (0, "Xbox 360 Controller", 0x045E, 0x028E),
XBOX_ONE (1, "Xbox One Controller", 0x045E, 0x02EA),
DUALSHOCK_4(2, "Sony DualShock 4", 0x054C, 0x05C4),
- DUALSENSE (3, "Sony DualSense (PS5)", 0x054C, 0x0CE6);
+ DUALSENSE (3, "Sony DualSense (PS5)", 0x054C, 0x0CE6),
+ MOUSE (4, "Desktop (mouse + keyboard)", 0x046D, 0xC077, isMouseMode = true);
companion object {
fun fromId(id: Int): GamepadProfile = values().firstOrNull { it.id == id } ?: XBOX_360
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 9398831..58e181d 100644
--- a/app/src/main/java/com/steamcontroller/android/uinput/UInputGamepad.kt
+++ b/app/src/main/java/com/steamcontroller/android/uinput/UInputGamepad.kt
@@ -6,12 +6,17 @@ import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log
import com.steamcontroller.android.Prefs
+import com.steamcontroller.android.input.DEFAULT_MOUSE_MAPPING
+import com.steamcontroller.android.input.MOUSE_LEFT_PAD_CLICK_BIT
+import com.steamcontroller.android.input.MOUSE_MODE_FIXED_DPAD
+import com.steamcontroller.android.input.MouseTarget
import com.steamcontroller.android.input.SteamButton
import com.steamcontroller.android.input.StickCalibration
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 kotlin.math.abs
import rikka.shizuku.Shizuku
// High-level Kotlin API for the virtual Xbox 360 gamepad.
@@ -19,6 +24,13 @@ import rikka.shizuku.Shizuku
class UInputGamepad(private val context: Context, initialProfile: GamepadProfile) {
private val TAG = "UInputGamepad"
+
+ companion object {
+ // Sentinel meaning "the setting had no value before we touched it" (settings get
+ // returns "null" as a string in that case) — restored by deleting the key, not by
+ // writing the literal string "null".
+ private const val SHOW_IME_UNSET_SENTINEL = "__unset__"
+ }
private var service: IUInputService? = null
private var bound = false
@Volatile private var profile: GamepadProfile = initialProfile
@@ -48,6 +60,18 @@ class UInputGamepad(private val context: Context, initialProfile: GamepadProfile
// raw button bits so we can fire on 0 → 1 transitions only (not while held).
private var lastFrameButtons: Int = 0
+ // Mouse-mode state: previous trackpad position (for delta) and sensitivity cache.
+ private var lastRightPadX: Int = 0
+ private var lastRightPadY: Int = 0
+ private var rightPadHadContact: Boolean = false
+ // Left trackpad → scroll wheel state (used in gamepad sidecar mode).
+ private var lastLeftPadY: Int = 0
+ private var leftPadHadContact: Boolean = false
+ @Volatile private var cachedMouseSensitivity: Float = 1f
+ @Volatile private var cachedTrackpadAsMouse: Boolean = true
+ // Trigger / pad scroll: accumulator so we can convert continuous 0..32767 deltas into discrete wheel ticks
+ private var scrollAccumulator: Int = 0
+
private val args = Shizuku.UserServiceArgs(
ComponentName(context.packageName, UInputService::class.java.name)
)
@@ -70,6 +94,7 @@ class UInputGamepad(private val context: Context, initialProfile: GamepadProfile
val ok = svc.createGamepad(profile.id)
deviceReady = ok
Log.i(TAG, "createGamepad(${profile.displayName}) → $ok")
+ if (ok) applyShowImeOverride(svc)
} else {
Log.e(TAG, "Cannot open /dev/uinput from shell UID — SELinux likely blocks it on this device")
}
@@ -95,12 +120,52 @@ class UInputGamepad(private val context: Context, initialProfile: GamepadProfile
fun unbind() {
if (!bound) return
stopRumbleThread()
+ try { service?.let { restoreShowImeOverride(it) } } catch (_: Throwable) {}
try { service?.destroy() } catch (_: Throwable) {}
Shizuku.unbindUserService(args, connection, true)
bound = false
service = null
}
+ // Android's InputManager treats a paired/connected HID keyboard-classified device as a
+ // hardware keyboard and suppresses the on-screen keyboard for every text field, system-wide,
+ // for as long as it's attached. The Steam Controller's own USB/BT HID interfaces can trigger
+ // this classification independently of our uinput device (e.g. a legacy "boot keyboard" HID
+ // interface used for lizard mode, auto-bound by the kernel/Bluetooth stack outside our app's
+ // control). Forcing Settings.Secure.show_ime_with_hard_keyboard=1 via the shell UID makes
+ // Android show the soft keyboard regardless. Reverted on unbind() so a real Bluetooth
+ // keyboard paired later behaves normally.
+ private fun applyShowImeOverride(svc: IUInputService) {
+ try {
+ if (Prefs.getSavedShowImeHardKeyboard(context) == null) {
+ val current = try {
+ svc.runShellCommandForOutput(arrayOf("settings", "get", "secure", "show_ime_with_hard_keyboard"))
+ } catch (_: Throwable) { null }
+ val toSave = current?.takeIf { it.isNotBlank() && it != "null" } ?: SHOW_IME_UNSET_SENTINEL
+ 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")
+ } catch (t: Throwable) {
+ Log.w(TAG, "applyShowImeOverride failed: ${t.message}")
+ }
+ }
+
+ private fun restoreShowImeOverride(svc: IUInputService) {
+ try {
+ val saved = Prefs.getSavedShowImeHardKeyboard(context) ?: return
+ if (saved == SHOW_IME_UNSET_SENTINEL) {
+ svc.runShellCommand(arrayOf("settings", "delete", "secure", "show_ime_with_hard_keyboard"))
+ } else {
+ svc.runShellCommand(arrayOf("settings", "put", "secure", "show_ime_with_hard_keyboard", saved))
+ }
+ Prefs.clearSavedShowImeHardKeyboard(context)
+ Log.i(TAG, "show_ime_with_hard_keyboard restored to '$saved'")
+ } catch (t: Throwable) {
+ Log.w(TAG, "restoreShowImeOverride failed: ${t.message}")
+ }
+ }
+
/**
* Poll the user service for FF (rumble) events triggered by games.
* Runs at 50 Hz — Android emits FF events at the game's frame rate (~60 Hz)
@@ -165,22 +230,40 @@ class UInputGamepad(private val context: Context, initialProfile: GamepadProfile
cachedLeftCal = Prefs.getLeftCalibration(context)
cachedRightCal = Prefs.getRightCalibration(context)
cachedMapping = Prefs.getAllMappings(context)
+ cachedMouseSensitivity = Prefs.getMouseSensitivity(context)
+ cachedTrackpadAsMouse = Prefs.getTrackpadAsMouseInGamepad(context)
lastCalRefresh = now
}
+
+ if (profile.isMouseMode) {
+ pushMouseFrame(svc, state)
+ return
+ }
+
val leftCal = cachedLeftCal
val rightCal = cachedRightCal
// Apply the user-configurable button mapping.
- // target.mask > 0 → regular Xbox button bit (OR into the bitmask).
- // target.mask < 0 → special action (screenshot etc.), edge-triggered on press.
+ // target.mask > 0 → regular Xbox button bit (OR into the bitmask).
+ // target.keyBit >= 0 → sidecar keyboard 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
+ var sidecarMappedKeys = 0
+ var ltOverride = 0
+ var rtOverride = 0
for ((source, target) in cachedMapping) {
val pressed = state.isButtonPressed(source.mask)
when {
target.mask > 0 && pressed -> {
xboxButtons = xboxButtons or target.mask
}
- target.mask < 0 -> {
+ target.keyBit >= 0 && pressed -> {
+ sidecarMappedKeys = sidecarMappedKeys or (1 shl target.keyBit)
+ }
+ target.triggerSide == 1 && pressed -> { ltOverride = 255 }
+ target.triggerSide == 2 && pressed -> { rtOverride = 255 }
+ target.mask < 0 && target.keyBit < 0 && target.triggerSide == 0 -> {
val wasPressed = (lastFrameButtons and source.mask) != 0
if (pressed && !wasPressed) handleSpecialAction(target)
}
@@ -189,9 +272,12 @@ class UInputGamepad(private val context: Context, initialProfile: GamepadProfile
lastFrameButtons = state.buttons
// SC2026 sticks are already in Int16 range — direct passthrough
- // SC2026 triggers are 0-32767 → scale down to Xbox 0-255
- val lt = (state.leftTrigger * 255 / 32767).coerceIn(0, 255)
- val rt = (state.rightTrigger * 255 / 32767).coerceIn(0, 255)
+ // 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)
+ val lt = maxOf(ltAnalog, ltOverride)
+ val rt = maxOf(rtAnalog, rtOverride)
val dpadX = when {
state.isButtonPressed(Buttons.DPAD_RIGHT) -> 1
@@ -221,5 +307,136 @@ class UInputGamepad(private val context: Context, initialProfile: GamepadProfile
} catch (t: Throwable) {
Log.e(TAG, "sendFrame IPC failed: ${t.message}")
}
+
+ // 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)
+ }
+
+ /**
+ * 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).
+ */
+ private fun pushSidecarFrame(svc: IUInputService, state: SteamControllerState, mappedKeys: Int) {
+ val (relX, relY, scrollTicks) = if (cachedTrackpadAsMouse) {
+ val (rx, ry) = computeRightPadDelta(state)
+ Triple(rx, ry, computeLeftPadScroll(state))
+ } else {
+ // Still reset accumulators / contact flags so a re-enable mid-session
+ // doesn't trigger a phantom delta on first touch.
+ rightPadHadContact = false
+ leftPadHadContact = false
+ scrollAccumulator = 0
+ Triple(0, 0, 0)
+ }
+
+ 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
+ try {
+ svc.sendMouseFrame(relX, relY, scrollTicks, keys)
+ } catch (t: Throwable) {
+ Log.e(TAG, "sendMouseFrame (sidecar) IPC failed: ${t.message}")
+ }
+ }
+
+ /** Right trackpad delta (in mouse-cursor units). Resets cleanly on lift-off. */
+ private fun computeRightPadDelta(state: SteamControllerState): Pair {
+ val touching = state.isButtonPressed(Buttons.TP_RT)
+ if (!touching) {
+ rightPadHadContact = false
+ return 0 to 0
+ }
+ val curX = state.rightPadX.toInt()
+ val curY = state.rightPadY.toInt()
+ var relX = 0
+ var relY = 0
+ if (rightPadHadContact) {
+ val sens = cachedMouseSensitivity
+ relX = ((curX - lastRightPadX) / 128f * sens).toInt()
+ // SC2026 Y up positive → mouse Y down positive: invert
+ relY = (-(curY - lastRightPadY) / 128f * sens).toInt()
+ }
+ lastRightPadX = curX
+ lastRightPadY = curY
+ rightPadHadContact = true
+ return relX to relY
+ }
+
+ /** Left trackpad vertical → wheel ticks. One tick per ~1000 accumulator units. */
+ private fun computeLeftPadScroll(state: SteamControllerState): Int {
+ val touching = state.isButtonPressed(Buttons.TP_LT)
+ if (!touching) {
+ leftPadHadContact = false
+ scrollAccumulator = 0
+ return 0
+ }
+ val curY = state.leftPadY.toInt()
+ if (leftPadHadContact) {
+ // Y positive = up on SC2026; scroll wheel positive = up → keep sign.
+ scrollAccumulator += (curY - lastLeftPadY) / 8
+ }
+ lastLeftPadY = curY
+ leftPadHadContact = true
+ if (abs(scrollAccumulator) < 1000) return 0
+ val ticks = scrollAccumulator / 1000
+ scrollAccumulator -= ticks * 1000
+ return ticks
+ }
+
+ /**
+ * Desktop / mouse mode: right trackpad → cursor delta, triggers → scroll wheel,
+ * face/system buttons → mapped keys, DPAD → arrow keys, left pad click → right mouse.
+ * Special actions (e.g. SCREENSHOT) still fire via the gamepad mapping.
+ */
+ private fun pushMouseFrame(svc: IUInputService, state: SteamControllerState) {
+ // Right trackpad → cursor delta; left trackpad vertical → scroll wheel.
+ // Same helpers as the gamepad sidecar mode so the gesture is identical.
+ val (relX, relY) = computeRightPadDelta(state)
+ val scrollTicks = computeLeftPadScroll(state)
+
+ // ── Key/mouse-button bitmask ─────────────────────────────────────────
+ var keys = 0
+
+ // Customisable face/system mapping (uses MOUSE-mode defaults, no Prefs persistence in V1.1).
+ for ((source, target) in DEFAULT_MOUSE_MAPPING) {
+ if (target.bit >= 0 && state.isButtonPressed(source.mask)) {
+ keys = keys or (1 shl target.bit)
+ }
+ }
+
+ // Fixed DPAD → arrow keys
+ for ((mask, target) in MOUSE_MODE_FIXED_DPAD) {
+ if (state.isButtonPressed(mask)) keys = keys or (1 shl target.bit)
+ }
+
+ // Left trackpad click → right mouse click
+ if (state.isButtonPressed(MOUSE_LEFT_PAD_CLICK_BIT)) {
+ keys = keys or (1 shl MouseTarget.BTN_RIGHT.bit)
+ }
+
+ // Special actions (screenshot) still honoured via the gamepad mapping table —
+ // keeps QA → screenshot working even in mouse mode.
+ for ((source, target) in cachedMapping) {
+ if (target.mask < 0) {
+ val pressed = state.isButtonPressed(source.mask)
+ val wasPressed = (lastFrameButtons and source.mask) != 0
+ if (pressed && !wasPressed) handleSpecialAction(target)
+ }
+ }
+ lastFrameButtons = state.buttons
+
+ try {
+ svc.sendMouseFrame(relX, relY, scrollTicks, keys)
+ } catch (t: Throwable) {
+ Log.e(TAG, "sendMouseFrame IPC failed: ${t.message}")
+ }
}
}
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 98635f2..3c3845f 100644
--- a/app/src/main/java/com/steamcontroller/android/uinput/UInputNative.kt
+++ b/app/src/main/java/com/steamcontroller/android/uinput/UInputNative.kt
@@ -19,5 +19,7 @@ object UInputNative {
leftTrigger: Int, rightTrigger: Int,
dpadX: Int, dpadY: Int
)
+ /** Desktop mode frame. `keys` is a MouseTarget bitmask. */
+ external fun sendMouseFrame(relX: Int, relY: Int, scrollY: Int, keys: Int)
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 6315738..f303914 100644
--- a/app/src/main/java/com/steamcontroller/android/uinput/UInputService.kt
+++ b/app/src/main/java/com/steamcontroller/android/uinput/UInputService.kt
@@ -60,6 +60,14 @@ class UInputService : IUInputService.Stub {
}
}
+ override fun sendMouseFrame(relX: Int, relY: Int, scrollY: Int, keys: Int) {
+ try {
+ UInputNative.sendMouseFrame(relX, relY, scrollY, keys)
+ } catch (t: Throwable) {
+ Log.e(TAG, "sendMouseFrame failed: ${t.message}")
+ }
+ }
+
override fun pollForceFeedback(): IntArray? {
return try {
UInputNative.pollFFEvent()
@@ -84,6 +92,21 @@ class UInputService : IUInputService.Stub {
}
}
+ override fun runShellCommandForOutput(cmd: Array?): String? {
+ if (cmd.isNullOrEmpty()) return null
+ return try {
+ val proc = ProcessBuilder(cmd.toList())
+ .redirectErrorStream(true)
+ .start()
+ val output = proc.inputStream.bufferedReader().readText()
+ proc.waitFor()
+ output.trim()
+ } catch (t: Throwable) {
+ Log.e(TAG, "runShellCommandForOutput failed: ${t.message}")
+ null
+ }
+ }
+
override fun destroy() {
try { UInputNative.destroy() } catch (t: Throwable) {
Log.e(TAG, "destroy native failed: ${t.message}")
diff --git a/app/src/main/java/com/steamcontroller/android/update/UpdateChecker.kt b/app/src/main/java/com/steamcontroller/android/update/UpdateChecker.kt
new file mode 100644
index 0000000..3536f15
--- /dev/null
+++ b/app/src/main/java/com/steamcontroller/android/update/UpdateChecker.kt
@@ -0,0 +1,71 @@
+package com.steamcontroller.android.update
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import java.net.HttpURLConnection
+import java.net.URL
+
+/**
+ * Checks GitHub Releases for a newer signed APK than the one currently installed.
+ * The app is distributed outside the Play Store, so this is the only update channel.
+ */
+object UpdateChecker {
+
+ private const val API_URL =
+ "https://api.github.com/repos/SonicDX12/SteamController-Android/releases/latest"
+
+ data class ReleaseInfo(
+ val tagName: String,
+ val versionName: String,
+ val notes: String,
+ val apkUrl: String,
+ val apkName: String,
+ )
+
+ /** Fetches the latest GitHub release. Returns null on any network/parsing failure. */
+ suspend fun fetchLatestRelease(): ReleaseInfo? = withContext(Dispatchers.IO) {
+ try {
+ val connection = (URL(API_URL).openConnection() as HttpURLConnection).apply {
+ requestMethod = "GET"
+ setRequestProperty("Accept", "application/vnd.github+json")
+ connectTimeout = 10_000
+ readTimeout = 10_000
+ }
+ if (connection.responseCode != HttpURLConnection.HTTP_OK) return@withContext null
+
+ val body = connection.inputStream.bufferedReader().use { it.readText() }
+ val json = JSONObject(body)
+ val tag = json.getString("tag_name")
+
+ val assets = json.getJSONArray("assets")
+ val apkAsset = (0 until assets.length())
+ .map { assets.getJSONObject(it) }
+ .firstOrNull { it.getString("name").endsWith(".apk") }
+ ?: return@withContext null
+
+ ReleaseInfo(
+ tagName = tag,
+ versionName = tag.removePrefix("v"),
+ notes = json.optString("body", ""),
+ apkUrl = apkAsset.getString("browser_download_url"),
+ apkName = apkAsset.getString("name"),
+ )
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ /** True if [remoteVersion] is a higher semantic version than [currentVersion] ("1.2.0" vs "1.0"). */
+ fun isNewer(remoteVersion: String, currentVersion: String): Boolean {
+ fun parts(v: String) = v.split(".").map { it.toIntOrNull() ?: 0 }
+ val remote = parts(remoteVersion)
+ val current = parts(currentVersion)
+ for (i in 0 until maxOf(remote.size, current.size)) {
+ val r = remote.getOrElse(i) { 0 }
+ val c = current.getOrElse(i) { 0 }
+ if (r != c) return r > c
+ }
+ return false
+ }
+}
diff --git a/app/src/main/java/com/steamcontroller/android/update/UpdateInstaller.kt b/app/src/main/java/com/steamcontroller/android/update/UpdateInstaller.kt
new file mode 100644
index 0000000..0e77ec0
--- /dev/null
+++ b/app/src/main/java/com/steamcontroller/android/update/UpdateInstaller.kt
@@ -0,0 +1,38 @@
+package com.steamcontroller.android.update
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.provider.Settings
+import androidx.core.content.FileProvider
+import java.io.File
+
+/**
+ * Launches the system package installer for a downloaded APK. Android verifies the new
+ * APK is signed with the same key as the installed app before allowing the install to
+ * proceed, so no manual checksum verification is needed here.
+ */
+object UpdateInstaller {
+
+ fun canInstall(context: Context): Boolean =
+ context.packageManager.canRequestPackageInstalls()
+
+ /** Sends the user to the system screen where they grant "install unknown apps" for this app. */
+ fun requestInstallPermission(activity: Activity) {
+ val intent = Intent(
+ Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
+ Uri.parse("package:${activity.packageName}"),
+ )
+ activity.startActivity(intent)
+ }
+
+ fun install(context: Context, apkFile: File) {
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apkFile)
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, "application/vnd.android.package-archive")
+ flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+ context.startActivity(intent)
+ }
+}
diff --git a/app/src/main/java/com/steamcontroller/android/usb/HidReportReader.kt b/app/src/main/java/com/steamcontroller/android/usb/HidReportReader.kt
index 0da0991..f61c903 100644
--- a/app/src/main/java/com/steamcontroller/android/usb/HidReportReader.kt
+++ b/app/src/main/java/com/steamcontroller/android/usb/HidReportReader.kt
@@ -33,9 +33,12 @@ class HidReportReader(
Log.i(TAG, "Reports received: $totalReports, last ID=0x${(buf[0].toInt() and 0xFF).toString(16)}, len=$len")
}
val raw = buf.copyOf(len)
- val state = SteamReportParser.parse(buf)
+ // Parse `raw` (actual length), not the reused `buf` — short reports like
+ // the id-0x43 battery status would otherwise pick up stale bytes from a
+ // previous, longer read past offset `len`.
+ val state = SteamReportParser.parse(raw)
// Always emit raw so DebugActivity shows something even with unexpected IDs
- onReport(state ?: SteamReportParser.parseRaw(buf), raw)
+ onReport(state ?: SteamReportParser.parseRaw(raw), raw)
}
len == 0 -> yield()
else -> yield() // -1 = timeout, normal
diff --git a/app/src/main/java/com/steamcontroller/android/usb/UsbConnectionManager.kt b/app/src/main/java/com/steamcontroller/android/usb/UsbConnectionManager.kt
index b876d8d..14771c6 100644
--- a/app/src/main/java/com/steamcontroller/android/usb/UsbConnectionManager.kt
+++ b/app/src/main/java/com/steamcontroller/android/usb/UsbConnectionManager.kt
@@ -15,6 +15,14 @@ class UsbConnectionManager(private val context: Context) {
var endpointIn: UsbEndpoint? = null
private set
+ // Interfaces claimed only to keep them away from the kernel — e.g. the Steam
+ // Controller's legacy "boot keyboard" HID interface used for lizard mode. If we
+ // don't claim it, Android's kernel usbhid driver binds it and creates a real,
+ // non-virtual keyboard InputDevice — which makes Android believe a hardware
+ // keyboard is always connected and suppresses the on-screen keyboard everywhere,
+ // for as long as the controller stays plugged in. Released on disconnect().
+ private val heldInterfaces = mutableListOf()
+
companion object {
private const val TAG = "UsbConnectionManager"
const val STEAM_VID = 0x28DE
@@ -48,10 +56,27 @@ class UsbConnectionManager(private val context: Context) {
device = dev
connection = conn
endpointIn = ep
+ claimRemainingInterfaces(dev, conn, iface)
Log.i(TAG, "Connected — PID=0x${dev.productId.toString(16).uppercase()} iface=${iface.id} ep=${ep.address} type=${ep.type}")
return true
}
+ // Force-claims every other interface on the device so the kernel usbhid driver
+ // can't bind them behind our back (see heldInterfaces comment). We never read
+ // from these — just holding the claim is enough to keep the kernel off them.
+ private fun claimRemainingInterfaces(dev: UsbDevice, conn: UsbDeviceConnection, dataIface: UsbInterface) {
+ for (i in 0 until dev.interfaceCount) {
+ val iface = dev.getInterface(i)
+ if (iface.id == dataIface.id) continue
+ if (conn.claimInterface(iface, true)) {
+ heldInterfaces.add(iface)
+ Log.i(TAG, "Claimed iface ${iface.id} (class=0x${iface.interfaceClass.toString(16)}) to keep it off the kernel usbhid driver")
+ } else {
+ Log.w(TAG, "Could not claim iface ${iface.id} to withhold it from kernel usbhid")
+ }
+ }
+ }
+
private fun logAllInterfaces(dev: UsbDevice) {
Log.i(TAG, "Device VID=0x${dev.vendorId.toString(16)} PID=0x${dev.productId.toString(16)} interfaces=${dev.interfaceCount}")
for (i in 0 until dev.interfaceCount) {
@@ -96,10 +121,14 @@ class UsbConnectionManager(private val context: Context) {
fun disconnect() {
try {
- connection?.close()
+ connection?.let { conn ->
+ heldInterfaces.forEach { conn.releaseInterface(it) }
+ conn.close()
+ }
} catch (e: Exception) {
Log.w(TAG, "Error during disconnect: ${e.message}")
} finally {
+ heldInterfaces.clear()
device = null
connection = null
endpointIn = null
diff --git a/app/src/main/res/drawable-xhdpi/tv_banner.xml b/app/src/main/res/drawable-xhdpi/tv_banner.xml
new file mode 100644
index 0000000..5b4d273
--- /dev/null
+++ b/app/src/main/res/drawable-xhdpi/tv_banner.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/chip_circle_bg.xml b/app/src/main/res/drawable/chip_circle_bg.xml
new file mode 100644
index 0000000..0e0a164
--- /dev/null
+++ b/app/src/main/res/drawable/chip_circle_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/chip_circle_bg_active.xml b/app/src/main/res/drawable/chip_circle_bg_active.xml
new file mode 100644
index 0000000..8d949f6
--- /dev/null
+++ b/app/src/main/res/drawable/chip_circle_bg_active.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_calibration.xml b/app/src/main/res/drawable/ic_calibration.xml
new file mode 100644
index 0000000..3cc4385
--- /dev/null
+++ b/app/src/main/res/drawable/ic_calibration.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_chevron_down.xml b/app/src/main/res/drawable/ic_chevron_down.xml
new file mode 100644
index 0000000..933b933
--- /dev/null
+++ b/app/src/main/res/drawable/ic_chevron_down.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_debug.xml b/app/src/main/res/drawable/ic_debug.xml
new file mode 100644
index 0000000..2141e85
--- /dev/null
+++ b/app/src/main/res/drawable/ic_debug.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_game_profiles.xml b/app/src/main/res/drawable/ic_game_profiles.xml
new file mode 100644
index 0000000..a2646ac
--- /dev/null
+++ b/app/src/main/res/drawable/ic_game_profiles.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_gamepad.xml b/app/src/main/res/drawable/ic_gamepad.xml
new file mode 100644
index 0000000..e2a581f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_gamepad.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_github.xml b/app/src/main/res/drawable/ic_github.xml
new file mode 100644
index 0000000..6b4321c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_github.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml
index a55235a..ec3bdb2 100644
--- a/app/src/main/res/drawable/ic_launcher.xml
+++ b/app/src/main/res/drawable/ic_launcher.xml
@@ -1,16 +1,18 @@
+
+ android:viewportWidth="36"
+ android:viewportHeight="36">
+
+
+ android:pathData="M0,0h36v36h-36z" />
+
+
-
+ android:pathData="M34.5489,14.8979C34.0471,13.107 33.4146,11.3703 32.6633,9.69886C31.5496,7.22244 29.0787,5.63623 26.3628,5.63623H9.539C6.82361,5.63623 4.35214,7.22244 3.23897,9.69886C2.48764,11.3703 1.85526,13.107 1.35345,14.8979C0.452174,18.1113 -0.0302734,21.4994 -0.0302734,25.0005C-0.0302734,25.6495 -0.0136754,26.2952 0.0189672,26.9359C0.121874,28.9492 1.77614,30.5332 3.79169,30.5332C5.15659,30.5332 6.41639,29.7979 7.0836,28.6073C7.09136,28.5929 7.09967,28.5791 7.10742,28.5647C7.86815,27.2191 9.29118,26.3837 10.837,26.3837H25.0637C26.6095,26.3837 28.0325,27.2191 28.7932,28.5647C28.801,28.5785 28.8092,28.5929 28.817,28.6067C29.4843,29.7974 30.744,30.5327 32.1089,30.5327C34.1251,30.5327 35.7788,28.9492 35.8817,26.9353C35.9143,26.294 35.9309,25.649 35.9309,25C35.9309,21.4989 35.4485,18.1107 34.5477,14.8973L34.5489,14.8979ZM25.2634,8.40256C26.0274,8.40256 26.6466,9.02167 26.6466,9.78573C26.6466,10.5498 26.0274,11.1689 25.2634,11.1689C24.4993,11.1689 23.8802,10.5498 23.8802,9.78573C23.8802,9.02167 24.4993,8.40256 25.2634,8.40256ZM26.6466,15.3184C26.6466,16.0825 26.0274,16.7016 25.2634,16.7016C24.4993,16.7016 23.8802,16.0825 23.8802,15.3184C23.8802,14.5544 24.4993,13.9352 25.2634,13.9352C26.0274,13.9352 26.6466,14.5544 26.6466,15.3184ZM10.6383,9.78573C12.1659,9.78573 13.4047,11.0245 13.4047,12.552C13.4047,14.0796 12.1659,15.3184 10.6383,15.3184C9.11076,15.3184 7.87203,14.0796 7.87203,12.552C7.87203,11.0245 9.11076,9.78573 10.6383,9.78573ZM13.6913,19.262L13.0506,23.3059C12.9886,23.6982 12.6202,23.9659 12.2279,23.904L8.18409,23.2633C7.79179,23.2013 7.52403,22.8328 7.58597,22.4406L8.2267,18.3968C8.28864,18.0045 8.65712,17.7367 9.04936,17.7987L13.0932,18.4394C13.4855,18.5013 13.7532,18.8698 13.6913,19.262ZM21.1139,12.552C21.1139,11.788 21.733,11.1689 22.4971,11.1689C23.2611,11.1689 23.8802,11.788 23.8802,12.552C23.8802,13.3161 23.2611,13.9352 22.4971,13.9352C21.733,13.9352 21.1139,13.3161 21.1139,12.552ZM27.7177,23.2633L23.6738,23.904C23.2816,23.9659 22.9131,23.6987 22.8511,23.3059L22.2105,19.262C22.1485,18.8698 22.4157,18.5013 22.8086,18.4394L26.8524,17.7987C27.2446,17.7367 27.6131,18.0045 27.6751,18.3968L28.3158,22.4406C28.3777,22.8328 28.11,23.2013 27.7177,23.2633ZM28.0297,13.9352C27.2657,13.9352 26.6466,13.3161 26.6466,12.552C26.6466,11.788 27.2657,11.1689 28.0297,11.1689C28.7938,11.1689 29.4129,11.788 29.4129,12.552C29.4129,13.3161 28.7938,13.9352 28.0297,13.9352Z" />
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..02ce346
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..48061c4
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml
new file mode 100644
index 0000000..8afc3cc
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_mapping.xml b/app/src/main/res/drawable/ic_mapping.xml
new file mode 100644
index 0000000..2ff38c8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_mapping.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_mouse.xml b/app/src/main/res/drawable/ic_mouse.xml
new file mode 100644
index 0000000..9af3f2e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_mouse.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_update.xml b/app/src/main/res/drawable/ic_update.xml
new file mode 100644
index 0000000..9c01d22
--- /dev/null
+++ b/app/src/main/res/drawable/ic_update.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/mapping_button_circle.xml b/app/src/main/res/drawable/mapping_button_circle.xml
new file mode 100644
index 0000000..d66b120
--- /dev/null
+++ b/app/src/main/res/drawable/mapping_button_circle.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc2026_controller_black.xml b/app/src/main/res/drawable/sc2026_controller_black.xml
new file mode 100644
index 0000000..d68ea69
--- /dev/null
+++ b/app/src/main/res/drawable/sc2026_controller_black.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_a.xml b/app/src/main/res/drawable/sc_btn_a.xml
new file mode 100644
index 0000000..bfb6942
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_a.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_a_color.xml b/app/src/main/res/drawable/sc_btn_a_color.xml
new file mode 100644
index 0000000..1403b9f
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_a_color.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_b.xml b/app/src/main/res/drawable/sc_btn_b.xml
new file mode 100644
index 0000000..e454450
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_b.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_b_color.xml b/app/src/main/res/drawable/sc_btn_b_color.xml
new file mode 100644
index 0000000..fdb5200
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_b_color.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_l3.xml b/app/src/main/res/drawable/sc_btn_l3.xml
new file mode 100644
index 0000000..90f02ac
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_l3.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_l4.xml b/app/src/main/res/drawable/sc_btn_l4.xml
new file mode 100644
index 0000000..a4c11fd
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_l4.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_l5.xml b/app/src/main/res/drawable/sc_btn_l5.xml
new file mode 100644
index 0000000..ee2df6b
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_l5.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_lb.xml b/app/src/main/res/drawable/sc_btn_lb.xml
new file mode 100644
index 0000000..678ef72
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_lb.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_lg.xml b/app/src/main/res/drawable/sc_btn_lg.xml
new file mode 100644
index 0000000..fd33b53
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_lg.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_lt.xml b/app/src/main/res/drawable/sc_btn_lt.xml
new file mode 100644
index 0000000..e12c4e4
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_lt.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_menu.xml b/app/src/main/res/drawable/sc_btn_menu.xml
new file mode 100644
index 0000000..9081168
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_menu.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_qa.xml b/app/src/main/res/drawable/sc_btn_qa.xml
new file mode 100644
index 0000000..c00fdea
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_qa.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_r3.xml b/app/src/main/res/drawable/sc_btn_r3.xml
new file mode 100644
index 0000000..74be189
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_r3.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_r4.xml b/app/src/main/res/drawable/sc_btn_r4.xml
new file mode 100644
index 0000000..c307383
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_r4.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_r5.xml b/app/src/main/res/drawable/sc_btn_r5.xml
new file mode 100644
index 0000000..3a9bd49
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_r5.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_rb.xml b/app/src/main/res/drawable/sc_btn_rb.xml
new file mode 100644
index 0000000..c836f41
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_rb.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_rg.xml b/app/src/main/res/drawable/sc_btn_rg.xml
new file mode 100644
index 0000000..0ebf34b
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_rg.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_rt.xml b/app/src/main/res/drawable/sc_btn_rt.xml
new file mode 100644
index 0000000..55a6078
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_rt.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_steam.xml b/app/src/main/res/drawable/sc_btn_steam.xml
new file mode 100644
index 0000000..19f4907
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_steam.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_stick_press.xml b/app/src/main/res/drawable/sc_btn_stick_press.xml
new file mode 100644
index 0000000..05e1c09
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_stick_press.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_view.xml b/app/src/main/res/drawable/sc_btn_view.xml
new file mode 100644
index 0000000..99b14fb
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_view.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_x.xml b/app/src/main/res/drawable/sc_btn_x.xml
new file mode 100644
index 0000000..0997778
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_x.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_x_color.xml b/app/src/main/res/drawable/sc_btn_x_color.xml
new file mode 100644
index 0000000..b06d36a
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_x_color.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_y.xml b/app/src/main/res/drawable/sc_btn_y.xml
new file mode 100644
index 0000000..d8a4463
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_y.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/sc_btn_y_color.xml b/app/src/main/res/drawable/sc_btn_y_color.xml
new file mode 100644
index 0000000..718d3db
--- /dev/null
+++ b/app/src/main/res/drawable/sc_btn_y_color.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/xbox_btn_l3.xml b/app/src/main/res/drawable/xbox_btn_l3.xml
new file mode 100644
index 0000000..535a936
--- /dev/null
+++ b/app/src/main/res/drawable/xbox_btn_l3.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/xbox_btn_lb.xml b/app/src/main/res/drawable/xbox_btn_lb.xml
new file mode 100644
index 0000000..af01d68
--- /dev/null
+++ b/app/src/main/res/drawable/xbox_btn_lb.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/xbox_btn_logo.xml b/app/src/main/res/drawable/xbox_btn_logo.xml
new file mode 100644
index 0000000..7062a63
--- /dev/null
+++ b/app/src/main/res/drawable/xbox_btn_logo.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/xbox_btn_mode.xml b/app/src/main/res/drawable/xbox_btn_mode.xml
new file mode 100644
index 0000000..fd239f8
--- /dev/null
+++ b/app/src/main/res/drawable/xbox_btn_mode.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/xbox_btn_r3.xml b/app/src/main/res/drawable/xbox_btn_r3.xml
new file mode 100644
index 0000000..259d3f6
--- /dev/null
+++ b/app/src/main/res/drawable/xbox_btn_r3.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/xbox_btn_rb.xml b/app/src/main/res/drawable/xbox_btn_rb.xml
new file mode 100644
index 0000000..dd8b5a4
--- /dev/null
+++ b/app/src/main/res/drawable/xbox_btn_rb.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/xbox_btn_select.xml b/app/src/main/res/drawable/xbox_btn_select.xml
new file mode 100644
index 0000000..7e8b095
--- /dev/null
+++ b/app/src/main/res/drawable/xbox_btn_select.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/xbox_btn_start.xml b/app/src/main/res/drawable/xbox_btn_start.xml
new file mode 100644
index 0000000..3db7184
--- /dev/null
+++ b/app/src/main/res/drawable/xbox_btn_start.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-sw600dp/activity_calibration.xml b/app/src/main/res/layout-sw600dp/activity_calibration.xml
index 71222b3..f820e0f 100644
--- a/app/src/main/res/layout-sw600dp/activity_calibration.xml
+++ b/app/src/main/res/layout-sw600dp/activity_calibration.xml
@@ -1,12 +1,17 @@
+
+ android:background="?attr/colorSurface"
+ android:fitsSystemWindows="true">
+ app:title="@string/calib_title"
+ app:titleTextColor="?attr/colorPrimary" />
-
-
+ android:padding="24dp">
-
+
+ app:cardElevation="0dp"
+ app:strokeColor="?attr/colorOutlineVariant"
+ app:strokeWidth="1dp">
+ android:padding="20dp">
-
-
+ android:textStyle="bold" />
-
-
+ android:text="@string/calib_deadzone_label"
+ android:textColor="?attr/colorOnSurface"
+ android:textSize="14sp" />
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:layout_marginTop="14dp"
+ android:baselineAligned="false"
+ android:orientation="horizontal">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:layout_height="56dp"
+ android:layout_marginTop="18dp"
+ android:text="@string/calib_calibrate_all"
+ android:textStyle="bold"
+ app:cornerRadius="16dp" />
-
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="6dp"
+ android:fontFamily="sans-serif-light"
+ android:text="@string/calib_calibrate_hint"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:textSize="12sp" />
-
-
+
+ android:orientation="vertical">
-
-
-
+ app:cardCornerRadius="20dp"
+ app:cardElevation="0dp"
+ app:strokeColor="?attr/colorOutlineVariant"
+ app:strokeWidth="1dp">
-
-
-
-
-
-
-
-
-
+ android:orientation="vertical"
+ android:padding="18dp">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:orientation="vertical"
+ android:padding="18dp">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-sw600dp/activity_debug.xml b/app/src/main/res/layout-sw600dp/activity_debug.xml
index 588612e..72bf436 100644
--- a/app/src/main/res/layout-sw600dp/activity_debug.xml
+++ b/app/src/main/res/layout-sw600dp/activity_debug.xml
@@ -18,10 +18,11 @@
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorSurface"
- app:title="HID Debug"
+ app:title="@string/debug_title"
app:titleTextColor="?attr/colorPrimary"
app:navigationIcon="@android:drawable/ic_menu_close_clear_cancel"
- app:navigationIconTint="?attr/colorPrimary" />
+ app:navigationIconTint="?attr/colorPrimary"
+ app:menu="@menu/menu_debug" />
+
+
+
-
+
-
-
-
-
+
+
+
+
-
+
-
-
-
-
-
-
+
+
+
+
+
+
-
+
-
-
-
-
+
+
+
+
-
+
-
-
+
+
-
+
-
-
-
-
-
-
+
+
+
+
+
+
-
+
-
+
@@ -123,7 +133,7 @@
-
+
@@ -140,35 +150,35 @@
android:layout_marginStart="16dp"
android:orientation="vertical">
-
+
-
+
-
+
-
+
-
+
@@ -177,7 +187,7 @@
-
+
+ android:lineSpacingExtra="2dp"
+ tools:ignore="HardcodedText" />
diff --git a/app/src/main/res/layout-sw600dp/activity_main.xml b/app/src/main/res/layout-sw600dp/activity_main.xml
index 7205baf..06bc4fa 100644
--- a/app/src/main/res/layout-sw600dp/activity_main.xml
+++ b/app/src/main/res/layout-sw600dp/activity_main.xml
@@ -1,180 +1,349 @@
+
+ android:padding="32dp">
-
-
+
+ android:layout_height="wrap_content"
+ android:baselineAligned="false"
+ android:gravity="center_vertical"
+ android:orientation="horizontal">
+
+
+
+
+
+
-
-
-
-
+ app:iconTint="?attr/colorOnSurfaceVariant" />
+
+ android:textSize="14sp" />
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+ android:layout_marginTop="20dp"
+ android:baselineAligned="false"
+ android:orientation="horizontal">
-
+
+ android:orientation="vertical">
+ android:padding="24dp">
+ android:textSize="24sp"
+ android:textStyle="bold" />
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="4dp"
+ android:fontFamily="monospace"
+ android:text="@string/main_battery_placeholder"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:textSize="14sp" />
+
+
+ android:text="@string/main_mode_placeholder"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:textSize="13sp" />
-
+
+
+ android:gravity="center_vertical"
+ android:orientation="horizontal">
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+ android:layout_marginTop="16dp"
+ android:orientation="vertical"
+ android:visibility="gone">
-
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:hint="@string/main_bt_device_hint">
+ android:layout_marginStart="10dp"
+ android:contentDescription="@string/main_refresh_bt_desc"
+ app:icon="@drawable/ic_refresh" />
-
-
-
-
-
+
+ android:layout_height="64dp"
+ android:layout_marginTop="18dp"
+ android:text="@string/btn_start"
+ android:textSize="18sp"
+ android:textStyle="bold"
+ app:cornerRadius="20dp" />
+
-
+
+
+
+ android:clickable="true"
+ android:focusable="true"
+ app:cardCornerRadius="20dp"
+ app:cardElevation="0dp"
+ app:strokeColor="?attr/colorOutlineVariant"
+ app:strokeWidth="1dp">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:layout_marginTop="14dp"
+ android:clickable="true"
+ android:focusable="true"
+ app:cardCornerRadius="20dp"
+ app:cardElevation="0dp"
+ app:strokeColor="?attr/colorOutlineVariant"
+ app:strokeWidth="1dp">
-
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+ android:layout_marginTop="14dp"
+ android:clickable="true"
+ android:focusable="true"
+ app:cardCornerRadius="20dp"
+ app:cardElevation="0dp"
+ app:strokeColor="?attr/colorOutlineVariant"
+ app:strokeWidth="1dp">
-
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ android:padding="20dp">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ android:padding="20dp">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-television/activity_calibration.xml b/app/src/main/res/layout-television/activity_calibration.xml
new file mode 100644
index 0000000..6a71713
--- /dev/null
+++ b/app/src/main/res/layout-television/activity_calibration.xml
@@ -0,0 +1,370 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-television/activity_debug.xml b/app/src/main/res/layout-television/activity_debug.xml
new file mode 100644
index 0000000..1b44b65
--- /dev/null
+++ b/app/src/main/res/layout-television/activity_debug.xml
@@ -0,0 +1,214 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout-television/activity_main.xml b/app/src/main/res/layout-television/activity_main.xml
new file mode 100644
index 0000000..54893e9
--- /dev/null
+++ b/app/src/main/res/layout-television/activity_main.xml
@@ -0,0 +1,554 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_app_picker.xml b/app/src/main/res/layout/activity_app_picker.xml
new file mode 100644
index 0000000..fcbd555
--- /dev/null
+++ b/app/src/main/res/layout/activity_app_picker.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_calibration.xml b/app/src/main/res/layout/activity_calibration.xml
index 498d6f8..b75b303 100644
--- a/app/src/main/res/layout/activity_calibration.xml
+++ b/app/src/main/res/layout/activity_calibration.xml
@@ -1,7 +1,16 @@
+
-
-
+
+ android:layout_marginBottom="16dp"
+ app:cardCornerRadius="20dp"
+ app:cardElevation="0dp"
+ app:strokeColor="?attr/colorOutlineVariant"
+ app:strokeWidth="1dp">
-
-
+ android:orientation="vertical"
+ android:padding="18dp">
+
+
+
+
+
+ android:orientation="horizontal"
+ android:baselineAligned="false">
-
-
-
-
-
+
+
+
+
+
+
+
+ android:layout_weight="1"
+ android:layout_marginStart="8dp"
+ android:orientation="vertical">
+
+
+
+
+
-
-
-
-
+
+ android:layout_marginTop="14dp"
+ android:orientation="horizontal"
+ android:baselineAligned="false">
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ android:layout_weight="1"
+ android:layout_marginStart="10dp"
+ android:orientation="vertical"
+ android:gravity="center_horizontal">
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ android:layout_marginBottom="16dp"
+ app:cardCornerRadius="20dp"
+ app:cardElevation="0dp"
+ app:strokeColor="?attr/colorOutlineVariant"
+ app:strokeWidth="1dp">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ app:cardCornerRadius="20dp"
+ app:cardElevation="0dp"
+ app:strokeColor="?attr/colorOutlineVariant"
+ app:strokeWidth="1dp">
-
-
-
-
-
-
+ android:orientation="vertical"
+ android:padding="18dp">
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_debug.xml b/app/src/main/res/layout/activity_debug.xml
index 88dc3cf..04b2971 100644
--- a/app/src/main/res/layout/activity_debug.xml
+++ b/app/src/main/res/layout/activity_debug.xml
@@ -1,7 +1,13 @@
+
+ app:navigationIconTint="?attr/colorPrimary"
+ app:menu="@menu/menu_debug" />
-
-
-
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ android:text="@string/debug_readonly_notice"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:textSize="12sp"
+ android:layout_marginBottom="12dp" />
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:fontFamily="monospace"
+ android:layout_marginBottom="8dp"
+ tools:ignore="HardcodedText" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 8071171..8c73cce 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,9 +1,21 @@
+
+ android:paddingHorizontal="20dp"
+ android:paddingTop="16dp"
+ android:paddingBottom="32dp">
-
-
+
+ android:layout_marginTop="8dp"
+ android:orientation="horizontal"
+ android:baselineAligned="false"
+ android:gravity="center_vertical">
+
+
+
+
+
+
-
-
-
-
+ app:iconTint="?attr/colorOnSurfaceVariant" />
+
+ android:textSize="13sp" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+ android:padding="20dp">
+ android:textSize="22sp"
+ android:textStyle="bold" />
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="4dp"
+ android:text="@string/main_battery_placeholder"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:textSize="13sp"
+ android:fontFamily="monospace" />
+
+
+
+ android:layout_gravity="center_horizontal"
+ android:layout_marginBottom="14dp"
+ android:text="@string/main_mode_placeholder"
+ android:textColor="?attr/colorOnSurfaceVariant"
+ android:textSize="12sp"
+ android:fontFamily="monospace" />
-
+
+
+
+
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+ android:layout_marginTop="14dp"
+ android:orientation="vertical"
+ android:visibility="gone">
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
@@ -174,7 +357,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
- android:hint="Bluetooth device">
+ android:hint="@string/main_bt_device_hint">
+ android:contentDescription="@string/main_refresh_bt_desc"
+ app:icon="@drawable/ic_refresh" />
-
-
-
-
-
-
-
+
-
+
+ android:layout_marginTop="14dp"
+ android:clickable="true"
+ android:focusable="true"
+ app:cardCornerRadius="18dp"
+ app:cardElevation="0dp"
+ app:strokeColor="?attr/colorOutlineVariant"
+ app:strokeWidth="1dp">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:layout_marginTop="10dp"
+ android:clickable="true"
+ android:focusable="true"
+ app:cardCornerRadius="18dp"
+ app:cardElevation="0dp"
+ app:strokeColor="?attr/colorOutlineVariant"
+ app:strokeWidth="1dp">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:layout_marginTop="10dp"
+ android:clickable="true"
+ android:focusable="true"
+ app:cardCornerRadius="18dp"
+ app:cardElevation="0dp"
+ app:strokeColor="?attr/colorOutlineVariant"
+ app:strokeWidth="1dp">
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ android:visibility="gone" />
diff --git a/app/src/main/res/layout/activity_mapping.xml b/app/src/main/res/layout/activity_mapping.xml
index 99c4319..1fbe983 100644
--- a/app/src/main/res/layout/activity_mapping.xml
+++ b/app/src/main/res/layout/activity_mapping.xml
@@ -1,11 +1,18 @@
-
+
+ app:menu="@menu/menu_mapping" />
+ android:layout_height="match_parent">
+ android:layout_marginBottom="20dp" />
-
+
diff --git a/app/src/main/res/layout/activity_profiles.xml b/app/src/main/res/layout/activity_profiles.xml
new file mode 100644
index 0000000..1edde77
--- /dev/null
+++ b/app/src/main/res/layout/activity_profiles.xml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_connection_help.xml b/app/src/main/res/layout/dialog_connection_help.xml
index 4b743a0..d7716e4 100644
--- a/app/src/main/res/layout/dialog_connection_help.xml
+++ b/app/src/main/res/layout/dialog_connection_help.xml
@@ -15,31 +15,26 @@
+ android:text="@string/help_para_puck_slots" />
+ android:text="@string/help_para_bt_independent" />
+ android:text="@string/help_para_power_off" />
+ android:text="@string/help_section_wireless" />
+ android:text="@string/help_para_wireless_intro" />
@@ -48,8 +43,7 @@
+ android:text="@string/help_section_wired" />
diff --git a/app/src/main/res/layout/item_app_row.xml b/app/src/main/res/layout/item_app_row.xml
new file mode 100644
index 0000000..02d5e29
--- /dev/null
+++ b/app/src/main/res/layout/item_app_row.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_mapping_row.xml b/app/src/main/res/layout/item_mapping_row.xml
index 806035a..0e304c3 100644
--- a/app/src/main/res/layout/item_mapping_row.xml
+++ b/app/src/main/res/layout/item_mapping_row.xml
@@ -1,48 +1,105 @@
+
-
-
+
+
+
+
+
+
+
-
+
+
-
-
+ android:layout_height="match_parent"
+ android:visibility="gone"
+ tools:ignore="ContentDescription" />
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_profile_row.xml b/app/src/main/res/layout/item_profile_row.xml
new file mode 100644
index 0000000..f7ba777
--- /dev/null
+++ b/app/src/main/res/layout/item_profile_row.xml
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/menu_app_picker.xml b/app/src/main/res/menu/menu_app_picker.xml
new file mode 100644
index 0000000..79952fc
--- /dev/null
+++ b/app/src/main/res/menu/menu_app_picker.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/app/src/main/res/menu/menu_calibration.xml b/app/src/main/res/menu/menu_calibration.xml
index 7dba5f5..7e16af5 100644
--- a/app/src/main/res/menu/menu_calibration.xml
+++ b/app/src/main/res/menu/menu_calibration.xml
@@ -3,6 +3,6 @@
xmlns:app="http://schemas.android.com/apk/res-auto">
diff --git a/app/src/main/res/menu/menu_debug.xml b/app/src/main/res/menu/menu_debug.xml
new file mode 100644
index 0000000..30cbce3
--- /dev/null
+++ b/app/src/main/res/menu/menu_debug.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/app/src/main/res/menu/menu_mapping.xml b/app/src/main/res/menu/menu_mapping.xml
index 7dba5f5..3047500 100644
--- a/app/src/main/res/menu/menu_mapping.xml
+++ b/app/src/main/res/menu/menu_mapping.xml
@@ -1,8 +1,13 @@
diff --git a/app/src/main/res/menu/menu_profiles.xml b/app/src/main/res/menu/menu_profiles.xml
new file mode 100644
index 0000000..2aa4045
--- /dev/null
+++ b/app/src/main/res/menu/menu_profiles.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..8fde456
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..8fde456
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 05cb62d..d08f2d2 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,4 +1,21 @@
#8F98A0
+
+
+ #1F3A2A
+ #7CD49B
+
+
+ #3A2A2D
+ #E5BAB4
+
+
+ #5CC04C
+ #E6432A
+ #3FB2EA
+ #F5C518
+
+ #E8E8E8
+ #5A6F7A
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 4672cd4..a1a9237 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -11,26 +11,6 @@
Debug HID
Switching connection modes
- The Steam Controller uses the right Puck slot by default. Pairing to another device will overwrite the default pairing. To maintain two pairings simultaneously, one in each slot, switch wireless modes to the left slot before completing a second pairing. Once two pairings are complete, you can switch between them at any time.
-
-The Steam Controller can also maintain a Bluetooth pairing independently of its Puck pairings.
-
-When powered on, the controller connects by default to the last paired connection used. To power off, hold the Steam button for ~5 seconds until you hear the chime and the LED turns off.
-
-To switch wireless modes: start with the controller turned off, then power it on with one of:
-
-- Puck (right slot) — hold A + R1 + Steam, chime + white LED.
-- Puck (left slot) — hold A + L1 + Steam, chime + white LED.
-- Bluetooth — hold B + R1 + Steam, chime + blue LED.
-
-
-To use wired USB-C:
-
-- If the controller is off, plug it into the device. Chime + green LED.
-- If the controller is on in another mode, hold Steam while plugging it in. Chime + green LED.
-
- ]]>
Controller Service
Steam Controller
Starting…
@@ -38,4 +18,155 @@
Fallback: Shizuku inject (limited)
No injection method available
Connecting…
+
+
+ Steam Controller
+ for Android via Shizuku
+ for Android TV via Shizuku
+ Open GitHub repository
+ Help: switching connection modes
+ Check for updates
+ Shizuku: checking…
+ USB / Puck
+ Bluetooth
+ Controller: disconnected
+ Battery: —
+ Steam Controller 2026 illustration
+ Mode: —
+ Control Mode
+ Gamepad
+ Desktop
+ Emulated controller
+ Xbox 360
+ Xbox One
+ DualShock 4
+ DualSense
+ Bluetooth device
+ Refresh paired devices
+ Calibration
+ Calibrate sticks and rumble
+ Button mapping
+ Remap button inputs
+ Game Profiles
+ Save presets and auto-switch by app
+ View low-level HID reports
+ CONNECTION
+
+
+ Update available
+ Update
+ Skip this version
+ Later
+ You\'re up to date (%1$s)
+ Could not check for updates
+ Downloading update…
+ Allow installing from this app on the next screen, then tap Update again
+
+
+ Calibration
+ STICKS
+ Deadzone
+ Deadzone L / R
+ Invert Y
+ Invert L Y
+ Invert R Y
+ Calibrate All
+ Leave both sticks centered, then tap
+ RUMBLE
+ Test intensity
+ Intensity
+ Test rumble
+ DESKTOP MODE
+ Mouse sensitivity
+ Trackpads as mouse in Xbox/PS profiles
+ Trackpads as mouse (Xbox/PS)
+ When ON, the right trackpad drives a cursor and the left one scrolls — alongside the gamepad.
+ Left stick deadzone
+ Right stick deadzone
+ Rumble intensity
+
+
+ HID Debug
+ Live controller state — for reference only, not interactive.
+ FACE BUTTONS
+ D-PAD
+ BUMPERS / SYSTEM
+ STICKS / PADS (clicks)
+ STICKS / PADS
+ GRIPS / BACK
+ TRIGGERS (0–255)
+ LT
+ RT
+ STICKS (raw ±32767)
+ LEFT STICK
+ RIGHT STICK
+ Left X
+ Left Y
+ Right X
+ Right Y
+ TRACKPADS (raw ±32767)
+ LEFT TRACKPAD
+ RIGHT TRACKPAD
+ IMU QUATERNION
+ RAW REPORT (hex)
+ A
+ B
+ X
+ Y
+ LB
+ RB
+ SEL
+ ◆
+ ☰
+ ≡
+ START
+ QA
+ ▲
+ ▼
+ ◀
+ ▶
+ L3
+ R3
+ LGrip
+ RGrip
+ L4
+ L5
+ R4
+ R5
+
+
+ Button mapping
+ Tap any row to remap it. Changes apply live, no service restart needed.
+
+
+ Game Profiles
+ No game profiles yet
+ Tune your controller for a game, then tap Save current to keep it as a preset. Bind it to an app and we\'ll auto-switch when you launch the game.
+ Save current
+ ACTIVE
+ Load
+ Profile actions
+ Backup saved
+ Could not save backup: %1$s
+ Restored %1$d game profile(s). Restart the service for full effect.
+ Could not restore backup: %1$s
+
+
+ Bind apps
+ Tick every app that should auto-switch to this profile when launched.
+
+
+ The Steam Controller uses the right Puck slot by default. Pairing to another device will overwrite the default pairing. To maintain two pairings simultaneously, one in each slot, switch wireless modes to the left slot before completing a second pairing. Once two pairings are complete, you can switch between them at any time.
+ The Steam Controller can also maintain a Bluetooth pairing independently of its Puck pairings.
+ When powered on, the controller connects by default to the last paired connection used. To power off, hold the Steam button for ~5 seconds until you hear the chime and the LED turns off.
+ To switch wireless modes:
+ Start with the controller turned off, then power it on with one of these combos:
+ To use wired USB-C:
+
+
+ Reset to defaults
+ Log to File
+ Save
+ Export backup…
+ Import backup…
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index ce1cbac..4920ef9 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -4,7 +4,7 @@
@@ -36,7 +36,7 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..f571bc9
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/skills-lock.json b/skills-lock.json
new file mode 100644
index 0000000..47d8464
--- /dev/null
+++ b/skills-lock.json
@@ -0,0 +1,11 @@
+{
+ "version": 1,
+ "skills": {
+ "mobile-android-design": {
+ "source": "wshobson/agents",
+ "sourceType": "github",
+ "skillPath": "plugins/ui-design/skills/mobile-android-design/SKILL.md",
+ "computedHash": "143a60d6a0744a5058adf984e3a64b75cd2ef3714a15557f4ff6fd95e39ff23a"
+ }
+ }
+}