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
45 changes: 37 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,39 @@
- Follow current Android, Kotlin, and Compose conventions
- Boy Scout Rule: leave code better than you found it
- One class per file
- Code should read like well-written prose
- Names should make code read like well-written prose (comments do not — see below)
- Methods should be short enough that they explain themselves
- Methods and composables should be reusable like components

## Comments
- Default to **no comment**: a well-named class or method is its own documentation
- Never write a comment that restates the code, the signature, or what the next line does — if a comment can be made redundant by renaming or extracting, do that instead
- A genuine "why" (a constraint the code cannot express, e.g. "StateFlow conflation requires a synchronous callback") gets one or two lines, never a paragraph
- New classes get **no KDoc by default**; earn it only with a non-obvious "why"
- Comments describing the physical world ARE welcome and can be detailed: BLE protocol details, byte layouts, timing constraints, and hardware behavior being mirrored (e.g. Switch combo/LED conventions) — these cannot be derived from code
- Litmus test before writing any comment: "could a reader reconstruct this from the code alone?" If yes, delete it
- Default to **no comment**. A name is the documentation; if renaming or extracting would make a
comment redundant, do that instead
- A comment says only what the code cannot: a constraint, a unit, a byte's meaning, what a
workaround works around. Litmus test: "could a reader reconstruct this from the code?" If yes, delete it
- **Plain and terse.** State the fact like a spec sheet, not a story: no narrative, aphorism or
flourish. `// Dolphin ORs its inputs, so any bound source fires the target.`
- **One line is the norm, three the ceiling.** Longer means it is explaining the subject rather
than the line — a derivation, a measurement, a byte layout, an emulator's internals, approaches
that failed. That goes in the doc that owns it, with a one-line pointer:
`/** Axes and signs: docs/dsu-motion.md#motion-frame */`
- **No KDoc on classes, interfaces, use cases or properties by default** — remove one that restates
the name when you touch the file. Keep it only for a non-obvious "why"
- **One home per fact.** Never repeat a doc in a comment, or the same comment in sibling files (two
emulator generators, two ViewModels): point at the doc, or put it on the shared type. Copies drift
- Physical-world facts (BLE protocol, byte layouts, timing, measurements, emulator internals) live in
`protocol.md`, `virtual-gamepad.md`, `dsu-motion.md`; UI decisions in `DESIGN.md`; structure in
`architecture.md`
- Tests: say it in the test name. An inline comment only labels a magic value (`// slot`, `// BUTTON_A 96`)

## Docs
- Written for someone about to change the code: what is true now, and why. No changelogs, "was X
until Y" stories or ticked-off to-do lists — git holds history
- Lead with the fact. Tables and short bullets over paragraphs; cut preamble and restatement
- Keep a failed approach only when it stops someone retrying it, in a sentence or two
- Date a measurement and name the hardware it came from; nothing else needs a date
- **Docs are part of the change.** When behaviour a doc or the README describes changes, update it
in the same commit
- `README.md` is for players (setup, troubleshooting); implementation detail goes in `docs/`

## SOLID Principles
- **Single Responsibility:** each class has one reason to change — if you need "Manager" or "Handler" in the name, it's probably doing too much
Expand Down Expand Up @@ -49,7 +72,13 @@

## Conventions
- Use `enableEdgeToEdge()` with `WindowInsets.systemBars` for edge-to-edge inset handling
- Avoid hard-coded strings — use string resources where possible
- **User-facing strings never live in a domain module** (pure JVM, no `R.string`). A domain type
carries its identity — the enum entry, the stored id — and presentation names it through an
exhaustive `when`, so a new case without a word fails to build (`MappingLabels`, `LayoutLabels`)
- Not copy, so they stay in code: **wire tokens** an emulator or protocol must match exactly
(`DolphinControls`, `EdenControls`), and **hardware markings** the controller graphics draw
(`JoyconButton.label` — "ZL", "+", "A")
- Avoid hard-coded strings elsewhere too — use string resources where possible
- Use theme for dimensions and colors rather than inline literals
- Prefer immutable data classes for state
- Use `@SuppressLint("MissingPermission")` only on methods guarded by the permission launcher
Expand Down
51 changes: 16 additions & 35 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,39 +1,25 @@
# Contributing to Joycon2Android

