From e8f4fe41517df1f204e27fc4b5a6e738bdf72d13 Mon Sep 17 00:00:00 2001 From: thisishuey Date: Fri, 9 Oct 2026 02:21:09 +0000 Subject: [PATCH 1/3] Let controller buttons act as Android's Home, Back and Screenshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Switch 2 controller's Home, Capture and C reach Android only as gamepad keys that no system action answers to, so going home or back from a game, or taking a screenshot, meant reaching for the device itself. On a Retroid Pocket Nova, C on player 1 even arrived as B. Settings now has an Android buttons section with three rows: Home, Back and Screenshot. Each takes one of Home, Capture, C, GL, GR, − or +, and all start as None. A choice applies to every player while the Virtual Gamepad is on, and the chosen button no longer reaches games. Home and Back travel on the virtual gamepad. It gains a trailing Consumer Control collection, and ReportMapper moves a bound button out of the gamepad bits into AC Home or AC Back, which Android reads as HOME and BACK. Screenshot runs `input keyevent 120` (SYSRQ) through the privileged shell, once per press: a Print Screen usage on the pad took no screenshot on the Nova, while the shell command did. Only buttons SidewaysMapper never moves are offered, so a button's id is the same in the player's input and in the report. Emulator configs keep their bindings to a bound button, so setting it back to None needs no new Set up. Android always handles Home itself, so a bound Home leaves the game. Tested on a Retroid Pocket Nova (Android 13) with NSO GameCube controllers: Home, Back and Screenshot each work, and holding the Screenshot button takes one screenshot. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 + .../com/joegec/joycon2android/AppContainer.kt | 14 +++- .../com/joegec/joycon2android/MainActivity.kt | 17 ++++- .../joegec/joycon2android/model/AndroidKey.kt | 3 + .../model/AndroidKeyBindings.kt | 22 ++++++ .../model/AndroidKeyBindingsTest.kt | 41 ++++++++++ docs/architecture.md | 3 +- docs/virtual-gamepad.md | 25 +++++- .../joycon2android/gamepad/GamepadManager.kt | 20 ++++- .../joycon2android/gamepad/ReportMapper.kt | 20 ++++- .../joycon2android/gamepad/ShellScreenshot.kt | 26 +++++++ .../joycon2android/gamepad/UhidRelay.kt | 18 ++++- .../gamepad/ReportMapperTest.kt | 30 +++++++- .../settings/AndroidKeyBindingsDataStore.kt | 39 ++++++++++ .../settings/AndroidKeyBindingsRepository.kt | 11 +++ .../settings/BindAndroidKeyUseCase.kt | 8 ++ .../ObserveAndroidKeyBindingsUseCase.kt | 8 ++ .../presentation/AndroidKeysSetting.kt | 76 +++++++++++++++++++ .../settings/presentation/SettingsPanel.kt | 4 + .../presentation/SettingsPanelState.kt | 2 + .../presentation/SettingsViewModel.kt | 14 ++++ .../src/main/res/values/strings.xml | 6 ++ 22 files changed, 395 insertions(+), 15 deletions(-) create mode 100644 core/model/src/main/kotlin/com/joegec/joycon2android/model/AndroidKey.kt create mode 100644 core/model/src/main/kotlin/com/joegec/joycon2android/model/AndroidKeyBindings.kt create mode 100644 core/model/src/test/kotlin/com/joegec/joycon2android/model/AndroidKeyBindingsTest.kt create mode 100644 feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ShellScreenshot.kt create mode 100644 feature/settings/data/src/main/kotlin/com/joegec/joycon2android/settings/AndroidKeyBindingsDataStore.kt create mode 100644 feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/AndroidKeyBindingsRepository.kt create mode 100644 feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/BindAndroidKeyUseCase.kt create mode 100644 feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/ObserveAndroidKeyBindingsUseCase.kt create mode 100644 feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/AndroidKeysSetting.kt diff --git a/README.md b/README.md index fe69e8df..a8218215 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,9 @@ The gear at the top right opens the settings panel: quicker buttons, at a battery cost. Applies while the virtual gamepad or DSU is on. - **Ignore this device's motion in Eden** (on by default, needs Shizuku) — stops Eden mixing this device's gyro into Player 1. Lifted when the virtual gamepad and DSU are both off. +- **Android buttons** (all off by default) — pick a controller button for Android's Home, Back and + Screenshot, for every player, while the virtual gamepad is on. A button picked here no longer + reaches games, and Home always leaves the game for the home screen. ### 5. Set up your emulator diff --git a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt index 1c22642e..ed957e47 100644 --- a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt +++ b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt @@ -44,6 +44,7 @@ import com.joegec.joycon2android.emulator.EmulatorLauncher import com.joegec.joycon2android.emulator.EmulatorSetup import com.joegec.joycon2android.emulator.dolphinGamepadIds import com.joegec.joycon2android.emulator.edenGamepads +import com.joegec.joycon2android.model.AndroidKeyBindings import com.joegec.joycon2android.session.AssignControllerUseCase import com.joegec.joycon2android.session.ObserveSessionUseCase import com.joegec.joycon2android.session.SessionCoordinator @@ -54,6 +55,10 @@ import com.joegec.joycon2android.dsu.DsuServer import com.joegec.joycon2android.dsu.EnableDsuUseCase import com.joegec.joycon2android.dsu.ObserveDsuStatusUseCase import com.joegec.joycon2android.dsu.PushDsuPadDataUseCase +import com.joegec.joycon2android.settings.AndroidKeyBindingsDataStore +import com.joegec.joycon2android.settings.AndroidKeyBindingsRepository +import com.joegec.joycon2android.settings.BindAndroidKeyUseCase +import com.joegec.joycon2android.settings.ObserveAndroidKeyBindingsUseCase import com.joegec.joycon2android.settings.ObserveOutputSettingsUseCase import com.joegec.joycon2android.settings.OutputSettingsDataStore import com.joegec.joycon2android.settings.OutputSettingsRepository @@ -86,8 +91,10 @@ import com.joegec.joycon2android.update.installedAppVersion import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import java.io.File /** Composition root: docs/architecture.md#composition-root--appcontainer */ @@ -151,13 +158,18 @@ class AppContainer(context: Context) { val setFasterUpdates = SetFasterUpdatesUseCase(outputSettings) val setBlockDeviceMotion = SetBlockDeviceMotionUseCase(outputSettings) + private val androidKeyBindings: AndroidKeyBindingsRepository = AndroidKeyBindingsDataStore(appContext) + val observeAndroidKeyBindings = ObserveAndroidKeyBindingsUseCase(androidKeyBindings) + val bindAndroidKey = BindAndroidKeyUseCase(androidKeyBindings) + private val androidKeys = observeAndroidKeyBindings().stateIn(scope, SharingStarted.Eagerly, AndroidKeyBindings()) + // --- Assignment --- val assignmentRepository: AssignmentRepository = PlayerAssignmentManager() // --- Gamepad + privileged access --- private val privilegedAccess = PrivilegedAccess() private val gamepadRepository: GamepadRepository = - GamepadOutput(scope, GamepadManager(scope, appContext), privilegedAccess::acquire) + GamepadOutput(scope, GamepadManager(scope, appContext, androidKeys), privilegedAccess::acquire) val enableGamepad = EnableGamepadUseCase(gamepadRepository) val disableGamepad = DisableGamepadUseCase(gamepadRepository) diff --git a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt index 98dad2f8..ecf90f40 100644 --- a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt +++ b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt @@ -90,7 +90,13 @@ class MainActivity : ComponentActivity() { viewModelFactory { initializer { val c = (application as JoyconApplication).container - SettingsViewModel(c.observeOutputSettings, c.setFasterUpdates, c.setBlockDeviceMotion) + SettingsViewModel( + c.observeOutputSettings, + c.setFasterUpdates, + c.setBlockDeviceMotion, + c.observeAndroidKeyBindings, + c.bindAndroidKey, + ) } } } @@ -256,16 +262,23 @@ class MainActivity : ComponentActivity() { @Composable private fun SettingsDrawer(drawerState: DrawerState, content: @Composable () -> Unit) { val outputSettings by settingsViewModel.outputSettings.collectAsState() + val androidKeys by settingsViewModel.androidKeys.collectAsState() val viewMode by viewModel.viewMode.collectAsState() val shizukuAvailable by gamepadViewModel.shizukuAvailable.collectAsState() EndDrawer( drawerState = drawerState, drawerContent = { SettingsPanel( - state = SettingsPanelState(viewMode, outputSettings, deviceMotionBlockAvailable = shizukuAvailable), + state = SettingsPanelState( + viewMode, + outputSettings, + deviceMotionBlockAvailable = shizukuAvailable, + androidKeys = androidKeys, + ), onViewModeChange = viewModel::setViewMode, onFasterUpdatesToggle = settingsViewModel::toggleFasterUpdates, onBlockDeviceMotionToggle = settingsViewModel::toggleBlockDeviceMotion, + onBindAndroidKey = settingsViewModel::assignAndroidKey, ) }, containerColor = Background, diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/AndroidKey.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/AndroidKey.kt new file mode 100644 index 00000000..5ee69ded --- /dev/null +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/AndroidKey.kt @@ -0,0 +1,3 @@ +package com.joegec.joycon2android.model + +enum class AndroidKey { HOME, BACK, SCREENSHOT } diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/AndroidKeyBindings.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/AndroidKeyBindings.kt new file mode 100644 index 00000000..37da172b --- /dev/null +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/AndroidKeyBindings.kt @@ -0,0 +1,22 @@ +package com.joegec.joycon2android.model + +data class AndroidKeyBindings(val buttons: Map = emptyMap()) { + + /** A button stands in for one key at most, so binding it elsewhere moves it. */ + fun bind(key: AndroidKey, button: JoyconButton?): AndroidKeyBindings { + val others = buttons.filterValues { it != button } - key + return AndroidKeyBindings(if (button == null) others else others + (key to button)) + } + + fun pressedKeys(pressed: Set): Set = buttons.filterValues { it.id in pressed }.keys + + fun gamepadButtons(pressed: Set): Set = pressed - buttons.values.mapTo(mutableSetOf()) { it.id } + + companion object { + // SidewaysMapper moves none of these, so each reaches the report under its own id. + val CHOICES = listOf( + JoyconButton.Home, JoyconButton.Capture, JoyconButton.Chat, + JoyconButton.GL, JoyconButton.GR, JoyconButton.Minus, JoyconButton.Plus, + ) + } +} diff --git a/core/model/src/test/kotlin/com/joegec/joycon2android/model/AndroidKeyBindingsTest.kt b/core/model/src/test/kotlin/com/joegec/joycon2android/model/AndroidKeyBindingsTest.kt new file mode 100644 index 00000000..5bd187db --- /dev/null +++ b/core/model/src/test/kotlin/com/joegec/joycon2android/model/AndroidKeyBindingsTest.kt @@ -0,0 +1,41 @@ +package com.joegec.joycon2android.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AndroidKeyBindingsTest { + + private val homeAndBack = AndroidKeyBindings() + .bind(AndroidKey.HOME, JoyconButton.Home) + .bind(AndroidKey.BACK, JoyconButton.Chat) + + @Test + fun `a bound button presses its key and leaves the gamepad`() { + val pressed = setOf(JoyconButton.Chat.id, JoyconButton.A.id) + + assertEquals(setOf(AndroidKey.BACK), homeAndBack.pressedKeys(pressed)) + assertEquals(setOf(JoyconButton.A.id), homeAndBack.gamepadButtons(pressed)) + } + + @Test + fun `binding a button to a second key moves it`() { + val moved = homeAndBack.bind(AndroidKey.SCREENSHOT, JoyconButton.Chat) + + assertEquals(mapOf(AndroidKey.HOME to JoyconButton.Home, AndroidKey.SCREENSHOT to JoyconButton.Chat), moved.buttons) + } + + @Test + fun `binding a key to nothing frees its button for the gamepad`() { + val cleared = homeAndBack.bind(AndroidKey.BACK, null) + + assertEquals(setOf(JoyconButton.Chat.id), cleared.gamepadButtons(setOf(JoyconButton.Chat.id))) + } + + @Test + fun `nothing is bound by default`() { + val pressed = setOf(JoyconButton.Home.id) + + assertEquals(emptySet(), AndroidKeyBindings().pressedKeys(pressed)) + assertEquals(pressed, AndroidKeyBindings().gamepadButtons(pressed)) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index bda78856..2f276dac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -138,7 +138,8 @@ One **ViewModel per feature**, in its presentation module, built in `MainActivit - `GamepadViewModel` — gamepad status, Shizuku availability and emulator auto setup. - `UpdateViewModel` — the once-per-launch release check and the update prompt. - `SettingsViewModel` — the settings that apply to whichever output runs (faster updates, the Eden - motion block). The panel's layout choice stays with `Joycon2ViewModel`, which renders it. + motion block, Android buttons). The panel's layout choice stays with `Joycon2ViewModel`, which + renders it. - `ControllerMappingViewModel` (in `:core:buttonmapping:presentation`) — the button-mapping editor. - `Joycon2ViewModel` (in `:app`) — the app-level host: the coordinator's session `uiState` (genuinely cross-feature), BLE permissions, scan/assign/disconnect, and the service binding. diff --git a/docs/virtual-gamepad.md b/docs/virtual-gamepad.md index 3ed21eac..60da0606 100644 --- a/docs/virtual-gamepad.md +++ b/docs/virtual-gamepad.md @@ -23,7 +23,7 @@ Emulators address it by enumeration rank instead: with P1, P2 and P4, P4 is the ## Report layout -14 bytes: +15 bytes: | Bytes | Content | |---|---| @@ -33,6 +33,7 @@ Emulators address it by enumeration rank instead: with P1, P2 and P4, P4 is the | 11 | left trigger / brake — digital, 0 or 255 | | 12 | right trigger / accelerator — digital, 0 or 255 | | 13 | overflow buttons, in a trailing vendor-defined collection: bit 0 GR, bit 1 C | +| 14 | Android keys, in a trailing Consumer Control collection: bit 0 Home, bit 1 Back ([below](#android-keys)) | ## Buttons and keycodes @@ -66,6 +67,28 @@ shift: (right). Eden's config uses numeric keycodes; Dolphin's uses names (`Button L2` = ZL, `Select` = −, …). +## Android keys + +The settings panel's **Android buttons** binds a controller button to Android's Home, Back or +Screenshot, for every player. `ReportMapper` takes a bound button out of the gamepad bits +(`AndroidKeyBindings`). + +| Key | Sent as | Reaches Android as | +|---|---|---| +| Home | byte 14 bit 0: Consumer `0x223` AC Home → `KEY_HOMEPAGE` | `HOME` | +| Back | byte 14 bit 1: Consumer `0x224` AC Back → `KEY_BACK` | `BACK` | +| Screenshot | `input keyevent 120` through the privileged shell, on press (`ShellScreenshot`) | `SYSRQ`, which the window manager takes as a screenshot | + +- **Screenshot skips the virtual pad.** Retroid Pocket Nova, Android 13, 2026-10-09: a Keyboard + `0x46` Print Screen usage on the pad took no screenshot; `input keyevent 120` through the shell did. + +- **Only buttons `SidewaysMapper` never moves can be bound** (Home, Capture, C, GL, GR, −, +), so + a button's id is the same in the player's input and in the report. +- **Android handles `HOME` itself** and never passes it to the app in front, so a bound Home always + leaves the game. +- **Emulator configs keep their bindings to a bound button.** It never arrives while bound, and + unbinding it needs no new Set up. + ## Sideways Joy-Cons ### Why they're set up as Pro Controllers diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadManager.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadManager.kt index 5de5c113..3f744500 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadManager.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadManager.kt @@ -3,16 +3,24 @@ import com.joegec.joycon2android.gamepad.privileged.PrivilegedShell import android.content.Context import android.util.Log +import com.joegec.joycon2android.model.AndroidKey +import com.joegec.joycon2android.model.AndroidKeyBindings import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class GamepadManager(private val scope: CoroutineScope, private val context: Context) { +class GamepadManager( + private val scope: CoroutineScope, + private val context: Context, + private val androidKeys: StateFlow, + private val screenshot: ShellScreenshot = ShellScreenshot(scope), +) { private val devices = mutableMapOf() private val reportJobs = mutableMapOf() @@ -20,6 +28,7 @@ class GamepadManager(private val scope: CoroutineScope, private val context: Con val activeCount: Int get() = devices.size suspend fun createGamepad(player: PlayerNumber, shell: PrivilegedShell): Boolean = withContext(Dispatchers.IO) { + screenshot.use(shell) if (player in devices) return@withContext true val device = UhidRelay("Joy-Con Virtual Gamepad", player.index) @@ -38,9 +47,12 @@ class GamepadManager(private val scope: CoroutineScope, private val context: Con reportJobs[player]?.cancel() val device = devices[player] ?: return reportJobs[player] = scope.launch(Dispatchers.Default) { - stateFlow.collect { state -> - val report = ReportMapper.buildReport(state) - device.sendReport(report) + var screenshotHeld = false + combine(stateFlow, androidKeys) { state, keys -> state to keys }.collect { (state, keys) -> + device.sendReport(ReportMapper.buildReport(state, keys)) + val held = AndroidKey.SCREENSHOT in keys.pressedKeys(state.gamepad.pressed) + if (held && !screenshotHeld) screenshot.take() + screenshotHeld = held } } } diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt index 03048e7a..00493596 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt @@ -1,11 +1,13 @@ package com.joegec.joycon2android.gamepad +import com.joegec.joycon2android.model.AndroidKey +import com.joegec.joycon2android.model.AndroidKeyBindings import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.PlayerState object ReportMapper { - private const val REPORT_SIZE = 14 + private const val REPORT_SIZE = 15 // Each bit's keycode carries the button's own name: docs/virtual-gamepad.md#buttons-and-keycodes private val BUTTON_MAP: Map = mapOf( @@ -32,12 +34,19 @@ object ReportMapper { JoyconButton.Chat.id to 1, ) + // The trailing system collection's byte. Screenshot goes through the shell instead: + // docs/virtual-gamepad.md#android-keys + private val ANDROID_KEY_BITS: Map = mapOf( + AndroidKey.HOME to 0, + AndroidKey.BACK to 1, + ) + private const val HAT_CENTER = 0x0F - fun buildReport(state: PlayerState): ByteArray { + fun buildReport(state: PlayerState, androidKeys: AndroidKeyBindings = AndroidKeyBindings()): ByteArray { val gamepad = state.gamepad val report = ByteArray(REPORT_SIZE) - val pressed = gamepad.pressed + val pressed = androidKeys.gamepadButtons(gamepad.pressed) // Bytes 0-1: 15 button bits (little-endian) then one padding bit val buttons = bits(pressed, BUTTON_MAP) @@ -63,6 +72,11 @@ object ReportMapper { report[13] = bits(pressed, OVERFLOW_MAP).toByte() + report[14] = androidKeys.pressedKeys(gamepad.pressed) + .mapNotNull(ANDROID_KEY_BITS::get) + .fold(0) { acc, bit -> acc or (1 shl bit) } + .toByte() + return report } diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ShellScreenshot.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ShellScreenshot.kt new file mode 100644 index 00000000..77168067 --- /dev/null +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ShellScreenshot.kt @@ -0,0 +1,26 @@ +package com.joegec.joycon2android.gamepad + +import com.joegec.joycon2android.gamepad.privileged.PrivilegedShell +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** Why the shell and not the virtual pad: docs/virtual-gamepad.md#android-keys */ +class ShellScreenshot(private val scope: CoroutineScope) { + + @Volatile + private var shell: PrivilegedShell? = null + + fun use(shell: PrivilegedShell) { + this.shell = shell + } + + fun take() { + val active = shell ?: return + scope.launch(Dispatchers.IO) { active.shell(COMMAND)?.waitFor() } + } + + private companion object { + const val COMMAND = "input keyevent 120" // KEYCODE_SYSRQ + } +} diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt index b1cdb386..c7d34abf 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt @@ -154,7 +154,7 @@ class UhidRelay(private val name: String, private val playerIndex: Int) { private const val UHID_INPUT2 = 12 private const val BUS_USB = 3 private const val UHID_EVENT_SIZE = 4380 - private const val REPORT_SIZE = 14 + private const val REPORT_SIZE = 15 // Avoid Nintendo VID/PID — the hid-nintendo kernel driver intercepts those // and fails to initialize (since this isn't a real Joy-Con). @@ -247,6 +247,22 @@ class UhidRelay(private val name: String, private val playerIndex: Int) { 0x95.toByte(), 0x01, // Report Count (1) 0x81.toByte(), 0x03, // Input (Const, Var, Abs) 0xC0.toByte(), // End Collection + + // Android's Home and Back keys: docs/virtual-gamepad.md#android-keys + 0x05, 0x0C, // Usage Page (Consumer) + 0x09, 0x01, // Usage (Consumer Control) + 0xA1.toByte(), 0x01, // Collection (Application) + 0x0A, 0x23, 0x02, // Usage (AC Home) + 0x0A, 0x24, 0x02, // Usage (AC Back) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x01, // Logical Maximum (1) + 0x75, 0x01, // Report Size (1) + 0x95.toByte(), 0x02, // Report Count (2) + 0x81.toByte(), 0x02, // Input (Data, Var, Abs) + 0x75, 0x06, // Report Size (6) - padding + 0x95.toByte(), 0x01, // Report Count (1) + 0x81.toByte(), 0x03, // Input (Const, Var, Abs) + 0xC0.toByte(), // End Collection ) } } diff --git a/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt b/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt index 4967c458..321b9e9d 100644 --- a/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt +++ b/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt @@ -1,5 +1,7 @@ package com.joegec.joycon2android.gamepad +import com.joegec.joycon2android.model.AndroidKey +import com.joegec.joycon2android.model.AndroidKeyBindings import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.JoyconInput @@ -12,7 +14,9 @@ import org.junit.Test /** The bit is the Android keycode, so a moved button breaks every emulator config the app writes. */ class ReportMapperTest { - private fun report(vararg pressed: JoyconButton): ByteArray { + private fun report(vararg pressed: JoyconButton): ByteArray = ReportMapper.buildReport(proPlayer(*pressed)) + + private fun proPlayer(vararg pressed: JoyconButton): PlayerState { val input = JoyconInput( packetId = 0, buttons = 0, @@ -26,7 +30,7 @@ class ReportMapperTest { batteryVolts = 4f, ) val pro = ConnectedJoycon(address = "pro", side = Side.PRO, deviceName = "Pro", input = input) - return ReportMapper.buildReport(PlayerState(PlayerNumber.P1, left = pro)) + return PlayerState(PlayerNumber.P1, left = pro) } private fun bitOf(button: JoyconButton): Int { @@ -71,4 +75,26 @@ class ReportMapperTest { assertEquals(0xFF.toByte(), report(JoyconButton.ZR)[12]) assertEquals(0x00.toByte(), report(JoyconButton.ZR)[11]) } + + @Test + fun `a button bound to an Android key leaves the gamepad, and Home and Back take the system byte`() { + val keys = AndroidKeyBindings() + .bind(AndroidKey.HOME, JoyconButton.Home) + .bind(AndroidKey.BACK, JoyconButton.Chat) + .bind(AndroidKey.SCREENSHOT, JoyconButton.Capture) + val report = ReportMapper.buildReport(proPlayer(JoyconButton.Home, JoyconButton.Chat, JoyconButton.Capture), keys) + + assertEquals(0b011.toByte(), report[14]) // Screenshot goes through the shell + assertEquals(0.toByte(), report[0]) // Capture, bit 2 + assertEquals(0.toByte(), report[1]) // Home, bit 12 + assertEquals(0.toByte(), report[13]) // C, overflow bit 1 + } + + @Test + fun `with nothing bound the system byte stays clear`() { + val report = report(JoyconButton.Home, JoyconButton.Chat) + + assertEquals(0.toByte(), report[14]) + assertEquals(0b10.toByte(), report[13]) // C, overflow bit 1 + } } diff --git a/feature/settings/data/src/main/kotlin/com/joegec/joycon2android/settings/AndroidKeyBindingsDataStore.kt b/feature/settings/data/src/main/kotlin/com/joegec/joycon2android/settings/AndroidKeyBindingsDataStore.kt new file mode 100644 index 00000000..beb6d52b --- /dev/null +++ b/feature/settings/data/src/main/kotlin/com/joegec/joycon2android/settings/AndroidKeyBindingsDataStore.kt @@ -0,0 +1,39 @@ +package com.joegec.joycon2android.settings + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.joegec.joycon2android.model.AndroidKey +import com.joegec.joycon2android.model.AndroidKeyBindings +import com.joegec.joycon2android.model.JoyconButton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.androidKeysDataStore: DataStore by preferencesDataStore(name = "android_keys") + +class AndroidKeyBindingsDataStore(context: Context) : AndroidKeyBindingsRepository { + + private val dataStore = context.applicationContext.androidKeysDataStore + + override val bindings: Flow = dataStore.data.map(::read) + + override suspend fun bind(key: AndroidKey, button: JoyconButton?) { + dataStore.edit { prefs -> + val updated = read(prefs).bind(key, button) + AndroidKey.entries.forEach { prefs.remove(preferenceFor(it)) } + updated.buttons.forEach { (boundKey, boundButton) -> prefs[preferenceFor(boundKey)] = boundButton.id } + } + } + + private fun read(prefs: Preferences) = AndroidKeyBindings( + AndroidKey.entries.mapNotNull { key -> + val id = prefs[preferenceFor(key)] + JoyconButton.entries.firstOrNull { it.id == id }?.let { key to it } + }.toMap(), + ) + + private fun preferenceFor(key: AndroidKey) = stringPreferencesKey(key.name.lowercase()) +} diff --git a/feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/AndroidKeyBindingsRepository.kt b/feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/AndroidKeyBindingsRepository.kt new file mode 100644 index 00000000..a9dcb03b --- /dev/null +++ b/feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/AndroidKeyBindingsRepository.kt @@ -0,0 +1,11 @@ +package com.joegec.joycon2android.settings + +import com.joegec.joycon2android.model.AndroidKey +import com.joegec.joycon2android.model.AndroidKeyBindings +import com.joegec.joycon2android.model.JoyconButton +import kotlinx.coroutines.flow.Flow + +interface AndroidKeyBindingsRepository { + val bindings: Flow + suspend fun bind(key: AndroidKey, button: JoyconButton?) +} diff --git a/feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/BindAndroidKeyUseCase.kt b/feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/BindAndroidKeyUseCase.kt new file mode 100644 index 00000000..587671f7 --- /dev/null +++ b/feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/BindAndroidKeyUseCase.kt @@ -0,0 +1,8 @@ +package com.joegec.joycon2android.settings + +import com.joegec.joycon2android.model.AndroidKey +import com.joegec.joycon2android.model.JoyconButton + +class BindAndroidKeyUseCase(private val repository: AndroidKeyBindingsRepository) { + suspend operator fun invoke(key: AndroidKey, button: JoyconButton?) = repository.bind(key, button) +} diff --git a/feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/ObserveAndroidKeyBindingsUseCase.kt b/feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/ObserveAndroidKeyBindingsUseCase.kt new file mode 100644 index 00000000..7a16378d --- /dev/null +++ b/feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/ObserveAndroidKeyBindingsUseCase.kt @@ -0,0 +1,8 @@ +package com.joegec.joycon2android.settings + +import com.joegec.joycon2android.model.AndroidKeyBindings +import kotlinx.coroutines.flow.Flow + +class ObserveAndroidKeyBindingsUseCase(private val repository: AndroidKeyBindingsRepository) { + operator fun invoke(): Flow = repository.bindings +} diff --git a/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/AndroidKeysSetting.kt b/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/AndroidKeysSetting.kt new file mode 100644 index 00000000..f7275974 --- /dev/null +++ b/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/AndroidKeysSetting.kt @@ -0,0 +1,76 @@ +package com.joegec.joycon2android.settings.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.model.AndroidKey +import com.joegec.joycon2android.model.AndroidKeyBindings +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.ui.components.DropdownOption +import com.joegec.joycon2android.ui.components.OptionDropdown +import com.joegec.joycon2android.ui.theme.Dimens +import com.joegec.joycon2android.ui.theme.TextDim + +private const val NONE_ID = "none" + +@Composable +internal fun AndroidKeysSetting( + bindings: AndroidKeyBindings, + onBind: (AndroidKey, JoyconButton?) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(Dimens.elementSpacing)) { + Text( + stringResource(R.string.settings_android_keys_title), + color = Color.White, + style = MaterialTheme.typography.titleMedium, + ) + Text( + stringResource(R.string.settings_android_keys_description), + color = TextDim, + style = MaterialTheme.typography.bodySmall, + ) + AndroidKey.entries.forEach { key -> + AndroidKeyRow(key, bindings.buttons[key], onBind = { button -> onBind(key, button) }) + } + } +} + +@Composable +private fun AndroidKeyRow(key: AndroidKey, button: JoyconButton?, onBind: (JoyconButton?) -> Unit) { + val none = stringResource(R.string.settings_android_key_none) + val options = listOf(DropdownOption(NONE_ID, none)) + + AndroidKeyBindings.CHOICES.map { DropdownOption(it.id, it.label) } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), + ) { + Text( + key.label(), + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + OptionDropdown( + options = options, + selectedId = button?.id ?: NONE_ID, + label = button?.label ?: none, + onSelect = { id -> onBind(AndroidKeyBindings.CHOICES.firstOrNull { it.id == id }) }, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun AndroidKey.label(): String = when (this) { + AndroidKey.HOME -> stringResource(R.string.settings_android_key_home) + AndroidKey.BACK -> stringResource(R.string.settings_android_key_back) + AndroidKey.SCREENSHOT -> stringResource(R.string.settings_android_key_screenshot) +} diff --git a/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsPanel.kt b/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsPanel.kt index c2d3e619..599463b8 100644 --- a/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsPanel.kt +++ b/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsPanel.kt @@ -11,7 +11,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.model.AndroidKey import com.joegec.joycon2android.model.ConnectionViewMode +import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.ui.components.SettingSwitch import com.joegec.joycon2android.ui.theme.Dimens @@ -21,6 +23,7 @@ fun SettingsPanel( onViewModeChange: (ConnectionViewMode) -> Unit, onFasterUpdatesToggle: (Boolean) -> Unit, onBlockDeviceMotionToggle: (Boolean) -> Unit, + onBindAndroidKey: (AndroidKey, JoyconButton?) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -56,5 +59,6 @@ fun SettingsPanel( else stringResource(R.string.settings_block_device_motion_needs_shizuku), ) } + SettingsSection { AndroidKeysSetting(state.androidKeys, onBindAndroidKey) } } } diff --git a/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsPanelState.kt b/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsPanelState.kt index 9392ef69..1e0cc30f 100644 --- a/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsPanelState.kt +++ b/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsPanelState.kt @@ -1,5 +1,6 @@ package com.joegec.joycon2android.settings.presentation +import com.joegec.joycon2android.model.AndroidKeyBindings import com.joegec.joycon2android.model.ConnectionViewMode import com.joegec.joycon2android.settings.OutputSettings @@ -7,4 +8,5 @@ data class SettingsPanelState( val viewMode: ConnectionViewMode = ConnectionViewMode.DETAILED, val outputSettings: OutputSettings = OutputSettings(), val deviceMotionBlockAvailable: Boolean = false, + val androidKeys: AndroidKeyBindings = AndroidKeyBindings(), ) diff --git a/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsViewModel.kt b/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsViewModel.kt index 360d3577..47bef00d 100644 --- a/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsViewModel.kt +++ b/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsViewModel.kt @@ -2,6 +2,11 @@ package com.joegec.joycon2android.settings.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.joegec.joycon2android.model.AndroidKey +import com.joegec.joycon2android.model.AndroidKeyBindings +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.settings.BindAndroidKeyUseCase +import com.joegec.joycon2android.settings.ObserveAndroidKeyBindingsUseCase import com.joegec.joycon2android.settings.ObserveOutputSettingsUseCase import com.joegec.joycon2android.settings.OutputSettings import com.joegec.joycon2android.settings.SetBlockDeviceMotionUseCase @@ -15,11 +20,16 @@ class SettingsViewModel( observeOutputSettings: ObserveOutputSettingsUseCase, private val setFasterUpdates: SetFasterUpdatesUseCase, private val setBlockDeviceMotion: SetBlockDeviceMotionUseCase, + observeAndroidKeyBindings: ObserveAndroidKeyBindingsUseCase, + private val bindAndroidKey: BindAndroidKeyUseCase, ) : ViewModel() { val outputSettings: StateFlow = observeOutputSettings() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), OutputSettings()) + val androidKeys: StateFlow = observeAndroidKeyBindings() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), AndroidKeyBindings()) + fun toggleFasterUpdates(enabled: Boolean) { viewModelScope.launch { setFasterUpdates(enabled) } } @@ -28,6 +38,10 @@ class SettingsViewModel( viewModelScope.launch { setBlockDeviceMotion(enabled) } } + fun assignAndroidKey(key: AndroidKey, button: JoyconButton?) { + viewModelScope.launch { bindAndroidKey(key, button) } + } + private companion object { const val STOP_TIMEOUT_MS = 5_000L } diff --git a/feature/settings/presentation/src/main/res/values/strings.xml b/feature/settings/presentation/src/main/res/values/strings.xml index 98572bfd..a706e8bd 100644 --- a/feature/settings/presentation/src/main/res/values/strings.xml +++ b/feature/settings/presentation/src/main/res/values/strings.xml @@ -9,4 +9,10 @@ Ignore this device\'s motion in Eden Eden adds this device\'s gyro to Player 1, which makes controller motion stutter. Lifted when the Virtual Gamepad and DSU are both off. Needs Shizuku running. + Android buttons + Controller buttons that work as Android\'s own, on every player while the Virtual Gamepad is on. A button chosen here no longer reaches games, and Home always leaves the game for the home screen. + Home + Back + Screenshot + None From e2f5557fb61ecd98385444442cdd3fc477162cb3 Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Mon, 12 Oct 2026 19:04:36 +0100 Subject: [PATCH 2/3] Map Android keys per player and console in the mapping screen Home, Back and Screenshot become targets in each player's mapping card, under Buttons/Sticks/Motion/Android headings, so they follow the selected emulator's console like every other binding. A bound button is withheld from the game on both outputs. Home and Back ride the virtual gamepad's Consumer keys; Screenshot, and every key over DSU alone, go through the Shizuku shell once per press, with failures caught and the process closed. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +- .../com/joegec/joycon2android/AppContainer.kt | 56 +++++++++--- .../com/joegec/joycon2android/MainActivity.kt | 30 ++----- .../androidkeys/AndroidKeyRouter.kt | 37 ++++++++ .../androidkeys/OutputAndroidKeys.kt | 18 ++++ .../androidkeys/RoutedOutputs.kt | 6 ++ .../androidkeys/ShellAndroidKeyPresser.kt | 41 +++++++++ .../emulator/EmulatorConsoles.kt | 12 +++ .../androidkeys/AndroidKeyRouterTest.kt | 87 +++++++++++++++++++ .../buttonmapping/AndroidKeyTargets.kt | 17 ++++ .../buttonmapping/MappingLayouts.kt | 5 +- .../ObserveAndroidKeyBindingsUseCase.kt | 16 ++++ .../buttonmapping/AndroidKeyMappingTest.kt | 58 +++++++++++++ .../presentation/ControllerMappingScreen.kt | 2 + .../presentation/MappingBindings.kt | 39 ++++++++- .../presentation/MappingLabels.kt | 16 ++++ .../presentation/MappingOptions.kt | 17 ++-- .../presentation/MappingSection.kt | 3 + .../presentation/PlayerMappingCard.kt | 3 +- .../src/main/res/values/strings.xml | 9 ++ .../ui/components/SettingSwitch.kt | 2 +- .../model/AndroidKeyBindings.kt | 25 +++--- .../model/AndroidKeyBindingsTest.kt | 46 +++++----- docs/architecture.md | 11 ++- docs/virtual-gamepad.md | 33 +++---- .../dsu/presentation/DsuViewModel.kt | 6 ++ .../joycon2android/gamepad/GamepadManager.kt | 22 +---- .../joycon2android/gamepad/GamepadOutput.kt | 16 ++-- .../joycon2android/gamepad/ReportMapper.kt | 15 ++-- .../joycon2android/gamepad/ShellScreenshot.kt | 26 ------ .../gamepad/ReportMapperTest.kt | 27 +++--- .../joycon2android/gamepad/GamepadFrame.kt | 11 +++ .../gamepad/GamepadRepository.kt | 2 +- .../gamepad/PushGamepadStateUseCase.kt | 4 +- .../gamepad/presentation/GamepadViewModel.kt | 6 ++ .../settings/AndroidKeyBindingsDataStore.kt | 39 --------- .../settings/AndroidKeyBindingsRepository.kt | 11 --- .../settings/BindAndroidKeyUseCase.kt | 8 -- .../ObserveAndroidKeyBindingsUseCase.kt | 8 -- .../presentation/AndroidKeysSetting.kt | 76 ---------------- .../settings/presentation/SettingsPanel.kt | 4 - .../presentation/SettingsPanelState.kt | 2 - .../presentation/SettingsViewModel.kt | 14 --- .../src/main/res/values/strings.xml | 6 -- 44 files changed, 547 insertions(+), 352 deletions(-) create mode 100644 app/src/main/java/com/joegec/joycon2android/androidkeys/AndroidKeyRouter.kt create mode 100644 app/src/main/java/com/joegec/joycon2android/androidkeys/OutputAndroidKeys.kt create mode 100644 app/src/main/java/com/joegec/joycon2android/androidkeys/RoutedOutputs.kt create mode 100644 app/src/main/java/com/joegec/joycon2android/androidkeys/ShellAndroidKeyPresser.kt create mode 100644 app/src/main/java/com/joegec/joycon2android/emulator/EmulatorConsoles.kt create mode 100644 app/src/test/java/com/joegec/joycon2android/androidkeys/AndroidKeyRouterTest.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/AndroidKeyTargets.kt create mode 100644 core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveAndroidKeyBindingsUseCase.kt create mode 100644 core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/AndroidKeyMappingTest.kt create mode 100644 core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingSection.kt delete mode 100644 feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ShellScreenshot.kt create mode 100644 feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadFrame.kt delete mode 100644 feature/settings/data/src/main/kotlin/com/joegec/joycon2android/settings/AndroidKeyBindingsDataStore.kt delete mode 100644 feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/AndroidKeyBindingsRepository.kt delete mode 100644 feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/BindAndroidKeyUseCase.kt delete mode 100644 feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/ObserveAndroidKeyBindingsUseCase.kt delete mode 100644 feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/AndroidKeysSetting.kt diff --git a/README.md b/README.md index 6ed466e9..00bcf304 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,6 @@ The gear at the top right opens the settings panel: quicker buttons, at a battery cost. Applies while the virtual gamepad or DSU is on. - **Ignore this device's motion in Eden** (on by default, needs Shizuku) — stops Eden mixing this device's gyro into Player 1. Lifted when the virtual gamepad and DSU are both off. -- **Android buttons** (all off by default) — pick a controller button for Android's Home, Back and - Screenshot, for every player, while the virtual gamepad is on. A button picked here no longer - reaches games, and Home always leaves the game for the home screen. ### 5. Set up your emulator @@ -115,6 +112,10 @@ target can take **several sources** — tick as many as you like, and any of the - **All players** at the top sets and saves everyone at once. A grip only some bodies fit — **Mario Kart Wheel**, say — gives the rest the same game's other grip. A saved set remembers who held which body ("P1 L, P2 R, P3 L/R") and stays greyed out until those players are back. +- **Android.** Below the emulator's buttons, sticks and motion, each card has Android's **Home**, + **Back** and **Screenshot**. A button picked here works as the device's own and never reaches the + game; Home always leaves it. Like the rest of the mapping it's per player and console, and saved in + layouts. Over DSU alone it needs Shizuku running. - **Sideways Wii Remote.** On the Wii console, a lone Joy-Con also gets this switch, for games that steer by tilting a sideways remote. On, up on the stick is towards the rail; off, towards L or R. The layout sets it (on for Mario Kart) and you can override it until you next pick a layout. diff --git a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt index ed957e47..cd83bf2a 100644 --- a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt +++ b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt @@ -12,7 +12,14 @@ import com.joegec.joycon2android.connection.StartScanUseCase import com.joegec.joycon2android.connection.StopScanUseCase import com.joegec.joycon2android.connection.ViewModePreferences import com.joegec.joycon2android.connection.ViewModePreferencesDataStore +import com.joegec.joycon2android.androidkeys.AndroidKeyRouter +import com.joegec.joycon2android.androidkeys.OutputAndroidKeys +import com.joegec.joycon2android.androidkeys.ShellAndroidKeyPresser import com.joegec.joycon2android.buttonmapping.ApplyGlobalLayoutUseCase +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.buttonmapping.ObserveAndroidKeyBindingsUseCase +import com.joegec.joycon2android.buttonmapping.PlayerBody +import com.joegec.joycon2android.buttonmapping.body import com.joegec.joycon2android.buttonmapping.ApplyMappingLayoutUseCase import com.joegec.joycon2android.buttonmapping.ControllerMappingDataStore import com.joegec.joycon2android.buttonmapping.ControllerMappingRepository @@ -40,11 +47,11 @@ import com.joegec.joycon2android.assignment.AssignmentRepository import com.joegec.joycon2android.assignment.ComboAssignmentDetector import com.joegec.joycon2android.assignment.PlayerAssignmentManager import com.joegec.joycon2android.assignment.PlayerStateResolver +import com.joegec.joycon2android.emulator.EmulatorConsoles import com.joegec.joycon2android.emulator.EmulatorLauncher import com.joegec.joycon2android.emulator.EmulatorSetup import com.joegec.joycon2android.emulator.dolphinGamepadIds import com.joegec.joycon2android.emulator.edenGamepads -import com.joegec.joycon2android.model.AndroidKeyBindings import com.joegec.joycon2android.session.AssignControllerUseCase import com.joegec.joycon2android.session.ObserveSessionUseCase import com.joegec.joycon2android.session.SessionCoordinator @@ -55,10 +62,6 @@ import com.joegec.joycon2android.dsu.DsuServer import com.joegec.joycon2android.dsu.EnableDsuUseCase import com.joegec.joycon2android.dsu.ObserveDsuStatusUseCase import com.joegec.joycon2android.dsu.PushDsuPadDataUseCase -import com.joegec.joycon2android.settings.AndroidKeyBindingsDataStore -import com.joegec.joycon2android.settings.AndroidKeyBindingsRepository -import com.joegec.joycon2android.settings.BindAndroidKeyUseCase -import com.joegec.joycon2android.settings.ObserveAndroidKeyBindingsUseCase import com.joegec.joycon2android.settings.ObserveOutputSettingsUseCase import com.joegec.joycon2android.settings.OutputSettingsDataStore import com.joegec.joycon2android.settings.OutputSettingsRepository @@ -89,10 +92,14 @@ import com.joegec.joycon2android.update.UpdatePreferencesDataStore import com.joegec.joycon2android.update.UpdateRepository import com.joegec.joycon2android.update.installedAppVersion import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import java.io.File @@ -158,18 +165,13 @@ class AppContainer(context: Context) { val setFasterUpdates = SetFasterUpdatesUseCase(outputSettings) val setBlockDeviceMotion = SetBlockDeviceMotionUseCase(outputSettings) - private val androidKeyBindings: AndroidKeyBindingsRepository = AndroidKeyBindingsDataStore(appContext) - val observeAndroidKeyBindings = ObserveAndroidKeyBindingsUseCase(androidKeyBindings) - val bindAndroidKey = BindAndroidKeyUseCase(androidKeyBindings) - private val androidKeys = observeAndroidKeyBindings().stateIn(scope, SharingStarted.Eagerly, AndroidKeyBindings()) - // --- Assignment --- val assignmentRepository: AssignmentRepository = PlayerAssignmentManager() // --- Gamepad + privileged access --- private val privilegedAccess = PrivilegedAccess() private val gamepadRepository: GamepadRepository = - GamepadOutput(scope, GamepadManager(scope, appContext, androidKeys), privilegedAccess::acquire) + GamepadOutput(scope, GamepadManager(scope, appContext), privilegedAccess::acquire) val enableGamepad = EnableGamepadUseCase(gamepadRepository) val disableGamepad = DisableGamepadUseCase(gamepadRepository) @@ -192,6 +194,32 @@ class AppContainer(context: Context) { val emulatorLauncher = EmulatorLauncher(appContext) + // --- Android keys (either output, from the mapping of the console each is set to) --- + private val gamepadConsole = MutableStateFlow(Console.GAMECUBE) + private val dsuConsole = MutableStateFlow(Console.WIIMOTE_NUNCHUK) + private val activeBodies = MutableStateFlow(emptyList()) + private val observeAndroidKeyBindings = ObserveAndroidKeyBindingsUseCase(observeControllerMapping) + + private val androidKeyRouter = AndroidKeyRouter( + gamepad = OutputAndroidKeys(gamepadRepository.enabled, androidKeyBindingsFor(gamepadConsole)), + dsu = OutputAndroidKeys(dsuRepository.enabled, androidKeyBindingsFor(dsuConsole)), + pressKey = ShellAndroidKeyPresser(scope, privilegedAccess::readyShell)::press, + ) + + fun selectGamepadEmulator(emulatorId: String) { + gamepadConsole.value = EmulatorConsoles.forGamepad(emulatorId) + } + + fun selectDsuEmulator(emulatorId: String) { + dsuConsole.value = EmulatorConsoles.forDsu(emulatorId) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun androidKeyBindingsFor(console: StateFlow) = + combine(console, activeBodies, ::Pair) + .flatMapLatest { (console, bodies) -> observeAndroidKeyBindings(console, bodies) } + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + // --- Updates --- private val skippedVersions: SkippedVersionRepository = UpdatePreferencesDataStore(appContext) private val updateRepository: UpdateRepository = GitHubReleases(GITHUB_REPOSITORY) @@ -213,8 +241,10 @@ class AppContainer(context: Context) { resolver = PlayerStateResolver(evictConflicting = assignmentRepository::unassign), comboDetector = ComboAssignmentDetector(), onState = { state -> - pushGamepadState(state.players) - pushDsuPadData(state.activePlayers) + activeBodies.value = state.activePlayers.mapNotNull { it.body() } + val routed = androidKeyRouter.route(state) + pushGamepadState(routed.gamepad) + pushDsuPadData(routed.dsu) }, onPlayerAssigned = { onPlayerAssigned(it) }, onPlayerUnassigned = { onPlayerUnassigned(it) }, diff --git a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt index ecf90f40..840bdfbe 100644 --- a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt +++ b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt @@ -32,7 +32,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory -import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.body import com.joegec.joycon2android.buttonmapping.presentation.ControllerMappingScreen import com.joegec.joycon2android.buttonmapping.presentation.ControllerMappingViewModel @@ -43,7 +42,7 @@ import com.joegec.joycon2android.ui.Joycon2ViewModel import com.joegec.joycon2android.ui.JoyconScreen import com.joegec.joycon2android.ui.pushTransition import com.joegec.joycon2android.dsu.DsuSlots -import com.joegec.joycon2android.emulatorconfig.EdenPaths +import com.joegec.joycon2android.emulator.EmulatorConsoles import com.joegec.joycon2android.ui.components.CloseEmulatorDialog import com.joegec.joycon2android.ui.components.EmulatorOption import com.joegec.joycon2android.ui.components.StartEmulatorDialog @@ -67,6 +66,7 @@ class MainActivity : ComponentActivity() { c.disableDsu, dsuEmulators = c.emulatorSetup.dsuEmulators(), configureDsu = c.emulatorSetup::configureDsu, + onEmulatorSelected = c::selectDsuEmulator, ) } } @@ -82,6 +82,7 @@ class MainActivity : ComponentActivity() { c.disableGamepad, gamepadEmulators = c.emulatorSetup.gamepadEmulators(), configureGamepad = c.emulatorSetup::configureGamepad, + onEmulatorSelected = c::selectGamepadEmulator, ) } } @@ -90,13 +91,7 @@ class MainActivity : ComponentActivity() { viewModelFactory { initializer { val c = (application as JoyconApplication).container - SettingsViewModel( - c.observeOutputSettings, - c.setFasterUpdates, - c.setBlockDeviceMotion, - c.observeAndroidKeyBindings, - c.bindAndroidKey, - ) + SettingsViewModel(c.observeOutputSettings, c.setFasterUpdates, c.setBlockDeviceMotion) } } } @@ -218,6 +213,7 @@ class MainActivity : ComponentActivity() { val players = session.activePlayers val bodies = players.mapNotNull { it.body() } val state by controllerMappingViewModel.uiState.collectAsState() + val shizukuAvailable by gamepadViewModel.shizukuAvailable.collectAsState() var showDsuHelp by rememberSaveable { mutableStateOf(false) } LaunchedEffect(console, bodies) { controllerMappingViewModel.edit(console, bodies) } @@ -229,6 +225,7 @@ class MainActivity : ComponentActivity() { actions = mappingActions, onBack = onBack, onInfoClick = if (route.fromDsu) ({ showDsuHelp = true }) else null, + androidKeysNeedShizuku = route.fromDsu && !shizukuAvailable, ) } if (showDsuHelp) { @@ -262,23 +259,16 @@ class MainActivity : ComponentActivity() { @Composable private fun SettingsDrawer(drawerState: DrawerState, content: @Composable () -> Unit) { val outputSettings by settingsViewModel.outputSettings.collectAsState() - val androidKeys by settingsViewModel.androidKeys.collectAsState() val viewMode by viewModel.viewMode.collectAsState() val shizukuAvailable by gamepadViewModel.shizukuAvailable.collectAsState() EndDrawer( drawerState = drawerState, drawerContent = { SettingsPanel( - state = SettingsPanelState( - viewMode, - outputSettings, - deviceMotionBlockAvailable = shizukuAvailable, - androidKeys = androidKeys, - ), + state = SettingsPanelState(viewMode, outputSettings, deviceMotionBlockAvailable = shizukuAvailable), onViewModeChange = viewModel::setViewMode, onFasterUpdatesToggle = settingsViewModel::toggleFasterUpdates, onBlockDeviceMotionToggle = settingsViewModel::toggleBlockDeviceMotion, - onBindAndroidKey = settingsViewModel::assignAndroidKey, ) }, containerColor = Background, @@ -357,15 +347,13 @@ class MainActivity : ComponentActivity() { gamepadSetupPhase = gamepadSetupPhase, onConfigureGamepad = { gamepadViewModel.configureGamepad(state.activePlayers) }, onOpenGamepadMapping = { - val console = if (selectedEmulator in EdenPaths.PACKAGES) Console.SWITCH_PRO else Console.GAMECUBE - onOpenMapping(MappingRoute(console, fromDsu = false)) + onOpenMapping(MappingRoute(EmulatorConsoles.forGamepad(selectedEmulator), fromDsu = false)) }, onDsuToggle = dsuViewModel::toggle, onSelectDsuEmulator = dsuViewModel::selectEmulator, onConfigureDsu = { dsuViewModel.configureDsu(state.activePlayers) }, onOpenDsuMapping = { - val console = if (selectedDsuEmulator in EdenPaths.PACKAGES) Console.SWITCH_PRO else Console.WIIMOTE_NUNCHUK - onOpenMapping(MappingRoute(console, fromDsu = true)) + onOpenMapping(MappingRoute(EmulatorConsoles.forDsu(selectedDsuEmulator), fromDsu = true)) }, onOpenSystemSettings = { startActivity(viewModel.permissionHandler.buildSettingsIntent()) }, onOpenSettings = onOpenSettings, diff --git a/app/src/main/java/com/joegec/joycon2android/androidkeys/AndroidKeyRouter.kt b/app/src/main/java/com/joegec/joycon2android/androidkeys/AndroidKeyRouter.kt new file mode 100644 index 00000000..333bf0c8 --- /dev/null +++ b/app/src/main/java/com/joegec/joycon2android/androidkeys/AndroidKeyRouter.kt @@ -0,0 +1,37 @@ +package com.joegec.joycon2android.androidkeys + +import com.joegec.joycon2android.gamepad.GamepadFrame +import com.joegec.joycon2android.model.AndroidKey +import com.joegec.joycon2android.model.AppUiState +import com.joegec.joycon2android.model.PlayerNumber +import com.joegec.joycon2android.model.PlayerState + +/** How each key reaches Android: docs/virtual-gamepad.md#android-keys */ +class AndroidKeyRouter( + private val gamepad: OutputAndroidKeys, + private val dsu: OutputAndroidKeys, + private val pressKey: (AndroidKey) -> Unit, +) { + private var heldThroughShell = emptyMap>() + + fun route(state: AppUiState): RoutedOutputs { + val routed = RoutedOutputs( + gamepad = state.players.map { GamepadFrame(gamepad.of(it).withheldFrom(it), sentByPad(it)) }, + dsu = state.activePlayers.map { dsu.of(it).withheldFrom(it) }, + ) + pressNewlyHeld(state.players) + return routed + } + + private fun held(player: PlayerState) = gamepad.heldKeys(player) + dsu.heldKeys(player) + + private fun sentByPad(player: PlayerState): Set = + if (gamepad.isEnabled) held(player) intersect GamepadFrame.SENDABLE_KEYS else emptySet() + + // The shell presses once per press, so a key held across frames fires only as it goes down. + private fun pressNewlyHeld(players: List) { + val held = players.associate { it.player to held(it) - sentByPad(it) } + held.forEach { (player, keys) -> (keys - heldThroughShell[player].orEmpty()).forEach(pressKey) } + heldThroughShell = held + } +} diff --git a/app/src/main/java/com/joegec/joycon2android/androidkeys/OutputAndroidKeys.kt b/app/src/main/java/com/joegec/joycon2android/androidkeys/OutputAndroidKeys.kt new file mode 100644 index 00000000..d1d59c34 --- /dev/null +++ b/app/src/main/java/com/joegec/joycon2android/androidkeys/OutputAndroidKeys.kt @@ -0,0 +1,18 @@ +package com.joegec.joycon2android.androidkeys + +import com.joegec.joycon2android.model.AndroidKey +import com.joegec.joycon2android.model.AndroidKeyBindings +import com.joegec.joycon2android.model.PlayerNumber +import com.joegec.joycon2android.model.PlayerState +import kotlinx.coroutines.flow.StateFlow + +class OutputAndroidKeys( + private val enabled: StateFlow, + private val bindings: StateFlow>, +) { + val isEnabled: Boolean get() = enabled.value + + fun of(player: PlayerState): AndroidKeyBindings = bindings.value[player.player] ?: AndroidKeyBindings() + + fun heldKeys(player: PlayerState): Set = if (isEnabled) of(player).heldKeys(player) else emptySet() +} diff --git a/app/src/main/java/com/joegec/joycon2android/androidkeys/RoutedOutputs.kt b/app/src/main/java/com/joegec/joycon2android/androidkeys/RoutedOutputs.kt new file mode 100644 index 00000000..c47952fb --- /dev/null +++ b/app/src/main/java/com/joegec/joycon2android/androidkeys/RoutedOutputs.kt @@ -0,0 +1,6 @@ +package com.joegec.joycon2android.androidkeys + +import com.joegec.joycon2android.gamepad.GamepadFrame +import com.joegec.joycon2android.model.PlayerState + +data class RoutedOutputs(val gamepad: List, val dsu: List) diff --git a/app/src/main/java/com/joegec/joycon2android/androidkeys/ShellAndroidKeyPresser.kt b/app/src/main/java/com/joegec/joycon2android/androidkeys/ShellAndroidKeyPresser.kt new file mode 100644 index 00000000..e9ef362e --- /dev/null +++ b/app/src/main/java/com/joegec/joycon2android/androidkeys/ShellAndroidKeyPresser.kt @@ -0,0 +1,41 @@ +package com.joegec.joycon2android.androidkeys + +import android.util.Log +import android.view.KeyEvent +import com.joegec.joycon2android.gamepad.privileged.PrivilegedShell +import com.joegec.joycon2android.model.AndroidKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** Injects a key as if pressed on the device. Why not for every key: docs/virtual-gamepad.md#android-keys */ +class ShellAndroidKeyPresser( + private val scope: CoroutineScope, + private val readyShell: () -> PrivilegedShell?, +) { + + fun press(key: AndroidKey) { + val shell = readyShell() ?: return + scope.launch(Dispatchers.IO) { + val proc = shell.shell("input keyevent ${key.keyCode}") ?: return@launch + try { + proc.waitFor() + } catch (e: Exception) { + Log.w(TAG, "Could not press $key", e) + } finally { + proc.destroy() + } + } + } + + private val AndroidKey.keyCode: Int + get() = when (this) { + AndroidKey.HOME -> KeyEvent.KEYCODE_HOME + AndroidKey.BACK -> KeyEvent.KEYCODE_BACK + AndroidKey.SCREENSHOT -> KeyEvent.KEYCODE_SYSRQ + } + + private companion object { + const val TAG = "ShellAndroidKeyPresser" + } +} diff --git a/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorConsoles.kt b/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorConsoles.kt new file mode 100644 index 00000000..ec1ee136 --- /dev/null +++ b/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorConsoles.kt @@ -0,0 +1,12 @@ +package com.joegec.joycon2android.emulator + +import com.joegec.joycon2android.buttonmapping.Console +import com.joegec.joycon2android.emulatorconfig.EdenPaths + +object EmulatorConsoles { + fun forGamepad(emulatorId: String): Console = + if (emulatorId in EdenPaths.PACKAGES) Console.SWITCH_PRO else Console.GAMECUBE + + fun forDsu(emulatorId: String): Console = + if (emulatorId in EdenPaths.PACKAGES) Console.SWITCH_PRO else Console.WIIMOTE_NUNCHUK +} diff --git a/app/src/test/java/com/joegec/joycon2android/androidkeys/AndroidKeyRouterTest.kt b/app/src/test/java/com/joegec/joycon2android/androidkeys/AndroidKeyRouterTest.kt new file mode 100644 index 00000000..48bd2a29 --- /dev/null +++ b/app/src/test/java/com/joegec/joycon2android/androidkeys/AndroidKeyRouterTest.kt @@ -0,0 +1,87 @@ +package com.joegec.joycon2android.androidkeys + +import com.joegec.joycon2android.model.AndroidKey +import com.joegec.joycon2android.model.AndroidKeyBindings +import com.joegec.joycon2android.model.AppUiState +import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.JoyconInput +import com.joegec.joycon2android.model.PlayerNumber +import com.joegec.joycon2android.model.PlayerState +import com.joegec.joycon2android.model.Side +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Test + +class AndroidKeyRouterTest { + + private val gamepadOn = MutableStateFlow(true) + private val dsuOn = MutableStateFlow(false) + private val gamepadBindings = MutableStateFlow(mapOf(PlayerNumber.P1 to bindings(AndroidKey.HOME to JoyconButton.Home))) + private val dsuBindings = MutableStateFlow(mapOf(PlayerNumber.P1 to bindings(AndroidKey.BACK to JoyconButton.Minus))) + private val pressed = mutableListOf() + + private val router = AndroidKeyRouter( + gamepad = OutputAndroidKeys(gamepadOn, gamepadBindings), + dsu = OutputAndroidKeys(dsuOn, dsuBindings), + pressKey = { pressed += it }, + ) + + private fun bindings(vararg keys: Pair) = + AndroidKeyBindings(keys.associate { (key, button) -> key to setOf(button) }) + + private fun holding(vararg buttons: JoyconButton): AppUiState { + val pro = ConnectedJoycon("pro", Side.PRO, "Pro", input = JoyconInput(pressed = buttons.map { it.id }.toSet())) + return AppUiState(players = listOf(PlayerState(PlayerNumber.P1, left = pro))) + } + + @Test + fun `the pad sends Home itself and withholds its button from the game`() { + val frame = router.route(holding(JoyconButton.Home, JoyconButton.A)).gamepad.single() + + assertEquals(setOf(AndroidKey.HOME), frame.androidKeys) + assertEquals(setOf(JoyconButton.A.id), frame.state.left!!.input.pressed) + assertEquals(emptyList(), pressed) + } + + @Test + fun `screenshot goes through the shell once per press however long it is held`() { + gamepadBindings.value = mapOf(PlayerNumber.P1 to bindings(AndroidKey.SCREENSHOT to JoyconButton.Capture)) + + repeat(3) { router.route(holding(JoyconButton.Capture)) } + router.route(holding()) + router.route(holding(JoyconButton.Capture)) + + assertEquals(listOf(AndroidKey.SCREENSHOT, AndroidKey.SCREENSHOT), pressed) + } + + @Test + fun `with only DSU running every key goes through the shell`() { + gamepadOn.value = false + dsuOn.value = true + + val routed = router.route(holding(JoyconButton.Minus, JoyconButton.Home)) + + assertEquals(listOf(AndroidKey.BACK), pressed) + assertEquals(setOf(JoyconButton.Home.id), routed.dsu.single().left!!.input.pressed) + } + + @Test + fun `with both outputs running the pad sends a DSU binding's Back so it fires once`() { + dsuOn.value = true + + val frame = router.route(holding(JoyconButton.Minus)).gamepad.single() + + assertEquals(setOf(AndroidKey.BACK), frame.androidKeys) + assertEquals(emptyList(), pressed) + } + + @Test + fun `an output that is off fires nothing`() { + gamepadOn.value = false + + router.route(holding(JoyconButton.Home, JoyconButton.Minus)) + + assertEquals(emptyList(), pressed) + } +} diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/AndroidKeyTargets.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/AndroidKeyTargets.kt new file mode 100644 index 00000000..e8d5d52f --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/AndroidKeyTargets.kt @@ -0,0 +1,17 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.model.AndroidKey +import com.joegec.joycon2android.model.AndroidKeyBindings + +/** Prefixed so it never collides with an emulator target such as `SwitchProButton.Home`. */ +val AndroidKey.targetKey: String get() = "ANDROID_$name" + +/** Only buttons fire an Android key; a stick direction in the entry is ignored. */ +fun Map.androidKeyBindings(): AndroidKeyBindings = AndroidKeyBindings( + AndroidKey.entries.mapNotNull { key -> + val buttons = this[key.targetKey].orEmpty().toMappingSources() + .filterIsInstance() + .mapTo(mutableSetOf()) { it.button } + buttons.takeIf { it.isNotEmpty() }?.let { key to it } + }.toMap(), +) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt index ea79107a..f49fb66a 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/MappingLayouts.kt @@ -21,9 +21,12 @@ object MappingLayouts { sidewaysRemote: Boolean, saved: List, ): MappingLayout? = forBody(console, side, saved).firstOrNull { - it.sidewaysRemote == sidewaysRemote && entriesOf(console, side, it) == entries + it.sidewaysRemote == sidewaysRemote && entriesOf(console, side, it).bound() == entries.bound() } + // A target cleared back to "None" is stored as empty, which a layout that never bound it omits. + private fun Map.bound() = filterValues { it.isNotEmpty() } + /** Falls back to the family's other grip, then the console default (as a deleted layout does). */ fun byId(console: Console, side: JoyconSide, id: String?, saved: List): MappingLayout = forBody(console, side, saved).firstOrNull { it.id == id } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveAndroidKeyBindingsUseCase.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveAndroidKeyBindingsUseCase.kt new file mode 100644 index 00000000..f51f4568 --- /dev/null +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/ObserveAndroidKeyBindingsUseCase.kt @@ -0,0 +1,16 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.model.AndroidKeyBindings +import com.joegec.joycon2android.model.PlayerNumber +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +class ObserveAndroidKeyBindingsUseCase(private val observeControllerMapping: ObserveControllerMappingUseCase) { + operator fun invoke(console: Console, bodies: List): Flow> = + if (bodies.isEmpty()) flowOf(emptyMap()) + else combine(bodies.map { body -> observeControllerMapping(console, body).map { body.player to it.androidKeyBindings() } }) { + it.toMap() + } +} diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/AndroidKeyMappingTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/AndroidKeyMappingTest.kt new file mode 100644 index 00000000..f25c2cba --- /dev/null +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/AndroidKeyMappingTest.kt @@ -0,0 +1,58 @@ +package com.joegec.joycon2android.buttonmapping + +import com.joegec.joycon2android.buttonmapping.preset.GameCubeMapping +import com.joegec.joycon2android.model.AndroidKey +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.PlayerNumber +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class AndroidKeyMappingTest { + + private val p1 = PlayerBody(PlayerNumber.P1, JoyconSide.DUAL) + private val p2 = PlayerBody(PlayerNumber.P2, JoyconSide.LEFT) + + @Test + fun `an Android key fires on any of its buttons and ignores stick directions`() { + val entries = mapOf(AndroidKey.HOME.targetKey to "Home|Chat|LEFT_STICK_UP") + + assertEquals( + mapOf(AndroidKey.HOME to setOf(JoyconButton.Home, JoyconButton.Chat)), + entries.androidKeyBindings().buttons, + ) + } + + @Test + fun `each player and console keeps its own Android keys`() = runBlocking { + val mappings = FakeControllerMappings() + mappings.set(Console.GAMECUBE, p1, AndroidKey.HOME.targetKey, "Home") + mappings.set(Console.GAMECUBE, p2, AndroidKey.BACK.targetKey, "Minus") + mappings.set(Console.SWITCH_PRO, p1, AndroidKey.SCREENSHOT.targetKey, "Capture") + + val bindings = ObserveAndroidKeyBindingsUseCase(ObserveControllerMappingUseCase(mappings))( + Console.GAMECUBE, + listOf(p1, p2), + ).first() + + assertEquals(mapOf(AndroidKey.HOME to setOf(JoyconButton.Home)), bindings.getValue(PlayerNumber.P1).buttons) + assertEquals(mapOf(AndroidKey.BACK to setOf(JoyconButton.Minus)), bindings.getValue(PlayerNumber.P2).buttons) + } + + @Test + fun `an Android key cleared back to None still matches the layout`() { + val entries = GameCubeMapping.entries(JoyconSide.DUAL) + (AndroidKey.HOME.targetKey to "") + + val layout = MappingLayouts.matching(Console.GAMECUBE, JoyconSide.DUAL, entries, false, emptyList()) + + assertEquals(GameCubeMapping, layout) + } + + @Test + fun `a bound Android key makes the mapping custom`() { + val entries = GameCubeMapping.entries(JoyconSide.DUAL) + (AndroidKey.HOME.targetKey to "Home") + + assertEquals(null, MappingLayouts.matching(Console.GAMECUBE, JoyconSide.DUAL, entries, false, emptyList())) + } +} diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt index e5021cc1..81487183 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerMappingScreen.kt @@ -51,6 +51,7 @@ fun ControllerMappingScreen( onBack: () -> Unit, modifier: Modifier = Modifier, onInfoClick: (() -> Unit)? = null, + androidKeysNeedShizuku: Boolean = false, ) { BackHandler(onBack = onBack) var dialog by remember { mutableStateOf(null) } @@ -86,6 +87,7 @@ fun ControllerMappingScreen( actions = actions, onSaveLayout = { dialog = MappingDialog.Save(player.body) }, onDeleteLayout = { dialog = MappingDialog.Delete(it.id, it.label, global = false) }, + androidKeysNeedShizuku = androidKeysNeedShizuku, ) } } diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt index 4448d7c2..a14653ce 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt @@ -4,22 +4,55 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import com.joegec.joycon2android.buttonmapping.Console import com.joegec.joycon2android.buttonmapping.sourceIdOf import com.joegec.joycon2android.buttonmapping.sourceIdsOf +import com.joegec.joycon2android.core.buttonmapping.presentation.R import com.joegec.joycon2android.ui.components.MultiSelectDropdown +import com.joegec.joycon2android.ui.components.SettingWarning import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.TextDim @Composable -fun MappingBindings(console: Console, state: PlayerMappingUiState, actions: MappingActions) { +fun MappingBindings( + console: Console, + state: PlayerMappingUiState, + actions: MappingActions, + androidKeysNeedShizuku: Boolean = false, +) { + Column(verticalArrangement = Arrangement.spacedBy(Dimens.sectionSpacing)) { + MappingSection.entries.forEach { section -> + val targets = MappingOptions.targets(console, section) + if (targets.isNotEmpty()) { + BindingSection(section, targets, state, actions, androidKeysNeedShizuku) + } + } + } +} + +@Composable +private fun BindingSection( + section: MappingSection, + targets: List>, + state: PlayerMappingUiState, + actions: MappingActions, + androidKeysNeedShizuku: Boolean, +) { Column(verticalArrangement = Arrangement.spacedBy(Dimens.elementSpacing)) { - val sourceOptions = MappingOptions.sources(state.body.side) - MappingOptions.targets(console).forEach { (key, label) -> + Text(section.label(), color = Color.White, style = MaterialTheme.typography.titleSmall) + if (section == MappingSection.ANDROID) { + Text(stringResource(R.string.android_keys_description), color = TextDim, style = MaterialTheme.typography.bodySmall) + if (androidKeysNeedShizuku) SettingWarning(stringResource(R.string.android_keys_need_shizuku)) + } + val sourceOptions = MappingOptions.sources(state.body.side, section) + targets.forEach { (key, label) -> val selectedIds = sourceIdsOf(state.mapping[key].orEmpty()) BindingRow(label, selectedIds, sourceOptions) { toggled -> actions.setMapping(state.body, key, sourceIdOf(selectedIds.toggling(toggled))) diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt index 25bea766..6e2c490a 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt @@ -14,6 +14,7 @@ import com.joegec.joycon2android.buttonmapping.target.SwitchProStick import com.joegec.joycon2android.buttonmapping.target.WiimoteButton import com.joegec.joycon2android.buttonmapping.target.WiimoteStick import com.joegec.joycon2android.core.buttonmapping.presentation.R +import com.joegec.joycon2android.model.AndroidKey @Composable internal fun Console.label(): String = when (this) { @@ -122,3 +123,18 @@ internal fun SwitchProStick.label(): String = when (this) { SwitchProStick.LStick -> stringResource(R.string.stick_l) SwitchProStick.RStick -> stringResource(R.string.stick_r) } + +@Composable +internal fun MappingSection.label(): String = when (this) { + MappingSection.BUTTONS -> stringResource(R.string.section_buttons) + MappingSection.STICKS -> stringResource(R.string.section_sticks) + MappingSection.MOTION -> stringResource(R.string.section_motion) + MappingSection.ANDROID -> stringResource(R.string.section_android) +} + +@Composable +internal fun AndroidKey.label(): String = when (this) { + AndroidKey.HOME -> stringResource(R.string.android_key_home) + AndroidKey.BACK -> stringResource(R.string.android_key_back) + AndroidKey.SCREENSHOT -> stringResource(R.string.android_key_screenshot) +} diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt index 1148a9f4..ab55a63f 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt @@ -7,6 +7,7 @@ import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.MappingSource import com.joegec.joycon2android.buttonmapping.StickDirection import com.joegec.joycon2android.buttonmapping.StickSource +import com.joegec.joycon2android.buttonmapping.targetKey import com.joegec.joycon2android.buttonmapping.directionKey import com.joegec.joycon2android.buttonmapping.target.GameCubeButton import com.joegec.joycon2android.buttonmapping.target.GameCubeStick @@ -15,6 +16,7 @@ import com.joegec.joycon2android.buttonmapping.target.SwitchProStick import com.joegec.joycon2android.buttonmapping.target.WiimoteButton import com.joegec.joycon2android.buttonmapping.target.WiimoteStick import com.joegec.joycon2android.core.buttonmapping.presentation.R +import com.joegec.joycon2android.model.AndroidKey import com.joegec.joycon2android.model.JoyconButton internal object MappingOptions { @@ -24,8 +26,12 @@ internal object MappingOptions { console == Console.WIIMOTE_NUNCHUK && side != JoyconSide.DUAL @Composable - fun targets(console: Console): List> = - buttonTargets(console) + stickDirectionTargets(console) + motionTargets(console) + fun targets(console: Console, section: MappingSection): List> = when (section) { + MappingSection.BUTTONS -> buttonTargets(console) + MappingSection.STICKS -> stickDirectionTargets(console) + MappingSection.MOTION -> motionTargets(console) + MappingSection.ANDROID -> AndroidKey.entries.map { it.targetKey to it.label() } + } @Composable private fun buttonTargets(console: Console): List> = when (console) { @@ -34,7 +40,7 @@ internal object MappingOptions { Console.SWITCH_PRO -> SwitchProButton.entries.map { it.name to it.label() } } - // Not buttons on the remote, so they're listed after the sticks. + // Not buttons on the remote, so they get their own section. private val MOTION_TARGETS = setOf(WiimoteButton.Shake, WiimoteButton.Recenter) @Composable @@ -55,11 +61,12 @@ internal object MappingOptions { } } + // An Android key fires on a button press, so it offers no stick directions. @Composable - fun sources(side: JoyconSide): List> = + fun sources(side: JoyconSide, section: MappingSection): List> = listOf(NONE_ID to stringResource(R.string.source_none)) + physicalButtons(side).map { it.name to it.id } + - stickDirections(side) + if (section == MappingSection.ANDROID) emptyList() else stickDirections(side) // A lone Joy-Con has one stick, so its directions need no "Left"/"Right" to tell them apart. @Composable diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingSection.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingSection.kt new file mode 100644 index 00000000..c8662947 --- /dev/null +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingSection.kt @@ -0,0 +1,3 @@ +package com.joegec.joycon2android.buttonmapping.presentation + +internal enum class MappingSection { BUTTONS, STICKS, MOTION, ANDROID } diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt index c2c876ee..46051874 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt @@ -55,6 +55,7 @@ fun PlayerMappingCard( onSaveLayout: () -> Unit, onDeleteLayout: (DropdownOption) -> Unit, modifier: Modifier = Modifier, + androidKeysNeedShizuku: Boolean = false, ) { var expanded by rememberSaveable(state.body) { mutableStateOf(false) } @@ -92,7 +93,7 @@ fun PlayerMappingCard( actions.setSidewaysRemote(state.body, it) } } - MappingBindings(console, state, actions) + MappingBindings(console, state, actions, androidKeysNeedShizuku) } } } diff --git a/core/buttonmapping/presentation/src/main/res/values/strings.xml b/core/buttonmapping/presentation/src/main/res/values/strings.xml index 88bfb064..6a16dc70 100644 --- a/core/buttonmapping/presentation/src/main/res/values/strings.xml +++ b/core/buttonmapping/presentation/src/main/res/values/strings.xml @@ -64,4 +64,13 @@ D-Pad Right None %1$s %2$s + Buttons + Sticks + Motion + Android + Home + Back + Screenshot + Work as this device\'s own buttons, and never reach the game. + Over DSU, these need Shizuku running. diff --git a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/SettingSwitch.kt b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/SettingSwitch.kt index 7cff82d4..7d0e256d 100644 --- a/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/SettingSwitch.kt +++ b/core/designsystem/src/main/kotlin/com/joegec/joycon2android/ui/components/SettingSwitch.kt @@ -71,7 +71,7 @@ fun SettingSwitch( } @Composable -private fun SettingWarning(text: String) { +fun SettingWarning(text: String) { Row( horizontalArrangement = Arrangement.spacedBy(Dimens.statusDotGap), verticalAlignment = Alignment.CenterVertically, diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/AndroidKeyBindings.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/AndroidKeyBindings.kt index 37da172b..317f1274 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/AndroidKeyBindings.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/AndroidKeyBindings.kt @@ -1,22 +1,17 @@ package com.joegec.joycon2android.model -data class AndroidKeyBindings(val buttons: Map = emptyMap()) { +/** Bound by the buttons the controller reports, before any sideways rotation. */ +data class AndroidKeyBindings(val buttons: Map> = emptyMap()) { - /** A button stands in for one key at most, so binding it elsewhere moves it. */ - fun bind(key: AndroidKey, button: JoyconButton?): AndroidKeyBindings { - val others = buttons.filterValues { it != button } - key - return AndroidKeyBindings(if (button == null) others else others + (key to button)) - } - - fun pressedKeys(pressed: Set): Set = buttons.filterValues { it.id in pressed }.keys + private val boundIds: Set = buttons.values.flatten().mapTo(mutableSetOf()) { it.id } - fun gamepadButtons(pressed: Set): Set = pressed - buttons.values.mapTo(mutableSetOf()) { it.id } + fun heldKeys(state: PlayerState): Set = + buttons.filterValues { bound -> bound.any { it.id in state.pressed } }.keys - companion object { - // SidewaysMapper moves none of these, so each reaches the report under its own id. - val CHOICES = listOf( - JoyconButton.Home, JoyconButton.Capture, JoyconButton.Chat, - JoyconButton.GL, JoyconButton.GR, JoyconButton.Minus, JoyconButton.Plus, - ) + fun withheldFrom(state: PlayerState): PlayerState { + if (boundIds.isEmpty()) return state + return state.copy(left = state.left?.withoutBound(), right = state.right?.withoutBound()) } + + private fun ConnectedJoycon.withoutBound() = copy(input = input.copy(pressed = input.pressed - boundIds)) } diff --git a/core/model/src/test/kotlin/com/joegec/joycon2android/model/AndroidKeyBindingsTest.kt b/core/model/src/test/kotlin/com/joegec/joycon2android/model/AndroidKeyBindingsTest.kt index 5bd187db..fa0bdc7b 100644 --- a/core/model/src/test/kotlin/com/joegec/joycon2android/model/AndroidKeyBindingsTest.kt +++ b/core/model/src/test/kotlin/com/joegec/joycon2android/model/AndroidKeyBindingsTest.kt @@ -1,41 +1,45 @@ package com.joegec.joycon2android.model import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame import org.junit.Test class AndroidKeyBindingsTest { - private val homeAndBack = AndroidKeyBindings() - .bind(AndroidKey.HOME, JoyconButton.Home) - .bind(AndroidKey.BACK, JoyconButton.Chat) - - @Test - fun `a bound button presses its key and leaves the gamepad`() { - val pressed = setOf(JoyconButton.Chat.id, JoyconButton.A.id) - - assertEquals(setOf(AndroidKey.BACK), homeAndBack.pressedKeys(pressed)) - assertEquals(setOf(JoyconButton.A.id), homeAndBack.gamepadButtons(pressed)) + private val homeAndBack = AndroidKeyBindings( + mapOf( + AndroidKey.HOME to setOf(JoyconButton.Home), + AndroidKey.BACK to setOf(JoyconButton.Chat, JoyconButton.Minus), + ), + ) + + private fun pair(vararg pressed: JoyconButton): PlayerState { + val ids = pressed.map { it.id }.toSet() + return PlayerState( + PlayerNumber.P1, + left = ConnectedJoycon("l", Side.LEFT, "L", input = JoyconInput(pressed = ids)), + right = ConnectedJoycon("r", Side.RIGHT, "R", input = JoyconInput(pressed = ids)), + ) } @Test - fun `binding a button to a second key moves it`() { - val moved = homeAndBack.bind(AndroidKey.SCREENSHOT, JoyconButton.Chat) - - assertEquals(mapOf(AndroidKey.HOME to JoyconButton.Home, AndroidKey.SCREENSHOT to JoyconButton.Chat), moved.buttons) + fun `any bound button holds its key`() { + assertEquals(setOf(AndroidKey.BACK), homeAndBack.heldKeys(pair(JoyconButton.Minus, JoyconButton.A))) } @Test - fun `binding a key to nothing frees its button for the gamepad`() { - val cleared = homeAndBack.bind(AndroidKey.BACK, null) + fun `bound buttons are withheld from both Joy-Cons and the rest pass through`() { + val withheld = homeAndBack.withheldFrom(pair(JoyconButton.Home, JoyconButton.Chat, JoyconButton.A)) - assertEquals(setOf(JoyconButton.Chat.id), cleared.gamepadButtons(setOf(JoyconButton.Chat.id))) + assertEquals(setOf(JoyconButton.A.id), withheld.left!!.input.pressed) + assertEquals(setOf(JoyconButton.A.id), withheld.right!!.input.pressed) } @Test - fun `nothing is bound by default`() { - val pressed = setOf(JoyconButton.Home.id) + fun `nothing bound leaves the state untouched`() { + val state = pair(JoyconButton.Home) - assertEquals(emptySet(), AndroidKeyBindings().pressedKeys(pressed)) - assertEquals(pressed, AndroidKeyBindings().gamepadButtons(pressed)) + assertEquals(emptySet(), AndroidKeyBindings().heldKeys(state)) + assertSame(state, AndroidKeyBindings().withheldFrom(state)) } } diff --git a/docs/architecture.md b/docs/architecture.md index 2f276dac..a02ccd55 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,6 +83,9 @@ shipped layouts in `preset/`), persistence (`data`) and the editor (`presentatio `MappingLayouts.matching` reads the name back by comparing a body's bindings with every shipped layout and every one the user saved for that body; no match is "Custom". So deleting a layout removes only its name, and the same bindings answer to it again if an identical one is saved. +- **Android keys are mapping targets too** (`ANDROID_HOME`, …), so layouts save them. They act in + the app rather than the emulator: `AndroidKeyRouter` sits between the coordinator and both + outputs ([how](virtual-gamepad.md#android-keys)). - **`GlobalLayout` freezes the whole session** — every player's bindings in full, and the bodies they held — so it restores exactly what it saved, but only onto those players. @@ -138,8 +141,7 @@ One **ViewModel per feature**, in its presentation module, built in `MainActivit - `GamepadViewModel` — gamepad status, Shizuku availability and emulator auto setup. - `UpdateViewModel` — the once-per-launch release check and the update prompt. - `SettingsViewModel` — the settings that apply to whichever output runs (faster updates, the Eden - motion block, Android buttons). The panel's layout choice stays with `Joycon2ViewModel`, which - renders it. + motion block). The panel's layout choice stays with `Joycon2ViewModel`, which renders it. - `ControllerMappingViewModel` (in `:core:buttonmapping:presentation`) — the button-mapping editor. - `Joycon2ViewModel` (in `:app`) — the app-level host: the coordinator's session `uiState` (genuinely cross-feature), BLE permissions, scan/assign/disconnect, and the service binding. @@ -155,8 +157,9 @@ BLE notify ─→ Joycon2Manager (connection/data, ControllerRepository) ▼ SessionCoordinator (core/session) ── combines connections + assignments │ onState(state) - ├─→ PushGamepadStateUseCase ─→ GamepadOutput ─→ UHID relay ─→ /dev/uhid - ├─→ PushDsuPadDataUseCase ─→ DsuServer ─→ UDP :26760 ─→ emulators + ├─→ AndroidKeyRouter (withholds Android-key buttons, presses keys via shell) + │ ├─→ PushGamepadStateUseCase ─→ GamepadOutput ─→ UHID relay ─→ /dev/uhid + │ └─→ PushDsuPadDataUseCase ─→ DsuServer ─→ UDP :26760 ─→ emulators └─→ ObserveSessionUseCase ─→ Joycon2ViewModel ─→ AppUiState ─→ Compose ``` diff --git a/docs/virtual-gamepad.md b/docs/virtual-gamepad.md index 60da0606..c99ab409 100644 --- a/docs/virtual-gamepad.md +++ b/docs/virtual-gamepad.md @@ -69,25 +69,28 @@ shift: ## Android keys -The settings panel's **Android buttons** binds a controller button to Android's Home, Back or -Screenshot, for every player. `ReportMapper` takes a bound button out of the gamepad bits -(`AndroidKeyBindings`). +Each player's mapping, per console, has an **Android** section: buttons bound to Home, Back or +Screenshot act as the device's own keys and never reach the game. `AndroidKeyRouter` applies the +mapping of the console each output's card is set to, while that output runs. -| Key | Sent as | Reaches Android as | +| Key | Virtual gamepad | DSU only | |---|---|---| -| Home | byte 14 bit 0: Consumer `0x223` AC Home → `KEY_HOMEPAGE` | `HOME` | -| Back | byte 14 bit 1: Consumer `0x224` AC Back → `KEY_BACK` | `BACK` | -| Screenshot | `input keyevent 120` through the privileged shell, on press (`ShellScreenshot`) | `SYSRQ`, which the window manager takes as a screenshot | - -- **Screenshot skips the virtual pad.** Retroid Pocket Nova, Android 13, 2026-10-09: a Keyboard - `0x46` Print Screen usage on the pad took no screenshot; `input keyevent 120` through the shell did. - -- **Only buttons `SidewaysMapper` never moves can be bound** (Home, Capture, C, GL, GR, −, +), so - a button's id is the same in the player's input and in the report. +| Home | byte 14 bit 0: Consumer `0x223` AC Home → `KEY_HOMEPAGE` → `HOME` | shell `input keyevent` | +| Back | byte 14 bit 1: Consumer `0x224` AC Back → `KEY_BACK` → `BACK` | shell `input keyevent` | +| Screenshot | shell `input keyevent 120` (`SYSRQ`), once per press | same | + +- **Withheld before either output.** `AndroidKeyBindings.withheldFrom` drops bound buttons from the + player's input as the controller reports it, so the same rule covers sideways Joy-Cons and both + outputs. +- **Home and Back ride the pad when it runs**, even for a DSU binding, so a key bound on both + screens fires once. +- **The shell is the slow path.** AYN Thor, Android 13, 2026-10-12: `input keyevent` took 50 ms to + 1.5 s and twice hung; the pad's Consumer keys are immediate. DSU never reaches Android, so it has + no other path, and needs Shizuku. +- **Screenshot skips the pad.** Retroid Pocket Nova, Android 13, 2026-10-09: a Keyboard `0x46` + Print Screen usage on the pad took no screenshot; `input keyevent 120` through the shell did. - **Android handles `HOME` itself** and never passes it to the app in front, so a bound Home always leaves the game. -- **Emulator configs keep their bindings to a bound button.** It never arrives while bound, and - unbinding it needs no new Set up. ## Sideways Joy-Cons diff --git a/feature/dsu/presentation/src/main/kotlin/com/joegec/joycon2android/dsu/presentation/DsuViewModel.kt b/feature/dsu/presentation/src/main/kotlin/com/joegec/joycon2android/dsu/presentation/DsuViewModel.kt index 2ab2fbeb..f00c12cd 100644 --- a/feature/dsu/presentation/src/main/kotlin/com/joegec/joycon2android/dsu/presentation/DsuViewModel.kt +++ b/feature/dsu/presentation/src/main/kotlin/com/joegec/joycon2android/dsu/presentation/DsuViewModel.kt @@ -25,6 +25,7 @@ class DsuViewModel( val dsuEmulators: List = emptyList(), private val configureDsu: suspend (emulatorId: String, players: List, closeEmulator: Boolean) -> EmulatorSetupResult = { _, _, _ -> EmulatorSetupResult.FAILED }, + private val onEmulatorSelected: (emulatorId: String) -> Unit = {}, ) : ViewModel() { val status: StateFlow = observeDsuStatus() @@ -33,6 +34,10 @@ class DsuViewModel( private val _selectedEmulator = MutableStateFlow(dsuEmulators.firstOrNull()?.id ?: "") val selectedEmulator: StateFlow = _selectedEmulator.asStateFlow() + init { + onEmulatorSelected(_selectedEmulator.value) + } + private val _setupPhase = MutableStateFlow(DolphinSetupPhase.IDLE) val setupPhase: StateFlow = _setupPhase.asStateFlow() @@ -48,6 +53,7 @@ class DsuViewModel( fun selectEmulator(id: String) { _selectedEmulator.value = id + onEmulatorSelected(id) _emulatorToClose.value = null _emulatorToStart.value = null resetSetupPhase() diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadManager.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadManager.kt index 3f744500..f8fd3a02 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadManager.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadManager.kt @@ -3,24 +3,15 @@ import com.joegec.joycon2android.gamepad.privileged.PrivilegedShell import android.content.Context import android.util.Log -import com.joegec.joycon2android.model.AndroidKey -import com.joegec.joycon2android.model.AndroidKeyBindings import com.joegec.joycon2android.model.PlayerNumber -import com.joegec.joycon2android.model.PlayerState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class GamepadManager( - private val scope: CoroutineScope, - private val context: Context, - private val androidKeys: StateFlow, - private val screenshot: ShellScreenshot = ShellScreenshot(scope), -) { +class GamepadManager(private val scope: CoroutineScope, private val context: Context) { private val devices = mutableMapOf() private val reportJobs = mutableMapOf() @@ -28,7 +19,6 @@ class GamepadManager( val activeCount: Int get() = devices.size suspend fun createGamepad(player: PlayerNumber, shell: PrivilegedShell): Boolean = withContext(Dispatchers.IO) { - screenshot.use(shell) if (player in devices) return@withContext true val device = UhidRelay("Joy-Con Virtual Gamepad", player.index) @@ -43,17 +33,11 @@ class GamepadManager( success } - fun startReporting(player: PlayerNumber, stateFlow: StateFlow) { + fun startReporting(player: PlayerNumber, frames: StateFlow) { reportJobs[player]?.cancel() val device = devices[player] ?: return reportJobs[player] = scope.launch(Dispatchers.Default) { - var screenshotHeld = false - combine(stateFlow, androidKeys) { state, keys -> state to keys }.collect { (state, keys) -> - device.sendReport(ReportMapper.buildReport(state, keys)) - val held = AndroidKey.SCREENSHOT in keys.pressedKeys(state.gamepad.pressed) - if (held && !screenshotHeld) screenshot.take() - screenshotHeld = held - } + frames.collect { device.sendReport(ReportMapper.buildReport(it)) } } } diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadOutput.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadOutput.kt index 70ab64b3..01949400 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadOutput.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadOutput.kt @@ -23,8 +23,8 @@ class GamepadOutput( private var shell: PrivilegedShell? = null - private val playerStateFlows = PlayerNumber.entries.associateWith { - MutableStateFlow(PlayerState(it)) + private val playerFrames = PlayerNumber.entries.associateWith { + MutableStateFlow(GamepadFrame(PlayerState(it))) } override fun enable(players: List) { @@ -47,9 +47,9 @@ class GamepadOutput( fun destroyAll() = gamepadManager.destroyAll() - override fun push(players: List) { + override fun push(frames: List) { if (!_enabled.value) return - players.forEach { playerStateFlows[it.player]?.value = it } + frames.forEach { playerFrames[it.state.player]?.value = it } } override fun onPlayerAssigned(player: PlayerNumber) { @@ -57,7 +57,7 @@ class GamepadOutput( val active = shell ?: return scope.launch { if (gamepadManager.createGamepad(player, active)) { - gamepadManager.startReporting(player, playerStateFlows.getValue(player)) + gamepadManager.startReporting(player, playerFrames.getValue(player)) } } } @@ -78,9 +78,9 @@ class GamepadOutput( var anyCreated = false for (playerState in active) { if (gamepadManager.createGamepad(playerState.player, shell)) { - val flow = playerStateFlows.getValue(playerState.player) - flow.value = playerState - gamepadManager.startReporting(playerState.player, flow) + val frames = playerFrames.getValue(playerState.player) + frames.value = GamepadFrame(playerState) + gamepadManager.startReporting(playerState.player, frames) anyCreated = true } } diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt index 00493596..da1f4066 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt @@ -1,9 +1,7 @@ package com.joegec.joycon2android.gamepad import com.joegec.joycon2android.model.AndroidKey -import com.joegec.joycon2android.model.AndroidKeyBindings import com.joegec.joycon2android.model.JoyconButton -import com.joegec.joycon2android.model.PlayerState object ReportMapper { @@ -34,19 +32,18 @@ object ReportMapper { JoyconButton.Chat.id to 1, ) - // The trailing system collection's byte. Screenshot goes through the shell instead: - // docs/virtual-gamepad.md#android-keys - private val ANDROID_KEY_BITS: Map = mapOf( + // The trailing Consumer collection's byte: docs/virtual-gamepad.md#android-keys + internal val ANDROID_KEY_BITS: Map = mapOf( AndroidKey.HOME to 0, AndroidKey.BACK to 1, ) private const val HAT_CENTER = 0x0F - fun buildReport(state: PlayerState, androidKeys: AndroidKeyBindings = AndroidKeyBindings()): ByteArray { - val gamepad = state.gamepad + fun buildReport(frame: GamepadFrame): ByteArray { + val gamepad = frame.state.gamepad val report = ByteArray(REPORT_SIZE) - val pressed = androidKeys.gamepadButtons(gamepad.pressed) + val pressed = gamepad.pressed // Bytes 0-1: 15 button bits (little-endian) then one padding bit val buttons = bits(pressed, BUTTON_MAP) @@ -72,7 +69,7 @@ object ReportMapper { report[13] = bits(pressed, OVERFLOW_MAP).toByte() - report[14] = androidKeys.pressedKeys(gamepad.pressed) + report[14] = frame.androidKeys .mapNotNull(ANDROID_KEY_BITS::get) .fold(0) { acc, bit -> acc or (1 shl bit) } .toByte() diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ShellScreenshot.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ShellScreenshot.kt deleted file mode 100644 index 77168067..00000000 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ShellScreenshot.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.joegec.joycon2android.gamepad - -import com.joegec.joycon2android.gamepad.privileged.PrivilegedShell -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -/** Why the shell and not the virtual pad: docs/virtual-gamepad.md#android-keys */ -class ShellScreenshot(private val scope: CoroutineScope) { - - @Volatile - private var shell: PrivilegedShell? = null - - fun use(shell: PrivilegedShell) { - this.shell = shell - } - - fun take() { - val active = shell ?: return - scope.launch(Dispatchers.IO) { active.shell(COMMAND)?.waitFor() } - } - - private companion object { - const val COMMAND = "input keyevent 120" // KEYCODE_SYSRQ - } -} diff --git a/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt b/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt index 8ada74c1..d475fa13 100644 --- a/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt +++ b/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt @@ -1,7 +1,6 @@ package com.joegec.joycon2android.gamepad import com.joegec.joycon2android.model.AndroidKey -import com.joegec.joycon2android.model.AndroidKeyBindings import com.joegec.joycon2android.model.BatteryCharge import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.JoyconButton @@ -15,7 +14,7 @@ import org.junit.Test /** The bit is the Android keycode, so a moved button breaks every emulator config the app writes. */ class ReportMapperTest { - private fun report(vararg pressed: JoyconButton): ByteArray = ReportMapper.buildReport(proPlayer(*pressed)) + private fun report(vararg pressed: JoyconButton): ByteArray = ReportMapper.buildReport(GamepadFrame(proPlayer(*pressed))) private fun proPlayer(vararg pressed: JoyconButton): PlayerState { val input = JoyconInput( @@ -78,24 +77,22 @@ class ReportMapperTest { } @Test - fun `a button bound to an Android key leaves the gamepad, and Home and Back take the system byte`() { - val keys = AndroidKeyBindings() - .bind(AndroidKey.HOME, JoyconButton.Home) - .bind(AndroidKey.BACK, JoyconButton.Chat) - .bind(AndroidKey.SCREENSHOT, JoyconButton.Capture) - val report = ReportMapper.buildReport(proPlayer(JoyconButton.Home, JoyconButton.Chat, JoyconButton.Capture), keys) + fun `the frame's Home and Back take the Consumer byte`() { + val frame = GamepadFrame(proPlayer(), setOf(AndroidKey.HOME, AndroidKey.BACK)) - assertEquals(0b011.toByte(), report[14]) // Screenshot goes through the shell - assertEquals(0.toByte(), report[0]) // Capture, bit 2 - assertEquals(0.toByte(), report[1]) // Home, bit 12 - assertEquals(0.toByte(), report[13]) // C, overflow bit 1 + assertEquals(0b11.toByte(), ReportMapper.buildReport(frame)[14]) } @Test - fun `with nothing bound the system byte stays clear`() { - val report = report(JoyconButton.Home, JoyconButton.Chat) + fun `the Consumer byte has a bit for every key the pad can send`() { + assertEquals(GamepadFrame.SENDABLE_KEYS, ReportMapper.ANDROID_KEY_BITS.keys) + } + + @Test + fun `with no Android keys the Consumer byte stays clear and Home stays a gamepad button`() { + val report = report(JoyconButton.Home) assertEquals(0.toByte(), report[14]) - assertEquals(0b10.toByte(), report[13]) // C, overflow bit 1 + assertEquals(0b10000.toByte(), report[1]) // Home, bit 12 } } diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadFrame.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadFrame.kt new file mode 100644 index 00000000..c387ea14 --- /dev/null +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadFrame.kt @@ -0,0 +1,11 @@ +package com.joegec.joycon2android.gamepad + +import com.joegec.joycon2android.model.AndroidKey +import com.joegec.joycon2android.model.PlayerState + +data class GamepadFrame(val state: PlayerState, val androidKeys: Set = emptySet()) { + companion object { + /** Screenshot goes through the shell instead: docs/virtual-gamepad.md#android-keys */ + val SENDABLE_KEYS = setOf(AndroidKey.HOME, AndroidKey.BACK) + } +} diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadRepository.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadRepository.kt index deb00fb0..91846915 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadRepository.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/GamepadRepository.kt @@ -10,7 +10,7 @@ interface GamepadRepository { fun enable(players: List) fun disable() - fun push(players: List) + fun push(frames: List) fun onPlayerAssigned(player: PlayerNumber) fun onPlayerUnassigned(player: PlayerNumber) } diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/PushGamepadStateUseCase.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/PushGamepadStateUseCase.kt index 06afb8f6..830ac94f 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/PushGamepadStateUseCase.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/PushGamepadStateUseCase.kt @@ -1,7 +1,5 @@ package com.joegec.joycon2android.gamepad -import com.joegec.joycon2android.model.PlayerState - class PushGamepadStateUseCase(private val repository: GamepadRepository) { - operator fun invoke(players: List) = repository.push(players) + operator fun invoke(frames: List) = repository.push(frames) } diff --git a/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/GamepadViewModel.kt b/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/GamepadViewModel.kt index 7630629d..b4b23be8 100644 --- a/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/GamepadViewModel.kt +++ b/feature/gamepad/presentation/src/main/kotlin/com/joegec/joycon2android/gamepad/presentation/GamepadViewModel.kt @@ -27,6 +27,7 @@ class GamepadViewModel( val gamepadEmulators: List = emptyList(), private val configureGamepad: suspend (emulatorId: String, players: List, closeEmulator: Boolean) -> EmulatorSetupResult = { _, _, _ -> EmulatorSetupResult.FAILED }, + private val onEmulatorSelected: (emulatorId: String) -> Unit = {}, ) : ViewModel() { val status: StateFlow = observeGamepadStatus() @@ -38,6 +39,10 @@ class GamepadViewModel( private val _selectedEmulator = MutableStateFlow(gamepadEmulators.firstOrNull()?.id ?: "") val selectedEmulator: StateFlow = _selectedEmulator.asStateFlow() + init { + onEmulatorSelected(_selectedEmulator.value) + } + private val _setupPhase = MutableStateFlow(DolphinSetupPhase.IDLE) val setupPhase: StateFlow = _setupPhase.asStateFlow() @@ -53,6 +58,7 @@ class GamepadViewModel( fun selectEmulator(id: String) { _selectedEmulator.value = id + onEmulatorSelected(id) _emulatorToClose.value = null _emulatorToStart.value = null resetSetupPhase() diff --git a/feature/settings/data/src/main/kotlin/com/joegec/joycon2android/settings/AndroidKeyBindingsDataStore.kt b/feature/settings/data/src/main/kotlin/com/joegec/joycon2android/settings/AndroidKeyBindingsDataStore.kt deleted file mode 100644 index beb6d52b..00000000 --- a/feature/settings/data/src/main/kotlin/com/joegec/joycon2android/settings/AndroidKeyBindingsDataStore.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.joegec.joycon2android.settings - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import com.joegec.joycon2android.model.AndroidKey -import com.joegec.joycon2android.model.AndroidKeyBindings -import com.joegec.joycon2android.model.JoyconButton -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -private val Context.androidKeysDataStore: DataStore by preferencesDataStore(name = "android_keys") - -class AndroidKeyBindingsDataStore(context: Context) : AndroidKeyBindingsRepository { - - private val dataStore = context.applicationContext.androidKeysDataStore - - override val bindings: Flow = dataStore.data.map(::read) - - override suspend fun bind(key: AndroidKey, button: JoyconButton?) { - dataStore.edit { prefs -> - val updated = read(prefs).bind(key, button) - AndroidKey.entries.forEach { prefs.remove(preferenceFor(it)) } - updated.buttons.forEach { (boundKey, boundButton) -> prefs[preferenceFor(boundKey)] = boundButton.id } - } - } - - private fun read(prefs: Preferences) = AndroidKeyBindings( - AndroidKey.entries.mapNotNull { key -> - val id = prefs[preferenceFor(key)] - JoyconButton.entries.firstOrNull { it.id == id }?.let { key to it } - }.toMap(), - ) - - private fun preferenceFor(key: AndroidKey) = stringPreferencesKey(key.name.lowercase()) -} diff --git a/feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/AndroidKeyBindingsRepository.kt b/feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/AndroidKeyBindingsRepository.kt deleted file mode 100644 index a9dcb03b..00000000 --- a/feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/AndroidKeyBindingsRepository.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.joegec.joycon2android.settings - -import com.joegec.joycon2android.model.AndroidKey -import com.joegec.joycon2android.model.AndroidKeyBindings -import com.joegec.joycon2android.model.JoyconButton -import kotlinx.coroutines.flow.Flow - -interface AndroidKeyBindingsRepository { - val bindings: Flow - suspend fun bind(key: AndroidKey, button: JoyconButton?) -} diff --git a/feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/BindAndroidKeyUseCase.kt b/feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/BindAndroidKeyUseCase.kt deleted file mode 100644 index 587671f7..00000000 --- a/feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/BindAndroidKeyUseCase.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.joegec.joycon2android.settings - -import com.joegec.joycon2android.model.AndroidKey -import com.joegec.joycon2android.model.JoyconButton - -class BindAndroidKeyUseCase(private val repository: AndroidKeyBindingsRepository) { - suspend operator fun invoke(key: AndroidKey, button: JoyconButton?) = repository.bind(key, button) -} diff --git a/feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/ObserveAndroidKeyBindingsUseCase.kt b/feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/ObserveAndroidKeyBindingsUseCase.kt deleted file mode 100644 index 7a16378d..00000000 --- a/feature/settings/domain/src/main/kotlin/com/joegec/joycon2android/settings/ObserveAndroidKeyBindingsUseCase.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.joegec.joycon2android.settings - -import com.joegec.joycon2android.model.AndroidKeyBindings -import kotlinx.coroutines.flow.Flow - -class ObserveAndroidKeyBindingsUseCase(private val repository: AndroidKeyBindingsRepository) { - operator fun invoke(): Flow = repository.bindings -} diff --git a/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/AndroidKeysSetting.kt b/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/AndroidKeysSetting.kt deleted file mode 100644 index f7275974..00000000 --- a/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/AndroidKeysSetting.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.joegec.joycon2android.settings.presentation - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import com.joegec.joycon2android.model.AndroidKey -import com.joegec.joycon2android.model.AndroidKeyBindings -import com.joegec.joycon2android.model.JoyconButton -import com.joegec.joycon2android.ui.components.DropdownOption -import com.joegec.joycon2android.ui.components.OptionDropdown -import com.joegec.joycon2android.ui.theme.Dimens -import com.joegec.joycon2android.ui.theme.TextDim - -private const val NONE_ID = "none" - -@Composable -internal fun AndroidKeysSetting( - bindings: AndroidKeyBindings, - onBind: (AndroidKey, JoyconButton?) -> Unit, - modifier: Modifier = Modifier, -) { - Column(modifier, verticalArrangement = Arrangement.spacedBy(Dimens.elementSpacing)) { - Text( - stringResource(R.string.settings_android_keys_title), - color = Color.White, - style = MaterialTheme.typography.titleMedium, - ) - Text( - stringResource(R.string.settings_android_keys_description), - color = TextDim, - style = MaterialTheme.typography.bodySmall, - ) - AndroidKey.entries.forEach { key -> - AndroidKeyRow(key, bindings.buttons[key], onBind = { button -> onBind(key, button) }) - } - } -} - -@Composable -private fun AndroidKeyRow(key: AndroidKey, button: JoyconButton?, onBind: (JoyconButton?) -> Unit) { - val none = stringResource(R.string.settings_android_key_none) - val options = listOf(DropdownOption(NONE_ID, none)) + - AndroidKeyBindings.CHOICES.map { DropdownOption(it.id, it.label) } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), - ) { - Text( - key.label(), - color = Color.White, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - ) - OptionDropdown( - options = options, - selectedId = button?.id ?: NONE_ID, - label = button?.label ?: none, - onSelect = { id -> onBind(AndroidKeyBindings.CHOICES.firstOrNull { it.id == id }) }, - modifier = Modifier.weight(1f), - ) - } -} - -@Composable -private fun AndroidKey.label(): String = when (this) { - AndroidKey.HOME -> stringResource(R.string.settings_android_key_home) - AndroidKey.BACK -> stringResource(R.string.settings_android_key_back) - AndroidKey.SCREENSHOT -> stringResource(R.string.settings_android_key_screenshot) -} diff --git a/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsPanel.kt b/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsPanel.kt index 599463b8..c2d3e619 100644 --- a/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsPanel.kt +++ b/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsPanel.kt @@ -11,9 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import com.joegec.joycon2android.model.AndroidKey import com.joegec.joycon2android.model.ConnectionViewMode -import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.ui.components.SettingSwitch import com.joegec.joycon2android.ui.theme.Dimens @@ -23,7 +21,6 @@ fun SettingsPanel( onViewModeChange: (ConnectionViewMode) -> Unit, onFasterUpdatesToggle: (Boolean) -> Unit, onBlockDeviceMotionToggle: (Boolean) -> Unit, - onBindAndroidKey: (AndroidKey, JoyconButton?) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -59,6 +56,5 @@ fun SettingsPanel( else stringResource(R.string.settings_block_device_motion_needs_shizuku), ) } - SettingsSection { AndroidKeysSetting(state.androidKeys, onBindAndroidKey) } } } diff --git a/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsPanelState.kt b/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsPanelState.kt index 1e0cc30f..9392ef69 100644 --- a/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsPanelState.kt +++ b/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsPanelState.kt @@ -1,6 +1,5 @@ package com.joegec.joycon2android.settings.presentation -import com.joegec.joycon2android.model.AndroidKeyBindings import com.joegec.joycon2android.model.ConnectionViewMode import com.joegec.joycon2android.settings.OutputSettings @@ -8,5 +7,4 @@ data class SettingsPanelState( val viewMode: ConnectionViewMode = ConnectionViewMode.DETAILED, val outputSettings: OutputSettings = OutputSettings(), val deviceMotionBlockAvailable: Boolean = false, - val androidKeys: AndroidKeyBindings = AndroidKeyBindings(), ) diff --git a/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsViewModel.kt b/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsViewModel.kt index 47bef00d..360d3577 100644 --- a/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsViewModel.kt +++ b/feature/settings/presentation/src/main/kotlin/com/joegec/joycon2android/settings/presentation/SettingsViewModel.kt @@ -2,11 +2,6 @@ package com.joegec.joycon2android.settings.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.joegec.joycon2android.model.AndroidKey -import com.joegec.joycon2android.model.AndroidKeyBindings -import com.joegec.joycon2android.model.JoyconButton -import com.joegec.joycon2android.settings.BindAndroidKeyUseCase -import com.joegec.joycon2android.settings.ObserveAndroidKeyBindingsUseCase import com.joegec.joycon2android.settings.ObserveOutputSettingsUseCase import com.joegec.joycon2android.settings.OutputSettings import com.joegec.joycon2android.settings.SetBlockDeviceMotionUseCase @@ -20,16 +15,11 @@ class SettingsViewModel( observeOutputSettings: ObserveOutputSettingsUseCase, private val setFasterUpdates: SetFasterUpdatesUseCase, private val setBlockDeviceMotion: SetBlockDeviceMotionUseCase, - observeAndroidKeyBindings: ObserveAndroidKeyBindingsUseCase, - private val bindAndroidKey: BindAndroidKeyUseCase, ) : ViewModel() { val outputSettings: StateFlow = observeOutputSettings() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), OutputSettings()) - val androidKeys: StateFlow = observeAndroidKeyBindings() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), AndroidKeyBindings()) - fun toggleFasterUpdates(enabled: Boolean) { viewModelScope.launch { setFasterUpdates(enabled) } } @@ -38,10 +28,6 @@ class SettingsViewModel( viewModelScope.launch { setBlockDeviceMotion(enabled) } } - fun assignAndroidKey(key: AndroidKey, button: JoyconButton?) { - viewModelScope.launch { bindAndroidKey(key, button) } - } - private companion object { const val STOP_TIMEOUT_MS = 5_000L } diff --git a/feature/settings/presentation/src/main/res/values/strings.xml b/feature/settings/presentation/src/main/res/values/strings.xml index a706e8bd..98572bfd 100644 --- a/feature/settings/presentation/src/main/res/values/strings.xml +++ b/feature/settings/presentation/src/main/res/values/strings.xml @@ -9,10 +9,4 @@ Ignore this device\'s motion in Eden Eden adds this device\'s gyro to Player 1, which makes controller motion stutter. Lifted when the Virtual Gamepad and DSU are both off. Needs Shizuku running. - Android buttons - Controller buttons that work as Android\'s own, on every player while the Virtual Gamepad is on. A button chosen here no longer reaches games, and Home always leaves the game for the home screen. - Home - Back - Screenshot - None From 3d5787ba64a8606cf0de7edca75f36ecfed1a8f7 Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Mon, 12 Oct 2026 19:11:19 +0100 Subject: [PATCH 3/3] Drop the Android section's subtitle Co-Authored-By: Claude Opus 5.5 --- .../buttonmapping/presentation/MappingBindings.kt | 5 ++--- .../presentation/src/main/res/values/strings.xml | 1 - 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt index a14653ce..165df807 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingBindings.kt @@ -47,9 +47,8 @@ private fun BindingSection( ) { Column(verticalArrangement = Arrangement.spacedBy(Dimens.elementSpacing)) { Text(section.label(), color = Color.White, style = MaterialTheme.typography.titleSmall) - if (section == MappingSection.ANDROID) { - Text(stringResource(R.string.android_keys_description), color = TextDim, style = MaterialTheme.typography.bodySmall) - if (androidKeysNeedShizuku) SettingWarning(stringResource(R.string.android_keys_need_shizuku)) + if (section == MappingSection.ANDROID && androidKeysNeedShizuku) { + SettingWarning(stringResource(R.string.android_keys_need_shizuku)) } val sourceOptions = MappingOptions.sources(state.body.side, section) targets.forEach { (key, label) -> diff --git a/core/buttonmapping/presentation/src/main/res/values/strings.xml b/core/buttonmapping/presentation/src/main/res/values/strings.xml index 6a16dc70..9110e2bb 100644 --- a/core/buttonmapping/presentation/src/main/res/values/strings.xml +++ b/core/buttonmapping/presentation/src/main/res/values/strings.xml @@ -71,6 +71,5 @@ Home Back Screenshot - Work as this device\'s own buttons, and never reach the game. Over DSU, these need Shizuku running.