diff --git a/README.md b/README.md index ba2493ac..00bcf304 100644 --- a/README.md +++ b/README.md @@ -112,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 1c22642e..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,6 +47,7 @@ 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 @@ -84,10 +92,16 @@ 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 /** Composition root: docs/architecture.md#composition-root--appcontainer */ @@ -180,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) @@ -201,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 98dad2f8..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, ) } } @@ -212,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) } @@ -223,6 +225,7 @@ class MainActivity : ComponentActivity() { actions = mappingActions, onBack = onBack, onInfoClick = if (route.fromDsu) ({ showDsuHelp = true }) else null, + androidKeysNeedShizuku = route.fromDsu && !shizukuAvailable, ) } if (showDsuHelp) { @@ -344,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..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 @@ -4,22 +4,54 @@ 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 && 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..9110e2bb 100644 --- a/core/buttonmapping/presentation/src/main/res/values/strings.xml +++ b/core/buttonmapping/presentation/src/main/res/values/strings.xml @@ -64,4 +64,12 @@ D-Pad Right None %1$s %2$s + Buttons + Sticks + Motion + Android + Home + Back + Screenshot + 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/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..317f1274 --- /dev/null +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/AndroidKeyBindings.kt @@ -0,0 +1,17 @@ +package com.joegec.joycon2android.model + +/** Bound by the buttons the controller reports, before any sideways rotation. */ +data class AndroidKeyBindings(val buttons: Map> = emptyMap()) { + + private val boundIds: Set = buttons.values.flatten().mapTo(mutableSetOf()) { it.id } + + fun heldKeys(state: PlayerState): Set = + buttons.filterValues { bound -> bound.any { it.id in state.pressed } }.keys + + 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 new file mode 100644 index 00000000..fa0bdc7b --- /dev/null +++ b/core/model/src/test/kotlin/com/joegec/joycon2android/model/AndroidKeyBindingsTest.kt @@ -0,0 +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( + 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 `any bound button holds its key`() { + assertEquals(setOf(AndroidKey.BACK), homeAndBack.heldKeys(pair(JoyconButton.Minus, JoyconButton.A))) + } + + @Test + 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.A.id), withheld.left!!.input.pressed) + assertEquals(setOf(JoyconButton.A.id), withheld.right!!.input.pressed) + } + + @Test + fun `nothing bound leaves the state untouched`() { + val state = pair(JoyconButton.Home) + + assertEquals(emptySet(), AndroidKeyBindings().heldKeys(state)) + assertSame(state, AndroidKeyBindings().withheldFrom(state)) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index bda78856..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. @@ -154,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 3ed21eac..c99ab409 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,31 @@ shift: (right). Eden's config uses numeric keycodes; Dolphin's uses names (`Button L2` = ZL, `Select` = −, …). +## Android keys + +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 | Virtual gamepad | DSU only | +|---|---|---| +| 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. + ## Sideways Joy-Cons ### Why they're set up as Pro Controllers 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 5de5c113..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 @@ -4,7 +4,6 @@ import com.joegec.joycon2android.gamepad.privileged.PrivilegedShell import android.content.Context import android.util.Log import com.joegec.joycon2android.model.PlayerNumber -import com.joegec.joycon2android.model.PlayerState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -34,14 +33,11 @@ class GamepadManager(private val scope: CoroutineScope, private val context: Con 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) { - stateFlow.collect { state -> - val report = ReportMapper.buildReport(state) - device.sendReport(report) - } + 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 03048e7a..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,11 +1,11 @@ package com.joegec.joycon2android.gamepad +import com.joegec.joycon2android.model.AndroidKey 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,10 +32,16 @@ object ReportMapper { JoyconButton.Chat.id to 1, ) + // 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): ByteArray { - val gamepad = state.gamepad + fun buildReport(frame: GamepadFrame): ByteArray { + val gamepad = frame.state.gamepad val report = ByteArray(REPORT_SIZE) val pressed = gamepad.pressed @@ -63,6 +69,11 @@ object ReportMapper { report[13] = bits(pressed, OVERFLOW_MAP).toByte() + report[14] = frame.androidKeys + .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/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 8a218ab9..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,5 +1,6 @@ package com.joegec.joycon2android.gamepad +import com.joegec.joycon2android.model.AndroidKey import com.joegec.joycon2android.model.BatteryCharge import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.JoyconButton @@ -13,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(GamepadFrame(proPlayer(*pressed))) + + private fun proPlayer(vararg pressed: JoyconButton): PlayerState { val input = JoyconInput( packetId = 0, buttons = 0, @@ -27,7 +30,7 @@ class ReportMapperTest { battery = BatteryCharge(100), ) 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 { @@ -72,4 +75,24 @@ class ReportMapperTest { assertEquals(0xFF.toByte(), report(JoyconButton.ZR)[12]) assertEquals(0x00.toByte(), report(JoyconButton.ZR)[11]) } + + @Test + fun `the frame's Home and Back take the Consumer byte`() { + val frame = GamepadFrame(proPlayer(), setOf(AndroidKey.HOME, AndroidKey.BACK)) + + assertEquals(0b11.toByte(), ReportMapper.buildReport(frame)[14]) + } + + @Test + 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(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()