Thanks for your interest in improving Joycon2Android! This guide covers how to get set up, the
standards your change is expected to meet, and how to get it merged.
Thanks for helping improve Joycon2Android! This covers setup, the standards a change must meet, and
how to get it merged.

## Coding standards

**[`CLAUDE.md`](CLAUDE.md) is the project's coding-standards contract, and it applies to everyone —
human or AI.** Read it before writing code. In short, it asks for:

- **Kotlin, Jetpack Compose, Material 3**, targeting Android API 24+.
- **Clean, self-documenting code** — short, well-named methods; one class per file; reusable
composables. Prefer renaming or extracting over adding a comment.
- **Minimal comments** — no KDoc that restates the signature. A comment earns its place only when
it captures a *why* the code can't express. Comments describing hardware/BLE protocol details
(byte layouts, timing, Switch conventions) are welcome and can be detailed.
- **SOLID and clean architecture** — small focused classes, dependencies injected via constructors,
orchestrators that delegate rather than implement.
- **No hard-coded strings** — use string resources. **No inline dimensions/colors** — use the
theme.
- Immutable data classes for state; `@SuppressLint("MissingPermission")` only on permission-guarded
methods.
**[`CLAUDE.md`](CLAUDE.md) is the coding-standards contract for everyone, human or AI.** Read it
before writing code. In short:

## Architecture

This is a **Gradle multi-module** app split by **feature × layer** (`domain` / `data` /
`presentation`), and the module graph *enforces* the dependency rules at compile time. Before
making structural changes:
- **Self-documenting code** — short, well-named methods, one class per file, reusable composables.
- **Minimal, terse comments** — only what the code can't say. Protocol details, measurements and
emulator internals go in [`docs/`](docs/README.md), not in comments.
- **SOLID and clean architecture** — small focused classes, constructor injection, orchestrators that
delegate.
- **No hard-coded strings, dimensions or colours** — string resources and the theme.

- Read [`docs/architecture.md`](docs/architecture.md) — the module graph, layers, and dependency
rules.
- Follow [`docs/adding-a-feature.md`](docs/adding-a-feature.md) when adding or changing a feature —
it has step-by-step recipes and explains the convention plugins in `build-logic/`.
## Architecture

Presentation reaches data **only through use cases** — never bypass it. Every module applies one of
the convention plugins; don't hand-roll `android {}` blocks in a module.
A Gradle multi-module app split by feature × layer, whose module graph enforces the dependency rules.
Read [`docs/architecture.md`](docs/architecture.md) before structural changes, and follow
[`docs/adding-a-feature.md`](docs/adding-a-feature.md) when adding or changing a feature.

## Development setup

