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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 44 additions & 2 deletions app/src/main/java/com/joegec/joycon2android/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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<PlayerBody>())
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<Console>) =
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)
Expand All @@ -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) },
Expand Down
13 changes: 7 additions & 6 deletions app/src/main/java/com/joegec/joycon2android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -67,6 +66,7 @@ class MainActivity : ComponentActivity() {
c.disableDsu,
dsuEmulators = c.emulatorSetup.dsuEmulators(),
configureDsu = c.emulatorSetup::configureDsu,
onEmulatorSelected = c::selectDsuEmulator,
)
}
}
Expand All @@ -82,6 +82,7 @@ class MainActivity : ComponentActivity() {
c.disableGamepad,
gamepadEmulators = c.emulatorSetup.gamepadEmulators(),
configureGamepad = c.emulatorSetup::configureGamepad,
onEmulatorSelected = c::selectGamepadEmulator,
)
}
}
Expand Down Expand Up @@ -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) }
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PlayerNumber, Set<AndroidKey>>()

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<AndroidKey> =
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<PlayerState>) {
val held = players.associate { it.player to held(it) - sentByPad(it) }
held.forEach { (player, keys) -> (keys - heldThroughShell[player].orEmpty()).forEach(pressKey) }
heldThroughShell = held
}
}
Original file line number Diff line number Diff line change
@@ -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<Boolean>,
private val bindings: StateFlow<Map<PlayerNumber, AndroidKeyBindings>>,
) {
val isEnabled: Boolean get() = enabled.value

fun of(player: PlayerState): AndroidKeyBindings = bindings.value[player.player] ?: AndroidKeyBindings()

fun heldKeys(player: PlayerState): Set<AndroidKey> = if (isEnabled) of(player).heldKeys(player) else emptySet()
}
Original file line number Diff line number Diff line change
@@ -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<GamepadFrame>, val dsu: List<PlayerState>)
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<AndroidKey>()

private val router = AndroidKeyRouter(
gamepad = OutputAndroidKeys(gamepadOn, gamepadBindings),
dsu = OutputAndroidKeys(dsuOn, dsuBindings),
pressKey = { pressed += it },
)

private fun bindings(vararg keys: Pair<AndroidKey, JoyconButton>) =
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<AndroidKey>(), 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<AndroidKey>(), pressed)
}

@Test
fun `an output that is off fires nothing`() {
gamepadOn.value = false

router.route(holding(JoyconButton.Home, JoyconButton.Minus))

assertEquals(emptyList<AndroidKey>(), pressed)
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String>.androidKeyBindings(): AndroidKeyBindings = AndroidKeyBindings(
AndroidKey.entries.mapNotNull { key ->
val buttons = this[key.targetKey].orEmpty().toMappingSources()
.filterIsInstance<MappingSource.Button>()
.mapTo(mutableSetOf()) { it.button }
buttons.takeIf { it.isNotEmpty() }?.let { key to it }
}.toMap(),
)
Loading
Loading