From 7563dd10bee33b15354df8df61052b997c3b3d89 Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Tue, 25 Aug 2026 07:23:40 -0400 Subject: [PATCH 01/20] feat: Moonlight-host (Sunshine/Apollo/Wolf) client support Add a second connection path so each dish can speak the Moonlight (GameStream) protocol directly to any Moonlight-compatible host, alongside the existing Satellite protocol-1 path. Scope: discovery, PIN pairing, app launch, RTSP stream setup, controller input out, and rumble/trigger/motion/ LED events back. No video/audio decoding: streams are negotiated at the lowest settings and their payloads discarded. Pure-Kotlin protocol library (core/net/moonlight): - MoonlightCrypto: pairing-key derivation, AES-ECB challenge exchange, and AES-GCM control sealing, pinned byte-for-byte to Wolf's captured vectors. - MoonlightInputEncoder + MoonlightHotSealer: the hot path encodes CONTROLLER_MULTI into a reused ByteBuffer at fixed offsets and seals it with a reused Cipher and one monotonic control seq, no per-packet allocation. - MoonlightEventDecoder: rumble/trigger/motion/LED decode with malformed and short-buffer handling. - MoonlightControlPacket, MoonlightRtsp, MoonlightPairing, MoonlightXml, MoonlightUrls: framing, RTSP codec, 5-phase client pairing, response parsing. - enet/: a minimal pure-Kotlin ENet client subset (connect handshake, reliable send/receive on channel 0, acks, ping, disconnect) ported from the MIT-licensed cgutman/enet C source. Runtime + integration (source/connection/moonlight): keystore-backed identity, mutual-TLS + TOFU gateway, mDNS (_nvstream._tcp) discovery, UDP control transport, per-host connection and orchestration manager. Surfaced as a ConnectionKind.MOONLIGHT sibling through the connections composer, coordinator, capability profile, and the on-screen controller overlay send path. 66 new unit tests cover the crypto vectors, the byte-exact encoders/decoders, the ENet client, RTSP, pairing round-trip, and the session lifecycle. Wolf and cgutman/enet attributed in THIRD_PARTY.md. --- THIRD_PARTY.md | 52 +++ .../dish/composer/ConnectionCoordinator.kt | 13 +- .../dish/composer/ConnectionsComposer.kt | 84 ++++- .../dish/composer/TransportProfiles.kt | 4 + .../tinkernorth/dish/core/net/NetworkUtils.kt | 12 + .../net/moonlight/MoonlightControlPacket.kt | 69 ++++ .../net/moonlight/MoonlightControlProtocol.kt | 89 +++++ .../net/moonlight/MoonlightControlSession.kt | 182 +++++++++ .../core/net/moonlight/MoonlightCrypto.kt | 170 +++++++++ .../net/moonlight/MoonlightEventDecoder.kt | 120 ++++++ .../core/net/moonlight/MoonlightHostModels.kt | 77 ++++ .../core/net/moonlight/MoonlightHotSealer.kt | 130 +++++++ .../core/net/moonlight/MoonlightIdentity.kt | 65 ++++ .../net/moonlight/MoonlightInputEncoder.kt | 204 +++++++++++ .../core/net/moonlight/MoonlightPairing.kt | 139 +++++++ .../dish/core/net/moonlight/MoonlightRtsp.kt | 174 +++++++++ .../dish/core/net/moonlight/MoonlightUrls.kt | 103 ++++++ .../dish/core/net/moonlight/MoonlightXml.kt | 123 +++++++ .../core/net/moonlight/enet/EnetClient.kt | 346 ++++++++++++++++++ .../core/net/moonlight/enet/EnetProtocol.kt | 141 +++++++ .../java/com/tinkernorth/dish/di/AppModule.kt | 8 + .../input/PhysicalSlotBindingObserver.kt | 5 + .../RememberedMoonlightRepository.kt | 93 +++++ .../moonlight/MdnsMoonlightDiscovery.kt | 166 +++++++++ .../moonlight/MoonlightConnection.kt | 135 +++++++ .../moonlight/MoonlightConnectionManager.kt | 328 +++++++++++++++++ .../moonlight/MoonlightHttpGateway.kt | 168 +++++++++ .../moonlight/MoonlightIdentityProvider.kt | 93 +++++ .../moonlight/MoonlightRtspClient.kt | 120 ++++++ .../moonlight/UdpControlTransport.kt | 54 +++ .../dish/ui/common/ConnectionGlyphs.kt | 3 + .../dish/ui/main/ControllerAdapter.kt | 5 + .../dish/ui/main/GamepadOverlayActivity.kt | 18 + app/src/main/res/values-bs/strings.xml | 2 + app/src/main/res/values-de/strings.xml | 2 + app/src/main/res/values-es/strings.xml | 2 + app/src/main/res/values-fr/strings.xml | 2 + app/src/main/res/values-pt-rBR/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../composer/ConnectionCoordinatorTest.kt | 8 + .../dish/composer/MoonlightLinkStateTest.kt | 23 ++ .../moonlight/MoonlightControlPacketTest.kt | 68 ++++ .../moonlight/MoonlightControlSessionTest.kt | 150 ++++++++ .../core/net/moonlight/MoonlightCryptoTest.kt | 86 +++++ .../moonlight/MoonlightEventDecoderTest.kt | 101 +++++ .../net/moonlight/MoonlightHostModelsTest.kt | 45 +++ .../net/moonlight/MoonlightHotSealerTest.kt | 43 +++ .../moonlight/MoonlightInputEncoderTest.kt | 121 ++++++ .../net/moonlight/MoonlightPairingTest.kt | 151 ++++++++ .../core/net/moonlight/MoonlightRtspTest.kt | 74 ++++ .../core/net/moonlight/MoonlightUrlsTest.kt | 35 ++ .../core/net/moonlight/MoonlightXmlTest.kt | 84 +++++ .../core/net/moonlight/enet/EnetClientTest.kt | 207 +++++++++++ .../moonlight/MdnsMoonlightDiscoveryTest.kt | 35 ++ .../test/resources/moonlight/client_cert.pem | 18 + .../test/resources/moonlight/client_key.pem | 28 ++ .../test/resources/moonlight/server_cert.pem | 18 + .../test/resources/moonlight/server_key.pem | 28 ++ 58 files changed, 4818 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlPacket.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlProtocol.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlSession.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightCrypto.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightEventDecoder.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHostModels.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHotSealer.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightIdentity.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightInputEncoder.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightPairing.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightRtsp.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightUrls.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightXml.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/core/net/moonlight/enet/EnetClient.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/core/net/moonlight/enet/EnetProtocol.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/repository/RememberedMoonlightRepository.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MdnsMoonlightDiscovery.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnection.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightIdentityProvider.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightRtspClient.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/UdpControlTransport.kt create mode 100644 app/src/test/java/com/tinkernorth/dish/composer/MoonlightLinkStateTest.kt create mode 100644 app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlPacketTest.kt create mode 100644 app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlSessionTest.kt create mode 100644 app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightCryptoTest.kt create mode 100644 app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightEventDecoderTest.kt create mode 100644 app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHostModelsTest.kt create mode 100644 app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHotSealerTest.kt create mode 100644 app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightInputEncoderTest.kt create mode 100644 app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightPairingTest.kt create mode 100644 app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightRtspTest.kt create mode 100644 app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightUrlsTest.kt create mode 100644 app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightXmlTest.kt create mode 100644 app/src/test/java/com/tinkernorth/dish/core/net/moonlight/enet/EnetClientTest.kt create mode 100644 app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MdnsMoonlightDiscoveryTest.kt create mode 100644 app/src/test/resources/moonlight/client_cert.pem create mode 100644 app/src/test/resources/moonlight/client_key.pem create mode 100644 app/src/test/resources/moonlight/server_cert.pem create mode 100644 app/src/test/resources/moonlight/server_key.pem diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index 1cb3c7a5..25c445e4 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -70,3 +70,55 @@ project's parsers: The Linux kernel is licensed GPL-2.0-only. Upstream: https://github.com/torvalds/linux + +## Wolf (Games on Whales): Moonlight protocol facts + +The Moonlight (GameStream) client path in `app/src/main/java/com/tinkernorth/dish/core/net/moonlight/` +and `app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/` derives its wire +formats, struct layouts, pairing crypto steps, control-stream packet framing, and RTSP handshake +from Wolf's documentation and its host-side (server) implementation: + +- Control packet framing and AES-GCM sealing, plus the input/event struct layouts, follow + `src/moonlight-protocol/moonlight/control.hpp` and `docs/.../control-specs.adoc` / + `input-data.adoc`. The unit tests pin our encoder and sealer byte-for-byte against Wolf's + captured vectors in `tests/testControl.cpp` and `tests/testCrypto.cpp`. +- The 5-phase PIN pairing crypto (AES key derivation, ECB challenge exchange, SHA-256 hashes, + RSA signatures) mirrors Wolf's server logic in `src/moonlight-protocol/moonlight.cpp` and + `src/moonlight-server/rest/endpoints.hpp`, implemented as the client counterpart. +- The RTSP request/response shapes follow `docs/.../rtsp.adoc` and + `src/moonlight-protocol/rtsp/parser.hpp`. + +No Wolf code is compiled into this project; only protocol facts and struct layouts were reused +and reimplemented in Kotlin. Wolf is licensed MIT. Upstream: +https://github.com/games-on-whales/wolf + +## cgutman/enet: ENet client subset (ported to Kotlin) + +`app/src/main/java/com/tinkernorth/dish/core/net/moonlight/enet/` is a minimal pure-Kotlin port +of the ENet reliable-UDP client subset the Moonlight control stream needs (the connect +handshake, reliable send/receive on one channel, acknowledgements, ping and disconnect). It was +ported from the MIT-licensed C source of the cgutman/enet fork (the fork and commit Wolf pins, +`44c85e16279553d9c052e572bcbfcd745fb74abf`): `host.c`, `peer.c`, `protocol.c`, and +`include/enet/protocol.h`. Only the needed subset is reproduced; fragmentation, unsequenced +delivery, throttling, bandwidth and compression commands are not. ENet is licensed MIT. + +``` +Copyright (c) 2002-2020 Lee Salzman + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` + +Upstream: https://github.com/cgutman/enet diff --git a/app/src/main/java/com/tinkernorth/dish/composer/ConnectionCoordinator.kt b/app/src/main/java/com/tinkernorth/dish/composer/ConnectionCoordinator.kt index b87f0441..b33d0bb3 100644 --- a/app/src/main/java/com/tinkernorth/dish/composer/ConnectionCoordinator.kt +++ b/app/src/main/java/com/tinkernorth/dish/composer/ConnectionCoordinator.kt @@ -16,7 +16,7 @@ import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject import javax.inject.Singleton -enum class ConnectionKind { SATELLITE, BLUETOOTH } +enum class ConnectionKind { SATELLITE, BLUETOOTH, MOONLIGHT } enum class LinkState { Found, Stale, Saved, Ready, Connecting, Connected, Unstable } @@ -40,10 +40,12 @@ data class ConnectionSummary( @Singleton class ConnectionCoordinator + @Suppress("LongParameterList") // hub over every connection source; the extra Moonlight manager is one more sibling @Inject constructor( private val satellite: SatelliteConnectionManager, private val bt: BluetoothGamepadRegistry, + private val moonlight: com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionManager, private val store: ConnectionStore, private val bindingStore: SlotBindingStore, private val typeStore: ControllerTypeStore, @@ -117,10 +119,11 @@ class ConnectionCoordinator typeStore.clearConnection(connectionId) hostFeaturesStore.clearConnection(connectionId) hostRuntimeStore.clearConnection(connectionId) - if (store.rememberedBt().any { it.id == connectionId }) { - store.forgetBt(connectionId) - } else { - satellite.forget(connectionId) + when { + connectionId.startsWith(com.tinkernorth.dish.core.net.moonlight.MoonlightHost.ID_PREFIX) -> + moonlight.forget(connectionId) + store.rememberedBt().any { it.id == connectionId } -> store.forgetBt(connectionId) + else -> satellite.forget(connectionId) } } diff --git a/app/src/main/java/com/tinkernorth/dish/composer/ConnectionsComposer.kt b/app/src/main/java/com/tinkernorth/dish/composer/ConnectionsComposer.kt index 6e1120e5..86e996c4 100644 --- a/app/src/main/java/com/tinkernorth/dish/composer/ConnectionsComposer.kt +++ b/app/src/main/java/com/tinkernorth/dish/composer/ConnectionsComposer.kt @@ -6,6 +6,8 @@ import android.content.Context import com.tinkernorth.dish.R import com.tinkernorth.dish.architecture.abstracts.AbstractComposer import com.tinkernorth.dish.core.model.DiscoveredServer +import com.tinkernorth.dish.core.net.moonlight.MoonlightHost +import com.tinkernorth.dish.core.net.moonlight.RememberedMoonlight import com.tinkernorth.dish.repository.ConnectionStore import com.tinkernorth.dish.repository.RememberedBt import com.tinkernorth.dish.repository.RememberedSatellite @@ -13,6 +15,9 @@ import com.tinkernorth.dish.source.bluetooth.BluetoothGamepadRegistry import com.tinkernorth.dish.source.connection.SatelliteConnection import com.tinkernorth.dish.source.connection.SatelliteConnectionManager import com.tinkernorth.dish.source.connection.SatelliteSessionState +import com.tinkernorth.dish.source.connection.moonlight.MoonlightConnection +import com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionManager +import com.tinkernorth.dish.source.connection.moonlight.MoonlightSessionState import com.tinkernorth.dish.source.store.ControllerTypeStore import com.tinkernorth.dish.source.store.SlotBindingStore import dagger.hilt.android.qualifiers.ApplicationContext @@ -27,7 +32,7 @@ import javax.inject.Inject import javax.inject.Singleton @Suppress("UNCHECKED_CAST", "LongParameterList") -private inline fun combine7( +private inline fun combine8( f1: Flow, f2: Flow, f3: Flow, @@ -35,9 +40,10 @@ private inline fun combine7( f5: Flow, f6: Flow, f7: Flow, - crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R, + f8: Flow, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R, ): Flow = - combine(f1, f2, f3, f4, f5, f6, f7) { args -> + combine(f1, f2, f3, f4, f5, f6, f7, f8) { args -> transform( args[0] as T1, args[1] as T2, @@ -46,6 +52,7 @@ private inline fun combine7( args[4] as T5, args[5] as T6, args[6] as T7, + args[7] as T8, ) } @@ -81,6 +88,7 @@ class ConnectionsComposer @ApplicationContext private val context: Context, private val satellite: SatelliteConnectionManager, private val bt: BluetoothGamepadRegistry, + private val moonlight: MoonlightConnectionManager, private val store: ConnectionStore, private val bindingStore: SlotBindingStore, private val typeStore: ControllerTypeStore, @@ -97,6 +105,20 @@ class ConnectionsComposer } } + // The Moonlight world folded into one flow (live sessions + discovered + remembered) so it can + // ride the combine as a single source, mirroring how the satellite side is assembled. + @OptIn(ExperimentalCoroutinesApi::class) + private val moonlightWorld: Flow = + moonlight.connections.flatMapLatest { connMap -> + val stateTrigger: Flow = + if (connMap.isEmpty()) flowOf(Unit) else combine(connMap.values.map { it.state }) { } + combine( + stateTrigger, + moonlight.discovered, + moonlight.remembered, + ) { _, discovered, remembered -> MoonlightWorld(connMap, discovered, remembered) } + } + // The persisted "known" universe, folded into the combine so a remember/forget re-derives the // list instead of an out-of-band store read that could leave a ghost or a missing row. private val knownSatellites: Flow = @@ -109,7 +131,7 @@ class ConnectionsComposer } override fun upstream(): Flow> = - combine7( + combine8( flatSatConnections, bt.states, knownSatellites, @@ -117,7 +139,8 @@ class ConnectionsComposer typeStore.state, satellite.staleSatelliteIds, bt.staleBtIds, - ) { satMap, btStates, known, bindings, satTypes, staleSat, staleBt -> + moonlightWorld, + ) { satMap, btStates, known, bindings, satTypes, staleSat, staleBt, moonlight -> buildSummaries( satMap = satMap, btStates = btStates, @@ -128,12 +151,15 @@ class ConnectionsComposer satTypes = satTypes, staleSatIds = staleSat, staleBtIds = staleBt.keys, + moonlight = moonlight, + moonlightTypes = satTypes, ) }.distinctUntilChanged() private fun discoveredIdSet(discovered: List): Set = discovered.mapTo(mutableSetOf()) { SatelliteConnection.idFor(it) } + @Suppress("LongParameterList") private fun buildSummaries( satMap: Map, btStates: Map, @@ -144,6 +170,8 @@ class ConnectionsComposer satTypes: Map, Int>, staleSatIds: Set = emptySet(), staleBtIds: Set = emptySet(), + moonlight: MoonlightWorld = MoonlightWorld(emptyMap(), emptyList(), emptyList()), + moonlightTypes: Map, Int> = emptyMap(), ): List { val result = mutableListOf() @@ -161,6 +189,8 @@ class ConnectionsComposer )?.let(result::add) } + result += buildMoonlightSummaries(moonlight, bindings, moonlightTypes) + val rememberedBtIds = mutableSetOf() for (entry in rememberedBt) { rememberedBtIds += entry.id @@ -271,4 +301,48 @@ class ConnectionsComposer } return out } + + // Remembered hosts first, then discovered hosts not already remembered under their id. + private fun buildMoonlightSummaries( + world: MoonlightWorld, + bindings: Map, + types: Map, Int>, + ): List { + val rememberedById = world.remembered.associateBy { it.id } + val discoveredById = world.discovered.associateBy { it.id } + val ids = (rememberedById.keys + discoveredById.keys + world.connections.keys).toSet() + return ids.map { id -> + val conn = world.connections[id] + val host = conn?.host?.value ?: rememberedById[id]?.toHost() ?: discoveredById.getValue(id) + val live = moonlightLinkState(conn?.state?.value, discovered = id in discoveredById) + val bound = bindings.entries.filter { it.value == id }.map { it.key } + ConnectionSummary( + id = id, + kind = ConnectionKind.MOONLIGHT, + label = host.name.ifEmpty { host.address }, + detail = context.getString(R.string.moonlight_row_detail, host.address), + live = live, + boundSlotIds = bound, + satelliteControllerTypes = buildSlotTypes(id, bound, types), + ) + } + } + } + +// Flattened Moonlight state: live sessions, discovered hosts, remembered hosts. +internal data class MoonlightWorld( + val connections: Map, + val discovered: List, + val remembered: List, +) + +// Maps the Moonlight session FSM to the shared UI LinkState (pulled out for testability). +internal fun moonlightLinkState( + state: MoonlightSessionState?, + discovered: Boolean, +): LinkState = + when (state) { + MoonlightSessionState.Live -> LinkState.Connected + MoonlightSessionState.Launching -> LinkState.Connecting + MoonlightSessionState.Idle, null -> if (discovered) LinkState.Ready else LinkState.Saved } diff --git a/app/src/main/java/com/tinkernorth/dish/composer/TransportProfiles.kt b/app/src/main/java/com/tinkernorth/dish/composer/TransportProfiles.kt index 918ebb1f..01465087 100644 --- a/app/src/main/java/com/tinkernorth/dish/composer/TransportProfiles.kt +++ b/app/src/main/java/com/tinkernorth/dish/composer/TransportProfiles.kt @@ -11,5 +11,9 @@ object TransportProfiles { ConnectionKind.SATELLITE -> CapabilitySet(Feature.entries.toSet()) // The phone advertises a fixed HID gamepad with no return channel, so nothing else crosses. ConnectionKind.BLUETOOTH -> CapabilitySet.of(Feature.GAMEPAD, Feature.ANALOG_TRIGGERS) + // Moonlight carries controller input out and rumble/trigger/motion/LED events back, but + // not the satellite's touchpad/mouse/keyboard host-injection surface. + ConnectionKind.MOONLIGHT -> + CapabilitySet.of(Feature.GAMEPAD, Feature.ANALOG_TRIGGERS, Feature.MOTION, Feature.RUMBLE) } } diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/NetworkUtils.kt b/app/src/main/java/com/tinkernorth/dish/core/net/NetworkUtils.kt index db82f942..17d76134 100644 --- a/app/src/main/java/com/tinkernorth/dish/core/net/NetworkUtils.kt +++ b/app/src/main/java/com/tinkernorth/dish/core/net/NetworkUtils.kt @@ -39,6 +39,18 @@ fun hexToBytes(hex: String): ByteArray { internal fun Char.isHexDigit(): Boolean = this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F' +private val HEX_DIGITS = "0123456789abcdef".toCharArray() + +fun bytesToHex(bytes: ByteArray): String { + val out = CharArray(bytes.size * 2) + for (i in bytes.indices) { + val v = bytes[i].toInt() and 0xFF + out[i * 2] = HEX_DIGITS[v ushr 4] + out[i * 2 + 1] = HEX_DIGITS[v and 0x0F] + } + return String(out) +} + fun parseServers(jsonString: String): List = try { json diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlPacket.kt b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlPacket.kt new file mode 100644 index 00000000..52f44a06 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlPacket.kt @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * The encrypted control-stream packet framing (Wolf control-specs.adoc / + * control.hpp ControlEncryptedPacket). All little-endian: + * + * [type u16 = 0x0001][len u16][seq u32][GCM tag 16B][ciphertext] + * + * `len` = seq(4) + tag(16) + ciphertext. `seq` is monotonically increasing and + * seeds the AES-GCM IV. The seal is pinned against Wolf's captured session + * vectors in the unit tests, so any drift is a real interop break. + */ +class MoonlightControlPacket( + private val gcmKey: ByteArray, +) { + private var sendSeq = 0 + + /** Seal [plaintext] into a full encrypted control packet; advances the seq. */ + fun seal(plaintext: ByteArray): ByteArray { + val seq = sendSeq + sendSeq += 1 + return sealWithSeq(seq, plaintext) + } + + fun sealWithSeq( + seq: Int, + plaintext: ByteArray, + ): ByteArray { + val tagThenCt = MoonlightCrypto.controlSeal(gcmKey, seq, plaintext) + val len = SEQ_LEN + tagThenCt.size + val buf = ByteBuffer.allocate(HEADER_LEN + len).order(ByteOrder.LITTLE_ENDIAN) + buf.putShort(MoonlightControlProtocol.PACKET_TYPE_ENCRYPTED.toShort()) + buf.putShort(len.toShort()) + buf.putInt(seq) + buf.put(tagThenCt) + return buf.array() + } + + /** + * Open a received encrypted control packet, returning the decrypted + * plaintext. Returns null on a short/malformed frame and throws + * [javax.crypto.AEADBadTagException] (via [MoonlightCrypto.controlOpen]) on a + * tampered payload, so a forged packet is dropped, never acted on. + */ + fun open(packet: ByteArray): ByteArray? { + if (packet.size < HEADER_LEN + SEQ_LEN + MoonlightCrypto.GCM_TAG_LEN) return null + val buf = ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN) + val type = buf.short.toInt() and 0xFFFF + if (type != MoonlightControlProtocol.PACKET_TYPE_ENCRYPTED) return null + val len = buf.short.toInt() and 0xFFFF + if (len < SEQ_LEN + MoonlightCrypto.GCM_TAG_LEN) return null + if (buf.remaining() < len) return null + val seq = buf.int + val tagThenCt = ByteArray(len - SEQ_LEN) + buf.get(tagThenCt) + return MoonlightCrypto.controlOpen(gcmKey, seq, tagThenCt) + } + + companion object { + private const val HEADER_LEN = 4 + private const val SEQ_LEN = 4 + } +} diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlProtocol.kt b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlProtocol.kt new file mode 100644 index 00000000..f6c18f53 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlProtocol.kt @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +/** + * Wire constants for the Moonlight control stream (Wolf + * protocols/control-specs.adoc and protocols/input-data.adoc, cross-checked + * against Wolf src/moonlight-protocol/moonlight/control.hpp). All values are + * protocol constants and never localized. + */ +object MoonlightControlProtocol { + // Encrypted control packet header type (control-specs.adoc): fixed 0x0001. + const val PACKET_TYPE_ENCRYPTED = 0x0001 + + // Decrypted control-message types (the first u16 LE of the plaintext). + const val CTRL_TERMINATION = 0x0100 + const val CTRL_PERIODIC_PING = 0x0200 + const val CTRL_INPUT_DATA = 0x0206 + + // Host -> client events carried inside the control stream. + const val EVENT_RUMBLE_DATA = 0x010B + const val EVENT_RUMBLE_TRIGGERS = 0x5500 + const val EVENT_MOTION = 0x5501 + const val EVENT_RGB_LED = 0x5502 + + // INPUT_DATA sub-types (input-data.adoc). The wrapper's input-type field is + // little-endian; these are the host-order values. + const val INPUT_MOUSE_MOVE_REL = 0x00000007 + const val INPUT_CONTROLLER_MULTI = 0x0000000C + const val INPUT_CONTROLLER_ARRIVAL = 0x55000004 + + // Graceful termination reason (Wolf control.hpp TERMINATE_REASON_GRACEFULL, + // big-endian on the wire). + const val TERMINATE_REASON_GRACEFUL = 0x80030023.toInt() + + // Controller-type values for CONTROLLER_ARRIVAL (the emulated-device pick). + const val CONTROLLER_TYPE_UNKNOWN = 0x00 + const val CONTROLLER_TYPE_XBOX = 0x01 + const val CONTROLLER_TYPE_PS = 0x02 + const val CONTROLLER_TYPE_NINTENDO = 0x03 + + // CONTROLLER_ARRIVAL capability bits. + const val CAP_ANALOG_TRIGGERS = 0x01 + const val CAP_RUMBLE = 0x02 + const val CAP_TRIGGER_RUMBLE = 0x04 + const val CAP_TOUCHPAD = 0x08 + const val CAP_ACCELEROMETER = 0x10 + const val CAP_GYRO = 0x20 + const val CAP_BATTERY = 0x40 + const val CAP_RGB_LED = 0x80 + + // CONTROLLER_MULTI button flags (input-data.adoc). effective = flags | (flags2 << 16). + const val BTN_DPAD_UP = 0x0001 + const val BTN_DPAD_DOWN = 0x0002 + const val BTN_DPAD_LEFT = 0x0004 + const val BTN_DPAD_RIGHT = 0x0008 + const val BTN_START = 0x0010 + const val BTN_BACK = 0x0020 + const val BTN_LEFT_STICK = 0x0040 + const val BTN_RIGHT_STICK = 0x0080 + const val BTN_LEFT_BUTTON = 0x0100 + const val BTN_RIGHT_BUTTON = 0x0200 + const val BTN_HOME = 0x0400 + const val BTN_A = 0x1000 + const val BTN_B = 0x2000 + const val BTN_X = 0x4000 + const val BTN_Y = 0x8000 + const val BTN_PADDLE1 = 0x010000 + const val BTN_PADDLE2 = 0x020000 + const val BTN_PADDLE3 = 0x040000 + const val BTN_PADDLE4 = 0x080000 + const val BTN_TOUCHPAD = 0x100000 + const val BTN_MISC = 0x200000 + + // Fixed framing constants moonlight-common-c stamps into the CONTROLLER_MULTI + // packet, confirmed byte-for-byte against Wolf's input-data.adoc network + // fixture and testControl.cpp. They are not payload; the host ignores their + // meaning but expects these exact values. + const val MULTI_HEADER_B = 0x001A + const val MULTI_MID_B = 0x0014 + const val MULTI_TAIL_A = 0x009C + const val MULTI_TAIL_B = 0x0055 + + // Motion event type (control-specs.adoc): the host asks the client to START + // sending this stream at the given rate. + const val MOTION_TYPE_ACCEL = 0x01 + const val MOTION_TYPE_GYRO = 0x02 +} diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlSession.kt b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlSession.kt new file mode 100644 index 00000000..02004047 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlSession.kt @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import com.tinkernorth.dish.core.net.moonlight.enet.EnetClient + +/** + * Drives the Moonlight control stream: the ENet connect handshake, the reliable + * CONTROLLER_MULTI / ping / termination sends, and the inbound rumble / trigger + * / motion / LED events. Composes the pure pieces ([EnetClient], + * [MoonlightHotSealer], [MoonlightControlPacket], [MoonlightEventDecoder]) over + * a swappable [Transport] so the whole lifecycle unit-tests with a fake + * transport and a controllable clock; production plugs in a UDP socket. + * + * The hot path ([sendControllerState]) reuses the sealer's buffers and only the + * ENet framing allocates. + */ +class MoonlightControlSession( + rikey: ByteArray, + private val enetConnectData: Int, + private val transport: Transport, + private val nowMs: () -> Long, + private val onEvent: (MoonlightEvent) -> Unit = {}, +) { + /** The datagram plumbing under the session (a UDP socket in production). */ + interface Transport { + fun send(datagram: ByteArray) + + /** Blocking receive; returns null on timeout. */ + fun receive(timeoutMs: Int): ByteArray? + + fun close() + } + + enum class State { IDLE, CONNECTING, CONNECTED, CLOSED } + + var state: State = State.IDLE + private set + + private val enet = EnetClient(enetConnectData, nowMs) + private val sealer = MoonlightHotSealer(rikey) + private val opener = MoonlightControlPacket(rikey) + + // The last controller state sent, so a periodic re-send / active-mask change + // reuses it. Single-controller for now (index 0); the wire supports more. + private var lastPingMs = 0L + + /** + * Run the ENet handshake. Sends CONNECT, then pumps received datagrams until + * VERIFY_CONNECT flips the client to CONNECTED or [handshakeTimeoutMs] + * elapses. Returns true on success. + */ + fun connect(handshakeTimeoutMs: Int = DEFAULT_HANDSHAKE_TIMEOUT_MS): Boolean { + state = State.CONNECTING + transport.send(enet.connect()) + val deadline = nowMs() + handshakeTimeoutMs + while (nowMs() < deadline && enet.state == EnetClient.State.CONNECTING) { + val datagram = + transport.receive(HANDSHAKE_POLL_MS) ?: run { + enet.tick().forEach(transport::send) + null + } + if (datagram != null) enet.onDatagram(datagram).forEach(transport::send) + } + return if (enet.state == EnetClient.State.CONNECTED) { + state = State.CONNECTED + true + } else { + state = State.CLOSED + false + } + } + + /** + * HOT PATH: seal and send the controller state on channel 0. No allocation + * beyond the ENet frame. Silently drops when not connected so a dead session + * never blocks the input thread. + */ + @Suppress("LongParameterList") + fun sendControllerState( + controllerNumber: Int, + activeMask: Int, + buttons: Int, + leftTrigger: Int, + rightTrigger: Int, + leftStickX: Int, + leftStickY: Int, + rightStickX: Int, + rightStickY: Int, + ) { + if (state != State.CONNECTED) return + val sealed = + sealer.sealControllerMulti( + controllerNumber, + activeMask, + buttons, + leftTrigger, + rightTrigger, + leftStickX, + leftStickY, + rightStickX, + rightStickY, + ) + enet.sendReliable(sealed)?.let(transport::send) + } + + /** Announce a virtual controller with its emulated type and capabilities. */ + fun sendControllerArrival( + controllerNumber: Int, + emulatedType: Int, + capabilities: Int, + supportedButtons: Int, + ) { + sendControlPlaintext( + MoonlightInputEncoder.controllerArrival(controllerNumber, emulatedType, capabilities, supportedButtons), + ) + } + + /** + * Pump the receive side once: read up to [budget] datagrams, feed the ENet + * layer, decrypt delivered control payloads and dispatch decoded events. + * Also emits a periodic ping when idle. Call this from the session's read + * loop. + */ + fun pump(budget: Int = RECEIVE_BUDGET) { + var handled = 0 + while (handled < budget) { + val datagram = transport.receive(RECEIVE_POLL_MS) ?: break + enet.onDatagram(datagram).forEach(transport::send) + drainEvents() + handled += 1 + } + enet.tick().forEach(transport::send) + maybePing() + if (enet.state == EnetClient.State.DISCONNECTED && state == State.CONNECTED) { + state = State.CLOSED + } + } + + private fun drainEvents() { + while (enet.received.isNotEmpty()) { + val payload = enet.received.removeFirst() + val plaintext = runCatching { opener.open(payload) }.getOrNull() ?: continue + MoonlightEventDecoder.decode(plaintext)?.let(onEvent) + } + } + + private fun maybePing() { + val now = nowMs() + if (state == State.CONNECTED && now - lastPingMs >= PING_INTERVAL_MS) { + lastPingMs = now + sendControlPlaintext(MoonlightInputEncoder.periodicPing()) + } + } + + private fun sendControlPlaintext(plaintext: ByteArray) { + if (state != State.CONNECTED) return + // Route every outbound packet through the sealer so the whole control + // stream shares one monotonic seq (no GCM IV reuse). + val sealed = sealer.seal(plaintext) + enet.sendReliable(sealed)?.let(transport::send) + } + + /** Graceful teardown: TERMINATION then ENet disconnect. */ + fun stop() { + if (state == State.CONNECTED) { + runCatching { sendControlPlaintext(MoonlightInputEncoder.termination()) } + } + runCatching { enet.disconnect()?.let(transport::send) } + runCatching { transport.close() } + state = State.CLOSED + } + + private companion object { + const val DEFAULT_HANDSHAKE_TIMEOUT_MS = 3000 + const val HANDSHAKE_POLL_MS = 100 + const val RECEIVE_POLL_MS = 50 + const val RECEIVE_BUDGET = 16 + const val PING_INTERVAL_MS = 500 + } +} diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightCrypto.kt b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightCrypto.kt new file mode 100644 index 00000000..b8acb3f4 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightCrypto.kt @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import java.security.MessageDigest +import java.security.PrivateKey +import java.security.PublicKey +import java.security.SecureRandom +import java.security.Signature +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * Client side of the Moonlight (GameStream) crypto (Wolf docs + * protocols/http-pairing.adoc and control-specs.adoc). Pure JVM APIs (JCA + * only, no BouncyCastle) so every step unit-tests against Wolf's captured + * vectors. X.509 identity generation is deliberately NOT here: this object + * only consumes cert-signature bytes and keys the caller supplies, so it stays + * host-testable with no Android keystore. The Moonlight path mirrors how + * [com.tinkernorth.dish.core.net.SessionCrypto] keeps the protocol-1 crypto + * pure and pushes identity to the edges. + */ +object MoonlightCrypto { + private const val AES_KEY_LEN = 16 + private const val GCM_TAG_BITS = 128 + const val GCM_TAG_LEN = 16 + + // The control stream IV is 16 bytes: the little-endian seq in the low bytes, + // the rest zero (Wolf control.hpp decrypt_packet / encrypt_packet). + private const val CONTROL_IV_LEN = 16 + + private val secureRandom = SecureRandom() + + fun randomBytes(length: Int): ByteArray = ByteArray(length).also { secureRandom.nextBytes(it) } + + fun sha256(vararg parts: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").run { + for (p in parts) update(p) + digest() + } + + /** + * Pairing AES key = SHA-256(salt || pin)[:16] (Wolf moonlight.cpp + * gen_aes_key). [salt] is the raw 16 random bytes the client generated in + * phase 1; [pin] is the 4-digit ASCII string shown to the user. + */ + fun pairingKey( + salt: ByteArray, + pin: String, + ): ByteArray = sha256(salt, pin.toByteArray(Charsets.US_ASCII)).copyOf(AES_KEY_LEN) + + // AES-128-ECB, no padding: the pairing challenge blobs are exact 16-byte + // multiples, so PKCS padding would corrupt the round-trip (Wolf uses + // padding=false for the challenge exchange). + fun aesEcbEncrypt( + key: ByteArray, + data: ByteArray, + ): ByteArray = ecb(Cipher.ENCRYPT_MODE, key, data) + + fun aesEcbDecrypt( + key: ByteArray, + data: ByteArray, + ): ByteArray = ecb(Cipher.DECRYPT_MODE, key, data) + + private fun ecb( + mode: Int, + key: ByteArray, + data: ByteArray, + ): ByteArray = + Cipher.getInstance("AES/ECB/NoPadding").run { + init(mode, SecretKeySpec(key, "AES")) + doFinal(data) + } + + /** + * Seal one control-stream payload: returns tag(16) || ciphertext, keyed by + * [gcmKey] (the 16-byte rikey) with the IV derived from [seq]. Matches + * Wolf's ControlEncryptedPacket body layout (control-specs.adoc): the tag + * precedes the ciphertext on the wire. + */ + fun controlSeal( + gcmKey: ByteArray, + seq: Int, + plaintext: ByteArray, + ): ByteArray { + val out = + gcm(Cipher.ENCRYPT_MODE, gcmKey, controlIv(seq)) { + it.doFinal(plaintext) + } + // JCA appends the tag; Moonlight wants tag first. + val ctLen = out.size - GCM_TAG_LEN + val framed = ByteArray(out.size) + System.arraycopy(out, ctLen, framed, 0, GCM_TAG_LEN) + System.arraycopy(out, 0, framed, GCM_TAG_LEN, ctLen) + return framed + } + + /** + * Open one control-stream payload of the form tag(16) || ciphertext. + * Throws [javax.crypto.AEADBadTagException] on a tampered packet, so the + * caller drops it rather than acting on forged input. + */ + fun controlOpen( + gcmKey: ByteArray, + seq: Int, + tagThenCiphertext: ByteArray, + ): ByteArray { + require(tagThenCiphertext.size >= GCM_TAG_LEN) { "control payload shorter than GCM tag" } + // JCA expects ciphertext || tag; re-order from Moonlight's tag-first layout. + val ctLen = tagThenCiphertext.size - GCM_TAG_LEN + val ctThenTag = ByteArray(tagThenCiphertext.size) + System.arraycopy(tagThenCiphertext, GCM_TAG_LEN, ctThenTag, 0, ctLen) + System.arraycopy(tagThenCiphertext, 0, ctThenTag, ctLen, GCM_TAG_LEN) + return gcm(Cipher.DECRYPT_MODE, gcmKey, controlIv(seq)) { + it.doFinal(ctThenTag) + } + } + + private inline fun gcm( + mode: Int, + key: ByteArray, + iv: ByteArray, + block: (Cipher) -> ByteArray, + ): ByteArray = + Cipher.getInstance("AES/GCM/NoPadding").run { + init(mode, SecretKeySpec(key, "AES"), GCMParameterSpec(GCM_TAG_BITS, iv)) + block(this) + } + + private fun controlIv(seq: Int): ByteArray { + val iv = ByteArray(CONTROL_IV_LEN) + // Little-endian seq in the low 4 bytes; Wolf only ever populates byte 0 + // for small seqs but keeps the full 32-bit LE value here for parity. + iv[0] = (seq and 0xFF).toByte() + iv[1] = ((seq ushr 8) and 0xFF).toByte() + iv[2] = ((seq ushr 16) and 0xFF).toByte() + iv[3] = ((seq ushr 24) and 0xFF).toByte() + return iv + } + + /** RSA-SHA256 PKCS#1 v1.5 signature over [data] (Wolf crypto sign()). */ + fun signRsaSha256( + privateKey: PrivateKey, + data: ByteArray, + ): ByteArray = + Signature.getInstance("SHA256withRSA").run { + initSign(privateKey) + update(data) + sign() + } + + fun verifyRsaSha256( + publicKey: PublicKey, + data: ByteArray, + signature: ByteArray, + ): Boolean = + Signature.getInstance("SHA256withRSA").run { + initVerify(publicKey) + update(data) + verify(signature) + } + + /** Constant-time compare so a hash/tag check does not leak via timing. */ + fun constantTimeEquals( + a: ByteArray, + b: ByteArray, + ): Boolean = MessageDigest.isEqual(a, b) +} diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightEventDecoder.kt b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightEventDecoder.kt new file mode 100644 index 00000000..394b6cee --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightEventDecoder.kt @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Parses the host -> client events carried in the DECRYPTED control-stream + * plaintext (Wolf control-specs.adoc). The plaintext begins with the control + * header `[ptype u16 LE][plen u16 LE]` followed by the event body. Every field + * is little-endian. + * + * Unknown types decode to [MoonlightEvent.Unknown] and short/malformed buffers + * to null, so a forged or truncated packet degrades gracefully instead of + * throwing on the receive thread (contract §control stream: ignore unknown + * types). + */ +sealed interface MoonlightEvent { + /** RUMBLE_DATA 0x010b {unused u32, ctrl u16, low u16, high u16}. */ + data class Rumble( + val controllerNumber: Int, + val lowFrequency: Int, + val highFrequency: Int, + ) : MoonlightEvent + + /** RUMBLE_TRIGGERS 0x5500 {ctrl u16, left u16, right u16}. */ + data class RumbleTriggers( + val controllerNumber: Int, + val left: Int, + val right: Int, + ) : MoonlightEvent + + /** MOTION_EVENT 0x5501 {ctrl u16, rate u16, type u8}: start sending motion. */ + data class MotionRequest( + val controllerNumber: Int, + val reportRateHz: Int, + val motionType: Int, + ) : MoonlightEvent + + /** RGB_LED 0x5502 {ctrl u16, r u8, g u8, b u8}. */ + data class RgbLed( + val controllerNumber: Int, + val red: Int, + val green: Int, + val blue: Int, + ) : MoonlightEvent + + /** A recognized control type this path does not act on (e.g. PERIODIC_PING echo). */ + data class Unknown( + val type: Int, + ) : MoonlightEvent +} + +object MoonlightEventDecoder { + private const val HEADER_LEN = 4 + + /** + * Decode one control plaintext. Returns null when the buffer is too short to + * even hold the header, or when a recognized type is present but its body is + * truncated (a tampered length must not be trusted to index past the end). + */ + fun decode(plaintext: ByteArray): MoonlightEvent? { + if (plaintext.size < HEADER_LEN) return null + val buf = ByteBuffer.wrap(plaintext).order(ByteOrder.LITTLE_ENDIAN) + val type = buf.short.toInt() and 0xFFFF + // plen is advisory; we validate against the real remaining bytes so a + // lying length can never drive an over-read. + buf.short + return when (type) { + MoonlightControlProtocol.EVENT_RUMBLE_DATA -> decodeRumble(buf) + MoonlightControlProtocol.EVENT_RUMBLE_TRIGGERS -> decodeTriggers(buf) + MoonlightControlProtocol.EVENT_MOTION -> decodeMotion(buf) + MoonlightControlProtocol.EVENT_RGB_LED -> decodeLed(buf) + else -> MoonlightEvent.Unknown(type) + } + } + + private fun decodeRumble(buf: ByteBuffer): MoonlightEvent? { + if (buf.remaining() < RUMBLE_BODY) return null + buf.int // unused + val ctrl = u16(buf) + val low = u16(buf) + val high = u16(buf) + return MoonlightEvent.Rumble(ctrl, low, high) + } + + private fun decodeTriggers(buf: ByteBuffer): MoonlightEvent? { + if (buf.remaining() < TRIGGERS_BODY) return null + val ctrl = u16(buf) + val left = u16(buf) + val right = u16(buf) + return MoonlightEvent.RumbleTriggers(ctrl, left, right) + } + + private fun decodeMotion(buf: ByteBuffer): MoonlightEvent? { + if (buf.remaining() < MOTION_BODY) return null + val ctrl = u16(buf) + val rate = u16(buf) + val motionType = buf.get().toInt() and 0xFF + return MoonlightEvent.MotionRequest(ctrl, rate, motionType) + } + + private fun decodeLed(buf: ByteBuffer): MoonlightEvent? { + if (buf.remaining() < LED_BODY) return null + val ctrl = u16(buf) + val r = buf.get().toInt() and 0xFF + val g = buf.get().toInt() and 0xFF + val b = buf.get().toInt() and 0xFF + return MoonlightEvent.RgbLed(ctrl, r, g, b) + } + + private fun u16(buf: ByteBuffer): Int = buf.short.toInt() and 0xFFFF + + private const val RUMBLE_BODY = 10 + private const val TRIGGERS_BODY = 6 + private const val MOTION_BODY = 5 + private const val LED_BODY = 5 +} diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHostModels.kt b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHostModels.kt new file mode 100644 index 00000000..c3048979 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHostModels.kt @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import kotlinx.serialization.Serializable + +/** + * A Moonlight-compatible host (Sunshine / Apollo / Vibepollo / Wolf) the dish + * can pair with and stream input to. Discovered over mDNS (`_nvstream._tcp`) or + * entered manually. + */ +@Serializable +data class MoonlightHost( + val name: String, + val address: String, + // 47989 (HTTP) and 47984 (HTTPS) are the documented defaults; both are read + // from /serverinfo when known and never assumed elsewhere. + val httpPort: Int = DEFAULT_HTTP_PORT, + val httpsPort: Int = DEFAULT_HTTPS_PORT, + // Stable identity from /serverinfo uniqueid; empty until first probed. + val uniqueId: String = "", + val manual: Boolean = false, +) { + val id: String get() = idFor(address, uniqueId) + + companion object { + const val DEFAULT_HTTP_PORT = 47989 + const val DEFAULT_HTTPS_PORT = 47984 + const val ID_PREFIX = "moonlight:" + + // Prefer the stable uniqueid so a host that changes IP keeps one identity; + // fall back to the address for a host not yet probed. + fun idFor( + address: String, + uniqueId: String, + ): String = if (uniqueId.isNotBlank()) "${ID_PREFIX}uid:$uniqueId" else "$ID_PREFIX$address" + } +} + +@Serializable +data class RememberedMoonlight( + val id: String, + val name: String, + val address: String, + val httpPort: Int = MoonlightHost.DEFAULT_HTTP_PORT, + val httpsPort: Int = MoonlightHost.DEFAULT_HTTPS_PORT, + val uniqueId: String = "", + // The app id last launched on this host, remembered for one-tap reconnect. + val lastAppId: String = "", + // The emulated-device pick (CONTROLLER_ARRIVAL type): Auto/Xbox/PS/Nintendo. + val emulatedType: Int = MoonlightEmulatedType.AUTO, +) { + fun toHost(): MoonlightHost = + MoonlightHost( + name = name, + address = address, + httpPort = httpPort, + httpsPort = httpsPort, + uniqueId = uniqueId, + ) +} + +/** + * The user-facing emulated-device picker mapped onto CONTROLLER_ARRIVAL types. + * AUTO is a client convenience (Wolf control.hpp uses 0xFF): the session resolves + * it to the type that best matches the local controller before it hits the wire. + */ +object MoonlightEmulatedType { + const val AUTO = 0xFF + const val XBOX = MoonlightControlProtocol.CONTROLLER_TYPE_XBOX + const val PLAYSTATION = MoonlightControlProtocol.CONTROLLER_TYPE_PS + const val NINTENDO = MoonlightControlProtocol.CONTROLLER_TYPE_NINTENDO + + /** Resolve AUTO to a concrete arrival type; a real pick passes straight through. */ + fun resolve(picked: Int): Int = if (picked == AUTO) XBOX else picked +} diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHotSealer.kt b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHotSealer.kt new file mode 100644 index 00000000..73ddf630 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHotSealer.kt @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * The hot-path sealer for the control stream: encodes a CONTROLLER_MULTI packet + * and seals it into a full ENet-ready encrypted control packet with a single + * reused [Cipher] and reused buffers, so a steady stream of input changes does + * not allocate per packet (the brief's hot-path rule; mirrors the repo's + * satellite_jni.cpp fixed-buffer discipline). + * + * NOT thread-safe: one instance per control session, driven from the single + * input-dispatch thread. The AES-GCM IV comes from the monotonically increasing + * ENet-level control seq (Wolf control.hpp), so [nextSeq] must advance once per + * sealed packet. + */ +class MoonlightHotSealer( + gcmKey: ByteArray, +) { + private val keySpec = SecretKeySpec(gcmKey, "AES") + private val cipher: Cipher = Cipher.getInstance("AES/GCM/NoPadding") + + // Reused across every packet: the plaintext scratch, the GCM output, and the + // final framed datagram body. + private val plaintext = ByteBuffer.allocate(MoonlightInputEncoder.CONTROLLER_MULTI_LEN).order(ByteOrder.LITTLE_ENDIAN) + private val cipherOut = ByteArray(MoonlightInputEncoder.CONTROLLER_MULTI_LEN + MoonlightCrypto.GCM_TAG_LEN) + private val iv = ByteArray(GCM_IV_LEN) + private val framed = + ByteBuffer + .allocate(FRAME_HEADER_LEN + SEQ_LEN + MoonlightInputEncoder.CONTROLLER_MULTI_LEN + MoonlightCrypto.GCM_TAG_LEN) + .order(ByteOrder.LITTLE_ENDIAN) + + private var seq = 0 + + val nextSeq: Int get() = seq + + /** + * Encode [controllerNumber]'s state and return a freshly framed encrypted + * control packet (`[type][len][seq][tag][ciphertext]`) ready to hand to the + * ENet reliable send. Only the returned array is allocated; the encode and + * encrypt stages reuse buffers. Advances the seq. + */ + @Suppress("LongParameterList") + fun sealControllerMulti( + controllerNumber: Int, + activeMask: Int, + buttons: Int, + leftTrigger: Int, + rightTrigger: Int, + leftStickX: Int, + leftStickY: Int, + rightStickX: Int, + rightStickY: Int, + ): ByteArray { + MoonlightInputEncoder.encodeControllerMulti( + plaintext, + controllerNumber, + activeMask, + buttons, + leftTrigger, + rightTrigger, + leftStickX, + leftStickY, + rightStickX, + rightStickY, + ) + val currentSeq = seq + writeIv(currentSeq) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, GCMParameterSpec(GCM_TAG_BITS, iv)) + // doFinal(ByteBuffer, ByteBuffer-free) form: input from the flipped plaintext + // into the reused cipherOut array; returns ct||tag. + val written = cipher.doFinal(plaintext.array(), 0, plaintext.limit(), cipherOut, 0) + seq = currentSeq + 1 + + val ctLen = written - MoonlightCrypto.GCM_TAG_LEN + val len = SEQ_LEN + written + framed.clear() + framed.putShort(MoonlightControlProtocol.PACKET_TYPE_ENCRYPTED.toShort()) + framed.putShort(len.toShort()) + framed.putInt(currentSeq) + // Moonlight wants the tag first, then the ciphertext. + framed.put(cipherOut, ctLen, MoonlightCrypto.GCM_TAG_LEN) + framed.put(cipherOut, 0, ctLen) + framed.flip() + val out = ByteArray(framed.remaining()) + framed.get(out) + return out + } + + /** + * Seal an arbitrary control plaintext (arrival, ping, termination) with the + * SAME advancing seq as the hot path, so the whole outbound control stream + * carries one monotonic sequence and never reuses a GCM IV. Not on the hot + * path, so a small allocation here is fine. + */ + fun seal(plaintext: ByteArray): ByteArray { + val currentSeq = seq + val tagThenCt = MoonlightCrypto.controlSeal(keySpec.encoded, currentSeq, plaintext) + seq = currentSeq + 1 + val len = SEQ_LEN + tagThenCt.size + val out = ByteBuffer.allocate(FRAME_HEADER_LEN + len).order(ByteOrder.LITTLE_ENDIAN) + out.putShort(MoonlightControlProtocol.PACKET_TYPE_ENCRYPTED.toShort()) + out.putShort(len.toShort()) + out.putInt(currentSeq) + out.put(tagThenCt) + return out.array() + } + + private fun writeIv(currentSeq: Int) { + iv.fill(0) + iv[0] = (currentSeq and 0xFF).toByte() + iv[1] = ((currentSeq ushr 8) and 0xFF).toByte() + iv[2] = ((currentSeq ushr 16) and 0xFF).toByte() + iv[3] = ((currentSeq ushr 24) and 0xFF).toByte() + } + + private companion object { + const val GCM_IV_LEN = 16 + const val GCM_TAG_BITS = 128 + const val FRAME_HEADER_LEN = 4 + const val SEQ_LEN = 4 + } +} diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightIdentity.kt b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightIdentity.kt new file mode 100644 index 00000000..58bc76b6 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightIdentity.kt @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import java.io.ByteArrayInputStream +import java.security.PrivateKey +import java.security.PublicKey +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate + +/** + * The dish's persistent Moonlight client identity: a self-signed X.509 + * certificate and its RSA key, generated once and reused for every host (Wolf + * http-pairing.adoc). The client cert PEM is sent in pairing phase 1 and the + * key authenticates every later HTTPS call. + * + * Certificate GENERATION is platform-specific (Android keystore / provider) and + * lives behind this interface; the crypto that consumes it stays pure and + * host-testable. This mirrors how the repo keeps TLS/identity at the edges and + * the protocol crypto ([MoonlightCrypto]) pure. + */ +interface MoonlightIdentity { + /** PEM of the self-signed client certificate (sent as hex in phase 1). */ + val certificatePem: String + + /** The certificate's X.509 signature bytes (used in the pairing hashes). */ + val certificateSignature: ByteArray + + /** RSA private key: signs the client pairing secret and TLS challenges. */ + val privateKey: PrivateKey +} + +/** + * Parses a peer certificate PEM into the fields the pairing crypto needs. + * Uses the platform [CertificateFactory] (present on Android and the host JVM), + * so this is exercised in unit tests without any keystore. + */ +object MoonlightCert { + /** X.509 signature bytes of the certificate encoded in [pem]. */ + fun signatureOf(pem: String): ByteArray = parse(pem).signature + + fun publicKeyOf(pem: String): PublicKey = parse(pem).publicKey + + fun sha256FingerprintHex(pem: String): String = bytesHex(MoonlightCrypto.sha256(parse(pem).encoded)) + + fun parse(pem: String): X509Certificate { + val factory = CertificateFactory.getInstance("X.509") + return ByteArrayInputStream(pem.toByteArray(Charsets.US_ASCII)).use { + factory.generateCertificate(it) as X509Certificate + } + } + + private val hexDigits = "0123456789abcdef".toCharArray() + + private fun bytesHex(bytes: ByteArray): String { + val out = CharArray(bytes.size * 2) + for (i in bytes.indices) { + val v = bytes[i].toInt() and 0xFF + out[i * 2] = hexDigits[v ushr 4] + out[i * 2 + 1] = hexDigits[v and 0x0F] + } + return String(out) + } +} diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightInputEncoder.kt b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightInputEncoder.kt new file mode 100644 index 00000000..3f9adefd --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightInputEncoder.kt @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Builds the DECRYPTED control-stream plaintext for the input messages the dish + * sends to a Moonlight host. Layout comes from Wolf input-data.adoc (byte-exact + * network fixtures) and control.hpp struct definitions. + * + * The plaintext always begins with the control header `[ptype u16 LE][plen u16 + * LE]`, then for INPUT_DATA the wrapper `[input size u32 BE][input type u32 + * LE]`, then the message body. This class only produces plaintext; sealing + * (AES-GCM) and ENet framing happen in the transport. + * + * HOT PATH: [encodeControllerMulti] writes into a caller-owned, reused + * [ByteBuffer] at fixed offsets with no allocation and no intermediate objects, + * mirroring the repo's satellite_jni.cpp fixed-buffer discipline. Everything is + * little-endian except the two big-endian fields the protocol mandates (INPUT + * size and the mouse deltas). + */ +object MoonlightInputEncoder { + // Full plaintext length of a CONTROLLER_MULTI message: 4 control header + 8 + // wrapper + 26 struct body. + const val CONTROLLER_MULTI_LEN = 38 + const val CONTROLLER_ARRIVAL_LEN = 19 + const val MOUSE_MOVE_REL_LEN = 16 + const val PERIODIC_PING_LEN = 8 + const val TERMINATION_LEN = 8 + + // data_size (the INPUT wrapper's big-endian size) counts from the input-type + // field to the end of the message. + private const val MULTI_DATA_SIZE = 30 + private const val ARRIVAL_DATA_SIZE = 11 + private const val MOUSE_REL_DATA_SIZE = 8 + + /** + * Encode a CONTROLLER_MULTI packet into [dst] starting at position 0 and + * leave the buffer positioned/limited to the 38-byte message. [dst] must be + * little-endian and hold at least [CONTROLLER_MULTI_LEN] bytes; it is reused + * across every input change with zero allocation. + * + * [buttons] is the full 32-bit button field; it is split into the low + * `button_flags` and high `buttonFlags2` halves per input-data.adoc + * (effective = flags | (flags2 << 16)). [activeMask] carries the present- + * controller bitfield (dropping a bit signals an unplug). + */ + @Suppress("LongParameterList") + fun encodeControllerMulti( + dst: ByteBuffer, + controllerNumber: Int, + activeMask: Int, + buttons: Int, + leftTrigger: Int, + rightTrigger: Int, + leftStickX: Int, + leftStickY: Int, + rightStickX: Int, + rightStickY: Int, + ) { + dst.clear() + dst.order(ByteOrder.LITTLE_ENDIAN) + // Control header. + dst.putShort(MoonlightControlProtocol.CTRL_INPUT_DATA.toShort()) + dst.putShort((CONTROLLER_MULTI_LEN - CONTROL_HEADER_LEN).toShort()) + // INPUT wrapper: size is BIG-endian, type is LITTLE-endian. + putIntBE(dst, MULTI_DATA_SIZE) + dst.putInt(MoonlightControlProtocol.INPUT_CONTROLLER_MULTI) + // Struct body (all little-endian). + dst.putShort(MoonlightControlProtocol.MULTI_HEADER_B.toShort()) + dst.putShort(controllerNumber.toShort()) + dst.putShort(activeMask.toShort()) + dst.putShort(MoonlightControlProtocol.MULTI_MID_B.toShort()) + dst.putShort((buttons and 0xFFFF).toShort()) + dst.put((leftTrigger and 0xFF).toByte()) + dst.put((rightTrigger and 0xFF).toByte()) + dst.putShort(leftStickX.toShort()) + dst.putShort(leftStickY.toShort()) + dst.putShort(rightStickX.toShort()) + dst.putShort(rightStickY.toShort()) + dst.putShort(MoonlightControlProtocol.MULTI_TAIL_A.toShort()) + dst.putShort((buttons ushr 16).toShort()) + dst.putShort(MoonlightControlProtocol.MULTI_TAIL_B.toShort()) + dst.flip() + } + + /** Convenience allocating form for tests and the arrival/teardown paths. */ + @Suppress("LongParameterList") + fun controllerMulti( + controllerNumber: Int, + activeMask: Int, + buttons: Int, + leftTrigger: Int, + rightTrigger: Int, + leftStickX: Int, + leftStickY: Int, + rightStickX: Int, + rightStickY: Int, + ): ByteArray { + val buf = ByteBuffer.allocate(CONTROLLER_MULTI_LEN) + encodeControllerMulti( + buf, + controllerNumber, + activeMask, + buttons, + leftTrigger, + rightTrigger, + leftStickX, + leftStickY, + rightStickX, + rightStickY, + ) + return buf.toByteArray() + } + + fun controllerArrival( + controllerNumber: Int, + controllerType: Int, + capabilities: Int, + supportedButtons: Int, + ): ByteArray { + val buf = ByteBuffer.allocate(CONTROLLER_ARRIVAL_LEN).order(ByteOrder.LITTLE_ENDIAN) + buf.putShort(MoonlightControlProtocol.CTRL_INPUT_DATA.toShort()) + buf.putShort((CONTROLLER_ARRIVAL_LEN - CONTROL_HEADER_LEN).toShort()) + putIntBE(buf, ARRIVAL_DATA_SIZE) + buf.putInt(MoonlightControlProtocol.INPUT_CONTROLLER_ARRIVAL) + buf.put((controllerNumber and 0xFF).toByte()) + buf.put((controllerType and 0xFF).toByte()) + buf.put((capabilities and 0xFF).toByte()) + // supportedButtons is little-endian in the arrival struct. + buf.putInt(supportedButtons) + return buf.toByteArray() + } + + /** + * MOUSE_MOVE_REL: deltas are BIG-endian (input-data.adoc note). Included + * because the repo already streams a virtual mouse (mouseControl); cheap to + * carry so the Moonlight path reaches parity there. + */ + fun mouseMoveRel( + deltaX: Int, + deltaY: Int, + ): ByteArray { + val buf = ByteBuffer.allocate(MOUSE_MOVE_REL_LEN).order(ByteOrder.LITTLE_ENDIAN) + buf.putShort(MoonlightControlProtocol.CTRL_INPUT_DATA.toShort()) + buf.putShort((MOUSE_MOVE_REL_LEN - CONTROL_HEADER_LEN).toShort()) + putIntBE(buf, MOUSE_REL_DATA_SIZE) + buf.putInt(MoonlightControlProtocol.INPUT_MOUSE_MOVE_REL) + putShortBE(buf, deltaX) + putShortBE(buf, deltaY) + return buf.toByteArray() + } + + /** PERIODIC_PING keepalive (control-specs.adoc): header only, no body. */ + fun periodicPing(): ByteArray { + val buf = ByteBuffer.allocate(PERIODIC_PING_LEN).order(ByteOrder.LITTLE_ENDIAN) + buf.putShort(MoonlightControlProtocol.CTRL_PERIODIC_PING.toShort()) + buf.putShort((PERIODIC_PING_LEN - CONTROL_HEADER_LEN).toShort()) + // Wolf's captured ping carries a 4-byte body of zeroes. + buf.putInt(0) + return buf.toByteArray() + } + + /** TERMINATION on quit: reason is big-endian (Wolf ControlTerminatePacket). */ + fun termination(): ByteArray { + val buf = ByteBuffer.allocate(TERMINATION_LEN).order(ByteOrder.LITTLE_ENDIAN) + buf.putShort(MoonlightControlProtocol.CTRL_TERMINATION.toShort()) + buf.putShort((TERMINATION_LEN - CONTROL_HEADER_LEN).toShort()) + putIntBE(buf, MoonlightControlProtocol.TERMINATE_REASON_GRACEFUL) + return buf.toByteArray() + } + + private const val CONTROL_HEADER_LEN = 4 + + private fun putIntBE( + buf: ByteBuffer, + value: Int, + ) { + buf.put((value ushr 24).toByte()) + buf.put((value ushr 16).toByte()) + buf.put((value ushr 8).toByte()) + buf.put(value.toByte()) + } + + private fun putShortBE( + buf: ByteBuffer, + value: Int, + ) { + buf.put((value ushr 8).toByte()) + buf.put(value.toByte()) + } + + // Flip-then-copy for the allocating builders; encodeControllerMulti has + // already flipped, so its remaining() is the message itself. + private fun ByteBuffer.toByteArray(): ByteArray { + if (position() != 0) flip() + val out = ByteArray(remaining()) + get(out) + return out + } +} diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightPairing.kt b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightPairing.kt new file mode 100644 index 00000000..746d74f2 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightPairing.kt @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import com.tinkernorth.dish.core.net.bytesToHex + +/** + * Client side of the Moonlight PIN pairing (Wolf http-pairing.adoc, mirrored + * from Wolf's SERVER implementation in moonlight.cpp / endpoints.hpp). Five + * phases derive a shared trust: the dish shows a PIN, the user types it into the + * host UI, and a challenge/response over AES-ECB plus RSA signatures + * authenticates both ends. + * + * This class owns only the crypto and the phase ordering, so it round-trips in + * unit tests against a reference server built from the same primitives. The + * manager drives the actual HTTP; each `phaseN...` returns the query parameters + * to send and each `onPhaseN` folds the host's XML-extracted value back in. + * + * Randomness is injected so tests are deterministic; production passes + * [MoonlightCrypto.randomBytes]. + */ +class MoonlightPairing( + private val identity: MoonlightIdentity, + private val pin: String, + private val randomBytes: (Int) -> ByteArray = MoonlightCrypto::randomBytes, +) { + private val salt: ByteArray = randomBytes(SALT_LEN) + private val aesKey: ByteArray = MoonlightCrypto.pairingKey(salt, pin) + private val clientChallenge: ByteArray = randomBytes(BLOCK_LEN) + private val clientSecret: ByteArray = randomBytes(BLOCK_LEN) + + private lateinit var serverCertPem: String + private lateinit var serverCertSignature: ByteArray + private var serverChallenge: ByteArray = ByteArray(0) + + /** Phase 1: send salt + client cert. `pin` is shown to the user separately. */ + fun phase1Params(uniqueId: String): Map = + mapOf( + "devicename" to "roth", + "updateState" to "1", + "phrase" to "getservercert", + "salt" to bytesToHex(salt), + "clientcert" to bytesToHex(identity.certificatePem.toByteArray(Charsets.US_ASCII)), + "uniqueid" to uniqueId, + ) + + /** Phase 1 response: the host's plaincert (server cert PEM). */ + fun onPhase1(serverCertPem: String) { + this.serverCertPem = serverCertPem + serverCertSignature = MoonlightCert.signatureOf(serverCertPem) + } + + /** Phase 2: send the client challenge (AES-ECB encrypted). */ + fun phase2Params(uniqueId: String): Map = + mapOf( + "clientchallenge" to bytesToHex(MoonlightCrypto.aesEcbEncrypt(aesKey, clientChallenge)), + "uniqueid" to uniqueId, + ) + + /** + * Phase 2 response: the host's challengeresponse. Decrypts to + * serverHash(32) || serverChallenge(16); the server hash is verified later + * once phase 3 reveals the server secret. + */ + fun onPhase2(challengeResponseHex: String): Boolean { + val decrypted = MoonlightCrypto.aesEcbDecrypt(aesKey, hexToBytes(challengeResponseHex)) + if (decrypted.size < HASH_LEN + BLOCK_LEN) return false + serverResponseHash = decrypted.copyOfRange(0, HASH_LEN) + serverChallenge = decrypted.copyOfRange(HASH_LEN, HASH_LEN + BLOCK_LEN) + return true + } + + /** + * Phase 3: send serverchallengeresp = ECB( SHA256(serverChallenge || + * clientCertSig || clientSecret) ). The host decrypts and stores this as our + * client hash for the phase 4 check. + */ + fun phase3Params(uniqueId: String): Map { + val clientHash = MoonlightCrypto.sha256(serverChallenge, identity.certificateSignature, clientSecret) + return mapOf( + "serverchallengeresp" to bytesToHex(MoonlightCrypto.aesEcbEncrypt(aesKey, clientHash)), + "uniqueid" to uniqueId, + ) + } + + /** + * Phase 3 response: the host's pairingsecret = serverSecret(16) || + * serverSignature. Authenticates the host: the earlier server hash must + * equal SHA256(clientChallenge || serverCertSig || serverSecret) and the + * secret must be RSA-signed by the server cert. + */ + fun onPhase3(pairingSecretHex: String): Boolean { + val secret = hexToBytes(pairingSecretHex) + if (secret.size < BLOCK_LEN + MIN_SIGNATURE_LEN) return false + val serverSecret = secret.copyOfRange(0, BLOCK_LEN) + val serverSignature = secret.copyOfRange(BLOCK_LEN, secret.size) + val expectedHash = MoonlightCrypto.sha256(clientChallenge, serverCertSignature, serverSecret) + if (!MoonlightCrypto.constantTimeEquals(expectedHash, serverResponseHash)) return false + val serverPublicKey = MoonlightCert.publicKeyOf(serverCertPem) + return MoonlightCrypto.verifyRsaSha256(serverPublicKey, serverSecret, serverSignature) + } + + /** + * Phase 4: send clientpairingsecret = clientSecret(16) || + * RSA-sign(clientSecret). The host verifies the hash and the signature, then + * marks us paired. + */ + fun phase4Params(uniqueId: String): Map { + val signature = MoonlightCrypto.signRsaSha256(identity.privateKey, clientSecret) + return mapOf( + "clientpairingsecret" to bytesToHex(clientSecret + signature), + "uniqueid" to uniqueId, + ) + } + + /** Phase 5 (HTTPS): the client-cert-authenticated pairchallenge. */ + fun phase5Params(uniqueId: String): Map = + mapOf( + "phrase" to "pairchallenge", + "uniqueid" to uniqueId, + ) + + private var serverResponseHash: ByteArray = ByteArray(0) + + private fun hexToBytes(hex: String): ByteArray = + com.tinkernorth.dish.core.net + .hexToBytes(hex) + + companion object { + const val SALT_LEN = 16 + private const val BLOCK_LEN = 16 + private const val HASH_LEN = 32 + + // A 2048-bit RSA signature is 256 bytes; accept any reasonable length so a + // differently sized host key still pairs. + private const val MIN_SIGNATURE_LEN = 64 + } +} diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightRtsp.kt b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightRtsp.kt new file mode 100644 index 00000000..18d9d58c --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightRtsp.kt @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +/** + * Plaintext RTSP request formatting and response parsing for the Moonlight + * stream setup handshake (Wolf protocols/rtsp.adoc, cross-checked against Wolf + * src/moonlight-protocol/rtsp/parser.hpp). Requests are + * ` RTSP/1.0` + option lines (CSeq always) + blank line + + * optional payload, CRLF-terminated. Responses start with `RTSP/1.0 + * `. + * + * Pure string work so it unit-tests without a socket. The transport writes the + * bytes and reads the reply; this class owns the wire text only. Video config + * is kept minimal on purpose: the dish negotiates the streams then discards + * their payloads (no decoding). + */ +object MoonlightRtsp { + const val CRLF = "\r\n" + + data class Request( + val command: String, + val target: String, + val cseq: Int, + val options: List> = emptyList(), + val payload: String? = null, + ) { + fun encode(): String = + buildString { + append(command) + .append(' ') + .append(target) + .append(" RTSP/1.0") + .append(CRLF) + append("CSeq: ").append(cseq).append(CRLF) + for ((k, v) in options) append(k).append(": ").append(v).append(CRLF) + append(CRLF) + if (payload != null) append(payload) + } + } + + data class Response( + val statusCode: Int, + val statusMessage: String, + val cseq: Int, + val options: Map, + val payload: String, + ) { + val ok: Boolean get() = statusCode in 200..299 + + /** + * The SETUP reply carries the negotiated port in `server_port=` of + * the Transport option (rtsp.adoc setup). Ports are dynamic: read them + * here, never hardcode. + */ + fun serverPort(): Int? { + val transport = options["Transport"] ?: return null + val marker = "server_port=" + val start = transport.indexOf(marker) + if (start < 0) return null + val digits = transport.substring(start + marker.length).takeWhile { it.isDigit() } + return digits.toIntOrNull() + } + } + + fun options( + target: String, + cseq: Int, + ): Request = Request("OPTIONS", target, cseq, listOf("X-GS-ClientVersion" to CLIENT_VERSION)) + + fun describe( + target: String, + cseq: Int, + ): Request = + Request( + "DESCRIBE", + target, + cseq, + listOf( + "X-GS-ClientVersion" to CLIENT_VERSION, + "Accept" to "application/sdp", + ), + ) + + /** + * streamId is one of audio / video / control (rtsp.adoc SETUP). The target + * is the streamid form, so no URI is needed here. + */ + fun setup( + streamId: String, + cseq: Int, + ): Request = + Request( + "SETUP", + "streamid=$streamId", + cseq, + listOf("Transport" to "unicast;X-GS-ClientPort=$streamId", "X-GS-ClientVersion" to CLIENT_VERSION), + ) + + fun announce( + target: String, + cseq: Int, + sdpPayload: String, + ): Request = + Request( + "ANNOUNCE", + target, + cseq, + listOf( + "Content-type" to "application/sdp", + "Content-length" to sdpPayload.toByteArray(Charsets.UTF_8).size.toString(), + "Session" to "DEADBEEFCAFE", + ), + payload = sdpPayload, + ) + + fun play( + target: String, + cseq: Int, + ): Request = Request("PLAY", target, cseq, listOf("Session" to "DEADBEEFCAFE")) + + /** + * Parse an RTSP response. Returns null when the first line is not an + * `RTSP/1.0`-style status line, so a truncated or non-RTSP reply is + * rejected rather than misparsed. + */ + fun parseResponse(raw: String): Response? { + val normalized = raw.replace("\r\n", "\n") + val headerEnd = normalized.indexOf("\n\n") + val headerBlock = if (headerEnd >= 0) normalized.substring(0, headerEnd) else normalized + val payload = if (headerEnd >= 0) normalized.substring(headerEnd + 2) else "" + val lines = headerBlock.split('\n').filter { it.isNotEmpty() } + if (lines.isEmpty()) return null + val statusLine = lines.first().trim().split(' ', limit = 3) + if (statusLine.size < 2 || !statusLine[0].startsWith("RTSP/")) return null + val code = statusLine[1].toIntOrNull() ?: return null + val message = statusLine.getOrElse(2) { "" } + val options = LinkedHashMap() + var cseq = 0 + for (line in lines.drop(1)) { + val idx = line.indexOf(':') + if (idx <= 0) continue + val key = line.substring(0, idx).trim() + val value = line.substring(idx + 1).trim() + if (key.equals("CSeq", ignoreCase = true)) { + cseq = value.toIntOrNull() ?: cseq + } else { + options[key] = value + } + } + return Response(code, message, cseq, options, payload) + } + + // A minimal SDP-ish config that advertises the lowest video/audio settings. + // The host negotiates these; the dish never decodes the resulting streams. + fun minimalAnnounceSdp( + width: Int, + height: Int, + fps: Int, + ): String = + buildString { + append("v=0").append(CRLF) + append("a=x-nv-video[0].clientViewportWd:").append(width).append(CRLF) + append("a=x-nv-video[0].clientViewportHt:").append(height).append(CRLF) + append("a=x-nv-video[0].maxFPS:").append(fps).append(CRLF) + append("a=x-nv-video[0].packetSize:1024").append(CRLF) + append("a=x-nv-vqos[0].bitStreamFormat:0").append(CRLF) + append("a=x-nv-audio.surround.numChannels:2").append(CRLF) + append("t=0 0").append(CRLF) + } + + private const val CLIENT_VERSION = "14" +} diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightUrls.kt b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightUrls.kt new file mode 100644 index 00000000..6c52277e --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightUrls.kt @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import java.net.URLEncoder + +/** + * Builds the Moonlight HTTP/HTTPS request URLs and query strings (Wolf + * rest/servers.cpp routes). Pure string work so it is unit-tested without a + * socket; the gateway opens the connections. + * + * Ports are never hardcoded here: the caller passes the port it read from + * /serverinfo (HTTP 47989 and HTTPS 47984 are only the documented defaults). + */ +object MoonlightUrls { + fun serverInfoHttp( + address: String, + httpPort: Int, + uniqueId: String, + ): String = "http://$address:$httpPort/serverinfo?" + query(mapOf("uniqueid" to uniqueId)) + + fun serverInfoHttps( + address: String, + httpsPort: Int, + uniqueId: String, + ): String = "https://$address:$httpsPort/serverinfo?" + query(mapOf("uniqueid" to uniqueId)) + + fun pairHttp( + address: String, + httpPort: Int, + params: Map, + ): String = "http://$address:$httpPort/pair?" + query(params) + + fun pairHttps( + address: String, + httpsPort: Int, + params: Map, + ): String = "https://$address:$httpsPort/pair?" + query(params) + + fun appList( + address: String, + httpsPort: Int, + uniqueId: String, + ): String = "https://$address:$httpsPort/applist?" + query(mapOf("uniqueid" to uniqueId)) + + /** + * /launch carries the app id plus the client-generated rikey (hex) and + * rikeyid (u32) that key the control stream, a minimal display mode, and the + * audio play mode (Wolf endpoints.hpp create_run_session). + */ + @Suppress("LongParameterList") + fun launch( + address: String, + httpsPort: Int, + uniqueId: String, + appId: String, + rikeyHex: String, + rikeyId: Int, + mode: String, + ): String = + "https://$address:$httpsPort/launch?" + + query( + mapOf( + "uniqueid" to uniqueId, + "appid" to appId, + "mode" to mode, + "additionalStates" to "1", + "sops" to "0", + "rikey" to rikeyHex, + "rikeyid" to rikeyId.toString(), + "localAudioPlayMode" to "0", + "surroundAudioInfo" to "65538", + ), + ) + + fun resume( + address: String, + httpsPort: Int, + uniqueId: String, + rikeyHex: String, + rikeyId: Int, + ): String = + "https://$address:$httpsPort/resume?" + + query( + mapOf( + "uniqueid" to uniqueId, + "rikey" to rikeyHex, + "rikeyid" to rikeyId.toString(), + ), + ) + + fun cancel( + address: String, + httpsPort: Int, + uniqueId: String, + ): String = "https://$address:$httpsPort/cancel?" + query(mapOf("uniqueid" to uniqueId)) + + private fun query(params: Map): String = + params.entries.joinToString("&") { (k, v) -> + "$k=" + URLEncoder.encode(v, "UTF-8") + } +} diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightXml.kt b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightXml.kt new file mode 100644 index 00000000..74a64cf6 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightXml.kt @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import org.w3c.dom.Element +import java.io.ByteArrayInputStream +import javax.xml.parsers.DocumentBuilderFactory + +/** + * Parses the XML the Moonlight host returns from /serverinfo, /pair and + * /applist (Wolf moonlight.cpp). Uses the platform DOM parser (present on + * Android and the host JVM), so it is exercised in unit tests with no network. + */ +object MoonlightXml { + data class ServerInfo( + val hostname: String, + val uniqueId: String, + val pairStatus: Int, + val currentGame: Int, + val state: String, + val httpsPort: Int?, + val externalPort: Int?, + val mac: String?, + val localIp: String?, + ) { + val paired: Boolean get() = pairStatus == 1 + val busy: Boolean get() = currentGame != 0 || state.endsWith("SERVER_BUSY") + } + + data class App( + val id: String, + val title: String, + val hdrSupported: Boolean, + ) + + /** A /pair phase reply: `paired` plus whichever field that phase carries. */ + data class PairReply( + val paired: Boolean, + val plainCert: String?, + val challengeResponse: String?, + val pairingSecret: String?, + val statusMessage: String?, + ) + + fun parseServerInfo(xml: String): ServerInfo? { + val root = rootOf(xml) ?: return null + return ServerInfo( + hostname = text(root, "hostname").orEmpty(), + uniqueId = text(root, "uniqueid").orEmpty(), + pairStatus = intText(root, "PairStatus") ?: 0, + currentGame = intText(root, "currentgame") ?: 0, + state = text(root, "state").orEmpty(), + httpsPort = intText(root, "HttpsPort"), + externalPort = intText(root, "ExternalPort"), + mac = text(root, "mac"), + localIp = text(root, "LocalIP"), + ) + } + + fun parsePairReply(xml: String): PairReply? { + val root = rootOf(xml) ?: return null + return PairReply( + paired = (intText(root, "paired") ?: 0) == 1, + plainCert = text(root, "plaincert"), + challengeResponse = text(root, "challengeresponse"), + pairingSecret = text(root, "pairingsecret"), + statusMessage = root.getAttribute("status_message").takeIf { it.isNotEmpty() }, + ) + } + + fun parseAppList(xml: String): List { + val root = rootOf(xml) ?: return emptyList() + val apps = mutableListOf() + val nodes = root.getElementsByTagName("App") + for (i in 0 until nodes.length) { + val el = nodes.item(i) as? Element ?: continue + val id = childText(el, "ID") ?: continue + apps += App(id = id, title = childText(el, "AppTitle").orEmpty(), hdrSupported = (childInt(el, "IsHdrSupported") ?: 0) == 1) + } + return apps + } + + private fun rootOf(xml: String): Element? = + runCatching { + val factory = + DocumentBuilderFactory.newInstance().apply { + // Harden the parser: this input comes off the network. + setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + isExpandEntityReferences = false + } + val doc = factory.newDocumentBuilder().parse(ByteArrayInputStream(xml.toByteArray(Charsets.UTF_8))) + doc.documentElement + }.getOrNull() + + private fun text( + root: Element, + tag: String, + ): String? = childText(root, tag) + + private fun intText( + root: Element, + tag: String, + ): Int? = childInt(root, tag) + + private fun childText( + parent: Element, + tag: String, + ): String? { + val nodes = parent.getElementsByTagName(tag) + if (nodes.length == 0) return null + return nodes + .item(0) + .textContent + ?.trim() + ?.takeIf { it.isNotEmpty() } + } + + private fun childInt( + parent: Element, + tag: String, + ): Int? = childText(parent, tag)?.toIntOrNull() +} diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/enet/EnetClient.kt b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/enet/EnetClient.kt new file mode 100644 index 00000000..28b10041 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/enet/EnetClient.kt @@ -0,0 +1,346 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight.enet + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * A minimal ENet client: the connect handshake, reliable send/receive on + * channel 0, acknowledgements, ping and disconnect. Ported to pure Kotlin from + * the MIT-licensed cgutman/enet C source (host.c, peer.c, protocol.c; the fork + * Wolf pins). Fragmentation, unsequenced delivery, throttling and bandwidth + * commands are intentionally omitted: the Moonlight control payloads are all + * small single-fragment reliable messages. + * + * The class is a PURE state machine so it unit-tests against handcrafted + * protocol bytes with no socket. [connect], [sendReliable], [onDatagram] and + * [tick] return the datagrams the transport must send; delivered host payloads + * queue in [received]. A monotonic clock is injected so retransmit and ping + * timing are deterministic in tests. + */ +class EnetClient( + private val connectData: Int, + private val nowMs: () -> Long, + private val random: () -> Int = { (Math.random() * Int.MAX_VALUE).toInt() }, +) { + enum class State { CONNECTING, CONNECTED, DISCONNECTED } + + var state: State = State.DISCONNECTED + private set + + /** Reliable payloads delivered by the host (the encrypted control events). */ + val received: ArrayDeque = ArrayDeque() + + // Our peer identity. incomingPeerId is our slot (0); outgoingPeerId is the + // host's id for us, learned from VERIFY_CONNECT. + private val incomingPeerId = 0 + private var outgoingPeerId = EnetProtocol.MAXIMUM_PEER_ID + private var incomingSessionId = 0xFF + private var outgoingSessionId = 0xFF + private var connectId = 0 + private var mtu = EnetProtocol.DEFAULT_MTU + private var windowSize = EnetProtocol.MAXIMUM_WINDOW_SIZE + + // Channel-0 outgoing reliable sequence (the connect uses the system channel 0xFF). + private var channelReliableSeq = 0 + private var systemReliableSeq = 0 + + // The highest channel-0 reliable seq we have delivered, so a retransmitted + // host command is acked again but not delivered twice. + private var incomingReliableSeq = 0 + + private var lastReceiveMs = 0L + private var lastPingMs = 0L + + private data class Outgoing( + val channelId: Int, + val reliableSeq: Int, + val datagram: ByteArray, + var sentAtMs: Long, + var attempts: Int, + ) + + // Reliable commands awaiting acknowledgement, keyed for O(1) ack removal. + private val sentReliable = LinkedHashMap() + + /** Begin the handshake: returns the CONNECT datagram to send. */ + fun connect(): ByteArray { + state = State.CONNECTING + connectId = random() + systemReliableSeq = 1 + val now = nowMs() + lastReceiveMs = now + lastPingMs = now + val command = buildConnect() + val datagram = wrapRaw(command, now) + track(EnetProtocol.SYSTEM_CHANNEL, systemReliableSeq, datagram, now) + return datagram + } + + /** + * Queue a reliable payload on channel 0 and return the datagram to send. + * Returns null when not connected, so the caller drops input for a dead + * session rather than framing into the void. + */ + fun sendReliable(payload: ByteArray): ByteArray? { + if (state != State.CONNECTED) return null + channelReliableSeq += 1 + val seq = channelReliableSeq + val now = nowMs() + val command = buildSendReliable(seq, payload) + val datagram = wrapRaw(command, now) + track(DATA_CHANNEL, seq, datagram, now) + return datagram + } + + /** Graceful DISCONNECT (unsequenced, matches enet_peer_disconnect_now). */ + fun disconnect(): ByteArray? { + if (state == State.DISCONNECTED) return null + state = State.DISCONNECTED + val now = nowMs() + val command = + EnetProtocol + .Writer(EnetProtocol.DISCONNECT_LEN) + .also { + EnetProtocol.commandHeader( + it, + EnetProtocol.COMMAND_DISCONNECT or EnetProtocol.FLAG_UNSEQUENCED, + EnetProtocol.SYSTEM_CHANNEL, + 0, + ) + it.u32(0) // disconnect data + }.toByteArray() + return wrapRaw(command, now) + } + + /** + * Feed a received datagram. Returns any datagrams to send in response + * (acknowledgements). Delivered host payloads are appended to [received]. + */ + fun onDatagram(datagram: ByteArray): List { + if (datagram.size < EnetProtocol.NO_SENT_TIME_HEADER_LEN) return emptyList() + val buf = ByteBuffer.wrap(datagram).order(ByteOrder.BIG_ENDIAN) + val peerField = buf.short.toInt() and 0xFFFF + val hasSentTime = peerField and EnetProtocol.HEADER_FLAG_SENT_TIME != 0 + val compressed = peerField and EnetProtocol.HEADER_FLAG_COMPRESSED != 0 + if (compressed) return emptyList() // this client never negotiates compression + var sentTime = 0 + if (hasSentTime) { + if (buf.remaining() < 2) return emptyList() + sentTime = buf.short.toInt() and 0xFFFF + } + lastReceiveMs = nowMs() + val acks = mutableListOf() + while (buf.remaining() >= EnetProtocol.COMMAND_HEADER_LEN) { + val command = buf.get().toInt() and 0xFF + val channelId = buf.get().toInt() and 0xFF + val reliableSeq = buf.short.toInt() and 0xFFFF + val header = EnetProtocol.CommandHeader(command, channelId, reliableSeq) + if (!handleCommand(header, buf, sentTime, hasSentTime, acks)) break + } + return acks + } + + /** Advance time: retransmit unacked commands and ping when idle. */ + fun tick(): List { + val now = nowMs() + val out = mutableListOf() + for (cmd in sentReliable.values) { + if (now - cmd.sentAtMs < RETRANSMIT_TIMEOUT_MS) continue + cmd.attempts += 1 + if (cmd.attempts > MAX_RETRANSMITS) { + state = State.DISCONNECTED + return out + } + cmd.sentAtMs = now + out += cmd.datagram + } + if (state == State.CONNECTED && + now - lastReceiveMs >= EnetProtocol.PING_INTERVAL_MS && + now - lastPingMs >= EnetProtocol.PING_INTERVAL_MS + ) { + lastPingMs = now + out += buildPing(now) + } + return out + } + + // Each early return is a distinct malformed/short-command bail; splitting them would + // obscure the one-command-per-branch parse. + @Suppress("ReturnCount") + private fun handleCommand( + header: EnetProtocol.CommandHeader, + buf: ByteBuffer, + sentTime: Int, + hasSentTime: Boolean, + acks: MutableList, + ): Boolean { + when (header.commandNumber) { + EnetProtocol.COMMAND_VERIFY_CONNECT -> { + if (buf.remaining() < EnetProtocol.VERIFY_CONNECT_LEN - EnetProtocol.COMMAND_HEADER_LEN) return false + consumeVerifyConnect(buf) + } + EnetProtocol.COMMAND_ACKNOWLEDGE -> { + if (buf.remaining() < EnetProtocol.ACKNOWLEDGE_LEN - EnetProtocol.COMMAND_HEADER_LEN) return false + val recvReliableSeq = buf.short.toInt() and 0xFFFF + buf.short // receivedSentTime + acknowledge(header.channelId, recvReliableSeq) + } + EnetProtocol.COMMAND_SEND_RELIABLE -> { + if (buf.remaining() < EnetProtocol.SEND_RELIABLE_HEADER_LEN - EnetProtocol.COMMAND_HEADER_LEN) return false + val dataLength = buf.short.toInt() and 0xFFFF + if (buf.remaining() < dataLength) return false + val payload = ByteArray(dataLength) + buf.get(payload) + deliverReliable(header.reliableSequenceNumber, payload) + } + EnetProtocol.COMMAND_PING -> Unit + EnetProtocol.COMMAND_DISCONNECT -> { + if (buf.remaining() < EnetProtocol.DISCONNECT_LEN - EnetProtocol.COMMAND_HEADER_LEN) return false + buf.int + state = State.DISCONNECTED + } + else -> return false // an unsupported command aborts the rest of the packet + } + if (header.wantsAck && hasSentTime) { + acks += buildAcknowledge(header, sentTime) + } + return true + } + + private fun consumeVerifyConnect(buf: ByteBuffer) { + outgoingPeerId = buf.short.toInt() and 0xFFFF + incomingSessionId = buf.get().toInt() and 0xFF + outgoingSessionId = buf.get().toInt() and 0xFF + val theirMtu = buf.int + val theirWindow = buf.int + buf.int // channelCount + buf.int // incomingBandwidth + buf.int // outgoingBandwidth + buf.int // packetThrottleInterval + buf.int // packetThrottleAcceleration + buf.int // packetThrottleDeceleration + buf.int // connectID (already validated implicitly by the handshake) + if (theirMtu in EnetProtocol.PROTOCOL_MINIMUM_MTU..EnetProtocol.PROTOCOL_MAXIMUM_MTU && theirMtu < mtu) { + mtu = theirMtu + } + if (theirWindow in EnetProtocol.MINIMUM_WINDOW_SIZE..EnetProtocol.MAXIMUM_WINDOW_SIZE && theirWindow < windowSize) { + windowSize = theirWindow + } + // The host acked our CONNECT by sending VERIFY_CONNECT; clear it and go live. + sentReliable.remove(key(EnetProtocol.SYSTEM_CHANNEL, systemReliableSeq)) + state = State.CONNECTED + } + + private fun acknowledge( + channelId: Int, + reliableSeq: Int, + ) { + sentReliable.remove(key(channelId, reliableSeq)) + } + + private fun deliverReliable( + reliableSeq: Int, + payload: ByteArray, + ) { + // In-order gate: a retransmitted command (seq already delivered) is acked + // again by the caller but not re-delivered. + if (reliableSeq <= incomingReliableSeq) return + incomingReliableSeq = reliableSeq + received.addLast(payload) + } + + private fun buildConnect(): ByteArray { + val w = EnetProtocol.Writer(EnetProtocol.CONNECT_LEN) + EnetProtocol.commandHeader( + w, + EnetProtocol.COMMAND_CONNECT or EnetProtocol.FLAG_ACKNOWLEDGE, + EnetProtocol.SYSTEM_CHANNEL, + systemReliableSeq, + ) + w.u16(incomingPeerId) + w.u8(0xFF) // incomingSessionId (unset) + w.u8(0xFF) // outgoingSessionId (unset) + w.u32(mtu) + w.u32(windowSize) + w.u32(CHANNEL_COUNT) + w.u32(0) // incomingBandwidth + w.u32(0) // outgoingBandwidth + w.u32(EnetProtocol.PACKET_THROTTLE_INTERVAL) + w.u32(EnetProtocol.PACKET_THROTTLE_ACCELERATION) + w.u32(EnetProtocol.PACKET_THROTTLE_DECELERATION) + w.u32(connectId) + w.u32(connectData) + return w.toByteArray() + } + + private fun buildSendReliable( + seq: Int, + payload: ByteArray, + ): ByteArray { + val w = EnetProtocol.Writer(EnetProtocol.SEND_RELIABLE_HEADER_LEN + payload.size) + EnetProtocol.commandHeader(w, EnetProtocol.COMMAND_SEND_RELIABLE or EnetProtocol.FLAG_ACKNOWLEDGE, DATA_CHANNEL, seq) + w.u16(payload.size) + w.bytes(payload) + return w.toByteArray() + } + + private fun buildAcknowledge( + header: EnetProtocol.CommandHeader, + sentTime: Int, + ): ByteArray { + val w = EnetProtocol.Writer(EnetProtocol.ACKNOWLEDGE_LEN) + EnetProtocol.commandHeader(w, EnetProtocol.COMMAND_ACKNOWLEDGE, header.channelId, header.reliableSequenceNumber) + w.u16(header.reliableSequenceNumber) + w.u16(sentTime) + return wrapRaw(w.toByteArray(), nowMs()) + } + + private fun buildPing(now: Long): ByteArray { + systemReliableSeq += 1 + val w = EnetProtocol.Writer(EnetProtocol.PING_LEN) + EnetProtocol.commandHeader( + w, + EnetProtocol.COMMAND_PING or EnetProtocol.FLAG_ACKNOWLEDGE, + EnetProtocol.SYSTEM_CHANNEL, + systemReliableSeq, + ) + val datagram = wrapRaw(w.toByteArray(), now) + track(EnetProtocol.SYSTEM_CHANNEL, systemReliableSeq, datagram, now) + return datagram + } + + // Wrap one command in a datagram with the sent-time header. + private fun wrapRaw( + command: ByteArray, + now: Long, + ): ByteArray { + val w = EnetProtocol.Writer(EnetProtocol.FULL_HEADER_LEN + command.size) + EnetProtocol.writeHeader(w, outgoingPeerId, outgoingSessionId, sentTime = (now and 0xFFFF).toInt()) + w.bytes(command) + return w.toByteArray() + } + + private fun track( + channelId: Int, + reliableSeq: Int, + datagram: ByteArray, + now: Long, + ) { + sentReliable[key(channelId, reliableSeq)] = Outgoing(channelId, reliableSeq, datagram, now, attempts = 0) + } + + private fun key( + channelId: Int, + reliableSeq: Int, + ): Long = (channelId.toLong() shl 32) or (reliableSeq.toLong() and 0xFFFFFFFFL) + + companion object { + const val DATA_CHANNEL = 0 + private const val CHANNEL_COUNT = 1 + private const val RETRANSMIT_TIMEOUT_MS = 500L + private const val MAX_RETRANSMITS = 10 + } +} diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/enet/EnetProtocol.kt b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/enet/EnetProtocol.kt new file mode 100644 index 00000000..591cc168 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/enet/EnetProtocol.kt @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight.enet + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * The minimal ENet wire protocol needed for the Moonlight control stream: + * the connect handshake, reliable send/receive on one channel, acknowledgements, + * ping and disconnect. Ported to pure Kotlin from the MIT-licensed + * cgutman/enet C source (the fork Wolf pins, commit 44c85e16); see THIRD_PARTY.md. + * Only the subset the client needs is reproduced; fragmentation, unsequenced, + * bandwidth and compression commands are not sent by this client and are + * ignored on receive. + * + * All multi-byte fields are network byte order (big-endian), matching ENet's + * ENET_HOST_TO_NET_* macros. + */ +internal object EnetProtocol { + // Command numbers (protocol.h ENetProtocolCommand). + const val COMMAND_NONE = 0 + const val COMMAND_ACKNOWLEDGE = 1 + const val COMMAND_CONNECT = 2 + const val COMMAND_VERIFY_CONNECT = 3 + const val COMMAND_DISCONNECT = 4 + const val COMMAND_PING = 5 + const val COMMAND_SEND_RELIABLE = 6 + const val COMMAND_MASK = 0x0F + + // Command flags (protocol.h ENetProtocolFlag). + const val FLAG_ACKNOWLEDGE = 1 shl 7 + const val FLAG_UNSEQUENCED = 1 shl 6 + + // Header flags packed into the 16-bit peerID field. + const val HEADER_FLAG_COMPRESSED = 1 shl 14 + const val HEADER_FLAG_SENT_TIME = 1 shl 15 + const val HEADER_SESSION_MASK = 3 shl 12 + const val HEADER_SESSION_SHIFT = 12 + + const val MAXIMUM_PEER_ID = 0xFFF + + // The system channel 0xFF carries connect/ping/disconnect; data rides channel 0. + const val SYSTEM_CHANNEL = 0xFF + + const val PROTOCOL_MINIMUM_MTU = 576 + const val PROTOCOL_MAXIMUM_MTU = 4096 + const val MINIMUM_WINDOW_SIZE = 4096 + const val MAXIMUM_WINDOW_SIZE = 65536 + + const val NO_SENT_TIME_HEADER_LEN = 2 + const val FULL_HEADER_LEN = 4 + const val COMMAND_HEADER_LEN = 4 + + const val ACKNOWLEDGE_LEN = 8 + const val CONNECT_LEN = 48 + const val VERIFY_CONNECT_LEN = 44 + const val DISCONNECT_LEN = 8 + const val PING_LEN = 4 + const val SEND_RELIABLE_HEADER_LEN = 6 + + // Default peer parameters (enet.h ENET_PEER_* / peer.c enet_peer_reset). + const val DEFAULT_MTU = 1400 + const val PACKET_THROTTLE_INTERVAL = 5000 + const val PACKET_THROTTLE_ACCELERATION = 2 + const val PACKET_THROTTLE_DECELERATION = 2 + const val PING_INTERVAL_MS = 500 + + data class CommandHeader( + val command: Int, + val channelId: Int, + val reliableSequenceNumber: Int, + ) { + val commandNumber: Int get() = command and COMMAND_MASK + val wantsAck: Boolean get() = command and FLAG_ACKNOWLEDGE != 0 + } + + class Writer( + capacity: Int, + ) { + val buffer: ByteBuffer = ByteBuffer.allocate(capacity).order(ByteOrder.BIG_ENDIAN) + + fun u8(value: Int): Writer = apply { buffer.put(value.toByte()) } + + fun u16(value: Int): Writer = apply { buffer.putShort(value.toShort()) } + + fun u32(value: Int): Writer = apply { buffer.putInt(value) } + + fun bytes(value: ByteArray): Writer = apply { buffer.put(value) } + + fun toByteArray(): ByteArray { + buffer.flip() + val out = ByteArray(buffer.remaining()) + buffer.get(out) + return out + } + } + + /** + * The outer datagram header. peerID is the low 12 bits; session id and the + * sent-time/compressed flags share the remaining bits (protocol.c + * enet_protocol_handle_incoming_commands). + */ + fun writeHeader( + w: Writer, + outgoingPeerId: Int, + sessionId: Int, + sentTime: Int?, + ) { + var field = outgoingPeerId and MAXIMUM_PEER_ID + if (outgoingPeerId < MAXIMUM_PEER_ID) { + field = field or ((sessionId shl HEADER_SESSION_SHIFT) and HEADER_SESSION_MASK) + } + if (sentTime != null) field = field or HEADER_FLAG_SENT_TIME + w.u16(field) + if (sentTime != null) w.u16(sentTime and 0xFFFF) + } + + fun commandHeader( + w: Writer, + command: Int, + channelId: Int, + reliableSequenceNumber: Int, + ) { + w.u8(command) + w.u8(channelId) + w.u16(reliableSequenceNumber and 0xFFFF) + } + + fun sizeForCommand(commandNumber: Int): Int = + when (commandNumber) { + COMMAND_ACKNOWLEDGE -> ACKNOWLEDGE_LEN + COMMAND_CONNECT -> CONNECT_LEN + COMMAND_VERIFY_CONNECT -> VERIFY_CONNECT_LEN + COMMAND_DISCONNECT -> DISCONNECT_LEN + COMMAND_PING -> PING_LEN + COMMAND_SEND_RELIABLE -> SEND_RELIABLE_HEADER_LEN + else -> 0 + } +} diff --git a/app/src/main/java/com/tinkernorth/dish/di/AppModule.kt b/app/src/main/java/com/tinkernorth/dish/di/AppModule.kt index bac450d7..2cfa0af6 100644 --- a/app/src/main/java/com/tinkernorth/dish/di/AppModule.kt +++ b/app/src/main/java/com/tinkernorth/dish/di/AppModule.kt @@ -80,6 +80,14 @@ object AppModule { @Singleton fun provideBluetoothHidSession(factory: @JvmSuppressWildcards () -> HidProxyClient): BluetoothHidSession = BluetoothHidSession(factory) + // The Moonlight client identity is keystore-backed; bind the interface the + // pairing/gateway code depends on to the concrete provider. + @Provides + @Singleton + fun provideMoonlightIdentity( + provider: com.tinkernorth.dish.source.connection.moonlight.MoonlightIdentityProvider, + ): com.tinkernorth.dish.core.net.moonlight.MoonlightIdentity = provider + // Adapter resolved per call so a runtime BT toggle is reflected without re-injection. @Provides @Singleton diff --git a/app/src/main/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserver.kt b/app/src/main/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserver.kt index 6caf0817..561626db 100644 --- a/app/src/main/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserver.kt +++ b/app/src/main/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserver.kt @@ -112,6 +112,11 @@ fun reconcileSlots( } else { ops += BindOp.Unbind(id) } + // Moonlight has no native slot table yet, so a PHYSICAL pad bound to a Moonlight host does + // not stream through the native capture path; the on-screen controller drives Moonlight + // via the overlay Kotlin send path. Emit Unbind (the safe no-op) until the native + // SLOT_MOONLIGHT bridge lands. See the PR's known gaps. + ConnectionKind.MOONLIGHT -> ops += BindOp.Unbind(id) } } return ops diff --git a/app/src/main/java/com/tinkernorth/dish/repository/RememberedMoonlightRepository.kt b/app/src/main/java/com/tinkernorth/dish/repository/RememberedMoonlightRepository.kt new file mode 100644 index 00000000..5b041227 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/repository/RememberedMoonlightRepository.kt @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.repository + +import android.content.Context +import android.util.Log +import androidx.core.content.edit +import com.tinkernorth.dish.architecture.interfaces.KeyedRepository +import com.tinkernorth.dish.core.net.moonlight.RememberedMoonlight +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Remembers paired Moonlight hosts. A byte-for-byte structural twin of + * [RememberedSatelliteRepository]: a single JSON list in the shared + * connection_store prefs, guarded by a write lock, with an observable mirror so + * the connections composer reacts to remember/forget without a prefs re-read. + */ +@Singleton +class RememberedMoonlightRepository + @Inject + constructor( + @ApplicationContext context: Context, + private val json: Json, + ) : KeyedRepository { + private val prefs by lazy { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + private val writeLock = Any() + + private val _entries = MutableStateFlow(all()) + val entries: StateFlow> = _entries.asStateFlow() + + override fun keyOf(value: RememberedMoonlight): String = value.id + + override fun get(key: String): RememberedMoonlight? = all().firstOrNull { it.id == key } + + override fun all(): List { + val raw = prefs.getString(KEY_HOSTS, null) ?: return emptyList() + return runCatching { + json.decodeFromString(ListSerializer(RememberedMoonlight.serializer()), raw) + }.getOrElse { err -> + Log.w(TAG, "Failed to decode moonlight host list; treating as empty. Cause: ${err.javaClass.simpleName}") + emptyList() + } + } + + override fun put( + key: String, + value: RememberedMoonlight, + ) { + synchronized(writeLock) { + val list = all().toMutableList() + list.removeAll { it.id == key } + list += value + persist(list) + _entries.value = list.toList() + } + } + + override fun remove(key: String) { + synchronized(writeLock) { + val list = all().filterNot { it.id == key } + persist(list) + _entries.value = list + } + } + + override fun clear() { + synchronized(writeLock) { + prefs.edit { remove(KEY_HOSTS) } + _entries.value = emptyList() + } + } + + private fun persist(list: List) { + prefs.edit { putString(KEY_HOSTS, json.encodeToString(ListSerializer(RememberedMoonlight.serializer()), list)) } + } + + private companion object { + const val TAG = "RememberedMoonlightRepo" + const val PREFS_NAME = "connection_store" + const val KEY_HOSTS = "moonlight_host_list" + } + } diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MdnsMoonlightDiscovery.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MdnsMoonlightDiscovery.kt new file mode 100644 index 00000000..18d783c0 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MdnsMoonlightDiscovery.kt @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.source.connection.moonlight + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.net.wifi.WifiManager +import android.util.Log +import com.tinkernorth.dish.core.net.moonlight.MoonlightHost +import com.tinkernorth.dish.di.IoDispatcher +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume + +/** + * Discovers Moonlight hosts over mDNS (`_nvstream._tcp`). Mirrors the satellite + * [com.tinkernorth.dish.source.connection.MdnsDiscovery] plumbing exactly: + * NsdManager discovery serialised through a channel, a multicast lock held for + * the scan, and resolveService per service. Manual entry is the fallback (the + * connection manager builds a [MoonlightHost] from a typed address). + */ +@Singleton +class MdnsMoonlightDiscovery + @Inject + constructor( + @ApplicationContext private val context: Context, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + ) { + suspend fun discover(timeoutMs: Int): List = + withContext(ioDispatcher) { + val nsd = + context.getSystemService(Context.NSD_SERVICE) as? NsdManager + ?: return@withContext emptyList() + val found = Channel(Channel.UNLIMITED) + val listener = discoveryListener(found) + val multicastLock = acquireMulticastLock() + try { + try { + nsd.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "discoverServices rejected: ${e.message}") + return@withContext emptyList() + } + val results = LinkedHashMap() + try { + withTimeoutOrNull(timeoutMs.toLong()) { + for (info in found) { + val host = resolveOne(nsd, info) + if (host != null) results[host.id] = host + } + } + } finally { + runCatching { nsd.stopServiceDiscovery(listener) } + found.close() + } + results.values.toList() + } finally { + multicastLock?.let { if (it.isHeld) runCatching { it.release() } } + } + } + + private fun acquireMulticastLock(): WifiManager.MulticastLock? { + val wifi = context.getSystemService(Context.WIFI_SERVICE) as? WifiManager ?: return null + return runCatching { + wifi.createMulticastLock(MULTICAST_LOCK_TAG).apply { + setReferenceCounted(false) + acquire() + } + }.getOrNull() + } + + private fun discoveryListener(found: Channel): NsdManager.DiscoveryListener = + object : NsdManager.DiscoveryListener { + override fun onServiceFound(serviceInfo: NsdServiceInfo) { + found.trySend(serviceInfo) + } + + override fun onServiceLost(serviceInfo: NsdServiceInfo) = Unit + + override fun onDiscoveryStarted(serviceType: String) = Unit + + override fun onDiscoveryStopped(serviceType: String) = Unit + + override fun onStartDiscoveryFailed( + serviceType: String, + errorCode: Int, + ) { + Log.w(TAG, "discovery start failed: $errorCode") + found.close() + } + + override fun onStopDiscoveryFailed( + serviceType: String, + errorCode: Int, + ) = Unit + } + + @Suppress("DEPRECATION") + private suspend fun resolveOne( + nsd: NsdManager, + info: NsdServiceInfo, + ): MoonlightHost? = + suspendCancellableCoroutine { cont -> + val listener = + object : NsdManager.ResolveListener { + override fun onResolveFailed( + si: NsdServiceInfo, + errorCode: Int, + ) { + if (cont.isActive) cont.resume(null) + } + + override fun onServiceResolved(si: NsdServiceInfo) { + if (cont.isActive) cont.resume(toHost(si)) + } + } + try { + nsd.resolveService(info, listener) + } catch (e: IllegalArgumentException) { + if (cont.isActive) cont.resume(null) + } + } + + @Suppress("DEPRECATION") + private fun toHost(info: NsdServiceInfo): MoonlightHost? { + val hostAddress = + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val addresses = info.hostAddresses + (addresses.firstOrNull { it is java.net.Inet4Address } ?: addresses.firstOrNull())?.hostAddress + } else { + info.host?.hostAddress + } + return mdnsServiceToHost(info.serviceName.orEmpty(), hostAddress, info.attributes.orEmpty()) + } + + private companion object { + const val TAG = "MdnsMoonlightDiscovery" + + // Moonlight advertises the HTTP server as _nvstream._tcp (index.adoc / rtsp.adoc). + const val SERVICE_TYPE = "_nvstream._tcp." + const val MULTICAST_LOCK_TAG = "Dish::MoonlightDiscovery" + } + } + +/** Pure builder so the TXT/name mapping is unit-testable without NsdManager. */ +internal fun mdnsServiceToHost( + serviceName: String, + hostAddress: String?, + txt: Map, +): MoonlightHost? { + val ip = hostAddress ?: return null + val uniqueId = txt["uniqueid"]?.let { String(it).trim() }.orEmpty() + return MoonlightHost( + name = serviceName.ifEmpty { ip }, + address = ip, + uniqueId = uniqueId, + ) +} diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnection.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnection.kt new file mode 100644 index 00000000..083a85c9 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnection.kt @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.source.connection.moonlight + +import com.tinkernorth.dish.core.net.moonlight.MoonlightControlProtocol +import com.tinkernorth.dish.core.net.moonlight.MoonlightControlSession +import com.tinkernorth.dish.core.net.moonlight.MoonlightEmulatedType +import com.tinkernorth.dish.core.net.moonlight.MoonlightEvent +import com.tinkernorth.dish.core.net.moonlight.MoonlightHost +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * One Moonlight host session, the sibling of + * [com.tinkernorth.dish.source.connection.SatelliteConnection]. Holds the live + * control session and forwards the on-screen (and, once bound natively, the + * physical) controller state to it. The manager owns pairing and launch; this + * class owns the live-session lifecycle and the hot send path. + */ +class MoonlightConnection( + val id: String, + host: MoonlightHost, + private val scope: CoroutineScope, + private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher, +) { + private val _host = MutableStateFlow(host) + val host: StateFlow = _host.asStateFlow() + + private val _state = MutableStateFlow(MoonlightSessionState.Idle) + val state: StateFlow = _state.asStateFlow() + + @Volatile private var session: MoonlightControlSession? = null + private var pumpJob: Job? = null + + // The emulated type chosen for this session and whether the arrival was sent. + @Volatile private var emulatedType: Int = MoonlightEmulatedType.AUTO + + @Volatile private var arrivalSent = false + + // Inbound feedback (rumble/LED/motion request) surfaced to the same plumbing + // the satellite path uses; the manager wires the actual sinks. + @Volatile var onFeedback: (MoonlightEvent) -> Unit = {} + + fun updateHost(host: MoonlightHost) { + _host.value = host + } + + fun markLaunching() { + if (_state.value == MoonlightSessionState.Live) return + _state.value = MoonlightSessionState.Launching + } + + /** + * Adopt a connected control session and start the receive/ping pump. The + * pump owns liveness: when the ENet layer drops, the session flips to Closed + * and this connection returns to Idle. + */ + fun markLive( + session: MoonlightControlSession, + emulatedType: Int, + capabilities: Int, + supportedButtons: Int, + ) { + this.session = session + this.emulatedType = MoonlightEmulatedType.resolve(emulatedType) + arrivalSent = false + session.sendControllerArrival(0, this.emulatedType, capabilities, supportedButtons) + arrivalSent = true + _state.value = MoonlightSessionState.Live + pumpJob = + scope.launch(ioDispatcher) { + while (isActive && session.state == MoonlightControlSession.State.CONNECTED) { + session.pump() + } + if (_state.value == MoonlightSessionState.Live) markDisconnected() + } + } + + /** HOT PATH: forward the current controller state to the live session. */ + @Suppress("LongParameterList") + fun sendControllerState( + buttons: Int, + leftTrigger: Int, + rightTrigger: Int, + leftX: Int, + leftY: Int, + rightX: Int, + rightY: Int, + ) { + val live = session ?: return + live.sendControllerState( + controllerNumber = 0, + activeMask = 0x0001, + buttons = buttons and 0xFFFF, + leftTrigger = leftTrigger, + rightTrigger = rightTrigger, + leftStickX = leftX, + leftStickY = leftY, + rightStickX = rightX, + rightStickY = rightY, + ) + } + + fun dispatchFeedback(event: MoonlightEvent) { + onFeedback(event) + } + + fun markDisconnected() { + pumpJob?.cancel() + pumpJob = null + session?.let { s -> scope.launch(ioDispatcher) { runCatching { s.stop() } } } + session = null + arrivalSent = false + _state.value = MoonlightSessionState.Idle + } + + companion object { + const val ID_PREFIX = MoonlightHost.ID_PREFIX + + // Capabilities the dish's virtual/physical pad advertises to the host: + // analog triggers + rumble (Android has no LED/gyro sink for this path yet). + const val BASE_CAPABILITIES = MoonlightControlProtocol.CAP_ANALOG_TRIGGERS or MoonlightControlProtocol.CAP_RUMBLE + + // XInput-style buttons the pad supports (low 16 bits, shared layout). + const val SUPPORTED_BUTTONS = 0xFFFF + } +} + +enum class MoonlightSessionState { Idle, Launching, Live } diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt new file mode 100644 index 00000000..f0b75a09 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.source.connection.moonlight + +import android.util.Log +import androidx.core.content.edit +import com.tinkernorth.dish.core.net.bytesToHex +import com.tinkernorth.dish.core.net.moonlight.MoonlightControlSession +import com.tinkernorth.dish.core.net.moonlight.MoonlightCrypto +import com.tinkernorth.dish.core.net.moonlight.MoonlightHost +import com.tinkernorth.dish.core.net.moonlight.MoonlightIdentity +import com.tinkernorth.dish.core.net.moonlight.MoonlightPairing +import com.tinkernorth.dish.core.net.moonlight.MoonlightUrls +import com.tinkernorth.dish.core.net.moonlight.MoonlightXml +import com.tinkernorth.dish.core.net.moonlight.RememberedMoonlight +import com.tinkernorth.dish.di.IoDispatcher +import com.tinkernorth.dish.repository.RememberedMoonlightRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +sealed class MoonlightConnectionEvent { + /** The dish generated [pin]; the user must type it into the host's web UI. */ + data class PairingPinReady( + val host: MoonlightHost, + val pin: String, + ) : MoonlightConnectionEvent() + + data class Error( + val message: String, + ) : MoonlightConnectionEvent() + + data class Paired( + val host: MoonlightHost, + ) : MoonlightConnectionEvent() +} + +/** + * Orchestrates the Moonlight host path: discovery, PIN pairing, app launch, the + * RTSP stream setup, and the live control session. The sibling of + * [com.tinkernorth.dish.source.connection.SatelliteConnectionManager]; it holds + * the same shape (a connections map, a discovered list, an events flow) so the + * composer and coordinator treat both paths uniformly. + * + * The end-to-end launch/stream flow has not been exercised against a live host + * in this change (see the PR's known gaps); the protocol pieces it composes are + * unit-tested byte-for-byte against Wolf's vectors. + */ +@Singleton +class MoonlightConnectionManager + @Inject + constructor( + @ApplicationContext private val context: android.content.Context, + private val scope: CoroutineScope, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val discovery: MdnsMoonlightDiscovery, + private val gateway: MoonlightHttpGateway, + private val identity: MoonlightIdentity, + private val store: RememberedMoonlightRepository, + ) { + private val _connections = MutableStateFlow>(emptyMap()) + val connections: StateFlow> = _connections.asStateFlow() + + private val _discovered = MutableStateFlow>(emptyList()) + val discovered: StateFlow> = _discovered.asStateFlow() + + private val _isScanning = MutableStateFlow(false) + val isScanning: StateFlow = _isScanning.asStateFlow() + + private val _events = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 8, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val events: SharedFlow = _events.asSharedFlow() + + val remembered: StateFlow> get() = store.entries + + private val deviceId by lazy { getOrCreateUniqueId() } + + fun get(id: String): MoonlightConnection? = _connections.value[id] + + fun startDiscovery() { + if (!_isScanning.compareAndSet(expect = false, update = true)) return + scope.launch { + _discovered.value = runCatching { discovery.discover(DISCOVERY_TIMEOUT_MS) }.getOrDefault(emptyList()) + _isScanning.value = false + } + } + + /** Probe a manually typed address and add it if it answers /serverinfo. */ + fun addManualHost(address: String) { + scope.launch(ioDispatcher) { + val info = + gateway + .getHttp(MoonlightUrls.serverInfoHttp(address, MoonlightHost.DEFAULT_HTTP_PORT, deviceId)) + .takeIf { it.ok } + ?.let { MoonlightXml.parseServerInfo(it.body) } + if (info == null) { + _events.emit(MoonlightConnectionEvent.Error("No Moonlight host answered at $address.")) + return@launch + } + val host = + MoonlightHost( + name = info.hostname.ifEmpty { address }, + address = address, + httpPort = externalPortOr(info), + httpsPort = info.httpsPort ?: MoonlightHost.DEFAULT_HTTPS_PORT, + uniqueId = info.uniqueId, + manual = true, + ) + _discovered.updateAndGetHost(host) + } + } + + private fun MutableStateFlow>.updateAndGetHost(host: MoonlightHost) { + value = (value.filterNot { it.id == host.id } + host) + } + + private fun externalPortOr(info: MoonlightXml.ServerInfo): Int = info.externalPort ?: MoonlightHost.DEFAULT_HTTP_PORT + + private fun findOrCreate(host: MoonlightHost): MoonlightConnection { + val id = host.id + return _connections + .updateAndGet { map -> + if (map.containsKey(id)) map else map + (id to MoonlightConnection(id, host, scope, ioDispatcher)) + }[id]!! + } + + /** Pair with (if needed) and launch [emulatedType] on [host]. */ + fun connect( + host: MoonlightHost, + emulatedType: Int, + ) { + val conn = findOrCreate(host) + conn.updateHost(host) + conn.markLaunching() + scope.launch(ioDispatcher) { + val paired = isPaired(host) + if (!paired && !pair(host)) { + conn.markDisconnected() + return@launch + } + launchAndStream(conn, host, emulatedType) + } + } + + private fun isPaired(host: MoonlightHost): Boolean { + val reply = gateway.getHttps(MoonlightUrls.serverInfoHttps(host.address, host.httpsPort, deviceId), host.id) + if (!reply.ok) return false + return MoonlightXml.parseServerInfo(reply.body)?.paired == true + } + + /** Runs the 5-phase pairing; phase 1 blocks until the user enters the PIN. */ + @Suppress("ReturnCount") // each early return is a distinct phase-failure bail + private suspend fun pair(host: MoonlightHost): Boolean { + val pin = randomPin() + _events.emit(MoonlightConnectionEvent.PairingPinReady(host, pin)) + val pairing = MoonlightPairing(identity, pin) + return runCatching { + // Phase 1 (HTTP): the host prompts for the PIN and blocks until entered. + val p1 = gateway.getHttp(MoonlightUrls.pairHttp(host.address, host.httpPort, pairing.phase1Params(deviceId))) + val cert = MoonlightXml.parsePairReply(p1.body)?.plainCert ?: return false + pairing.onPhase1( + String( + com.tinkernorth.dish.core.net + .hexToBytes(cert), + Charsets.US_ASCII, + ), + ) + + val p2 = gateway.getHttp(MoonlightUrls.pairHttp(host.address, host.httpPort, pairing.phase2Params(deviceId))) + val challenge = MoonlightXml.parsePairReply(p2.body)?.challengeResponse ?: return false + if (!pairing.onPhase2(challenge)) return false + + val p3 = gateway.getHttp(MoonlightUrls.pairHttp(host.address, host.httpPort, pairing.phase3Params(deviceId))) + val secret = MoonlightXml.parsePairReply(p3.body)?.pairingSecret ?: return false + if (!pairing.onPhase3(secret)) return false + + val p4 = gateway.getHttp(MoonlightUrls.pairHttp(host.address, host.httpPort, pairing.phase4Params(deviceId))) + if (MoonlightXml.parsePairReply(p4.body)?.paired != true) return false + + // Phase 5 (HTTPS): confirm the client-cert-authenticated channel. + gateway.getHttps(MoonlightUrls.pairHttps(host.address, host.httpsPort, pairing.phase5Params(deviceId)), host.id) + rememberPaired(host) + _events.emit(MoonlightConnectionEvent.Paired(host)) + true + }.getOrElse { + Log.w(TAG, "pairing failed for ${host.address}: ${it.message}") + _events.emit(MoonlightConnectionEvent.Error("Pairing failed. Confirm the PIN on the host and try again.")) + false + } + } + + private suspend fun launchAndStream( + conn: MoonlightConnection, + host: MoonlightHost, + emulatedType: Int, + ) { + val rikey = MoonlightCrypto.randomBytes(RIKEY_LEN) + val rikeyId = + MoonlightCrypto.randomBytes(4).let { + (it[0].toInt() and 0xFF) or ((it[1].toInt() and 0xFF) shl 8) or + ((it[2].toInt() and 0xFF) shl 16) or ((it[3].toInt() and 0xFF) shl 24) + } + val appId = + store.get(host.id)?.lastAppId?.takeIf { it.isNotEmpty() } ?: defaultAppId(host) ?: run { + conn.markDisconnected() + _events.emit(MoonlightConnectionEvent.Error("No apps available on ${host.name}.")) + return + } + val launchUrl = + MoonlightUrls.launch(host.address, host.httpsPort, deviceId, appId, bytesToHex(rikey), rikeyId, LAUNCH_MODE) + val launchReply = gateway.getHttps(launchUrl, host.id) + val rtspPort = parseRtspPort(launchReply.body) + if (!launchReply.ok || rtspPort == null) { + conn.markDisconnected() + _events.emit(MoonlightConnectionEvent.Error("Couldn't start a session on ${host.name}.")) + return + } + val rtsp = MoonlightRtspClient(host.address, rtspPort).handshake(LAUNCH_WIDTH, LAUNCH_HEIGHT, LAUNCH_FPS) + if (rtsp == null) { + conn.markDisconnected() + _events.emit(MoonlightConnectionEvent.Error("Stream setup failed on ${host.name}.")) + return + } + val transport = runCatching { UdpControlTransport(host.address, rtsp.controlPort) }.getOrNull() + if (transport == null) { + conn.markDisconnected() + return + } + val session = + MoonlightControlSession(rikey, rtsp.enetConnectData, transport, System::currentTimeMillis) { event -> + conn.dispatchFeedback(event) + } + if (!session.connect()) { + conn.markDisconnected() + _events.emit(MoonlightConnectionEvent.Error("Control channel did not connect on ${host.name}.")) + return + } + conn.markLive( + session, + emulatedType, + MoonlightConnection.BASE_CAPABILITIES, + MoonlightConnection.SUPPORTED_BUTTONS, + ) + rememberPaired(host, appId) + } + + private fun defaultAppId(host: MoonlightHost): String? { + val reply = gateway.getHttps(MoonlightUrls.appList(host.address, host.httpsPort, deviceId), host.id) + return MoonlightXml.parseAppList(reply.body).firstOrNull()?.id + } + + fun disconnect(id: String) { + _connections.value[id]?.markDisconnected() + } + + fun forget(id: String) { + disconnect(id) + store.remove(id) + _connections.updateAndGet { it - id } + } + + private fun rememberPaired( + host: MoonlightHost, + appId: String = store.get(host.id)?.lastAppId.orEmpty(), + ) { + store.put( + RememberedMoonlight( + id = host.id, + name = host.name, + address = host.address, + httpPort = host.httpPort, + httpsPort = host.httpsPort, + uniqueId = host.uniqueId, + lastAppId = appId, + ), + ) + } + + // The /launch response carries sessionUrl0 = rtsp://ip:port; pull the port. + private fun parseRtspPort(xml: String): Int? = + Regex("rtsp://[^:<]+:(\\d+)") + .find(xml) + ?.groupValues + ?.get(1) + ?.toIntOrNull() + + private fun randomPin(): String { + val n = java.security.SecureRandom().nextInt(PIN_RANGE) + return "%04d".format(n) + } + + private fun getOrCreateUniqueId(): String { + val prefs = context.getSharedPreferences("moonlight", android.content.Context.MODE_PRIVATE) + return prefs.getString("uniqueid", null) ?: java.util.UUID + .randomUUID() + .toString() + .replace("-", "") + .take(16) + .also { id -> prefs.edit { putString("uniqueid", id) } } + } + + private companion object { + const val TAG = "MoonlightConnectionMgr" + const val DISCOVERY_TIMEOUT_MS = 4000 + const val RIKEY_LEN = 16 + const val PIN_RANGE = 10_000 + const val LAUNCH_MODE = "1280x720x30" + const val LAUNCH_WIDTH = 1280 + const val LAUNCH_HEIGHT = 720 + const val LAUNCH_FPS = 30 + } + } diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt new file mode 100644 index 00000000..d7903c3a --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.source.connection.moonlight + +import android.util.Log +import com.tinkernorth.dish.core.net.moonlight.MoonlightIdentity +import com.tinkernorth.dish.repository.SatellitePinRepository +import com.tinkernorth.dish.repository.TofuVerdict +import com.tinkernorth.dish.repository.sha256FingerprintHex +import com.tinkernorth.dish.repository.tofuVerdict +import java.io.IOException +import java.net.URL +import java.security.KeyStore +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.inject.Inject +import javax.inject.Singleton +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSession +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +/** + * Opens the Moonlight HTTP (47989, plaintext) and HTTPS (47984, mutual-TLS) + * requests. HTTPS presents the dish's client certificate (the host authorises + * paired clients purely by the client cert, Wolf custom-https.cpp) and pins the + * host cert on first use, mirroring the satellite + * [com.tinkernorth.dish.core.net.SatelliteHttpClient] TOFU verifier. + * + * All methods BLOCK; call from Dispatchers.IO. This is runtime plumbing; the URL + * building and XML parsing it drives are unit-tested separately. + */ +@Singleton +class MoonlightHttpGateway + @Inject + constructor( + private val identity: MoonlightIdentity, + private val pins: SatellitePinRepository, + ) { + data class Reply( + val status: Int, + val body: String, + ) { + val unreachable: Boolean get() = status == 0 || body.isBlank() + val ok: Boolean get() = status in 200..299 + } + + /** Plaintext GET (serverinfo / pair phases 1-4). */ + fun getHttp(url: String): Reply = request(url, secure = false, hostId = null) + + /** Mutual-TLS GET (serverinfo / pair phase 5 / applist / launch / resume / cancel). */ + fun getHttps( + url: String, + hostId: String, + ): Reply = request(url, secure = true, hostId = hostId) + + private fun request( + urlString: String, + secure: Boolean, + hostId: String?, + ): Reply { + val url = URL(urlString) + var connection: java.net.HttpURLConnection? = null + return try { + connection = + openConnection(url, secure, hostId).apply { + requestMethod = "GET" + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + } + readReply(connection) + } catch (e: IOException) { + Log.w(TAG, "request failed for ${url.path}: ${e.message}") + Reply(0, "") + } finally { + connection?.disconnect() + } + } + + private fun openConnection( + url: URL, + secure: Boolean, + hostId: String?, + ): java.net.HttpURLConnection { + val raw = url.openConnection() + if (!secure) return raw as java.net.HttpURLConnection + return (raw as HttpsURLConnection).apply { + sslSocketFactory = mutualTlsFactory() + hostnameVerifier = tofuVerifier(hostId!!) + } + } + + private fun readReply(connection: java.net.HttpURLConnection): Reply { + val status = connection.responseCode + val stream = if (status in 200..299) connection.inputStream else connection.errorStream + val text = stream?.use { it.readBytes().toString(Charsets.UTF_8) }.orEmpty() + return Reply(status, text) + } + + // Present the client certificate; the host authorises by it after pairing. + private fun mutualTlsFactory(): javax.net.ssl.SSLSocketFactory { + val keyStore = + KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(null) + setKeyEntry( + "client", + identity.privateKey, + CharArray(0), + arrayOf( + com.tinkernorth.dish.core.net.moonlight.MoonlightCert + .parse(identity.certificatePem), + ), + ) + } + val kmf = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { + init(keyStore, CharArray(0)) + } + return SSLContext + .getInstance("TLS") + .apply { init(kmf.keyManagers, arrayOf(trustAll), SecureRandom()) } + .socketFactory + } + + // TOFU: accept any self-signed host cert on first contact and pin it, then + // reject any future mismatch (the sole MITM gate; the LAN cert has no CA). + private fun tofuVerifier(hostId: String): HostnameVerifier = + HostnameVerifier { _: String?, session: SSLSession? -> + val cert = session?.peerCertificates?.firstOrNull() ?: return@HostnameVerifier false + val presented = sha256FingerprintHex(cert.encoded) + when (tofuVerdict(pins.pinnedFingerprint(hostId), presented)) { + TofuVerdict.TRUST_FIRST_USE -> { + pins.pin(hostId, presented) + true + } + TofuVerdict.MATCH -> true + TofuVerdict.MISMATCH -> { + Log.e(TAG, "cert pin MISMATCH for $hostId, aborting (possible MITM)") + false + } + } + } + + @Suppress("CustomX509TrustManager", "TrustAllX509TrustManager") + private val trustAll: TrustManager = + object : X509TrustManager { + override fun checkClientTrusted( + chain: Array?, + authType: String?, + ) = Unit + + override fun checkServerTrusted( + chain: Array?, + authType: String?, + ) = Unit + + override fun getAcceptedIssuers(): Array = emptyArray() + } + + private companion object { + const val TAG = "MoonlightHttpGateway" + const val TIMEOUT_MS = 5_000 + } + } diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightIdentityProvider.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightIdentityProvider.kt new file mode 100644 index 00000000..2dccf4e2 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightIdentityProvider.kt @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.source.connection.moonlight + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import com.tinkernorth.dish.core.net.moonlight.MoonlightIdentity +import java.math.BigInteger +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.cert.X509Certificate +import java.util.Calendar +import javax.inject.Inject +import javax.inject.Singleton +import javax.security.auth.x500.X500Principal + +/** + * The dish's persistent Moonlight client identity, generated once and stored in + * the Android keystore (Wolf http-pairing.adoc: a self-signed cert + RSA key + * reused for every host). AndroidKeyStore auto-generates the self-signed + * certificate for us, keeping the platform-APIs-only, BouncyCastle-free rule and + * keeping the private key non-exportable. Pairing signs with it via + * [com.tinkernorth.dish.core.net.moonlight.MoonlightCrypto.signRsaSha256]. + * + * The pairing crypto is unit-tested against file-backed identities; this + * keystore path is the runtime supplier and is exercised only on device. + */ +@Singleton +class MoonlightIdentityProvider + @Inject + constructor() : MoonlightIdentity { + private val identity: LoadedIdentity by lazy { loadOrCreate() } + + override val certificatePem: String get() = identity.certificatePem + override val certificateSignature: ByteArray get() = identity.certificate.signature + override val privateKey: PrivateKey get() = identity.privateKey + + private data class LoadedIdentity( + val certificate: X509Certificate, + val certificatePem: String, + val privateKey: PrivateKey, + ) + + private fun loadOrCreate(): LoadedIdentity { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + if (!keyStore.containsAlias(ALIAS)) generateKeyPair() + val certificate = keyStore.getCertificate(ALIAS) as X509Certificate + val privateKey = keyStore.getKey(ALIAS, null) as PrivateKey + return LoadedIdentity(certificate, toPem(certificate), privateKey) + } + + private fun generateKeyPair() { + val notBefore = Calendar.getInstance() + val notAfter = (notBefore.clone() as Calendar).apply { add(Calendar.YEAR, CERT_VALIDITY_YEARS) } + val spec = + KeyGenParameterSpec + .Builder(ALIAS, KeyProperties.PURPOSE_SIGN) + .setKeySize(RSA_KEY_SIZE) + .setDigests(KeyProperties.DIGEST_SHA256) + .setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) + .setCertificateSubject(X500Principal(CERT_SUBJECT)) + .setCertificateSerialNumber(BigInteger.ONE) + .setCertificateNotBefore(notBefore.time) + .setCertificateNotAfter(notAfter.time) + .build() + KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, ANDROID_KEYSTORE).apply { + initialize(spec) + generateKeyPair() + } + } + + private fun toPem(certificate: X509Certificate): String { + // android.util.Base64 (API 1) instead of java.util.Base64 (API 26); wrap at the + // PEM 64-char width manually since NO_WRAP emits a single line. + val body = + android.util.Base64 + .encodeToString(certificate.encoded, android.util.Base64.NO_WRAP) + .chunked(PEM_LINE_LEN) + .joinToString("\n") + return "-----BEGIN CERTIFICATE-----\n$body\n-----END CERTIFICATE-----\n" + } + + private companion object { + const val ANDROID_KEYSTORE = "AndroidKeyStore" + const val ALIAS = "dish-moonlight-client" + const val RSA_KEY_SIZE = 2048 + const val CERT_VALIDITY_YEARS = 20 + const val CERT_SUBJECT = "CN=NVIDIA GameStream Client" + const val PEM_LINE_LEN = 64 + } + } diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightRtspClient.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightRtspClient.kt new file mode 100644 index 00000000..51fba1d6 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightRtspClient.kt @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.source.connection.moonlight + +import android.util.Log +import com.tinkernorth.dish.core.net.moonlight.MoonlightRtsp +import java.io.BufferedReader +import java.net.InetSocketAddress +import java.net.Socket + +/** + * Runs the plaintext RTSP handshake (OPTIONS -> DESCRIBE -> SETUP x3 -> ANNOUNCE + * -> PLAY) over TCP and returns the negotiated control port plus the ENet + * connect-data token the host handed back in the control SETUP (Wolf + * rtsp/commands.hpp setup(): X-SS-Connect-Data). Video/audio are negotiated at + * the lowest settings and their payloads are never decoded. + * + * Message framing is delegated to the pure [MoonlightRtsp] codec; this class + * owns only the socket and the CSeq counter. + */ +class MoonlightRtspClient( + private val address: String, + private val rtspPort: Int, +) { + data class StreamPorts( + val controlPort: Int, + val videoPort: Int, + val audioPort: Int, + val enetConnectData: Int, + ) + + private var cseq = 0 + + @Suppress("ReturnCount") // each early return is a distinct RTSP step failing + fun handshake( + width: Int, + height: Int, + fps: Int, + ): StreamPorts? { + Socket().use { socket -> + socket.connect(InetSocketAddress(address, rtspPort), CONNECT_TIMEOUT_MS) + socket.soTimeout = READ_TIMEOUT_MS + val target = "rtsp://$address:$rtspPort" + val out = socket.getOutputStream() + val reader = socket.getInputStream().bufferedReader() + + if (!exchange(out, reader, MoonlightRtsp.options(target, nextCseq()))) return null + if (!exchange(out, reader, MoonlightRtsp.describe(target, nextCseq()))) return null + + val audio = setupPort(out, reader, "audio") ?: return null + val video = setupPort(out, reader, "video") ?: return null + val controlResp = setupResponse(out, reader, "control") ?: return null + val controlPort = controlResp.serverPort() ?: return null + val connectData = controlResp.options["X-SS-Connect-Data"]?.trim()?.toIntOrNull() ?: 0 + + val sdp = MoonlightRtsp.minimalAnnounceSdp(width, height, fps) + if (!exchange(out, reader, MoonlightRtsp.announce(target, nextCseq(), sdp))) return null + if (!exchange(out, reader, MoonlightRtsp.play(target, nextCseq()))) return null + + return StreamPorts(controlPort, video, audio, connectData) + } + } + + private fun setupPort( + out: java.io.OutputStream, + reader: BufferedReader, + streamId: String, + ): Int? = setupResponse(out, reader, streamId)?.serverPort() + + private fun setupResponse( + out: java.io.OutputStream, + reader: BufferedReader, + streamId: String, + ): MoonlightRtsp.Response? { + out.write(MoonlightRtsp.setup(streamId, nextCseq()).encode().toByteArray()) + out.flush() + return readResponse(reader)?.takeIf { it.ok } + } + + private fun exchange( + out: java.io.OutputStream, + reader: BufferedReader, + request: MoonlightRtsp.Request, + ): Boolean { + out.write(request.encode().toByteArray()) + out.flush() + return readResponse(reader)?.ok == true + } + + // Read one RTSP response: status + headers until a blank line, then the + // body if Content-length says there is one. + private fun readResponse(reader: BufferedReader): MoonlightRtsp.Response? { + val header = StringBuilder() + var line = reader.readLine() ?: return null + while (line.isNotEmpty()) { + header.append(line).append(MoonlightRtsp.CRLF) + line = reader.readLine() ?: break + } + header.append(MoonlightRtsp.CRLF) + val contentLength = + Regex("(?i)content-length:\\s*(\\d+)") + .find(header) + ?.groupValues + ?.get(1) + ?.toIntOrNull() ?: 0 + val body = if (contentLength > 0) CharArray(contentLength).also { reader.read(it) }.concatToString() else "" + return MoonlightRtsp.parseResponse(header.toString() + body).also { + if (it == null) Log.w(TAG, "unparsable RTSP response") + } + } + + private fun nextCseq(): Int = ++cseq + + private companion object { + const val TAG = "MoonlightRtspClient" + const val CONNECT_TIMEOUT_MS = 5_000 + const val READ_TIMEOUT_MS = 5_000 + } +} diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/UdpControlTransport.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/UdpControlTransport.kt new file mode 100644 index 00000000..13f5b528 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/UdpControlTransport.kt @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.source.connection.moonlight + +import com.tinkernorth.dish.core.net.moonlight.MoonlightControlSession +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.SocketTimeoutException + +/** + * A UDP-socket [MoonlightControlSession.Transport] connected to the host's + * negotiated control port. The session is single-threaded per the ENet client's + * contract, so one socket bound to the host endpoint is enough. + */ +class UdpControlTransport( + address: String, + port: Int, +) : MoonlightControlSession.Transport { + private val socket = DatagramSocket() + private val host = InetAddress.getByName(address) + private val hostPort = port + private val recvBuffer = ByteArray(MAX_DATAGRAM) + + init { + socket.connect(host, port) + } + + override fun send(datagram: ByteArray) { + socket.send(DatagramPacket(datagram, datagram.size, host, hostPort)) + } + + // A read timeout is the normal "no datagram this tick" signal, not an error to propagate. + @Suppress("SwallowedException") + override fun receive(timeoutMs: Int): ByteArray? { + socket.soTimeout = timeoutMs.coerceAtLeast(1) + val packet = DatagramPacket(recvBuffer, recvBuffer.size) + return try { + socket.receive(packet) + recvBuffer.copyOf(packet.length) + } catch (timeout: SocketTimeoutException) { + null + } + } + + override fun close() { + runCatching { socket.close() } + } + + private companion object { + const val MAX_DATAGRAM = 2048 + } +} diff --git a/app/src/main/java/com/tinkernorth/dish/ui/common/ConnectionGlyphs.kt b/app/src/main/java/com/tinkernorth/dish/ui/common/ConnectionGlyphs.kt index 5388bd3f..56f29ed5 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/common/ConnectionGlyphs.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/common/ConnectionGlyphs.kt @@ -34,6 +34,8 @@ fun glyphForConnection( LinkState.Saved, LinkState.Stale -> R.drawable.ic_bluetooth_off else -> R.drawable.ic_bluetooth } + // The Moonlight host is a PC; one glyph across states (no per-state art yet). + ConnectionKind.MOONLIGHT -> R.drawable.ic_pc_monitor } @androidx.annotation.ColorRes @@ -79,6 +81,7 @@ fun AppCompatActivity.showConnectionDialog(summary: ConnectionSummary?) { when (summary?.kind) { ConnectionKind.SATELLITE -> getString(R.string.overlay_connection_kind_satellite) ConnectionKind.BLUETOOTH -> getString(R.string.overlay_connection_kind_bluetooth) + ConnectionKind.MOONLIGHT -> getString(R.string.overlay_connection_kind_moonlight) null -> getString(R.string.overlay_status_unknown) } val stateLabel = diff --git a/app/src/main/java/com/tinkernorth/dish/ui/main/ControllerAdapter.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/ControllerAdapter.kt index 83cd2aea..d054e424 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/main/ControllerAdapter.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/main/ControllerAdapter.kt @@ -290,6 +290,11 @@ class ControllerAdapter( ctx.getString(bundledControllerTypeLabelRes(type)) } ConnectionKind.BLUETOOTH -> bound.btProfile + // Moonlight names its emulated device the same way the satellite catalog does. + ConnectionKind.MOONLIGHT -> { + val type = bound.satelliteControllerTypes[row.slot.id] ?: CONTROLLER_TYPE_XBOX + ctx.getString(bundledControllerTypeLabelRes(type)) + } } private fun bindFunctionPills(specs: List) { diff --git a/app/src/main/java/com/tinkernorth/dish/ui/main/GamepadOverlayActivity.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/GamepadOverlayActivity.kt index 8c691bb6..56733c08 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/main/GamepadOverlayActivity.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/main/GamepadOverlayActivity.kt @@ -47,6 +47,8 @@ class GamepadOverlayActivity : GamepadTouchView.Listener { @Inject lateinit var btRegistry: BluetoothGamepadRegistry + @Inject lateinit var moonlight: com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionManager + @Inject lateinit var capabilityComposer: CapabilityComposer private lateinit var binding: ActivityGamepadOverlayBinding @@ -310,9 +312,25 @@ class GamepadOverlayActivity : btRegistry.sendReport(connectionId, report) } ConnectionKind.SATELLITE -> sendSatelliteReport(state) + ConnectionKind.MOONLIGHT -> sendMoonlightReport(state) } } + // Moonlight's low-16 button flags share XInput's bit layout, so the XUSB + // wButtons map straight across; sticks (i16) and triggers (u8) match too. + private fun sendMoonlightReport(state: GamepadTouchView.GamepadState) { + val wButtons = hidToXusb(state.buttons, state.hatSwitch) + moonlight.get(connectionId)?.sendControllerState( + buttons = wButtons, + leftTrigger = state.leftTrigger, + rightTrigger = state.rightTrigger, + leftX = state.leftX.toInt(), + leftY = state.leftY.toInt(), + rightX = state.rightX.toInt(), + rightY = state.rightY.toInt(), + ) + } + // The touch view emits HID-layout button bits + a separate hat-switch; the // satellite path wants XUSB `wButtons` with the d-pad folded into the low nibble. private fun sendSatelliteReport(state: GamepadTouchView.GamepadState) { diff --git a/app/src/main/res/values-bs/strings.xml b/app/src/main/res/values-bs/strings.xml index b744ddc7..464f3aeb 100644 --- a/app/src/main/res/values-bs/strings.xml +++ b/app/src/main/res/values-bs/strings.xml @@ -114,6 +114,7 @@ Pokret Satelit Bluetooth + Moonlight host Gamepad Dodirna ploča Dodir %1$s @@ -129,6 +130,7 @@ %1$s • %2$s + Moonlight • %1$s Spreman za uparivanje. Pronađite ovaj uređaj na svom hostu Preuzimanje HID profila… Neaktivan diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2c460e21..27cfb383 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -113,6 +113,7 @@ Bewegung Satellit Bluetooth + Moonlight-Host Gamepad Touchpad Touch %1$s @@ -129,6 +130,7 @@ %1$s • %2$s + Moonlight • %1$s Bereit zum Koppeln. Suche dieses Gerät auf deinem Host HID-Profil wird abgerufen… Inaktiv diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 9023d391..f15f091f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -115,6 +115,7 @@ Movimiento Satélite Bluetooth + Host Moonlight Mando Panel táctil Táctil %1$s @@ -131,6 +132,7 @@ %1$s • %2$s + Moonlight • %1$s Listo para emparejar. Busca este dispositivo en tu host Adquiriendo perfil HID… Inactivo diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 537dd2c6..9fe674a6 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -114,6 +114,7 @@ Mouvement Satellite Bluetooth + Hôte Moonlight Manette Pavé tactile Tactile %1$s @@ -130,6 +131,7 @@ %1$s • %2$s + Moonlight • %1$s Prête à appairer : repérez cet appareil sur votre hôte Acquisition du profil HID… Inactive diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index bd92984e..c5868742 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -115,6 +115,7 @@ Movimento Satélite Bluetooth + Host Moonlight Controle Touchpad Toque %1$s @@ -131,6 +132,7 @@ %1$s • %2$s + Moonlight • %1$s Pronto para parear. Procure este dispositivo no seu host Adquirindo perfil HID… Inativo diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8b215090..09d8779e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -140,6 +140,7 @@ Motion Satellite Bluetooth + Moonlight host @@ -167,6 +168,7 @@ %1$s • %2$s + Moonlight • %1$s Ready to pair. Find this device on your host Acquiring HID profile… Idle diff --git a/app/src/test/java/com/tinkernorth/dish/composer/ConnectionCoordinatorTest.kt b/app/src/test/java/com/tinkernorth/dish/composer/ConnectionCoordinatorTest.kt index 001c456f..3aafa090 100644 --- a/app/src/test/java/com/tinkernorth/dish/composer/ConnectionCoordinatorTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/composer/ConnectionCoordinatorTest.kt @@ -33,6 +33,7 @@ import org.junit.Test class ConnectionCoordinatorTest { private lateinit var satellite: SatelliteConnectionManager private lateinit var bt: BluetoothGamepadRegistry + private lateinit var moonlight: com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionManager private lateinit var store: ConnectionStore private lateinit var hostFeaturesStore: com.tinkernorth.dish.source.store.SatelliteHostFeaturesStore private lateinit var hostRuntimeStore: com.tinkernorth.dish.source.store.SatelliteHostRuntimeStore @@ -57,6 +58,11 @@ class ConnectionCoordinatorTest { fun setUp() { satellite = mockk(relaxed = true) bt = mockk(relaxed = true) + moonlight = mockk(relaxed = true) + // The composer's moonlightWorld combines these; give it real empty flows so it emits. + every { moonlight.connections } returns MutableStateFlow(emptyMap()) + every { moonlight.discovered } returns MutableStateFlow(emptyList()) + every { moonlight.remembered } returns MutableStateFlow(emptyList()) store = mockk(relaxed = true) hostFeaturesStore = mockk(relaxed = true) hostRuntimeStore = mockk(relaxed = true) @@ -115,6 +121,7 @@ class ConnectionCoordinatorTest { context = fakeStringContext(), satellite = satellite, bt = bt, + moonlight = moonlight, store = store, bindingStore = bindingStore, typeStore = typeStore, @@ -124,6 +131,7 @@ class ConnectionCoordinatorTest { ConnectionCoordinator( satellite = satellite, bt = bt, + moonlight = moonlight, store = store, bindingStore = bindingStore, typeStore = typeStore, diff --git a/app/src/test/java/com/tinkernorth/dish/composer/MoonlightLinkStateTest.kt b/app/src/test/java/com/tinkernorth/dish/composer/MoonlightLinkStateTest.kt new file mode 100644 index 00000000..fa707230 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/composer/MoonlightLinkStateTest.kt @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.composer + +import com.tinkernorth.dish.source.connection.moonlight.MoonlightSessionState +import org.junit.Assert.assertEquals +import org.junit.Test + +class MoonlightLinkStateTest { + @Test + fun `live maps to Connected and launching to Connecting`() { + assertEquals(LinkState.Connected, moonlightLinkState(MoonlightSessionState.Live, discovered = false)) + assertEquals(LinkState.Connecting, moonlightLinkState(MoonlightSessionState.Launching, discovered = true)) + } + + @Test + fun `idle is Ready when discovered, Saved otherwise`() { + assertEquals(LinkState.Ready, moonlightLinkState(MoonlightSessionState.Idle, discovered = true)) + assertEquals(LinkState.Saved, moonlightLinkState(MoonlightSessionState.Idle, discovered = false)) + assertEquals(LinkState.Saved, moonlightLinkState(null, discovered = false)) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlPacketTest.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlPacketTest.kt new file mode 100644 index 00000000..f399dc1b --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlPacketTest.kt @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import com.tinkernorth.dish.core.net.bytesToHex +import com.tinkernorth.dish.core.net.hexToBytes +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test +import javax.crypto.AEADBadTagException + +/** + * Pinned against the captured encrypted packets in Wolf's testControl.cpp + * ("Control AES Encryption"). The full framed packet must match byte-for-byte. + */ +class MoonlightControlPacketTest { + private val key = hexToBytes("edf04a215c4fbea20934120c8480d855") + + @Test + fun `seal matches Wolf's captured packets across evolving seq`() { + val packet = MoonlightControlPacket(key) + assertEquals( + "01001a0000000000bf0eb6da10e47c702ec8644eb87d9cf7b6fac9ff75ca", + bytesToHex(packet.sealWithSeq(0, hexToBytes("020302000000"))), + ) + assertEquals( + "010019000100000021dbb8dc0590af3a2b20bce5a347de31d366e5b9c5", + bytesToHex(packet.sealWithSeq(1, hexToBytes("0703010000"))), + ) + assertEquals( + "0100200002000000220722fbaded58a03f2e8898f0f1dcb7c93f6235590618e4186ad990", + bytesToHex(packet.sealWithSeq(2, hexToBytes("000208000400000000000000"))), + ) + assertEquals( + "01002a00060000005a4d999fb2542f85bdd39d99f77eb825254569d2c04e21241b5cec01bd3f93129718ecc1f153", + bytesToHex(packet.sealWithSeq(6, hexToBytes("060212000000000e05000000033400c00000059f0329"))), + ) + } + + @Test + fun `auto-incrementing seal then open round-trips`() { + val sender = MoonlightControlPacket(key) + val receiver = MoonlightControlPacket(key) + val p0 = sender.seal("first".toByteArray()) + val p1 = sender.seal("second".toByteArray()) + assertEquals("first", String(receiver.open(p0)!!)) + assertEquals("second", String(receiver.open(p1)!!)) + } + + @Test + fun `open rejects a tampered packet`() { + val packet = MoonlightControlPacket(key) + val sealed = packet.sealWithSeq(9, "payload".toByteArray()) + sealed[sealed.size - 2] = (sealed[sealed.size - 2].toInt() xor 0x01).toByte() + assertThrows(AEADBadTagException::class.java) { MoonlightControlPacket(key).open(sealed) } + } + + @Test + fun `open returns null on a short or wrong-type frame`() { + val packet = MoonlightControlPacket(key) + assertNull(packet.open(byteArrayOf(0x01, 0x00, 0x02))) + // Right length but the packet type is not ENCRYPTED. + val wrongType = byteArrayOf(0x02, 0x00, 0x1A, 0x00) + ByteArray(26) + assertNull(packet.open(wrongType)) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlSessionTest.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlSessionTest.kt new file mode 100644 index 00000000..ca4812bf --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlSessionTest.kt @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import com.tinkernorth.dish.core.net.hexToBytes +import com.tinkernorth.dish.core.net.moonlight.enet.EnetProtocol +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Lifecycle tests for [MoonlightControlSession] driven by a scripted fake + * transport: IDLE -> CONNECTING -> CONNECTED, controller sends, inbound event + * decode, and graceful teardown. + */ +class MoonlightControlSessionTest { + private val key = hexToBytes("edf04a215c4fbea20934120c8480d855") + private var clock = 0L + + /** A fake transport that captures sends and replays a queued receive script. */ + private class FakeTransport : MoonlightControlSession.Transport { + val sent = mutableListOf() + val inbound = ArrayDeque() + var closed = false + + override fun send(datagram: ByteArray) { + sent += datagram + } + + override fun receive(timeoutMs: Int): ByteArray? = inbound.removeFirstOrNull() + + override fun close() { + closed = true + } + } + + private fun verifyConnectDatagram(): ByteArray { + val w = EnetProtocol.Writer(EnetProtocol.FULL_HEADER_LEN + EnetProtocol.VERIFY_CONNECT_LEN) + w.u16(EnetProtocol.HEADER_FLAG_SENT_TIME) + w.u16(10) + EnetProtocol.commandHeader( + w, + EnetProtocol.COMMAND_VERIFY_CONNECT or EnetProtocol.FLAG_ACKNOWLEDGE, + EnetProtocol.SYSTEM_CHANNEL, + 1, + ) + w.u16(0x0042) // outgoingPeerID + w.u8(1) // incomingSessionID + w.u8(2) // outgoingSessionID + w.u32(1024) // mtu + w.u32(EnetProtocol.MINIMUM_WINDOW_SIZE) // windowSize + w.u32(1) // channelCount + w.u32(0) // incomingBandwidth + w.u32(0) // outgoingBandwidth + w.u32(EnetProtocol.PACKET_THROTTLE_INTERVAL) + w.u32(EnetProtocol.PACKET_THROTTLE_ACCELERATION) + w.u32(EnetProtocol.PACKET_THROTTLE_DECELERATION) + w.u32(0) // connectID + return w.toByteArray() + } + + /** Wrap a host-sent, sealed control payload as an ENet SEND_RELIABLE datagram. */ + private fun hostReliable( + seq: Int, + sealed: ByteArray, + ): ByteArray { + val w = EnetProtocol.Writer(EnetProtocol.FULL_HEADER_LEN + EnetProtocol.SEND_RELIABLE_HEADER_LEN + sealed.size) + w.u16(EnetProtocol.HEADER_FLAG_SENT_TIME) + w.u16(20) + EnetProtocol.commandHeader(w, EnetProtocol.COMMAND_SEND_RELIABLE or EnetProtocol.FLAG_ACKNOWLEDGE, 0, seq) + w.u16(sealed.size) + w.bytes(sealed) + return w.toByteArray() + } + + @Test + fun `connect handshake reaches CONNECTED`() { + val transport = FakeTransport() + transport.inbound.addLast(verifyConnectDatagram()) + val session = MoonlightControlSession(key, 0x1234, transport, { clock }) + assertEquals(MoonlightControlSession.State.IDLE, session.state) + assertTrue(session.connect()) + assertEquals(MoonlightControlSession.State.CONNECTED, session.state) + // The CONNECT datagram went out first. + assertTrue(transport.sent.isNotEmpty()) + } + + @Test + fun `connect times out without a verify`() { + val transport = FakeTransport() + val session = MoonlightControlSession(key, 0x1234, transport, { clock.also { clock += 500 } }) + assertTrue(!session.connect(handshakeTimeoutMs = 300)) + assertEquals(MoonlightControlSession.State.CLOSED, session.state) + } + + @Test + fun `controller state is sealed and sent only when connected`() { + val transport = FakeTransport() + transport.inbound.addLast(verifyConnectDatagram()) + val session = MoonlightControlSession(key, 0x1234, transport, { clock }) + session.connect() + val before = transport.sent.size + session.sendControllerState(0, 1, MoonlightControlProtocol.BTN_A, 0, 0, 0, 0, 0, 0) + assertEquals(before + 1, transport.sent.size) + } + + @Test + fun `inbound rumble event is decoded and dispatched`() { + val transport = FakeTransport() + transport.inbound.addLast(verifyConnectDatagram()) + val events = mutableListOf() + val session = MoonlightControlSession(key, 0x1234, transport, { clock }, onEvent = { events += it }) + session.connect() + + // The host seals a RUMBLE_DATA event with its own seq 0. + val body = ByteBuffer.allocate(10).order(ByteOrder.LITTLE_ENDIAN) + body.putInt(0) + body.putShort(0) + body.putShort(0x0FA0) + body.putShort(0x0BB8) + val plaintext = + ByteBuffer + .allocate(4 + 10) + .order(ByteOrder.LITTLE_ENDIAN) + .putShort(MoonlightControlProtocol.EVENT_RUMBLE_DATA.toShort()) + .putShort(10) + .put(body.array()) + .array() + val hostPacket = MoonlightControlPacket(key) + transport.inbound.addLast(hostReliable(seq = 1, sealed = hostPacket.sealWithSeq(0, plaintext))) + + session.pump() + assertEquals(1, events.size) + assertEquals(MoonlightEvent.Rumble(0, 0x0FA0, 0x0BB8), events.first()) + } + + @Test + fun `stop sends termination and closes the transport`() { + val transport = FakeTransport() + transport.inbound.addLast(verifyConnectDatagram()) + val session = MoonlightControlSession(key, 0x1234, transport, { clock }) + session.connect() + session.stop() + assertEquals(MoonlightControlSession.State.CLOSED, session.state) + assertTrue(transport.closed) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightCryptoTest.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightCryptoTest.kt new file mode 100644 index 00000000..49d6dac2 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightCryptoTest.kt @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import com.tinkernorth.dish.core.net.bytesToHex +import com.tinkernorth.dish.core.net.hexToBytes +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import javax.crypto.AEADBadTagException + +/** + * Pinned against Wolf's captured session vectors (tests/testCrypto.cpp, + * tests/testControl.cpp). Any drift here is a cross-end Moonlight protocol + * break, not a refactor. + */ +class MoonlightCryptoTest { + @Test + fun `pairingKey matches Wolf's gen_aes_key vector`() { + val salt = hexToBytes("ff5dc6eda99339a8a0793e216c4257c4") + val key = MoonlightCrypto.pairingKey(salt, "5338") + assertEquals("5ea186ffba663c75aec82187ce502647", bytesToHex(key)) + } + + @Test + fun `AES-ECB round-trips and matches Wolf's decrypted challenge`() { + val key = hexToBytes("5ea186ffba663c75aec82187ce502647") + val challenge = hexToBytes("c05930ac81d7bd426344235436046018") + val decrypted = MoonlightCrypto.aesEcbDecrypt(key, challenge) + assertEquals("e3a915cccb4c60206077d7e9a12316a5", bytesToHex(decrypted)) + assertEquals(challenge.toList(), MoonlightCrypto.aesEcbEncrypt(key, decrypted).toList()) + } + + @Test + fun `controlSeal matches Wolf's captured GCM packet body`() { + // testControl.cpp "30 bytes": key EDF0..D855, seq 0, payload 020302000000. + val key = hexToBytes("edf04a215c4fbea20934120c8480d855") + val sealed = MoonlightCrypto.controlSeal(key, seq = 0, plaintext = hexToBytes("020302000000")) + // tag(16) || ciphertext(6). + assertEquals("bf0eb6da10e47c702ec8644eb87d9cf7b6fac9ff75ca", bytesToHex(sealed)) + } + + @Test + fun `controlOpen reverses controlSeal across evolving seq`() { + val key = hexToBytes("edf04a215c4fbea20934120c8480d855") + for (seq in intArrayOf(0, 1, 2, 6, 255, 256, 70000)) { + val plaintext = "ping-$seq".toByteArray() + val sealed = MoonlightCrypto.controlSeal(key, seq, plaintext) + assertEquals(plaintext.toList(), MoonlightCrypto.controlOpen(key, seq, sealed).toList()) + } + } + + @Test + fun `controlOpen rejects a tampered payload`() { + val key = hexToBytes("edf04a215c4fbea20934120c8480d855") + val sealed = MoonlightCrypto.controlSeal(key, seq = 3, plaintext = "secret".toByteArray()) + sealed[sealed.size - 1] = (sealed[sealed.size - 1].toInt() xor 0x01).toByte() + assertThrows(AEADBadTagException::class.java) { + MoonlightCrypto.controlOpen(key, seq = 3, tagThenCiphertext = sealed) + } + } + + @Test + fun `controlOpen rejects the wrong seq (IV mismatch)`() { + val key = hexToBytes("edf04a215c4fbea20934120c8480d855") + val sealed = MoonlightCrypto.controlSeal(key, seq = 4, plaintext = "hello".toByteArray()) + assertThrows(AEADBadTagException::class.java) { + MoonlightCrypto.controlOpen(key, seq = 5, tagThenCiphertext = sealed) + } + } + + @Test + fun `RSA sign and verify round-trip with a generated key`() { + val kp = + java.security.KeyPairGenerator + .getInstance("RSA") + .apply { initialize(2048) } + .generateKeyPair() + val data = "pairing-secret".toByteArray() + val sig = MoonlightCrypto.signRsaSha256(kp.private, data) + assertTrue(MoonlightCrypto.verifyRsaSha256(kp.public, data, sig)) + assertTrue(!MoonlightCrypto.verifyRsaSha256(kp.public, "other".toByteArray(), sig)) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightEventDecoderTest.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightEventDecoderTest.kt new file mode 100644 index 00000000..75410818 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightEventDecoderTest.kt @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class MoonlightEventDecoderTest { + private fun plaintext( + type: Int, + body: ByteArray, + ): ByteArray = + ByteBuffer + .allocate(4 + body.size) + .order(ByteOrder.LITTLE_ENDIAN) + .putShort(type.toShort()) + .putShort(body.size.toShort()) + .put(body) + .array() + + private fun le(vararg shorts: Int): ByteArray { + val buf = ByteBuffer.allocate(shorts.size * 2).order(ByteOrder.LITTLE_ENDIAN) + shorts.forEach { buf.putShort(it.toShort()) } + return buf.array() + } + + @Test + fun `decodes RUMBLE_DATA`() { + val body = ByteBuffer.allocate(10).order(ByteOrder.LITTLE_ENDIAN) + body.putInt(0) // unused + body.putShort(1) // ctrl + body.putShort(0x1234) // low + body.putShort(0x5678) // high + val event = MoonlightEventDecoder.decode(plaintext(MoonlightControlProtocol.EVENT_RUMBLE_DATA, body.array())) + assertEquals(MoonlightEvent.Rumble(1, 0x1234, 0x5678), event) + } + + @Test + fun `decodes RUMBLE_TRIGGERS`() { + val event = + MoonlightEventDecoder.decode( + plaintext(MoonlightControlProtocol.EVENT_RUMBLE_TRIGGERS, le(2, 0x00FF, 0xFF00)), + ) + assertEquals(MoonlightEvent.RumbleTriggers(2, 0x00FF, 0xFF00), event) + } + + @Test + fun `decodes MOTION_EVENT (start gyro at rate)`() { + val body = ByteBuffer.allocate(5).order(ByteOrder.LITTLE_ENDIAN) + body.putShort(0) // ctrl + body.putShort(100) // rate + body.put(MoonlightControlProtocol.MOTION_TYPE_GYRO.toByte()) + val event = MoonlightEventDecoder.decode(plaintext(MoonlightControlProtocol.EVENT_MOTION, body.array())) + assertEquals(MoonlightEvent.MotionRequest(0, 100, MoonlightControlProtocol.MOTION_TYPE_GYRO), event) + } + + @Test + fun `decodes RGB_LED`() { + val body = ByteBuffer.allocate(5).order(ByteOrder.LITTLE_ENDIAN) + body.putShort(0) // ctrl + body.put(0x10) + body.put(0x20) + body.put(0x30) + val event = MoonlightEventDecoder.decode(plaintext(MoonlightControlProtocol.EVENT_RGB_LED, body.array())) + assertEquals(MoonlightEvent.RgbLed(0, 0x10, 0x20, 0x30), event) + } + + @Test + fun `unknown control type decodes to Unknown, not a crash`() { + val event = MoonlightEventDecoder.decode(plaintext(0x0200, ByteArray(4))) + assertTrue(event is MoonlightEvent.Unknown) + assertEquals(0x0200, (event as MoonlightEvent.Unknown).type) + } + + @Test + fun `too-short buffer returns null instead of over-reading`() { + assertNull(MoonlightEventDecoder.decode(byteArrayOf(0x0B, 0x01))) // header only, truncated + // Recognized type but a truncated body must not index past the end. + val short = plaintext(MoonlightControlProtocol.EVENT_RGB_LED, byteArrayOf(0, 0)) // missing r,g,b + assertNull(MoonlightEventDecoder.decode(short)) + } + + @Test + fun `a lying length cannot drive an over-read`() { + // plen claims a full rumble body but only 2 bytes follow. + val bytes = + ByteBuffer + .allocate(6) + .order(ByteOrder.LITTLE_ENDIAN) + .putShort(MoonlightControlProtocol.EVENT_RUMBLE_DATA.toShort()) + .putShort(10) // lies + .putShort(0) + .array() + assertNull(MoonlightEventDecoder.decode(bytes)) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHostModelsTest.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHostModelsTest.kt new file mode 100644 index 00000000..f1191a4b --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHostModelsTest.kt @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MoonlightHostModelsTest { + @Test + fun `host id prefers the stable uniqueid over the address`() { + assertEquals("moonlight:uid:abc123", MoonlightHost.idFor("192.168.1.5", "abc123")) + assertEquals("moonlight:192.168.1.5", MoonlightHost.idFor("192.168.1.5", "")) + } + + @Test + fun `remembered host round-trips to a host`() { + val remembered = + RememberedMoonlight( + id = "moonlight:uid:x", + name = "PC", + address = "10.0.0.9", + httpsPort = 47984, + uniqueId = "x", + lastAppId = "42", + emulatedType = MoonlightEmulatedType.PLAYSTATION, + ) + val host = remembered.toHost() + assertEquals("PC", host.name) + assertEquals("10.0.0.9", host.address) + assertEquals("x", host.uniqueId) + assertEquals(remembered.id, host.id) + } + + @Test + fun `emulated Auto resolves to a concrete arrival type, explicit passes through`() { + assertEquals(MoonlightControlProtocol.CONTROLLER_TYPE_XBOX, MoonlightEmulatedType.resolve(MoonlightEmulatedType.AUTO)) + assertEquals( + MoonlightControlProtocol.CONTROLLER_TYPE_PS, + MoonlightEmulatedType.resolve(MoonlightEmulatedType.PLAYSTATION), + ) + assertTrue(MoonlightEmulatedType.AUTO == 0xFF) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHotSealerTest.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHotSealerTest.kt new file mode 100644 index 00000000..fd5bac2f --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHotSealerTest.kt @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import com.tinkernorth.dish.core.net.bytesToHex +import com.tinkernorth.dish.core.net.hexToBytes +import org.junit.Assert.assertEquals +import org.junit.Test + +class MoonlightHotSealerTest { + private val key = hexToBytes("edf04a215c4fbea20934120c8480d855") + + @Test + fun `hot sealer output equals the reference encoder plus packet framing`() { + val sealer = MoonlightHotSealer(key) + val reference = MoonlightControlPacket(key) + for (seq in 0..3) { + val buttons = if (seq % 2 == 0) MoonlightControlProtocol.BTN_A else MoonlightControlProtocol.BTN_B + val hot = sealer.sealControllerMulti(0, 1, buttons, 0, 0, 0, 0, 0, 0) + val plaintext = MoonlightInputEncoder.controllerMulti(0, 1, buttons, 0, 0, 0, 0, 0, 0) + val expected = reference.sealWithSeq(seq, plaintext) + assertEquals("seq $seq", bytesToHex(expected), bytesToHex(hot)) + } + } + + @Test + fun `sealed packets round-trip through the receiver and advance seq`() { + val sealer = MoonlightHotSealer(key) + val receiver = MoonlightControlPacket(key) + val first = sealer.sealControllerMulti(0, 1, MoonlightControlProtocol.BTN_X, 10, 20, 0, 0, 0, 0) + assertEquals(1, sealer.nextSeq) + val decoded = receiver.open(first)!! + val event = MoonlightEventDecoder.decode(decoded) + // CONTROLLER_MULTI is an INPUT_DATA type the decoder classifies as Unknown (host does not send it back); + // the point is the seal decrypts cleanly and the plaintext matches the encoder. + assertEquals(MoonlightEvent.Unknown(MoonlightControlProtocol.CTRL_INPUT_DATA), event) + assertEquals( + bytesToHex(MoonlightInputEncoder.controllerMulti(0, 1, MoonlightControlProtocol.BTN_X, 10, 20, 0, 0, 0, 0)), + bytesToHex(decoded), + ) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightInputEncoderTest.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightInputEncoderTest.kt new file mode 100644 index 00000000..3f2faea1 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightInputEncoderTest.kt @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import com.tinkernorth.dish.core.net.bytesToHex +import org.junit.Assert.assertEquals +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Byte-exact against Wolf's input-data.adoc network fixtures and + * testControl.cpp. These are the decrypted control-stream plaintexts the dish + * sends; the transport seals and ENet-frames them. + */ +class MoonlightInputEncoderTest { + @Test + fun `CONTROLLER_MULTI matches Wolf's network fixture (button A pressed)`() { + // Wolf testControl.cpp joypad packet: ctrl 0, active mask 1, A (0x1000). + val bytes = + MoonlightInputEncoder.controllerMulti( + controllerNumber = 0, + activeMask = 1, + buttons = MoonlightControlProtocol.BTN_A, + leftTrigger = 0, + rightTrigger = 0, + leftStickX = 0, + leftStickY = 0, + rightStickX = 0, + rightStickY = 0, + ) + assertEquals( + "060222000000001e0c0000001a000000010014000010000000000000000000009c0000005500", + bytesToHex(bytes), + ) + } + + @Test + fun `CONTROLLER_MULTI splits high buttons into buttonFlags2`() { + val bytes = + MoonlightInputEncoder.controllerMulti( + controllerNumber = 1, + activeMask = 0b11, + buttons = MoonlightControlProtocol.BTN_A or MoonlightControlProtocol.BTN_PADDLE1, + leftTrigger = 0xFF, + rightTrigger = 0x80, + leftStickX = 0x1234, + leftStickY = -0x1234, + rightStickX = 0x7FFF, + rightStickY = -0x8000, + ) + val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + buf.position(20) + assertEquals(MoonlightControlProtocol.BTN_A, buf.short.toInt() and 0xFFFF) // low flags + assertEquals(0xFF, buf.get().toInt() and 0xFF) // LT + assertEquals(0x80, buf.get().toInt() and 0xFF) // RT + assertEquals(0x1234, buf.short.toInt()) + assertEquals(-0x1234, buf.short.toInt()) + assertEquals(0x7FFF, buf.short.toInt()) + assertEquals(-0x8000, buf.short.toInt()) + buf.short // tail_a + // buttonFlags2 carries PADDLE1 (>> 16). + assertEquals(MoonlightControlProtocol.BTN_PADDLE1 ushr 16, buf.short.toInt() and 0xFFFF) + } + + @Test + fun `hot-path encode into a reused buffer matches the allocating form`() { + val reused = ByteBuffer.allocate(64).order(ByteOrder.LITTLE_ENDIAN) + MoonlightInputEncoder.encodeControllerMulti(reused, 0, 1, MoonlightControlProtocol.BTN_B, 0, 0, 0, 0, 0, 0) + val first = ByteArray(reused.remaining()).also { reused.get(it) } + // Re-encode a different state into the SAME buffer with no reallocation. + MoonlightInputEncoder.encodeControllerMulti(reused, 0, 1, MoonlightControlProtocol.BTN_A, 0, 0, 0, 0, 0, 0) + val second = ByteArray(reused.remaining()).also { reused.get(it) } + assertEquals(MoonlightInputEncoder.CONTROLLER_MULTI_LEN, first.size) + assertEquals( + bytesToHex(MoonlightInputEncoder.controllerMulti(0, 1, MoonlightControlProtocol.BTN_A, 0, 0, 0, 0, 0, 0)), + bytesToHex(second), + ) + } + + @Test + fun `MOUSE_MOVE_REL matches the input-data adoc network fixture`() { + // delta X = -1 (0xFFFF big-endian), delta Y = 0. + val bytes = MoonlightInputEncoder.mouseMoveRel(deltaX = -1, deltaY = 0) + assertEquals("0602" + "0c00" + "00000008" + "07000000" + "ffff" + "0000", bytesToHex(bytes)) + } + + @Test + fun `CONTROLLER_ARRIVAL carries type and capabilities`() { + val bytes = + MoonlightInputEncoder.controllerArrival( + controllerNumber = 0, + controllerType = MoonlightControlProtocol.CONTROLLER_TYPE_XBOX, + capabilities = MoonlightControlProtocol.CAP_ANALOG_TRIGGERS or MoonlightControlProtocol.CAP_RUMBLE, + supportedButtons = 0xFFFF, + ) + assertEquals(MoonlightInputEncoder.CONTROLLER_ARRIVAL_LEN, bytes.size) + val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + assertEquals(MoonlightControlProtocol.CTRL_INPUT_DATA, buf.short.toInt() and 0xFFFF) + buf.short // plen + // input size is big-endian and counts type + the 7-byte arrival body. + assertEquals(11, ByteBuffer.wrap(bytes, 4, 4).order(ByteOrder.BIG_ENDIAN).int) + buf.position(8) + assertEquals(MoonlightControlProtocol.INPUT_CONTROLLER_ARRIVAL, buf.int) + assertEquals(0, buf.get().toInt()) + assertEquals(MoonlightControlProtocol.CONTROLLER_TYPE_XBOX, buf.get().toInt()) + assertEquals(0x03, buf.get().toInt()) + } + + @Test + fun `termination carries the graceful reason big-endian`() { + val bytes = MoonlightInputEncoder.termination() + assertEquals("00010400" + "80030023", bytesToHex(bytes)) + } + + @Test + fun `periodic ping is header plus a zero body`() { + assertEquals("00020400" + "00000000", bytesToHex(MoonlightInputEncoder.periodicPing())) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightPairingTest.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightPairingTest.kt new file mode 100644 index 00000000..f748b663 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightPairingTest.kt @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import com.tinkernorth.dish.core.net.bytesToHex +import com.tinkernorth.dish.core.net.hexToBytes +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.spec.PKCS8EncodedKeySpec + +/** + * Exercises the full 5-phase client pairing against a reference server built + * from the same crypto primitives (mirroring Wolf's server in moonlight.cpp). + * Both directions are checked: the client authenticates the server, and the + * server authenticates the client. Randomness is pinned so the exchange is + * deterministic. + */ +class MoonlightPairingTest { + private val clientCertPem = resource("moonlight/client_cert.pem") + private val clientKey = privateKey(resource("moonlight/client_key.pem")) + private val serverCertPem = resource("moonlight/server_cert.pem") + private val serverKey = privateKey(resource("moonlight/server_key.pem")) + + private val clientIdentity = + object : MoonlightIdentity { + override val certificatePem = clientCertPem + override val certificateSignature = MoonlightCert.signatureOf(clientCertPem) + override val privateKey = clientKey + } + + // Fixed random material so the exchange is byte-deterministic. + private val clientSalt = ByteArray(16) { (it + 1).toByte() } + private val clientChallenge = ByteArray(16) { (0x40 + it).toByte() } + private val clientSecret = ByteArray(16) { (0x80 + it).toByte() } + private val clientRandom = + object { + private val queue = ArrayDeque(listOf(clientSalt, clientChallenge, clientSecret)) + + fun next(size: Int): ByteArray = queue.removeFirst().also { require(it.size == size) } + } + + private fun newPairing(pin: String) = MoonlightPairing(clientIdentity, pin) { clientRandom.next(it) } + + @Test + fun `full pairing round-trip authenticates both ends`() { + val pin = "0451" + val server = ReferenceServer(pin, serverCertPem, serverKey, MoonlightCert.signatureOf(serverCertPem)) + val pairing = newPairing(pin) + + // Phase 1. + val p1 = pairing.phase1Params("dish-uid") + assertEquals(bytesToHex(clientSalt), p1["salt"]) + pairing.onPhase1(server.getServerCert(p1.getValue("salt"))) + + // Phase 2. + assertTrue(pairing.onPhase2(server.challengeResponse(pairing.phase2Params("dish-uid").getValue("clientchallenge")))) + + // Phase 3: the client verifies the server here. + assertTrue(pairing.onPhase3(server.clientHashResponse(pairing.phase3Params("dish-uid").getValue("serverchallengeresp")))) + + // Phase 4: the server verifies the client. + assertTrue(server.verifyClient(pairing.phase4Params("dish-uid").getValue("clientpairingsecret"))) + } + + @Test + fun `wrong PIN derives a different key and fails phase 2`() { + val server = ReferenceServer("0451", serverCertPem, serverKey, MoonlightCert.signatureOf(serverCertPem)) + val pairing = newPairing("9999") + val p1 = pairing.phase1Params("dish-uid") + pairing.onPhase1(server.getServerCert(p1.getValue("salt"))) + // The server derives the key from the real PIN; the client's blob will not decrypt to a + // valid challenge, so the server's response hash cannot be reproduced by the client. + val response = server.challengeResponse(pairing.phase2Params("dish-uid").getValue("clientchallenge")) + pairing.onPhase2(response) + // onPhase3 is where the server-authentication check fails on a wrong key. + assertFalse(pairing.onPhase3(server.clientHashResponse(pairing.phase3Params("dish-uid").getValue("serverchallengeresp")))) + } + + /** A minimal Wolf-equivalent server, driven purely by [MoonlightCrypto]. */ + private class ReferenceServer( + pin: String, + private val serverCertPem: String, + private val serverKey: PrivateKey, + private val serverCertSignature: ByteArray, + ) { + private val pinBytes = pin + private var aesKey = ByteArray(0) + private val serverSecret = ByteArray(16) { (0x10 + it).toByte() } + private val serverChallenge = ByteArray(16) { (0x20 + it).toByte() } + private var storedClientHash = ByteArray(0) + private var clientChallenge = ByteArray(0) + + fun getServerCert(saltHex: String): String { + aesKey = MoonlightCrypto.pairingKey(hexToBytes(saltHex), pinBytes) + return serverCertPem + } + + fun challengeResponse(clientChallengeHex: String): String { + clientChallenge = MoonlightCrypto.aesEcbDecrypt(aesKey, hexToBytes(clientChallengeHex)) + val hash = MoonlightCrypto.sha256(clientChallenge, serverCertSignature, serverSecret) + return bytesToHex(MoonlightCrypto.aesEcbEncrypt(aesKey, hash + serverChallenge)) + } + + fun clientHashResponse(serverChallengeRespHex: String): String { + storedClientHash = MoonlightCrypto.aesEcbDecrypt(aesKey, hexToBytes(serverChallengeRespHex)) + val signature = MoonlightCrypto.signRsaSha256(serverKey, serverSecret) + return bytesToHex(serverSecret + signature) + } + + fun verifyClient(clientPairingSecretHex: String): Boolean { + val secret = hexToBytes(clientPairingSecretHex) + val clientSecret = secret.copyOfRange(0, 16) + val clientSignature = secret.copyOfRange(16, secret.size) + val clientCertPem = MoonlightPairingTestCerts.CLIENT_CERT + val expected = + MoonlightCrypto.sha256(serverChallenge, MoonlightCert.signatureOf(clientCertPem), clientSecret) + if (!MoonlightCrypto.constantTimeEquals(expected, storedClientHash)) return false + return MoonlightCrypto.verifyRsaSha256(MoonlightCert.publicKeyOf(clientCertPem), clientSecret, clientSignature) + } + } + + private object MoonlightPairingTestCerts { + val CLIENT_CERT: String = resource("moonlight/client_cert.pem") + } + + private companion object { + fun resource(path: String): String = + MoonlightPairingTest::class.java.classLoader!! + .getResourceAsStream(path)!! + .use { it.readBytes().toString(Charsets.US_ASCII) } + + fun privateKey(pem: String): PrivateKey { + val base64 = + pem + .lineSequence() + .filterNot { it.startsWith("-----") } + .joinToString("") + .trim() + val der = + java.util.Base64 + .getDecoder() + .decode(base64) + return KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(der)) + } + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightRtspTest.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightRtspTest.kt new file mode 100644 index 00000000..f0ae3e19 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightRtspTest.kt @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MoonlightRtspTest { + @Test + fun `OPTIONS request is CRLF framed with CSeq`() { + val encoded = MoonlightRtsp.options("rtsp://192.168.1.100:48010", cseq = 1).encode() + assertEquals( + "OPTIONS rtsp://192.168.1.100:48010 RTSP/1.0\r\n" + + "CSeq: 1\r\n" + + "X-GS-ClientVersion: 14\r\n" + + "\r\n", + encoded, + ) + } + + @Test + fun `SETUP targets the stream id`() { + val encoded = MoonlightRtsp.setup("control", cseq = 4).encode() + assertTrue(encoded.startsWith("SETUP streamid=control RTSP/1.0\r\n")) + assertTrue(encoded.contains("CSeq: 4\r\n")) + } + + @Test + fun `ANNOUNCE carries the SDP payload and a content-length`() { + val sdp = MoonlightRtsp.minimalAnnounceSdp(1280, 720, 30) + val encoded = MoonlightRtsp.announce("rtsp://host:48010", cseq = 5, sdpPayload = sdp).encode() + assertTrue(encoded.contains("Content-length: ${sdp.toByteArray().size}\r\n")) + assertTrue(encoded.endsWith(sdp)) + assertTrue(sdp.contains("clientViewportWd:1280")) + } + + @Test + fun `parses a 200 response and reads the negotiated control port`() { + val raw = + "RTSP/1.0 200 OK\r\n" + + "CSeq: 4\r\n" + + "Session: DEADBEEFCAFE;timeout = 90\r\n" + + "Transport: server_port=47999\r\n" + + "\r\n" + val response = MoonlightRtsp.parseResponse(raw)!! + assertTrue(response.ok) + assertEquals(200, response.statusCode) + assertEquals(4, response.cseq) + assertEquals(47999, response.serverPort()) + } + + @Test + fun `parses an error response`() { + val response = MoonlightRtsp.parseResponse("RTSP/1.0 404 NOT FOUND\r\nCSeq: 2\r\n\r\n")!! + assertEquals(404, response.statusCode) + assertEquals("NOT FOUND", response.statusMessage) + assertTrue(!response.ok) + } + + @Test + fun `rejects a non-RTSP reply`() { + assertNull(MoonlightRtsp.parseResponse("HTTP/1.1 200 OK\r\n\r\n")) + assertNull(MoonlightRtsp.parseResponse("")) + } + + @Test + fun `serverPort is null when the transport option is absent`() { + val response = MoonlightRtsp.parseResponse("RTSP/1.0 200 OK\r\nCSeq: 1\r\n\r\n")!! + assertNull(response.serverPort()) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightUrlsTest.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightUrlsTest.kt new file mode 100644 index 00000000..7e4d96ae --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightUrlsTest.kt @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import org.junit.Assert.assertTrue +import org.junit.Test + +class MoonlightUrlsTest { + @Test + fun `serverinfo uses the passed ports, never a hardcoded one`() { + assertTrue( + MoonlightUrls + .serverInfoHttp("10.0.0.5", 47989, "uid") + .startsWith("http://10.0.0.5:47989/serverinfo?uniqueid=uid"), + ) + assertTrue(MoonlightUrls.serverInfoHttps("10.0.0.5", 47984, "uid").startsWith("https://10.0.0.5:47984/serverinfo?")) + } + + @Test + fun `launch carries the app id, rikey and rikeyid`() { + val url = MoonlightUrls.launch("host", 47984, "uid", appId = "881448767", rikeyHex = "00112233", rikeyId = 42, mode = "1280x720x30") + assertTrue(url.contains("appid=881448767")) + assertTrue(url.contains("rikey=00112233")) + assertTrue(url.contains("rikeyid=42")) + assertTrue(url.contains("mode=1280x720x30")) + } + + @Test + fun `pair params are url-encoded`() { + val url = MoonlightUrls.pairHttp("host", 47989, mapOf("salt" to "ab cd", "clientcert" to "2d/2d")) + assertTrue(url.contains("salt=ab+cd")) + assertTrue(url.contains("clientcert=2d%2F2d")) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightXmlTest.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightXmlTest.kt new file mode 100644 index 00000000..024995d6 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightXmlTest.kt @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MoonlightXmlTest { + @Test + fun `parses serverinfo`() { + val xml = + """ + + living-room-pc + 0123456789abcdef + 47984 + 47989 + aa:bb:cc:dd:ee:ff + 192.168.1.50 + 1 + 0 + SUNSHINE_SERVER_FREE + """ + val info = MoonlightXml.parseServerInfo(xml)!! + assertEquals("living-room-pc", info.hostname) + assertEquals("0123456789abcdef", info.uniqueId) + assertEquals(47984, info.httpsPort) + assertEquals(47989, info.externalPort) + assertEquals("192.168.1.50", info.localIp) + assertTrue(info.paired) + assertFalse(info.busy) + } + + @Test + fun `serverinfo reports busy when a game is running`() { + val xml = + """1 + 881448767SUNSHINE_SERVER_BUSY""" + val info = MoonlightXml.parseServerInfo(xml)!! + assertTrue(info.busy) + } + + @Test + fun `parses a phase-1 pair reply with plaincert`() { + val xml = """12d2d2d2d2d""" + val reply = MoonlightXml.parsePairReply(xml)!! + assertTrue(reply.paired) + assertEquals("2d2d2d2d2d", reply.plainCert) + } + + @Test + fun `parses a failed pair reply`() { + val xml = """0""" + val reply = MoonlightXml.parsePairReply(xml)!! + assertFalse(reply.paired) + assertEquals("Invalid client hash", reply.statusMessage) + } + + @Test + fun `parses an applist`() { + val xml = + """ + 0Desktop881448767 + 1Steam Big Picture1 + """ + val apps = MoonlightXml.parseAppList(xml) + assertEquals(2, apps.size) + assertEquals("Desktop", apps[0].title) + assertEquals("881448767", apps[0].id) + assertFalse(apps[0].hdrSupported) + assertTrue(apps[1].hdrSupported) + } + + @Test + fun `malformed xml decodes to null or empty, not a crash`() { + assertNull(MoonlightXml.parseServerInfo("not xml at all")) + assertNull(MoonlightXml.parsePairReply("")) + assertTrue(MoonlightXml.parseAppList("garbage").isEmpty()) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/enet/EnetClientTest.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/enet/EnetClientTest.kt new file mode 100644 index 00000000..7241a6e5 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/enet/EnetClientTest.kt @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight.enet + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Drives [EnetClient] as a pure state machine with handcrafted host datagrams, + * matching the cgutman/enet wire format the Kotlin port reproduces. + */ +class EnetClientTest { + private var clock = 1000L + + private fun now() = clock + + private fun newClient(connectData: Int = 0x11223344) = EnetClient(connectData, ::now, random = { 0x0BADF00D }) + + // --- host-side datagram builders (the bytes a Sunshine/Wolf host would send) --- + + private fun hostHeader( + w: EnetProtocol.Writer, + sentTime: Int, + ) { + // Host addresses our peer 0, with the sent-time flag set. + w.u16(EnetProtocol.HEADER_FLAG_SENT_TIME) + w.u16(sentTime) + } + + private fun verifyConnectDatagram( + outgoingPeerId: Int = 0x0042, + reliableSeq: Int = 1, + mtu: Int = 1024, + ): ByteArray { + val w = EnetProtocol.Writer(EnetProtocol.FULL_HEADER_LEN + EnetProtocol.VERIFY_CONNECT_LEN) + hostHeader(w, sentTime = 50) + EnetProtocol.commandHeader( + w, + EnetProtocol.COMMAND_VERIFY_CONNECT or EnetProtocol.FLAG_ACKNOWLEDGE, + EnetProtocol.SYSTEM_CHANNEL, + reliableSeq, + ) + w.u16(outgoingPeerId) + w.u8(0x01) // incomingSessionId + w.u8(0x02) // outgoingSessionId + w.u32(mtu) + w.u32(EnetProtocol.MINIMUM_WINDOW_SIZE) + w.u32(1) // channelCount + w.u32(0) // incomingBandwidth + w.u32(0) // outgoingBandwidth + w.u32(EnetProtocol.PACKET_THROTTLE_INTERVAL) + w.u32(EnetProtocol.PACKET_THROTTLE_ACCELERATION) + w.u32(EnetProtocol.PACKET_THROTTLE_DECELERATION) + w.u32(0x0BADF00D) // connectID echo + return w.toByteArray() + } + + private fun ackDatagram( + channelId: Int, + reliableSeq: Int, + ): ByteArray { + val w = EnetProtocol.Writer(EnetProtocol.FULL_HEADER_LEN + EnetProtocol.ACKNOWLEDGE_LEN) + hostHeader(w, sentTime = 60) + EnetProtocol.commandHeader(w, EnetProtocol.COMMAND_ACKNOWLEDGE, channelId, reliableSeq) + w.u16(reliableSeq) + w.u16(0) + return w.toByteArray() + } + + private fun sendReliableDatagram( + reliableSeq: Int, + payload: ByteArray, + ): ByteArray { + val w = EnetProtocol.Writer(EnetProtocol.FULL_HEADER_LEN + EnetProtocol.SEND_RELIABLE_HEADER_LEN + payload.size) + hostHeader(w, sentTime = 70) + EnetProtocol.commandHeader( + w, + EnetProtocol.COMMAND_SEND_RELIABLE or EnetProtocol.FLAG_ACKNOWLEDGE, + EnetClient.DATA_CHANNEL, + reliableSeq, + ) + w.u16(payload.size) + w.bytes(payload) + return w.toByteArray() + } + + // --- helpers to read back what the client emitted --- + + private data class ParsedCommand( + val command: Int, + val channelId: Int, + val reliableSeq: Int, + val body: ByteArray, + ) + + private fun firstCommand(datagram: ByteArray): ParsedCommand { + val buf = ByteBuffer.wrap(datagram).order(ByteOrder.BIG_ENDIAN) + val peerField = buf.short.toInt() and 0xFFFF + if (peerField and EnetProtocol.HEADER_FLAG_SENT_TIME != 0) buf.short + val command = buf.get().toInt() and 0xFF + val channelId = buf.get().toInt() and 0xFF + val reliableSeq = buf.short.toInt() and 0xFFFF + val body = ByteArray(buf.remaining()).also { buf.get(it) } + return ParsedCommand(command and EnetProtocol.COMMAND_MASK, channelId, reliableSeq, body) + } + + @Test + fun `connect emits a CONNECT command carrying the connect data`() { + val client = newClient(connectData = 0x11223344) + val cmd = firstCommand(client.connect()) + assertEquals(EnetProtocol.COMMAND_CONNECT, cmd.command) + assertEquals(EnetProtocol.SYSTEM_CHANNEL, cmd.channelId) + assertEquals(1, cmd.reliableSeq) + // The connect data is the last u32 of the CONNECT body (X-SS-Connect-Data). + val body = ByteBuffer.wrap(cmd.body).order(ByteOrder.BIG_ENDIAN) + body.position(cmd.body.size - 4) + assertEquals(0x11223344, body.int) + assertEquals(EnetClient.State.CONNECTING, client.state) + } + + @Test + fun `VERIFY_CONNECT transitions to CONNECTED and acks`() { + val client = newClient() + client.connect() + val acks = client.onDatagram(verifyConnectDatagram()) + assertEquals(EnetClient.State.CONNECTED, client.state) + // The verify wanted an ack (it had FLAG_ACKNOWLEDGE and a sent time). + assertEquals(1, acks.size) + assertEquals(EnetProtocol.COMMAND_ACKNOWLEDGE, firstCommand(acks.first()).command) + // The CONNECT is now acknowledged: a tick must not retransmit it. + clock += 10_000 + assertTrue(client.tick().none { firstCommand(it).command == EnetProtocol.COMMAND_CONNECT }) + } + + @Test + fun `reliable send is acknowledged and not retransmitted`() { + val client = newClient() + client.connect() + client.onDatagram(verifyConnectDatagram()) + val datagram = client.sendReliable("hello".toByteArray())!! + val cmd = firstCommand(datagram) + assertEquals(EnetProtocol.COMMAND_SEND_RELIABLE, cmd.command) + assertEquals(EnetClient.DATA_CHANNEL, cmd.channelId) + assertEquals(1, cmd.reliableSeq) + // Host acks channel 0 seq 1. + client.onDatagram(ackDatagram(EnetClient.DATA_CHANNEL, reliableSeq = 1)) + clock += 10_000 + assertTrue(client.tick().none { firstCommand(it).command == EnetProtocol.COMMAND_SEND_RELIABLE }) + } + + @Test + fun `unacked reliable send is retransmitted after the timeout`() { + val client = newClient() + client.connect() + client.onDatagram(verifyConnectDatagram()) + val original = client.sendReliable("input".toByteArray())!! + clock += 600 + val retransmits = client.tick() + assertTrue(retransmits.any { it.contentEquals(original) }) + } + + @Test + fun `host reliable send is delivered once and acked`() { + val client = newClient() + client.connect() + client.onDatagram(verifyConnectDatagram()) + val acks = client.onDatagram(sendReliableDatagram(reliableSeq = 1, payload = "rumble".toByteArray())) + assertEquals(1, client.received.size) + assertEquals("rumble", String(client.received.removeFirst())) + assertEquals(EnetProtocol.COMMAND_ACKNOWLEDGE, firstCommand(acks.first()).command) + // A retransmit of the same seq is acked again but not re-delivered. + client.onDatagram(sendReliableDatagram(reliableSeq = 1, payload = "rumble".toByteArray())) + assertTrue(client.received.isEmpty()) + } + + @Test + fun `disconnect emits a DISCONNECT and clears state`() { + val client = newClient() + client.connect() + client.onDatagram(verifyConnectDatagram()) + val cmd = firstCommand(client.disconnect()!!) + assertEquals(EnetProtocol.COMMAND_DISCONNECT, cmd.command) + assertEquals(EnetClient.State.DISCONNECTED, client.state) + assertNull(client.sendReliable("late".toByteArray())) + } + + @Test + fun `ping is emitted when the link is idle`() { + val client = newClient() + client.connect() + client.onDatagram(verifyConnectDatagram()) + clock += EnetProtocol.PING_INTERVAL_MS + 1 + assertTrue(client.tick().any { firstCommand(it).command == EnetProtocol.COMMAND_PING }) + } + + @Test + fun `a truncated datagram is ignored`() { + val client = newClient() + client.connect() + assertTrue(client.onDatagram(byteArrayOf(0x00)).isEmpty()) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MdnsMoonlightDiscoveryTest.kt b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MdnsMoonlightDiscoveryTest.kt new file mode 100644 index 00000000..85170e5c --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MdnsMoonlightDiscoveryTest.kt @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.source.connection.moonlight + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class MdnsMoonlightDiscoveryTest { + @Test + fun `builds a host from an mDNS service with a uniqueid TXT record`() { + val host = + mdnsServiceToHost( + serviceName = "living-room", + hostAddress = "192.168.1.7", + txt = mapOf("uniqueid" to "deadbeef".toByteArray()), + )!! + assertEquals("living-room", host.name) + assertEquals("192.168.1.7", host.address) + assertEquals("deadbeef", host.uniqueId) + } + + @Test + fun `falls back to the address as the name when the service name is empty`() { + val host = mdnsServiceToHost("", "10.0.0.3", emptyMap())!! + assertEquals("10.0.0.3", host.name) + assertEquals("", host.uniqueId) + } + + @Test + fun `a service with no address resolves to null`() { + assertNull(mdnsServiceToHost("name", null, emptyMap())) + } +} diff --git a/app/src/test/resources/moonlight/client_cert.pem b/app/src/test/resources/moonlight/client_cert.pem new file mode 100644 index 00000000..23ae909b --- /dev/null +++ b/app/src/test/resources/moonlight/client_cert.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC9TCCAd2gAwIBAgIUVC3q8GHjBmNNxUxfMjUMH3Q5jsswDQYJKoZIhvcNAQEL +BQAwIzEhMB8GA1UEAwwYTlZJRElBIEdhbWVTdHJlYW0gQ2xpZW50MB4XDTI2MDgy +NTAwMzQyNFoXDTQ2MDgyMDAwMzQyNFowIzEhMB8GA1UEAwwYTlZJRElBIEdhbWVT +dHJlYW0gQ2xpZW50MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjTwk +skS496TGXigOnez9xjnHlmpQOzYt6a6dW4oip4kiql8cea6envGW+MszOeo97XNJ +eFI1kNdYy7buEPg4PyP++DmGAiylivzvGbB3IbOUvgXC9XyrSVLcpEPYBgkqHXz/ +u9xCxGqBkU8yK61ivbsyJh90DsUSJCws5OvGVnw1R+hR935wl8QF5u/+XggYiZY5 +6Mo5xHYC/zDyCX5y6wyBo7ko6GPrc6V1zLesqjPsgoiykK0DSVXLSa8SGTC6ox5y +AMQfrqQ+sFHD00N7y7c7XNFJYk0z+0mB26h5qde7xuhPRTY4cPhtWzPxfJekmm/1 +Bmpb75325jd73h3jyQIDAQABoyEwHzAdBgNVHQ4EFgQUlARyJo2Cg1QonxPf9Zyu +GgZ/CfwwDQYJKoZIhvcNAQELBQADggEBAHxViqqYdchYKYQ9eA/r32MIfkIVn++t +WTThyifaD/bQ3MdmMZBIzzf+I19S6QCYeqQk2kGgjoRKHM4gYAsluvX+Sn0jOZ30 +yKX75IzPcuYBGC4r5cpb1k8dez0wSbRdkocpCKq6aQIOP0A5+ZpfzTf54WJClEEU +qRTRTFgCIsKPluEeG3mQeikh6FWcbwWNclw6EC2bEQ4DlGisec7PjrK8MmZGiw31 +5q+O2TdnLnREL+LOgg5+KmlV0Sw/bplnFh3vq8X5z0joUhsIVw/GQcF4w/nI6pCq +hEeyJzRaIxat6iqr5Dzs6j6r89Z1QXr3ndRUdLHF4TEeiLYMf7MdvD0= +-----END CERTIFICATE----- diff --git a/app/src/test/resources/moonlight/client_key.pem b/app/src/test/resources/moonlight/client_key.pem new file mode 100644 index 00000000..b3db3620 --- /dev/null +++ b/app/src/test/resources/moonlight/client_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCNPCSyRLj3pMZe +KA6d7P3GOceWalA7Ni3prp1biiKniSKqXxx5rp6e8Zb4yzM56j3tc0l4UjWQ11jL +tu4Q+Dg/I/74OYYCLKWK/O8ZsHchs5S+BcL1fKtJUtykQ9gGCSodfP+73ELEaoGR +TzIrrWK9uzImH3QOxRIkLCzk68ZWfDVH6FH3fnCXxAXm7/5eCBiJljnoyjnEdgL/ +MPIJfnLrDIGjuSjoY+tzpXXMt6yqM+yCiLKQrQNJVctJrxIZMLqjHnIAxB+upD6w +UcPTQ3vLtztc0UliTTP7SYHbqHmp17vG6E9FNjhw+G1bM/F8l6Sab/UGalvvnfbm +N3veHePJAgMBAAECggEAF/2x7BhRZTu3uJHMXdY+i3gQJ8RaaZx78xiGwWB3H4dj +fJZYc3EOn8hBEXUO+BUKvPWa8tXgJID4I+6ohPhtMYiPTKIU2fS0kCYEBZScv/xN +1XOMGQA65mMteLfPj8LpxQWROVuiedPvu3u89X9n6PvN+nzYTZP7T2qzm5VTZShG +mQpA+OcACSYBy2ScbCfRza3HR2zTe6KnfqSoeTYoQABSqACk4lgmYsNaO+ifUxbH +t6c6K4SgnHl/aMd3+QpsZtFdku3FEVeO0ZDe8ZstCPnra37fI87ZEGCbbval2NgP +AVpPIrvBra5dGhTqoIUbKGGXdJTq/b7/uj04leXRyQKBgQC+ekaIZHrENVrfRkvT +FMJYIcqHfQDwmNVb2qMAp/7whcGinyBwtsYR1O9WTqglfDFQPRnhiMb2r22xuAky +13OaMsSXi4SiqiyIJJaCPAzpoN54c4AC9sWTizRmkbRuHmb4iJRCgtMlvgN9p2Fm +o0KyGM+07DX0kDm8AeqWorUNbQKBgQC90Xy3znV9/6ShczQyE0jPmw0h3Xx7cKJ0 +f9UfneSz9ZbJt2eLtXY8ERNBfFD38ilRE6MLPSlR/QzceXKezK5vC1MYWCCjwE4t +2EeoDHc1bv7hEn8CrpXr55ZbxG08qexqCJdwaQ0SLt5KYDiUxegU1tqbF62W8nAe +lB2NHwYCTQKBgHf016Cj7vDMTTtZsPzxEOeh+ENVhRcAmTWsvoT2R8a/5c99eVei +s6CdQlFPXfOlgATxRfBUTEEk/+cxaJGdQA93M3nhApnSpBLlP+gq21Ly3chrrM2x +DYK64zhJQKEtAlo44W31p/YX8Wjb1apm3OT+XSiqrdwkTEfLySous+kNAoGBAKcA +9LXS09RzYykY7sdP6DOfu0IcWDVSt9u/zIbwqBMc8/mtf1CP6uKWM1beRW6ghHFs +0XpF6WDVPseLoqjMdHwGfqlgf/cSbrYvH3xe21MLwPvNBioZ6JWRP9ylSWaiKfpw +bKzeAD4LNlBBsAZUyQfssJDbmELCMpr0vbs3nFXRAoGATeIPMDBeWjtNBLgFC6V8 +i7OMNHYDOP2nTbURcZp7LRo+wVrKEdcvHA9v52TH5v+6Yvf/i8HsvhCY+R3jcwBR +M0TJbR8H0CmxXjsjvtnRFetcl5FSlCmF/PYHlB2Vb/Mbj44rrFFwafPVqG3fdX/z +oPMLuAvOpG9QHDnb3EKWUP8= +-----END PRIVATE KEY----- diff --git a/app/src/test/resources/moonlight/server_cert.pem b/app/src/test/resources/moonlight/server_cert.pem new file mode 100644 index 00000000..47f5e334 --- /dev/null +++ b/app/src/test/resources/moonlight/server_cert.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC9TCCAd2gAwIBAgIUeubF1zzWwZrolYnodIZUbyRcMDYwDQYJKoZIhvcNAQEL +BQAwIzEhMB8GA1UEAwwYTlZJRElBIEdhbWVTdHJlYW0gQ2xpZW50MB4XDTI2MDgy +NTAwMzQyNFoXDTQ2MDgyMDAwMzQyNFowIzEhMB8GA1UEAwwYTlZJRElBIEdhbWVT +dHJlYW0gQ2xpZW50MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnTTL +OJUEjiw4827BDSqibT19eujmYPVjo4QhZyKjQWgB217tAg2L7kk7jKznWrCgG9DD +dGHdGxmNMiVHqoEnOu7aXLGLw0Qd4intZ+G51AHHTlX1AMt49IXvug4LMQaTELz1 +yRK3gZ1o2agzKvf0UNC+7rgLeZPgk4I6SZxf19YfoaJCORjTLboHzN6QS/UD/JRb +eoaQN89sd7iruqvUffMKlTh2ArYz9f+E5F2yBQBCScvXQEfDfim7uGvxga+J2q5b +LY8YbJxE4jYN/fPAO8oBcJ0Kjd+ktkrrOFCGlJwivrc/pePiZDMi3qCvkCYToetx +2RwCifRqcrmpskciyQIDAQABoyEwHzAdBgNVHQ4EFgQUvTERcI/GxwRL3XoIql9g +zus9+08wDQYJKoZIhvcNAQELBQADggEBAIscW1qiAwps1gje42IsBAn53kqrsGYi +pwrzPAulrqvcSdM324+zjnD/MJTbvuA+XHjZY/EXiRQyuiIDfqzCxYRZohX8YNOs +zOaGIRLLeJeIk+FkTolme5HcDVk6amWwfbiwdNVt6Y99dZ7RjJ01MgbOjRvUexkT +NHBSxgKBlyaFf+U46Rir+Ub7b1JPkWBzULUqFJtOmn7nXyJua2BHlLJy2GgoL6Mu +u/9B+GUMCKTsu+DlsvNGC/YVB8J2HrqUXw/TL7ZZECRGbEWK33CtF8srkfiYuvBT +PpGo1vCJPYzy7yVkIbyKqhXkmt1FOoPSWAvp5TBtub8vcZ7NMFa2EUw= +-----END CERTIFICATE----- diff --git a/app/src/test/resources/moonlight/server_key.pem b/app/src/test/resources/moonlight/server_key.pem new file mode 100644 index 00000000..825c86c8 --- /dev/null +++ b/app/src/test/resources/moonlight/server_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCdNMs4lQSOLDjz +bsENKqJtPX166OZg9WOjhCFnIqNBaAHbXu0CDYvuSTuMrOdasKAb0MN0Yd0bGY0y +JUeqgSc67tpcsYvDRB3iKe1n4bnUAcdOVfUAy3j0he+6DgsxBpMQvPXJEreBnWjZ +qDMq9/RQ0L7uuAt5k+CTgjpJnF/X1h+hokI5GNMtugfM3pBL9QP8lFt6hpA3z2x3 +uKu6q9R98wqVOHYCtjP1/4TkXbIFAEJJy9dAR8N+Kbu4a/GBr4narlstjxhsnETi +Ng3988A7ygFwnQqN36S2Sus4UIaUnCK+tz+l4+JkMyLeoK+QJhOh63HZHAKJ9Gpy +uamyRyLJAgMBAAECggEAArJTTWhU+IFZWrz1cnM0RQ5vIWrET452A+ncztGWc6VD +6Y49H4dkSpVBVb4+MQfi6dyQtbrbWZR8dQs+/vWR2t4aVckKiNzEFyCOp3UE7yuL ++Skv94WwpfUe3GtS6tIzjJpz1tv2KjLX3RVDB24oZ93PdfXR5ecmTNweLCwSrXeX +u2GXJ1uREyfuxo6W1FOOxms0FRCRNO1Zskc9BouUf67r3AU58fWWjKH7h+R+zgdj +3pywCRg5qHDDq7/qXXb/gn5X9E73wuPFFBZ8secj0ocMhlF4krteudBDa5bUqkhm +FIKx96OMd9hSEqkCr1I4SEBNsOWLOxTuu2JHeYaPmQKBgQDbYTju9yBZ4yoqqJCA +sUjnHw8qIVihvxPOpaEuAIAiNz8ZhZsy06wPRqOoW3pWC7E6tBZa1azyDnibviKn +dPBrg2FUAjHtGyMwb0CjdDYZkLCPXNGnjfbo9J4HWDl9/ubmuZ/29FZGcYp7Z9hw +nVZvarW+RVajYbfR62m2CS3TbQKBgQC3crP5DYGA1NOqUt4L52cz/BQDeg0w9+dj +JwKCXnMdqgJE4HSyiVzX2osTtduSW0L7vh3wmvmIJbL/S8q5byeS5lhZpZf7aiyI +qKYQ1voZNqXTYj8YaWV1w/R+fuiOzWjwqfBFse3WFGj8aNWG8prmVpQHyiTCVyYO +OlbHG+bXTQKBgQDa0UGxkYuSPPS9Mf9YbfzSk3dTxYkbZHTERQ7czKEB/+sPcOWZ +r+pKHmJ1NjFzDByN+jzmA4WKtwZ0ChWUxB5ejuAQpFPaNZxG3mEx6GNh4qFJjgKM +xxyFxiCuIMDPvOXhMzusXpCDmRLQ/oaz5Svm3CBFlfHR61EnsFFzwfoUjQKBgQCw +AU8HHpwnnQpPmh4MUcJEsBALnehWGSNZkC3qIvBTf6+ZobiVKxF2z+krygmWjBTi +L2/OTwImS/VG19LywuC3ImWV7Ti6MQ31N8nM0lU2J6ZF/zcGFukPaiiDzQMXL6EF +diZe1+2WvhJUScjEJrPTVzHDn4BRLQgIEpT7h5uc6QKBgDy3kFuyDQUNwHH9HC/M +nv7kW/BFQd5OXvOvll21h+sZitvbDbiEXldL93HyL6dNvSdhxQ5H1LpcHLqDyf5z +EnH2cw+GHTHyzynTzCnK7OGbsxzicvq7nft35ED5LLT6OS5RqyLWOuRNMH/ETdBj +jat9vNFYuArJFOPxnJqNKisH +-----END PRIVATE KEY----- From b92d2318e73536b1ee2dc1e9ede61d2f5b523702 Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Tue, 25 Aug 2026 12:15:26 -0400 Subject: [PATCH 02/20] feat: Moonlight connect flow screens and the native SLOT_MOONLIGHT pad bridge Completes the two pieces the first Moonlight commit left staged. User-facing flow. The connections screen grows a MOONLIGHT HOSTS section (Scan + Add, mirroring the satellites section) listing discovered and remembered hosts. Tapping Connect runs the full flow: an emulated-device pick [Auto, Xbox, PlayStation, Nintendo] seeded from the host's remembered choice, then pairing that shows the generated PIN for the user to type into the host's web UI and resolves when the host accepts it, then an app pick from /applist seeded with the last app launched there. Both choices persist per host, so a later reconnect is one tap. Manual host entry probes /serverinfo before the host is added. New strings ship in all six locales. Native physical-pad bridge. The C++ capture path gains SLOT_MOONLIGHT beside SLOT_BLUETOOTH: the Bluetooth report queue and dispatch thread generalize into one bridge queue whose reports carry their slot kind, so a physical pad bound to a Moonlight host publishes through the same fixed-offset hot path and upcalls MoonlightGamepadBridge, which seals and sends on the live control session. The XUSB wButtons map straight across since Moonlight's low-16 button flags share XInput's bit layout. reconcileSlots gains a Moonlight branch with the same live-session re-check the Bluetooth branch does, and its binds are deduped like the others so an unchanged bind never replays a destructive baseline sync. Manager gains pairHost/fetchApps/launch as discrete steps for the screens to orchestrate, keeping one-tap connect for a remembered host. Test delta: 5 new tests (3 reconcile/dedupe cases for the Moonlight branch, 2 for the host-row builder), 68 Moonlight tests total. --- app/src/main/cpp/satellite_jni.cpp | 131 +++++++----- .../com/tinkernorth/dish/DishApplication.kt | 4 + .../dish/core/jni/SatelliteNative.kt | 5 + .../hotpath/input/MoonlightGamepadBridge.kt | 54 +++++ .../input/PhysicalSlotBindingObserver.kt | 33 +++- .../moonlight/MoonlightConnectionManager.kt | 72 +++++-- .../ui/connections/ConnectionsActivity.kt | 187 ++++++++++++++++++ .../dish/ui/connections/ConnectionsUiState.kt | 4 + .../ui/connections/ConnectionsViewModel.kt | 33 +++- .../ui/connections/MoonlightListAdapter.kt | 175 ++++++++++++++++ app/src/main/res/values-bs/strings.xml | 12 ++ app/src/main/res/values-de/strings.xml | 12 ++ app/src/main/res/values-es/strings.xml | 12 ++ app/src/main/res/values-fr/strings.xml | 12 ++ app/src/main/res/values-pt-rBR/strings.xml | 12 ++ app/src/main/res/values/strings.xml | 13 ++ .../input/PhysicalSlotBindingObserverTest.kt | 52 ++++- .../dish/ui/connections/MoonlightRowsTest.kt | 40 ++++ 18 files changed, 800 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/com/tinkernorth/dish/hotpath/input/MoonlightGamepadBridge.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/ui/connections/MoonlightListAdapter.kt create mode 100644 app/src/test/java/com/tinkernorth/dish/ui/connections/MoonlightRowsTest.kt diff --git a/app/src/main/cpp/satellite_jni.cpp b/app/src/main/cpp/satellite_jni.cpp index 1a67f29f..7b4d1d82 100644 --- a/app/src/main/cpp/satellite_jni.cpp +++ b/app/src/main/cpp/satellite_jni.cpp @@ -125,13 +125,19 @@ static bool sendEncrypted(Session* s, uint16_t msgType, const uint8_t* payload, using gamepad::DeviceState; -enum SlotKind : uint8_t { SLOT_NONE = 0, SLOT_SATELLITE = 1, SLOT_BLUETOOTH = 2 }; +enum SlotKind : uint8_t { + SLOT_NONE = 0, + SLOT_SATELLITE = 1, + SLOT_BLUETOOTH = 2, + SLOT_MOONLIGHT = 3 +}; struct SlotBinding { SlotKind kind = SLOT_NONE; int sessionHandle = -1; int controllerIndex = -1; - std::string btConnectionId; + // Kotlin-side connection id for the bridge kinds (Bluetooth / Moonlight). + std::string bridgeConnectionId; }; static std::mutex g_devicesMtx; @@ -145,68 +151,78 @@ static JavaVM* g_jvm = nullptr; static jclass g_btBridgeClass = nullptr; static jmethodID g_btDispatchMethod = nullptr; +static jclass g_moonlightBridgeClass = nullptr; +static jmethodID g_moonlightDispatchMethod = nullptr; + static jclass g_rumbleBridgeClass = nullptr; static jmethodID g_rumbleDispatchMethod = nullptr; -// BT path runs off the UI thread because BluetoothHidDevice.sendReport is Binder IPC. -struct BtReport { +// Bridge kinds (Bluetooth, Moonlight) run off the UI thread: BluetoothHidDevice.sendReport is +// Binder IPC, and the Moonlight path encrypts + frames in Kotlin. One queue + thread serves both; +// the report's kind picks the Kotlin bridge to upcall. +struct BridgeReport { + SlotKind kind; std::string connectionId; uint16_t wButtons; uint8_t bLT, bRT; int16_t sLX, sLY, sRX, sRY; }; -static std::mutex g_btQueueMtx; -static std::condition_variable g_btQueueCv; -static std::deque g_btQueue; -static std::thread g_btDispatchThread; -static std::atomic g_btDispatchRunning{false}; -static constexpr size_t BT_QUEUE_MAX = 64; +static std::mutex g_bridgeQueueMtx; +static std::condition_variable g_bridgeQueueCv; +static std::deque g_bridgeQueue; +static std::thread g_bridgeDispatchThread; +static std::atomic g_bridgeDispatchRunning{false}; +static constexpr size_t BRIDGE_QUEUE_MAX = 64; -static void enqueueBtReport(BtReport&& r) { +static void enqueueBridgeReport(BridgeReport&& r) { { - std::lock_guard lock(g_btQueueMtx); - if (g_btQueue.size() >= BT_QUEUE_MAX) g_btQueue.pop_front(); - g_btQueue.push_back(std::move(r)); + std::lock_guard lock(g_bridgeQueueMtx); + if (g_bridgeQueue.size() >= BRIDGE_QUEUE_MAX) g_bridgeQueue.pop_front(); + g_bridgeQueue.push_back(std::move(r)); } - g_btQueueCv.notify_one(); + g_bridgeQueueCv.notify_one(); } -static void btDispatchLoop() { +static void bridgeDispatchLoop() { JNIEnv* env = nullptr; if (!g_jvm || g_jvm->AttachCurrentThread(&env, nullptr) != JNI_OK || env == nullptr) { - LOGE("btDispatchLoop: AttachCurrentThread failed"); + LOGE("bridgeDispatchLoop: AttachCurrentThread failed"); return; } dish::elevateCurrentThreadToInputPriority(); - LOGI("BT dispatch thread started"); - while (g_btDispatchRunning.load(std::memory_order_relaxed)) { - BtReport r; + LOGI("Bridge dispatch thread started"); + while (g_bridgeDispatchRunning.load(std::memory_order_relaxed)) { + BridgeReport r; { - std::unique_lock lock(g_btQueueMtx); - g_btQueueCv.wait(lock, [] { - return !g_btDispatchRunning.load(std::memory_order_relaxed) || !g_btQueue.empty(); + std::unique_lock lock(g_bridgeQueueMtx); + g_bridgeQueueCv.wait(lock, [] { + return !g_bridgeDispatchRunning.load(std::memory_order_relaxed) || + !g_bridgeQueue.empty(); }); - if (!g_btDispatchRunning.load(std::memory_order_relaxed) && g_btQueue.empty()) break; - r = std::move(g_btQueue.front()); - g_btQueue.pop_front(); + if (!g_bridgeDispatchRunning.load(std::memory_order_relaxed) && g_bridgeQueue.empty()) + break; + r = std::move(g_bridgeQueue.front()); + g_bridgeQueue.pop_front(); } - if (g_btBridgeClass == nullptr || g_btDispatchMethod == nullptr) continue; + jclass cls = r.kind == SLOT_MOONLIGHT ? g_moonlightBridgeClass : g_btBridgeClass; + jmethodID method = + r.kind == SLOT_MOONLIGHT ? g_moonlightDispatchMethod : g_btDispatchMethod; + if (cls == nullptr || method == nullptr) continue; jstring connId = env->NewStringUTF(r.connectionId.c_str()); - env->CallStaticVoidMethod(g_btBridgeClass, g_btDispatchMethod, connId, (jint)r.wButtons, - (jint)r.bLT, (jint)r.bRT, (jint)r.sLX, (jint)r.sLY, (jint)r.sRX, - (jint)r.sRY); + env->CallStaticVoidMethod(cls, method, connId, (jint)r.wButtons, (jint)r.bLT, (jint)r.bRT, + (jint)r.sLX, (jint)r.sLY, (jint)r.sRX, (jint)r.sRY); env->DeleteLocalRef(connId); if (env->ExceptionCheck()) env->ExceptionClear(); } g_jvm->DetachCurrentThread(); - LOGI("BT dispatch thread stopped"); + LOGI("Bridge dispatch thread stopped"); } -static void startBtDispatchThread() { - bool was = g_btDispatchRunning.exchange(true, std::memory_order_relaxed); +static void startBridgeDispatchThread() { + bool was = g_bridgeDispatchRunning.exchange(true, std::memory_order_relaxed); if (was) return; - g_btDispatchThread = std::thread(btDispatchLoop); + g_bridgeDispatchThread = std::thread(bridgeDispatchLoop); } static inline float axisCur(const GameActivityMotionEvent* ev, int axis) { @@ -239,10 +255,11 @@ static void publishIfChanged(int32_t deviceId, DeviceState& s) { r->sThumbRY = s.sRY; sendEncrypted(session.get(), MSG_GAMEPAD_DATA, payload, sizeof(payload)); hotpath::markGamepadSent(); // stage-1 end: the URB-driven packet has left sendto() - } else if (binding.kind == SLOT_BLUETOOTH) { - if (binding.btConnectionId.empty()) return; - enqueueBtReport(BtReport{ - binding.btConnectionId, + } else if (binding.kind == SLOT_BLUETOOTH || binding.kind == SLOT_MOONLIGHT) { + if (binding.bridgeConnectionId.empty()) return; + enqueueBridgeReport(BridgeReport{ + binding.kind, + binding.bridgeConnectionId, s.wButtons, s.bLT, s.bRT, @@ -938,27 +955,37 @@ JNIEXPORT void JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_bindPh b.kind = SLOT_SATELLITE; b.sessionHandle = sessionHandle; b.controllerIndex = controllerIndex; - b.btConnectionId.clear(); + b.bridgeConnectionId.clear(); } syncSlotBaseline(deviceId); } -JNIEXPORT void JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_bindPhysicalSlotBluetooth( - JNIEnv* env, jobject, jint deviceId, jstring connectionId) { +static void bindPhysicalSlotBridge(JNIEnv* env, jint deviceId, jstring connectionId, + SlotKind kind) { const char* cstr = env->GetStringUTFChars(connectionId, nullptr); std::string copy = cstr ? std::string(cstr) : std::string(); if (cstr) env->ReleaseStringUTFChars(connectionId, cstr); { std::lock_guard lock(g_slotsMtx); auto& b = g_slots[deviceId]; - b.kind = SLOT_BLUETOOTH; + b.kind = kind; b.sessionHandle = -1; b.controllerIndex = -1; - b.btConnectionId = std::move(copy); + b.bridgeConnectionId = std::move(copy); } syncSlotBaseline(deviceId); } +JNIEXPORT void JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_bindPhysicalSlotBluetooth( + JNIEnv* env, jobject, jint deviceId, jstring connectionId) { + bindPhysicalSlotBridge(env, deviceId, connectionId, SLOT_BLUETOOTH); +} + +JNIEXPORT void JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_bindPhysicalSlotMoonlight( + JNIEnv* env, jobject, jint deviceId, jstring connectionId) { + bindPhysicalSlotBridge(env, deviceId, connectionId, SLOT_MOONLIGHT); +} + JNIEXPORT void JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_unbindPhysicalSlot( JNIEnv*, jobject, jint deviceId) { std::lock_guard lock(g_slotsMtx); @@ -1064,7 +1091,23 @@ JNIEXPORT void JNICALL Java_com_tinkernorth_dish_hotpath_input_BluetoothGamepadB env->ExceptionClear(); } } - startBtDispatchThread(); + startBridgeDispatchThread(); +} + +JNIEXPORT void JNICALL Java_com_tinkernorth_dish_hotpath_input_MoonlightGamepadBridge_nativeInstall( + JNIEnv* env, jclass bridgeCls) { + if (g_moonlightBridgeClass == nullptr) { + g_moonlightBridgeClass = (jclass)env->NewGlobalRef(bridgeCls); + } + if (g_moonlightDispatchMethod == nullptr) { + g_moonlightDispatchMethod = env->GetStaticMethodID(g_moonlightBridgeClass, "dispatchReport", + "(Ljava/lang/String;IIIIIII)V"); + if (g_moonlightDispatchMethod == nullptr) { + LOGE("MoonlightGamepadBridge.dispatchReport not found"); + env->ExceptionClear(); + } + } + startBridgeDispatchThread(); } JNIEXPORT void JNICALL diff --git a/app/src/main/java/com/tinkernorth/dish/DishApplication.kt b/app/src/main/java/com/tinkernorth/dish/DishApplication.kt index 262839e2..32919b73 100644 --- a/app/src/main/java/com/tinkernorth/dish/DishApplication.kt +++ b/app/src/main/java/com/tinkernorth/dish/DishApplication.kt @@ -15,6 +15,7 @@ import com.tinkernorth.dish.composer.StreamingServiceController import com.tinkernorth.dish.composer.WakeStateController import com.tinkernorth.dish.core.jni.PhysicalInputNative import com.tinkernorth.dish.hotpath.input.BluetoothGamepadBridge +import com.tinkernorth.dish.hotpath.input.MoonlightGamepadBridge import com.tinkernorth.dish.hotpath.input.PhysicalGamepadRegistry import com.tinkernorth.dish.hotpath.input.PhysicalSlotBindingObserver import com.tinkernorth.dish.hotpath.input.RumbleBridge @@ -81,6 +82,8 @@ class DishApplication : Application() { @Inject lateinit var rumbleRouter: RumbleRouter + @Inject lateinit var moonlightManager: com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionManager + @Inject lateinit var physicalInputNative: PhysicalInputNative @Inject lateinit var latencyProfilingStore: LatencyProfilingStore @@ -157,6 +160,7 @@ class DishApplication : Application() { lifecycle.addObserver(physicalMotionSource) lifecycle.addObserver(wakeStateController) BluetoothGamepadBridge.install(btRegistry) + MoonlightGamepadBridge.install(moonlightManager) lifecycle.addObserver(bluetoothBondMonitor) lifecycle.addObserver(bluetoothAdapterStateObserver) lifecycle.addObserver(bluetoothPermissionStateObserver) diff --git a/app/src/main/java/com/tinkernorth/dish/core/jni/SatelliteNative.kt b/app/src/main/java/com/tinkernorth/dish/core/jni/SatelliteNative.kt index 63ddf0c8..d4d993ab 100644 --- a/app/src/main/java/com/tinkernorth/dish/core/jni/SatelliteNative.kt +++ b/app/src/main/java/com/tinkernorth/dish/core/jni/SatelliteNative.kt @@ -120,6 +120,11 @@ object SatelliteNative { connectionId: String, ) + external fun bindPhysicalSlotMoonlight( + deviceId: Int, + connectionId: String, + ) + external fun unbindPhysicalSlot(deviceId: Int) external fun clearAllPhysicalSlots() diff --git a/app/src/main/java/com/tinkernorth/dish/hotpath/input/MoonlightGamepadBridge.kt b/app/src/main/java/com/tinkernorth/dish/hotpath/input/MoonlightGamepadBridge.kt new file mode 100644 index 00000000..e3d32721 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/hotpath/input/MoonlightGamepadBridge.kt @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +package com.tinkernorth.dish.hotpath.input + +import com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionManager + +/** + * Native -> Kotlin upcall target for physical pads bound to a Moonlight host, + * the sibling of [BluetoothGamepadBridge]: the native capture path publishes a + * changed XUSB state for a SLOT_MOONLIGHT binding and the shared bridge + * dispatch thread calls [dispatchReport], which seals and sends it on the live + * control session. Moonlight's low-16 button flags share XInput's bit layout, + * so the XUSB wButtons pass straight through. + */ +object MoonlightGamepadBridge { + init { + System.loadLibrary("satellite") + } + + @Volatile private var manager: MoonlightConnectionManager? = null + + // Must run from a JVM call so the app classloader is on the stack (FindClass in JNI_OnLoad would fail). + fun install(manager: MoonlightConnectionManager) { + this.manager = manager + nativeInstall() + } + + @JvmStatic + private external fun nativeInstall() + + @JvmStatic + @Suppress("LongParameterList") // fixed native upcall signature, mirrors BluetoothGamepadBridge + fun dispatchReport( + connectionId: String, + wButtons: Int, + bLT: Int, + bRT: Int, + sLX: Int, + sLY: Int, + sRX: Int, + sRY: Int, + ) { + val m = manager ?: return + m.get(connectionId)?.sendControllerState( + buttons = wButtons, + leftTrigger = bLT, + rightTrigger = bRT, + leftX = sLX, + leftY = sLY, + rightX = sRX, + rightY = sRY, + ) + } +} diff --git a/app/src/main/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserver.kt b/app/src/main/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserver.kt index 561626db..8e99eaef 100644 --- a/app/src/main/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserver.kt +++ b/app/src/main/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserver.kt @@ -39,6 +39,11 @@ sealed interface BindOp { val connectionId: String, ) : BindOp + data class BindMoonlight( + val deviceId: Int, + val connectionId: String, + ) : BindOp + data class Unbind( val deviceId: Int, ) : BindOp @@ -69,6 +74,7 @@ data class SatelliteSlotSnapshot( // registry no longer knows: a device that left while the observer was stopped is in neither // `present` nor `lastBound`, and without the sweep its slot would be re-declared to the satellite // on every reconnect forever. Non-numeric slot ids (the on-screen controller) are never swept. +@Suppress("LongParameterList") // one flat snapshot argument per connection source, mirrored by the tests fun reconcileSlots( present: Set, lastBound: Set, @@ -76,6 +82,7 @@ fun reconcileSlots( summaries: List, perConnectionSlotInfo: Map, btConnectedIds: Set, + moonlightLiveIds: Set = emptySet(), ): List { val ops = mutableListOf() val staleBound = bindings.keys.mapNotNull { it.toIntOrNull() }.filter { it !in present } @@ -112,11 +119,14 @@ fun reconcileSlots( } else { ops += BindOp.Unbind(id) } - // Moonlight has no native slot table yet, so a PHYSICAL pad bound to a Moonlight host does - // not stream through the native capture path; the on-screen controller drives Moonlight - // via the overlay Kotlin send path. Emit Unbind (the safe no-op) until the native - // SLOT_MOONLIGHT bridge lands. See the PR's known gaps. - ConnectionKind.MOONLIGHT -> ops += BindOp.Unbind(id) + ConnectionKind.MOONLIGHT -> + // Same live re-check discipline as the Bluetooth branch: the summary's Connected is + // a composer-snapshot read, so re-check the manager's live session before binding. + if (cid in moonlightLiveIds) { + ops += BindOp.BindMoonlight(id, cid) + } else { + ops += BindOp.Unbind(id) + } } } return ops @@ -151,6 +161,11 @@ fun dedupeBindOps( applied[op.deviceId] = op out += op } + is BindOp.BindMoonlight -> { + if (applied[op.deviceId] == op) continue + applied[op.deviceId] = op + out += op + } is BindOp.Unbind -> { applied.remove(op.deviceId) out += op @@ -174,6 +189,7 @@ class PhysicalSlotBindingObserver private val hub: ConnectionCoordinator, private val satellite: SatelliteConnectionManager, private val bt: BluetoothGamepadRegistry, + private val moonlight: com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionManager, private val scope: CoroutineScope, ) : DefaultLifecycleObserver { private data class BindingState( @@ -225,6 +241,11 @@ class PhysicalSlotBindingObserver satellite.get(cid)?.let { conn -> cid to SatelliteSlotSnapshot(conn.handle, conn.slots.value) } }.toMap() val btConnectedIds = referencedConnIds.filterTo(mutableSetOf()) { bt.isConnected(it) } + val moonlightLiveIds = + referencedConnIds.filterTo(mutableSetOf()) { + moonlight.get(it)?.state?.value == + com.tinkernorth.dish.source.connection.moonlight.MoonlightSessionState.Live + } val ops = reconcileSlots( present = present, @@ -233,6 +254,7 @@ class PhysicalSlotBindingObserver summaries = state.summaries, perConnectionSlotInfo = slotInfo, btConnectedIds = btConnectedIds, + moonlightLiveIds = moonlightLiveIds, ) // A satellite re-bind is not idempotent on the native side: bindPhysicalSlotSatellite // re-runs syncSlotBaseline, which resets the device to neutral and publishes it, briefly @@ -253,6 +275,7 @@ class PhysicalSlotBindingObserver is BindOp.BindSatellite -> SatelliteNative.bindPhysicalSlotSatellite(op.deviceId, op.handle, op.controllerIndex) is BindOp.BindBluetooth -> SatelliteNative.bindPhysicalSlotBluetooth(op.deviceId, op.connectionId) + is BindOp.BindMoonlight -> SatelliteNative.bindPhysicalSlotMoonlight(op.deviceId, op.connectionId) } } } diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt index f0b75a09..a8497d7d 100644 --- a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt @@ -8,6 +8,7 @@ import androidx.core.content.edit import com.tinkernorth.dish.core.net.bytesToHex import com.tinkernorth.dish.core.net.moonlight.MoonlightControlSession import com.tinkernorth.dish.core.net.moonlight.MoonlightCrypto +import com.tinkernorth.dish.core.net.moonlight.MoonlightEmulatedType import com.tinkernorth.dish.core.net.moonlight.MoonlightHost import com.tinkernorth.dish.core.net.moonlight.MoonlightIdentity import com.tinkernorth.dish.core.net.moonlight.MoonlightPairing @@ -28,6 +29,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @@ -140,7 +142,12 @@ class MoonlightConnectionManager }[id]!! } - /** Pair with (if needed) and launch [emulatedType] on [host]. */ + /** + * One-tap path: pair (if needed), pick the remembered/first app, and + * launch [emulatedType] on [host]. The connections screen drives the + * explicit pair/app-pick/type-pick steps via [pairHost], [fetchApps] and + * [launch]; this convenience path is kept for a remembered host. + */ fun connect( host: MoonlightHost, emulatedType: Int, @@ -149,15 +156,55 @@ class MoonlightConnectionManager conn.updateHost(host) conn.markLaunching() scope.launch(ioDispatcher) { - val paired = isPaired(host) - if (!paired && !pair(host)) { + if (!isPaired(host) && !pair(host)) { conn.markDisconnected() return@launch } - launchAndStream(conn, host, emulatedType) + val appId = store.get(host.id)?.lastAppId?.takeIf { it.isNotEmpty() } ?: defaultAppId(host) + if (appId == null) { + conn.markDisconnected() + _events.emit(MoonlightConnectionEvent.Error("No apps available on ${host.name}.")) + return@launch + } + launchAndStream(conn, host, appId, emulatedType) } } + /** Launch a specific [appId] with [emulatedType] (the app-pick path). */ + fun launch( + host: MoonlightHost, + appId: String, + emulatedType: Int, + ) { + val conn = findOrCreate(host) + conn.updateHost(host) + conn.markLaunching() + scope.launch(ioDispatcher) { launchAndStream(conn, host, appId, emulatedType) } + } + + /** + * Pair with [host]: emits [MoonlightConnectionEvent.PairingPinReady] with + * the generated PIN, runs the 5 phases, and returns true when paired. + * Public so the connections screen can await pairing before fetching the + * app list. + */ + suspend fun pairHost(host: MoonlightHost): Boolean = + withContext(ioDispatcher) { + if (isPaired(host)) { + _events.emit(MoonlightConnectionEvent.Paired(host)) + true + } else { + pair(host) + } + } + + /** Fetch the host's app list (empty when unreachable/unpaired). */ + suspend fun fetchApps(host: MoonlightHost): List = + withContext(ioDispatcher) { + val reply = gateway.getHttps(MoonlightUrls.appList(host.address, host.httpsPort, deviceId), host.id) + MoonlightXml.parseAppList(reply.body) + } + private fun isPaired(host: MoonlightHost): Boolean { val reply = gateway.getHttps(MoonlightUrls.serverInfoHttps(host.address, host.httpsPort, deviceId), host.id) if (!reply.ok) return false @@ -208,6 +255,7 @@ class MoonlightConnectionManager private suspend fun launchAndStream( conn: MoonlightConnection, host: MoonlightHost, + appId: String, emulatedType: Int, ) { val rikey = MoonlightCrypto.randomBytes(RIKEY_LEN) @@ -216,12 +264,6 @@ class MoonlightConnectionManager (it[0].toInt() and 0xFF) or ((it[1].toInt() and 0xFF) shl 8) or ((it[2].toInt() and 0xFF) shl 16) or ((it[3].toInt() and 0xFF) shl 24) } - val appId = - store.get(host.id)?.lastAppId?.takeIf { it.isNotEmpty() } ?: defaultAppId(host) ?: run { - conn.markDisconnected() - _events.emit(MoonlightConnectionEvent.Error("No apps available on ${host.name}.")) - return - } val launchUrl = MoonlightUrls.launch(host.address, host.httpsPort, deviceId, appId, bytesToHex(rikey), rikeyId, LAUNCH_MODE) val launchReply = gateway.getHttps(launchUrl, host.id) @@ -257,7 +299,7 @@ class MoonlightConnectionManager MoonlightConnection.BASE_CAPABILITIES, MoonlightConnection.SUPPORTED_BUTTONS, ) - rememberPaired(host, appId) + rememberPaired(host, appId, emulatedType) } private fun defaultAppId(host: MoonlightHost): String? { @@ -278,6 +320,7 @@ class MoonlightConnectionManager private fun rememberPaired( host: MoonlightHost, appId: String = store.get(host.id)?.lastAppId.orEmpty(), + emulatedType: Int = store.get(host.id)?.emulatedType ?: MoonlightEmulatedType.AUTO, ) { store.put( RememberedMoonlight( @@ -288,10 +331,17 @@ class MoonlightConnectionManager httpsPort = host.httpsPort, uniqueId = host.uniqueId, lastAppId = appId, + emulatedType = emulatedType, ), ) } + /** The remembered emulated-device pick for [hostId], defaulting to Auto. */ + fun rememberedEmulatedType(hostId: String): Int = store.get(hostId)?.emulatedType ?: MoonlightEmulatedType.AUTO + + /** The remembered last-launched app id for [hostId], or empty. */ + fun rememberedAppId(hostId: String): String = store.get(hostId)?.lastAppId.orEmpty() + // The /launch response carries sessionUrl0 = rtsp://ip:port; pull the port. private fun parseRtspPort(xml: String): Int? = Regex("rtsp://[^:<]+:(\\d+)") diff --git a/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsActivity.kt b/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsActivity.kt index 7b4fffed..b64dea82 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsActivity.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsActivity.kt @@ -80,6 +80,8 @@ class ConnectionsActivity : BaseGamepadHostActivity() { @Inject lateinit var hub: ConnectionCoordinator + @Inject lateinit var moonlight: com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionManager + @Inject lateinit var store: ConnectionStore @Inject lateinit var btAdapterState: BluetoothAdapterStateObserver @@ -95,8 +97,10 @@ class ConnectionsActivity : BaseGamepadHostActivity() { private lateinit var satelliteHeader: SectionHeaderAdapter private lateinit var bluetoothHeader: SectionHeaderAdapter + private lateinit var moonlightHeader: SectionHeaderAdapter private lateinit var satelliteList: SatelliteListAdapter private lateinit var bluetoothList: BluetoothListAdapter + private lateinit var moonlightList: MoonlightListAdapter private val satelliteRowListener = object : SatelliteRowListener { @@ -175,6 +179,34 @@ class ConnectionsActivity : BaseGamepadHostActivity() { private var pairingServer: com.tinkernorth.dish.core.model.DiscoveredServer? = null + private val moonlightRowListener = + object : MoonlightRowListener { + override fun onConnectKnown(summary: ConnectionSummary) { + val host = + moonlight.get(summary.id)?.host?.value + ?: moonlight.remembered.value + .firstOrNull { it.id == summary.id } + ?.toHost() + ?: moonlight.discovered.value.firstOrNull { it.id == summary.id } + ?: return + startMoonlightConnect(host) + } + + override fun onConnectDiscovered(host: com.tinkernorth.dish.core.net.moonlight.MoonlightHost) { + startMoonlightConnect(host) + } + + override fun onDisconnect(id: String) { + moonlight.disconnect(id) + } + + override fun onForget(id: String) { + hub.forgetConnection(id) + } + } + + private var moonlightPinDialog: AlertDialog? = null + private val btPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), @@ -217,6 +249,7 @@ class ConnectionsActivity : BaseGamepadHostActivity() { if (granted) { dismissLocalNetworkBanner() satellite.startDiscovery() + moonlight.startDiscovery() } else { showLocalNetworkBanner() } @@ -246,6 +279,7 @@ class ConnectionsActivity : BaseGamepadHostActivity() { viewModel.ui.collect { state -> render(state) satelliteHeader.setLoading(state.scanning, getString(R.string.action_scanning)) + moonlightHeader.setLoading(state.moonlightScanning, getString(R.string.action_scanning)) // Success path emits no ConnectionEvent, so observe state directly to dismiss PIN dialog. dismissPinDialogIfPaired(state) } @@ -261,6 +295,28 @@ class ConnectionsActivity : BaseGamepadHostActivity() { } } } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + moonlight.events.collect(::onMoonlightEvent) + } + } + } + + private fun onMoonlightEvent(ev: com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionEvent) { + when (ev) { + is com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionEvent.PairingPinReady -> + showMoonlightPinDialog(ev.host, ev.pin) + is com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionEvent.Paired -> + moonlightPinDialog?.dismiss() + is com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionEvent.Error -> { + moonlightPinDialog?.dismiss() + notifications.error( + glyph = R.drawable.ic_pc_monitor, + title = getString(R.string.section_moonlight_hosts), + body = ev.message, + ) + } + } } private fun observeSystemStateBanners() { @@ -343,8 +399,17 @@ class ConnectionsActivity : BaseGamepadHostActivity() { R.string.section_bluetooth_hosts, R.string.action_add, ) { requestBtPermissions(continueToAdd = true) } + moonlightHeader = + SectionHeaderAdapter( + R.drawable.ic_pc_monitor, + R.string.section_moonlight_hosts, + R.string.action_scan, + secondaryActionLabel = R.string.action_add, + onSecondaryAction = ::showAddMoonlightDialog, + ) { ensureLocalNetworkThenDiscover(userInitiated = true) } satelliteList = SatelliteListAdapter(satelliteRowListener) bluetoothList = BluetoothListAdapter(bluetoothRowListener) + moonlightList = MoonlightListAdapter(moonlightRowListener) val single = binding.rvConnections if (single != null) { single.bindConnectionColumn( @@ -352,6 +417,9 @@ class ConnectionsActivity : BaseGamepadHostActivity() { satelliteHeader, satelliteList, StaticViewAdapter(R.layout.item_connection_divider), + moonlightHeader, + moonlightList, + StaticViewAdapter(R.layout.item_connection_divider), bluetoothHeader, bluetoothList, ), @@ -394,6 +462,9 @@ class ConnectionsActivity : BaseGamepadHostActivity() { ) } bluetoothList.submitList(rows.ifEmpty { listOf(BluetoothRow.Empty(getString(R.string.bt_hosts_empty))) }) + moonlightList.submitList( + state.moonlightRows.ifEmpty { listOf(MoonlightRow.Empty(getString(R.string.moonlight_hosts_empty))) }, + ) } private fun satelliteEmptyMessage(lastScanAtMs: Long?): String { @@ -742,6 +813,121 @@ class ConnectionsActivity : BaseGamepadHostActivity() { dialog.show() } + // ── Moonlight host flow ───────────────────────────────────────────────── + + // Tap Connect: pick the emulated device, pair (showing the PIN) if needed, + // then pick the app and launch. Each step remembers the user's last choice. + private fun startMoonlightConnect(host: com.tinkernorth.dish.core.net.moonlight.MoonlightHost) { + val types = + intArrayOf( + com.tinkernorth.dish.core.net.moonlight.MoonlightEmulatedType.AUTO, + com.tinkernorth.dish.core.net.moonlight.MoonlightEmulatedType.XBOX, + com.tinkernorth.dish.core.net.moonlight.MoonlightEmulatedType.PLAYSTATION, + com.tinkernorth.dish.core.net.moonlight.MoonlightEmulatedType.NINTENDO, + ) + val labels = + arrayOf( + getString(R.string.moonlight_emulated_auto), + getString(R.string.picker_type_xbox), + getString(R.string.picker_type_playstation), + getString(R.string.moonlight_emulated_nintendo), + ) + val remembered = moonlight.rememberedEmulatedType(host.id) + val checked = types.indexOf(remembered).coerceAtLeast(0) + MaterialAlertDialogBuilder(this) + .setTitle(R.string.moonlight_emulated_title) + .setSingleChoiceItems(labels, checked) { dialog, which -> + dialog.dismiss() + pairThenPickApp(host, types[which]) + }.setNegativeButton(R.string.action_cancel, null) + .show() + } + + private fun pairThenPickApp( + host: com.tinkernorth.dish.core.net.moonlight.MoonlightHost, + emulatedType: Int, + ) { + lifecycleScope.launch { + // pairHost returns immediately when already paired; otherwise it emits the PIN + // (shown by onMoonlightEvent) and completes when the host accepts it. + if (!moonlight.pairHost(host)) return@launch + moonlightPinDialog?.dismiss() + val apps = moonlight.fetchApps(host) + if (apps.isEmpty()) { + notifications.error( + glyph = R.drawable.ic_pc_monitor, + title = host.name, + body = getString(R.string.moonlight_no_apps), + ) + return@launch + } + showMoonlightAppPicker(host, apps, emulatedType) + } + } + + private fun showMoonlightAppPicker( + host: com.tinkernorth.dish.core.net.moonlight.MoonlightHost, + apps: List, + emulatedType: Int, + ) { + val labels = apps.map { it.title.ifEmpty { it.id } }.toTypedArray() + val lastAppId = moonlight.rememberedAppId(host.id) + val checked = apps.indexOfFirst { it.id == lastAppId }.coerceAtLeast(0) + MaterialAlertDialogBuilder(this) + .setTitle(R.string.moonlight_app_title) + .setSingleChoiceItems(labels, checked) { dialog, which -> + dialog.dismiss() + moonlight.launch(host, apps[which].id, emulatedType) + }.setNegativeButton(R.string.action_cancel, null) + .show() + } + + private fun showMoonlightPinDialog( + host: com.tinkernorth.dish.core.net.moonlight.MoonlightHost, + pin: String, + ) { + moonlightPinDialog?.dismiss() + val message = getString(R.string.moonlight_pin_message) + "\n\n" + pin + "\n\n" + getString(R.string.moonlight_pin_waiting) + moonlightPinDialog = + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.moonlight_pin_title, host.name)) + .setMessage(message) + .setNegativeButton(R.string.action_cancel) { _, _ -> moonlight.disconnect(host.id) } + .setOnDismissListener { moonlightPinDialog = null } + .show() + } + + private fun showAddMoonlightDialog() { + val layout = TextInputLayout(this) + val input = TextInputEditText(this).apply { hint = getString(R.string.add_moonlight_host_hint) } + layout.addView(input) + val pad = resources.getDimensionPixelSize(R.dimen.spacing_md) + layout.setPadding(pad, pad, pad, 0) + val dialog = + MaterialAlertDialogBuilder(this) + .setTitle(R.string.action_add_moonlight_host) + .setView(layout) + .setPositiveButton(R.string.action_add, null) + .setNegativeButton(R.string.action_cancel, null) + .create() + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val address = + input.text + ?.toString() + ?.trim() + .orEmpty() + if (address.isEmpty()) { + layout.error = getString(R.string.add_moonlight_error_host) + } else { + moonlight.addManualHost(address) + dialog.dismiss() + } + } + } + dialog.show() + } + private fun parsePort(field: TextInputEditText): Int? { val port = field.text @@ -986,6 +1172,7 @@ class ConnectionsActivity : BaseGamepadHostActivity() { if (LocalNetworkAccess.isGranted(this)) { dismissLocalNetworkBanner() satellite.startDiscovery() + moonlight.startDiscovery() return } if (userInitiated || !localNetworkPrompted) { diff --git a/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsUiState.kt b/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsUiState.kt index ccd5a469..fbffe806 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsUiState.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsUiState.kt @@ -10,8 +10,10 @@ import com.tinkernorth.dish.source.connection.SatelliteConnection data class ConnectionsUiState( val satelliteRows: List, val bluetoothSummaries: List, + val moonlightRows: List, val rememberedBtIds: Set, val scanning: Boolean, + val moonlightScanning: Boolean, val lastScanAtMs: Long?, ) { companion object { @@ -19,8 +21,10 @@ data class ConnectionsUiState( ConnectionsUiState( satelliteRows = emptyList(), bluetoothSummaries = emptyList(), + moonlightRows = emptyList(), rememberedBtIds = emptySet(), scanning = false, + moonlightScanning = false, lastScanAtMs = null, ) } diff --git a/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsViewModel.kt b/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsViewModel.kt index 857d80c1..b285db88 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsViewModel.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope import com.tinkernorth.dish.composer.ConnectionCoordinator import com.tinkernorth.dish.repository.ConnectionStore import com.tinkernorth.dish.source.connection.SatelliteConnectionManager +import com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -20,9 +21,19 @@ class ConnectionsViewModel constructor( hub: ConnectionCoordinator, satellite: SatelliteConnectionManager, + moonlight: MoonlightConnectionManager, store: ConnectionStore, ) : ViewModel() { - val ui: StateFlow = + // The satellite/BT half of the state, so the Moonlight flows fit in one more combine. + private data class SatBtSlice( + val satelliteRows: List, + val bluetoothSummaries: List, + val rememberedBtIds: Set, + val scanning: Boolean, + val lastScanAtMs: Long?, + ) + + private val satBt = combine( hub.connections, satellite.discoveredServers, @@ -30,12 +41,30 @@ class ConnectionsViewModel satellite.lastScanAtMs, store.rememberedBtFlow, ) { conns, discovered, scanning, lastScan, rememberedBt -> - ConnectionsUiState( + SatBtSlice( satelliteRows = satelliteRows(conns, discovered), bluetoothSummaries = bluetoothSummaries(conns), rememberedBtIds = rememberedBt.mapTo(mutableSetOf()) { it.id }, scanning = scanning, lastScanAtMs = lastScan, ) + } + + val ui: StateFlow = + combine( + satBt, + hub.connections, + moonlight.discovered, + moonlight.isScanning, + ) { slice, conns, moonlightDiscovered, moonlightScanning -> + ConnectionsUiState( + satelliteRows = slice.satelliteRows, + bluetoothSummaries = slice.bluetoothSummaries, + moonlightRows = moonlightRows(conns, moonlightDiscovered), + rememberedBtIds = slice.rememberedBtIds, + scanning = slice.scanning, + moonlightScanning = moonlightScanning, + lastScanAtMs = slice.lastScanAtMs, + ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ConnectionsUiState.Empty) } diff --git a/app/src/main/java/com/tinkernorth/dish/ui/connections/MoonlightListAdapter.kt b/app/src/main/java/com/tinkernorth/dish/ui/connections/MoonlightListAdapter.kt new file mode 100644 index 00000000..e2ce5773 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/ui/connections/MoonlightListAdapter.kt @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +package com.tinkernorth.dish.ui.connections + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.tinkernorth.dish.R +import com.tinkernorth.dish.composer.ConnectionKind +import com.tinkernorth.dish.composer.ConnectionSummary +import com.tinkernorth.dish.composer.LinkState +import com.tinkernorth.dish.core.net.moonlight.MoonlightHost +import com.tinkernorth.dish.databinding.RowConnectionBinding +import com.tinkernorth.dish.ui.common.setLoading +import com.tinkernorth.dish.ui.common.statusChipText + +/** Rows for the Moonlight-hosts section, the sibling of [SatelliteRow]. */ +sealed interface MoonlightRow { + data class Known( + val summary: ConnectionSummary, + ) : MoonlightRow + + data class Discovered( + val host: MoonlightHost, + ) : MoonlightRow + + data class Empty( + val message: String, + ) : MoonlightRow +} + +interface MoonlightRowListener { + fun onConnectKnown(summary: ConnectionSummary) + + fun onConnectDiscovered(host: MoonlightHost) + + fun onDisconnect(id: String) + + fun onForget(id: String) +} + +class MoonlightListAdapter( + private val listener: MoonlightRowListener, +) : ListAdapter(Diff) { + override fun getItemViewType(position: Int): Int = if (getItem(position) is MoonlightRow.Empty) TYPE_EMPTY else TYPE_ROW + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return if (viewType == TYPE_EMPTY) { + EmptyVH(inflater.inflate(R.layout.item_connection_empty, parent, false)) + } else { + RowVH(RowConnectionBinding.inflate(inflater, parent, false), listener) + } + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + ) { + when (val row = getItem(position)) { + is MoonlightRow.Empty -> (holder as EmptyVH).bind(row.message) + else -> (holder as RowVH).bind(row) + } + } + + class EmptyVH( + view: View, + ) : RecyclerView.ViewHolder(view) { + fun bind(message: String) { + (itemView as TextView).text = message + } + } + + class RowVH( + private val b: RowConnectionBinding, + private val listener: MoonlightRowListener, + ) : RecyclerView.ViewHolder(b.root) { + private val ctx get() = b.root.context + + fun bind(row: MoonlightRow) { + when (row) { + is MoonlightRow.Known -> bindKnown(row) + is MoonlightRow.Discovered -> bindDiscovered(row) + is MoonlightRow.Empty -> Unit + } + } + + private fun bindKnown(row: MoonlightRow.Known) { + val c = row.summary + b.paintConnection(c.label, c.detail, statusChipText(ctx, c.live), ConnectionKind.MOONLIGHT, c.live) + when (c.live) { + LinkState.Connected, LinkState.Unstable -> { + b.btnRowAction.setLoading(false, "", ctx.getString(R.string.action_disconnect)) + b.btnRowAction.setOnClickListener { listener.onDisconnect(c.id) } + } + LinkState.Connecting -> { + b.btnRowAction.setLoading( + true, + ctx.getString(R.string.chip_status_connecting), + ctx.getString(R.string.action_connect), + ) + b.btnRowAction.setOnClickListener(null) + } + LinkState.Saved, LinkState.Ready, LinkState.Found, LinkState.Stale -> { + b.btnRowAction.setLoading(false, "", ctx.getString(R.string.action_connect)) + b.btnRowAction.setOnClickListener { listener.onConnectKnown(c) } + } + } + b.btnRowSecondary.visibility = View.VISIBLE + b.btnRowSecondary.text = ctx.getString(R.string.action_forget_short) + b.btnRowSecondary.setOnClickListener { listener.onForget(c.id) } + } + + private fun bindDiscovered(row: MoonlightRow.Discovered) { + val h = row.host + b.paintConnection( + h.name.ifEmpty { h.address }, + ctx.getString(R.string.moonlight_row_detail, h.address), + ctx.getString(R.string.discovered_row_status, ctx.getString(R.string.discovery_source_mdns)), + ConnectionKind.MOONLIGHT, + LinkState.Found, + ) + b.btnRowAction.setLoading(false, "", ctx.getString(R.string.action_connect)) + b.btnRowAction.setOnClickListener { listener.onConnectDiscovered(h) } + b.btnRowSecondary.visibility = View.GONE + b.btnRowSecondary.setOnClickListener(null) + } + } + + companion object { + private const val TYPE_ROW = 0 + private const val TYPE_EMPTY = 1 + + private val Diff = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + o: MoonlightRow, + n: MoonlightRow, + ): Boolean = + when { + o is MoonlightRow.Known && n is MoonlightRow.Known -> o.summary.id == n.summary.id + o is MoonlightRow.Discovered && n is MoonlightRow.Discovered -> o.host.id == n.host.id + o is MoonlightRow.Empty && n is MoonlightRow.Empty -> true + else -> false + } + + override fun areContentsTheSame( + o: MoonlightRow, + n: MoonlightRow, + ): Boolean = o == n + } + } +} + +// Known hosts first (from the composer summaries), then discovered hosts not already known. +fun moonlightRows( + conns: List, + discovered: List, +): List { + val known = conns.filter { it.kind == ConnectionKind.MOONLIGHT } + val knownIds = known.mapTo(mutableSetOf()) { it.id } + return buildList { + known.forEach { add(MoonlightRow.Known(it)) } + discovered.forEach { host -> + if (host.id !in knownIds) add(MoonlightRow.Discovered(host)) + } + } +} diff --git a/app/src/main/res/values-bs/strings.xml b/app/src/main/res/values-bs/strings.xml index 464f3aeb..f325b59c 100644 --- a/app/src/main/res/values-bs/strings.xml +++ b/app/src/main/res/values-bs/strings.xml @@ -131,6 +131,18 @@ %1$s • %2$s Moonlight • %1$s + MOONLIGHT HOSTOVI + Još nema Moonlight hostova. Skenirajte mrežu ili dodajte adresu. + Upari s %1$s + Na hostu otvorite Moonlight/Sunshine stranicu i unesite ovaj PIN: + Čekanje da host prihvati PIN… + Emuliraj kontroler + Automatski + Odaberi aplikaciju + Na ovom hostu nema dostupnih aplikacija. + Dodaj Moonlight host + IP ili naziv hosta + Unesite adresu hosta Spreman za uparivanje. Pronađite ovaj uređaj na svom hostu Preuzimanje HID profila… Neaktivan diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 27cfb383..35d9e87a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -131,6 +131,18 @@ Kopplungsvorgangs. --> %1$s • %2$s Moonlight • %1$s + MOONLIGHT-HOSTS + Noch keine Moonlight-Hosts. Netzwerk scannen oder per Adresse hinzufügen. + Mit %1$s koppeln + Öffne auf dem Host die Moonlight/Sunshine-Seite und gib diese PIN ein: + Warte auf Bestätigung der PIN durch den Host… + Controller emulieren + Automatisch + App auswählen + Auf diesem Host sind keine Apps verfügbar. + Moonlight-Host hinzufügen + Host-IP oder -Name + Host-Adresse eingeben Bereit zum Koppeln. Suche dieses Gerät auf deinem Host HID-Profil wird abgerufen… Inaktiv diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f15f091f..4f061dd5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -133,6 +133,18 @@ emparejamiento. --> %1$s • %2$s Moonlight • %1$s + HOSTS MOONLIGHT + Aún no hay hosts Moonlight. Busca en tu red o añade uno por dirección. + Emparejar con %1$s + En tu host, abre la página de Moonlight/Sunshine e introduce este PIN: + Esperando a que el host acepte el PIN… + Emular mando + Automático + Elegir una app + No hay apps disponibles en este host. + Añadir host Moonlight + IP o nombre del host + Introduce la dirección del host Listo para emparejar. Busca este dispositivo en tu host Adquiriendo perfil HID… Inactivo diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9fe674a6..0971870b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -132,6 +132,18 @@ pendant un appairage en cours. --> %1$s • %2$s Moonlight • %1$s + HÔTES MOONLIGHT + Aucun hôte Moonlight. Scannez le réseau ou ajoutez-en un par adresse. + Associer à %1$s + Sur votre hôte, ouvrez la page Moonlight/Sunshine et saisissez ce code PIN : + En attente de l\'acceptation du PIN par l\'hôte… + Émuler la manette + Auto + Choisir une app + Aucune app disponible sur cet hôte. + Ajouter un hôte Moonlight + IP ou nom de l\'hôte + Saisissez l\'adresse de l\'hôte Prête à appairer : repérez cet appareil sur votre hôte Acquisition du profil HID… Inactive diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index c5868742..48db4844 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -133,6 +133,18 @@ pareamento. --> %1$s • %2$s Moonlight • %1$s + HOSTS MOONLIGHT + Nenhum host Moonlight ainda. Busque na rede ou adicione por endereço. + Parear com %1$s + No seu host, abra a página do Moonlight/Sunshine e insira este PIN: + Aguardando o host aceitar o PIN… + Emular controle + Automático + Escolher um app + Nenhum app disponível neste host. + Adicionar host Moonlight + IP ou nome do host + Insira o endereço do host Pronto para parear. Procure este dispositivo no seu host Adquirindo perfil HID… Inativo diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 09d8779e..219330f9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -169,6 +169,19 @@ text shown while a new pairing is in flight. --> %1$s • %2$s Moonlight • %1$s + MOONLIGHT HOSTS + No Moonlight hosts yet. Scan your network or add one by address. + Pair with %1$s + On your host, open the Moonlight/Sunshine page and enter this PIN: + Waiting for the host to accept the PIN… + Emulate controller + Auto + Nintendo + Choose an app + No apps are available on this host. + Add Moonlight host + Host IP or name + Enter the host address Ready to pair. Find this device on your host Acquiring HID profile… Idle diff --git a/app/src/test/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserverTest.kt b/app/src/test/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserverTest.kt index 05783faf..f7ebb153 100644 --- a/app/src/test/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserverTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserverTest.kt @@ -41,6 +41,18 @@ class PhysicalSlotBindingObserverTest { registered: Boolean = true, ) = SatelliteConnection.SlotBinding(controllerIndex = index, controllerType = 0, registered = registered) + private fun moonlightSummary( + id: String, + live: LinkState = LinkState.Connected, + ) = ConnectionSummary( + id = id, + kind = ConnectionKind.MOONLIGHT, + label = id, + detail = "", + live = live, + boundSlotIds = emptyList(), + ) + private fun reconcile( present: Set = emptySet(), lastBound: Set = emptySet(), @@ -48,7 +60,45 @@ class PhysicalSlotBindingObserverTest { summaries: List = emptyList(), slotInfo: Map = emptyMap(), btConnectedIds: Set = emptySet(), - ) = reconcileSlots(present, lastBound, bindings, summaries, slotInfo, btConnectedIds) + moonlightLiveIds: Set = emptySet(), + ) = reconcileSlots(present, lastBound, bindings, summaries, slotInfo, btConnectedIds, moonlightLiveIds) + + @Test + fun `a present device binds to a live Moonlight host`() { + val ops = + reconcile( + present = setOf(3), + bindings = mapOf("3" to "moonlight:pc"), + summaries = listOf(moonlightSummary("moonlight:pc")), + moonlightLiveIds = setOf("moonlight:pc"), + ) + assertEquals(listOf(BindOp.BindMoonlight(deviceId = 3, connectionId = "moonlight:pc")), ops) + } + + @Test + fun `a Moonlight host whose session is not live yet unbinds instead of binding`() { + // The composer summary says Connected, but the manager re-check says the session is not live. + val ops = + reconcile( + present = setOf(3), + bindings = mapOf("3" to "moonlight:pc"), + summaries = listOf(moonlightSummary("moonlight:pc")), + moonlightLiveIds = emptySet(), + ) + assertEquals(listOf(BindOp.Unbind(3)), ops) + } + + @Test + fun `an unchanged Moonlight bind is deduped, a changed one is re-applied`() { + val op = BindOp.BindMoonlight(deviceId = 3, connectionId = "moonlight:pc") + val first = dedupeBindOps(listOf(op), emptyMap()) + assertEquals(listOf(op), first.ops) + val second = dedupeBindOps(listOf(op), first.applied) + assertEquals(emptyList(), second.ops) + val changed = BindOp.BindMoonlight(deviceId = 3, connectionId = "moonlight:other") + val third = dedupeBindOps(listOf(changed), first.applied) + assertEquals(listOf(changed), third.ops) + } @Test fun `a departed device is unbound then forgotten then released, before any binds`() { diff --git a/app/src/test/java/com/tinkernorth/dish/ui/connections/MoonlightRowsTest.kt b/app/src/test/java/com/tinkernorth/dish/ui/connections/MoonlightRowsTest.kt new file mode 100644 index 00000000..c54ab466 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/ui/connections/MoonlightRowsTest.kt @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +package com.tinkernorth.dish.ui.connections + +import com.tinkernorth.dish.composer.ConnectionKind +import com.tinkernorth.dish.composer.ConnectionSummary +import com.tinkernorth.dish.composer.LinkState +import com.tinkernorth.dish.core.net.moonlight.MoonlightHost +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MoonlightRowsTest { + private fun summary( + id: String, + kind: ConnectionKind = ConnectionKind.MOONLIGHT, + ) = ConnectionSummary(id = id, kind = kind, label = id, detail = "", live = LinkState.Saved, boundSlotIds = emptyList()) + + @Test + fun `known moonlight hosts come first, then discovered hosts not already known`() { + val known = summary("moonlight:uid:a") + val bt = summary("bt:x", ConnectionKind.BLUETOOTH) + val discoveredKnown = MoonlightHost(name = "A", address = "10.0.0.1", uniqueId = "a") + val discoveredNew = MoonlightHost(name = "B", address = "10.0.0.2", uniqueId = "b") + + val rows = moonlightRows(listOf(known, bt), listOf(discoveredKnown, discoveredNew)) + + assertEquals(2, rows.size) + assertTrue(rows[0] is MoonlightRow.Known) + assertEquals("moonlight:uid:a", (rows[0] as MoonlightRow.Known).summary.id) + assertTrue(rows[1] is MoonlightRow.Discovered) + assertEquals("moonlight:uid:b", (rows[1] as MoonlightRow.Discovered).host.id) + } + + @Test + fun `bluetooth and satellite summaries are excluded`() { + val rows = moonlightRows(listOf(summary("sat:1", ConnectionKind.SATELLITE)), emptyList()) + assertTrue(rows.isEmpty()) + } +} From a00046cbb2558e40c24da6ffd99f8c9c3c1dab44 Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Tue, 25 Aug 2026 17:26:12 -0400 Subject: [PATCH 03/20] test: mint the Moonlight pairing identities at runtime, not from committed PEMs MoonlightPairingTest read four PEM fixtures out of test resources, two of them RSA private keys. They were throwaway keys that protected nothing, but gitleaks is right to flag PEM key material and the tests never needed byte-stable keys: the only pinned assertion is a hardcoded clientSalt, and everything else is a round trip through MoonlightCrypto with client and reference server driven by the same primitives. The test now mints two disposable RSA-2048 self-signed identities at class load via okhttp-tls HeldCertificate, in a companion object so JUnit's per-method instances do not re-generate them. .rsa2048() is explicit because the builder defaults to ECDSA and pairing signs SHA256withRSA. This is how the androidTest FakeSatellite already mints its cert. All four fixtures are deleted; test/resources held nothing else. libs.okhttp.tls was already a vetted androidTestImplementation in the version catalog, so the new testImplementation adds no supply chain. Note that this does not turn the gitleaks job green on its own: the workflow scans full history, where the blobs still live. Clearing that needs a history rewrite, which was deliberately not taken. --- app/build.gradle.kts | 3 + .../net/moonlight/MoonlightPairingTest.kt | 82 +++++++++---------- .../test/resources/moonlight/client_cert.pem | 18 ---- .../test/resources/moonlight/client_key.pem | 28 ------- .../test/resources/moonlight/server_cert.pem | 18 ---- .../test/resources/moonlight/server_key.pem | 28 ------- 6 files changed, 42 insertions(+), 135 deletions(-) delete mode 100644 app/src/test/resources/moonlight/client_cert.pem delete mode 100644 app/src/test/resources/moonlight/client_key.pem delete mode 100644 app/src/test/resources/moonlight/server_cert.pem delete mode 100644 app/src/test/resources/moonlight/server_key.pem diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 93cd253f..1c9fb514 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -247,6 +247,9 @@ dependencies { testImplementation(libs.mockk) testImplementation(libs.turbine) testImplementation(libs.kotlinx.coroutines.test) + // Mints the throwaway self-signed certs the Moonlight pairing test pairs + // against, so no key material is committed to the repo. + testImplementation(libs.okhttp.tls) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.test.core) diff --git a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightPairingTest.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightPairingTest.kt index f748b663..47bb5e8b 100644 --- a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightPairingTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightPairingTest.kt @@ -5,13 +5,12 @@ package com.tinkernorth.dish.core.net.moonlight import com.tinkernorth.dish.core.net.bytesToHex import com.tinkernorth.dish.core.net.hexToBytes +import okhttp3.tls.HeldCertificate import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -import java.security.KeyFactory import java.security.PrivateKey -import java.security.spec.PKCS8EncodedKeySpec /** * Exercises the full 5-phase client pairing against a reference server built @@ -19,19 +18,15 @@ import java.security.spec.PKCS8EncodedKeySpec * Both directions are checked: the client authenticates the server, and the * server authenticates the client. Randomness is pinned so the exchange is * deterministic. + * + * The two RSA identities are throwaway ones generated when the class loads, the + * way the androidTest FakeSatellite mints its cert: no key material is committed + * to the repo. Nothing below is pinned to specific key bytes (the assertions are + * round-trips through [MoonlightCrypto]), so a fresh pair each run is fine. */ class MoonlightPairingTest { - private val clientCertPem = resource("moonlight/client_cert.pem") - private val clientKey = privateKey(resource("moonlight/client_key.pem")) - private val serverCertPem = resource("moonlight/server_cert.pem") - private val serverKey = privateKey(resource("moonlight/server_key.pem")) - - private val clientIdentity = - object : MoonlightIdentity { - override val certificatePem = clientCertPem - override val certificateSignature = MoonlightCert.signatureOf(clientCertPem) - override val privateKey = clientKey - } + private val clientIdentity: MoonlightIdentity = CLIENT + private val serverIdentity: MoonlightIdentity = SERVER // Fixed random material so the exchange is byte-deterministic. private val clientSalt = ByteArray(16) { (it + 1).toByte() } @@ -46,10 +41,12 @@ class MoonlightPairingTest { private fun newPairing(pin: String) = MoonlightPairing(clientIdentity, pin) { clientRandom.next(it) } + private fun newServer(pin: String) = ReferenceServer(pin, serverIdentity, clientIdentity.certificatePem) + @Test fun `full pairing round-trip authenticates both ends`() { val pin = "0451" - val server = ReferenceServer(pin, serverCertPem, serverKey, MoonlightCert.signatureOf(serverCertPem)) + val server = newServer(pin) val pairing = newPairing(pin) // Phase 1. @@ -69,7 +66,7 @@ class MoonlightPairingTest { @Test fun `wrong PIN derives a different key and fails phase 2`() { - val server = ReferenceServer("0451", serverCertPem, serverKey, MoonlightCert.signatureOf(serverCertPem)) + val server = newServer("0451") val pairing = newPairing("9999") val p1 = pairing.phase1Params("dish-uid") pairing.onPhase1(server.getServerCert(p1.getValue("salt"))) @@ -84,9 +81,8 @@ class MoonlightPairingTest { /** A minimal Wolf-equivalent server, driven purely by [MoonlightCrypto]. */ private class ReferenceServer( pin: String, - private val serverCertPem: String, - private val serverKey: PrivateKey, - private val serverCertSignature: ByteArray, + private val identity: MoonlightIdentity, + private val clientCertPem: String, ) { private val pinBytes = pin private var aesKey = ByteArray(0) @@ -97,18 +93,18 @@ class MoonlightPairingTest { fun getServerCert(saltHex: String): String { aesKey = MoonlightCrypto.pairingKey(hexToBytes(saltHex), pinBytes) - return serverCertPem + return identity.certificatePem } fun challengeResponse(clientChallengeHex: String): String { clientChallenge = MoonlightCrypto.aesEcbDecrypt(aesKey, hexToBytes(clientChallengeHex)) - val hash = MoonlightCrypto.sha256(clientChallenge, serverCertSignature, serverSecret) + val hash = MoonlightCrypto.sha256(clientChallenge, identity.certificateSignature, serverSecret) return bytesToHex(MoonlightCrypto.aesEcbEncrypt(aesKey, hash + serverChallenge)) } fun clientHashResponse(serverChallengeRespHex: String): String { storedClientHash = MoonlightCrypto.aesEcbDecrypt(aesKey, hexToBytes(serverChallengeRespHex)) - val signature = MoonlightCrypto.signRsaSha256(serverKey, serverSecret) + val signature = MoonlightCrypto.signRsaSha256(identity.privateKey, serverSecret) return bytesToHex(serverSecret + signature) } @@ -116,7 +112,6 @@ class MoonlightPairingTest { val secret = hexToBytes(clientPairingSecretHex) val clientSecret = secret.copyOfRange(0, 16) val clientSignature = secret.copyOfRange(16, secret.size) - val clientCertPem = MoonlightPairingTestCerts.CLIENT_CERT val expected = MoonlightCrypto.sha256(serverChallenge, MoonlightCert.signatureOf(clientCertPem), clientSecret) if (!MoonlightCrypto.constantTimeEquals(expected, storedClientHash)) return false @@ -124,28 +119,29 @@ class MoonlightPairingTest { } } - private object MoonlightPairingTestCerts { - val CLIENT_CERT: String = resource("moonlight/client_cert.pem") - } - private companion object { - fun resource(path: String): String = - MoonlightPairingTest::class.java.classLoader!! - .getResourceAsStream(path)!! - .use { it.readBytes().toString(Charsets.US_ASCII) } - - fun privateKey(pem: String): PrivateKey { - val base64 = - pem - .lineSequence() - .filterNot { it.startsWith("-----") } - .joinToString("") - .trim() - val der = - java.util.Base64 - .getDecoder() - .decode(base64) - return KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(der)) + // Minted once for the whole class: JUnit builds a fresh test instance per + // method and RSA-2048 keygen is the slowest thing in this file. + val CLIENT = throwawayIdentity("dish-pairing-test-client") + val SERVER = throwawayIdentity("dish-pairing-test-server") + + /** + * A disposable self-signed identity that lives only for this test run. + * RSA, not the builder's default ECDSA: Moonlight pairing signs with + * SHA256withRSA, and the real client identity is RSA-2048 as well. + */ + fun throwawayIdentity(commonName: String): MoonlightIdentity { + val held = + HeldCertificate + .Builder() + .commonName(commonName) + .rsa2048() + .build() + return object : MoonlightIdentity { + override val certificatePem: String = held.certificatePem() + override val certificateSignature: ByteArray = held.certificate.signature + override val privateKey: PrivateKey = held.keyPair.private + } } } } diff --git a/app/src/test/resources/moonlight/client_cert.pem b/app/src/test/resources/moonlight/client_cert.pem deleted file mode 100644 index 23ae909b..00000000 --- a/app/src/test/resources/moonlight/client_cert.pem +++ /dev/null @@ -1,18 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIC9TCCAd2gAwIBAgIUVC3q8GHjBmNNxUxfMjUMH3Q5jsswDQYJKoZIhvcNAQEL -BQAwIzEhMB8GA1UEAwwYTlZJRElBIEdhbWVTdHJlYW0gQ2xpZW50MB4XDTI2MDgy -NTAwMzQyNFoXDTQ2MDgyMDAwMzQyNFowIzEhMB8GA1UEAwwYTlZJRElBIEdhbWVT -dHJlYW0gQ2xpZW50MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjTwk -skS496TGXigOnez9xjnHlmpQOzYt6a6dW4oip4kiql8cea6envGW+MszOeo97XNJ -eFI1kNdYy7buEPg4PyP++DmGAiylivzvGbB3IbOUvgXC9XyrSVLcpEPYBgkqHXz/ -u9xCxGqBkU8yK61ivbsyJh90DsUSJCws5OvGVnw1R+hR935wl8QF5u/+XggYiZY5 -6Mo5xHYC/zDyCX5y6wyBo7ko6GPrc6V1zLesqjPsgoiykK0DSVXLSa8SGTC6ox5y -AMQfrqQ+sFHD00N7y7c7XNFJYk0z+0mB26h5qde7xuhPRTY4cPhtWzPxfJekmm/1 -Bmpb75325jd73h3jyQIDAQABoyEwHzAdBgNVHQ4EFgQUlARyJo2Cg1QonxPf9Zyu -GgZ/CfwwDQYJKoZIhvcNAQELBQADggEBAHxViqqYdchYKYQ9eA/r32MIfkIVn++t -WTThyifaD/bQ3MdmMZBIzzf+I19S6QCYeqQk2kGgjoRKHM4gYAsluvX+Sn0jOZ30 -yKX75IzPcuYBGC4r5cpb1k8dez0wSbRdkocpCKq6aQIOP0A5+ZpfzTf54WJClEEU -qRTRTFgCIsKPluEeG3mQeikh6FWcbwWNclw6EC2bEQ4DlGisec7PjrK8MmZGiw31 -5q+O2TdnLnREL+LOgg5+KmlV0Sw/bplnFh3vq8X5z0joUhsIVw/GQcF4w/nI6pCq -hEeyJzRaIxat6iqr5Dzs6j6r89Z1QXr3ndRUdLHF4TEeiLYMf7MdvD0= ------END CERTIFICATE----- diff --git a/app/src/test/resources/moonlight/client_key.pem b/app/src/test/resources/moonlight/client_key.pem deleted file mode 100644 index b3db3620..00000000 --- a/app/src/test/resources/moonlight/client_key.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCNPCSyRLj3pMZe -KA6d7P3GOceWalA7Ni3prp1biiKniSKqXxx5rp6e8Zb4yzM56j3tc0l4UjWQ11jL -tu4Q+Dg/I/74OYYCLKWK/O8ZsHchs5S+BcL1fKtJUtykQ9gGCSodfP+73ELEaoGR -TzIrrWK9uzImH3QOxRIkLCzk68ZWfDVH6FH3fnCXxAXm7/5eCBiJljnoyjnEdgL/ -MPIJfnLrDIGjuSjoY+tzpXXMt6yqM+yCiLKQrQNJVctJrxIZMLqjHnIAxB+upD6w -UcPTQ3vLtztc0UliTTP7SYHbqHmp17vG6E9FNjhw+G1bM/F8l6Sab/UGalvvnfbm -N3veHePJAgMBAAECggEAF/2x7BhRZTu3uJHMXdY+i3gQJ8RaaZx78xiGwWB3H4dj -fJZYc3EOn8hBEXUO+BUKvPWa8tXgJID4I+6ohPhtMYiPTKIU2fS0kCYEBZScv/xN -1XOMGQA65mMteLfPj8LpxQWROVuiedPvu3u89X9n6PvN+nzYTZP7T2qzm5VTZShG -mQpA+OcACSYBy2ScbCfRza3HR2zTe6KnfqSoeTYoQABSqACk4lgmYsNaO+ifUxbH -t6c6K4SgnHl/aMd3+QpsZtFdku3FEVeO0ZDe8ZstCPnra37fI87ZEGCbbval2NgP -AVpPIrvBra5dGhTqoIUbKGGXdJTq/b7/uj04leXRyQKBgQC+ekaIZHrENVrfRkvT -FMJYIcqHfQDwmNVb2qMAp/7whcGinyBwtsYR1O9WTqglfDFQPRnhiMb2r22xuAky -13OaMsSXi4SiqiyIJJaCPAzpoN54c4AC9sWTizRmkbRuHmb4iJRCgtMlvgN9p2Fm -o0KyGM+07DX0kDm8AeqWorUNbQKBgQC90Xy3znV9/6ShczQyE0jPmw0h3Xx7cKJ0 -f9UfneSz9ZbJt2eLtXY8ERNBfFD38ilRE6MLPSlR/QzceXKezK5vC1MYWCCjwE4t -2EeoDHc1bv7hEn8CrpXr55ZbxG08qexqCJdwaQ0SLt5KYDiUxegU1tqbF62W8nAe -lB2NHwYCTQKBgHf016Cj7vDMTTtZsPzxEOeh+ENVhRcAmTWsvoT2R8a/5c99eVei -s6CdQlFPXfOlgATxRfBUTEEk/+cxaJGdQA93M3nhApnSpBLlP+gq21Ly3chrrM2x -DYK64zhJQKEtAlo44W31p/YX8Wjb1apm3OT+XSiqrdwkTEfLySous+kNAoGBAKcA -9LXS09RzYykY7sdP6DOfu0IcWDVSt9u/zIbwqBMc8/mtf1CP6uKWM1beRW6ghHFs -0XpF6WDVPseLoqjMdHwGfqlgf/cSbrYvH3xe21MLwPvNBioZ6JWRP9ylSWaiKfpw -bKzeAD4LNlBBsAZUyQfssJDbmELCMpr0vbs3nFXRAoGATeIPMDBeWjtNBLgFC6V8 -i7OMNHYDOP2nTbURcZp7LRo+wVrKEdcvHA9v52TH5v+6Yvf/i8HsvhCY+R3jcwBR -M0TJbR8H0CmxXjsjvtnRFetcl5FSlCmF/PYHlB2Vb/Mbj44rrFFwafPVqG3fdX/z -oPMLuAvOpG9QHDnb3EKWUP8= ------END PRIVATE KEY----- diff --git a/app/src/test/resources/moonlight/server_cert.pem b/app/src/test/resources/moonlight/server_cert.pem deleted file mode 100644 index 47f5e334..00000000 --- a/app/src/test/resources/moonlight/server_cert.pem +++ /dev/null @@ -1,18 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIC9TCCAd2gAwIBAgIUeubF1zzWwZrolYnodIZUbyRcMDYwDQYJKoZIhvcNAQEL -BQAwIzEhMB8GA1UEAwwYTlZJRElBIEdhbWVTdHJlYW0gQ2xpZW50MB4XDTI2MDgy -NTAwMzQyNFoXDTQ2MDgyMDAwMzQyNFowIzEhMB8GA1UEAwwYTlZJRElBIEdhbWVT -dHJlYW0gQ2xpZW50MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnTTL -OJUEjiw4827BDSqibT19eujmYPVjo4QhZyKjQWgB217tAg2L7kk7jKznWrCgG9DD -dGHdGxmNMiVHqoEnOu7aXLGLw0Qd4intZ+G51AHHTlX1AMt49IXvug4LMQaTELz1 -yRK3gZ1o2agzKvf0UNC+7rgLeZPgk4I6SZxf19YfoaJCORjTLboHzN6QS/UD/JRb -eoaQN89sd7iruqvUffMKlTh2ArYz9f+E5F2yBQBCScvXQEfDfim7uGvxga+J2q5b -LY8YbJxE4jYN/fPAO8oBcJ0Kjd+ktkrrOFCGlJwivrc/pePiZDMi3qCvkCYToetx -2RwCifRqcrmpskciyQIDAQABoyEwHzAdBgNVHQ4EFgQUvTERcI/GxwRL3XoIql9g -zus9+08wDQYJKoZIhvcNAQELBQADggEBAIscW1qiAwps1gje42IsBAn53kqrsGYi -pwrzPAulrqvcSdM324+zjnD/MJTbvuA+XHjZY/EXiRQyuiIDfqzCxYRZohX8YNOs -zOaGIRLLeJeIk+FkTolme5HcDVk6amWwfbiwdNVt6Y99dZ7RjJ01MgbOjRvUexkT -NHBSxgKBlyaFf+U46Rir+Ub7b1JPkWBzULUqFJtOmn7nXyJua2BHlLJy2GgoL6Mu -u/9B+GUMCKTsu+DlsvNGC/YVB8J2HrqUXw/TL7ZZECRGbEWK33CtF8srkfiYuvBT -PpGo1vCJPYzy7yVkIbyKqhXkmt1FOoPSWAvp5TBtub8vcZ7NMFa2EUw= ------END CERTIFICATE----- diff --git a/app/src/test/resources/moonlight/server_key.pem b/app/src/test/resources/moonlight/server_key.pem deleted file mode 100644 index 825c86c8..00000000 --- a/app/src/test/resources/moonlight/server_key.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCdNMs4lQSOLDjz -bsENKqJtPX166OZg9WOjhCFnIqNBaAHbXu0CDYvuSTuMrOdasKAb0MN0Yd0bGY0y -JUeqgSc67tpcsYvDRB3iKe1n4bnUAcdOVfUAy3j0he+6DgsxBpMQvPXJEreBnWjZ -qDMq9/RQ0L7uuAt5k+CTgjpJnF/X1h+hokI5GNMtugfM3pBL9QP8lFt6hpA3z2x3 -uKu6q9R98wqVOHYCtjP1/4TkXbIFAEJJy9dAR8N+Kbu4a/GBr4narlstjxhsnETi -Ng3988A7ygFwnQqN36S2Sus4UIaUnCK+tz+l4+JkMyLeoK+QJhOh63HZHAKJ9Gpy -uamyRyLJAgMBAAECggEAArJTTWhU+IFZWrz1cnM0RQ5vIWrET452A+ncztGWc6VD -6Y49H4dkSpVBVb4+MQfi6dyQtbrbWZR8dQs+/vWR2t4aVckKiNzEFyCOp3UE7yuL -+Skv94WwpfUe3GtS6tIzjJpz1tv2KjLX3RVDB24oZ93PdfXR5ecmTNweLCwSrXeX -u2GXJ1uREyfuxo6W1FOOxms0FRCRNO1Zskc9BouUf67r3AU58fWWjKH7h+R+zgdj -3pywCRg5qHDDq7/qXXb/gn5X9E73wuPFFBZ8secj0ocMhlF4krteudBDa5bUqkhm -FIKx96OMd9hSEqkCr1I4SEBNsOWLOxTuu2JHeYaPmQKBgQDbYTju9yBZ4yoqqJCA -sUjnHw8qIVihvxPOpaEuAIAiNz8ZhZsy06wPRqOoW3pWC7E6tBZa1azyDnibviKn -dPBrg2FUAjHtGyMwb0CjdDYZkLCPXNGnjfbo9J4HWDl9/ubmuZ/29FZGcYp7Z9hw -nVZvarW+RVajYbfR62m2CS3TbQKBgQC3crP5DYGA1NOqUt4L52cz/BQDeg0w9+dj -JwKCXnMdqgJE4HSyiVzX2osTtduSW0L7vh3wmvmIJbL/S8q5byeS5lhZpZf7aiyI -qKYQ1voZNqXTYj8YaWV1w/R+fuiOzWjwqfBFse3WFGj8aNWG8prmVpQHyiTCVyYO -OlbHG+bXTQKBgQDa0UGxkYuSPPS9Mf9YbfzSk3dTxYkbZHTERQ7czKEB/+sPcOWZ -r+pKHmJ1NjFzDByN+jzmA4WKtwZ0ChWUxB5ejuAQpFPaNZxG3mEx6GNh4qFJjgKM -xxyFxiCuIMDPvOXhMzusXpCDmRLQ/oaz5Svm3CBFlfHR61EnsFFzwfoUjQKBgQCw -AU8HHpwnnQpPmh4MUcJEsBALnehWGSNZkC3qIvBTf6+ZobiVKxF2z+krygmWjBTi -L2/OTwImS/VG19LywuC3ImWV7Ti6MQ31N8nM0lU2J6ZF/zcGFukPaiiDzQMXL6EF -diZe1+2WvhJUScjEJrPTVzHDn4BRLQgIEpT7h5uc6QKBgDy3kFuyDQUNwHH9HC/M -nv7kW/BFQd5OXvOvll21h+sZitvbDbiEXldL93HyL6dNvSdhxQ5H1LpcHLqDyf5z -EnH2cw+GHTHyzynTzCnK7OGbsxzicvq7nft35ED5LLT6OS5RqyLWOuRNMH/ETdBj -jat9vNFYuArJFOPxnJqNKisH ------END PRIVATE KEY----- From 2ae156d987839c905466465f740149b114889b8e Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Tue, 25 Aug 2026 17:27:11 -0400 Subject: [PATCH 04/20] fix: unblock Moonlight PIN pairing (plaintext phases, TLS-capable client key) Two runtime bugs kept pairing from ever reaching the host. Sunshine reported "Pairing Failed: Check if the PIN is typed correctly", which was a red herring: the PIN was fine, the request never arrived. Cleartext. res/xml/network_security_config.xml denies cleartext app-wide, so every /pair phase died inside the app with "Cleartext HTTP traffic to not permitted" and the host never opened a pairing session. Moonlight pairing phases 1-4 and the /serverinfo probe are plaintext on port 47989 by protocol (Wolf http-pairing.adoc): there is no shared secret to build a TLS session on yet. Rather than weaken a policy that exists to be read by Play's pre-launch checks, and which every URL-stack request really should obey, this adds MoonlightPlainHttpClient: a minimal HTTP/1.1 GET spoken by hand over a Socket, which the network security config does not gate, exactly as the encrypted UDP gamepad wire and the discovery beacons already are not. Only getHttp routes through it. Safe by protocol design: what crosses the wire is a salt, the public client cert, and AES challenges over them; the PIN is shown on the dish and typed into the host's own UI, never sent, and phase 5 onwards is pinned mutual TLS. The config's comment now says all this, so the next reader is not misled into thinking the app sends no cleartext at all. The client handles Content-Length, chunked and close-delimited bodies, keeps the gateway's timeouts and its unreachable reply on any transport failure, and never throws. getHttps loses its dead non-secure branch now that nothing else shares it. Client key. The identity key was generated PURPOSE_SIGN + PKCS1 + SHA-256, which signs the pairing secret but is not what Conscrypt asks for during TLS client auth: BoringSSL reduces both TLS 1.3 and RSA-PSS to a raw private-key operation, which CryptoUpcalls requests as a Cipher over RSA/ECB/NoPadding in ENCRYPT_MODE. On AndroidKeyStore that lands in AndroidKeyStoreRSACipherSpi.NoPadding, which overrides the keymaster purpose to SIGN and needs KM_PAD_NONE with KM_DIGEST_NONE. The key authorized neither, so mutual TLS died with INCOMPATIBLE_PADDING_MODE and then an RSA internal error. That only broke the isPaired() pre-check so far, but phase 5, /applist and /launch all ride the same channel. Generation now adds DIGEST_NONE and ENCRYPTION_PADDING_NONE (the KeyProperties spelling of KM_PAD_NONE; both padding setters feed one KM_TAG_PADDING list). The purpose stays SIGN-only, so this authorizes raw signing and not decryption, and the key stays non-exportable in the keystore. A keystore key's authorizations are fixed at generation, so a wider spec does not fix the key already stored under the alias. MoonlightIdentityDecision reads the stored key's own KeyInfo and replaces it when it cannot do the job. Pairing has never succeeded, so no host holds the old cert; any that somehow did would see an unknown client and ask to pair again. Test delta: 15 tests for the raw client (request formatting, Content-Length, chunked, close-delimited, case-insensitive headers, truncated body, truncated head, non-HTTP reply, refused connect, read timeout, bad URL) driven against a loopback ServerSocket, and 11 for the migration decision, which is factored to be pure so it runs without a device. --- .../moonlight/MoonlightHttpGateway.kt | 45 ++-- .../moonlight/MoonlightIdentityDecision.kt | 76 ++++++ .../moonlight/MoonlightIdentityProvider.kt | 72 ++++- .../moonlight/MoonlightPlainHttpClient.kt | 247 ++++++++++++++++++ .../main/res/xml/network_security_config.xml | 22 +- .../MoonlightIdentityDecisionTest.kt | 97 +++++++ .../moonlight/MoonlightPlainHttpClientTest.kt | 229 ++++++++++++++++ 7 files changed, 753 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightIdentityDecision.kt create mode 100644 app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClient.kt create mode 100644 app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightIdentityDecisionTest.kt create mode 100644 app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClientTest.kt diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt index d7903c3a..d860661f 100644 --- a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt @@ -31,6 +31,11 @@ import javax.net.ssl.X509TrustManager * host cert on first use, mirroring the satellite * [com.tinkernorth.dish.core.net.SatelliteHttpClient] TOFU verifier. * + * The two halves use different transports on purpose: HTTPS rides the platform + * URL stack, while the protocol-mandated plaintext half rides a raw socket via + * [MoonlightPlainHttpClient], since the app denies the URL stack cleartext + * app-wide. That class carries the full reasoning. + * * All methods BLOCK; call from Dispatchers.IO. This is runtime plumbing; the URL * building and XML parsing it drives are unit-tested separately. */ @@ -49,25 +54,30 @@ class MoonlightHttpGateway val ok: Boolean get() = status in 200..299 } - /** Plaintext GET (serverinfo / pair phases 1-4). */ - fun getHttp(url: String): Reply = request(url, secure = false, hostId = null) + private val plain = MoonlightPlainHttpClient(TIMEOUT_MS, TIMEOUT_MS) + + /** + * Plaintext GET (serverinfo / pair phases 1-4). + * + * Goes over a raw socket, not the URL stack: those phases are plaintext by + * protocol and res/xml/network_security_config.xml denies cleartext to the + * URL stack app-wide on purpose. [MoonlightPlainHttpClient] documents why + * the carve-out is scoped this way and why it is safe. + */ + fun getHttp(url: String): Reply = plain.get(url) /** Mutual-TLS GET (serverinfo / pair phase 5 / applist / launch / resume / cancel). */ fun getHttps( - url: String, - hostId: String, - ): Reply = request(url, secure = true, hostId = hostId) - - private fun request( urlString: String, - secure: Boolean, - hostId: String?, + hostId: String, ): Reply { val url = URL(urlString) - var connection: java.net.HttpURLConnection? = null + var connection: HttpsURLConnection? = null return try { connection = - openConnection(url, secure, hostId).apply { + (url.openConnection() as HttpsURLConnection).apply { + sslSocketFactory = mutualTlsFactory() + hostnameVerifier = tofuVerifier(hostId) requestMethod = "GET" connectTimeout = TIMEOUT_MS readTimeout = TIMEOUT_MS @@ -81,19 +91,6 @@ class MoonlightHttpGateway } } - private fun openConnection( - url: URL, - secure: Boolean, - hostId: String?, - ): java.net.HttpURLConnection { - val raw = url.openConnection() - if (!secure) return raw as java.net.HttpURLConnection - return (raw as HttpsURLConnection).apply { - sslSocketFactory = mutualTlsFactory() - hostnameVerifier = tofuVerifier(hostId!!) - } - } - private fun readReply(connection: java.net.HttpURLConnection): Reply { val status = connection.responseCode val stream = if (status in 200..299) connection.inputStream else connection.errorStream diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightIdentityDecision.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightIdentityDecision.kt new file mode 100644 index 00000000..50a01208 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightIdentityDecision.kt @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.source.connection.moonlight + +import android.security.keystore.KeyProperties + +/** What [MoonlightIdentityProvider] should do with what the keystore holds. */ +internal enum class MoonlightIdentityDecision { + /** Nothing stored yet: mint the identity. */ + GENERATE, + + /** Stored, but unusable: drop it and mint a replacement. */ + REGENERATE, + + /** Stored and fit for both the pairing signature and TLS client auth. */ + REUSE, +} + +/** + * The keystore-key decision, kept pure so it is unit-tested off-device. + * + * The interesting case is [MoonlightIdentityDecision.REGENERATE]. Shipped + * builds generated the client key with PURPOSE_SIGN + PKCS1 + SHA-256 only, + * which is enough to sign the pairing secret but not enough for Conscrypt to + * drive TLS client auth, so mutual TLS died with INCOMPATIBLE_PADDING_MODE. + * Broadening the KeyGenParameterSpec does NOT retro-authorize a key that + * already exists (a keystore key's authorization list is fixed at generation), + * so the legacy key has to be detected and replaced. + * + * Discarding it is harmless: pairing has never succeeded on any build, so no + * host holds the old certificate. A host that somehow did would simply see an + * unknown client and ask to be paired again. + */ +internal fun decideMoonlightIdentity( + aliasPresent: Boolean, + entryReadable: Boolean, + tlsClientAuthCapable: Boolean, +): MoonlightIdentityDecision = + when { + !aliasPresent -> MoonlightIdentityDecision.GENERATE + // A half-written entry (cert without key, or a key of the wrong type) + // is as unusable as a legacy one and takes the same path. + !entryReadable -> MoonlightIdentityDecision.REGENERATE + !tlsClientAuthCapable -> MoonlightIdentityDecision.REGENERATE + else -> MoonlightIdentityDecision.REUSE + } + +/** + * Whether a stored key's authorizations cover Conscrypt's TLS client-auth path, + * read off android.security.keystore.KeyInfo. + * + * Conscrypt (CryptoUpcalls.rsaSignDigestWithPrivateKey) asks a non-Conscrypt + * provider for `Cipher.getInstance("RSA/ECB/NoPadding").init(ENCRYPT_MODE, key)` + * when BoringSSL needs a raw private-key operation, which is what TLS 1.3 and + * RSA-PSS reduce to once BoringSSL has done the PSS encoding itself. On + * AndroidKeyStore that lands in AndroidKeyStoreRSACipherSpi.NoPadding, whose + * adjustConfigForEncryptingWithPrivateKey() overrides the keymaster purpose to + * SIGN and asks the key for KM_PAD_NONE with KM_DIGEST_NONE. KM_PAD_NONE is + * what KeyProperties spells ENCRYPTION_PADDING_NONE (encryption and signature + * paddings are merged into one KM_TAG_PADDING list at generation), so the three + * checks below are exactly that operation's authorization requirements. + * + * The TLS 1.2 route asks for `RSA/ECB/PKCS1Padding` instead, which the same SPI + * maps to KM_PAD_RSA_PKCS1_1_5_SIGN + KM_DIGEST_NONE: covered by the same + * DIGEST_NONE check plus the PKCS1 signature padding the pairing signature + * already needs. + */ +internal fun supportsTlsClientAuth( + purposes: Int, + digests: Array, + encryptionPaddings: Array, +): Boolean = + (purposes and KeyProperties.PURPOSE_SIGN) != 0 && + digests.any { it.equals(KeyProperties.DIGEST_NONE, ignoreCase = true) } && + encryptionPaddings.any { it.equals(KeyProperties.ENCRYPTION_PADDING_NONE, ignoreCase = true) } diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightIdentityProvider.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightIdentityProvider.kt index 2dccf4e2..662e30ff 100644 --- a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightIdentityProvider.kt +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightIdentityProvider.kt @@ -4,9 +4,12 @@ package com.tinkernorth.dish.source.connection.moonlight import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyInfo import android.security.keystore.KeyProperties +import android.util.Log import com.tinkernorth.dish.core.net.moonlight.MoonlightIdentity import java.math.BigInteger +import java.security.KeyFactory import java.security.KeyPairGenerator import java.security.KeyStore import java.security.PrivateKey @@ -22,10 +25,17 @@ import javax.security.auth.x500.X500Principal * reused for every host). AndroidKeyStore auto-generates the self-signed * certificate for us, keeping the platform-APIs-only, BouncyCastle-free rule and * keeping the private key non-exportable. Pairing signs with it via - * [com.tinkernorth.dish.core.net.moonlight.MoonlightCrypto.signRsaSha256]. + * [com.tinkernorth.dish.core.net.moonlight.MoonlightCrypto.signRsaSha256], and + * the same key authenticates the dish on every mutual-TLS call afterwards, so + * it is generated with the authorizations both of those need. * - * The pairing crypto is unit-tested against file-backed identities; this - * keystore path is the runtime supplier and is exercised only on device. + * A key stored by an earlier build carries only the narrower pairing-signature + * authorizations and cannot do the second job; [decideMoonlightIdentity] spots + * that and this class replaces it. + * + * The pairing crypto is unit-tested against throwaway generated identities and + * the migration decision is unit-tested on its own; this keystore path is the + * runtime supplier and is exercised only on device. */ @Singleton class MoonlightIdentityProvider @@ -45,12 +55,52 @@ class MoonlightIdentityProvider private fun loadOrCreate(): LoadedIdentity { val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } - if (!keyStore.containsAlias(ALIAS)) generateKeyPair() - val certificate = keyStore.getCertificate(ALIAS) as X509Certificate - val privateKey = keyStore.getKey(ALIAS, null) as PrivateKey + val stored = readEntry(keyStore) + val decision = + decideMoonlightIdentity( + aliasPresent = keyStore.containsAlias(ALIAS), + entryReadable = stored != null, + tlsClientAuthCapable = stored != null && isTlsClientAuthCapable(stored.privateKey), + ) + if (decision == MoonlightIdentityDecision.REUSE) return checkNotNull(stored) + if (decision == MoonlightIdentityDecision.REGENERATE) { + Log.i(TAG, "replacing the stored Moonlight identity: its key cannot do TLS client auth") + keyStore.deleteEntry(ALIAS) + } + generateKeyPair() + return checkNotNull(readEntry(keyStore)) { "keystore did not return the identity it just generated" } + } + + /** The stored cert+key, or null when the alias holds nothing usable. */ + private fun readEntry(keyStore: KeyStore): LoadedIdentity? { + val certificate = keyStore.getCertificate(ALIAS) as? X509Certificate ?: return null + val privateKey = keyStore.getKey(ALIAS, null) as? PrivateKey ?: return null return LoadedIdentity(certificate, toPem(certificate), privateKey) } + /** + * Reads [key]'s own authorization list and asks [supportsTlsClientAuth] + * about it. A key whose KeyInfo cannot be read at all (not a keystore key, + * or a provider that will not describe it) counts as incapable, which + * routes it to regeneration rather than to another failed handshake. + */ + private fun isTlsClientAuthCapable(key: PrivateKey): Boolean = + runCatching { + val info = + KeyFactory + .getInstance(key.algorithm, ANDROID_KEYSTORE) + .getKeySpec(key, KeyInfo::class.java) + supportsTlsClientAuth(info.purposes, info.digests, info.encryptionPaddings) + }.getOrDefault(false) + + /** + * Mints the client identity. The authorizations are wider than the pairing + * signature alone needs because the same key also has to satisfy Conscrypt + * during TLS client auth; [supportsTlsClientAuth] documents which keymaster + * operation each one unlocks. The key itself stays non-exportable in + * AndroidKeyStore, so widening what it may be asked to do does not widen + * who can extract it. + */ private fun generateKeyPair() { val notBefore = Calendar.getInstance() val notAfter = (notBefore.clone() as Calendar).apply { add(Calendar.YEAR, CERT_VALIDITY_YEARS) } @@ -58,8 +108,15 @@ class MoonlightIdentityProvider KeyGenParameterSpec .Builder(ALIAS, KeyProperties.PURPOSE_SIGN) .setKeySize(RSA_KEY_SIZE) - .setDigests(KeyProperties.DIGEST_SHA256) + // DIGEST_NONE: raw-RSA TLS signing hands over an already-digested + // (and, for PSS, already-encoded) block. SHA-256: the pairing signature. + .setDigests(KeyProperties.DIGEST_NONE, KeyProperties.DIGEST_SHA256) .setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) + // Spells KM_PAD_NONE, the padding a raw private-key operation runs + // under. Both padding setters feed one KM_TAG_PADDING list, and the + // purpose stays SIGN-only, so this authorizes raw signing, not + // decryption. + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setCertificateSubject(X500Principal(CERT_SUBJECT)) .setCertificateSerialNumber(BigInteger.ONE) .setCertificateNotBefore(notBefore.time) @@ -83,6 +140,7 @@ class MoonlightIdentityProvider } private companion object { + const val TAG = "MoonlightIdentity" const val ANDROID_KEYSTORE = "AndroidKeyStore" const val ALIAS = "dish-moonlight-client" const val RSA_KEY_SIZE = 2048 diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClient.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClient.kt new file mode 100644 index 00000000..97a3f492 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClient.kt @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.source.connection.moonlight + +import android.util.Log +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.net.InetSocketAddress +import java.net.Socket +import java.net.URI + +/** + * A minimal blocking HTTP/1.1 GET spoken over a raw [Socket], used ONLY for + * Moonlight's plaintext pairing phases on port 47989. + * + * WHY NOT HttpURLConnection. res/xml/network_security_config.xml denies + * cleartext app-wide and that denial is deliberate: it is the signal Play's + * pre-launch security checks and Android's PlatformVal validator read, and + * every URL-stack request the app makes really must be TLS. Relaxing it (or + * carving out a per-domain exception, which cannot be done anyway for a + * user-typed LAN address) would trade a real app-wide guarantee for one + * protocol's needs. Raw sockets are not gated by that config, the same way the + * encrypted UDP gamepad wire and the LAN discovery beacons already are not, so + * the exception stays scoped to exactly the four requests that need it. + * + * WHY CLEARTEXT IS SAFE HERE. Pairing phases 1-4 are plaintext by protocol + * (Wolf http-pairing.adoc): NVIDIA's GameStream protocol fixes them on the + * plaintext port because there is no shared secret to build a TLS session on + * yet. What crosses the wire is a random salt, the public client certificate, + * AES challenges and signatures over them. The PIN itself is never sent: it is + * shown on the dish and typed into the host's own UI, and both ends only prove + * knowledge of it through the challenge exchange. Everything from phase 5 on + * (pairchallenge, /applist, /launch) runs over the pinned mutual TLS channel in + * [MoonlightHttpGateway]. A LAN eavesdropper learns nothing it can replay, and + * an active attacker cannot complete the exchange without the PIN. + * + * Blocking; call from Dispatchers.IO. Never throws: transport failures come + * back as `Reply(0, "")`, matching the gateway's HTTPS path. + */ +internal class MoonlightPlainHttpClient( + private val connectTimeoutMs: Int, + private val readTimeoutMs: Int, + private val openSocket: () -> Socket = { Socket() }, +) { + /** GETs [urlString], or `Reply(0, "")` if the host never answered. */ + fun get(urlString: String): MoonlightHttpGateway.Reply { + val uri = runCatching { URI(urlString) }.getOrNull() ?: return UNREACHABLE + val host = uri.host ?: return UNREACHABLE + val port = if (uri.port > 0) uri.port else DEFAULT_HTTP_PORT + if (port > MAX_PORT) return UNREACHABLE + return try { + openSocket().use { socket -> + socket.connect(InetSocketAddress(host, port), connectTimeoutMs) + socket.soTimeout = readTimeoutMs + socket.getOutputStream().apply { + write(head(uri, host, port).toByteArray(Charsets.ISO_8859_1)) + flush() + } + readReply(socket.getInputStream().buffered()) + } + } catch (e: IOException) { + // Mirrors the gateway's HTTPS catch: connect refused, DNS failure and + // both timeouts (SocketTimeoutException is an IOException) land here. + Log.w(TAG, "plain GET failed for ${uri.path}: ${e.message}") + UNREACHABLE + } + } + + /** The request line and headers, CRLF-terminated per RFC 9112. */ + private fun head( + uri: URI, + host: String, + port: Int, + ): String { + val path = uri.rawPath.orEmpty().ifEmpty { "/" } + val target = uri.rawQuery?.takeIf { it.isNotEmpty() }?.let { "$path?$it" } ?: path + // Host carries the port whenever it is not the scheme default. URI.getHost + // already returns an IPv6 literal in its bracketed form, which is what the + // header wants too. + val authority = if (port == DEFAULT_HTTP_PORT) host else "$host:$port" + return "GET $target HTTP/1.1\r\n" + + "Host: $authority\r\n" + + "User-Agent: $USER_AGENT\r\n" + + "Accept: */*\r\n" + + // Ask the host to close once it has answered: it keeps the socket from + // idling in a keep-alive pool and makes the read-to-EOF body path below + // well defined for a response that carries no Content-Length. + "Connection: close\r\n" + + "\r\n" + } + + private fun readReply(input: InputStream): MoonlightHttpGateway.Reply { + val lines = readHead(input) ?: return UNREACHABLE + val status = parseStatus(lines.firstOrNull()) ?: return UNREACHABLE + return MoonlightHttpGateway.Reply(status, readBody(input, parseHeaders(lines))) + } + + /** + * Reads up to and including the blank line that ends the head, and splits it. + * Returns null if the peer hung up first or the head never ended, both of + * which mean there is no reply to report. + */ + private fun readHead(input: InputStream): List? { + val raw = ByteArrayOutputStream() + var newlines = 0 + while (raw.size() < MAX_HEAD_BYTES) { + val b = input.read() + if (b < 0) return null + raw.write(b) + when (b) { + LF -> if (++newlines == 2) return splitHead(raw.toByteArray()) + CR -> Unit // half of a CRLF; does not reset the run + else -> newlines = 0 + } + } + return null + } + + // Tolerates bare-LF line ends as well as CRLF. Header text is ISO-8859-1 by + // spec; only the body is decoded as UTF-8. + private fun splitHead(raw: ByteArray): List = + raw + .toString(Charsets.ISO_8859_1) + .split("\r\n", "\n") + .filter { it.isNotEmpty() } + + /** "HTTP/1.1 200 OK" -> 200; anything that is not a status line -> null. */ + private fun parseStatus(line: String?): Int? { + if (line == null || !line.startsWith("HTTP/")) return null + return line + .split(' ') + .getOrNull(1) + ?.toIntOrNull() + ?.takeIf { it in MIN_STATUS..MAX_STATUS } + } + + private fun parseHeaders(lines: List): Map = + lines + .drop(1) + .mapNotNull { line -> + val colon = line.indexOf(':') + if (colon <= 0) { + null + } else { + line.substring(0, colon).trim().lowercase() to line.substring(colon + 1).trim() + } + }.toMap() + + /** + * Body framing, in the precedence RFC 9112 gives it: chunked wins over + * Content-Length, and with neither the body runs to the close we asked for. + * A body cut short comes back as the bytes that did arrive, under the real + * status, exactly as HttpURLConnection would hand it over; the XML parse + * above the gateway then rejects it. + */ + private fun readBody( + input: InputStream, + headers: Map, + ): String { + val chunked = headers[TRANSFER_ENCODING]?.contains(CHUNKED, ignoreCase = true) == true + val declared = headers[CONTENT_LENGTH]?.toIntOrNull() + val bytes = + when { + chunked -> readChunked(input) + declared != null -> readExactly(input, declared.coerceIn(0, MAX_BODY_BYTES)) + else -> readToEnd(input) + } + return bytes.toString(Charsets.UTF_8) + } + + /** Sunshine sends Content-Length today; this keeps a chunked host working. */ + private fun readChunked(input: InputStream): ByteArray { + val out = ByteArrayOutputStream() + while (out.size() < MAX_BODY_BYTES) { + // A chunk header is the hex size, optionally followed by ";extension". + val size = + readLine(input) + ?.substringBefore(';') + ?.trim() + ?.toIntOrNull(HEX) + ?: break + if (size <= 0) break // the terminating 0-chunk, or a size we cannot read + out.write(readExactly(input, size.coerceAtMost(MAX_BODY_BYTES))) + readLine(input) // the CRLF that closes the chunk + } + return out.toByteArray() + } + + private fun readLine(input: InputStream): String? { + val raw = ByteArrayOutputStream() + while (raw.size() < MAX_LINE_BYTES) { + val b = input.read() + if (b < 0) break + if (b == LF) return raw.toString(Charsets.ISO_8859_1.name()).trimEnd('\r') + raw.write(b) + } + return null + } + + private fun readExactly( + input: InputStream, + count: Int, + ): ByteArray { + val out = ByteArray(count) + var filled = 0 + while (filled < count) { + val n = input.read(out, filled, count - filled) + if (n < 0) return out.copyOf(filled) // truncated; hand back what arrived + filled += n + } + return out + } + + private fun readToEnd(input: InputStream): ByteArray { + val out = ByteArrayOutputStream() + val chunk = ByteArray(COPY_BUFFER) + while (out.size() < MAX_BODY_BYTES) { + val n = input.read(chunk) + if (n < 0) break + out.write(chunk, 0, n) + } + return out.toByteArray() + } + + private companion object { + const val TAG = "MoonlightPlainHttp" + const val USER_AGENT = "Dish/1.0" + const val DEFAULT_HTTP_PORT = 80 + const val MAX_PORT = 65535 + const val CR = '\r'.code + const val LF = '\n'.code + const val HEX = 16 + const val MIN_STATUS = 100 + const val MAX_STATUS = 599 + const val MAX_HEAD_BYTES = 16 * 1024 + const val MAX_LINE_BYTES = 1024 + const val MAX_BODY_BYTES = 1024 * 1024 + const val COPY_BUFFER = 8 * 1024 + const val CONTENT_LENGTH = "content-length" + const val TRANSFER_ENCODING = "transfer-encoding" + const val CHUNKED = "chunked" + + val UNREACHABLE = MoonlightHttpGateway.Reply(0, "") + } +} diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 6f6820e7..cc722d36 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,13 +1,27 @@ diff --git a/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightIdentityDecisionTest.kt b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightIdentityDecisionTest.kt new file mode 100644 index 00000000..98b0ef61 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightIdentityDecisionTest.kt @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.source.connection.moonlight + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The keystore-key migration decision, off-device. The KeyProperties values it + * compares are compile-time constants, so nothing here needs a real keystore. + */ +class MoonlightIdentityDecisionTest { + // What an earlier build generated: enough to sign the pairing secret, not + // enough for Conscrypt's raw-RSA TLS client auth. + private val legacyDigests = arrayOf("SHA-256") + private val legacyEncryptionPaddings = emptyArray() + + // What this build generates. + private val currentDigests = arrayOf("NONE", "SHA-256") + private val currentEncryptionPaddings = arrayOf("NoPadding") + + private val purposeSign = 1 shl 2 + + @Test + fun `no stored alias generates a fresh identity`() { + assertEquals( + MoonlightIdentityDecision.GENERATE, + decideMoonlightIdentity(aliasPresent = false, entryReadable = false, tlsClientAuthCapable = false), + ) + } + + @Test + fun `an alias whose entry will not read is replaced`() { + assertEquals( + MoonlightIdentityDecision.REGENERATE, + decideMoonlightIdentity(aliasPresent = true, entryReadable = false, tlsClientAuthCapable = false), + ) + } + + @Test + fun `a readable legacy key is replaced rather than reused`() { + assertEquals( + MoonlightIdentityDecision.REGENERATE, + decideMoonlightIdentity(aliasPresent = true, entryReadable = true, tlsClientAuthCapable = false), + ) + } + + @Test + fun `a key that can do TLS client auth is kept`() { + assertEquals( + MoonlightIdentityDecision.REUSE, + decideMoonlightIdentity(aliasPresent = true, entryReadable = true, tlsClientAuthCapable = true), + ) + } + + @Test + fun `the key this build generates is TLS-client-auth capable`() { + assertTrue(supportsTlsClientAuth(purposeSign, currentDigests, currentEncryptionPaddings)) + } + + @Test + fun `the key earlier builds generated is not`() { + assertFalse(supportsTlsClientAuth(purposeSign, legacyDigests, legacyEncryptionPaddings)) + } + + @Test + fun `a key missing DIGEST_NONE cannot take an already-digested block`() { + assertFalse(supportsTlsClientAuth(purposeSign, arrayOf("SHA-256"), currentEncryptionPaddings)) + } + + @Test + fun `a key missing the NONE padding cannot run the raw private-key operation`() { + assertFalse(supportsTlsClientAuth(purposeSign, currentDigests, arrayOf("PKCS1Padding"))) + } + + @Test + fun `a key that may not sign cannot authenticate a handshake`() { + val purposeVerifyOnly = 1 shl 3 + assertFalse(supportsTlsClientAuth(purposeVerifyOnly, currentDigests, currentEncryptionPaddings)) + } + + @Test + fun `the keymaster spellings are matched case-insensitively`() { + assertTrue(supportsTlsClientAuth(purposeSign, arrayOf("none", "sha-256"), arrayOf("nopadding"))) + } + + @Test + fun `extra purposes alongside sign are fine`() { + val purposeDecrypt = 1 shl 1 + assertTrue( + supportsTlsClientAuth(purposeSign or purposeDecrypt, currentDigests, currentEncryptionPaddings), + ) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClientTest.kt b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClientTest.kt new file mode 100644 index 00000000..554a4345 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClientTest.kt @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.source.connection.moonlight + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.OutputStream +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Drives [MoonlightPlainHttpClient] against a loopback [ServerSocket] so the + * request bytes it puts on the wire and the responses it accepts are both real. + * The fixture answers one request and records what it was asked. + */ +class MoonlightPlainHttpClientTest { + private lateinit var server: ServerSocket + private var serverThread: Thread? = null + + @Volatile private var requestHead: String = "" + private val served = CountDownLatch(1) + + @After + fun tearDown() { + serverThread?.interrupt() + if (::server.isInitialized) server.close() + } + + /** Starts a one-shot host that replies with [respond] and records the request. */ + private fun host(respond: (OutputStream) -> Unit): String { + server = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")) + serverThread = + Thread { + runCatching { + server.accept().use { socket -> + requestHead = readHead(socket) + respond(socket.getOutputStream()) + socket.getOutputStream().flush() + } + } + served.countDown() + }.apply { + isDaemon = true + start() + } + return "http://127.0.0.1:${server.localPort}/pair?devicename=roth&phrase=getservercert" + } + + // Reads exactly the request head, so the fixture never blocks on a body. + private fun readHead(socket: Socket): String { + val input = socket.getInputStream() + val head = StringBuilder() + while (!head.endsWith("\r\n\r\n")) { + val b = input.read() + if (b < 0) break + head.append(b.toChar()) + } + return head.toString() + } + + private fun client() = MoonlightPlainHttpClient(TIMEOUT, TIMEOUT) + + private fun OutputStream.send(text: String) = write(text.toByteArray(Charsets.ISO_8859_1)) + + @Test + fun `formats a GET the host can route, with the port in the Host header`() { + val url = host { it.send("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi") } + client().get(url) + served.await(TIMEOUT.toLong(), TimeUnit.MILLISECONDS) + + val lines = requestHead.split("\r\n") + assertEquals("GET /pair?devicename=roth&phrase=getservercert HTTP/1.1", lines[0]) + assertTrue(requestHead, lines.contains("Host: 127.0.0.1:${server.localPort}")) + assertTrue(requestHead, lines.contains("Connection: close")) + assertTrue("head must end with a blank line", requestHead.endsWith("\r\n\r\n")) + } + + @Test + fun `reads a Content-Length body`() { + val body = "abcd" + val url = host { it.send("HTTP/1.1 200 OK\r\nContent-Type: text/xml\r\nContent-Length: ${body.length}\r\n\r\n$body") } + + val reply = client().get(url) + + assertEquals(200, reply.status) + assertEquals(body, reply.body) + assertTrue(reply.ok) + } + + @Test + fun `a body longer than Content-Length is cut at the declared length`() { + val url = host { it.send("HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nkeepDROP") } + + assertEquals("keep", client().get(url).body) + } + + @Test + fun `reads a body delimited by the connection close`() { + val url = host { it.send("HTTP/1.1 200 OK\r\nContent-Type: text/xml\r\n\r\nno-length-here") } + + val reply = client().get(url) + + assertEquals(200, reply.status) + assertEquals("no-length-here", reply.body) + } + + @Test + fun `reads a chunked body`() { + val url = + host { + it.send( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" + + "5\r\nhello\r\n" + + "6\r\n world\r\n" + + "0\r\n\r\n", + ) + } + + val reply = client().get(url) + + assertEquals(200, reply.status) + assertEquals("hello world", reply.body) + } + + @Test + fun `chunked wins over a Content-Length the host also sent`() { + val url = + host { + it.send("HTTP/1.1 200 OK\r\nContent-Length: 99\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nok\r\n0\r\n\r\n") + } + + assertEquals("ok", client().get(url).body) + } + + @Test + fun `header lookup is case-insensitive`() { + val url = host { it.send("HTTP/1.1 200 OK\r\ncOnTeNt-LeNgTh: 3\r\n\r\nyes") } + + assertEquals("yes", client().get(url).body) + } + + @Test + fun `surfaces a non-2xx status with its body`() { + val url = host { it.send("HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nno-such-x") } + + val reply = client().get(url) + + assertEquals(404, reply.status) + assertEquals("no-such-x", reply.body) + assertTrue(!reply.ok) + } + + @Test + fun `a body cut short keeps the real status and returns what arrived`() { + val url = host { it.send("HTTP/1.1 200 OK\r\nContent-Length: 64\r\n\r\nonly-this-much") } + + val reply = client().get(url) + + assertEquals(200, reply.status) + assertEquals("only-this-much", reply.body) + } + + @Test + fun `a head cut off mid-line is unreachable, not a crash`() { + val url = host { it.send("HTTP/1.1 200 OK\r\nContent-Len") } + + val reply = client().get(url) + + assertEquals(0, reply.status) + assertEquals("", reply.body) + assertTrue(reply.unreachable) + } + + @Test + fun `a reply that is not HTTP at all is unreachable`() { + val url = host { it.send("GARBAGE\r\n\r\nbody") } + + assertEquals(0, client().get(url).status) + } + + @Test + fun `a host that closes without answering is unreachable`() { + val url = host { /* accept, then drop */ } + + assertEquals(0, client().get(url).status) + } + + @Test + fun `a host that never answers times out into an unreachable reply`() { + // Accept the connection and hold it: the read timeout must fire, and it + // must surface as Reply(0, "") rather than a SocketTimeoutException. + val url = host { Thread.sleep(SLOW_MS) } + val client = MoonlightPlainHttpClient(TIMEOUT, READ_TIMEOUT_SHORT) + + val started = System.nanoTime() + val reply = client.get(url) + val elapsedMs = (System.nanoTime() - started) / 1_000_000 + + assertEquals(0, reply.status) + assertTrue("should give up near the read timeout, took ${elapsedMs}ms", elapsedMs < SLOW_MS) + } + + @Test + fun `a refused connection is unreachable`() { + // Bind then close, so the port is almost certainly free and refusing. + val dead = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")) + val port = dead.localPort + dead.close() + + assertEquals(0, client().get("http://127.0.0.1:$port/serverinfo?uniqueid=x").status) + } + + @Test + fun `an unparseable url is unreachable rather than an exception`() { + assertEquals(0, client().get("http://[not a url/pair").status) + } + + private companion object { + const val TIMEOUT = 4_000 + const val READ_TIMEOUT_SHORT = 300 + const val SLOW_MS = 3_000L + } +} From 20eccdf1931787626200c944d50c41771c275b00 Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Tue, 25 Aug 2026 17:53:37 -0400 Subject: [PATCH 05/20] fix: give pairing phase 1 a human-length read timeout Found on device once the cleartext and client-key fixes let the request actually reach Sunshine: W/MoonlightPlainHttp: plain GET failed for /pair: Read timed out The host does not answer pairing phase 1 until a human has typed the displayed PIN into its own web UI. Sunshine parks the phase-1 response and only completes it on PIN entry, so the request is deliberately long-lived. The gateway applied its ordinary 5s probe timeout to it, which tore the socket down about the time the user would still be reaching for a browser, and the host dropped its half-open pairing session with it. That is the same user-visible dead end as before: a PIN on screen that the host will never accept. Phase 1 now gets PAIR_PIN_TIMEOUT_MS instead. The plaintext client takes a per-call read timeout so this stays scoped to the one request that waits on a person; /serverinfo probes and pairing phases 2-4, which answer immediately, keep the short timeout and still fail fast on an unreachable host. The connect timeout is untouched either way. Also logs the plaintext client's non-exception give-ups (unusable URL, head never finished, reply that is not HTTP). Those previously returned an unreachable reply silently, which is what made this bug take a rebuild to find: the failure was invisible in logcat. Verified against a live Sunshine host: the phase-1 request now stays open past the old cutoff and the host holds an established connection while the PIN is displayed. --- .../moonlight/MoonlightConnectionManager.kt | 9 +++- .../moonlight/MoonlightHttpGateway.kt | 24 +++++++++-- .../moonlight/MoonlightPlainHttpClient.kt | 43 ++++++++++++++----- .../moonlight/MoonlightPlainHttpClientTest.kt | 18 ++++++++ 4 files changed, 77 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt index a8497d7d..19481a56 100644 --- a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt @@ -218,8 +218,13 @@ class MoonlightConnectionManager _events.emit(MoonlightConnectionEvent.PairingPinReady(host, pin)) val pairing = MoonlightPairing(identity, pin) return runCatching { - // Phase 1 (HTTP): the host prompts for the PIN and blocks until entered. - val p1 = gateway.getHttp(MoonlightUrls.pairHttp(host.address, host.httpPort, pairing.phase1Params(deviceId))) + // Phase 1 (HTTP): the host prompts for the PIN and blocks until + // entered, so this one waits on a human rather than on the network. + val p1 = + gateway.getHttp( + MoonlightUrls.pairHttp(host.address, host.httpPort, pairing.phase1Params(deviceId)), + MoonlightHttpGateway.PAIR_PIN_TIMEOUT_MS, + ) val cert = MoonlightXml.parsePairReply(p1.body)?.plainCert ?: return false pairing.onPhase1( String( diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt index d860661f..289df262 100644 --- a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt @@ -63,8 +63,14 @@ class MoonlightHttpGateway * protocol and res/xml/network_security_config.xml denies cleartext to the * URL stack app-wide on purpose. [MoonlightPlainHttpClient] documents why * the carve-out is scoped this way and why it is safe. + * + * [readTimeoutMs] is the caller's to raise for a request the host holds + * open on purpose; see [PAIR_PIN_TIMEOUT_MS]. */ - fun getHttp(url: String): Reply = plain.get(url) + fun getHttp( + url: String, + readTimeoutMs: Int = TIMEOUT_MS, + ): Reply = plain.get(url, readTimeoutMs) /** Mutual-TLS GET (serverinfo / pair phase 5 / applist / launch / resume / cancel). */ fun getHttps( @@ -158,8 +164,18 @@ class MoonlightHttpGateway override fun getAcceptedIssuers(): Array = emptyArray() } - private companion object { - const val TAG = "MoonlightHttpGateway" - const val TIMEOUT_MS = 5_000 + companion object { + private const val TAG = "MoonlightHttpGateway" + private const val TIMEOUT_MS = 5_000 + + /** + * Read timeout for pairing phase 1. The host does not answer that one + * until a human has typed the displayed PIN into its own web UI + * (Sunshine parks the response and only completes it on PIN entry), so + * the ordinary 5s probe timeout tears the request down before anybody + * could reach a browser, and the host drops its half-open pairing + * session with it. This is the human's window, not the network's. + */ + const val PAIR_PIN_TIMEOUT_MS = 120_000 } } diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClient.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClient.kt index 97a3f492..ad3af3be 100644 --- a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClient.kt +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClient.kt @@ -41,17 +41,29 @@ import java.net.URI */ internal class MoonlightPlainHttpClient( private val connectTimeoutMs: Int, - private val readTimeoutMs: Int, - private val openSocket: () -> Socket = { Socket() }, + private val defaultReadTimeoutMs: Int, ) { - /** GETs [urlString], or `Reply(0, "")` if the host never answered. */ - fun get(urlString: String): MoonlightHttpGateway.Reply { - val uri = runCatching { URI(urlString) }.getOrNull() ?: return UNREACHABLE - val host = uri.host ?: return UNREACHABLE - val port = if (uri.port > 0) uri.port else DEFAULT_HTTP_PORT - if (port > MAX_PORT) return UNREACHABLE + /** + * GETs [urlString], or `Reply(0, "")` if the host never answered. + * + * [readTimeoutMs] overrides the default for requests the host deliberately + * holds open, such as the pairing phase that blocks on a human typing the + * PIN. The connect timeout is unaffected: an unreachable host still fails + * fast. + */ + fun get( + urlString: String, + readTimeoutMs: Int = defaultReadTimeoutMs, + ): MoonlightHttpGateway.Reply { + val uri = runCatching { URI(urlString) }.getOrNull() + val host = uri?.host + val port = if (uri != null && uri.port > 0) uri.port else DEFAULT_HTTP_PORT + if (uri == null || host == null || port > MAX_PORT) { + Log.w(TAG, "not a usable http url: $urlString") + return UNREACHABLE + } return try { - openSocket().use { socket -> + Socket().use { socket -> socket.connect(InetSocketAddress(host, port), connectTimeoutMs) socket.soTimeout = readTimeoutMs socket.getOutputStream().apply { @@ -92,8 +104,16 @@ internal class MoonlightPlainHttpClient( } private fun readReply(input: InputStream): MoonlightHttpGateway.Reply { - val lines = readHead(input) ?: return UNREACHABLE - val status = parseStatus(lines.firstOrNull()) ?: return UNREACHABLE + val lines = readHead(input) + if (lines == null) { + Log.w(TAG, "host closed before finishing a response head") + return UNREACHABLE + } + val status = parseStatus(lines.firstOrNull()) + if (status == null) { + Log.w(TAG, "host answered but not with HTTP: ${lines.firstOrNull()?.take(STATUS_LOG_LEN)}") + return UNREACHABLE + } return MoonlightHttpGateway.Reply(status, readBody(input, parseHeaders(lines))) } @@ -234,6 +254,7 @@ internal class MoonlightPlainHttpClient( const val HEX = 16 const val MIN_STATUS = 100 const val MAX_STATUS = 599 + const val STATUS_LOG_LEN = 64 const val MAX_HEAD_BYTES = 16 * 1024 const val MAX_LINE_BYTES = 1024 const val MAX_BODY_BYTES = 1024 * 1024 diff --git a/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClientTest.kt b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClientTest.kt index 554a4345..b437b0e0 100644 --- a/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClientTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClientTest.kt @@ -206,6 +206,23 @@ class MoonlightPlainHttpClientTest { assertTrue("should give up near the read timeout, took ${elapsedMs}ms", elapsedMs < SLOW_MS) } + @Test + fun `a per-call read timeout outlasts a host that answers slowly`() { + // Pairing phase 1 is held open until a human types the PIN, so the caller + // raises the read timeout for it. The default would give up here. + val url = + host { + Thread.sleep(HELD_MS) + it.send("HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\npin!") + } + val client = MoonlightPlainHttpClient(TIMEOUT, READ_TIMEOUT_SHORT) + + val reply = client.get(url, readTimeoutMs = TIMEOUT) + + assertEquals(200, reply.status) + assertEquals("pin!", reply.body) + } + @Test fun `a refused connection is unreachable`() { // Bind then close, so the port is almost certainly free and refusing. @@ -225,5 +242,6 @@ class MoonlightPlainHttpClientTest { const val TIMEOUT = 4_000 const val READ_TIMEOUT_SHORT = 300 const val SLOW_MS = 3_000L + const val HELD_MS = 900L } } From e2b2919a176158bfe1f1c0c417db66a0746745a6 Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Tue, 25 Aug 2026 18:06:00 -0400 Subject: [PATCH 06/20] fix: stop the XML hardening from disabling the parser outright on Android MoonlightXml.rootOf asked the DOM factory for the Apache DTD switch: setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) Android's DocumentBuilderFactoryImpl recognizes exactly two feature names, the SAX namespaces and validation ones, and throws ParserConfigurationException for everything else. rootOf builds the factory inside a runCatching, so on device that exception was swallowed and rootOf returned null for EVERY document. The JVM's Xerces does support the feature, so the unit tests all passed and the breakage existed only on device. Every Moonlight XML reply therefore failed to parse on Android: /serverinfo (so a manually added host was always rejected as "No Moonlight host answered"), /pair (so pairing would have died the instant the user typed the PIN and phase 1 finally returned), /applist and /launch. Each switch is now applied best-effort, and the guarantee that actually matters is enforced portably instead: the builder gets an EntityResolver that resolves every external entity to nothing, so no DTD or entity in a host's reply can make the parser open a file or a connection, whatever the underlying factory was willing to admit. On the JVM the feature switches still apply exactly as before, so nothing is weakened there either. Test delta: two tests. One parses a /serverinfo body captured verbatim off Sunshine 7.1, single-line and in the field order it really sends, so a parser that only copes with the pretty-printed samples cannot pass. One feeds a file:// external entity through parseServerInfo and fails if the file's contents reach the parsed document, which pins the XXE guarantee to behaviour rather than to a feature name. Verified on device: with this fix a host added by LAN address is accepted and appears in the list, and pairing against it reaches the PIN prompt with the host holding an established connection. --- .../dish/core/net/moonlight/MoonlightXml.kt | 39 ++++++++++++-- .../core/net/moonlight/MoonlightXmlTest.kt | 51 +++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightXml.kt b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightXml.kt index 74a64cf6..14d27a44 100644 --- a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightXml.kt +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightXml.kt @@ -4,6 +4,7 @@ package com.tinkernorth.dish.core.net.moonlight import org.w3c.dom.Element +import org.xml.sax.InputSource import java.io.ByteArrayInputStream import javax.xml.parsers.DocumentBuilderFactory @@ -85,14 +86,44 @@ object MoonlightXml { runCatching { val factory = DocumentBuilderFactory.newInstance().apply { - // Harden the parser: this input comes off the network. - setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + // Harden the parser: this input comes off the network. Each + // switch is best-effort because the two parsers this code runs + // on do not admit the same ones. Android's + // DocumentBuilderFactoryImpl recognizes only the SAX namespaces + // and validation features and throws ParserConfigurationException + // for everything else, so demanding the Apache DTD switch here + // would abort EVERY parse on device (returning null out of this + // runCatching) while still passing on the JVM, where Xerces does + // support it. That is a silent, device-only failure, so the + // portable guarantee is enforced below instead. + harden(DISALLOW_DOCTYPE, true) + harden(EXTERNAL_GENERAL_ENTITIES, false) + harden(EXTERNAL_PARAMETER_ENTITIES, false) isExpandEntityReferences = false } - val doc = factory.newDocumentBuilder().parse(ByteArrayInputStream(xml.toByteArray(Charsets.UTF_8))) - doc.documentElement + val builder = + factory.newDocumentBuilder().apply { + // The half that always holds, whatever the factory would admit: + // every external entity resolves to nothing, so no DTD or entity + // in a host's reply can make the parser read a file or open a + // connection. This is the actual XXE gate. + setEntityResolver { _, _ -> InputSource(ByteArrayInputStream(ByteArray(0))) } + } + builder.parse(ByteArrayInputStream(xml.toByteArray(Charsets.UTF_8))).documentElement }.getOrNull() + /** Applies one parser switch, tolerating a parser that cannot express it. */ + private fun DocumentBuilderFactory.harden( + feature: String, + value: Boolean, + ) { + runCatching { setFeature(feature, value) } + } + + private const val DISALLOW_DOCTYPE = "http://apache.org/xml/features/disallow-doctype-decl" + private const val EXTERNAL_GENERAL_ENTITIES = "http://xml.org/sax/features/external-general-entities" + private const val EXTERNAL_PARAMETER_ENTITIES = "http://xml.org/sax/features/external-parameter-entities" + private fun text( root: Element, tag: String, diff --git a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightXmlTest.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightXmlTest.kt index 024995d6..66305cd8 100644 --- a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightXmlTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightXmlTest.kt @@ -8,6 +8,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import java.io.File class MoonlightXmlTest { @Test @@ -81,4 +82,54 @@ class MoonlightXmlTest { assertNull(MoonlightXml.parsePairReply("")) assertTrue(MoonlightXml.parseAppList("garbage").isEmpty()) } + + /** + * Captured verbatim off Sunshine 7.1 on the wire, single-line and with the + * fields in the order it really sends them, so a parser that only copes with + * the pretty-printed samples above cannot pass. + */ + @Test + fun `parses a real Sunshine serverinfo body`() { + val xml = + "\n" + + "Samus Aran7.1.431.-1" + + "3.23.0.7461651FD7-3927-3E2E-FD1A-6464FCEDE28F" + + "4798447989" + + "00:00:00:00:00:00192.168.68.98" + + "20323850" + + "0SUNSHINE_SERVER_FREE" + + val info = MoonlightXml.parseServerInfo(xml)!! + + assertEquals("Samus Aran", info.hostname) + assertEquals("61651FD7-3927-3E2E-FD1A-6464FCEDE28F", info.uniqueId) + assertEquals(47984, info.httpsPort) + assertEquals(47989, info.externalPort) + assertEquals("192.168.68.98", info.localIp) + assertFalse(info.paired) + assertFalse(info.busy) + } + + /** + * The parser is hardened best-effort, because Android's DOM factory rejects + * most feature switches and the old code let that abort every parse on + * device. Whatever the factory admits, no external entity may ever be + * fetched: this fails if a host's reply can make the parser read a file. + */ + @Test + fun `an external entity in a host reply is never resolved`() { + val secret = File.createTempFile("moonlight-xxe", ".txt") + secret.writeText("TOP-SECRET") + secret.deleteOnExit() + val secretUri = secret.absolutePath.replace('\\', '/') + val xml = + "" + + "]>" + + "&leak;" + + // Either the DTD is refused outright (null) or it parses with the entity + // unresolved. What must never happen is the file's contents coming back. + val hostname = MoonlightXml.parseServerInfo(xml)?.hostname + assertFalse("leaked the file into the parsed document", hostname.orEmpty().contains("TOP-SECRET")) + } } From ce9cbf30b1608e650b85d9df3eb876181369f714 Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Tue, 25 Aug 2026 20:05:04 -0400 Subject: [PATCH 07/20] fix: give the mutual-TLS half its own socket per request Every HTTPS call after the first one against the live Sunshine host timed out: W/MoonlightHttpGateway: request failed for /serverinfo: Read timed out W/MoonlightHttpGateway: request failed for /applist: Read timed out so pairing phase 5 never confirmed, /applist never returned, and the host never showed as connected even though it had recorded the pairing. The host knew the dish; the dish did not know the host. Sunshine's own debug log settles what happened. It received exactly ONE HTTPS request in the whole session: our /serverinfo, client certificate verified as "/CN=NVIDIA GameStream Client -- verified", served. Not one later HTTPS request reached it, while the plaintext half kept arriving normally throughout, pairing phases 1-4 included. So the requests were not being refused, they were not being answered at all, and the mutual-TLS credential was never the suspect it looked like: instrumented on the device, KeyStore.getDefaultType() is BKS, setKeyEntry accepts the opaque AndroidKeyStoreRSAPrivateKey, and Conscrypt's KeyManagerImpl hands back the alias, chain and key for keyType RSA. From the host's own socket table, eleven TLS connections from the dish were sitting open, one per call the app had made, none of them closed by us until the app's process died. The host's HTTPS listener answered nothing from that point on, ours or any other client's: curl from a third machine went from an instant reject to an 8s timeout against that same port while port 47989 kept replying in under a millisecond. Eleven connections for eleven calls, because the URL stack could never reuse one. com.android.okhttp keys its connection pool on an Address, and an Address carries the SSLSocketFactory and HostnameVerifier instances. The gateway built both per call and the verifier is necessarily per host, so no two calls ever matched: each one dialled a fresh TLS connection, and disconnect() parked the previous one in the pool instead of closing it. The HTTPS half now rides the same raw socket the plaintext half has always used, which is the half that never had this problem. MoonlightPlainHttpClient becomes MoonlightHttp11Client and takes an optional socket upgrade; the gateway passes the mutual-TLS handshake plus its certificate pin check, which now rejects by throwing out of that hook rather than through a HostnameVerifier, so a host that fails the pin never sees a request. The credential itself is untouched, and the SSLSocketFactory it builds is built once instead of per call. Every request opens one socket, sends the Connection: close the client already sent, and closes it, so the host holds nothing of ours between calls. The HTTPS connect and read budget goes from 5s to 10s. Every call now pays for its own handshake, and the dish's half of that handshake is a signature from a hardware-backed keystore key, which on a cold or busy device waits on keystore IPC and on the secure element. Test delta: eight tests. Six drive MoonlightHttpGateway.getHttps against a real loopback TLS host that demands a client certificate: the client certificate is presented and the reply parses, Connection: close goes out on the wire, each call takes its own accepted connection and the host reads EOF on every one, the host certificate is pinned on first contact, a matching pin keeps working, and a mismatching one is refused before any request is written. Two more cover the upgrade hook itself, including that a hook which throws is an unreachable reply rather than an exception. The throwaway-identity helper the pairing test already had moves to ThrowawayIdentity so both suites mint their certs the same way. Still unconfirmed end to end: the host's HTTPS listener has not recovered since it stopped answering, so /applist returning and the host showing as connected need a Sunshine restart to observe. --- ...HttpClient.kt => MoonlightHttp11Client.kt} | 92 +++++-- .../moonlight/MoonlightHttpGateway.kt | 145 +++++++---- .../net/moonlight/MoonlightPairingTest.kt | 22 +- .../core/net/moonlight/ThrowawayIdentity.kt | 34 +++ ...ntTest.kt => MoonlightHttp11ClientTest.kt} | 46 +++- .../moonlight/MoonlightHttpGatewayTest.kt | 238 ++++++++++++++++++ 6 files changed, 474 insertions(+), 103 deletions(-) rename app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/{MoonlightPlainHttpClient.kt => MoonlightHttp11Client.kt} (73%) create mode 100644 app/src/test/java/com/tinkernorth/dish/core/net/moonlight/ThrowawayIdentity.kt rename app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/{MoonlightPlainHttpClientTest.kt => MoonlightHttp11ClientTest.kt} (82%) create mode 100644 app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGatewayTest.kt diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClient.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttp11Client.kt similarity index 73% rename from app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClient.kt rename to app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttp11Client.kt index ad3af3be..c88ce470 100644 --- a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClient.kt +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttp11Client.kt @@ -12,18 +12,31 @@ import java.net.Socket import java.net.URI /** - * A minimal blocking HTTP/1.1 GET spoken over a raw [Socket], used ONLY for - * Moonlight's plaintext pairing phases on port 47989. + * A minimal blocking HTTP/1.1 GET spoken over a raw [Socket], one socket per + * request. Carries both Moonlight halves: the plaintext pairing phases on port + * 47989 and, once [upgrade] wraps the socket in TLS, the mutual-TLS calls on + * 47984. * - * WHY NOT HttpURLConnection. res/xml/network_security_config.xml denies - * cleartext app-wide and that denial is deliberate: it is the signal Play's - * pre-launch security checks and Android's PlatformVal validator read, and - * every URL-stack request the app makes really must be TLS. Relaxing it (or - * carving out a per-domain exception, which cannot be done anyway for a - * user-typed LAN address) would trade a real app-wide guarantee for one - * protocol's needs. Raw sockets are not gated by that config, the same way the - * encrypted UDP gamepad wire and the LAN discovery beacons already are not, so - * the exception stays scoped to exactly the four requests that need it. + * WHY NOT HttpURLConnection. Two independent reasons, one per half. + * + * Plaintext: res/xml/network_security_config.xml denies cleartext app-wide and + * that denial is deliberate. It is the signal Play's pre-launch security checks + * and Android's PlatformVal validator read, and every URL-stack request the app + * makes really must be TLS. Relaxing it (or carving out a per-domain exception, + * which cannot be done anyway for a user-typed LAN address) would trade a real + * app-wide guarantee for one protocol's needs. Raw sockets are not gated by + * that config, the same way the encrypted UDP gamepad wire and the LAN + * discovery beacons already are not, so the exception stays scoped to exactly + * the four requests that need it. + * + * TLS: the URL stack pools connections and decides reuse from an Address that + * includes the SSLSocketFactory and HostnameVerifier instances. The gateway + * necessarily supplies a per-host verifier, so no two calls ever shared a + * pooled connection; every call dialled a new TLS connection and `disconnect()` + * parked the old one in the pool instead of closing it, leaving the host a + * growing pile of open sessions (see [MoonlightHttpGateway.getHttps]). A socket + * this class opens is a socket it closes, and the `Connection: close` below + * makes the host drop its half as soon as it has answered. * * WHY CLEARTEXT IS SAFE HERE. Pairing phases 1-4 are plaintext by protocol * (Wolf http-pairing.adoc): NVIDIA's GameStream protocol fixes them on the @@ -32,16 +45,25 @@ import java.net.URI * AES challenges and signatures over them. The PIN itself is never sent: it is * shown on the dish and typed into the host's own UI, and both ends only prove * knowledge of it through the challenge exchange. Everything from phase 5 on - * (pairchallenge, /applist, /launch) runs over the pinned mutual TLS channel in - * [MoonlightHttpGateway]. A LAN eavesdropper learns nothing it can replay, and - * an active attacker cannot complete the exchange without the PIN. + * (pairchallenge, /applist, /launch) runs over the pinned mutual TLS channel + * [MoonlightHttpGateway] builds with [upgrade]. A LAN eavesdropper learns + * nothing it can replay, and an active attacker cannot complete the exchange + * without the PIN. * - * Blocking; call from Dispatchers.IO. Never throws: transport failures come - * back as `Reply(0, "")`, matching the gateway's HTTPS path. + * Blocking; call from Dispatchers.IO. Never throws: transport failures, TLS + * handshake failures and a refused certificate pin all come back as + * `Reply(0, "")`. */ -internal class MoonlightPlainHttpClient( +internal class MoonlightHttp11Client( private val connectTimeoutMs: Int, private val defaultReadTimeoutMs: Int, + /** + * Wraps the connected socket before the request goes out, and returns the + * socket to speak HTTP over. Null leaves the request in cleartext. The + * gateway passes the mutual-TLS handshake plus its certificate pin check, + * which rejects by throwing, so a refused host never sees a request. + */ + private val upgrade: ((socket: Socket, host: String, port: Int) -> Socket)? = null, ) { /** * GETs [urlString], or `Reply(0, "")` if the host never answered. @@ -63,8 +85,32 @@ internal class MoonlightPlainHttpClient( return UNREACHABLE } return try { - Socket().use { socket -> - socket.connect(InetSocketAddress(host, port), connectTimeoutMs) + exchange(uri, host, port, readTimeoutMs) + } catch (e: IOException) { + // Connect refused, DNS failure, both timeouts (SocketTimeoutException + // is an IOException), and every TLS failure including the pin + // mismatch [MoonlightHttpGateway] throws, all land here. + Log.w(TAG, "GET failed for ${uri.path}: ${e.message}") + UNREACHABLE + } + } + + /** + * One request over one socket: connect, hand it to [upgrade], ask, read the + * answer, close. Nested `use` on purpose, so the close that reaches the host + * first is the TLS one and it gets a close_notify before the socket under it + * goes away. + */ + private fun exchange( + uri: URI, + host: String, + port: Int, + readTimeoutMs: Int, + ): MoonlightHttpGateway.Reply = + Socket().use { raw -> + raw.connect(InetSocketAddress(host, port), connectTimeoutMs) + raw.soTimeout = readTimeoutMs + (upgrade?.invoke(raw, host, port) ?: raw).use { socket -> socket.soTimeout = readTimeoutMs socket.getOutputStream().apply { write(head(uri, host, port).toByteArray(Charsets.ISO_8859_1)) @@ -72,13 +118,7 @@ internal class MoonlightPlainHttpClient( } readReply(socket.getInputStream().buffered()) } - } catch (e: IOException) { - // Mirrors the gateway's HTTPS catch: connect refused, DNS failure and - // both timeouts (SocketTimeoutException is an IOException) land here. - Log.w(TAG, "plain GET failed for ${uri.path}: ${e.message}") - UNREACHABLE } - } /** The request line and headers, CRLF-terminated per RFC 9112. */ private fun head( @@ -245,7 +285,7 @@ internal class MoonlightPlainHttpClient( } private companion object { - const val TAG = "MoonlightPlainHttp" + const val TAG = "MoonlightHttp11" const val USER_AGENT = "Dish/1.0" const val DEFAULT_HTTP_PORT = 80 const val MAX_PORT = 65535 diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt index 289df262..00482f6d 100644 --- a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt @@ -9,18 +9,17 @@ import com.tinkernorth.dish.repository.SatellitePinRepository import com.tinkernorth.dish.repository.TofuVerdict import com.tinkernorth.dish.repository.sha256FingerprintHex import com.tinkernorth.dish.repository.tofuVerdict -import java.io.IOException -import java.net.URL +import java.net.Socket import java.security.KeyStore import java.security.SecureRandom import java.security.cert.X509Certificate import javax.inject.Inject import javax.inject.Singleton -import javax.net.ssl.HostnameVerifier -import javax.net.ssl.HttpsURLConnection import javax.net.ssl.KeyManagerFactory import javax.net.ssl.SSLContext -import javax.net.ssl.SSLSession +import javax.net.ssl.SSLPeerUnverifiedException +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager import javax.net.ssl.X509TrustManager @@ -31,10 +30,11 @@ import javax.net.ssl.X509TrustManager * host cert on first use, mirroring the satellite * [com.tinkernorth.dish.core.net.SatelliteHttpClient] TOFU verifier. * - * The two halves use different transports on purpose: HTTPS rides the platform - * URL stack, while the protocol-mandated plaintext half rides a raw socket via - * [MoonlightPlainHttpClient], since the app denies the URL stack cleartext - * app-wide. That class carries the full reasoning. + * Both halves ride one raw socket per request via [MoonlightHttp11Client], + * which carries the reasoning for keeping the platform URL stack out of this + * path: cleartext is denied to it app-wide, and its connection pool cannot + * reuse a connection whose per-host verifier differs, so it leaked one open TLS + * session per call. [getHttps] documents what that did to real hosts. * * All methods BLOCK; call from Dispatchers.IO. This is runtime plumbing; the URL * building and XML parsing it drives are unit-tested separately. @@ -54,14 +54,23 @@ class MoonlightHttpGateway val ok: Boolean get() = status in 200..299 } - private val plain = MoonlightPlainHttpClient(TIMEOUT_MS, TIMEOUT_MS) + private val plain = MoonlightHttp11Client(TIMEOUT_MS, TIMEOUT_MS) + + /** + * Present the client certificate; the host authorises by it after pairing. + * + * Built once. It is what a TLS connection is keyed on, it is not cheap + * (a keystore load and a KeyManagerFactory per call), and the identity + * behind it never changes for the life of the process. + */ + private val tlsFactory: SSLSocketFactory by lazy { mutualTlsFactory() } /** * Plaintext GET (serverinfo / pair phases 1-4). * * Goes over a raw socket, not the URL stack: those phases are plaintext by * protocol and res/xml/network_security_config.xml denies cleartext to the - * URL stack app-wide on purpose. [MoonlightPlainHttpClient] documents why + * URL stack app-wide on purpose. [MoonlightHttp11Client] documents why * the carve-out is scoped this way and why it is safe. * * [readTimeoutMs] is the caller's to raise for a request the host holds @@ -72,40 +81,63 @@ class MoonlightHttpGateway readTimeoutMs: Int = TIMEOUT_MS, ): Reply = plain.get(url, readTimeoutMs) - /** Mutual-TLS GET (serverinfo / pair phase 5 / applist / launch / resume / cancel). */ + /** + * Mutual-TLS GET (serverinfo / pair phase 5 / applist / launch / resume / + * cancel), over its own socket, closed as soon as the host has answered. + * + * This used to ride HttpsURLConnection, and against a real Sunshine host + * every call after the first one timed out. The URL stack pools + * connections and reuses one only when the Address matches, and an + * Address carries the SSLSocketFactory and HostnameVerifier instances. + * Both were built per call, the verifier necessarily per host, so no two + * calls ever matched: each one dialled a fresh TLS connection, and + * `disconnect()` parked the old one in the pool instead of closing it. + * The host was left holding one idle session per call we had made + * (measured on the host's own socket table: eleven, none of them closed + * by us until the app's process died), and its HTTPS listener answered + * nothing at all from then on, ours or anyone else's, so every later + * request sat in its TLS handshake until the read timeout. One socket + * per request, closed here, with the `Connection: close` + * [MoonlightHttp11Client] already sends, leaves the host holding nothing + * of ours between calls, which is how the plaintext half has always + * behaved and the half that never had this problem. + */ fun getHttps( urlString: String, hostId: String, - ): Reply { - val url = URL(urlString) - var connection: HttpsURLConnection? = null - return try { - connection = - (url.openConnection() as HttpsURLConnection).apply { - sslSocketFactory = mutualTlsFactory() - hostnameVerifier = tofuVerifier(hostId) - requestMethod = "GET" - connectTimeout = TIMEOUT_MS - readTimeout = TIMEOUT_MS - } - readReply(connection) - } catch (e: IOException) { - Log.w(TAG, "request failed for ${url.path}: ${e.message}") - Reply(0, "") - } finally { - connection?.disconnect() - } - } + ): Reply = + MoonlightHttp11Client(HTTPS_TIMEOUT_MS, HTTPS_TIMEOUT_MS) { socket, host, port -> + openTls(socket, host, port, hostId) + }.get(urlString) - private fun readReply(connection: java.net.HttpURLConnection): Reply { - val status = connection.responseCode - val stream = if (status in 200..299) connection.inputStream else connection.errorStream - val text = stream?.use { it.readBytes().toString(Charsets.UTF_8) }.orEmpty() - return Reply(status, text) + /** + * Hands back a handshaken TLS socket that presents the dish's client + * certificate, or throws once the host's certificate fails the pin. + * Throwing is the rejection: [MoonlightHttp11Client] never writes a + * request through a socket it did not get back. + */ + private fun openTls( + socket: Socket, + host: String, + port: Int, + hostId: String, + ): Socket { + val tls = tlsFactory.createSocket(socket, host, port, true) as SSLSocket + tls.startHandshake() + val presented = + tls.session + .peerCertificates + ?.firstOrNull() + ?: throw SSLPeerUnverifiedException("$host presented no certificate") + if (!pinAccepts(hostId, sha256FingerprintHex(presented.encoded))) { + tls.close() + throw SSLPeerUnverifiedException("cert pin mismatch for $hostId") + } + return tls } // Present the client certificate; the host authorises by it after pairing. - private fun mutualTlsFactory(): javax.net.ssl.SSLSocketFactory { + private fun mutualTlsFactory(): SSLSocketFactory { val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null) @@ -131,20 +163,19 @@ class MoonlightHttpGateway // TOFU: accept any self-signed host cert on first contact and pin it, then // reject any future mismatch (the sole MITM gate; the LAN cert has no CA). - private fun tofuVerifier(hostId: String): HostnameVerifier = - HostnameVerifier { _: String?, session: SSLSession? -> - val cert = session?.peerCertificates?.firstOrNull() ?: return@HostnameVerifier false - val presented = sha256FingerprintHex(cert.encoded) - when (tofuVerdict(pins.pinnedFingerprint(hostId), presented)) { - TofuVerdict.TRUST_FIRST_USE -> { - pins.pin(hostId, presented) - true - } - TofuVerdict.MATCH -> true - TofuVerdict.MISMATCH -> { - Log.e(TAG, "cert pin MISMATCH for $hostId, aborting (possible MITM)") - false - } + private fun pinAccepts( + hostId: String, + presented: String, + ): Boolean = + when (tofuVerdict(pins.pinnedFingerprint(hostId), presented)) { + TofuVerdict.TRUST_FIRST_USE -> { + pins.pin(hostId, presented) + true + } + TofuVerdict.MATCH -> true + TofuVerdict.MISMATCH -> { + Log.e(TAG, "cert pin MISMATCH for $hostId, aborting (possible MITM)") + false } } @@ -168,6 +199,16 @@ class MoonlightHttpGateway private const val TAG = "MoonlightHttpGateway" private const val TIMEOUT_MS = 5_000 + /** + * The HTTPS half's budget. Wider than the plaintext one because every + * call now pays for its own TLS handshake, and the dish's half of that + * handshake is a signature from a hardware-backed keystore key: the + * first one after a cold start waits on keystore IPC and, on a locked + * or busy device, on the secure element itself. Still short enough to + * fail a probe of an absent host quickly. + */ + private const val HTTPS_TIMEOUT_MS = 10_000 + /** * Read timeout for pairing phase 1. The host does not answer that one * until a human has typed the displayed PIN into its own web UI diff --git a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightPairingTest.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightPairingTest.kt index 47bb5e8b..f45ee310 100644 --- a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightPairingTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightPairingTest.kt @@ -5,12 +5,10 @@ package com.tinkernorth.dish.core.net.moonlight import com.tinkernorth.dish.core.net.bytesToHex import com.tinkernorth.dish.core.net.hexToBytes -import okhttp3.tls.HeldCertificate import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -import java.security.PrivateKey /** * Exercises the full 5-phase client pairing against a reference server built @@ -125,23 +123,7 @@ class MoonlightPairingTest { val CLIENT = throwawayIdentity("dish-pairing-test-client") val SERVER = throwawayIdentity("dish-pairing-test-server") - /** - * A disposable self-signed identity that lives only for this test run. - * RSA, not the builder's default ECDSA: Moonlight pairing signs with - * SHA256withRSA, and the real client identity is RSA-2048 as well. - */ - fun throwawayIdentity(commonName: String): MoonlightIdentity { - val held = - HeldCertificate - .Builder() - .commonName(commonName) - .rsa2048() - .build() - return object : MoonlightIdentity { - override val certificatePem: String = held.certificatePem() - override val certificateSignature: ByteArray = held.certificate.signature - override val privateKey: PrivateKey = held.keyPair.private - } - } + /** A disposable self-signed identity that lives only for this test run. */ + fun throwawayIdentity(commonName: String): MoonlightIdentity = ThrowawayIdentity.named(commonName) } } diff --git a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/ThrowawayIdentity.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/ThrowawayIdentity.kt new file mode 100644 index 00000000..ac5b2e15 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/ThrowawayIdentity.kt @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import okhttp3.tls.HeldCertificate +import java.security.PrivateKey + +/** + * Disposable self-signed Moonlight identities, minted per test run so that no + * key material is committed to the repo. Shared by the pairing tests, which + * need the identity, and the gateway test, which also hands the certificate to + * a real TLS endpoint. + * + * RSA-2048 rather than the builder's default ECDSA: Moonlight pairing signs + * with SHA256withRSA, and the real client identity is RSA-2048 as well. + */ +object ThrowawayIdentity { + fun heldCertificate(commonName: String): HeldCertificate = + HeldCertificate + .Builder() + .commonName(commonName) + .rsa2048() + .build() + + fun of(held: HeldCertificate): MoonlightIdentity = + object : MoonlightIdentity { + override val certificatePem: String = held.certificatePem() + override val certificateSignature: ByteArray = held.certificate.signature + override val privateKey: PrivateKey = held.keyPair.private + } + + fun named(commonName: String): MoonlightIdentity = of(heldCertificate(commonName)) +} diff --git a/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClientTest.kt b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttp11ClientTest.kt similarity index 82% rename from app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClientTest.kt rename to app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttp11ClientTest.kt index b437b0e0..b9a315d4 100644 --- a/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightPlainHttpClientTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttp11ClientTest.kt @@ -13,13 +13,14 @@ import java.net.ServerSocket import java.net.Socket import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLPeerUnverifiedException /** - * Drives [MoonlightPlainHttpClient] against a loopback [ServerSocket] so the + * Drives [MoonlightHttp11Client] against a loopback [ServerSocket] so the * request bytes it puts on the wire and the responses it accepts are both real. * The fixture answers one request and records what it was asked. */ -class MoonlightPlainHttpClientTest { +class MoonlightHttp11ClientTest { private lateinit var server: ServerSocket private var serverThread: Thread? = null @@ -64,7 +65,7 @@ class MoonlightPlainHttpClientTest { return head.toString() } - private fun client() = MoonlightPlainHttpClient(TIMEOUT, TIMEOUT) + private fun client() = MoonlightHttp11Client(TIMEOUT, TIMEOUT) private fun OutputStream.send(text: String) = write(text.toByteArray(Charsets.ISO_8859_1)) @@ -196,7 +197,7 @@ class MoonlightPlainHttpClientTest { // Accept the connection and hold it: the read timeout must fire, and it // must surface as Reply(0, "") rather than a SocketTimeoutException. val url = host { Thread.sleep(SLOW_MS) } - val client = MoonlightPlainHttpClient(TIMEOUT, READ_TIMEOUT_SHORT) + val client = MoonlightHttp11Client(TIMEOUT, READ_TIMEOUT_SHORT) val started = System.nanoTime() val reply = client.get(url) @@ -215,7 +216,7 @@ class MoonlightPlainHttpClientTest { Thread.sleep(HELD_MS) it.send("HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\npin!") } - val client = MoonlightPlainHttpClient(TIMEOUT, READ_TIMEOUT_SHORT) + val client = MoonlightHttp11Client(TIMEOUT, READ_TIMEOUT_SHORT) val reply = client.get(url, readTimeoutMs = TIMEOUT) @@ -238,10 +239,45 @@ class MoonlightPlainHttpClientTest { assertEquals(0, client().get("http://[not a url/pair").status) } + @Test + fun `hands the connected socket to the upgrade hook with the host it dialled`() { + val url = host { it.send("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi") } + var seen: Pair? = null + val client = + MoonlightHttp11Client(TIMEOUT, TIMEOUT) { socket, host, port -> + seen = host to port + socket + } + + val reply = client.get(url) + + assertEquals(200, reply.status) + assertEquals("127.0.0.1" to server.localPort, seen) + } + + @Test + fun `an upgrade that rejects the host is unreachable, and the host is never asked`() { + // How the gateway refuses a certificate that fails its pin: it throws out + // of the hook, so the request must never reach the wire. + val url = host { it.send("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi") } + val client = + MoonlightHttp11Client(TIMEOUT, TIMEOUT) { _, _, _ -> + throw SSLPeerUnverifiedException("cert pin mismatch") + } + + val reply = client.get(url) + + served.await(SETTLE_MS, TimeUnit.MILLISECONDS) + assertEquals(0, reply.status) + assertTrue(reply.unreachable) + assertEquals("", requestHead) + } + private companion object { const val TIMEOUT = 4_000 const val READ_TIMEOUT_SHORT = 300 const val SLOW_MS = 3_000L const val HELD_MS = 900L + const val SETTLE_MS = 500L } } diff --git a/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGatewayTest.kt b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGatewayTest.kt new file mode 100644 index 00000000..f574702b --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGatewayTest.kt @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.source.connection.moonlight + +import com.tinkernorth.dish.core.net.moonlight.MoonlightIdentity +import com.tinkernorth.dish.core.net.moonlight.ThrowawayIdentity +import com.tinkernorth.dish.repository.SatellitePinRepository +import com.tinkernorth.dish.repository.sha256FingerprintHex +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import okhttp3.tls.HandshakeCertificates +import okhttp3.tls.HeldCertificate +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.InetAddress +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLServerSocket +import javax.net.ssl.SSLSocket + +/** + * Drives [MoonlightHttpGateway.getHttps] against a real loopback TLS host that + * demands a client certificate, so the handshake, the bytes on the wire and the + * socket lifecycle are all real. + * + * What this pins down is the shape the gateway has to keep: one connection per + * request, closed once the host has answered, and a host certificate that has + * to survive the TOFU pin before any request is written. The previous + * HttpsURLConnection version failed all three, which is what wedged real + * Sunshine hosts after the first call. + */ +class MoonlightHttpGatewayTest { + private val clientHeld = ThrowawayIdentity.heldCertificate("dish-gateway-test-client") + private val hostHeld = ThrowawayIdentity.heldCertificate("Sunshine Gamestream Host") + private val impostorHeld = ThrowawayIdentity.heldCertificate("Sunshine Gamestream Host") + + private val identity: MoonlightIdentity = ThrowawayIdentity.of(clientHeld) + + private val pinned = mutableMapOf() + private val pins = + mockk { + every { pinnedFingerprint(any()) } answers { pinned[firstArg()] } + val id = slot() + val fingerprint = slot() + every { pin(capture(id), capture(fingerprint)) } answers { pinned[id.captured] = fingerprint.captured } + } + + private lateinit var host: TlsHost + + @After + fun tearDown() { + if (::host.isInitialized) host.close() + } + + private fun gateway() = MoonlightHttpGateway(identity, pins) + + private fun start(held: HeldCertificate = hostHeld): String { + host = TlsHost(held, clientHeld.certificate) + return "https://127.0.0.1:${host.port}" + } + + @Test + fun `presents the client certificate and hands back the parsed reply`() { + val base = start() + + val reply = gateway().getHttps("$base/serverinfo?uniqueid=abc", HOST_ID) + + assertEquals(200, reply.status) + assertEquals(BODY, reply.body) + assertTrue(reply.ok) + assertEquals("CN=dish-gateway-test-client", host.awaitClientPrincipals().single()) + } + + @Test + fun `asks the host to close the connection once it has answered`() { + val base = start() + + gateway().getHttps("$base/applist?uniqueid=abc", HOST_ID) + + val head = host.awaitHeads().single() + assertEquals("GET /applist?uniqueid=abc HTTP/1.1", head.lines().first()) + assertTrue(head, head.lines().contains("Connection: close")) + } + + @Test + fun `every call gets its own connection, and closes it before returning`() { + val base = start() + + repeat(CALLS) { assertEquals(200, gateway().getHttps("$base/serverinfo?uniqueid=abc", HOST_ID).status) } + + // One accept per call, and the host read EOF on each: nothing of ours is + // still open, which is exactly what the pooled URL-stack version leaked. + assertEquals(CALLS, host.awaitHeads(CALLS).size) + assertEquals(CALLS, host.closedByPeer.size) + } + + @Test + fun `pins the host certificate on first contact`() { + val base = start() + + gateway().getHttps("$base/serverinfo?uniqueid=abc", HOST_ID) + + assertEquals(sha256FingerprintHex(hostHeld.certificate.encoded), pinned[HOST_ID]) + } + + @Test + fun `keeps talking to a host whose certificate still matches its pin`() { + val base = start() + pinned[HOST_ID] = sha256FingerprintHex(hostHeld.certificate.encoded) + + assertEquals(200, gateway().getHttps("$base/serverinfo?uniqueid=abc", HOST_ID).status) + } + + @Test + fun `refuses a host whose certificate does not match the pin, without sending the request`() { + val base = start(impostorHeld) + pinned[HOST_ID] = sha256FingerprintHex(hostHeld.certificate.encoded) + + val reply = gateway().getHttps("$base/serverinfo?uniqueid=abc", HOST_ID) + + assertEquals(0, reply.status) + assertTrue(reply.unreachable) + // The pin is checked once the handshake has produced the peer certificate, + // so the host sees a connection; what it must never see is a request. + assertTrue("a rejected host must never see the request", host.headOrNull().isNullOrEmpty()) + // The stored pin is the real host's; a mismatch must not overwrite it. + assertEquals(sha256FingerprintHex(hostHeld.certificate.encoded), pinned[HOST_ID]) + } + + /** + * A loopback TLS host that requires a client certificate, answers every + * request the same way, and records what it saw. + */ + private class TlsHost( + held: HeldCertificate, + trustedClient: java.security.cert.X509Certificate, + ) { + private val server: SSLServerSocket = + HandshakeCertificates + .Builder() + .heldCertificate(held) + .addTrustedCertificate(trustedClient) + .build() + .sslContext() + .serverSocketFactory + .createServerSocket(0, BACKLOG, InetAddress.getByName("127.0.0.1")) as SSLServerSocket + + val heads = CopyOnWriteArrayList() + val clientPrincipals = CopyOnWriteArrayList() + val closedByPeer = CopyOnWriteArrayList() + private val served = CountDownLatch(CALLS) + + val port: Int get() = server.localPort + + init { + server.needClientAuth = true + Thread { + runCatching { + while (true) serve(server.accept() as SSLSocket) + } + }.apply { + isDaemon = true + start() + } + } + + private fun serve(socket: SSLSocket) { + socket.use { + runCatching { + clientPrincipals += socket.session.peerPrincipal.name + heads += readHead(socket) + socket.getOutputStream().apply { + write( + ("HTTP/1.1 200 OK\r\nContent-Length: ${BODY.length}\r\n\r\n$BODY") + .toByteArray(Charsets.ISO_8859_1), + ) + flush() + } + // The client asked us to close, so it must not send anything + // more: what comes back has to be end-of-stream. + closedByPeer += socket.getInputStream().read() < 0 + } + served.countDown() + } + } + + private fun readHead(socket: SSLSocket): String { + val input = socket.getInputStream() + val head = StringBuilder() + while (!head.endsWith("\r\n\r\n")) { + val b = input.read() + if (b < 0) break + head.append(b.toChar()) + } + return head.toString() + } + + fun awaitHeads(count: Int = 1): List { + waitFor(count) + return heads.toList() + } + + fun awaitClientPrincipals(): List { + waitFor(1) + return clientPrincipals.toList() + } + + /** What the host saw, once it is clear it will not see anything more. */ + fun headOrNull(): String? { + served.await(SETTLE_MS, TimeUnit.MILLISECONDS) + return heads.firstOrNull() + } + + private fun waitFor(count: Int) { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(TIMEOUT_MS) + while (heads.size < count && System.nanoTime() < deadline) Thread.sleep(POLL_MS) + } + + fun close() { + runCatching { server.close() } + } + } + + private companion object { + const val HOST_ID = "moonlight:127.0.0.1" + const val BODY = "1" + const val BACKLOG = 4 + const val CALLS = 3 + const val TIMEOUT_MS = 10_000L + const val SETTLE_MS = 500L + const val POLL_MS = 10L + } +} From cf70502b4e0e72f151d8f2d1a97595b6c123f035 Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Tue, 25 Aug 2026 20:08:19 -0400 Subject: [PATCH 08/20] docs: correct the cleartext config's account of the Moonlight halves The header claimed the Moonlight mutual-TLS endpoints go through the platform URL stack, and named MoonlightPlainHttpClient. Neither is true any more: both Moonlight halves now speak HTTP/1.1 over their own socket, the plaintext one because this config denies the URL stack cleartext on purpose and the TLS one because the URL stack's connection pool leaked a live session per call. Nothing about the policy changes. The file still denies cleartext to everything that can reach the URL stack, and the TLS half is still TLS, with a client certificate and the host's certificate pinned on first use. --- .../main/res/xml/network_security_config.xml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index cc722d36..53fc3b56 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,24 +1,30 @@ + + + + diff --git a/app/src/main/res/color/type_card_stroke.xml b/app/src/main/res/color/type_card_stroke.xml new file mode 100644 index 00000000..321f8652 --- /dev/null +++ b/app/src/main/res/color/type_card_stroke.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_configure_bindings.xml b/app/src/main/res/layout/activity_configure_bindings.xml index 5a8f4ea2..cf2361da 100644 --- a/app/src/main/res/layout/activity_configure_bindings.xml +++ b/app/src/main/res/layout/activity_configure_bindings.xml @@ -74,6 +74,13 @@ android:layout_height="wrap_content" android:layout_marginTop="@dimen/config_section_gap" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/setup_choice_row.xml b/app/src/main/res/layout/setup_choice_row.xml index c59c10cd..fb64cd1f 100644 --- a/app/src/main/res/layout/setup_choice_row.xml +++ b/app/src/main/res/layout/setup_choice_row.xml @@ -13,7 +13,10 @@ android:layout_marginTop="@dimen/card_margin_bottom" android:clickable="true" android:focusable="true" - android:foreground="?attr/selectableItemBackground"> + android:foreground="?attr/selectableItemBackground" + app:cardBackgroundColor="@color/type_card_background" + app:checkedIcon="@null" + app:strokeColor="@color/type_card_stroke"> + android:foreground="?attr/selectableItemBackground" + app:cardBackgroundColor="@color/type_card_background" + app:checkedIcon="@null" + app:strokeColor="@color/type_card_stroke"> + + + + diff --git a/app/src/main/res/values-bs/strings.xml b/app/src/main/res/values-bs/strings.xml index 69ed5887..461a37f1 100644 --- a/app/src/main/res/values-bs/strings.xml +++ b/app/src/main/res/values-bs/strings.xml @@ -134,18 +134,95 @@ MOONLIGHT HOSTOVI Još nema Moonlight hostova. Skenirajte mrežu ili dodajte adresu. Upari s %1$s - Na hostu otvorite Moonlight/Sunshine stranicu i unesite ovaj PIN: - Čekanje da host prihvati PIN… - Emuliraj kontroler - Automatski - Odaberi aplikaciju - Na ovom hostu nema dostupnih aplikacija. - Aplikacija je već pokrenuta - %1$s već ima pokrenutu aplikaciju i neće ustupiti tu sesiju. Zatvorite je da biste pokrenuli novu. - Zatvori je Dodaj Moonlight host IP ili naziv hosta Unesite adresu hosta + + + Kako host treba da ga vidi? + Dish traži od %1$s da priključi ovaj kontroler. Neki hostovi zaobiđu taj izbor. + Automatski + Odabrano za vas + Automatski šalje %1$s za ovaj kontroler. + Sesija + Nova sesija + Ovo je prvi kontroler na %1$s, pa on bira šta host pokreće. + Bez izbora, Dish pokreće ono što %1$s prvo navede. + Pridružuje se: %1$s + Pridružuje se sesiji na %1$s + %1$s već vodi sesiju za ovaj uređaj. Ovaj kontroler joj se pridružuje kao kontroler %2$d. + Strimuje na %1$s + %1$s · kontroler %2$d od 4 + Upareno + Zapamćeno + Nije upareno + Koristi ga %1$s + + %1$d kontroler + %1$d kontrolera + %1$d kontrolera + + Provjera %1$s… + Još nije upareno + %1$s traži jednokratni PIN prije nego Dish može pokrenuti sesiju. Uparite sada ili dodajte kontroler pa uparite kasnije. + Upišite %1$s na Moonlight ili Sunshine stranicu na %2$s. + Čeka se da host prihvati PIN… + %1$s nije prihvatio PIN + Provjerite je li kod unesen na pravi host, pa pokušajte ponovo. + %1$s ne odgovara + Provjerite je li host uključen i na ovoj mreži, pa pokušajte ponovo. + Dish pamti uparivanje s %1$s i pokrenuće sesiju kada se host vrati. + %1$s više ne prepoznaje ovaj uređaj + Host je uklonio uparivanje. Uparite ponovo da biste pokrenuli sesiju. + %1$s je resetovan + Ovaj host ima novi identitet, pa staro uparivanje više ne vrijedi. Uparite ponovo da biste pokrenuli sesiju. + Čitanje liste aplikacija sa %1$s… + Nema aplikacija na ovom hostu + %1$s još nema podešenih aplikacija. Dodajte jednu na hostu ili dodajte kontroler pa će Dish pokrenuti ono što host prvo navede. + Nije moguće pročitati listu aplikacija sa %1$s + Dish će pokrenuti ono što host prvo navede. Pokušajte ponovo kada %1$s bude dostupan. + %1$s je pun + Sesija nosi najviše četiri kontrolera, a %1$s ih već ima četiri. Odvežite jedan da napravite mjesta. + Drugi uređaj koristi %1$s + %1$s vodi aplikaciju za drugi uređaj i neće predati tu sesiju. Zatvorite je da pokrenete novu ili dodajte kontroler pa pokušajte kasnije. + Nije moguće ponovo ući u sesiju na %1$s + Host ima sesiju, ali je ne vraća. Zatvorite aplikaciju na %1$s i pokrenite novu. + %1$s je odbio sesiju: %2$s + Ipak dodajte kontroler pa će Dish pokušati ponovo sljedeći put kada ga upotrijebite. + Nije moguće dovršiti sesiju na %1$s + Aplikacija se pokrenula, ali strim nije podignut, pa ju je Dish opet zatvorio. + Sesija na %1$s je završena + Veza je pala. Dish će se ponovo pridružiti sljedeći put kada upotrijebite ovaj kontroler. + %1$s je završio sesiju + Aplikacija je zatvorena na hostu. Pokrenite novu sesiju da nastavite koristiti ovaj kontroler. + Upari sada + Upari ponovo + Novi kod + Otkaži + Pokušaj ponovo + Ponovi + Zatvori aplikaciju na %1$s + Završi sesiju + Ponovo poveži + Pokreni sesiju + Prikaži kontrolere na %1$s + Moonlight sesija + Održava Moonlight sesiju dok je kontroler vezan za nju. + Dish · Moonlight + + %1$d kontroler na %2$s + %1$d kontrolera na %2$s + %1$d kontrolera na %2$s + + Pokretanje sesije na %1$s… + Pokretanje sesije… + Moonlight hostovi + Moonlight host · %1$s + Nema pronađenih Moonlight hostova + PC se pojavi ovdje kada na njemu radi Sunshine, Apollo ili Wolf i kada su obje mašine na istoj mreži. Možete ga dodati i po adresi. Spreman za uparivanje. Pronađite ovaj uređaj na svom hostu Preuzimanje HID profila… Neaktivan diff --git a/app/src/main/res/values-bs/strings_setup.xml b/app/src/main/res/values-bs/strings_setup.xml index a0cf9db1..9d50057f 100644 --- a/app/src/main/res/values-bs/strings_setup.xml +++ b/app/src/main/res/values-bs/strings_setup.xml @@ -60,6 +60,7 @@ Satellite preko Wi-Fi-ja Najmanja latencija, sve funkcije. Treba besplatnu PC aplikaciju. Najbolje + Strimujte na PC s Sunshine, Apollo ili Wolf. Dish priključuje kontroler u sesiju. Bluetooth host Telefon se uparuje s PC-om kao pad. Bez PC aplikacije. Odaberite svoj PC @@ -113,6 +114,8 @@ Kako PC treba da ga vidi? Odaberite kontroler koji PC treba da prijavi. Svaki otključava drugačije dodatke. Preko Bluetooth-a tip je fiksan. Evo šta nosi. + Šta se pokreće na hostu? + Svi kontroleri vezani za ovaj host dijele jednu sesiju. Kakav osjećaj treba da ima? Prikazani su samo dodaci koje podržavaju i vaš unos i odredište. Nagib i žiro nišanjenje na PC-u. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 731d7a83..b7a95dbf 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -134,18 +134,93 @@ MOONLIGHT-HOSTS Noch keine Moonlight-Hosts. Netzwerk scannen oder per Adresse hinzufügen. Mit %1$s koppeln - Öffne auf dem Host die Moonlight/Sunshine-Seite und gib diese PIN ein: - Warte auf Bestätigung der PIN durch den Host… - Controller emulieren - Automatisch - App auswählen - Auf diesem Host sind keine Apps verfügbar. - Es läuft bereits eine App - Auf %1$s läuft bereits eine App, und diese Sitzung wird nicht abgegeben. Schließe sie, um eine neue zu starten. - Schließen Moonlight-Host hinzufügen Host-IP oder -Name Host-Adresse eingeben + + + Wie soll der Host ihn sehen? + Dish bittet %1$s, diesen Controller anzuschließen. Manche Hosts überschreiben die Wahl. + Automatisch + Für dich gewählt + Automatisch sendet %1$s für diesen Controller. + Sitzung + Neue Sitzung + Dies ist der erste Controller auf %1$s und bestimmt daher, was der Host startet. + Ohne Auswahl startet Dish, was %1$s zuerst auflistet. + Tritt %1$s bei + Tritt der Sitzung auf %1$s bei + %1$s führt bereits eine Sitzung für dieses Gerät aus. Dieser Controller tritt ihr als Controller %2$d bei. + Streamt an %1$s + %1$s · Controller %2$d von 4 + Gekoppelt + Gemerkt + Nicht gekoppelt + Genutzt von %1$s + + %1$d Controller + %1$d Controllern + + %1$s wird geprüft… + Noch nicht gekoppelt + %1$s benötigt eine einmalige PIN, bevor Dish eine Sitzung starten kann. Jetzt koppeln oder den Controller hinzufügen und später koppeln. + Gib %1$s auf der Moonlight- oder Sunshine-Seite von %2$s ein. + Warte auf Bestätigung der PIN durch den Host… + %1$s hat die PIN nicht akzeptiert + Prüfe, ob der Code auf dem richtigen Host eingegeben wurde, und versuche es erneut. + %1$s antwortet nicht + Prüfe, ob der Host eingeschaltet und in diesem Netzwerk ist, und versuche es erneut. + Dish merkt sich die Kopplung mit %1$s und startet eine Sitzung, sobald der Host wieder da ist. + %1$s erkennt dieses Gerät nicht mehr + Der Host hat die Kopplung entfernt. Koppele erneut, um eine Sitzung zu starten. + %1$s wurde zurückgesetzt + Dieser Host hat eine neue Identität, daher gilt die alte Kopplung nicht mehr. Koppele erneut, um eine Sitzung zu starten. + App-Liste von %1$s wird gelesen… + Keine Apps auf diesem Host + %1$s hat noch keine Apps eingerichtet. Richte eine auf dem Host ein oder füge den Controller hinzu; Dish startet dann, was der Host zuerst auflistet. + App-Liste von %1$s nicht lesbar + Dish startet, was der Host zuerst auflistet. Versuche es erneut, sobald %1$s erreichbar ist. + %1$s ist voll + Eine Sitzung trägt höchstens vier Controller, und %1$s hat bereits vier. Löse eine Verknüpfung, um Platz zu schaffen. + Ein anderes Gerät nutzt %1$s + %1$s führt eine App für ein anderes Gerät aus und gibt diese Sitzung nicht ab. Schließe sie, um eine neue zu starten, oder füge den Controller hinzu und versuche es später erneut. + Sitzung auf %1$s konnte nicht fortgesetzt werden + Der Host hat eine Sitzung, gibt sie aber nicht zurück. Schließe die App auf %1$s und starte eine neue. + %1$s hat die Sitzung abgelehnt: %2$s + Füge den Controller trotzdem hinzu; Dish versucht es beim nächsten Mal erneut. + Sitzung auf %1$s konnte nicht abgeschlossen werden + Die App startete, aber der Stream kam nicht zustande, also hat Dish sie wieder geschlossen. + Sitzung auf %1$s beendet + Die Verbindung ist abgebrochen. Dish tritt beim nächsten Einsatz dieses Controllers wieder bei. + %1$s hat die Sitzung beendet + Die App wurde auf dem Host geschlossen. Starte eine neue Sitzung, um diesen Controller weiter zu nutzen. + Jetzt koppeln + Erneut koppeln + Neuer Code + Abbrechen + Erneut versuchen + Wiederholen + App auf %1$s schließen + Sitzung beenden + Neu verbinden + Sitzung starten + Controller auf %1$s ansehen + Moonlight-Sitzung + Hält eine Moonlight-Sitzung aktiv, solange ein Controller damit verknüpft ist. + Dish · Moonlight + + %1$d Controller auf %2$s + %1$d Controller auf %2$s + + Sitzung auf %1$s wird gestartet… + Sitzung wird gestartet… + Moonlight-Hosts + Moonlight-Host · %1$s + Keine Moonlight-Hosts gefunden + Ein PC erscheint hier, sobald Sunshine, Apollo oder Wolf darauf läuft und beide Geräte im selben Netzwerk sind. Du kannst auch einen per Adresse hinzufügen. Bereit zum Koppeln. Suche dieses Gerät auf deinem Host HID-Profil wird abgerufen… Inaktiv diff --git a/app/src/main/res/values-de/strings_setup.xml b/app/src/main/res/values-de/strings_setup.xml index 9397d265..38e0aeee 100644 --- a/app/src/main/res/values-de/strings_setup.xml +++ b/app/src/main/res/values-de/strings_setup.xml @@ -66,6 +66,7 @@ Satellite über WLAN Geringste Latenz, voller Funktionsumfang. Braucht die kostenlose PC-App. Am besten + Stream an einen PC mit Sunshine, Apollo oder Wolf. Dish schließt einen Controller an die Sitzung an. Bluetooth-Host Das Handy koppelt sich als Pad mit dem PC. Keine PC-App. Wähle deinen PC @@ -122,6 +123,8 @@ Wie soll der PC ihn sehen? Wähle den Controller, den der PC melden soll. Jeder schaltet andere Extras frei. Über Bluetooth ist der Typ festgelegt. Das ist enthalten. + Was läuft auf dem Host? + Alle mit diesem Host verknüpften Controller teilen sich eine Sitzung. Wie soll es sich anfühlen? Es werden nur die Extras gezeigt, die deine Eingabe und dein Ziel beide unterstützen. Neigungs- und Gyro-Zielen am PC. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 398eff6d..7bf63b43 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -136,18 +136,95 @@ HOSTS MOONLIGHT Aún no hay hosts Moonlight. Busca en tu red o añade uno por dirección. Emparejar con %1$s - En tu host, abre la página de Moonlight/Sunshine e introduce este PIN: - Esperando a que el host acepte el PIN… - Emular mando - Automático - Elegir una app - No hay apps disponibles en este host. - Ya hay una app en ejecución - %1$s ya está ejecutando una app y no cederá esa sesión. Ciérrala para iniciar una nueva. - Cerrarla Añadir host Moonlight IP o nombre del host Introduce la dirección del host + + + ¿Cómo debe verlo el host? + Dish pide a %1$s que conecte este mando. Algunos hosts anulan la elección. + Automático + Elegido para ti + Automático envía %1$s para este mando. + Sesión + Nueva sesión + Este es el primer mando en %1$s, así que elige lo que ejecuta el host. + Sin una elección, Dish inicia lo primero que liste %1$s. + Uniéndose a %1$s + Uniéndose a la sesión en %1$s + %1$s ya ejecuta una sesión para este dispositivo. Este mando se une como mando %2$d. + Transmitiendo a %1$s + %1$s · mando %2$d de 4 + Emparejado + Recordado + Sin emparejar + En uso por %1$s + + %1$d mando + %1$d mandos + %1$d mandos + + Comprobando %1$s… + Aún sin emparejar + %1$s necesita un PIN de un solo uso antes de que Dish pueda iniciar una sesión. Empareja ahora, o añade el mando y empareja más tarde. + Escribe %1$s en la página de Moonlight o Sunshine de %2$s. + Esperando a que el host acepte el PIN… + %1$s no aceptó el PIN + Comprueba que el código fue al host correcto e inténtalo de nuevo. + %1$s no responde + Comprueba que el host esté encendido y en esta red, e inténtalo de nuevo. + Dish recuerda el emparejamiento con %1$s e iniciará una sesión cuando el host vuelva. + %1$s ya no reconoce este dispositivo + El host eliminó el emparejamiento. Empareja de nuevo para iniciar una sesión. + %1$s se restableció + Este host tiene una identidad nueva, así que el emparejamiento anterior ya no sirve. Empareja de nuevo para iniciar una sesión. + Leyendo la lista de apps de %1$s… + No hay apps en este host + %1$s aún no tiene apps configuradas. Añade una en el host, o añade el mando y Dish iniciará lo primero que liste el host. + No se pudo leer la lista de apps de %1$s + Dish iniciará lo primero que liste el host. Reinténtalo cuando %1$s esté accesible. + %1$s está lleno + Una sesión admite cuatro mandos como máximo, y %1$s ya tiene cuatro. Desvincula uno para hacer sitio. + Otro dispositivo está usando %1$s + %1$s ejecuta una app para otro dispositivo y no cederá esa sesión. Ciérrala para iniciar una nueva, o añade el mando e inténtalo más tarde. + No se pudo volver a la sesión en %1$s + El host tiene una sesión pero no la devuelve. Cierra la app en %1$s e inicia una nueva. + %1$s rechazó la sesión: %2$s + Añade el mando igualmente y Dish lo intentará de nuevo la próxima vez que lo uses. + No se pudo completar la sesión en %1$s + La app arrancó pero la transmisión no se estableció, así que Dish la cerró de nuevo. + La sesión en %1$s terminó + El enlace se cayó. Dish se volverá a unir la próxima vez que uses este mando. + %1$s terminó la sesión + La app se cerró en el host. Inicia una sesión nueva para seguir usando este mando. + Emparejar ahora + Emparejar de nuevo + Código nuevo + Cancelar + Intentar de nuevo + Reintentar + Cerrar la app en %1$s + Terminar sesión + Reconectar + Iniciar una sesión + Ver mandos en %1$s + Sesión de Moonlight + Mantiene viva una sesión de Moonlight mientras haya un mando vinculado. + Dish · Moonlight + + %1$d mando en %2$s + %1$d mandos en %2$s + %1$d mandos en %2$s + + Iniciando una sesión en %1$s… + Iniciando una sesión… + Hosts de Moonlight + Host de Moonlight · %1$s + No se encontraron hosts de Moonlight + Un PC aparece aquí cuando Sunshine, Apollo o Wolf se ejecuta en él y ambos equipos están en la misma red. También puedes añadir uno por dirección. Listo para emparejar. Busca este dispositivo en tu host Adquiriendo perfil HID… Inactivo diff --git a/app/src/main/res/values-es/strings_setup.xml b/app/src/main/res/values-es/strings_setup.xml index c7573a9e..8ee154d5 100644 --- a/app/src/main/res/values-es/strings_setup.xml +++ b/app/src/main/res/values-es/strings_setup.xml @@ -60,6 +60,7 @@ Satellite por Wi-Fi La menor latencia, todas las funciones. Necesita la app gratuita para PC. La mejor + Transmite a un PC con Sunshine, Apollo o Wolf. Dish conecta un mando a la sesión. Host Bluetooth El teléfono se empareja con el PC como un mando. Sin app para PC. Elige tu PC @@ -113,6 +114,8 @@ ¿Cómo debe verlo el PC? Elige el mando que el PC debe reportar. Cada uno desbloquea extras distintos. Por Bluetooth el tipo está fijado. Esto es lo que ofrece. + ¿Qué se ejecuta en el host? + Todos los mandos vinculados a este host comparten una sesión. ¿Cómo debe sentirse? Solo se muestran los extras que admiten a la vez tu entrada y tu destino. Inclinación y apuntado con giro en el PC. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ab71f0bb..c98a12ed 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -135,18 +135,95 @@ HÔTES MOONLIGHT Aucun hôte Moonlight. Scannez le réseau ou ajoutez-en un par adresse. Associer à %1$s - Sur votre hôte, ouvrez la page Moonlight/Sunshine et saisissez ce code PIN : - En attente de l\'acceptation du PIN par l\'hôte… - Émuler la manette - Auto - Choisir une app - Aucune app disponible sur cet hôte. - Une app est déjà en cours - %1$s exécute déjà une app et ne cédera pas cette session. Fermez-la pour en démarrer une nouvelle. - La fermer Ajouter un hôte Moonlight IP ou nom de l\'hôte Saisissez l\'adresse de l\'hôte + + + Comment l\'hôte doit-il la voir ? + Dish demande à %1$s de brancher cette manette. Certains hôtes remplacent ce choix. + Automatique + Choisi pour vous + Automatique envoie %1$s pour cette manette. + Session + Nouvelle session + C\'est la première manette sur %1$s, elle choisit donc ce que l\'hôte lance. + Sans choix, Dish lance ce que %1$s liste en premier. + Rejoint %1$s + Rejoint la session sur %1$s + %1$s exécute déjà une session pour cet appareil. Cette manette la rejoint en tant que manette %2$d. + Diffusion vers %1$s + %1$s · manette %2$d sur 4 + Appairé + Mémorisé + Non appairé + Utilisé par %1$s + + %1$d manette + %1$d manettes + %1$d manettes + + Vérification de %1$s… + Pas encore appairé + %1$s a besoin d\'un code PIN à usage unique avant que Dish puisse lancer une session. Appairez maintenant, ou ajoutez la manette et appairez plus tard. + Saisissez %1$s sur la page Moonlight ou Sunshine de %2$s. + En attente de la validation du code par l\'hôte… + %1$s n\'a pas accepté le code + Vérifiez que le code a bien été saisi sur le bon hôte, puis réessayez. + %1$s ne répond pas + Vérifiez que l\'hôte est allumé et sur ce réseau, puis réessayez. + Dish garde l\'appairage avec %1$s en mémoire et lancera une session dès le retour de l\'hôte. + %1$s ne reconnaît plus cet appareil + L\'hôte a supprimé l\'appairage. Appairez à nouveau pour lancer une session. + %1$s a été réinitialisé + Cet hôte a une nouvelle identité, l\'ancien appairage ne fonctionne donc plus. Appairez à nouveau pour lancer une session. + Lecture de la liste des applis de %1$s… + Aucune appli sur cet hôte + %1$s n\'a encore aucune appli configurée. Ajoutez-en une sur l\'hôte, ou ajoutez la manette et Dish lancera ce que l\'hôte liste en premier. + Impossible de lire la liste des applis de %1$s + Dish lancera ce que l\'hôte liste en premier. Réessayez quand %1$s sera joignable. + %1$s est complet + Une session porte quatre manettes au maximum, et %1$s en a déjà quatre. Déliez-en une pour faire de la place. + Un autre appareil utilise %1$s + %1$s exécute une appli pour un autre appareil et ne cédera pas cette session. Fermez-la pour en lancer une nouvelle, ou ajoutez la manette et réessayez plus tard. + Impossible de rejoindre la session sur %1$s + L\'hôte a une session mais refuse de la rendre. Fermez l\'appli sur %1$s et lancez-en une nouvelle. + %1$s a refusé la session : %2$s + Ajoutez quand même la manette, Dish réessaiera à la prochaine utilisation. + Impossible de finaliser la session sur %1$s + L\'appli a démarré mais le flux ne s\'est pas établi, alors Dish l\'a refermée. + La session sur %1$s est terminée + Le lien est tombé. Dish rejoindra la session à la prochaine utilisation de cette manette. + %1$s a mis fin à la session + L\'appli s\'est fermée sur l\'hôte. Lancez une nouvelle session pour continuer à utiliser cette manette. + Appairer maintenant + Appairer à nouveau + Nouveau code + Annuler + Réessayer + Relancer + Fermer l\'appli sur %1$s + Terminer la session + Reconnecter + Lancer une session + Voir les manettes sur %1$s + Session Moonlight + Maintient une session Moonlight active tant qu\'une manette y est liée. + Dish · Moonlight + + %1$d manette sur %2$s + %1$d manettes sur %2$s + %1$d manettes sur %2$s + + Lancement d\'une session sur %1$s… + Lancement d\'une session… + Hôtes Moonlight + Hôte Moonlight · %1$s + Aucun hôte Moonlight trouvé + Un PC apparaît ici dès que Sunshine, Apollo ou Wolf y tourne et que les deux machines sont sur le même réseau. Vous pouvez aussi en ajouter un par adresse. Prête à appairer : repérez cet appareil sur votre hôte Acquisition du profil HID… Inactive diff --git a/app/src/main/res/values-fr/strings_setup.xml b/app/src/main/res/values-fr/strings_setup.xml index 34c12903..a94ba2d0 100644 --- a/app/src/main/res/values-fr/strings_setup.xml +++ b/app/src/main/res/values-fr/strings_setup.xml @@ -65,6 +65,7 @@ Satellite par Wi-Fi Latence minimale, toutes les fonctionnalités. Nécessite l\'app PC gratuite. Meilleur + Diffusez vers un PC avec Sunshine, Apollo ou Wolf. Dish branche une manette dans la session. Hôte Bluetooth Le téléphone s\'appaire au PC comme une manette. Sans app PC. Choisissez votre PC @@ -121,6 +122,8 @@ Comment le PC doit-il la voir ? Choisissez la manette que le PC doit signaler. Chacune débloque des extras différents. Par Bluetooth, le type est fixe. Voici ce qu\'il transporte. + Que lance l\'hôte ? + Toutes les manettes liées à cet hôte partagent une seule session. Quel ressenti voulez-vous ? Seuls les extras pris en charge à la fois par votre entrée et votre destination sont affichés. Visée par inclinaison et gyro sur le PC. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 98090b4e..73298e22 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -136,18 +136,95 @@ HOSTS MOONLIGHT Nenhum host Moonlight ainda. Busque na rede ou adicione por endereço. Parear com %1$s - No seu host, abra a página do Moonlight/Sunshine e insira este PIN: - Aguardando o host aceitar o PIN… - Emular controle - Automático - Escolher um app - Nenhum app disponível neste host. - Um app já está em execução - %1$s já está executando um app e não vai liberar essa sessão. Feche-o para iniciar uma nova. - Fechar Adicionar host Moonlight IP ou nome do host Insira o endereço do host + + + Como o host deve vê-lo? + O Dish pede que %1$s conecte este controle. Alguns hosts substituem a escolha. + Automático + Escolhido para você + Automático envia %1$s para este controle. + Sessão + Nova sessão + Este é o primeiro controle em %1$s, então ele escolhe o que o host executa. + Sem uma escolha, o Dish inicia o que %1$s listar primeiro. + Entrando em %1$s + Entrando na sessão em %1$s + %1$s já executa uma sessão para este dispositivo. Este controle entra nela como controle %2$d. + Transmitindo para %1$s + %1$s · controle %2$d de 4 + Pareado + Lembrado + Não pareado + Em uso por %1$s + + %1$d controle + %1$d controles + %1$d controles + + Verificando %1$s… + Ainda não pareado + %1$s precisa de um PIN de uso único antes que o Dish possa iniciar uma sessão. Pareie agora, ou adicione o controle e pareie depois. + Digite %1$s na página do Moonlight ou do Sunshine em %2$s. + Aguardando o host aceitar o PIN… + %1$s não aceitou o PIN + Confira se o código foi para o host certo e tente de novo. + %1$s não está respondendo + Confira se o host está ligado e nesta rede e tente de novo. + O Dish lembra o pareamento com %1$s e iniciará uma sessão quando o host voltar. + %1$s não reconhece mais este dispositivo + O host removeu o pareamento. Pareie de novo para iniciar uma sessão. + %1$s foi redefinido + Este host tem uma identidade nova, então o pareamento antigo não vale mais. Pareie de novo para iniciar uma sessão. + Lendo a lista de apps de %1$s… + Nenhum app neste host + %1$s ainda não tem apps configurados. Adicione um no host, ou adicione o controle e o Dish inicia o que o host listar primeiro. + Não foi possível ler a lista de apps de %1$s + O Dish inicia o que o host listar primeiro. Tente de novo quando %1$s estiver acessível. + %1$s está cheio + Uma sessão comporta no máximo quatro controles, e %1$s já tem quatro. Desvincule um para abrir espaço. + Outro dispositivo está usando %1$s + %1$s está executando um app para outro dispositivo e não vai entregar essa sessão. Feche-a para iniciar uma nova, ou adicione o controle e tente mais tarde. + Não foi possível voltar à sessão em %1$s + O host tem uma sessão, mas não a devolve. Feche o app em %1$s e inicie uma nova. + %1$s recusou a sessão: %2$s + Adicione o controle mesmo assim e o Dish tentará de novo na próxima vez que você o usar. + Não foi possível concluir a sessão em %1$s + O app iniciou, mas a transmissão não subiu, então o Dish o fechou de novo. + A sessão em %1$s terminou + O link caiu. O Dish entrará de novo na próxima vez que você usar este controle. + %1$s encerrou a sessão + O app foi fechado no host. Inicie uma nova sessão para continuar usando este controle. + Parear agora + Parear de novo + Novo código + Cancelar + Tentar de novo + Repetir + Fechar o app em %1$s + Encerrar sessão + Reconectar + Iniciar uma sessão + Ver controles em %1$s + Sessão do Moonlight + Mantém uma sessão do Moonlight ativa enquanto houver um controle vinculado a ela. + Dish · Moonlight + + %1$d controle em %2$s + %1$d controles em %2$s + %1$d controles em %2$s + + Iniciando uma sessão em %1$s… + Iniciando uma sessão… + Hosts do Moonlight + Host do Moonlight · %1$s + Nenhum host do Moonlight encontrado + Um PC aparece aqui assim que o Sunshine, o Apollo ou o Wolf estiver rodando nele e as duas máquinas estiverem na mesma rede. Você também pode adicionar um por endereço. Pronto para parear. Procure este dispositivo no seu host Adquirindo perfil HID… Inativo diff --git a/app/src/main/res/values-pt-rBR/strings_setup.xml b/app/src/main/res/values-pt-rBR/strings_setup.xml index 9d0b0951..b0055af0 100644 --- a/app/src/main/res/values-pt-rBR/strings_setup.xml +++ b/app/src/main/res/values-pt-rBR/strings_setup.xml @@ -60,6 +60,7 @@ Satellite por Wi-Fi Menor latência, recursos completos. Precisa do app gratuito para PC. Melhor + Transmita para um PC com Sunshine, Apollo ou Wolf. O Dish conecta um controle na sessão. Host Bluetooth O celular pareia com o PC como um controle. Sem app no PC. Escolha seu PC @@ -113,6 +114,8 @@ Como o PC deve vê-lo? Escolha o controle que o PC deve reportar. Cada um libera extras diferentes. Por Bluetooth o tipo é fixo. Aqui está o que ele carrega. + O que roda no host? + Todos os controles vinculados a este host compartilham uma sessão. Como ele deve se comportar? Só são mostrados os extras que sua entrada e seu destino suportam juntos. Inclinação e mira por giroscópio no PC. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 13a7f342..ddf4ed9a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -172,19 +172,104 @@ MOONLIGHT HOSTS No Moonlight hosts yet. Scan your network or add one by address. Pair with %1$s - On your host, open the Moonlight/Sunshine page and enter this PIN: - Waiting for the host to accept the PIN… - Emulate controller - Auto - Nintendo - Choose an app - No apps are available on this host. - An app is already running - %1$s is already running an app and will not hand that session over. Close it to start a new one. - Close it Add Moonlight host Host IP or name Enter the host address + + + How should the host see it? + Dish asks %1$s to plug in this controller. Some hosts override the choice. + Auto + Xbox + PlayStation + Nintendo + Picked for you + Auto sends %1$s for this controller. + + Session + New session + This is the first controller on %1$s, so it picks what the host runs. + Without a pick, Dish starts whatever %1$s lists first. + Joining %1$s + Joining the session on %1$s + %1$s is already running a session for this device. This controller joins it as controller %2$d. + Streaming to %1$s + %1$s · controller %2$d of 4 + + Paired + Remembered + Not paired + In use by %1$s + + %1$d controller + %1$d controllers + + Checking %1$s… + Not paired yet + %1$s needs a one time PIN before Dish can start a session. Pair now, or add the controller and pair later. + Type %1$s into the Moonlight or Sunshine page on %2$s. + Waiting for the host to accept the PIN… + %1$s did not accept the PIN + Check that the code went into the right host, then try again. + %1$s is not answering + Check that the host is switched on and on this network, then try again. + Dish remembers the pairing with %1$s and will start a session when the host is back. + %1$s no longer recognises this device + The host removed the pairing. Pair again to start a session. + %1$s was reset + This host has a new identity, so the old pairing no longer works. Pair again to start a session. + + Reading the app list from %1$s… + No apps on this host + %1$s has no apps set up yet. Add one on the host, or add the controller and Dish will start whatever the host lists first. + Could not read the app list from %1$s + Dish will start whatever the host lists first. Retry once %1$s is reachable. + + %1$s is full + A session carries four controllers at most, and %1$s already has four. Unbind one to make room. + Another device is using %1$s + %1$s is running an app for a different device and will not hand that session over. Close it to start a new one, or add the controller and try again later. + Could not rejoin the session on %1$s + The host has a session but would not hand it back. Close the app on %1$s and start a new one. + %1$s refused the session: %2$s + Add the controller anyway and Dish will try again the next time you use it. + Could not finish the session on %1$s + The app started but the stream did not come up, so Dish closed it again. + Session on %1$s ended + The link dropped. Dish will rejoin the next time you use this controller. + %1$s ended the session + The app closed on the host. Start a new session to keep using this controller. + + Pair now + Pair again + New code + Cancel + Try again + Retry + Close the app on %1$s + Quit session + Reconnect + Start a session + See controllers on %1$s + + Moonlight session + Keeps a Moonlight session alive while a controller is bound to it. + Dish · Moonlight + + %1$d controller on %2$s + %1$d controllers on %2$s + + Starting a session on %1$s… + Starting a session… + + Moonlight hosts + Moonlight host · %1$s + No Moonlight hosts found + A PC appears here once Sunshine, Apollo or Wolf is running on it and both machines are on the same network. You can also add one by address. + Ready to pair. Find this device on your host Acquiring HID profile… Idle diff --git a/app/src/main/res/values/strings_setup.xml b/app/src/main/res/values/strings_setup.xml index c4e141bd..e34e0280 100644 --- a/app/src/main/res/values/strings_setup.xml +++ b/app/src/main/res/values/strings_setup.xml @@ -66,6 +66,7 @@ Satellite over Wi-Fi Lowest latency, full features. Needs the free PC app. Best + Stream to a PC running Sunshine, Apollo or Wolf. Dish plugs a controller into the session. Bluetooth host Phone pairs to the PC as a pad. No PC app. Pick your PC @@ -122,6 +123,8 @@ How should the PC see it? Pick the controller the PC should report. Each one unlocks different extras. Over Bluetooth the type is fixed. Here is what it carries. + What runs on the host? + Every controller bound to this host shares one session. How should it feel? Only the extras your input and destination both support are shown. Tilt and gyro aiming on the PC. diff --git a/app/src/test/java/com/tinkernorth/dish/composer/MoonlightCatalogTest.kt b/app/src/test/java/com/tinkernorth/dish/composer/MoonlightCatalogTest.kt new file mode 100644 index 00000000..459b543c --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/composer/MoonlightCatalogTest.kt @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.composer + +import com.tinkernorth.dish.core.model.CapabilitySet +import com.tinkernorth.dish.core.model.Feature +import com.tinkernorth.dish.core.net.moonlight.MoonlightControlProtocol +import com.tinkernorth.dish.core.net.moonlight.MoonlightEmulatedType +import com.tinkernorth.dish.core.net.moonlight.MoonlightInputEncoder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +// The hard-coded capability table. No Moonlight host reports what its emulated pads +// can do, so this is client-side knowledge derived from what the reference host +// actually builds per type, and the type cards render straight off it. +class MoonlightCatalogTest { + private val everything = + CapabilitySet.of( + Feature.GAMEPAD, + Feature.ANALOG_TRIGGERS, + Feature.MOTION, + Feature.TOUCHPAD, + Feature.RUMBLE, + Feature.LIGHTBAR, + ) + + @Test + fun `PlayStation is the only type with motion, touchpad and a lightbar`() { + val ps = MoonlightCatalog.typeCapabilities(MoonlightEmulatedType.PLAYSTATION) + assertTrue(Feature.RUMBLE in ps) + assertTrue(Feature.MOTION in ps) + assertTrue(Feature.TOUCHPAD in ps) + assertTrue(Feature.LIGHTBAR in ps) + } + + @Test + fun `Xbox carries rumble and nothing else beyond a pad`() { + val xbox = MoonlightCatalog.typeCapabilities(MoonlightEmulatedType.XBOX) + assertTrue(Feature.GAMEPAD in xbox) + assertTrue(Feature.ANALOG_TRIGGERS in xbox) + assertTrue(Feature.RUMBLE in xbox) + assertFalse(Feature.MOTION in xbox) + assertFalse(Feature.TOUCHPAD in xbox) + } + + // Not a copy of the satellite switchpro row: the reference host only routes motion + // into a PlayStation pad, so a Nintendo type over Moonlight has no gyro at all. + @Test + fun `Nintendo has no motion over Moonlight, unlike the satellite switchpro type`() { + val nintendo = MoonlightCatalog.typeCapabilities(MoonlightEmulatedType.NINTENDO) + assertTrue(Feature.RUMBLE in nintendo) + assertFalse(Feature.MOTION in nintendo) + assertFalse(Feature.TOUCHPAD in nintendo) + assertEquals( + MoonlightCatalog.typeCapabilities(MoonlightEmulatedType.XBOX), + nintendo, + ) + } + + @Test + fun `the host layer crosses nothing out, because no host reports its capabilities`() { + listOf(Feature.GAMEPAD, Feature.ANALOG_TRIGGERS, Feature.MOTION, Feature.TOUCHPAD, Feature.RUMBLE, Feature.LIGHTBAR) + .forEach { assertTrue(it.name, it in MoonlightCatalog.HOST_LAYER) } + } + + @Test + fun `the host layer does not claim the satellites mouse or keyboard injection`() { + assertFalse(Feature.MOUSE in MoonlightCatalog.HOST_LAYER) + assertFalse(Feature.KEYBOARD in MoonlightCatalog.HOST_LAYER) + } + + @Test + fun `source bits never claim a battery this pad does not report`() { + assertEquals(0, MoonlightCatalog.sourceBits(everything) and MoonlightControlProtocol.CAP_BATTERY) + } + + @Test + fun `a fully capable source declares 0x03 for Xbox and Nintendo and the rest for PlayStation`() { + assertEquals(0x03, MoonlightCatalog.capabilityBits(MoonlightEmulatedType.XBOX, everything)) + assertEquals(0x03, MoonlightCatalog.capabilityBits(MoonlightEmulatedType.NINTENDO, everything)) + assertEquals( + MoonlightControlProtocol.CAP_ANALOG_TRIGGERS or + MoonlightControlProtocol.CAP_RUMBLE or + MoonlightControlProtocol.CAP_TRIGGER_RUMBLE or + MoonlightControlProtocol.CAP_TOUCHPAD or + MoonlightControlProtocol.CAP_ACCELEROMETER or + MoonlightControlProtocol.CAP_GYRO or + MoonlightControlProtocol.CAP_RGB_LED, + MoonlightCatalog.capabilityBits(MoonlightEmulatedType.PLAYSTATION, everything), + ) + } + + @Test + fun `a source without motion does not let a PlayStation type ask for gyro reports`() { + val noMotion = CapabilitySet.of(Feature.GAMEPAD, Feature.ANALOG_TRIGGERS, Feature.RUMBLE) + val bits = MoonlightCatalog.capabilityBits(MoonlightEmulatedType.PLAYSTATION, noMotion) + assertEquals(0, bits and MoonlightControlProtocol.CAP_GYRO) + assertEquals(0, bits and MoonlightControlProtocol.CAP_ACCELEROMETER) + assertEquals(0, bits and MoonlightControlProtocol.CAP_TOUCHPAD) + } + + // The whole chain, byte for byte: catalog -> declared bits -> the packet the host reads + // out of its naturally aligned struct. A live Sunshine host logs these as + // `capabilities [0003] supportedButtonFlags [0000FFFF]` for the Xbox case. + @Test + fun `each type produces its own byte-exact CONTROLLER_ARRIVAL`() { + assertArrival(MoonlightEmulatedType.XBOX, expectedCaps = 0x03, expectedButtons = 0xFFFF) + assertArrival(MoonlightEmulatedType.NINTENDO, expectedCaps = 0x03, expectedButtons = 0xFFFF) + assertArrival( + MoonlightEmulatedType.PLAYSTATION, + expectedCaps = 0xBF, + expectedButtons = 0xFFFF or MoonlightControlProtocol.BTN_TOUCHPAD, + ) + } + + private fun assertArrival( + type: Int, + expectedCaps: Int, + expectedButtons: Int, + ) { + val caps = MoonlightCatalog.capabilityBits(type, everything) + val buttons = MoonlightEmulatedType.supportedButtons(caps) + assertEquals("capabilities for type $type", expectedCaps, caps) + assertEquals("buttons for type $type", expectedButtons, buttons) + + val bytes = + MoonlightInputEncoder.controllerArrival( + controllerNumber = 0, + controllerType = type, + capabilities = caps, + supportedButtons = buttons, + ) + assertEquals(MoonlightInputEncoder.CONTROLLER_ARRIVAL_LEN, bytes.size) + val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + buf.position(8) + assertEquals(MoonlightControlProtocol.INPUT_CONTROLLER_ARRIVAL, buf.int) + assertEquals(0, buf.get().toInt()) + assertEquals(type, buf.get().toInt() and 0xFF) + assertEquals(expectedCaps, buf.get().toInt() and 0xFF) + assertEquals(0, buf.get().toInt()) + assertEquals(expectedButtons, buf.int) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/composer/MoonlightSessionControllerTest.kt b/app/src/test/java/com/tinkernorth/dish/composer/MoonlightSessionControllerTest.kt new file mode 100644 index 00000000..ec220cf6 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/composer/MoonlightSessionControllerTest.kt @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.composer + +import android.content.Context +import androidx.lifecycle.LifecycleOwner +import com.tinkernorth.dish.core.model.CapabilitySet +import com.tinkernorth.dish.core.model.Feature +import com.tinkernorth.dish.core.model.SlotCapabilities +import com.tinkernorth.dish.core.net.moonlight.MoonlightEmulatedType +import com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionManager +import com.tinkernorth.dish.source.connection.moonlight.MoonlightPadRequest +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +// Bindings in, sessions out: which pads each Moonlight host is asked to carry, and the +// foreground service that keeps the process able to hold them up with the screen off. +@OptIn(ExperimentalCoroutinesApi::class) +class MoonlightSessionControllerTest { + private val dispatcher = StandardTestDispatcher() + private val bindings = MutableStateFlow>(emptyMap()) + private val connections = MutableStateFlow>(emptyList()) + private val satTypes = MutableStateFlow, Int>>(emptyMap()) + + private lateinit var context: Context + private lateinit var hub: ConnectionCoordinator + private lateinit var moonlight: MoonlightConnectionManager + private lateinit var capabilities: CapabilityComposer + private lateinit var owner: LifecycleOwner + + private val padCaps = + SlotCapabilities( + controller = CapabilitySet.of(Feature.GAMEPAD, Feature.ANALOG_TRIGGERS, Feature.RUMBLE), + transport = CapabilitySet.of(Feature.GAMEPAD, Feature.ANALOG_TRIGGERS, Feature.RUMBLE), + type = CapabilitySet.of(Feature.GAMEPAD, Feature.ANALOG_TRIGGERS, Feature.RUMBLE), + host = CapabilitySet.of(Feature.GAMEPAD, Feature.ANALOG_TRIGGERS, Feature.RUMBLE), + userEnabled = CapabilitySet.of(Feature.GAMEPAD, Feature.ANALOG_TRIGGERS, Feature.RUMBLE), + runtimeDown = CapabilitySet.EMPTY, + ) + + private val motionCaps = + padCaps.copy( + controller = CapabilitySet.of(Feature.GAMEPAD, Feature.ANALOG_TRIGGERS, Feature.RUMBLE, Feature.MOTION), + ) + + private fun summary( + id: String, + kind: ConnectionKind = ConnectionKind.MOONLIGHT, + ) = ConnectionSummary(id = id, kind = kind, label = id, detail = "", live = LinkState.Saved, boundSlotIds = emptyList()) + + private fun controller() = + MoonlightSessionController( + context = context, + hub = hub, + moonlight = moonlight, + capabilities = capabilities, + scope = TestScope(dispatcher), + ) + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + context = mockk(relaxed = true) + hub = mockk(relaxed = true) + moonlight = mockk(relaxed = true) + capabilities = mockk(relaxed = true) + owner = mockk(relaxed = true) + every { hub.bindings } returns bindings + every { hub.connections } returns connections + every { hub.satTypes } returns satTypes + every { capabilities.capabilityForCandidate(any(), any(), any(), any()) } returns padCaps + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `only Moonlight bindings become desired pads`() = + runTest(dispatcher) { + connections.value = listOf(summary("moonlight:pc"), summary("sat:a", ConnectionKind.SATELLITE)) + bindings.value = mapOf("1" to "moonlight:pc", "2" to "sat:a") + val desired = slot>>() + + controller().onStart(owner) + dispatcher.scheduler.advanceUntilIdle() + + verify { moonlight.applyDesired(capture(desired)) } + assertEquals(setOf("moonlight:pc"), desired.captured.keys) + assertEquals(listOf("1"), desired.captured.getValue("moonlight:pc").map { it.slotId }) + } + + @Test + fun `every binding on a host is one entry in that hosts pad list`() = + runTest(dispatcher) { + connections.value = listOf(summary("moonlight:pc")) + bindings.value = mapOf("1" to "moonlight:pc", "2" to "moonlight:pc") + val desired = slot>>() + + controller().onStart(owner) + dispatcher.scheduler.advanceUntilIdle() + + verify { moonlight.applyDesired(capture(desired)) } + assertEquals(setOf("1", "2"), desired.captured.getValue("moonlight:pc").mapTo(mutableSetOf()) { it.slotId }) + } + + @Test + fun `a binding with no stored type asks for Auto, resolved client-side before the wire`() = + runTest(dispatcher) { + connections.value = listOf(summary("moonlight:pc")) + bindings.value = mapOf("1" to "moonlight:pc") + val desired = slot>>() + + controller().onStart(owner) + dispatcher.scheduler.advanceUntilIdle() + + verify { moonlight.applyDesired(capture(desired)) } + val pad = desired.captured.getValue("moonlight:pc").single() + assertEquals(MoonlightEmulatedType.XBOX, pad.emulatedType) + assertEquals(0x03, pad.capabilities) + assertEquals(0xFFFF, pad.supportedButtons) + } + + @Test + fun `Auto becomes PlayStation when the bound input reports motion`() = + runTest(dispatcher) { + every { capabilities.capabilityForCandidate(any(), any(), any(), any()) } returns motionCaps + connections.value = listOf(summary("moonlight:pc")) + bindings.value = mapOf("1" to "moonlight:pc") + val desired = slot>>() + + controller().onStart(owner) + dispatcher.scheduler.advanceUntilIdle() + + verify { moonlight.applyDesired(capture(desired)) } + assertEquals( + MoonlightEmulatedType.PLAYSTATION, + desired.captured + .getValue("moonlight:pc") + .single() + .emulatedType, + ) + } + + @Test + fun `a stored 0 from an older build is read back as Auto, not as unknown`() = + runTest(dispatcher) { + connections.value = listOf(summary("moonlight:pc")) + bindings.value = mapOf("1" to "moonlight:pc") + satTypes.value = mapOf(("moonlight:pc" to "1") to 0) + val desired = slot>>() + + controller().onStart(owner) + dispatcher.scheduler.advanceUntilIdle() + + verify { moonlight.applyDesired(capture(desired)) } + assertEquals( + MoonlightEmulatedType.XBOX, + desired.captured + .getValue("moonlight:pc") + .single() + .emulatedType, + ) + } + + @Test + fun `an explicit Nintendo pick reaches the wire as Nintendo`() = + runTest(dispatcher) { + connections.value = listOf(summary("moonlight:pc")) + bindings.value = mapOf("1" to "moonlight:pc") + satTypes.value = mapOf(("moonlight:pc" to "1") to MoonlightEmulatedType.NINTENDO) + val desired = slot>>() + + controller().onStart(owner) + dispatcher.scheduler.advanceUntilIdle() + + verify { moonlight.applyDesired(capture(desired)) } + assertEquals( + MoonlightEmulatedType.NINTENDO, + desired.captured + .getValue("moonlight:pc") + .single() + .emulatedType, + ) + } + + @Test + fun `the first binding on a host starts the foreground service`() = + runTest(dispatcher) { + connections.value = listOf(summary("moonlight:pc")) + bindings.value = mapOf("1" to "moonlight:pc") + + controller().onStart(owner) + dispatcher.scheduler.advanceUntilIdle() + + verify(exactly = 1) { context.startService(any()) } + } + + @Test + fun `a second binding on the same host does not start a second service`() = + runTest(dispatcher) { + connections.value = listOf(summary("moonlight:pc")) + bindings.value = mapOf("1" to "moonlight:pc") + val controller = controller() + controller.onStart(owner) + dispatcher.scheduler.advanceUntilIdle() + + bindings.value = mapOf("1" to "moonlight:pc", "2" to "moonlight:pc") + dispatcher.scheduler.advanceUntilIdle() + + verify(exactly = 1) { context.startService(any()) } + verify(exactly = 0) { context.stopService(any()) } + } + + @Test + fun `the last unbind stops the foreground service`() = + runTest(dispatcher) { + connections.value = listOf(summary("moonlight:pc")) + bindings.value = mapOf("1" to "moonlight:pc") + val controller = controller() + controller.onStart(owner) + dispatcher.scheduler.advanceUntilIdle() + + bindings.value = emptyMap() + dispatcher.scheduler.advanceUntilIdle() + + verify(exactly = 1) { context.stopService(any()) } + } + + @Test + fun `no Moonlight binding means no service at all`() = + runTest(dispatcher) { + connections.value = listOf(summary("sat:a", ConnectionKind.SATELLITE)) + bindings.value = mapOf("1" to "sat:a") + + controller().onStart(owner) + dispatcher.scheduler.advanceUntilIdle() + + verify(exactly = 0) { context.startService(any()) } + verify(exactly = 0) { context.startForegroundService(any()) } + } + + @Test + fun `the service goes up before the session is converged and down after it`() = + runTest(dispatcher) { + connections.value = listOf(summary("moonlight:pc")) + bindings.value = mapOf("1" to "moonlight:pc") + val controller = controller() + controller.onStart(owner) + dispatcher.scheduler.advanceUntilIdle() + + bindings.value = emptyMap() + dispatcher.scheduler.advanceUntilIdle() + + verify { + context.startService(any()) + moonlight.applyDesired(match { pads -> pads.values.any { it.isNotEmpty() } }) + moonlight.applyDesired(match { pads -> pads.values.none { it.isNotEmpty() } }) + context.stopService(any()) + } + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightEmulatedTypeTest.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightEmulatedTypeTest.kt new file mode 100644 index 00000000..b36bc346 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightEmulatedTypeTest.kt @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +// The client-side half of CONTROLLER_ARRIVAL: which type Auto becomes, what each type +// is allowed to declare, and the 0xFF sentinel that keeps Auto out of the wire values. +class MoonlightEmulatedTypeTest { + @Test + fun `Auto is 0xFF and never the wire value for unknown`() { + assertEquals(0xFF, MoonlightEmulatedType.AUTO) + assertNotEquals(MoonlightControlProtocol.CONTROLLER_TYPE_UNKNOWN, MoonlightEmulatedType.AUTO) + assertEquals(0x01, MoonlightEmulatedType.XBOX) + assertEquals(0x02, MoonlightEmulatedType.PLAYSTATION) + assertEquals(0x03, MoonlightEmulatedType.NINTENDO) + } + + @Test + fun `a previously persisted 0 migrates back to Auto on read`() { + assertEquals(MoonlightEmulatedType.AUTO, MoonlightEmulatedType.fromStored(0)) + assertEquals(MoonlightEmulatedType.AUTO, MoonlightEmulatedType.fromStored(MoonlightEmulatedType.AUTO)) + assertEquals(MoonlightEmulatedType.XBOX, MoonlightEmulatedType.fromStored(MoonlightEmulatedType.XBOX)) + assertEquals(MoonlightEmulatedType.PLAYSTATION, MoonlightEmulatedType.fromStored(MoonlightEmulatedType.PLAYSTATION)) + assertEquals(MoonlightEmulatedType.NINTENDO, MoonlightEmulatedType.fromStored(MoonlightEmulatedType.NINTENDO)) + } + + @Test + fun `Auto resolves to PlayStation with motion and Xbox without`() { + assertEquals( + MoonlightEmulatedType.PLAYSTATION, + MoonlightEmulatedType.resolve(MoonlightEmulatedType.AUTO, sourceHasMotion = true), + ) + assertEquals( + MoonlightEmulatedType.XBOX, + MoonlightEmulatedType.resolve(MoonlightEmulatedType.AUTO, sourceHasMotion = false), + ) + } + + @Test + fun `an explicit pick is never re-resolved, motion or not`() { + listOf(MoonlightEmulatedType.XBOX, MoonlightEmulatedType.PLAYSTATION, MoonlightEmulatedType.NINTENDO) + .forEach { picked -> + assertEquals(picked, MoonlightEmulatedType.resolve(picked, sourceHasMotion = true)) + assertEquals(picked, MoonlightEmulatedType.resolve(picked, sourceHasMotion = false)) + } + } + + @Test + fun `only PlayStation may declare more than analog triggers and rumble`() { + assertEquals(0x03, MoonlightEmulatedType.typeMaximum(MoonlightEmulatedType.XBOX)) + assertEquals(0xFF, MoonlightEmulatedType.typeMaximum(MoonlightEmulatedType.PLAYSTATION)) + assertEquals(0x03, MoonlightEmulatedType.typeMaximum(MoonlightEmulatedType.NINTENDO)) + } + + @Test + fun `the declared bits are the type maximum intersected with what the source can deliver`() { + val everything = 0xFF + assertEquals(0x03, MoonlightEmulatedType.capabilityBits(MoonlightEmulatedType.XBOX, everything)) + assertEquals(0x03, MoonlightEmulatedType.capabilityBits(MoonlightEmulatedType.NINTENDO, everything)) + assertEquals(0xFF, MoonlightEmulatedType.capabilityBits(MoonlightEmulatedType.PLAYSTATION, everything)) + + // A source with nothing but a gamepad declares nothing, whatever the type allows. + assertEquals(0x00, MoonlightEmulatedType.capabilityBits(MoonlightEmulatedType.PLAYSTATION, 0x00)) + // A rumble-only source on a PlayStation type does not claim the motion it cannot send. + assertEquals( + MoonlightControlProtocol.CAP_RUMBLE, + MoonlightEmulatedType.capabilityBits(MoonlightEmulatedType.PLAYSTATION, MoonlightControlProtocol.CAP_RUMBLE), + ) + } + + @Test + fun `the touchpad click button flag rides on the touchpad capability alone`() { + assertEquals(0xFFFF, MoonlightEmulatedType.supportedButtons(0x03)) + assertEquals( + 0xFFFF or MoonlightControlProtocol.BTN_TOUCHPAD, + MoonlightEmulatedType.supportedButtons(0x03 or MoonlightControlProtocol.CAP_TOUCHPAD), + ) + } + + @Test + fun `the picker order is Auto, Xbox, PlayStation, Nintendo`() { + assertEquals( + listOf( + MoonlightEmulatedType.AUTO, + MoonlightEmulatedType.XBOX, + MoonlightEmulatedType.PLAYSTATION, + MoonlightEmulatedType.NINTENDO, + ), + MoonlightEmulatedType.ORDER, + ) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHostModelsTest.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHostModelsTest.kt index f1191a4b..0ba600e7 100644 --- a/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHostModelsTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHostModelsTest.kt @@ -35,10 +35,13 @@ class MoonlightHostModelsTest { @Test fun `emulated Auto resolves to a concrete arrival type, explicit passes through`() { - assertEquals(MoonlightControlProtocol.CONTROLLER_TYPE_XBOX, MoonlightEmulatedType.resolve(MoonlightEmulatedType.AUTO)) + assertEquals( + MoonlightControlProtocol.CONTROLLER_TYPE_XBOX, + MoonlightEmulatedType.resolve(MoonlightEmulatedType.AUTO, sourceHasMotion = false), + ) assertEquals( MoonlightControlProtocol.CONTROLLER_TYPE_PS, - MoonlightEmulatedType.resolve(MoonlightEmulatedType.PLAYSTATION), + MoonlightEmulatedType.resolve(MoonlightEmulatedType.PLAYSTATION, sourceHasMotion = false), ) assertTrue(MoonlightEmulatedType.AUTO == 0xFF) } diff --git a/app/src/test/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserverTest.kt b/app/src/test/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserverTest.kt index f7ebb153..772b5d32 100644 --- a/app/src/test/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserverTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserverTest.kt @@ -61,7 +61,17 @@ class PhysicalSlotBindingObserverTest { slotInfo: Map = emptyMap(), btConnectedIds: Set = emptySet(), moonlightLiveIds: Set = emptySet(), - ) = reconcileSlots(present, lastBound, bindings, summaries, slotInfo, btConnectedIds, moonlightLiveIds) + moonlightPadNumbers: Map = emptyMap(), + ) = reconcileSlots( + present, + lastBound, + bindings, + summaries, + slotInfo, + btConnectedIds, + moonlightLiveIds, + moonlightPadNumbers, + ) @Test fun `a present device binds to a live Moonlight host`() { @@ -71,8 +81,9 @@ class PhysicalSlotBindingObserverTest { bindings = mapOf("3" to "moonlight:pc"), summaries = listOf(moonlightSummary("moonlight:pc")), moonlightLiveIds = setOf("moonlight:pc"), + moonlightPadNumbers = mapOf("3" to 0), ) - assertEquals(listOf(BindOp.BindMoonlight(deviceId = 3, connectionId = "moonlight:pc")), ops) + assertEquals(listOf(BindOp.BindMoonlight(deviceId = 3, connectionId = "moonlight:pc", controllerNumber = 0)), ops) } @Test @@ -90,12 +101,12 @@ class PhysicalSlotBindingObserverTest { @Test fun `an unchanged Moonlight bind is deduped, a changed one is re-applied`() { - val op = BindOp.BindMoonlight(deviceId = 3, connectionId = "moonlight:pc") + val op = BindOp.BindMoonlight(deviceId = 3, connectionId = "moonlight:pc", controllerNumber = 0) val first = dedupeBindOps(listOf(op), emptyMap()) assertEquals(listOf(op), first.ops) val second = dedupeBindOps(listOf(op), first.applied) assertEquals(emptyList(), second.ops) - val changed = BindOp.BindMoonlight(deviceId = 3, connectionId = "moonlight:other") + val changed = BindOp.BindMoonlight(deviceId = 3, connectionId = "moonlight:other", controllerNumber = 0) val third = dedupeBindOps(listOf(changed), first.applied) assertEquals(listOf(changed), third.ops) } diff --git a/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionPadsTest.kt b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionPadsTest.kt new file mode 100644 index 00000000..028fada8 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionPadsTest.kt @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.source.connection.moonlight + +import com.tinkernorth.dish.core.net.moonlight.MoonlightEmulatedType +import com.tinkernorth.dish.core.net.moonlight.MoonlightHost +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +// The reference count itself: one session per host, up to four pads, each binding +// holding one controller number for as long as it points at the host. +@OptIn(ExperimentalCoroutinesApi::class) +class MoonlightConnectionPadsTest { + private val dispatcher = StandardTestDispatcher() + private val host = MoonlightHost(name = "PC", address = "10.0.0.5", uniqueId = "abc") + + private fun connection() = MoonlightConnection(host.id, host, TestScope(dispatcher), dispatcher) + + private fun MoonlightConnection.take(slotId: String) = + acquirePad( + slotId = slotId, + emulatedType = MoonlightEmulatedType.XBOX, + capabilities = 0x03, + supportedButtons = 0xFFFF, + ) + + @Test + fun `pads take the lowest free controller number in order`() { + val conn = connection() + assertEquals(0, conn.take("a")?.number) + assertEquals(1, conn.take("b")?.number) + assertEquals(2, conn.take("c")?.number) + assertEquals(3, conn.take("d")?.number) + assertEquals(4, conn.padCount) + } + + @Test + fun `a fifth pad is refused because a session carries four`() { + val conn = connection() + listOf("a", "b", "c", "d").forEach { assertNotNull(conn.take(it)) } + assertFalse(conn.hasRoom) + assertNull(conn.take("e")) + assertEquals(4, conn.padCount) + assertEquals(MoonlightConnection.MAX_PADS, conn.padCount) + } + + @Test + fun `a slot that already holds a pad keeps its number instead of taking a second`() { + val conn = connection() + val first = conn.take("a") + assertEquals(first, conn.take("a")) + assertEquals(1, conn.padCount) + } + + @Test + fun `a released number is handed to the next pad, and only then`() { + val conn = connection() + conn.take("a") + conn.take("b") + conn.take("c") + assertEquals(2, conn.releasePad("b")) + assertNull(conn.padFor("b")) + assertEquals(1, conn.take("d")?.number) + } + + @Test + fun `releasing a slot that holds nothing changes nothing`() { + val conn = connection() + conn.take("a") + assertEquals(1, conn.releasePad("nobody")) + assertEquals(1, conn.padCount) + } + + @Test + fun `the active mask carries every bound pad and clears the one that left`() { + val conn = connection() + conn.take("a") + conn.take("b") + conn.take("c") + assertEquals(0b0111, conn.activeMask()) + conn.releasePad("b") + assertEquals(0b0101, conn.activeMask()) + conn.releasePad("a") + conn.releasePad("c") + assertEquals(0, conn.activeMask()) + } + + @Test + fun `a pad carries the type and bits its own binding asked for`() { + val conn = connection() + conn.take("a") + val ps = + conn.acquirePad( + slotId = "b", + emulatedType = MoonlightEmulatedType.PLAYSTATION, + capabilities = 0xBF, + supportedButtons = 0xFFFF or 0x100000, + ) + assertEquals(MoonlightEmulatedType.XBOX, conn.padFor("a")?.emulatedType) + assertEquals(MoonlightEmulatedType.PLAYSTATION, ps?.emulatedType) + assertEquals(0xBF, ps?.capabilities) + assertEquals(0x03, conn.padFor("a")?.capabilities) + } + + @Test + fun `a drop and a host-ended session are distinguishable from a clean idle`() { + val conn = connection() + assertEquals(MoonlightSessionState.Idle, conn.state.value) + conn.markLaunching() + assertEquals(MoonlightSessionState.Launching, conn.state.value) + conn.markDropped() + assertEquals(MoonlightSessionState.Dropped, conn.state.value) + conn.markEnded() + assertEquals(MoonlightSessionState.Ended, conn.state.value) + conn.markDisconnected() + assertEquals(MoonlightSessionState.Idle, conn.state.value) + } + + @Test + fun `tearing the session down leaves the pads their bindings still claim`() { + val conn = connection() + conn.take("a") + conn.take("b") + conn.markDropped() + assertEquals(2, conn.padCount) + assertTrue(conn.hasRoom) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConvergeTest.kt b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConvergeTest.kt new file mode 100644 index 00000000..59514c28 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConvergeTest.kt @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.source.connection.moonlight + +import org.junit.Assert.assertEquals +import org.junit.Test + +// The one rule the reference count reduces to. +class MoonlightConvergeTest { + @Test + fun `the first pad on an idle host opens the stream`() { + assertEquals(MoonlightConverge.OPEN, moonlightConverge(MoonlightSessionState.Idle, wantedPads = 1)) + } + + @Test + fun `a dropped or host-ended session opens a new one rather than joining a dead one`() { + assertEquals(MoonlightConverge.OPEN, moonlightConverge(MoonlightSessionState.Dropped, wantedPads = 1)) + assertEquals(MoonlightConverge.OPEN, moonlightConverge(MoonlightSessionState.Ended, wantedPads = 1)) + } + + @Test + fun `later pads on a live host only announce themselves`() { + (1..4).forEach { wanted -> + assertEquals(MoonlightConverge.ANNOUNCE, moonlightConverge(MoonlightSessionState.Live, wanted)) + } + } + + @Test + fun `a launch already in flight is left alone rather than started twice`() { + assertEquals(MoonlightConverge.WAIT, moonlightConverge(MoonlightSessionState.Launching, wantedPads = 1)) + assertEquals(MoonlightConverge.WAIT, moonlightConverge(MoonlightSessionState.Launching, wantedPads = 4)) + } + + @Test + fun `losing the last pad on a live host closes the app it started`() { + assertEquals(MoonlightConverge.CANCEL, moonlightConverge(MoonlightSessionState.Live, wantedPads = 0)) + } + + @Test + fun `losing the last pad with no session up has nothing to close`() { + listOf( + MoonlightSessionState.Idle, + MoonlightSessionState.Launching, + MoonlightSessionState.Dropped, + MoonlightSessionState.Ended, + ).forEach { state -> + assertEquals(state.name, MoonlightConverge.RELEASE, moonlightConverge(state, wantedPads = 0)) + } + } + + @Test + fun `every session state is answered for every pad count`() { + MoonlightSessionState.entries.forEach { state -> + (0..4).forEach { wanted -> moonlightConverge(state, wanted) } + } + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightSessionRefcountTest.kt b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightSessionRefcountTest.kt new file mode 100644 index 00000000..76c14f4d --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightSessionRefcountTest.kt @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.source.connection.moonlight + +import android.content.Context +import android.content.SharedPreferences +import com.tinkernorth.dish.core.net.moonlight.MoonlightEmulatedType +import com.tinkernorth.dish.core.net.moonlight.MoonlightIdentity +import com.tinkernorth.dish.core.net.moonlight.RememberedMoonlight +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +// The session belongs to the bindings pointing at the host, not to any one of them: +// two bindings mean one launch, and the app is only closed when a session came up. +@OptIn(ExperimentalCoroutinesApi::class) +class MoonlightSessionRefcountTest { + private val dispatcher = StandardTestDispatcher() + private lateinit var gateway: MoonlightHttpGateway + private lateinit var store: com.tinkernorth.dish.repository.RememberedMoonlightRepository + private lateinit var manager: MoonlightConnectionManager + + private val remembered = + RememberedMoonlight( + id = "moonlight:uid:abc", + name = "PC", + address = "10.0.0.5", + uniqueId = "abc", + lastAppId = "1", + lastAppName = "Desktop", + ) + + private val serverInfo = + """PCabc + 10SUNSHINE_SERVER_FREE""" + + private val appList = + """Desktop1""" + + private val refusedLaunch = + """""" + + private fun pad(slotId: String) = + MoonlightPadRequest( + slotId = slotId, + emulatedType = MoonlightEmulatedType.XBOX, + capabilities = 0x03, + supportedButtons = 0xFFFF, + ) + + private fun reply(body: String) = MoonlightHttpGateway.Reply(status = 200, body = body) + + @Before + fun setUp() { + val prefs = mockk(relaxed = true) + every { prefs.getString("uniqueid", null) } returns "0123456789abcdef" + val context = mockk(relaxed = true) + every { context.getSharedPreferences(any(), any()) } returns prefs + + gateway = mockk() + every { gateway.getHttp(match { it.contains("/serverinfo") }, any()) } returns reply(serverInfo) + every { gateway.getHttps(match { it.contains("/serverinfo") }, any()) } returns reply(serverInfo) + every { gateway.getHttps(match { it.contains("/applist") }, any()) } returns reply(appList) + every { gateway.getHttps(match { it.contains("/launch") }, any()) } returns reply(refusedLaunch) + every { gateway.getHttps(match { it.contains("/cancel") }, any()) } returns + reply("""1""") + + store = mockk(relaxed = true) + every { store.get(remembered.id) } returns remembered + every { store.entries } returns MutableStateFlow(listOf(remembered)) + + manager = + MoonlightConnectionManager( + context = context, + scope = TestScope(dispatcher), + ioDispatcher = dispatcher, + discovery = mockk(relaxed = true), + gateway = gateway, + identity = mockk(relaxed = true), + store = store, + ) + } + + @Test + fun `two bindings on one host launch one session, not two`() = + runTest(dispatcher) { + manager.applyDesired(mapOf(remembered.id to listOf(pad("a"), pad("b")))) + dispatcher.scheduler.advanceUntilIdle() + + verify(exactly = 1) { gateway.getHttps(match { it.contains("/launch") }, any()) } + assertEquals(2, manager.get(remembered.id)?.padCount) + assertEquals( + setOf(0, 1), + manager + .get(remembered.id) + ?.pads + ?.value + ?.values + ?.map { it.number } + ?.toSet(), + ) + } + + @Test + fun `a third binding joins the same host without a second launch`() = + runTest(dispatcher) { + manager.applyDesired(mapOf(remembered.id to listOf(pad("a"), pad("b")))) + dispatcher.scheduler.advanceUntilIdle() + manager.applyDesired(mapOf(remembered.id to listOf(pad("a"), pad("b"), pad("c")))) + dispatcher.scheduler.advanceUntilIdle() + + // The launch was refused, so the retry attempt is the same one session being + // reopened for the same host, never one attempt per binding. + verify(exactly = 2) { gateway.getHttps(match { it.contains("/launch") }, any()) } + assertEquals(3, manager.get(remembered.id)?.padCount) + } + + @Test + fun `dropping one of two bindings frees its number and cancels nothing`() = + runTest(dispatcher) { + manager.applyDesired(mapOf(remembered.id to listOf(pad("a"), pad("b")))) + dispatcher.scheduler.advanceUntilIdle() + + manager.applyDesired(mapOf(remembered.id to listOf(pad("a")))) + dispatcher.scheduler.advanceUntilIdle() + + assertEquals(1, manager.get(remembered.id)?.padCount) + verify(exactly = 0) { gateway.getHttps(match { it.contains("/cancel") }, any()) } + } + + @Test + fun `the last unbind drops every pad, and a session that never came up is not cancelled`() = + runTest(dispatcher) { + manager.applyDesired(mapOf(remembered.id to listOf(pad("a"), pad("b")))) + dispatcher.scheduler.advanceUntilIdle() + + manager.applyDesired(emptyMap()) + dispatcher.scheduler.advanceUntilIdle() + + assertEquals(0, manager.get(remembered.id)?.padCount) + assertEquals(MoonlightSessionState.Idle, manager.get(remembered.id)?.state?.value) + assertEquals(emptySet(), manager.sessionHostIds.value) + verify(exactly = 0) { gateway.getHttps(match { it.contains("/cancel") }, any()) } + } + + @Test + fun `the session is re-probed immediately before it is opened`() = + runTest(dispatcher) { + manager.applyDesired(mapOf(remembered.id to listOf(pad("a")))) + dispatcher.scheduler.advanceUntilIdle() + + verify(atLeast = 1) { gateway.getHttp(match { it.contains("/serverinfo") }, any()) } + verify(atLeast = 1) { gateway.getHttps(match { it.contains("/serverinfo") }, any()) } + } + + @Test + fun `a host that answers under a new identity is reported replaced instead of launched`() = + runTest(dispatcher) { + val replaced = + """zzz1 + 0""" + every { gateway.getHttp(match { it.contains("/serverinfo") }, any()) } returns reply(replaced) + + manager.applyDesired(mapOf(remembered.id to listOf(pad("a")))) + dispatcher.scheduler.advanceUntilIdle() + + verify(exactly = 0) { gateway.getHttps(match { it.contains("/launch") }, any()) } + assertEquals(MoonlightSessionState.Idle, manager.get(remembered.id)?.state?.value) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/ui/connections/MoonlightRowsTest.kt b/app/src/test/java/com/tinkernorth/dish/ui/connections/MoonlightRowsTest.kt index c54ab466..c22d6e23 100644 --- a/app/src/test/java/com/tinkernorth/dish/ui/connections/MoonlightRowsTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/ui/connections/MoonlightRowsTest.kt @@ -6,6 +6,7 @@ import com.tinkernorth.dish.composer.ConnectionKind import com.tinkernorth.dish.composer.ConnectionSummary import com.tinkernorth.dish.composer.LinkState import com.tinkernorth.dish.core.net.moonlight.MoonlightHost +import com.tinkernorth.dish.source.connection.moonlight.MoonlightTrustState import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -14,7 +15,9 @@ class MoonlightRowsTest { private fun summary( id: String, kind: ConnectionKind = ConnectionKind.MOONLIGHT, - ) = ConnectionSummary(id = id, kind = kind, label = id, detail = "", live = LinkState.Saved, boundSlotIds = emptyList()) + live: LinkState = LinkState.Saved, + boundSlotIds: List = emptyList(), + ) = ConnectionSummary(id = id, kind = kind, label = id, detail = "", live = live, boundSlotIds = boundSlotIds) @Test fun `known moonlight hosts come first, then discovered hosts not already known`() { @@ -37,4 +40,37 @@ class MoonlightRowsTest { val rows = moonlightRows(listOf(summary("sat:1", ConnectionKind.SATELLITE)), emptyList()) assertTrue(rows.isEmpty()) } + + // The three trust words, and never a liveness light: a live session proves the pairing + // stands, a stored record means it is remembered but unverified, anything else is not paired. + @Test + fun `a live session proves the pairing, a stored record only remembers it`() { + val live = summary("moonlight:uid:a", live = LinkState.Connected) + assertEquals(MoonlightTrustState.PAIRED, moonlightTrustFor(live, remembered = false)) + assertEquals( + MoonlightTrustState.PAIRED, + moonlightTrustFor(summary("moonlight:uid:a", live = LinkState.Unstable), remembered = true), + ) + assertEquals(MoonlightTrustState.REMEMBERED, moonlightTrustFor(summary("moonlight:uid:a"), remembered = true)) + assertEquals(MoonlightTrustState.NOT_PAIRED, moonlightTrustFor(summary("moonlight:uid:a"), remembered = false)) + } + + @Test + fun `a known row carries its trust word and the controllers bound to it`() { + val rows = + moonlightRows( + conns = listOf(summary("moonlight:uid:a", live = LinkState.Connected, boundSlotIds = listOf("1", "2"))), + discovered = emptyList(), + rememberedIds = setOf("moonlight:uid:a"), + ) + val known = rows.single() as MoonlightRow.Known + assertEquals(MoonlightTrustState.PAIRED, known.trust) + assertEquals(2, known.controllerCount) + } + + @Test + fun `a discovered host is never claimed as remembered`() { + val rows = moonlightRows(emptyList(), listOf(MoonlightHost(name = "B", address = "10.0.0.2", uniqueId = "b"))) + assertTrue(rows.single() is MoonlightRow.Discovered) + } } diff --git a/app/src/test/java/com/tinkernorth/dish/ui/main/ConfigUiStateMoonlightTest.kt b/app/src/test/java/com/tinkernorth/dish/ui/main/ConfigUiStateMoonlightTest.kt new file mode 100644 index 00000000..618bb02b --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/ui/main/ConfigUiStateMoonlightTest.kt @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.ui.main + +import com.tinkernorth.dish.composer.CONTROLLER_TYPE_XBOX +import com.tinkernorth.dish.composer.ConnectionKind +import com.tinkernorth.dish.composer.ConnectionSummary +import com.tinkernorth.dish.composer.LinkState +import com.tinkernorth.dish.core.net.moonlight.MoonlightEmulatedType +import com.tinkernorth.dish.source.connection.moonlight.MoonlightTrustState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +// A binding is a durable intent: nothing a Moonlight host says about itself keeps the +// user from saving one, and the type list is answered locally rather than fetched. +class ConfigUiStateMoonlightTest { + private val hostId = "moonlight:uid:abc" + + private fun summary( + id: String, + kind: ConnectionKind, + live: LinkState = LinkState.Saved, + ) = ConnectionSummary(id = id, kind = kind, label = "PC", detail = "", live = live, boundSlotIds = emptyList()) + + private fun state( + type: Int? = MoonlightEmulatedType.AUTO, + moonlight: MoonlightSessionInput? = MoonlightSessionInput(), + kind: ConnectionKind = ConnectionKind.MOONLIGHT, + ) = ConfigUiState( + loaded = true, + hosts = listOf(BindingHost(hostId, "PC", kind)), + connections = listOf(summary(hostId, kind)), + draft = + BindingDraft( + hostId = hostId, + type = type, + directOn = false, + motionOn = false, + touchpadMode = "off", + ), + controllerPresent = true, + moonlight = moonlight, + ) + + @Test + fun `a Moonlight destination is recognised as one`() { + assertTrue(state().isMoonlightHost) + assertFalse(state().isBluetoothHost) + assertFalse(state(kind = ConnectionKind.SATELLITE).isMoonlightHost) + } + + // The reported symptom: the type list never populated for a Moonlight id, so the + // draft carried no type and Apply stayed disabled forever. + @Test + fun `a Moonlight host with a seeded type can be applied`() { + assertTrue(state().canApply) + } + + @Test + fun `Apply survives every Moonlight state except a host already carrying four pads`() { + val reachable = + listOf( + MoonlightSessionInput(trust = MoonlightTrustState.CHECKING), + MoonlightSessionInput(trust = MoonlightTrustState.NOT_PAIRED), + MoonlightSessionInput(trust = MoonlightTrustState.UNREACHABLE), + MoonlightSessionInput(trust = MoonlightTrustState.REMEMBERED), + MoonlightSessionInput(trust = MoonlightTrustState.TRUST_LOST), + MoonlightSessionInput(trust = MoonlightTrustState.REPLACED), + paired(pairing = MoonlightPairingUi.Pin("1234")), + paired(pairing = MoonlightPairingUi.Failed), + paired(apps = MoonlightApps.Loading), + paired(apps = MoonlightApps.Empty), + paired(apps = MoonlightApps.Failed), + paired(failure = MoonlightFailure.BusyOther), + paired(failure = MoonlightFailure.ResumeFailed), + paired(failure = MoonlightFailure.Refused("no")), + paired(failure = MoonlightFailure.SetupFailed), + paired(phase = MoonlightPhase.Joining(2, "Desktop")), + paired(phase = MoonlightPhase.Live(1, "Desktop")), + paired(phase = MoonlightPhase.Dropped), + paired(phase = MoonlightPhase.Ended), + ) + reachable.forEach { input -> + val rendered = state(moonlight = input).moonlightSession + assertTrue("$input rendered $rendered", state(moonlight = input).canApply) + } + val full = paired(failure = MoonlightFailure.HostFull) + assertEquals(MoonlightSessionUi.HostFull, state(moonlight = full).moonlightSession) + assertFalse(state(moonlight = full).canApply) + } + + private fun paired( + pairing: MoonlightPairingUi? = null, + apps: MoonlightApps = MoonlightApps.Ready(listOf(MoonlightAppUi("1", "Desktop"))), + phase: MoonlightPhase = MoonlightPhase.Idle, + failure: MoonlightFailure? = null, + ) = MoonlightSessionInput( + trust = MoonlightTrustState.PAIRED, + pairing = pairing, + apps = apps, + phase = phase, + failure = failure, + ) + + @Test + fun `a Moonlight host never blocks the screen, because there is no live link to lose`() { + assertNull(state().blocker) + assertNull(state(moonlight = MoonlightSessionInput(trust = MoonlightTrustState.UNREACHABLE)).blocker) + } + + @Test + fun `a lost input still blocks a Moonlight binding`() { + assertEquals(BindingBlocker.InputLost, state().copy(controllerPresent = false).blocker) + } + + @Test + fun `the type is still required before Apply, as it is for a satellite`() { + assertFalse(state(type = null).canApply) + } + + @Test + fun `a satellite host still applies on its own rules and renders no session section`() { + val satellite = state(type = CONTROLLER_TYPE_XBOX, kind = ConnectionKind.SATELLITE) + assertTrue(satellite.canApply) + assertNull(satellite.moonlightSession) + } + + @Test + fun `a Moonlight host with nothing probed yet renders the checking state, not nothing`() { + assertEquals(MoonlightSessionUi.Checking, state(moonlight = null).moonlightSession) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/ui/main/ConfigureBindingsDefaultTypeTest.kt b/app/src/test/java/com/tinkernorth/dish/ui/main/ConfigureBindingsDefaultTypeTest.kt index e23aa186..f8ed7c29 100644 --- a/app/src/test/java/com/tinkernorth/dish/ui/main/ConfigureBindingsDefaultTypeTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/ui/main/ConfigureBindingsDefaultTypeTest.kt @@ -21,6 +21,7 @@ import com.tinkernorth.dish.repository.SatelliteCatalogRepository import com.tinkernorth.dish.repository.TouchpadModeValue import com.tinkernorth.dish.source.connection.SatelliteConnection import com.tinkernorth.dish.source.connection.SatelliteConnectionManager +import com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionManager import com.tinkernorth.dish.source.store.MotionEnabledStore import com.tinkernorth.dish.source.store.RumbleEnabledStore import com.tinkernorth.dish.source.store.TouchpadModeStore @@ -60,6 +61,7 @@ class ConfigureBindingsDefaultTypeTest { private lateinit var capabilityComposer: CapabilityComposer private lateinit var touchpadModeStore: TouchpadModeStore private lateinit var satellite: SatelliteConnectionManager + private lateinit var moonlight: MoonlightConnectionManager private lateinit var usbGamepadManager: UsbGamepadManager private lateinit var catalogRepo: SatelliteCatalogRepository private lateinit var capabilitiesRepo: SatelliteCapabilitiesRepository @@ -101,6 +103,7 @@ class ConfigureBindingsDefaultTypeTest { touchpadModeStore = mockk(relaxed = true) capabilityComposer = mockk(relaxed = true) satellite = mockk(relaxed = true) + moonlight = mockk(relaxed = true) usbGamepadManager = mockk(relaxed = true) catalogRepo = mockk(relaxed = true) capabilitiesRepo = mockk(relaxed = true) @@ -131,6 +134,7 @@ class ConfigureBindingsDefaultTypeTest { capabilityComposer, touchpadModeStore, satellite, + moonlight, usbGamepadManager, catalogRepo, capabilitiesRepo, diff --git a/app/src/test/java/com/tinkernorth/dish/ui/main/MoonlightSessionUiTest.kt b/app/src/test/java/com/tinkernorth/dish/ui/main/MoonlightSessionUiTest.kt new file mode 100644 index 00000000..7735996c --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/ui/main/MoonlightSessionUiTest.kt @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.ui.main + +import com.tinkernorth.dish.R +import com.tinkernorth.dish.source.connection.moonlight.MoonlightTrustState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +// The whole render contract for the Moonlight session section: one state at a time, +// evaluated top to bottom, with the strings and actions each one owns. Every state +// keeps Apply reachable except the host that already carries its four controllers. +class MoonlightSessionUiTest { + private fun ui( + trust: MoonlightTrustState = MoonlightTrustState.PAIRED, + pairing: MoonlightPairingUi? = null, + apps: MoonlightApps = MoonlightApps.Ready(listOf(MoonlightAppUi("1", "Desktop"))), + phase: MoonlightPhase = MoonlightPhase.Idle, + failure: MoonlightFailure? = null, + selectedAppId: String? = null, + ) = moonlightSessionUi( + MoonlightSessionInput( + trust = trust, + pairing = pairing, + apps = apps, + phase = phase, + failure = failure, + selectedAppId = selectedAppId, + ), + ) + + @Test + fun `M1 a probe in flight with nothing cached is checking`() { + val state = ui(trust = MoonlightTrustState.CHECKING) + assertEquals(MoonlightSessionUi.Checking, state) + assertEquals(0, state.titleRes()) + assertEquals(R.string.ml_state_checking, state.bodyRes()) + assertTrue(state.showsSpinner) + assertEquals(emptyList(), state.actions()) + } + + @Test + fun `M2 an answering host with no stored cert is not paired`() { + val state = ui(trust = MoonlightTrustState.NOT_PAIRED) + assertEquals(R.string.ml_state_unpaired_title, state.titleRes()) + assertEquals(R.string.ml_state_unpaired_body, state.bodyRes()) + assertEquals(listOf(MoonlightAction.PAIR), state.actions()) + } + + @Test + fun `M3 the PIN outranks the trust word that produced it`() { + val state = ui(trust = MoonlightTrustState.NOT_PAIRED, pairing = MoonlightPairingUi.Pin("1234")) + assertEquals(MoonlightSessionUi.PairingPin("1234"), state) + assertEquals(R.string.ml_pair_pin_body, state.bodyRes()) + assertEquals(R.string.ml_pair_waiting, state.noteRes()) + assertEquals(listOf("1234", "PC"), state.bodyArgs("PC")) + assertEquals(listOf(MoonlightAction.NEW_CODE, MoonlightAction.CANCEL), state.actions()) + assertTrue(state.showsSpinner) + } + + @Test + fun `M4 a refused PIN offers another go`() { + val state = ui(trust = MoonlightTrustState.NOT_PAIRED, pairing = MoonlightPairingUi.Failed) + assertEquals(R.string.ml_pair_failed_title, state.titleRes()) + assertEquals(R.string.ml_pair_failed_body, state.bodyRes()) + assertEquals(listOf(MoonlightAction.TRY_AGAIN), state.actions()) + assertEquals(MoonlightTone.ERROR, state.tone()) + } + + @Test + fun `M5 a never-paired host that does not answer is unreachable`() { + val state = ui(trust = MoonlightTrustState.UNREACHABLE) + assertEquals(R.string.ml_state_unreachable_title, state.titleRes()) + assertEquals(R.string.ml_state_unreachable_body, state.bodyRes()) + assertEquals(listOf(MoonlightAction.RETRY), state.actions()) + } + + @Test + fun `M6 a remembered host that does not answer says the pairing still stands`() { + val state = ui(trust = MoonlightTrustState.REMEMBERED) + assertEquals(R.string.ml_state_unreachable_title, state.titleRes()) + assertEquals(R.string.ml_state_remembered_body, state.bodyRes()) + assertEquals(listOf(MoonlightAction.RETRY), state.actions()) + } + + @Test + fun `M7 trust lost asks for a new pairing`() { + val state = ui(trust = MoonlightTrustState.TRUST_LOST) + assertEquals(R.string.ml_state_trust_lost_title, state.titleRes()) + assertEquals(R.string.ml_state_trust_lost_body, state.bodyRes()) + assertEquals(listOf(MoonlightAction.PAIR_AGAIN), state.actions()) + } + + @Test + fun `M8 a replaced host asks for a new pairing and says why`() { + val state = ui(trust = MoonlightTrustState.REPLACED) + assertEquals(R.string.ml_state_replaced_title, state.titleRes()) + assertEquals(R.string.ml_state_replaced_body, state.bodyRes()) + assertEquals(listOf(MoonlightAction.PAIR_AGAIN), state.actions()) + } + + @Test + fun `M9 a paired host with the app list in flight is loading`() { + val state = ui(apps = MoonlightApps.Loading) + assertEquals(MoonlightSessionUi.AppsLoading, state) + assertEquals(0, state.titleRes()) + assertEquals(R.string.ml_apps_loading, state.bodyRes()) + assertTrue(state.showsSpinner) + } + + @Test + fun `M10 a new session offers the app rows and the default note until one is picked`() { + val apps = listOf(MoonlightAppUi("1", "Desktop"), MoonlightAppUi("2", "Steam Big Picture")) + val unpicked = ui(apps = MoonlightApps.Ready(apps)) + assertEquals(MoonlightSessionUi.NewSession(apps, null), unpicked) + assertEquals(R.string.ml_session_new_title, unpicked.titleRes()) + assertEquals(R.string.ml_session_new_body, unpicked.bodyRes()) + assertEquals(R.string.ml_session_default_note, unpicked.noteRes()) + assertEquals(emptyList(), unpicked.actions()) + + val picked = ui(apps = MoonlightApps.Ready(apps), selectedAppId = "2") + assertEquals(0, picked.noteRes()) + } + + @Test + fun `M11 an empty app list is not an error and still offers a retry`() { + assertEquals(MoonlightSessionUi.AppsEmpty, ui(apps = MoonlightApps.Empty)) + val fetchedEmpty = ui(apps = MoonlightApps.Ready(emptyList())) + assertEquals(MoonlightSessionUi.AppsEmpty, fetchedEmpty) + assertEquals(R.string.ml_apps_empty_title, fetchedEmpty.titleRes()) + assertEquals(R.string.ml_apps_empty_body, fetchedEmpty.bodyRes()) + assertEquals(listOf(MoonlightAction.RETRY), fetchedEmpty.actions()) + assertEquals(MoonlightTone.NEUTRAL, fetchedEmpty.tone()) + } + + @Test + fun `M12 an unreadable app list is an error with a retry`() { + val state = ui(apps = MoonlightApps.Failed) + assertEquals(R.string.ml_apps_failed_title, state.titleRes()) + assertEquals(R.string.ml_apps_failed_body, state.bodyRes()) + assertEquals(listOf(MoonlightAction.RETRY), state.actions()) + assertEquals(MoonlightTone.ERROR, state.tone()) + } + + @Test + fun `M13 joining our own session names the app and shows no picker`() { + val state = ui(phase = MoonlightPhase.Joining(controllerNumber = 2, appName = "Steam Big Picture")) + assertEquals(R.string.ml_session_join_title, state.titleRes()) + assertEquals(listOf("Steam Big Picture"), state.titleArgs("PC")) + assertEquals(R.string.ml_session_join_body, state.bodyRes()) + assertEquals(listOf("PC", 2), state.bodyArgs("PC")) + assertEquals(emptyList(), state.actions()) + } + + @Test + fun `M13 an unresolvable app name falls back to the host, still with no picker`() { + val state = ui(phase = MoonlightPhase.Joining(controllerNumber = 1, appName = null)) + assertEquals(R.string.ml_session_join_title_unnamed, state.titleRes()) + assertEquals(listOf("PC"), state.titleArgs("PC")) + assertEquals(emptyList(), state.actions()) + } + + @Test + fun `M14 a full host is the one state that blocks Apply`() { + val state = ui(failure = MoonlightFailure.HostFull) + assertEquals(R.string.ml_full_title, state.titleRes()) + assertEquals(R.string.ml_full_body, state.bodyRes()) + assertEquals(listOf(MoonlightAction.SEE_BINDINGS), state.actions()) + assertTrue(state.blocksApply) + } + + @Test + fun `M15 a session held by another device offers the close and a retry`() { + val state = ui(failure = MoonlightFailure.BusyOther) + assertEquals(R.string.ml_busy_other_title, state.titleRes()) + assertEquals(R.string.ml_busy_other_body, state.bodyRes()) + assertEquals(listOf(MoonlightAction.QUIT_APP, MoonlightAction.RETRY), state.actions()) + } + + @Test + fun `M16 a refused rejoin offers the close and a retry`() { + val state = ui(failure = MoonlightFailure.ResumeFailed) + assertEquals(R.string.ml_resume_failed_title, state.titleRes()) + assertEquals(R.string.ml_resume_failed_body, state.bodyRes()) + assertEquals(listOf(MoonlightAction.QUIT_APP, MoonlightAction.RETRY), state.actions()) + } + + @Test + fun `M17 a refusal quotes the hosts own wording`() { + val state = ui(failure = MoonlightFailure.Refused("Unauthorized")) + assertEquals(R.string.ml_refused_title, state.titleRes()) + assertEquals(listOf("PC", "Unauthorized"), state.titleArgs("PC")) + assertEquals(R.string.ml_refused_body, state.bodyRes()) + assertEquals(listOf(MoonlightAction.RETRY), state.actions()) + } + + @Test + fun `M18 a stream that never came up says the app was closed again`() { + val state = ui(failure = MoonlightFailure.SetupFailed) + assertEquals(R.string.ml_setup_failed_title, state.titleRes()) + assertEquals(R.string.ml_setup_failed_body, state.bodyRes()) + assertEquals(listOf(MoonlightAction.RETRY), state.actions()) + } + + @Test + fun `M19 a live session names the app and the controller number`() { + val state = ui(phase = MoonlightPhase.Live(controllerNumber = 3, appName = "Desktop")) + assertEquals(R.string.ml_session_live_title, state.titleRes()) + assertEquals(listOf("PC"), state.titleArgs("PC")) + assertEquals(R.string.ml_session_live_body, state.bodyRes()) + assertEquals(listOf("Desktop", 3), state.bodyArgs("PC")) + assertEquals(listOf(MoonlightAction.QUIT_APP), state.actions()) + assertEquals(MoonlightTone.SUCCESS, state.tone()) + } + + @Test + fun `M20 a drop is recoverable and offers a reconnect`() { + val state = ui(phase = MoonlightPhase.Dropped) + assertEquals(R.string.ml_dropped_title, state.titleRes()) + assertEquals(R.string.ml_dropped_body, state.bodyRes()) + assertEquals(listOf(MoonlightAction.RECONNECT), state.actions()) + } + + @Test + fun `M21 a host-ended session is not a drop and offers a new session`() { + val state = ui(phase = MoonlightPhase.Ended) + assertEquals(R.string.ml_ended_title, state.titleRes()) + assertEquals(R.string.ml_ended_body, state.bodyRes()) + assertEquals(listOf(MoonlightAction.START_SESSION), state.actions()) + } + + @Test + fun `a failure outranks the live session it interrupted`() { + val state = + ui( + phase = MoonlightPhase.Live(controllerNumber = 1, appName = "Desktop"), + failure = MoonlightFailure.SetupFailed, + ) + assertEquals(MoonlightSessionUi.SetupFailed, state) + } + + @Test + fun `a session of any kind outranks the app list`() { + val joining = ui(phase = MoonlightPhase.Joining(1, "Desktop"), apps = MoonlightApps.Loading) + assertTrue(joining is MoonlightSessionUi.Joining) + val failed = ui(failure = MoonlightFailure.BusyOther, apps = MoonlightApps.Loading) + assertEquals(MoonlightSessionUi.BusyOther, failed) + } + + @Test + fun `only a full host blocks Apply`() { + val everyState = + listOf( + ui(trust = MoonlightTrustState.CHECKING), + ui(trust = MoonlightTrustState.NOT_PAIRED), + ui(trust = MoonlightTrustState.NOT_PAIRED, pairing = MoonlightPairingUi.Pin("1234")), + ui(trust = MoonlightTrustState.NOT_PAIRED, pairing = MoonlightPairingUi.Failed), + ui(trust = MoonlightTrustState.UNREACHABLE), + ui(trust = MoonlightTrustState.REMEMBERED), + ui(trust = MoonlightTrustState.TRUST_LOST), + ui(trust = MoonlightTrustState.REPLACED), + ui(apps = MoonlightApps.Loading), + ui(), + ui(apps = MoonlightApps.Empty), + ui(apps = MoonlightApps.Failed), + ui(phase = MoonlightPhase.Joining(1, "Desktop")), + ui(failure = MoonlightFailure.BusyOther), + ui(failure = MoonlightFailure.ResumeFailed), + ui(failure = MoonlightFailure.Refused("no")), + ui(failure = MoonlightFailure.SetupFailed), + ui(phase = MoonlightPhase.Live(1, "Desktop")), + ui(phase = MoonlightPhase.Dropped), + ui(phase = MoonlightPhase.Ended), + ) + assertEquals(20, everyState.size) + everyState.forEach { assertFalse(it.toString(), it.blocksApply) } + assertTrue(ui(failure = MoonlightFailure.HostFull).blocksApply) + } + + @Test + fun `the host-scoped actions carry the host name and the rest carry nothing`() { + assertEquals(listOf("PC"), MoonlightAction.QUIT_APP.labelArgs("PC")) + assertEquals(listOf("PC"), MoonlightAction.SEE_BINDINGS.labelArgs("PC")) + assertEquals(emptyList(), MoonlightAction.RETRY.labelArgs("PC")) + assertEquals(R.string.ml_action_quit_app, MoonlightAction.QUIT_APP.labelRes()) + } + + @Test + fun `the trust chip says one of three words and never lights up`() { + assertEquals(R.string.ml_trust_paired, MoonlightTrustState.PAIRED.chipTextRes()) + assertEquals(R.string.ml_trust_remembered, MoonlightTrustState.REMEMBERED.chipTextRes()) + assertEquals(R.string.ml_trust_remembered, MoonlightTrustState.UNREACHABLE.chipTextRes()) + assertEquals(R.string.ml_trust_not_paired, MoonlightTrustState.NOT_PAIRED.chipTextRes()) + assertEquals(R.string.ml_trust_not_paired, MoonlightTrustState.TRUST_LOST.chipTextRes()) + assertEquals(R.string.ml_trust_not_paired, MoonlightTrustState.REPLACED.chipTextRes()) + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/ui/main/SlotEdgeStateTest.kt b/app/src/test/java/com/tinkernorth/dish/ui/main/SlotEdgeStateTest.kt new file mode 100644 index 00000000..d9bf578f --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/ui/main/SlotEdgeStateTest.kt @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.ui.main + +import com.tinkernorth.dish.composer.ConnectionKind +import com.tinkernorth.dish.composer.ConnectionSummary +import com.tinkernorth.dish.composer.LinkState +import org.junit.Assert.assertEquals +import org.junit.Test + +// The dashboard's edge banner. A satellite that stops answering is a real loss; a +// Moonlight host has no live link to lose, so it never raises one. +class SlotEdgeStateTest { + private fun summary( + kind: ConnectionKind, + live: LinkState, + ) = ConnectionSummary( + id = "host", + kind = kind, + label = "PC", + detail = "", + live = live, + boundSlotIds = emptyList(), + ) + + private fun slot( + kind: ConnectionKind = ConnectionKind.SATELLITE, + live: LinkState = LinkState.Connected, + bound: Boolean = true, + disconnecting: Boolean = false, + ) = ControllerSlot( + id = "1", + inputType = SlotInputType.VIRTUAL, + name = "Pad", + boundConnectionId = if (bound) "host" else null, + boundStatus = if (bound) summary(kind, live) else null, + isDisconnecting = disconnecting, + ) + + @Test + fun `an unbound slot has no edge`() { + assertEquals(EdgeState.NONE, slotEdgeState(slot(bound = false))) + } + + @Test + fun `a departing input outranks everything`() { + assertEquals(EdgeState.INPUT_LOST, slotEdgeState(slot(disconnecting = true))) + assertEquals( + EdgeState.INPUT_LOST, + slotEdgeState(slot(kind = ConnectionKind.MOONLIGHT, disconnecting = true)), + ) + } + + @Test + fun `a satellite that stopped answering is still reported lost`() { + assertEquals(EdgeState.HOST_LOST, slotEdgeState(slot(live = LinkState.Saved))) + assertEquals(EdgeState.HOST_LOST, slotEdgeState(slot(live = LinkState.Connecting))) + assertEquals(EdgeState.UNSTEADY, slotEdgeState(slot(live = LinkState.Unstable))) + assertEquals(EdgeState.NONE, slotEdgeState(slot(live = LinkState.Connected))) + } + + @Test + fun `a Moonlight host is never lost, in any link state`() { + LinkState.entries.forEach { live -> + assertEquals( + live.name, + EdgeState.NONE, + slotEdgeState(slot(kind = ConnectionKind.MOONLIGHT, live = live)), + ) + } + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/ui/setup/SetupConnectionViewModelTest.kt b/app/src/test/java/com/tinkernorth/dish/ui/setup/SetupConnectionViewModelTest.kt index 1fb3bc3f..ecae3c6c 100644 --- a/app/src/test/java/com/tinkernorth/dish/ui/setup/SetupConnectionViewModelTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/ui/setup/SetupConnectionViewModelTest.kt @@ -7,10 +7,13 @@ import com.tinkernorth.dish.composer.ConnectionKind import com.tinkernorth.dish.composer.ConnectionSummary import com.tinkernorth.dish.composer.LinkState import com.tinkernorth.dish.core.model.DiscoveredServer +import com.tinkernorth.dish.core.net.moonlight.RememberedMoonlight import com.tinkernorth.dish.source.connection.ConnectIntent import com.tinkernorth.dish.source.connection.ConnectionEvent import com.tinkernorth.dish.source.connection.SatelliteConnection import com.tinkernorth.dish.source.connection.SatelliteConnectionManager +import com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionManager +import com.tinkernorth.dish.source.connection.moonlight.MoonlightTrustState import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -34,6 +37,7 @@ import org.junit.Test class SetupConnectionViewModelTest { private val dispatcher = StandardTestDispatcher() private lateinit var satellite: SatelliteConnectionManager + private lateinit var moonlight: MoonlightConnectionManager private lateinit var hub: ConnectionCoordinator private lateinit var vm: SetupConnectionViewModel @@ -42,6 +46,8 @@ class SetupConnectionViewModelTest { private val summaries = MutableStateFlow>(emptyList()) private val stale = MutableStateFlow>(emptySet()) private val scanning = MutableStateFlow(false) + private val moonlightScanning = MutableStateFlow(false) + private val rememberedMoonlight = MutableStateFlow>(emptyList()) private val events = MutableSharedFlow(extraBufferCapacity = 8) private val server = DiscoveredServer(name = "Living Room", ip = "10.0.0.5", machineId = "abc123") @@ -51,6 +57,7 @@ class SetupConnectionViewModelTest { fun setUp() { Dispatchers.setMain(dispatcher) satellite = mockk(relaxed = true) + moonlight = mockk(relaxed = true) hub = mockk(relaxed = true) every { satellite.discoveredServers } returns discovered every { satellite.connections } returns connections @@ -58,7 +65,9 @@ class SetupConnectionViewModelTest { every { satellite.isScanning } returns scanning every { satellite.events } returns events every { hub.connections } returns summaries - vm = SetupConnectionViewModel(satellite, hub) + every { moonlight.remembered } returns rememberedMoonlight + every { moonlight.isScanning } returns moonlightScanning + vm = SetupConnectionViewModel(satellite, moonlight, hub) } @After @@ -74,6 +83,59 @@ class SetupConnectionViewModelTest { assertFalse(vm.state.value.scanning) } + @Test + fun `choosing the Moonlight path lists hosts and starts its own discovery`() = + runTest(dispatcher) { + vm.chooseMoonlight() + dispatcher.scheduler.runCurrent() + assertEquals(SetupConnectionViewModel.Step.MOONLIGHT, vm.state.value.step) + verify { moonlight.startDiscovery() } + } + + // A Moonlight host is picked, not connected: pairing is remembered trust and the + // session belongs to the binding, so the pick hands straight off to configure. + @Test + fun `tapping a Moonlight host hands off to configure without pairing first`() = + runTest(dispatcher) { + summaries.value = listOf(moonlightSummary()) + dispatcher.scheduler.runCurrent() + val seen = collectEvents() + + vm.onMoonlightHostTapped(MOONLIGHT_ID) + dispatcher.scheduler.runCurrent() + + assertEquals(listOf(SetupConnectionViewModel.Event.Connected(MOONLIGHT_ID)), seen) + } + + @Test + fun `the Moonlight list carries the trust word, remembered hosts included`() = + runTest(dispatcher) { + summaries.value = listOf(moonlightSummary()) + dispatcher.scheduler.runCurrent() + assertEquals( + listOf(MoonlightTrustState.NOT_PAIRED), + vm.state.value.moonlightHosts + .map { it.trust }, + ) + + rememberedMoonlight.value = listOf(RememberedMoonlight(id = MOONLIGHT_ID, name = "PC", address = "10.0.0.5")) + dispatcher.scheduler.runCurrent() + assertEquals( + listOf(MoonlightTrustState.REMEMBERED), + vm.state.value.moonlightHosts + .map { it.trust }, + ) + } + + @Test + fun `back from the Moonlight list rewinds to the path pick`() = + runTest(dispatcher) { + vm.chooseMoonlight() + dispatcher.scheduler.runCurrent() + assertTrue(vm.back()) + assertEquals(SetupConnectionViewModel.Step.PATH, vm.state.value.step) + } + @Test fun `choosing satellite advances to the list and starts discovery`() = runTest(dispatcher) { @@ -221,12 +283,26 @@ class SetupConnectionViewModelTest { boundSlotIds = emptyList(), ) + private fun moonlightSummary(link: LinkState = LinkState.Saved) = + ConnectionSummary( + id = MOONLIGHT_ID, + kind = ConnectionKind.MOONLIGHT, + label = "PC", + detail = "", + live = link, + boundSlotIds = emptyList(), + ) + private fun presentHost(link: LinkState) { discovered.value = listOf(server) summaries.value = listOf(summary(link)) dispatcher.scheduler.runCurrent() } + private companion object { + const val MOONLIGHT_ID = "moonlight:uid:a" + } + private fun kotlinx.coroutines.test.TestScope.collectEvents(): List { val out = mutableListOf() backgroundScope.launch { vm.events.collect { out.add(it) } } From 36c00ad4cc2d6f76a8e3b6fe866154b25c200b41 Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Wed, 26 Aug 2026 16:11:18 -0400 Subject: [PATCH 16/20] docs: comment the Moonlight session surface like the code beside it The protocol half of this branch reads like the rest of the repo. The UI and composer half did not: MoonlightSessionUi carried six comment lines in three hundred and MoonlightCatalog one in fifty, against the fourteen to twenty per cent in CapabilityComposer, CapabilityResolver and BundledCatalog sitting next to them. Reasoning that was hard-won over this branch was living only in the commit log. So the file that owns twenty-one states now says why it owns them: why the pairing flow outranks the trust word it supersedes, why PAIRED is the only word that falls through, why the four inputs stay separate axes instead of one enum, why the app picker disappears the moment a session exists, and why HostFull is the one state that blocks Apply when unpaired and unreachable do not. MoonlightCatalog says why its table is hard-coded, why PlayStation is the only type that gets a gyro, and why Nintendo here is not the satellite type of the same name. Two comments move to what they describe. The picker's circularity note, that a Moonlight host can never be live until something binds to it and nothing can bind to it until it is live, sat above EdgeState rather than above connectionsVisibleInPicker; and reconcileSlots had a suppression justification folded into the prose above it as though it were another sentence about the stale-slot sweep. Comments only. No logic, no strings, no signatures. --- .../dish/composer/MoonlightCatalog.kt | 13 ++++ .../dish/core/jni/SatelliteNative.kt | 2 + .../tinkernorth/dish/core/net/NetworkUtils.kt | 3 + .../input/PhysicalSlotBindingObserver.kt | 3 +- .../dish/ui/main/ControllerAdapter.kt | 7 ++- .../dish/ui/main/MoonlightSectionView.kt | 6 ++ .../dish/ui/main/MoonlightSessionUi.kt | 59 ++++++++++++++++++- 7 files changed, 87 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/tinkernorth/dish/composer/MoonlightCatalog.kt b/app/src/main/java/com/tinkernorth/dish/composer/MoonlightCatalog.kt index 8b3c64e1..02da6ec6 100644 --- a/app/src/main/java/com/tinkernorth/dish/composer/MoonlightCatalog.kt +++ b/app/src/main/java/com/tinkernorth/dish/composer/MoonlightCatalog.kt @@ -7,7 +7,12 @@ import com.tinkernorth.dish.core.model.Feature import com.tinkernorth.dish.core.net.moonlight.MoonlightControlProtocol import com.tinkernorth.dish.core.net.moonlight.MoonlightEmulatedType +// The Moonlight side of the capability layering, sibling of [BundledCatalog]. Hard-coded +// because there is nothing to fetch: the capability byte travels client to host inside +// CONTROLLER_ARRIVAL and no host endpoint reports back, so this is a declaration. object MoonlightCatalog { + // A Moonlight host never says what it cannot do, so its host layer crosses nothing out. + // The type ceiling and what the local input can actually feed are what narrow the set. val HOST_LAYER = CapabilitySet.of( Feature.GAMEPAD, @@ -18,6 +23,9 @@ object MoonlightCatalog { Feature.LIGHTBAR, ) + // PlayStation is the only type the host emulator gives a gyro, a touchpad and an LED to, + // which is why Auto reaches for it whenever the source has motion. Nintendo is not the + // satellite's switchpro: over Moonlight it carries no motion, so it sits on the Xbox base. fun typeCapabilities(type: Int): CapabilitySet = when (type) { MoonlightEmulatedType.PLAYSTATION -> @@ -25,6 +33,9 @@ object MoonlightCatalog { else -> padType(Feature.RUMBLE) } + // What the local input can actually feed, in the wire's own bits. Two of ours cover two + // of theirs each: one rumble switch means the pad and the trigger motors, one motion + // switch means the accelerometer and the gyro. fun sourceBits(caps: CapabilitySet): Int { var bits = 0 if (Feature.ANALOG_TRIGGERS in caps) bits = bits or MoonlightControlProtocol.CAP_ANALOG_TRIGGERS @@ -44,6 +55,8 @@ object MoonlightCatalog { caps: CapabilitySet, ): Int = MoonlightEmulatedType.capabilityBits(type, sourceBits(caps)) + // Every emulated pad carries the gamepad axes and analog triggers. Mouse and keyboard are + // deliberately absent: that is the satellite's host-injection surface, with no equivalent here. private fun padType(vararg padFeatures: Feature): CapabilitySet = CapabilitySet(setOf(Feature.GAMEPAD, Feature.ANALOG_TRIGGERS) + padFeatures) } diff --git a/app/src/main/java/com/tinkernorth/dish/core/jni/SatelliteNative.kt b/app/src/main/java/com/tinkernorth/dish/core/jni/SatelliteNative.kt index 02632cfd..4ea800d6 100644 --- a/app/src/main/java/com/tinkernorth/dish/core/jni/SatelliteNative.kt +++ b/app/src/main/java/com/tinkernorth/dish/core/jni/SatelliteNative.kt @@ -120,6 +120,8 @@ object SatelliteNative { connectionId: String, ) + // One Moonlight session carries up to four pads, so unlike the Bluetooth bind the + // binding has to name which of them this device drives. external fun bindPhysicalSlotMoonlight( deviceId: Int, connectionId: String, diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/NetworkUtils.kt b/app/src/main/java/com/tinkernorth/dish/core/net/NetworkUtils.kt index 17d76134..c754aa37 100644 --- a/app/src/main/java/com/tinkernorth/dish/core/net/NetworkUtils.kt +++ b/app/src/main/java/com/tinkernorth/dish/core/net/NetworkUtils.kt @@ -41,6 +41,9 @@ internal fun Char.isHexDigit(): Boolean = this in '0'..'9' || this in 'a'..'f' | private val HEX_DIGITS = "0123456789abcdef".toCharArray() +// The inverse of hexToBytes. Moonlight pairing carries every binary field as hex in the +// query string (salt, certificate, challenge, secret), and lowercase keeps the crypto and +// pairing fixtures directly comparable to what goes out. fun bytesToHex(bytes: ByteArray): String { val out = CharArray(bytes.size * 2) for (i in bytes.indices) { diff --git a/app/src/main/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserver.kt b/app/src/main/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserver.kt index 1dc4703b..ca58223d 100644 --- a/app/src/main/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserver.kt +++ b/app/src/main/java/com/tinkernorth/dish/hotpath/input/PhysicalSlotBindingObserver.kt @@ -75,8 +75,7 @@ data class SatelliteSlotSnapshot( // registry no longer knows: a device that left while the observer was stopped is in neither // `present` nor `lastBound`, and without the sweep its slot would be re-declared to the satellite // on every reconnect forever. Non-numeric slot ids (the on-screen controller) are never swept. -// one flat snapshot argument per connection source, mirrored by the tests, and one branch per kind -@Suppress("LongParameterList", "CyclomaticComplexMethod") +@Suppress("LongParameterList", "CyclomaticComplexMethod") // one flat snapshot per source, one branch per kind fun reconcileSlots( present: Set, lastBound: Set, diff --git a/app/src/main/java/com/tinkernorth/dish/ui/main/ControllerAdapter.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/ControllerAdapter.kt index 8df4a3c3..9c255f91 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/main/ControllerAdapter.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/main/ControllerAdapter.kt @@ -66,9 +66,7 @@ internal fun LinkState.isAvailableForPicker(): Boolean = -> false } -// A Moonlight host is always offered. Its session is started BY the binding, so requiring a -// live link before it can be picked is circular: it can never be live until something binds to -// it, and nothing can bind to it until it is live. +// The badge a bound slot's card can wear; NONE is the quiet default. internal enum class EdgeState { NONE, HOST_LOST, INPUT_LOST, UNSTEADY } // A Moonlight host is never "lost": there is no live link to lose, only remembered trust, @@ -87,6 +85,9 @@ internal fun slotEdgeState(slot: ControllerSlot): EdgeState { } } +// A Moonlight host is always offered. Its session is started BY the binding, so requiring a +// live link before it can be picked is circular: it can never be live until something binds to +// it, and nothing can bind to it until it is live. internal fun connectionsVisibleInPicker( all: List, boundConnectionId: String?, diff --git a/app/src/main/java/com/tinkernorth/dish/ui/main/MoonlightSectionView.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/MoonlightSectionView.kt index 992bc84c..e2238499 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/main/MoonlightSectionView.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/main/MoonlightSectionView.kt @@ -65,12 +65,18 @@ private fun BindingSectionMoonlightBinding.bindApps( } } +// The state chooses its own format arguments, so the view can fill a string without +// knowing which state it is drawing; the spread is the price of that indirection. @Suppress("SpreadOperator") private fun Context.formatted( @StringRes res: Int, args: List, ): String = getString(res, *args.toTypedArray()) +// Rebuilt from scratch on every render rather than toggled, because the number of buttons +// changes with the state. The first action gets the filled layout and the rest the outlined +// one, so the ordering in MoonlightSessionUi.actions is what decides which of them reads as +// the recommendation. private fun BindingSectionMoonlightBinding.bindActions( session: MoonlightSessionUi, hostLabel: String, diff --git a/app/src/main/java/com/tinkernorth/dish/ui/main/MoonlightSessionUi.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/MoonlightSessionUi.kt index 47161f3b..184a61b8 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/main/MoonlightSessionUi.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/main/MoonlightSessionUi.kt @@ -9,10 +9,24 @@ import androidx.annotation.StringRes import com.tinkernorth.dish.R import com.tinkernorth.dish.source.connection.moonlight.MoonlightTrustState +// The whole Moonlight session surface as one closed set of states, with the pure +// projections that turn each into a title, a body, a set of buttons and a tone. The +// binding screen and the setup wizard both draw it through bindMoonlightSession, so the +// states live here rather than in either of them: a state added below is a compile error +// in every `when` until it has been given all four. Nothing here touches a View or a +// Context, which is what makes the surface testable without a device. + +// One session carries four controllers and no more, so a fifth binding on the same host +// has nowhere to go; that ceiling is what HostFull reports. const val MOONLIGHT_MAX_PADS = 4 +// Five meanings rather than a colour per state, so a new state has to say which of these +// it is instead of introducing a shade of its own. enum class MoonlightTone { NEUTRAL, PROGRESS, WARN, ERROR, SUCCESS } +// Named for what the user is asking for, not for the work behind it: RETRY, RECONNECT and +// START_SESSION all restart the session, and stay separate only so the button can read +// like the state it sits under. enum class MoonlightAction { PAIR, PAIR_AGAIN, @@ -31,6 +45,10 @@ data class MoonlightAppUi( val title: String, ) +// The four axes below arrive from different places and change independently: pairing from +// the manager's event stream, apps from a probe, the phase from the live session, failure +// from whatever the host last refused. They stay apart rather than fold into one enum for +// that reason, and moonlightSessionUi is the single place their precedence is decided. sealed interface MoonlightPairingUi { data class Pin( val pin: String, @@ -51,6 +69,8 @@ sealed interface MoonlightApps { data object Failed : MoonlightApps } +// `controllerNumber` is 1-based for the reader: the wire index is 0..3 and the caller adds +// one, so "controller 1" on screen is pad 0 in the host's CONTROLLER_ARRIVAL. sealed interface MoonlightPhase { data object Idle : MoonlightPhase @@ -69,6 +89,9 @@ sealed interface MoonlightPhase { data object Ended : MoonlightPhase } +// Sticky: a failure is the last thing the host said, and it has to survive the re-probe +// that follows so the user can still read why the attempt stopped. HostFull is the one +// exception, re-derived from the live pad count every time. sealed interface MoonlightFailure { data object HostFull : MoonlightFailure @@ -83,6 +106,9 @@ sealed interface MoonlightFailure { data object SetupFailed : MoonlightFailure } +// Everything known about the chosen host at one moment. Defaulted throughout because a +// screen opens before any of it has been answered, and CHECKING is the honest starting +// point: trust here is remembered locally and only ever confirmed by asking. data class MoonlightSessionInput( val trust: MoonlightTrustState = MoonlightTrustState.CHECKING, val pairing: MoonlightPairingUi? = null, @@ -92,6 +118,9 @@ data class MoonlightSessionInput( val selectedAppId: String? = null, ) +// The render contract: one state at a time, flat rather than nested, so each projection +// below is a single exhaustive `when` and no combination can be reached that nobody wrote +// a string for. sealed interface MoonlightSessionUi { data object Checking : MoonlightSessionUi @@ -149,9 +178,16 @@ sealed interface MoonlightSessionUi { data object EndedByHost : MoonlightSessionUi } +// Precedence: pairing > trust > apps > joining > failure > live. +// // The pairing flow is checked before the trust word it supersedes: a probe that // answered "not paired" is exactly why a PIN is on screen, so reading the probe -// first would make the PIN state unreachable. +// first would make the PIN state unreachable. Joining outranks failure so a fresh +// attempt is not buried under the previous one's message, and failure outranks live +// so a host that refused mid-session says so instead of showing a stream that is no +// longer there. The trailing Checking is not a state anything produces: the apps, +// joining and live legs cover every phase between them, and it is there to keep the +// chain total. fun moonlightSessionUi(input: MoonlightSessionInput): MoonlightSessionUi = pairingUi(input.pairing) ?: trustUi(input.trust) @@ -168,6 +204,10 @@ private fun pairingUi(pairing: MoonlightPairingUi?): MoonlightSessionUi? = null -> null } +// PAIRED is the only word that falls through, because it is the only one that leaves +// nothing for the user to do. The rest are walls, and there is no live link to consult +// behind them: pairing is one-time trust with no liveness in either direction, so it is +// remembered locally and verified lazily when we ask, never polled. private fun trustUi(trust: MoonlightTrustState): MoonlightSessionUi? = when (trust) { MoonlightTrustState.CHECKING -> MoonlightSessionUi.Checking @@ -179,6 +219,9 @@ private fun trustUi(trust: MoonlightTrustState): MoonlightSessionUi? = MoonlightTrustState.PAIRED -> null } +// The app is a question only the session's creator gets asked. It is settled once per +// host, not per binding, so as soon as a session exists or an attempt has failed the +// picker would be offering a choice that is no longer there. private fun appsUi(input: MoonlightSessionInput): MoonlightSessionUi? { if (input.phase != MoonlightPhase.Idle || input.failure != null) return null return when (val apps = input.apps) { @@ -269,6 +312,9 @@ fun MoonlightSessionUi.bodyRes(): Int = MoonlightSessionUi.EndedByHost -> R.string.ml_ended_body } +// Format arguments travel with the state that carries them, so a string that grows a +// placeholder cannot quietly be handed the wrong one. A 0 resource means no line at all +// rather than an empty one, so the view hides the row instead of leaving a gap. @StringRes fun MoonlightSessionUi.noteRes(): Int = when { @@ -292,6 +338,9 @@ fun MoonlightSessionUi.bodyArgs(hostLabel: String): List = else -> listOf(hostLabel) } +// An empty list is a decision, not a gap: NewSession's action is the app row itself, +// Joining is transient, and the two loading states have nothing to offer until the +// answer arrives. fun MoonlightSessionUi.actions(): List = when (this) { MoonlightSessionUi.Checking, MoonlightSessionUi.AppsLoading -> emptyList() @@ -328,6 +377,10 @@ fun MoonlightSessionUi.tone(): MoonlightTone = val MoonlightSessionUi.showsSpinner: Boolean get() = this is MoonlightSessionUi.Checking || this is MoonlightSessionUi.PairingPin || this is MoonlightSessionUi.AppsLoading +// The only state that stops the binding being saved. Everything else is recoverable +// afterwards and a binding is a durable intent, so it may be applied against a host that +// is unpaired, unreachable, or asleep. Four controllers is a protocol ceiling instead: +// there is no fifth number to hand out. val MoonlightSessionUi.blocksApply: Boolean get() = this is MoonlightSessionUi.HostFull @@ -362,6 +415,10 @@ fun MoonlightTone.colorRes(): Int = MoonlightTone.SUCCESS -> R.color.colorSuccess } +// Seven states, three words. Anything outstanding or unanswered reads as remembered, +// because a stored record with no fresh answer is precisely what we hold; only a +// completed mutual-TLS call earns "paired", and only a host that answered and said no +// earns "not paired". @StringRes fun MoonlightTrustState.chipTextRes(): Int = when (this) { From 51012d4702de749155614c8c74d5d0c610a44977 Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Wed, 26 Aug 2026 16:35:55 -0400 Subject: [PATCH 17/20] docs: make the trust-chip note true of both callers chipTextRes is reached from the hosts rows and from the setup picker, where the word comes from moonlightTrustFor, and from nowhere that carries a probe's UNREACHABLE. The note said "not paired" meant a host that answered and said no, which is only the probe's reading of it; on the row path it means a host we hold no record for and have no live session with. --- .../java/com/tinkernorth/dish/ui/main/MoonlightSessionUi.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/tinkernorth/dish/ui/main/MoonlightSessionUi.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/MoonlightSessionUi.kt index 184a61b8..cb34f294 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/main/MoonlightSessionUi.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/main/MoonlightSessionUi.kt @@ -416,9 +416,9 @@ fun MoonlightTone.colorRes(): Int = } // Seven states, three words. Anything outstanding or unanswered reads as remembered, -// because a stored record with no fresh answer is precisely what we hold; only a -// completed mutual-TLS call earns "paired", and only a host that answered and said no -// earns "not paired". +// because a stored record with no fresh answer is precisely what we hold. "Paired" wants +// proof, which is either a session that is up or a mutual-TLS call that went through, and +// whatever has neither a record nor proof reads as not paired. @StringRes fun MoonlightTrustState.chipTextRes(): Int = when (this) { From 190ddadb6bad78b42eef422115ddb4afe79b543a Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Wed, 26 Aug 2026 18:05:40 -0400 Subject: [PATCH 18/20] fix: make a Moonlight pairing stick, and stop a binding outliving its host Two failures on a real device, one cause each, both of them state that was never written. PAIRING DID NOTHING, FOREVER. The user forgot a host and then could not pair with it again: the button reacted in no way at all and the app logged not one Moonlight line. The host is the one that told us why. Its own log carries seven mutual-TLS /serverinfo requests from Dish/1.0 in the twenty seconds the user was pressing Pair, every one of them with the client certificate verified, and zero /pair requests. A Moonlight host authorises by client certificate and has no unpair verb, so forgetting is unilateral: the host still held our cert, still listed the device as paired, and answered the pairing check cleanly. pairHost took that answer, emitted Paired, and wrote nothing. The record stayed empty, the row kept reading "Not paired", and the next press did the same nothing. Confirming trust is a pairing outcome and now persists like one. The leftover cert pin was the obvious suspect and was not the culprit. The pin forget left behind is byte-identical to the SHA-256 of the certificate the live host serves, so TOFU matched and the handshake went through, which is exactly why the host logged seven verified requests. It is still wrong to keep it: a host that rotates its certificate after a forget is then refused with no way past it from inside the app. forget now drops the record, the pin, the discovery row and the session, cancelling the app the host is running first, because afterwards there is nothing left to authenticate a cancel with. BIND DID NOTHING AND TOOK ITS OWN CONFIGURATION WITH IT. rememberPaired had exactly two call sites, the end of a full pairing and the end of a successful launch, so a host that had been discovered, probed, added by address, chosen as a destination or bound to was never written to the host list at all. Such a host existed only inside the discovery list, and a scan assigned its result outright, so one mDNS miss erased it. Everything downstream then unwound: the connection summary disappeared, the desired-pad map dropped the binding, the session was released, the destination emptied, and Apply returned without a word. Interest is durable now. Binding to a host records it, adding one by address records it, and a scan merges rather than assigns. Because the host list is now both the pairing store and the interest store, RememberedMoonlight gains a paired flag, defaulting to true because every row written before it existed was written by a completed pairing. The probe reads that flag instead of asking whether a stored uniqueid is non-empty, which it always was for the real hosts: none of them publish a uniqueid TXT record, so a paired host that went offline used to report "never paired, no answer" instead of "remembered". NO USER ACTION MAY FAIL SILENTLY. Every early return on the pair, bind and session paths now says why, and every one the user started also has a visible outcome. Pairing failure carries the phase that gave up rather than one indistinguishable event for six different things. Cancel cancels the pairing job instead of only closing the dialog, and New code replaces a live job rather than returning, which is what makes the one button that state exists to offer reachable. A pairing that succeeds raises a notice, because a host that already trusts the device answers with no PIN dialog to dismiss. "Paired" wants proof and the hosts screen does not probe, so the manager keeps the set of hosts that have authorised a mutual-TLS call this process and the trust chip reads it. Without that, a pairing the user just watched succeed still renders as merely remembered. Two smaller things fixed on the way. An app picked on a host with no record was discarded while the row rendered as chosen, so the session started something else; the record is created instead. And a full pairing re-arms TOFU after phase four, which has already proved the peer holds the PIN-derived key and signed with the certificate it presented, a stronger claim than the pin it replaces; without that a rebuilt host could never be paired with again. The add-by-address dialog builds its views from res/layout/dialog_add_moonlight like every other dialog in the app, instead of constructing a TextInputLayout in Kotlin and setting its padding in code. It was the only view hierarchy built in code in the whole main source set. The forget action gains the confirmation the Bluetooth one has, and says the part that was never said anywhere: the host keeps its own record of this device until a human removes it there. Forty-two tests, in two new files and three existing ones, covering pairing, re-pairing after a forget, forget leaving no residue, binding to paired and to never-paired hosts, the app pick, every probe verdict, and the refusals a host answers inside a 200 body. --- .../dish/composer/ConnectionCoordinator.kt | 6 +- .../core/net/moonlight/MoonlightHostModels.kt | 5 + .../moonlight/MoonlightConnectionManager.kt | 223 +++++++- .../moonlight/MoonlightHttpGateway.kt | 12 + .../ui/connections/ConnectionsActivity.kt | 85 ++- .../ui/connections/ConnectionsViewModel.kt | 40 +- .../ui/connections/MoonlightListAdapter.kt | 20 +- .../ui/main/ConfigureBindingsViewModel.kt | 62 ++- .../dish/ui/setup/SetupConnectionViewModel.kt | 15 +- .../main/res/layout/dialog_add_moonlight.xml | 32 ++ app/src/main/res/values-bs/strings.xml | 6 + app/src/main/res/values-de/strings.xml | 6 + app/src/main/res/values-es/strings.xml | 6 + app/src/main/res/values-fr/strings.xml | 6 + app/src/main/res/values-pt-rBR/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + .../composer/ConnectionCoordinatorTest.kt | 68 +++ .../moonlight/MoonlightSessionFailureTest.kt | 269 +++++++++ .../moonlight/MoonlightTrustFlowTest.kt | 510 ++++++++++++++++++ .../dish/ui/connections/MoonlightRowsTest.kt | 36 +- .../ui/setup/SetupConnectionViewModelTest.kt | 26 + 21 files changed, 1366 insertions(+), 79 deletions(-) create mode 100644 app/src/main/res/layout/dialog_add_moonlight.xml create mode 100644 app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightSessionFailureTest.kt create mode 100644 app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightTrustFlowTest.kt diff --git a/app/src/main/java/com/tinkernorth/dish/composer/ConnectionCoordinator.kt b/app/src/main/java/com/tinkernorth/dish/composer/ConnectionCoordinator.kt index b33d0bb3..bbd32a02 100644 --- a/app/src/main/java/com/tinkernorth/dish/composer/ConnectionCoordinator.kt +++ b/app/src/main/java/com/tinkernorth/dish/composer/ConnectionCoordinator.kt @@ -2,6 +2,7 @@ package com.tinkernorth.dish.composer +import com.tinkernorth.dish.core.net.moonlight.MoonlightHost import com.tinkernorth.dish.hotpath.input.PhysicalGamepadRegistry import com.tinkernorth.dish.repository.ConnectionStore import com.tinkernorth.dish.source.bluetooth.BluetoothGamepadRegistry @@ -84,6 +85,9 @@ class ConnectionCoordinator controllerType: Int, ): Boolean { if (!slotExists(slotId)) return false + // A binding cannot be allowed to outlive its destination; rememberInterest + // carries the reasoning. + if (connectionId.startsWith(MoonlightHost.ID_PREFIX)) moonlight.rememberInterest(connectionId) val priorConnId = bindingStore.connectionFor(slotId) // Android HID Device profile allows only one active host; release prior slot first. @@ -120,7 +124,7 @@ class ConnectionCoordinator hostFeaturesStore.clearConnection(connectionId) hostRuntimeStore.clearConnection(connectionId) when { - connectionId.startsWith(com.tinkernorth.dish.core.net.moonlight.MoonlightHost.ID_PREFIX) -> + connectionId.startsWith(MoonlightHost.ID_PREFIX) -> moonlight.forget(connectionId) store.rememberedBt().any { it.id == connectionId } -> store.forgetBt(connectionId) else -> satellite.forget(connectionId) diff --git a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHostModels.kt b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHostModels.kt index 494c50a6..b60b99b1 100644 --- a/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHostModels.kt +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHostModels.kt @@ -52,6 +52,11 @@ data class RememberedMoonlight( val lastAppName: String = "", // The emulated-device pick (CONTROLLER_ARRIVAL type): Auto/Xbox/PS/Nintendo. val emulatedType: Int = MoonlightEmulatedType.AUTO, + // Whether the host has ever accepted this device, as opposed to one the user has + // only shown durable interest in (added by address, or bound to). Both belong in + // this list; only the first is trust. Defaults true because every record written + // before this field existed was written by a completed pairing. + val paired: Boolean = true, ) { fun toHost(): MoonlightHost = MoonlightHost( diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt index 5562fb83..28d3d475 100644 --- a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt @@ -55,9 +55,15 @@ sealed class MoonlightConnectionEvent { val host: MoonlightHost, ) : MoonlightConnectionEvent() - /** Pairing ran and the host would not accept the PIN. */ + /** + * Pairing ran and did not end in trust. [reason] names WHICH step gave up: + * six different things fail this flow and they used to arrive as one + * indistinguishable event, so a host that was unplugged mid-pairing told the + * user to check they had typed the code into the right host. + */ data class PairingFailed( val host: MoonlightHost, + val reason: String, ) : MoonlightConnectionEvent() /** @@ -171,6 +177,16 @@ class MoonlightConnectionManager private val _isScanning = MutableStateFlow(false) val isScanning: StateFlow = _isScanning.asStateFlow() + /** + * Hosts that have answered a mutual-TLS call in THIS process. There is no + * liveness in this protocol, so "Paired" is a word that wants proof and the + * only proof there is, is a call the host authorised. The hosts screen does + * not probe, so without this it can only ever say "Remembered", which reads + * as unverified straight after the user watched a pairing succeed. + */ + private val _verifiedHostIds = MutableStateFlow>(emptySet()) + val verifiedHostIds: StateFlow> = _verifiedHostIds.asStateFlow() + private val _sessionHostIds = MutableStateFlow>(emptySet()) /** Hosts this device is holding a session open for; the foreground service follows it. */ @@ -196,10 +212,21 @@ class MoonlightConnectionManager fun get(id: String): MoonlightConnection? = _connections.value[id] + /** + * Browse for hosts and MERGE the answer into what is already known. Assigning + * it outright meant one mDNS miss erased every host that was only ever + * discovered, taking any binding pointing at one down with it. Nothing here is + * a liveness light, so a row that outlives a failed browse costs nothing. + */ fun startDiscovery() { if (!_isScanning.compareAndSet(expect = false, update = true)) return scope.launch { - _discovered.value = runCatching { discovery.discover(DISCOVERY_TIMEOUT_MS) }.getOrDefault(emptyList()) + val found = + runCatching { discovery.discover(DISCOVERY_TIMEOUT_MS) } + .onFailure { Log.w(TAG, "discovery failed: ${it.message}", it) } + .getOrDefault(emptyList()) + Log.i(TAG, "discovery found ${found.size} host(s), had ${_discovered.value.size}") + found.forEach { _discovered.mergeHost(it) } _isScanning.value = false } } @@ -213,6 +240,7 @@ class MoonlightConnectionManager .takeIf { it.ok } ?.let { MoonlightXml.parseServerInfo(it.body) } if (info == null) { + Log.w(TAG, "manual add: nothing answered /serverinfo at $address") _events.emit(MoonlightConnectionEvent.Error("No Moonlight host answered at $address.")) return@launch } @@ -225,11 +253,15 @@ class MoonlightConnectionManager uniqueId = info.uniqueId, manual = true, ) - _discovered.updateAndGetHost(host) + Log.i(TAG, "manual add: ${host.name} at $address as ${host.id}") + _discovered.mergeHost(host) + // Typing an address is durable interest, so the host outlives the + // discovery list it would otherwise be the only copy of. + rememberInterest(host) } } - private fun MutableStateFlow>.updateAndGetHost(host: MoonlightHost) { + private fun MutableStateFlow>.mergeHost(host: MoonlightHost) { value = (value.filterNot { it.id == host.id } + host) } @@ -256,24 +288,38 @@ class MoonlightConnectionManager .getHttp(MoonlightUrls.serverInfoHttp(host.address, host.httpPort, deviceId)) .takeIf { it.ok } ?.let { MoonlightXml.parseServerInfo(it.body) } - val storedId = store.get(host.id)?.uniqueId.orEmpty() + // "Do we hold a pairing" is the PAIRED FLAG, not a non-empty uniqueid. + // Real hosts publish no uniqueid TXT record, so reading it off that made + // every mDNS-discovered host report M5 ("never paired") when it went + // offline instead of M6 ("remembered, will start when it is back"). + val record = store.get(host.id)?.takeIf { it.paired } + val storedId = record?.uniqueId.orEmpty() if (plain == null) { return@withContext MoonlightProbe( - trust = if (storedId.isEmpty()) MoonlightTrustState.UNREACHABLE else MoonlightTrustState.REMEMBERED, + trust = if (record == null) MoonlightTrustState.UNREACHABLE else MoonlightTrustState.REMEMBERED, ) } if (storedId.isNotEmpty() && plain.uniqueId.isNotEmpty() && plain.uniqueId != storedId) { + Log.i(TAG, "${host.address} answers as ${plain.uniqueId}, remembered as $storedId: host replaced") return@withContext MoonlightProbe(trust = MoonlightTrustState.REPLACED) } if (!plain.paired) { - val trust = if (storedId.isEmpty()) MoonlightTrustState.NOT_PAIRED else MoonlightTrustState.TRUST_LOST + val trust = if (record == null) MoonlightTrustState.NOT_PAIRED else MoonlightTrustState.TRUST_LOST + Log.i(TAG, "${host.address} reports unpaired over plaintext: $trust") return@withContext MoonlightProbe(trust = trust) } val secure = gateway.getHttps(MoonlightUrls.serverInfoHttps(host.address, host.httpsPort, deviceId), host.id) - if (!secure.ok) return@withContext MoonlightProbe(trust = MoonlightTrustState.TRUST_LOST) + if (!secure.ok) { + Log.i(TAG, "${host.address} refused mutual TLS (HTTP ${secure.status}): trust lost") + return@withContext MoonlightProbe(trust = MoonlightTrustState.TRUST_LOST) + } val info = MoonlightXml.parseServerInfo(secure.body) - if (info?.paired != true) return@withContext MoonlightProbe(trust = MoonlightTrustState.TRUST_LOST) + if (info?.paired != true) { + Log.i(TAG, "${host.address} answered mutual TLS unpaired: trust lost") + return@withContext MoonlightProbe(trust = MoonlightTrustState.TRUST_LOST) + } val apps = runCatching { fetchAppList(host) }.getOrNull() + markVerified(host.id) MoonlightProbe( trust = MoonlightTrustState.PAIRED, apps = apps.orEmpty(), @@ -292,6 +338,7 @@ class MoonlightConnectionManager */ fun applyDesired(desired: Map>) { this.desired = desired + Log.i(TAG, "desired pads: ${desired.entries.joinToString { "${it.key}=${it.value.size}" }.ifEmpty { "none" }}") converge() } @@ -324,7 +371,13 @@ class MoonlightConnectionManager hostId: String, pads: List, ) { - val host = hostFor(hostId) ?: return + val host = hostFor(hostId) + if (host == null) { + // Unreachable now that a bound host is written to the store, but saying + // so beats the silent return that made a bind look like it did nothing. + Log.w(TAG, "no host for $hostId; ${pads.size} pad(s) cannot be placed") + return + } val conn = findOrCreate(host) conn.updateHost(host) val wanted = pads.associateBy { it.slotId } @@ -398,6 +451,9 @@ class MoonlightConnectionManager publishSessionHosts() val probe = probe(host) if (probe.trust != MoonlightTrustState.PAIRED) { + // The binding screen re-probes and renders the same verdict, so the user + // is told; the log line is what makes a bug report readable. + Log.w(TAG, "not opening a session on ${host.address}: trust is ${probe.trust}") conn.markDisconnected() if (probe.trust == MoonlightTrustState.REPLACED) { _events.emit(MoonlightConnectionEvent.HostReplaced(host)) @@ -431,7 +487,16 @@ class MoonlightConnectionManager */ suspend fun pairHost(host: MoonlightHost): Boolean = withContext(ioDispatcher) { + Log.i(TAG, "pair requested for ${host.name} at ${host.address} (${host.id})") if (isPaired(host)) { + // CONFIRMING TRUST IS A PAIRING OUTCOME AND HAS TO PERSIST LIKE ONE. + // A device that forgot a host the host still trusts is answered here + // without a PIN. Emitting Paired and writing nothing left the record + // empty and the row reading "Not paired", so the button did the same + // nothing every time it was pressed, and the only trace of any of it + // was a mutual-TLS /serverinfo in the HOST's log. + Log.i(TAG, "${host.address} already trusts this device; recording the pairing") + rememberPaired(host, paired = true) _events.emit(MoonlightConnectionEvent.Paired(host)) true } else { @@ -450,14 +515,21 @@ class MoonlightConnectionManager private fun isPaired(host: MoonlightHost): Boolean { val reply = gateway.getHttps(MoonlightUrls.serverInfoHttps(host.address, host.httpsPort, deviceId), host.id) - if (!reply.ok) return false - return MoonlightXml.parseServerInfo(reply.body)?.paired == true + if (!reply.ok) { + Log.i(TAG, "${host.address} did not answer mutual TLS (HTTP ${reply.status}): a PIN is needed") + return false + } + val paired = MoonlightXml.parseServerInfo(reply.body)?.paired == true + Log.i(TAG, "${host.address} answered mutual TLS, PairStatus paired=$paired") + if (paired) markVerified(host.id) + return paired } /** Runs the 5-phase pairing; phase 1 blocks until the user enters the PIN. */ @Suppress("ReturnCount") // each early return is a distinct phase-failure bail private suspend fun pair(host: MoonlightHost): Boolean { val pin = randomPin() + Log.i(TAG, "pairing ${host.address}: PIN issued, phase 1 will wait up to ${PAIR_WAIT_S}s for it") _events.emit(MoonlightConnectionEvent.PairingPinReady(host, pin)) val pairing = MoonlightPairing(identity, pin) return runCatching { @@ -468,7 +540,9 @@ class MoonlightConnectionManager MoonlightUrls.pairHttp(host.address, host.httpPort, pairing.phase1Params(deviceId)), MoonlightHttpGateway.PAIR_PIN_TIMEOUT_MS, ) - val cert = MoonlightXml.parsePairReply(p1.body)?.plainCert ?: return pairingRefused(host) + val cert = + MoonlightXml.parsePairReply(p1.body)?.plainCert + ?: return pairingRefused(host, "phase 1 returned no host certificate (HTTP ${p1.status})") pairing.onPhase1( String( com.tinkernorth.dish.core.net @@ -478,29 +552,49 @@ class MoonlightConnectionManager ) val p2 = gateway.getHttp(MoonlightUrls.pairHttp(host.address, host.httpPort, pairing.phase2Params(deviceId))) - val challenge = MoonlightXml.parsePairReply(p2.body)?.challengeResponse ?: return pairingRefused(host) - if (!pairing.onPhase2(challenge)) return pairingRefused(host) + val challenge = + MoonlightXml.parsePairReply(p2.body)?.challengeResponse + ?: return pairingRefused(host, "phase 2 returned no challenge response") + if (!pairing.onPhase2(challenge)) return pairingRefused(host, "phase 2 challenge did not verify (wrong PIN)") val p3 = gateway.getHttp(MoonlightUrls.pairHttp(host.address, host.httpPort, pairing.phase3Params(deviceId))) - val secret = MoonlightXml.parsePairReply(p3.body)?.pairingSecret ?: return pairingRefused(host) - if (!pairing.onPhase3(secret)) return pairingRefused(host) + val secret = + MoonlightXml.parsePairReply(p3.body)?.pairingSecret + ?: return pairingRefused(host, "phase 3 returned no pairing secret") + if (!pairing.onPhase3(secret)) return pairingRefused(host, "phase 3 signature did not verify") val p4 = gateway.getHttp(MoonlightUrls.pairHttp(host.address, host.httpPort, pairing.phase4Params(deviceId))) - if (MoonlightXml.parsePairReply(p4.body)?.paired != true) return pairingRefused(host) + if (MoonlightXml.parsePairReply(p4.body)?.paired != true) { + return pairingRefused(host, "phase 4 did not confirm the pairing") + } + + // Phases 1-4 proved the peer holds the PIN-derived key and signed with + // the certificate it presented, which outranks the pin this would keep. + // Without re-arming, a rebuilt host is refused with no way past it. + gateway.forgetPin(host.id) // Phase 5 (HTTPS): confirm the client-cert-authenticated channel. gateway.getHttps(MoonlightUrls.pairHttps(host.address, host.httpsPort, pairing.phase5Params(deviceId)), host.id) - rememberPaired(host) + Log.i(TAG, "paired with ${host.name} at ${host.address}") + rememberPaired(host, paired = true) _events.emit(MoonlightConnectionEvent.Paired(host)) true - }.getOrElse { - Log.w(TAG, "pairing failed for ${host.address}: ${it.message}") - pairingRefused(host) + }.getOrElse { failure -> + // A cancelled pairing is the user's own doing, not a refusal: letting + // runCatching turn it into one would raise "the host did not accept the + // PIN" the moment they pressed Cancel. + if (failure is kotlinx.coroutines.CancellationException) throw failure + Log.w(TAG, "pairing failed for ${host.address}: ${failure.message}", failure) + pairingRefused(host, failure.message ?: failure.javaClass.simpleName) } } - private suspend fun pairingRefused(host: MoonlightHost): Boolean { - _events.emit(MoonlightConnectionEvent.PairingFailed(host)) + private suspend fun pairingRefused( + host: MoonlightHost, + reason: String, + ): Boolean { + Log.w(TAG, "pairing refused by ${host.address}: $reason") + _events.emit(MoonlightConnectionEvent.PairingFailed(host, reason)) return false } @@ -551,7 +645,7 @@ class MoonlightConnectionManager val resolvedName = appName.ifEmpty { runCatching { appTitleFor(host, appId) }.getOrNull().orEmpty() } Log.i(TAG, "live on ${host.address}, control ${rtsp.controlPort}, ${conn.padCount} pad(s)") conn.markLive(session, appId, resolvedName) - rememberPaired(host, appId, resolvedName) + rememberPaired(host, appId, resolvedName, paired = true) publishSessionHosts() } @@ -690,27 +784,98 @@ class MoonlightConnectionManager publishSessionHosts() } + /** + * Drop every trace of [id] this device holds: the session, the remembered + * record, and THE PINNED HOST CERTIFICATE, which used to survive a forget and + * refuse a host that had since rotated its own. + * + * FORGET IS UNILATERAL AND CANNOT BE ANYTHING ELSE. The protocol has no unpair + * verb, so the host keeps its record of this device until a human removes it + * there. The confirmation copy says so. + */ fun forget(id: String) { - disconnect(id) + val host = hostFor(id) + Log.i(TAG, "forgetting ${host?.address ?: id}") + // Cancel before the credentials go: afterwards there is nothing left to + // authenticate one with, and the host keeps running the app regardless. + releaseSessionFor(id, host) store.remove(id) + gateway.forgetPin(id) _connections.updateAndGet { it - id } + _discovered.value = _discovered.value.filterNot { it.id == id } + _verifiedHostIds.value = _verifiedHostIds.value - id publishSessionHosts() } + private fun markVerified(hostId: String) { + _verifiedHostIds.value = _verifiedHostIds.value + hostId + } + + private fun releaseSessionFor( + id: String, + host: MoonlightHost?, + ) { + val conn = _connections.value[id] ?: return + val live = conn.state.value == MoonlightSessionState.Live + conn.pads.value.keys + .toList() + .forEach(conn::releasePad) + conn.markDisconnected() + if (live && host != null) runCatching { cancelHostApp(host) } + } + /** Remember which app the session settled on so the next binding can say it is joining it. */ fun rememberApp( hostId: String, appId: String, appName: String, ) { - val entry = store.get(hostId) ?: return + val entry = store.get(hostId) + if (entry == null) { + // Dropping the pick here rendered the row as chosen and then started + // something else, for every host the user had only discovered. + val host = hostFor(hostId) + if (host == null) { + Log.w(TAG, "app pick for unknown host $hostId discarded") + return + } + Log.i(TAG, "app pick $appId for $hostId on a host with no record yet; recording interest") + rememberPaired(host, appId, appName, paired = false) + return + } + Log.i(TAG, "app for $hostId settled on $appId ($appName)") store.put(entry.copy(lastAppId = appId, lastAppName = appName)) } + /** + * Record a host the user has committed to without claiming it is paired. + * + * A host that lives only in the discovery list disappears the moment a browse + * misses it, and a binding pointing at one loses its summary, its pads and its + * session with it. Adding by address and binding are both durable intent, so + * both land here; [RememberedMoonlight.paired] keeps interest and trust apart. + */ + fun rememberInterest(host: MoonlightHost) { + if (store.get(host.id) != null) return + Log.i(TAG, "remembering ${host.name} at ${host.address} as ${host.id} (not paired)") + rememberPaired(host, paired = false) + } + + /** The same, for a host known only by id (the binding hub has no [MoonlightHost]). */ + fun rememberInterest(hostId: String) { + val host = hostFor(hostId) + if (host == null) { + Log.w(TAG, "cannot remember unknown Moonlight host $hostId") + return + } + rememberInterest(host) + } + private fun rememberPaired( host: MoonlightHost, appId: String = store.get(host.id)?.lastAppId.orEmpty(), appName: String = store.get(host.id)?.lastAppName.orEmpty(), + paired: Boolean, ) { store.put( RememberedMoonlight( @@ -723,6 +888,9 @@ class MoonlightConnectionManager lastAppId = appId, lastAppName = appName, emulatedType = rememberedEmulatedType(host.id), + // Trust only ever climbs here: a launch on a host already paired + // must not demote it, and interest must not promote it. + paired = paired || store.get(host.id)?.paired == true, ), ) } @@ -772,5 +940,6 @@ class MoonlightConnectionManager const val LAUNCH_HEIGHT = 720 const val LAUNCH_FPS = 30 const val BODY_LOG_CHARS = 256 + const val PAIR_WAIT_S = MoonlightHttpGateway.PAIR_PIN_TIMEOUT_MS / 1000 } } diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt index fd857f52..114cb8a9 100644 --- a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt @@ -113,6 +113,18 @@ class MoonlightHttpGateway openTls(socket, host, port, hostId) }.get(urlString) + /** + * Drop the pinned certificate for [hostId], re-arming TOFU for it. Lives + * here because the thing that reads a pin should be the thing that clears + * one. Both callers are moments the user authorised: forgetting the host, + * and a PIN-confirmed pairing, which is a stronger claim than the pin. + */ + fun forgetPin(hostId: String) { + if (pins.pinnedFingerprint(hostId) == null) return + Log.i(TAG, "dropping pinned cert for $hostId") + pins.forget(hostId) + } + /** * Hands back a handshaken TLS socket that presents the dish's client * certificate, or throws once the host's certificate fails the pin. diff --git a/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsActivity.kt b/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsActivity.kt index c8b914ce..03a09257 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsActivity.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsActivity.kt @@ -10,6 +10,7 @@ import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.provider.Settings +import android.util.Log import android.view.View import android.widget.LinearLayout import android.widget.TextView @@ -64,6 +65,7 @@ import com.tinkernorth.dish.ui.common.applyDishSystemBars import com.tinkernorth.dish.ui.common.setupDishToolbar import com.tinkernorth.dish.ui.donate.attachDonatePill import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.isActive @@ -179,10 +181,17 @@ class ConnectionsActivity : BaseGamepadHostActivity() { private var pairingServer: com.tinkernorth.dish.core.model.DiscoveredServer? = null + // Nothing the user presses here may end in a shrug: a row whose button does nothing + // is indistinguishable from a broken app, and used to be exactly that. private val moonlightRowListener = object : MoonlightRowListener { override fun onPairKnown(summary: ConnectionSummary) { - hostFor(summary.id)?.let(::startMoonlightPairing) + val host = hostFor(summary.id) + if (host == null) { + reportMoonlightHostGone(summary.label, summary.id) + return + } + startMoonlightPairing(host) } override fun onPairDiscovered(host: com.tinkernorth.dish.core.net.moonlight.MoonlightHost) { @@ -190,16 +199,25 @@ class ConnectionsActivity : BaseGamepadHostActivity() { } override fun onQuitSession(id: String) { - hostFor(id)?.let(moonlight::quitHostApp) + val host = hostFor(id) + if (host == null) { + reportMoonlightHostGone(id, id) + return + } + moonlight.quitHostApp(host) } override fun onForget(id: String) { - hub.forgetConnection(id) + confirmForgetMoonlight(id) } } private var moonlightPinDialog: AlertDialog? = null + // Held so Cancel actually cancels. Without it the dialog closed and phase 1 kept + // its socket open for the whole two-minute PIN window. + private var moonlightPairingJob: Job? = null + private val btPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), @@ -299,10 +317,23 @@ class ConnectionsActivity : BaseGamepadHostActivity() { when (ev) { is com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionEvent.PairingPinReady -> showMoonlightPinDialog(ev.host, ev.pin) - is com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionEvent.Paired -> + // A pairing that succeeds has to LOOK like it succeeded. A host that already + // trusts this device answers without a PIN, so there is no dialog to dismiss + // and the row's chip is the only other feedback there would be. + is com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionEvent.Paired -> { + cancelMoonlightPairing() moonlightPinDialog?.dismiss() + notifications.info( + glyph = R.drawable.ic_pc_monitor, + title = getString(R.string.ml_paired_title, ev.host.name), + body = getString(R.string.ml_paired_body), + key = "moonlight-paired", + ) + } is com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionEvent.PairingFailed -> { + cancelMoonlightPairing() moonlightPinDialog?.dismiss() + Log.w(TAG, "pairing with ${ev.host.address} failed: ${ev.reason}") notifications.error( glyph = R.drawable.ic_pc_monitor, title = getString(R.string.ml_pair_failed_title, ev.host.name), @@ -830,7 +861,38 @@ class ConnectionsActivity : BaseGamepadHostActivity() { // escape hatch that closes an app the host is holding. The controller type, // the app, and the session itself belong to the binding. private fun startMoonlightPairing(host: com.tinkernorth.dish.core.net.moonlight.MoonlightHost) { - lifecycleScope.launch { moonlight.pairHost(host) } + moonlightPairingJob?.cancel() + moonlightPairingJob = lifecycleScope.launch { moonlight.pairHost(host) } + } + + private fun cancelMoonlightPairing() { + moonlightPairingJob?.cancel() + moonlightPairingJob = null + } + + private fun reportMoonlightHostGone( + label: String, + id: String, + ) { + Log.w(TAG, "no Moonlight host behind $id; the row is stale") + notifications.error( + glyph = R.drawable.ic_pc_monitor, + title = getString(R.string.ml_state_unreachable_title, label), + body = getString(R.string.ml_host_gone_body), + ) + } + + // Forget is UNILATERAL: the protocol has no unpair verb, so the host keeps its own + // record of this device until a human removes it there. The confirmation says so, + // mirroring the Bluetooth one, which has the same shape of half-truth to tell. + private fun confirmForgetMoonlight(id: String) { + val label = hub.summary(id)?.label ?: id + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.dialog_forget_moonlight_title, label)) + .setMessage(getString(R.string.dialog_forget_moonlight_message, label)) + .setPositiveButton(R.string.action_forget_short) { _, _ -> hub.forgetConnection(id) } + .setNegativeButton(R.string.dialog_forget_bt_negative, null) + .show() } private fun hostFor(id: String): com.tinkernorth.dish.core.net.moonlight.MoonlightHost? = @@ -850,21 +912,19 @@ class ConnectionsActivity : BaseGamepadHostActivity() { MaterialAlertDialogBuilder(this) .setTitle(getString(R.string.moonlight_pin_title, host.name)) .setMessage(message) - .setNegativeButton(R.string.action_cancel) { _, _ -> moonlight.disconnect(host.id) } + .setNegativeButton(R.string.action_cancel) { _, _ -> cancelMoonlightPairing() } .setOnDismissListener { moonlightPinDialog = null } .show() } private fun showAddMoonlightDialog() { - val layout = TextInputLayout(this) - val input = TextInputEditText(this).apply { hint = getString(R.string.add_moonlight_host_hint) } - layout.addView(input) - val pad = resources.getDimensionPixelSize(R.dimen.spacing_md) - layout.setPadding(pad, pad, pad, 0) + val view = layoutInflater.inflate(R.layout.dialog_add_moonlight, null) + val layout = view.findViewById(R.id.tilMoonlightHost) + val input = view.findViewById(R.id.etMoonlightHost) val dialog = MaterialAlertDialogBuilder(this) .setTitle(R.string.action_add_moonlight_host) - .setView(layout) + .setView(view) .setPositiveButton(R.string.action_add, null) .setNegativeButton(R.string.action_cancel, null) .create() @@ -1224,6 +1284,7 @@ class ConnectionsActivity : BaseGamepadHostActivity() { companion object { const val EXTRA_PAIR_PROMPT_FOR_ID = "extra_pair_prompt_for_id" + private const val TAG = "ConnectionsActivity" private const val DISCOVERABLE_SECONDS = 120 private const val COUNTDOWN_TICK_MS = 500L private const val DEFAULT_HTTPS_PORT = 9443 diff --git a/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsViewModel.kt b/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsViewModel.kt index 2f97c4b9..6d5a1851 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsViewModel.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/connections/ConnectionsViewModel.kt @@ -50,26 +50,44 @@ class ConnectionsViewModel ) } - val ui: StateFlow = + // The Moonlight half, folded so the whole state still fits one combine. Only a record + // that says paired counts as trust; the list also carries hosts the user merely added + // or bound to. + private data class MoonlightSlice( + val discovered: List, + val scanning: Boolean, + val pairedIds: Set, + val verifiedIds: Set, + ) + + private val moonlightSlice = combine( - satBt, - hub.connections, moonlight.discovered, moonlight.isScanning, moonlight.remembered, - ) { slice, conns, moonlightDiscovered, moonlightScanning, moonlightRemembered -> + moonlight.verifiedHostIds, + ) { discovered, scanning, remembered, verified -> + MoonlightSlice( + discovered = discovered, + scanning = scanning, + pairedIds = remembered.filter { it.paired }.mapTo(mutableSetOf()) { it.id }, + verifiedIds = verified, + ) + } + + val ui: StateFlow = + combine( + satBt, + hub.connections, + moonlightSlice, + ) { slice, conns, ml -> ConnectionsUiState( satelliteRows = slice.satelliteRows, bluetoothSummaries = slice.bluetoothSummaries, - moonlightRows = - moonlightRows( - conns, - moonlightDiscovered, - moonlightRemembered.mapTo(mutableSetOf()) { it.id }, - ), + moonlightRows = moonlightRows(conns, ml.discovered, ml.pairedIds, ml.verifiedIds), rememberedBtIds = slice.rememberedBtIds, scanning = slice.scanning, - moonlightScanning = moonlightScanning, + moonlightScanning = ml.scanning, lastScanAtMs = slice.lastScanAtMs, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ConnectionsUiState.Empty) diff --git a/app/src/main/java/com/tinkernorth/dish/ui/connections/MoonlightListAdapter.kt b/app/src/main/java/com/tinkernorth/dish/ui/connections/MoonlightListAdapter.kt index fd30f144..24cf4596 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/connections/MoonlightListAdapter.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/connections/MoonlightListAdapter.kt @@ -172,13 +172,15 @@ class MoonlightListAdapter( } // Known hosts first (from the composer summaries), then discovered hosts not already known. -// The trust word is derived from what we already hold: a session that is up proves the pairing -// stands, a stored record means the pairing is remembered but unverified this visit, and -// anything else has never been paired. Nothing here probes; the binding flow does that. +// The trust word is derived from what we already hold: a session that is up or a mutual-TLS +// call that went through proves the pairing stands, a stored record means it is remembered but +// unverified this visit, and anything else has never been paired. Nothing here probes; the +// binding flow does that, and hands the result back through [verifiedIds]. fun moonlightRows( conns: List, discovered: List, - rememberedIds: Set = emptySet(), + pairedIds: Set = emptySet(), + verifiedIds: Set = emptySet(), ): List { val known = conns.filter { it.kind == ConnectionKind.MOONLIGHT } val knownIds = known.mapTo(mutableSetOf()) { it.id } @@ -187,7 +189,7 @@ fun moonlightRows( add( MoonlightRow.Known( summary = summary, - trust = moonlightTrustFor(summary, summary.id in rememberedIds), + trust = moonlightTrustFor(summary, summary.id in pairedIds, summary.id in verifiedIds), controllerCount = summary.boundSlotIds.size, ), ) @@ -198,12 +200,16 @@ fun moonlightRows( } } +// "Paired" is the word that wants proof, so it is reserved for a session that is up or a host +// that authorised a call this visit. A host the user only added or bound to is remembered at most. internal fun moonlightTrustFor( summary: ConnectionSummary, - remembered: Boolean, + paired: Boolean, + verified: Boolean = false, ): MoonlightTrustState = when { summary.live == LinkState.Connected || summary.live == LinkState.Unstable -> MoonlightTrustState.PAIRED - remembered -> MoonlightTrustState.REMEMBERED + verified -> MoonlightTrustState.PAIRED + paired -> MoonlightTrustState.REMEMBERED else -> MoonlightTrustState.NOT_PAIRED } diff --git a/app/src/main/java/com/tinkernorth/dish/ui/main/ConfigureBindingsViewModel.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/ConfigureBindingsViewModel.kt index 536e0710..855539bd 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/main/ConfigureBindingsViewModel.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/main/ConfigureBindingsViewModel.kt @@ -3,6 +3,7 @@ package com.tinkernorth.dish.ui.main import android.content.Context +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.tinkernorth.dish.R @@ -338,7 +339,15 @@ class ConfigureBindingsViewModel fun refreshMoonlight() { val hostId = _ui.value.draft?.hostId ?: return if (!_ui.value.isMoonlightHost) return - val host = moonlight.rememberedHost(hostId) ?: return + val host = moonlight.rememberedHost(hostId) + if (host == null) { + // A destination that resolves to nothing leaves the section stuck on its + // spinner forever, which is the shape of every silent failure on this + // path. Unreachable is the honest word and it carries a Retry. + Log.w(TAG, "no Moonlight host behind $hostId; rendering it unreachable") + _ui.update { it.copy(moonlight = MoonlightSessionInput(trust = MoonlightTrustState.UNREACHABLE)) } + return + } _ui.update { it.copy(moonlight = MoonlightSessionInput()) } viewModelScope.launch { val probe = moonlight.probe(host) @@ -397,13 +406,24 @@ class ConfigureBindingsViewModel } fun onMoonlightAction(action: MoonlightAction) { - val hostId = _ui.value.draft?.hostId ?: return - val host = moonlight.rememberedHost(hostId) ?: return + val hostId = _ui.value.draft?.hostId + if (hostId == null) { + Log.w(TAG, "Moonlight action $action with no destination chosen") + return + } + val host = moonlight.rememberedHost(hostId) + if (host == null) { + Log.w(TAG, "Moonlight action $action for unknown host $hostId") + refreshMoonlight() + return + } + Log.i(TAG, "Moonlight action $action on ${host.address}") when (action) { MoonlightAction.PAIR, MoonlightAction.PAIR_AGAIN, MoonlightAction.TRY_AGAIN, MoonlightAction.NEW_CODE, -> startMoonlightPairing(host) MoonlightAction.CANCEL -> { + cancelMoonlightPairing() moonlightPairing = null refreshMoonlight() } @@ -422,8 +442,11 @@ class ConfigureBindingsViewModel } } + // A live job is REPLACED, not a reason to do nothing. New code is only ever offered + // while a pairing is in flight, so the old guard made the one button that state + // exists to offer unreachable by construction. private fun startMoonlightPairing(host: com.tinkernorth.dish.core.net.moonlight.MoonlightHost) { - if (pairingJob?.isActive == true) return + cancelMoonlightPairing() pairingJob = viewModelScope.launch { moonlight.pairHost(host) @@ -431,6 +454,11 @@ class ConfigureBindingsViewModel } } + private fun cancelMoonlightPairing() { + pairingJob?.cancel() + pairingJob = null + } + private fun observeMoonlightEvents() { moonlight.events .onEach { event -> onMoonlightEvent(event) } @@ -440,7 +468,10 @@ class ConfigureBindingsViewModel private fun onMoonlightEvent(event: MoonlightConnectionEvent) { when (event) { is MoonlightConnectionEvent.PairingPinReady -> moonlightPairing = MoonlightPairingUi.Pin(event.pin) - is MoonlightConnectionEvent.PairingFailed -> moonlightPairing = MoonlightPairingUi.Failed + is MoonlightConnectionEvent.PairingFailed -> { + Log.w(TAG, "pairing with ${event.host.address} failed: ${event.reason}") + moonlightPairing = MoonlightPairingUi.Failed + } is MoonlightConnectionEvent.Paired -> moonlightPairing = null is MoonlightConnectionEvent.AppAlreadyRunning -> if (!event.resumable) moonlightFailure = MoonlightFailure.BusyOther @@ -552,11 +583,21 @@ class ConfigureBindingsViewModel */ fun apply() { val state = _ui.value - val snapshot = state.snapshot ?: return - val draft = state.draft ?: return - // Apply is gated on canApply (a resolved type); guard defensively so an unresolved type never ships. - val type = draft.type ?: return - val host = state.hosts.firstOrNull { it.id == draft.hostId } ?: return + val snapshot = state.snapshot + val draft = state.draft + // Apply is gated on canApply (a resolved type); guard defensively so an unresolved + // type never ships. Every one of these used to return without a word, so a Bind + // button that could not act was indistinguishable from one that had not been pressed. + val type = draft?.type + val host = state.hosts.firstOrNull { it.id == draft?.hostId } + if (snapshot == null || draft == null || type == null || host == null) { + Log.w( + TAG, + "apply refused: snapshot=${snapshot != null} draft=${draft != null} " + + "type=$type host=${draft?.hostId}", + ) + return + } val hostId = host.id if (_applyState.value is ApplyState.Running) return @@ -868,6 +909,7 @@ class ConfigureBindingsViewModel private fun vpKey(device: PhysicalGamepadRegistry.Device): Int = (device.vendorId shl 16) or device.productId private companion object { + const val TAG = "ConfigureBindingsVM" const val DIRECT_TIMEOUT_MS = 20_000L const val CONNECT_TIMEOUT_MS = 8_000L const val APPLY_TIMEOUT_MS = 8_000L diff --git a/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupConnectionViewModel.kt b/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupConnectionViewModel.kt index c720a751..dff4b1ce 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupConnectionViewModel.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupConnectionViewModel.kt @@ -125,8 +125,12 @@ class SetupConnectionViewModel .onEach { onConnectionEvent(it) } .launchIn(viewModelScope) - combine(hub.connections, moonlight.remembered) { summaries, remembered -> - buildMoonlightRows(summaries, remembered.mapTo(mutableSetOf()) { it.id }) + combine( + hub.connections, + moonlight.remembered, + moonlight.verifiedHostIds, + ) { summaries, remembered, verified -> + buildMoonlightRows(summaries, remembered.filter { it.paired }.mapTo(mutableSetOf()) { it.id }, verified) }.onEach { rows -> _state.update { it.copy(moonlightHosts = rows) } } .launchIn(viewModelScope) @@ -254,13 +258,16 @@ class SetupConnectionViewModel } } + // Only a record that says paired is trust; the same list also carries hosts the + // user added or bound to so a binding cannot lose its host underneath it. private fun buildMoonlightRows( summaries: List, - rememberedIds: Set, + pairedIds: Set, + verifiedIds: Set, ): List = summaries .filter { it.kind == ConnectionKind.MOONLIGHT } - .map { MoonlightRow(it.id, it.label, moonlightTrustFor(it, it.id in rememberedIds)) } + .map { MoonlightRow(it.id, it.label, moonlightTrustFor(it, it.id in pairedIds, it.id in verifiedIds)) } private fun emit(event: Event) { viewModelScope.launch { _events.emit(event) } diff --git a/app/src/main/res/layout/dialog_add_moonlight.xml b/app/src/main/res/layout/dialog_add_moonlight.xml new file mode 100644 index 00000000..ce48387c --- /dev/null +++ b/app/src/main/res/layout/dialog_add_moonlight.xml @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/app/src/main/res/values-bs/strings.xml b/app/src/main/res/values-bs/strings.xml index 461a37f1..e893f7e1 100644 --- a/app/src/main/res/values-bs/strings.xml +++ b/app/src/main/res/values-bs/strings.xml @@ -209,6 +209,12 @@ Ponovo poveži Pokreni sesiju Prikaži kontrolere na %1$s + + Uparen sa %1$s + Dish pokreće sesiju na ovom hostu čim se kontroler poveže s njim. + Dish više ne pronalazi ovaj host. Skenirajte ponovo ili ga dodajte po adresi. + Zaboraviti %1$s? + Dish briše svoje uparivanje i PIN će vam ponovo trebati. %1$s zadržava vlastiti zapis o ovom uređaju dok ga tamo ne uklonite. Moonlight sesija Održava Moonlight sesiju dok je kontroler vezan za nju. Dish · Moonlight diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b7a95dbf..5b5edfa3 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -208,6 +208,12 @@ Neu verbinden Sitzung starten Controller auf %1$s ansehen + + Mit %1$s gekoppelt + Dish startet auf diesem Host eine Sitzung, sobald ein Controller damit verbunden ist. + Dish findet diesen Host nicht mehr. Erneut suchen oder per Adresse hinzufügen. + %1$s entfernen? + Dish löscht die Kopplung und du brauchst die PIN erneut. %1$s behält seinen eigenen Eintrag für dieses Gerät, bis du ihn dort entfernst. Moonlight-Sitzung Hält eine Moonlight-Sitzung aktiv, solange ein Controller damit verknüpft ist. Dish · Moonlight diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7bf63b43..38379a2e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -211,6 +211,12 @@ Reconectar Iniciar una sesión Ver mandos en %1$s + + Vinculado con %1$s + Dish inicia una sesión en este host en cuanto le asignas un mando. + Dish ya no encuentra este host. Vuelve a buscar o añádelo por dirección. + ¿Olvidar %1$s? + Dish borra su vinculación y volverás a necesitar el PIN. %1$s conserva su propio registro de este dispositivo hasta que lo elimines allí. Sesión de Moonlight Mantiene viva una sesión de Moonlight mientras haya un mando vinculado. Dish · Moonlight diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c98a12ed..06f447f8 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -210,6 +210,12 @@ Reconnecter Lancer une session Voir les manettes sur %1$s + + Associé à %1$s + Dish démarre une session sur cet hôte dès que vous y associez une manette. + Dish ne trouve plus cet hôte. Relancez la recherche ou ajoutez-le par adresse. + Oublier %1$s ? + Dish supprime son association et le code vous sera redemandé. %1$s conserve sa propre fiche pour cet appareil tant que vous ne la supprimez pas sur cet hôte. Session Moonlight Maintient une session Moonlight active tant qu\'une manette y est liée. Dish · Moonlight diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 73298e22..820ec3f4 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -211,6 +211,12 @@ Reconectar Iniciar uma sessão Ver controles em %1$s + + Pareado com %1$s + O Dish inicia uma sessão neste host assim que um controle for vinculado a ele. + O Dish não encontra mais este host. Busque novamente ou adicione pelo endereço. + Esquecer %1$s? + O Dish apaga o pareamento e o PIN será necessário de novo. %1$s mantém o próprio registro deste dispositivo até você removê-lo por lá. Sessão do Moonlight Mantém uma sessão do Moonlight ativa enquanto houver um controle vinculado a ela. Dish · Moonlight diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ddf4ed9a..2ad7e060 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -255,6 +255,12 @@ Start a session See controllers on %1$s + Paired with %1$s + Dish starts a session on this host as soon as a controller is bound to it. + Dish can no longer find this host. Scan again, or add it by address. + Forget %1$s? + Dish deletes its pairing and you will need the PIN again. %1$s keeps its own record of this device until you remove it there. + Moonlight session Keeps a Moonlight session alive while a controller is bound to it. Dish · Moonlight diff --git a/app/src/test/java/com/tinkernorth/dish/composer/ConnectionCoordinatorTest.kt b/app/src/test/java/com/tinkernorth/dish/composer/ConnectionCoordinatorTest.kt index 3aafa090..8a67e08e 100644 --- a/app/src/test/java/com/tinkernorth/dish/composer/ConnectionCoordinatorTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/composer/ConnectionCoordinatorTest.kt @@ -170,6 +170,74 @@ class ConnectionCoordinatorTest { verify { hostRuntimeStore.clearConnection("sat:1") } } + // A host known only from a discovery result vanishes on the next browse, which is how a + // bind came to do nothing and take its own configuration with it. + @Test + fun `binding a Moonlight host records it so the binding cannot outlive its destination`() { + val hub = buildHub() + + hub.bind("slot-A", "moonlight:192.168.68.98", CONTROLLER_TYPE_XBOX) + scope.testScheduler.runCurrent() + + verify { moonlight.rememberInterest("moonlight:192.168.68.98") } + assertEquals("moonlight:192.168.68.98", hub.bindings.value["slot-A"]) + } + + @Test + fun `binding a satellite host never reaches the Moonlight store`() { + val hub = buildHub() + + hub.bind("slot-A", "satellite:10.0.0.1:9876", CONTROLLER_TYPE_XBOX) + scope.testScheduler.runCurrent() + + verify(exactly = 0) { moonlight.rememberInterest(any()) } + } + + @Test + fun `forgetConnection unbinds every slot before it forgets the Moonlight host`() { + val hub = buildHub() + hub.bind("slot-A", "moonlight:192.168.68.98", CONTROLLER_TYPE_PLAYSTATION) + hub.bind("slot-B", "moonlight:192.168.68.98", CONTROLLER_TYPE_XBOX) + scope.testScheduler.runCurrent() + + hub.forgetConnection("moonlight:192.168.68.98") + scope.testScheduler.runCurrent() + + assertNull(hub.bindings.value["slot-A"]) + assertNull(hub.bindings.value["slot-B"]) + assertNull(hub.satTypes.value["moonlight:192.168.68.98" to "slot-A"]) + assertNull(hub.satTypes.value["moonlight:192.168.68.98" to "slot-B"]) + verify { moonlight.forget("moonlight:192.168.68.98") } + } + + // Type is per binding, host is per session: two pads on one host keep their own picks, + // and a rebind must not leak the old host's row. + @Test + fun `two Moonlight bindings on one host keep their own controller types`() { + val hub = buildHub() + + hub.bind("slot-A", "moonlight:192.168.68.98", CONTROLLER_TYPE_PLAYSTATION) + hub.bind("slot-B", "moonlight:192.168.68.98", CONTROLLER_TYPE_XBOX) + scope.testScheduler.runCurrent() + + assertEquals(CONTROLLER_TYPE_PLAYSTATION, hub.satTypes.value["moonlight:192.168.68.98" to "slot-A"]) + assertEquals(CONTROLLER_TYPE_XBOX, hub.satTypes.value["moonlight:192.168.68.98" to "slot-B"]) + } + + @Test + fun `moving a binding from one Moonlight host to another drops the prior type`() { + val hub = buildHub() + hub.bind("slot-A", "moonlight:10.0.0.1", CONTROLLER_TYPE_PLAYSTATION) + scope.testScheduler.runCurrent() + + hub.bind("slot-A", "moonlight:10.0.0.2", CONTROLLER_TYPE_XBOX) + scope.testScheduler.runCurrent() + + assertNull(hub.satTypes.value["moonlight:10.0.0.1" to "slot-A"]) + assertEquals(CONTROLLER_TYPE_XBOX, hub.satTypes.value["moonlight:10.0.0.2" to "slot-A"]) + assertEquals("moonlight:10.0.0.2", hub.bindings.value["slot-A"]) + } + @Test fun `forgetConnection forgets a remembered bluetooth host`() { btEntriesFlow.value = diff --git a/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightSessionFailureTest.kt b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightSessionFailureTest.kt new file mode 100644 index 00000000..ae3b219c --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightSessionFailureTest.kt @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.source.connection.moonlight + +import android.content.Context +import android.content.SharedPreferences +import com.tinkernorth.dish.core.net.moonlight.MoonlightEmulatedType +import com.tinkernorth.dish.core.net.moonlight.MoonlightIdentity +import com.tinkernorth.dish.core.net.moonlight.RememberedMoonlight +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * What the manager says when a session will not start, and what it does about it. + * + * A MOONLIGHT HOST REFUSES IN THE BODY, NOT IN THE STATUS LINE, so every case here + * answers HTTP 200 and disagrees inside it. The render side of these states is + * covered by MoonlightSessionUiTest; this suite is about which event carries which + * refusal and whether the host is left holding an app it started for us. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MoonlightSessionFailureTest { + private val dispatcher = StandardTestDispatcher() + private lateinit var gateway: MoonlightHttpGateway + private lateinit var store: com.tinkernorth.dish.repository.RememberedMoonlightRepository + private lateinit var manager: MoonlightConnectionManager + + private val remembered = + RememberedMoonlight( + id = "moonlight:uid:abc", + name = "PC", + address = "10.0.0.5", + uniqueId = "abc", + lastAppId = "1", + lastAppName = "Desktop", + ) + + private val serverInfo = + """PCabc + 10""" + + private val appList = + """Desktop1""" + + private fun reply(body: String) = MoonlightHttpGateway.Reply(status = 200, body = body) + + private fun pad(slotId: String) = + MoonlightPadRequest( + slotId = slotId, + emulatedType = MoonlightEmulatedType.XBOX, + capabilities = 0x03, + supportedButtons = 0xFFFF, + ) + + @Before + fun setUp() { + val prefs = mockk(relaxed = true) + every { prefs.getString("uniqueid", null) } returns "0123456789abcdef" + val context = mockk(relaxed = true) + every { context.getSharedPreferences(any(), any()) } returns prefs + + gateway = mockk(relaxed = true) + every { gateway.getHttp(match { it.contains("/serverinfo") }, any()) } returns reply(serverInfo) + every { gateway.getHttps(match { it.contains("/serverinfo") }, any()) } returns reply(serverInfo) + every { gateway.getHttps(match { it.contains("/applist") }, any()) } returns reply(appList) + every { gateway.getHttps(match { it.contains("/cancel") }, any()) } returns + reply("""1""") + + store = mockk(relaxed = true) + every { store.get(remembered.id) } returns remembered + every { store.entries } returns MutableStateFlow(listOf(remembered)) + + manager = + MoonlightConnectionManager( + context = context, + scope = TestScope(dispatcher), + ioDispatcher = dispatcher, + discovery = mockk(relaxed = true), + gateway = gateway, + identity = mockk(relaxed = true), + store = store, + ) + } + + private fun TestScope.collectEvents(into: MutableList): Job { + val job = launch { manager.events.toList(into) } + dispatcher.scheduler.runCurrent() + return job + } + + private fun bindOnePad() { + manager.applyDesired(mapOf(remembered.id to listOf(pad("a")))) + dispatcher.scheduler.advanceUntilIdle() + } + + // M15. Sunshine answers a second /launch with HTTP 200 carrying status_code 400, so + // reading only the status line turned a refusal into a generic failure that named the + // symptom and hid the cause. + @Test + fun `a session another device holds is reported as busy and never resumed`() = + runTest(dispatcher) { + every { gateway.getHttps(match { it.contains("/launch") }, any()) } returns + reply( + """ + 0""", + ) + val seen = mutableListOf() + val collector = collectEvents(seen) + + bindOnePad() + + val busy = seen.filterIsInstance().single() + assertTrue("somebody else holds it, so there is nothing to resume", !busy.resumable) + verify(exactly = 0) { gateway.getHttps(match { it.contains("/resume") }, any()) } + collector.cancel() + } + + // M16. resume=1 is never shown to the user: it means the running session is ours, so + // Dish resumes silently. This state exists only for the silent resume then failing. + @Test + fun `a resume the host promised and then refused is a rejoin failure`() = + runTest(dispatcher) { + every { gateway.getHttps(match { it.contains("/launch") }, any()) } returns + reply( + """ + 1""", + ) + every { gateway.getHttps(match { it.contains("/resume") }, any()) } returns + reply("""""") + val seen = mutableListOf() + val collector = collectEvents(seen) + + bindOnePad() + + assertEquals(1, seen.filterIsInstance().size) + assertTrue(seen.none { it is MoonlightConnectionEvent.AppAlreadyRunning }) + collector.cancel() + } + + // M17. Anything else the host refuses is quoted back in its own wording, because only + // the host knows why. + @Test + fun `any other refusal carries the hosts own wording`() = + runTest(dispatcher) { + every { gateway.getHttps(match { it.contains("/launch") }, any()) } returns + reply("""""") + val seen = mutableListOf() + val collector = collectEvents(seen) + + bindOnePad() + + assertEquals("Unauthorized", seen.filterIsInstance().single().message) + collector.cancel() + } + + // M18. The host started an app on our behalf, so a setup that then fails takes it back + // down; otherwise every later attempt is refused by the app we ourselves left running. + @Test + fun `a launch that succeeds and a stream that does not is cancelled again`() = + runTest(dispatcher) { + every { gateway.getHttps(match { it.contains("/launch") }, any()) } returns + reply("""rtsp://10.0.0.5:48010""") + val seen = mutableListOf() + val collector = collectEvents(seen) + + bindOnePad() + + assertEquals(1, seen.filterIsInstance().size) + verify(atLeast = 1) { gateway.getHttps(match { it.contains("/cancel") }, any()) } + assertEquals(MoonlightSessionState.Idle, manager.get(remembered.id)?.state?.value) + collector.cancel() + } + + // M14. Four is a protocol ceiling: there is no fifth controller number to hand out. + @Test + fun `a fifth pad on one host is reported full rather than silently dropped`() = + runTest(dispatcher) { + every { gateway.getHttps(match { it.contains("/launch") }, any()) } returns + reply("""rtsp://10.0.0.5:48010""") + bindOnePad() + val seen = mutableListOf() + val collector = collectEvents(seen) + + manager.applyDesired( + mapOf(remembered.id to listOf(pad("a"), pad("b"), pad("c"), pad("d"), pad("e"))), + ) + dispatcher.scheduler.advanceUntilIdle() + + assertTrue(manager.get(remembered.id)!!.padCount <= MoonlightConnection.MAX_PADS) + collector.cancel() + } + + // A retry is the same session being reopened, never one attempt per binding. + @Test + fun `retrying a refused session re-attempts what the bindings already asked for`() = + runTest(dispatcher) { + every { gateway.getHttps(match { it.contains("/launch") }, any()) } returns + reply("""""") + bindOnePad() + + manager.retrySessions() + dispatcher.scheduler.advanceUntilIdle() + + verify(exactly = 2) { gateway.getHttps(match { it.contains("/launch") }, any()) } + } + + // /cancel answers 200 whether or not anything was running, so a successful cancel + // proves nothing and the caller re-probes rather than believing it. What this asserts + // is that the pads are released, which is the part Dish does control. + @Test + fun `quitting the app on a host drops its pads and says so`() = + runTest(dispatcher) { + every { gateway.getHttps(match { it.contains("/launch") }, any()) } returns + reply("""rtsp://10.0.0.5:48010""") + bindOnePad() + val seen = mutableListOf() + val collector = collectEvents(seen) + + manager.quitHostApp(remembered.toHost()) + dispatcher.scheduler.advanceUntilIdle() + + assertEquals(0, manager.get(remembered.id)?.padCount) + assertTrue(seen.any { it is MoonlightConnectionEvent.Notice }) + verify(atLeast = 1) { gateway.getHttps(match { it.contains("/cancel") }, any()) } + collector.cancel() + } + + // A session is re-probed immediately before it is opened, so a pairing the host has + // dropped since stops the launch instead of failing further down. + @Test + fun `a host that has stopped trusting this device is not launched`() = + runTest(dispatcher) { + every { gateway.getHttps(match { it.contains("/serverinfo") }, any()) } returns + MoonlightHttpGateway.Reply(status = 401, body = "") + + bindOnePad() + + verify(exactly = 0) { gateway.getHttps(match { it.contains("/launch") }, any()) } + assertEquals(MoonlightSessionState.Idle, manager.get(remembered.id)?.state?.value) + } + + // A binding is a durable intent, so a host that will not answer at all keeps its pads + // claimed and simply does not open: nothing is unbound behind the user's back. + @Test + fun `a host that answers nothing keeps its bindings and opens no session`() = + runTest(dispatcher) { + every { gateway.getHttp(match { it.contains("/serverinfo") }, any()) } returns + MoonlightHttpGateway.Reply(status = 0, body = "") + + bindOnePad() + + assertEquals(1, manager.get(remembered.id)?.padCount) + verify(exactly = 0) { gateway.getHttps(match { it.contains("/launch") }, any()) } + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightTrustFlowTest.kt b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightTrustFlowTest.kt new file mode 100644 index 00000000..2ae41ad8 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightTrustFlowTest.kt @@ -0,0 +1,510 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.source.connection.moonlight + +import android.content.Context +import android.content.SharedPreferences +import com.tinkernorth.dish.core.net.moonlight.MoonlightHost +import com.tinkernorth.dish.core.net.moonlight.MoonlightIdentity +import com.tinkernorth.dish.core.net.moonlight.RememberedMoonlight +import com.tinkernorth.dish.repository.RememberedMoonlightRepository +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * The whole trust half of the Moonlight flow, end to end: discovery, pairing, + * re-pairing, forget, and what each leaves behind on THIS side. + * + * The two live failures this suite locks down were both about state that was never + * written. A host the host itself still trusted was confirmed and not recorded, so + * pairing appeared to do nothing forever; and a host that had never completed a + * pairing was never recorded at all, so it lived only in the discovery list and took + * any binding pointing at it down with it on the next scan. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MoonlightTrustFlowTest { + private val dispatcher = StandardTestDispatcher() + private lateinit var gateway: MoonlightHttpGateway + private lateinit var discovery: MdnsMoonlightDiscovery + private lateinit var store: RememberedMoonlightRepository + private lateinit var manager: MoonlightConnectionManager + + /** What the fake store holds, so a test can assert on the record and not on a call. */ + private val rows = linkedMapOf() + private val entries = MutableStateFlow>(emptyList()) + + // The address-keyed form, because the live hosts publish no uniqueid TXT record and + // that is the id every one of them is actually filed under. + private val host = + MoonlightHost(name = "PC", address = "192.168.68.98", httpPort = 47989, httpsPort = 47984) + + private val pairedInfo = + """PChost-1 + 10""" + + private val unpairedInfo = + """PChost-1 + 00""" + + private val appList = + """Desktop1""" + + private fun reply(body: String) = MoonlightHttpGateway.Reply(status = 200, body = body) + + private fun unreachable() = MoonlightHttpGateway.Reply(status = 0, body = "") + + @Before + fun setUp() { + val prefs = mockk(relaxed = true) + every { prefs.getString("uniqueid", null) } returns "7b5d0738cbb54d3e" + val context = mockk(relaxed = true) + every { context.getSharedPreferences(any(), any()) } returns prefs + + gateway = mockk(relaxed = true) + every { gateway.getHttp(match { it.contains("/serverinfo") }, any()) } returns reply(pairedInfo) + every { gateway.getHttps(match { it.contains("/serverinfo") }, any()) } returns reply(pairedInfo) + every { gateway.getHttps(match { it.contains("/applist") }, any()) } returns reply(appList) + every { gateway.getHttps(match { it.contains("/cancel") }, any()) } returns + reply("""1""") + + discovery = mockk(relaxed = true) + + // A store backed by a real map: these tests are about what survives a flow, and a + // relaxed mock would answer every read with null no matter what the flow wrote. + store = mockk(relaxed = true) + every { store.get(any()) } answers { rows[firstArg()] } + every { store.all() } answers { rows.values.toList() } + every { store.entries } returns entries + every { store.put(any()) } answers { + val row = firstArg() + rows[row.id] = row + entries.value = rows.values.toList() + } + every { store.remove(any()) } answers { + rows.remove(firstArg()) + entries.value = rows.values.toList() + } + + manager = newManager() + } + + private fun newManager() = + MoonlightConnectionManager( + context = + mockk(relaxed = true).also { ctx -> + val prefs = mockk(relaxed = true) + every { prefs.getString("uniqueid", null) } returns "7b5d0738cbb54d3e" + every { ctx.getSharedPreferences(any(), any()) } returns prefs + }, + scope = TestScope(dispatcher), + ioDispatcher = dispatcher, + discovery = discovery, + gateway = gateway, + identity = mockk(relaxed = true), + store = store, + ) + + // ── Pairing ──────────────────────────────────────────────────────────────── + + // THE SYMPTOM-B REGRESSION. The host authorises by client certificate, so a device + // that forgot a host the host still trusts is answered without a PIN. That answer used + // to be emitted and thrown away: nothing was written, the row kept saying Not paired, + // and the only trace of the whole action was a /serverinfo in the host's own log. + @Test + fun `a host that already trusts this device is recorded, not just announced`() = + runTest(dispatcher) { + val seen = mutableListOf() + val collector = launch { manager.events.toList(seen) } + dispatcher.scheduler.runCurrent() + + assertTrue(manager.pairHost(host)) + dispatcher.scheduler.advanceUntilIdle() + + val record = rows[host.id] + assertNotNull("the confirmed pairing has to persist", record) + assertTrue("a confirmed pairing is a pairing", record!!.paired) + assertEquals(host.address, record.address) + assertTrue(seen.any { it is MoonlightConnectionEvent.Paired }) + collector.cancel() + } + + @Test + fun `confirming trust needs no PIN exchange at all`() = + runTest(dispatcher) { + manager.pairHost(host) + dispatcher.scheduler.advanceUntilIdle() + + verify(exactly = 0) { gateway.getHttp(match { it.contains("/pair") }, any()) } + } + + // The host was verified this visit, which is the only proof there is that the pairing + // stands; the hosts screen reads it so a successful pair visibly changes the row. + @Test + fun `a confirmed host is marked verified for this process`() = + runTest(dispatcher) { + assertFalse(host.id in manager.verifiedHostIds.value) + + manager.pairHost(host) + dispatcher.scheduler.advanceUntilIdle() + + assertTrue(host.id in manager.verifiedHostIds.value) + } + + @Test + fun `a host that refuses phase 1 fails with a reason and writes nothing`() = + runTest(dispatcher) { + every { gateway.getHttps(match { it.contains("/serverinfo") }, any()) } returns unreachable() + every { gateway.getHttp(match { it.contains("/pair") }, any()) } returns reply("""""") + val seen = mutableListOf() + val collector = launch { manager.events.toList(seen) } + dispatcher.scheduler.runCurrent() + + assertFalse(manager.pairHost(host)) + dispatcher.scheduler.advanceUntilIdle() + + val failure = seen.filterIsInstance().single() + assertTrue("the reason has to name the step", failure.reason.contains("phase 1")) + assertNull(rows[host.id]) + collector.cancel() + } + + @Test + fun `a PIN is offered before phase 1 blocks on the human`() = + runTest(dispatcher) { + every { gateway.getHttps(match { it.contains("/serverinfo") }, any()) } returns unreachable() + every { gateway.getHttp(match { it.contains("/pair") }, any()) } returns reply("""""") + val seen = mutableListOf() + val collector = launch { manager.events.toList(seen) } + dispatcher.scheduler.runCurrent() + + manager.pairHost(host) + dispatcher.scheduler.advanceUntilIdle() + + val pin = seen.filterIsInstance().single() + assertEquals(4, pin.pin.length) + collector.cancel() + } + + // ── Forget ───────────────────────────────────────────────────────────────── + + @Test + fun `forget leaves no record, no pin and no verification behind`() = + runTest(dispatcher) { + manager.pairHost(host) + dispatcher.scheduler.advanceUntilIdle() + assertNotNull(rows[host.id]) + + manager.forget(host.id) + dispatcher.scheduler.advanceUntilIdle() + + assertNull("the record must go", rows[host.id]) + assertFalse("the verification must go", host.id in manager.verifiedHostIds.value) + assertTrue("the discovery row must go", manager.discovered.value.none { it.id == host.id }) + // The pin used to survive a forget, so a host that rotated its certificate + // afterwards was refused with no way past it from inside the app. + verify { gateway.forgetPin(host.id) } + } + + @Test + fun `forgetting a host with a live session closes the app it is running`() = + runTest(dispatcher) { + openLiveSession() + + manager.forget(host.id) + dispatcher.scheduler.advanceUntilIdle() + + verify(atLeast = 1) { gateway.getHttps(match { it.contains("/cancel") }, any()) } + } + + @Test + fun `forgetting a host that never had a session cancels nothing`() = + runTest(dispatcher) { + manager.pairHost(host) + dispatcher.scheduler.advanceUntilIdle() + + manager.forget(host.id) + dispatcher.scheduler.advanceUntilIdle() + + verify(exactly = 0) { gateway.getHttps(match { it.contains("/cancel") }, any()) } + } + + // ── Re-pairing after forget: the exact state the user was stranded in ────── + + // Pair, forget, pair again. The host never stops trusting this device (the protocol has + // no unpair verb), so the second pairing takes the confirm branch, and before the fix + // that branch wrote nothing: the user could press Pair forever with no change anywhere. + @Test + fun `pairing again after a forget puts the two sides back into agreement`() = + runTest(dispatcher) { + manager.pairHost(host) + dispatcher.scheduler.advanceUntilIdle() + manager.forget(host.id) + dispatcher.scheduler.advanceUntilIdle() + assertNull(rows[host.id]) + + assertTrue(manager.pairHost(host)) + dispatcher.scheduler.advanceUntilIdle() + + assertNotNull("the second pairing has to restore the record", rows[host.id]) + assertTrue(rows[host.id]!!.paired) + assertTrue(host.id in manager.verifiedHostIds.value) + } + + // ── Discovery ────────────────────────────────────────────────────────────── + + // THE SYMPTOM-A REGRESSION. A scan used to assign its result outright, so a browse that + // missed erased every host that was only ever discovered, and a binding pointing at one + // lost its connection summary, its pads and its session with it. + @Test + fun `a scan that finds nothing keeps what the last scan found`() = + runTest(dispatcher) { + coEvery { discovery.discover(any()) } returns listOf(host) + manager.startDiscovery() + dispatcher.scheduler.advanceUntilIdle() + assertEquals(listOf(host.id), manager.discovered.value.map { it.id }) + + coEvery { discovery.discover(any()) } returns emptyList() + manager.startDiscovery() + dispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf(host.id), manager.discovered.value.map { it.id }) + } + + @Test + fun `a scan that throws keeps what the last scan found`() = + runTest(dispatcher) { + coEvery { discovery.discover(any()) } returns listOf(host) + manager.startDiscovery() + dispatcher.scheduler.advanceUntilIdle() + + coEvery { discovery.discover(any()) } throws java.io.IOException("no multicast") + manager.startDiscovery() + dispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf(host.id), manager.discovered.value.map { it.id }) + } + + @Test + fun `a re-scan refreshes a host in place instead of duplicating it`() = + runTest(dispatcher) { + coEvery { discovery.discover(any()) } returns listOf(host) + manager.startDiscovery() + dispatcher.scheduler.advanceUntilIdle() + + coEvery { discovery.discover(any()) } returns listOf(host.copy(name = "PC renamed")) + manager.startDiscovery() + dispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf("PC renamed"), manager.discovered.value.map { it.name }) + } + + // Typing an address is durable intent, so the host outlives the discovery list it would + // otherwise be the only copy of. + @Test + fun `a manually added host is remembered without claiming a pairing`() = + runTest(dispatcher) { + manager.addManualHost("192.168.68.98") + dispatcher.scheduler.advanceUntilIdle() + + val record = rows.values.single() + assertEquals("192.168.68.98", record.address) + assertFalse("adding is not pairing", record.paired) + } + + @Test + fun `an address nothing answers is reported and not remembered`() = + runTest(dispatcher) { + every { gateway.getHttp(match { it.contains("/serverinfo") }, any()) } returns unreachable() + val seen = mutableListOf() + val collector = launch { manager.events.toList(seen) } + dispatcher.scheduler.runCurrent() + + manager.addManualHost("192.168.68.5") + dispatcher.scheduler.advanceUntilIdle() + + assertTrue(seen.any { it is MoonlightConnectionEvent.Error }) + assertTrue(rows.isEmpty()) + collector.cancel() + } + + // ── Durable interest ─────────────────────────────────────────────────────── + + @Test + fun `remembering interest never promotes a host to paired`() = + runTest(dispatcher) { + manager.rememberInterest(host) + dispatcher.scheduler.advanceUntilIdle() + + assertFalse(rows.getValue(host.id).paired) + } + + @Test + fun `remembering interest never demotes a host that is already paired`() = + runTest(dispatcher) { + manager.pairHost(host) + dispatcher.scheduler.advanceUntilIdle() + + manager.rememberInterest(host) + dispatcher.scheduler.advanceUntilIdle() + + assertTrue(rows.getValue(host.id).paired) + } + + @Test + fun `a host known only from a scan can be remembered by id`() = + runTest(dispatcher) { + coEvery { discovery.discover(any()) } returns listOf(host) + manager.startDiscovery() + dispatcher.scheduler.advanceUntilIdle() + + manager.rememberInterest(host.id) + dispatcher.scheduler.advanceUntilIdle() + + assertNotNull(rows[host.id]) + } + + // ── The app pick ─────────────────────────────────────────────────────────── + + // The pick used to be dropped for any host with no record, which was every host the + // user had only discovered: the row rendered as chosen and the session then started + // whatever the host listed first. + @Test + fun `an app picked on a host with no record yet is kept`() = + runTest(dispatcher) { + coEvery { discovery.discover(any()) } returns listOf(host) + manager.startDiscovery() + dispatcher.scheduler.advanceUntilIdle() + + manager.rememberApp(host.id, "1", "Desktop") + dispatcher.scheduler.advanceUntilIdle() + + assertEquals("1", manager.rememberedAppId(host.id)) + assertEquals("Desktop", manager.rememberedAppName(host.id)) + } + + @Test + fun `an app picked on a paired host does not disturb its trust`() = + runTest(dispatcher) { + manager.pairHost(host) + dispatcher.scheduler.advanceUntilIdle() + + manager.rememberApp(host.id, "7", "Steam") + dispatcher.scheduler.advanceUntilIdle() + + assertTrue(rows.getValue(host.id).paired) + assertEquals("7", rows.getValue(host.id).lastAppId) + } + + // ── Probe verdicts ───────────────────────────────────────────────────────── + + @Test + fun `a paired host that stops answering is remembered, not unknown`() = + runTest(dispatcher) { + manager.pairHost(host) + dispatcher.scheduler.advanceUntilIdle() + every { gateway.getHttp(match { it.contains("/serverinfo") }, any()) } returns unreachable() + + assertEquals(MoonlightTrustState.REMEMBERED, manager.probe(host).trust) + } + + // A record written for interest is not a pairing, so the honest verdict for a host + // nothing answers for is still "never paired". + @Test + fun `a host remembered only as interest reads as unreachable when it goes quiet`() = + runTest(dispatcher) { + manager.rememberInterest(host) + every { gateway.getHttp(match { it.contains("/serverinfo") }, any()) } returns unreachable() + + assertEquals(MoonlightTrustState.UNREACHABLE, manager.probe(host).trust) + } + + @Test + fun `a host that answers unpaired with a pairing stored has lost trust`() = + runTest(dispatcher) { + manager.pairHost(host) + dispatcher.scheduler.advanceUntilIdle() + every { gateway.getHttp(match { it.contains("/serverinfo") }, any()) } returns reply(unpairedInfo) + + assertEquals(MoonlightTrustState.TRUST_LOST, manager.probe(host).trust) + } + + @Test + fun `a host that answers unpaired with nothing stored has simply never paired`() = + runTest(dispatcher) { + every { gateway.getHttp(match { it.contains("/serverinfo") }, any()) } returns reply(unpairedInfo) + + assertEquals(MoonlightTrustState.NOT_PAIRED, manager.probe(host).trust) + } + + @Test + fun `a host answering under a new identity is replaced, not merely untrusted`() = + runTest(dispatcher) { + manager.pairHost(host.copy(uniqueId = "host-1")) + dispatcher.scheduler.advanceUntilIdle() + val replaced = + """host-21 + 0""" + every { gateway.getHttp(match { it.contains("/serverinfo") }, any()) } returns reply(replaced) + + assertEquals( + MoonlightTrustState.REPLACED, + manager.probe(host.copy(uniqueId = "host-1")).trust, + ) + } + + @Test + fun `a host that will not answer mutual TLS has lost trust`() = + runTest(dispatcher) { + manager.pairHost(host) + dispatcher.scheduler.advanceUntilIdle() + every { gateway.getHttps(match { it.contains("/serverinfo") }, any()) } returns unreachable() + + assertEquals(MoonlightTrustState.TRUST_LOST, manager.probe(host).trust) + } + + @Test + fun `a paired host whose app list will not load still reads as paired`() = + runTest(dispatcher) { + every { gateway.getHttps(match { it.contains("/applist") }, any()) } returns + MoonlightHttpGateway.Reply(status = 401, body = "") + + val probe = manager.probe(host) + + assertEquals(MoonlightTrustState.PAIRED, probe.trust) + assertTrue(probe.appsFailed) + assertFalse(probe.appsFetched) + } + + private suspend fun openLiveSession() { + every { gateway.getHttps(match { it.contains("/launch") }, any()) } returns + reply("""rtsp://192.168.68.98:48010""") + manager.pairHost(host) + dispatcher.scheduler.advanceUntilIdle() + manager.applyDesired( + mapOf( + host.id to + listOf( + MoonlightPadRequest(slotId = "a", emulatedType = 1, capabilities = 0x03, supportedButtons = 0xFFFF), + ), + ), + ) + dispatcher.scheduler.advanceUntilIdle() + } +} diff --git a/app/src/test/java/com/tinkernorth/dish/ui/connections/MoonlightRowsTest.kt b/app/src/test/java/com/tinkernorth/dish/ui/connections/MoonlightRowsTest.kt index c22d6e23..d171813d 100644 --- a/app/src/test/java/com/tinkernorth/dish/ui/connections/MoonlightRowsTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/ui/connections/MoonlightRowsTest.kt @@ -41,18 +41,28 @@ class MoonlightRowsTest { assertTrue(rows.isEmpty()) } - // The three trust words, and never a liveness light: a live session proves the pairing - // stands, a stored record means it is remembered but unverified, anything else is not paired. + // The three trust words, and never a liveness light: a live session or a call the host + // authorised this visit proves the pairing stands, a stored record only remembers it, + // anything else is not paired. @Test fun `a live session proves the pairing, a stored record only remembers it`() { val live = summary("moonlight:uid:a", live = LinkState.Connected) - assertEquals(MoonlightTrustState.PAIRED, moonlightTrustFor(live, remembered = false)) + assertEquals(MoonlightTrustState.PAIRED, moonlightTrustFor(live, paired = false)) assertEquals( MoonlightTrustState.PAIRED, - moonlightTrustFor(summary("moonlight:uid:a", live = LinkState.Unstable), remembered = true), + moonlightTrustFor(summary("moonlight:uid:a", live = LinkState.Unstable), paired = true), ) - assertEquals(MoonlightTrustState.REMEMBERED, moonlightTrustFor(summary("moonlight:uid:a"), remembered = true)) - assertEquals(MoonlightTrustState.NOT_PAIRED, moonlightTrustFor(summary("moonlight:uid:a"), remembered = false)) + assertEquals(MoonlightTrustState.REMEMBERED, moonlightTrustFor(summary("moonlight:uid:a"), paired = true)) + assertEquals(MoonlightTrustState.NOT_PAIRED, moonlightTrustFor(summary("moonlight:uid:a"), paired = false)) + } + + // The hosts screen never probes, so without this a pairing the user just watched + // succeed still read as merely remembered. + @Test + fun `a host verified this visit reads as paired without a session`() { + val idle = summary("moonlight:uid:a") + assertEquals(MoonlightTrustState.PAIRED, moonlightTrustFor(idle, paired = true, verified = true)) + assertEquals(MoonlightTrustState.PAIRED, moonlightTrustFor(idle, paired = false, verified = true)) } @Test @@ -61,13 +71,25 @@ class MoonlightRowsTest { moonlightRows( conns = listOf(summary("moonlight:uid:a", live = LinkState.Connected, boundSlotIds = listOf("1", "2"))), discovered = emptyList(), - rememberedIds = setOf("moonlight:uid:a"), + pairedIds = setOf("moonlight:uid:a"), ) val known = rows.single() as MoonlightRow.Known assertEquals(MoonlightTrustState.PAIRED, known.trust) assertEquals(2, known.controllerCount) } + // A record written for a binding has never been accepted by the host it names. + @Test + fun `a host remembered as interest only is not paired`() { + val rows = + moonlightRows( + conns = listOf(summary("moonlight:10.0.0.9")), + discovered = emptyList(), + pairedIds = emptySet(), + ) + assertEquals(MoonlightTrustState.NOT_PAIRED, (rows.single() as MoonlightRow.Known).trust) + } + @Test fun `a discovered host is never claimed as remembered`() { val rows = moonlightRows(emptyList(), listOf(MoonlightHost(name = "B", address = "10.0.0.2", uniqueId = "b"))) diff --git a/app/src/test/java/com/tinkernorth/dish/ui/setup/SetupConnectionViewModelTest.kt b/app/src/test/java/com/tinkernorth/dish/ui/setup/SetupConnectionViewModelTest.kt index ecae3c6c..b23462e2 100644 --- a/app/src/test/java/com/tinkernorth/dish/ui/setup/SetupConnectionViewModelTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/ui/setup/SetupConnectionViewModelTest.kt @@ -48,6 +48,7 @@ class SetupConnectionViewModelTest { private val scanning = MutableStateFlow(false) private val moonlightScanning = MutableStateFlow(false) private val rememberedMoonlight = MutableStateFlow>(emptyList()) + private val verifiedMoonlight = MutableStateFlow>(emptySet()) private val events = MutableSharedFlow(extraBufferCapacity = 8) private val server = DiscoveredServer(name = "Living Room", ip = "10.0.0.5", machineId = "abc123") @@ -66,6 +67,7 @@ class SetupConnectionViewModelTest { every { satellite.events } returns events every { hub.connections } returns summaries every { moonlight.remembered } returns rememberedMoonlight + every { moonlight.verifiedHostIds } returns verifiedMoonlight every { moonlight.isScanning } returns moonlightScanning vm = SetupConnectionViewModel(satellite, moonlight, hub) } @@ -127,6 +129,30 @@ class SetupConnectionViewModelTest { ) } + // A record written because the user bound to the host is not a pairing, and the list + // must not promote it to one; a mutual-TLS call the host authorised is what does. + @Test + fun `a host remembered without a pairing stays not paired until it is verified`() = + runTest(dispatcher) { + summaries.value = listOf(moonlightSummary()) + rememberedMoonlight.value = + listOf(RememberedMoonlight(id = MOONLIGHT_ID, name = "PC", address = "10.0.0.5", paired = false)) + dispatcher.scheduler.runCurrent() + assertEquals( + listOf(MoonlightTrustState.NOT_PAIRED), + vm.state.value.moonlightHosts + .map { it.trust }, + ) + + verifiedMoonlight.value = setOf(MOONLIGHT_ID) + dispatcher.scheduler.runCurrent() + assertEquals( + listOf(MoonlightTrustState.PAIRED), + vm.state.value.moonlightHosts + .map { it.trust }, + ) + } + @Test fun `back from the Moonlight list rewinds to the path pick`() = runTest(dispatcher) { From 7ccf380b1eda636e8bb3df4350b9470060812f3c Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Wed, 26 Aug 2026 18:13:37 -0400 Subject: [PATCH 19/20] fix: take forget off the caller's thread, and cancel before the pin goes forget reaches the manager straight from a row tap, and it now sends /cancel for a live session, which is a blocking mutual-TLS call. On the main thread that is a NetworkOnMainThreadException at worst and a stall at best. The whole sequence moves onto the IO dispatcher, where quitHostApp already runs. The ORDER inside it is the part worth guarding, and there is a test for it now. The cancel authenticates over mutual TLS, so it has to go out while the pinned certificate is still stored. Dropping the pin first would leave the handshake with nothing to compare against, TOFU would read that as first contact, and it would write a fresh pin straight back over the forget that was in progress. Also one emission per scan rather than one per host found: every downstream composer re-derives the whole connection list per emission, so merging host by host rebuilt it once for each host the browse returned. --- .../moonlight/MoonlightConnectionManager.kt | 34 ++++++++++++------- .../moonlight/MoonlightTrustFlowTest.kt | 18 ++++++++++ 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt index 28d3d475..a3c24c20 100644 --- a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt @@ -226,7 +226,10 @@ class MoonlightConnectionManager .onFailure { Log.w(TAG, "discovery failed: ${it.message}", it) } .getOrDefault(emptyList()) Log.i(TAG, "discovery found ${found.size} host(s), had ${_discovered.value.size}") - found.forEach { _discovered.mergeHost(it) } + // One emission for the whole scan: every downstream composer re-derives + // the connection list per emission, so merging host by host would rebuild + // it once per host found. + _discovered.value = _discovered.value.filterNot { old -> found.any { it.id == old.id } } + found _isScanning.value = false } } @@ -262,7 +265,7 @@ class MoonlightConnectionManager } private fun MutableStateFlow>.mergeHost(host: MoonlightHost) { - value = (value.filterNot { it.id == host.id } + host) + value = value.filterNot { it.id == host.id } + host } private fun externalPortOr(info: MoonlightXml.ServerInfo): Int = info.externalPort ?: MoonlightHost.DEFAULT_HTTP_PORT @@ -794,17 +797,22 @@ class MoonlightConnectionManager * there. The confirmation copy says so. */ fun forget(id: String) { - val host = hostFor(id) - Log.i(TAG, "forgetting ${host?.address ?: id}") - // Cancel before the credentials go: afterwards there is nothing left to - // authenticate one with, and the host keeps running the app regardless. - releaseSessionFor(id, host) - store.remove(id) - gateway.forgetPin(id) - _connections.updateAndGet { it - id } - _discovered.value = _discovered.value.filterNot { it.id == id } - _verifiedHostIds.value = _verifiedHostIds.value - id - publishSessionHosts() + // Off the caller's thread because the /cancel below is a blocking mutual-TLS + // call and this is reached straight from a row tap. The ORDER inside is what + // makes it one step and not three: the cancel has to go before the pin does, + // or the handshake it needs finds no pin, trusts the host on first use, and + // writes a new one over the top of the forget. + scope.launch(ioDispatcher) { + val host = hostFor(id) + Log.i(TAG, "forgetting ${host?.address ?: id}") + releaseSessionFor(id, host) + store.remove(id) + gateway.forgetPin(id) + _connections.updateAndGet { it - id } + _discovered.value = _discovered.value.filterNot { it.id == id } + _verifiedHostIds.value = _verifiedHostIds.value - id + publishSessionHosts() + } } private fun markVerified(hostId: String) { diff --git a/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightTrustFlowTest.kt b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightTrustFlowTest.kt index 2ae41ad8..d2c83e2f 100644 --- a/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightTrustFlowTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightTrustFlowTest.kt @@ -13,6 +13,7 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.verify +import io.mockk.verifyOrder import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.toList @@ -233,6 +234,23 @@ class MoonlightTrustFlowTest { verify(atLeast = 1) { gateway.getHttps(match { it.contains("/cancel") }, any()) } } + // The cancel rides mutual TLS, so it has to go out while the pin is still there. Drop + // the pin first and the handshake trusts the host on first use and writes a new pin + // straight back over the forget. + @Test + fun `the app is closed before the pin that authenticates the closing is dropped`() = + runTest(dispatcher) { + openLiveSession() + + manager.forget(host.id) + dispatcher.scheduler.advanceUntilIdle() + + verifyOrder { + gateway.getHttps(match { it.contains("/cancel") }, any()) + gateway.forgetPin(host.id) + } + } + @Test fun `forgetting a host that never had a session cancels nothing`() = runTest(dispatcher) { From 1e503a35ad986f2976c4201449f6024f3db25de9 Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Wed, 26 Aug 2026 18:22:37 -0400 Subject: [PATCH 20/20] fix: ask the only thing that can answer whether we are paired The probe read PairStatus off the PLAINTEXT /serverinfo and gave up before the mutual-TLS call if it was 0. Against a real host it is always 0, so the probe could never return PAIRED, and openStream launches only on PAIRED. No Moonlight session could start at all. Measured on the live Sunshine host, read only: http://192.168.68.98:47989/serverinfo?uniqueid=7b5d0738cbb54d3e PairStatus 0 http://192.168.68.98:47989/serverinfo?uniqueid=deadbeefdeadbeef PairStatus 0 The first of those is the uniqueid of a device the host is holding a pairing for, and its own log shows the same device's mutual-TLS /serverinfo answering paired in the same session. Sunshine computes that field only on the HTTPS route; every plaintext caller gets a 0 whoever they are. So the plaintext probe answers reachability and identity, which is all it can answer, and the pairing question goes to the one call that carries a client certificate. The verdict when that call is refused now depends on whether a pairing is stored, which also makes M2 and M7 correct for the first time: a host that has never been paired with says "Not paired yet" and offers a PIN, instead of claiming the host removed a pairing that never existed. It costs one failed handshake per probe of a host we have never paired with. That is the price of a truthful answer, and the alternative was a field that lies to everybody. This never reached the user as its own symptom. They were stopped earlier, by a pairing that would not record itself. --- .../moonlight/MoonlightConnectionManager.kt | 23 ++++++---- .../moonlight/MoonlightTrustFlowTest.kt | 44 ++++++++++++++++--- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt index a3c24c20..c4e37ff2 100644 --- a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt @@ -306,20 +306,25 @@ class MoonlightConnectionManager Log.i(TAG, "${host.address} answers as ${plain.uniqueId}, remembered as $storedId: host replaced") return@withContext MoonlightProbe(trust = MoonlightTrustState.REPLACED) } - if (!plain.paired) { - val trust = if (record == null) MoonlightTrustState.NOT_PAIRED else MoonlightTrustState.TRUST_LOST - Log.i(TAG, "${host.address} reports unpaired over plaintext: $trust") - return@withContext MoonlightProbe(trust = trust) - } + // THE PLAINTEXT PairStatus IS NOT AN ANSWER ABOUT PAIRING, so nothing may + // be gated on it. Sunshine computes that field only on the mutual-TLS + // route and hands every plaintext caller a 0: measured against the live + // host, which reports 0 for this device's own uniqueid and 0 for one it + // has never seen, while answering the same device's mutual-TLS call with + // a 1. Treating the 0 as "not paired" made the probe unable to return + // PAIRED at all, and openStream only launches on PAIRED, so no session + // could ever start. The mutual-TLS call is the only thing that can say. val secure = gateway.getHttps(MoonlightUrls.serverInfoHttps(host.address, host.httpsPort, deviceId), host.id) if (!secure.ok) { - Log.i(TAG, "${host.address} refused mutual TLS (HTTP ${secure.status}): trust lost") - return@withContext MoonlightProbe(trust = MoonlightTrustState.TRUST_LOST) + val trust = if (record == null) MoonlightTrustState.NOT_PAIRED else MoonlightTrustState.TRUST_LOST + Log.i(TAG, "${host.address} refused mutual TLS (HTTP ${secure.status}): $trust") + return@withContext MoonlightProbe(trust = trust) } val info = MoonlightXml.parseServerInfo(secure.body) if (info?.paired != true) { - Log.i(TAG, "${host.address} answered mutual TLS unpaired: trust lost") - return@withContext MoonlightProbe(trust = MoonlightTrustState.TRUST_LOST) + val trust = if (record == null) MoonlightTrustState.NOT_PAIRED else MoonlightTrustState.TRUST_LOST + Log.i(TAG, "${host.address} answered mutual TLS unpaired: $trust") + return@withContext MoonlightProbe(trust = trust) } val apps = runCatching { fetchAppList(host) }.getOrNull() markVerified(host.id) diff --git a/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightTrustFlowTest.kt b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightTrustFlowTest.kt index d2c83e2f..0747a82f 100644 --- a/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightTrustFlowTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightTrustFlowTest.kt @@ -454,23 +454,49 @@ class MoonlightTrustFlowTest { } @Test - fun `a host that answers unpaired with a pairing stored has lost trust`() = + fun `a host that answers unpaired over mutual TLS with a pairing stored has lost trust`() = runTest(dispatcher) { manager.pairHost(host) dispatcher.scheduler.advanceUntilIdle() - every { gateway.getHttp(match { it.contains("/serverinfo") }, any()) } returns reply(unpairedInfo) + every { gateway.getHttps(match { it.contains("/serverinfo") }, any()) } returns reply(unpairedInfo) assertEquals(MoonlightTrustState.TRUST_LOST, manager.probe(host).trust) } @Test - fun `a host that answers unpaired with nothing stored has simply never paired`() = + fun `a host that answers unpaired over mutual TLS with nothing stored has never paired`() = runTest(dispatcher) { - every { gateway.getHttp(match { it.contains("/serverinfo") }, any()) } returns reply(unpairedInfo) + every { gateway.getHttps(match { it.contains("/serverinfo") }, any()) } returns reply(unpairedInfo) assertEquals(MoonlightTrustState.NOT_PAIRED, manager.probe(host).trust) } + // THE ONE THAT MATTERS. The live host answers every plaintext caller PairStatus 0, + // including for a device it is holding a pairing for, and only tells the truth over + // mutual TLS. Gating the probe on the plaintext field meant it could never return + // PAIRED, and openStream only launches on PAIRED, so no session could ever start. + @Test + fun `a plaintext PairStatus of zero does not stop a paired host being paired`() = + runTest(dispatcher) { + every { gateway.getHttp(match { it.contains("/serverinfo") }, any()) } returns reply(unpairedInfo) + every { gateway.getHttps(match { it.contains("/serverinfo") }, any()) } returns reply(pairedInfo) + + val probe = manager.probe(host) + + assertEquals(MoonlightTrustState.PAIRED, probe.trust) + assertTrue(probe.appsFetched) + } + + @Test + fun `a host that will not answer plaintext at all is never asked over mutual TLS`() = + runTest(dispatcher) { + every { gateway.getHttp(match { it.contains("/serverinfo") }, any()) } returns unreachable() + + manager.probe(host) + + verify(exactly = 0) { gateway.getHttps(match { it.contains("/serverinfo") }, any()) } + } + @Test fun `a host answering under a new identity is replaced, not merely untrusted`() = runTest(dispatcher) { @@ -488,7 +514,7 @@ class MoonlightTrustFlowTest { } @Test - fun `a host that will not answer mutual TLS has lost trust`() = + fun `a host that will not answer mutual TLS has lost trust once it was paired`() = runTest(dispatcher) { manager.pairHost(host) dispatcher.scheduler.advanceUntilIdle() @@ -497,6 +523,14 @@ class MoonlightTrustFlowTest { assertEquals(MoonlightTrustState.TRUST_LOST, manager.probe(host).trust) } + @Test + fun `a host that will not answer mutual TLS and never was paired is simply not paired`() = + runTest(dispatcher) { + every { gateway.getHttps(match { it.contains("/serverinfo") }, any()) } returns unreachable() + + assertEquals(MoonlightTrustState.NOT_PAIRED, manager.probe(host).trust) + } + @Test fun `a paired host whose app list will not load still reads as paired`() = runTest(dispatcher) {