Expand All @@ -58,13 +44,8 @@ Run the same checks CI runs, and make sure they pass:
./gradlew build :konsist:test
```

This compiles every module, runs the unit tests, runs Android lint, and runs the **Konsist**
architecture tests that enforce layer placement. If you moved classes between modules, `:konsist:test`
is what catches a misplacement.

- **Add tests** for new domain/data logic where practical — the layering exists so each class is
independently testable.
- **Leave the code better than you found it** (the Boy Scout Rule).
That compiles every module and runs the unit tests, Android lint and the Konsist architecture tests.
Add tests for new domain and data logic, and update any doc your change makes stale.

## Pull requests

Expand Down
51 changes: 47 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,31 @@ the emulator afterwards — it only reads its config when it starts.
| Virtual Gamepad | Eden, Eden Nightly, Dolphin (GameCube) | buttons and sticks |
| DSU Motion Server | Eden, Eden Nightly, Dolphin (Wii) | buttons, sticks and motion |

The gamepad button beside **Set up** opens the mapping editor. A single Joy-Con is set up as a Pro
Controller held sideways, so every button works in every game
([why](docs/virtual-gamepad.md#why-theyre-set-up-as-pro-controllers)).
The gamepad button beside **Set up** opens the mapping editor, with a card per connected player —
tap one to open its bindings. A single Joy-Con is set up as a Pro Controller held sideways, so every
button works in every game ([why](docs/virtual-gamepad.md#why-theyre-set-up-as-pro-controllers)). A
target can take **several sources** — tick as many as you like, and any of them fires it.

- **Layouts.** Each player picks a layout to start from, which resets their changes. The card reads
**Custom** once you change a binding, and the layout's name again if you change it back.
- **Saving.** The save icon beside the name keeps your mapping as a layout for any player holding the
same body. It dims when the mapping already matches a layout. Deleting a layout (the bin in the
dropdown) keeps everyone's buttons; they just read **Custom** until you save again.
- **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.
- **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.

The layouts the app ships:

| Layout | For |
|---|---|
| Wii | The Wii Remote's own arrangement: the trigger under your finger is B, 1 and 2 under the thumb |
| Joy-Con | The same, with the Joy-Con's own B as B, and 1 and 2 on the shoulders so your thumb stays on the stick |
| Mario Kart Wheel | A lone Joy-Con held sideways as a wheel, laid out the way Mario Kart 8 uses one — 2 accelerates, 1 brakes, SR hops and tricks, and SL throws an item alongside the stick. Steers by **tilt**, so it plays as a sideways Wii Remote: the d-pad turns with it and a right Joy-Con aims from its tail |
| Mario Kart Nunchuck | The remote-and-nunchuk scheme, which steers by **stick** instead. A pair splits the halves across the hands, each index finger on the shoulder its controller keeps a trigger on; a lone Joy-Con plays both halves itself, its own stick standing in for the Nunchuk's and its rails carrying C and Z |

> [!NOTE]
> Auto setup needs Shizuku, and some devices block writing into another app's `Android/data` — use
Expand Down Expand Up @@ -155,9 +177,30 @@ shoulder buttons.
| Gyroscope Roll Left / Right | `Gyro Roll Left` / `Right` | `Gyro Pitch Up` / `Down` | `Gyro Pitch Down` / `Up` |
| Gyroscope Yaw Left / Right | `Gyro Yaw Left` / `Right` | same | same |

That restores each Joy-Con's own body, which is what **pointing** wants: the remote's nose is the
shoulder edge, so aim R/ZR (or L/ZL) at the screen. Also set **Total Yaw** to around 60 —
Dolphin's 25 clamps the cursor after ±12.5° of turn, which a hand-held aim overruns.

**For a game written for a sideways Wii Remote** (Mario Kart Wii and its Wii Wheel) — both
**Mario Kart** layouts write these for you:

- Give a **right** Joy-Con the *left* column. Sideways, its top edge points where a sideways
remote's tail does, so without that half turn the wheel steers backwards; a left Joy-Con
already matches. Aiming then moves to the tail on that body.
- **Turn the four D-pad bindings a quarter**: put the source you'd bind to Up on **D-Pad/Right**,
Right on Down, Down on Left, Left on Up. A sideways remote's d-pad turns with it, so the
player's up is the remote's right.
- For **tricks and wheelies**, append
``+ pulse(<flick>, 0.6) * max(sin(timer(0.15) * 6.2832), 0) * 50`` to **IMUAccelerometer/Up**,
where `<flick>` is ``(\`Gyro Pitch Up\` / 9) & not(pulse(\`Gyro Pitch Down\` / 9, 0.4))`` —
then the same on **/Down** with Up and Down swapped
([why](docs/dsu-motion.md#tricks-and-wheelies)). Dolphin's own **Shake** group doesn't land
tricks, but you can bind **Shake** to a button.

5. Under **Swing**, set **Forward** to
``(`Accel Forward` - `Accel Backward`) - smooth((`Accel Forward` - `Accel Backward`), 0.03)``,
**Range** to 7% and **Dead Zone** to 20%. For a single Joy-Con, use `Accel Up` and `Accel Down`.
**Range** to 7% and **Dead Zone** to 20%. For a single Joy-Con use whichever pair its nose reads
above — `Accel Right`/`Left` on a right Joy-Con, and the other way round when it plays sideways.
This lets thrusts reach games like Wii Play Billiards ([why](docs/dsu-motion.md#dolphin-wii-remote-mapping)).
6. **Pair only:** under **Nunchuk → Motion Input**, map each accelerometer entry to the left Joy-Con's
slot, e.g. `` `DSUClient/3/Joycon2:Accel Up` `` — the highest slot no player uses (3 with one
Expand Down
56 changes: 44 additions & 12 deletions app/src/main/java/com/joegec/joycon2android/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,30 @@ 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.buttonmapping.ApplyGlobalLayoutUseCase
import com.joegec.joycon2android.buttonmapping.ApplyMappingLayoutUseCase
import com.joegec.joycon2android.buttonmapping.ControllerMappingDataStore
import com.joegec.joycon2android.buttonmapping.ControllerMappingRepository
import com.joegec.joycon2android.buttonmapping.DeleteCustomLayoutUseCase
import com.joegec.joycon2android.buttonmapping.DeleteGlobalLayoutUseCase
import com.joegec.joycon2android.buttonmapping.GetEffectiveControllerMappingUseCase
import com.joegec.joycon2android.buttonmapping.GetSidewaysRemoteUseCase
import com.joegec.joycon2android.buttonmapping.GlobalLayoutDataStore
import com.joegec.joycon2android.buttonmapping.GlobalLayoutRepository
import com.joegec.joycon2android.buttonmapping.ApplyPlayerMappingUseCase
import com.joegec.joycon2android.buttonmapping.ObserveControllerMappingUseCase
import com.joegec.joycon2android.buttonmapping.ResetControllerMappingUseCase
import com.joegec.joycon2android.buttonmapping.ObserveGlobalMappingUseCase
import com.joegec.joycon2android.buttonmapping.ObserveSavedLayoutsUseCase
import com.joegec.joycon2android.buttonmapping.ObservePlayerMappingUseCase
import com.joegec.joycon2android.buttonmapping.ObserveSidewaysRemoteUseCase
import com.joegec.joycon2android.buttonmapping.SaveCustomLayoutUseCase
import com.joegec.joycon2android.buttonmapping.SaveGlobalLayoutUseCase
import com.joegec.joycon2android.buttonmapping.SavedLayoutDataStore
import com.joegec.joycon2android.buttonmapping.SavedLayoutRepository
import com.joegec.joycon2android.buttonmapping.SetControllerMappingUseCase
import com.joegec.joycon2android.buttonmapping.SetSidewaysRemoteUseCase
import com.joegec.joycon2android.buttonmapping.SidewaysRemoteDataStore
import com.joegec.joycon2android.buttonmapping.SidewaysRemoteRepository
import com.joegec.joycon2android.assignment.AssignmentRepository
import com.joegec.joycon2android.assignment.ComboAssignmentDetector
import com.joegec.joycon2android.assignment.PlayerAssignmentManager
Expand Down Expand Up @@ -71,12 +89,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.map
import java.io.File

/**
* Composition root: owns app-scoped repositories (data) and binds them to use cases
* (domain). Presentation reaches data only through these use cases. Held by
* [JoyconApplication] so the servers/connections outlive any single Activity or the
* foreground service.
*/
/** Composition root: docs/architecture.md#composition-root--appcontainer */
class AppContainer(context: Context) {

private val appContext = context.applicationContext
Expand All @@ -97,10 +110,31 @@ class AppContainer(context: Context) {

// --- Controller button mapping (shared by Gamepad and DSU) ---
private val controllerMappingRepository: ControllerMappingRepository = ControllerMappingDataStore(appContext)
val observeControllerMapping = ObserveControllerMappingUseCase(controllerMappingRepository)
private val savedLayoutRepository: SavedLayoutRepository = SavedLayoutDataStore(appContext)
private val globalLayoutRepository: GlobalLayoutRepository = GlobalLayoutDataStore(appContext)
private val sidewaysRemoteRepository: SidewaysRemoteRepository = SidewaysRemoteDataStore(appContext)

private val observeControllerMapping = ObserveControllerMappingUseCase(controllerMappingRepository)
private val observeSidewaysRemote = ObserveSidewaysRemoteUseCase(sidewaysRemoteRepository)
private val observePlayerMapping =
ObservePlayerMappingUseCase(observeControllerMapping, observeSidewaysRemote, savedLayoutRepository)
private val applyPlayerMapping =
ApplyPlayerMappingUseCase(controllerMappingRepository, sidewaysRemoteRepository)

val observeGlobalMapping = ObserveGlobalMappingUseCase(observePlayerMapping, globalLayoutRepository)
val observeSavedLayouts = ObserveSavedLayoutsUseCase(savedLayoutRepository)
val setControllerMapping = SetControllerMappingUseCase(controllerMappingRepository)
val resetControllerMapping = ResetControllerMappingUseCase(controllerMappingRepository)
val setSidewaysRemote = SetSidewaysRemoteUseCase(sidewaysRemoteRepository)
val applyMappingLayout = ApplyMappingLayoutUseCase(savedLayoutRepository, applyPlayerMapping)
val applyGlobalLayout =
ApplyGlobalLayoutUseCase(globalLayoutRepository, applyMappingLayout, applyPlayerMapping)
val saveCustomLayout = SaveCustomLayoutUseCase(savedLayoutRepository, observePlayerMapping)
val saveGlobalLayout = SaveGlobalLayoutUseCase(globalLayoutRepository, observePlayerMapping)
val deleteCustomLayout = DeleteCustomLayoutUseCase(savedLayoutRepository)
val deleteGlobalLayout = DeleteGlobalLayoutUseCase(globalLayoutRepository)

private val getControllerMapping = GetEffectiveControllerMappingUseCase(observeControllerMapping)
private val getSidewaysRemote = GetSidewaysRemoteUseCase(observeSidewaysRemote)

// --- DSU ---
private val dsuRepository: DsuRepository = DsuServer(scope)
Expand All @@ -115,8 +149,6 @@ class AppContainer(context: Context) {
val setBlockDeviceMotion = SetBlockDeviceMotionUseCase(dsuMotionSettings)

// --- Assignment ---
// Cross-feature orchestration that reacts to assignment (gamepad/DSU lifecycle) lives in
// the SessionCoordinator below.
val assignmentRepository: AssignmentRepository = PlayerAssignmentManager()

// --- Gamepad + privileged access ---
Expand All @@ -140,6 +172,7 @@ class AppContainer(context: Context) {
gamepadDevices = { edenGamepads(appContext) },
gamepadControllerNumbers = { dolphinGamepadIds(appContext) },
getControllerMapping = getControllerMapping,
getSidewaysRemote = getSidewaysRemote,
)

val emulatorLauncher = EmulatorLauncher(appContext)
Expand Down Expand Up @@ -185,7 +218,6 @@ class AppContainer(context: Context) {
val assignController = AssignControllerUseCase(sessionCoordinator)
val unassignController = UnassignControllerUseCase(sessionCoordinator)

/** Cross-feature shutdown: stop every output and clear assignments/connections. */
fun disconnectAll() {
disableGamepad()
disableDsu()
Expand Down
Loading
Loading