diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index 1cb3c7a5..be985eac 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -70,3 +70,61 @@ 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`. Also ported: the peer liveness rules, meaning the round-trip +estimate and retransmission timeout of `enet_protocol_handle_acknowledge` and the give-up +conditions of `enet_protocol_check_timeouts`, along with `protocol.c`'s `commandSizes` table. + +Only the needed subset is reproduced. This client never *sends* a fragmented, unsequenced, +throttle or bandwidth command, and does not compress; it does measure and acknowledge all of +them on receive, because a peer packs several commands into one datagram and a command whose +size is unknown costs every command behind it. 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/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/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3965647f..ece9ca0f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -209,6 +209,11 @@ android:exported="false" android:foregroundServiceType="connectedDevice" /> + + diff --git a/app/src/main/cpp/satellite_jni.cpp b/app/src/main/cpp/satellite_jni.cpp index 1a67f29f..46a00e1d 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,88 @@ 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; + int32_t controllerNumber; 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); + // A Moonlight session carries up to four pads on one stream, so its upcall also + // names which pad the report belongs to; the Bluetooth link is one pad by nature. + if (r.kind == SLOT_MOONLIGHT) { + env->CallStaticVoidMethod(cls, method, connId, (jint)r.controllerNumber, + (jint)r.wButtons, (jint)r.bLT, (jint)r.bRT, (jint)r.sLX, + (jint)r.sLY, (jint)r.sRX, (jint)r.sRY); + } else { + 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 +265,12 @@ 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, + binding.controllerIndex, s.wButtons, s.bLT, s.bRT, @@ -938,27 +966,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, + jint controllerIndex) { 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.controllerIndex = controllerIndex; + 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, -1); +} + +JNIEXPORT void JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_bindPhysicalSlotMoonlight( + JNIEnv* env, jobject, jint deviceId, jstring connectionId, jint controllerNumber) { + bindPhysicalSlotBridge(env, deviceId, connectionId, SLOT_MOONLIGHT, controllerNumber); +} + JNIEXPORT void JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_unbindPhysicalSlot( JNIEnv*, jobject, jint deviceId) { std::lock_guard lock(g_slotsMtx); @@ -1064,7 +1102,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;IIIIIIII)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..ee2e6cad 100644 --- a/app/src/main/java/com/tinkernorth/dish/DishApplication.kt +++ b/app/src/main/java/com/tinkernorth/dish/DishApplication.kt @@ -10,11 +10,13 @@ import com.tinkernorth.dish.bench.HotPathBenchController import com.tinkernorth.dish.composer.CatalogPrewarmer import com.tinkernorth.dish.composer.CrashReportingController import com.tinkernorth.dish.composer.DiagnosticsLogRecorder +import com.tinkernorth.dish.composer.MoonlightSessionController import com.tinkernorth.dish.composer.SlotTopologyController 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 @@ -69,6 +71,8 @@ class DishApplication : Application() { @Inject lateinit var slotTopologyController: SlotTopologyController + @Inject lateinit var moonlightSessionController: MoonlightSessionController + @Inject lateinit var crashReportingController: CrashReportingController @Inject lateinit var themePreferenceStore: ThemePreferenceStore @@ -81,6 +85,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 @@ -146,6 +152,7 @@ class DishApplication : Application() { val lifecycle = ProcessLifecycleOwner.get().lifecycle lifecycle.addObserver(connectionForegroundObserver) lifecycle.addObserver(slotTopologyController) + lifecycle.addObserver(moonlightSessionController) // Process-scoped so bindings survive the MainActivity → GamepadOverlayActivity handoff. physicalGamepadRegistry.install() usbGamepadManager.install() @@ -157,6 +164,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/composer/CapabilityComposer.kt b/app/src/main/java/com/tinkernorth/dish/composer/CapabilityComposer.kt index bf8a7ce0..9c7a85b2 100644 --- a/app/src/main/java/com/tinkernorth/dish/composer/CapabilityComposer.kt +++ b/app/src/main/java/com/tinkernorth/dish/composer/CapabilityComposer.kt @@ -9,6 +9,7 @@ import com.tinkernorth.dish.core.model.Feature import com.tinkernorth.dish.core.model.HostFeatureSet import com.tinkernorth.dish.core.model.SlotCapabilities import com.tinkernorth.dish.core.net.ControllerDescriptor +import com.tinkernorth.dish.core.net.moonlight.MoonlightEmulatedType import com.tinkernorth.dish.hotpath.input.PhysicalGamepadRegistry import com.tinkernorth.dish.repository.SatelliteCatalogRepository import com.tinkernorth.dish.repository.TouchpadModeValue @@ -154,6 +155,9 @@ class CapabilityComposer typeCapabilitiesFor( hub.satTypes.value[connId to slotId] ?: CONTROLLER_TYPE_XBOX, connId, + hub.connections.value + .firstOrNull { it.id == connId } + ?.kind ?: ConnectionKind.SATELLITE, ), host = (hostFeatures.featuresFor(connId) ?: HostFeatureSet.SATELLITE_DEFAULT).toCapabilitySet(), ) @@ -173,7 +177,7 @@ class CapabilityComposer CapabilityResolver.resolve( controller = liveControllerLayer(slotId), transport = TransportProfiles.forKind(candidateHostKind), - type = typeCapabilitiesFor(candidateType, candidateHostId), + type = typeCapabilitiesFor(candidateType, candidateHostId, candidateHostKind), host = candidateHostLayer(candidateHostKind, candidateHostId), userEnabled = ALL, // Pre-bind runtime probe: lets the report show a feature present-but-down @@ -276,17 +280,28 @@ class CapabilityComposer summary: ConnectionSummary?, ): CapabilitySet { if (summary == null) return ALL - if (summary.kind != ConnectionKind.SATELLITE) return ALL + if (summary.kind == ConnectionKind.BLUETOOTH) return ALL val typeId = summary.satelliteControllerTypes[slotId] ?: return ALL - return typeCapabilitiesFor(typeId, summary.id) + return typeCapabilitiesFor(typeId, summary.id, summary.kind) } // The satellite's own per-type features from its cached catalog are the source // of truth; the bundled set covers an unfetched catalog or the slugs we ship. + // A Moonlight host has no catalog at all and its own table of types, so it never + // reads either: the two type systems share names and nothing else. private fun typeCapabilitiesFor( typeId: Int, connId: String?, + kind: ConnectionKind, ): CapabilitySet { + if (kind == ConnectionKind.MOONLIGHT) { + return MoonlightCatalog.typeCapabilities( + MoonlightEmulatedType.resolve( + MoonlightEmulatedType.fromStored(typeId), + sourceHasMotion = false, + ), + ) + } val catalogType = connId ?.let { catalogRepo.cached(it) } @@ -296,13 +311,15 @@ class CapabilityComposer ?: BundledCatalog.typeCapabilitiesById(typeId) } - // BLUETOOTH limits via transport, so its host layer is permissive; an unbound slot is too. + // BLUETOOTH limits via transport, so its host layer is permissive; an unbound slot is + // too. A Moonlight host exposes no capability API, so nothing about it can be crossed out. private fun hostLayer( connId: String?, summary: ConnectionSummary?, hostMap: Map, ): CapabilitySet { if (summary == null || connId == null) return ALL + if (summary.kind == ConnectionKind.MOONLIGHT) return MoonlightCatalog.HOST_LAYER if (summary.kind != ConnectionKind.SATELLITE) return ALL return (hostMap[connId] ?: HostFeatureSet.SATELLITE_DEFAULT).toCapabilitySet() } @@ -311,6 +328,7 @@ class CapabilityComposer kind: ConnectionKind, hostId: String?, ): CapabilitySet { + if (kind == ConnectionKind.MOONLIGHT) return MoonlightCatalog.HOST_LAYER if (kind != ConnectionKind.SATELLITE) return ALL val features = hostId?.let { hostFeatures.featuresFor(it) } ?: HostFeatureSet.SATELLITE_DEFAULT return features.toCapabilitySet() 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..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 @@ -16,7 +17,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 +41,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, @@ -82,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. @@ -117,10 +123,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(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..78cb3a02 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,54 @@ 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). +// A dropped or host-ended session is not a live link and never a degraded one: nothing is +// routing, so it reads the same as no session at all and the binding screen says which it was. +internal fun moonlightLinkState( + state: MoonlightSessionState?, + discovered: Boolean, +): LinkState = + when (state) { + MoonlightSessionState.Live -> LinkState.Connected + MoonlightSessionState.Launching -> LinkState.Connecting + MoonlightSessionState.Idle, + MoonlightSessionState.Dropped, + MoonlightSessionState.Ended, + null, + -> if (discovered) LinkState.Ready else LinkState.Saved } diff --git a/app/src/main/java/com/tinkernorth/dish/composer/MoonlightCatalog.kt b/app/src/main/java/com/tinkernorth/dish/composer/MoonlightCatalog.kt new file mode 100644 index 00000000..02da6ec6 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/composer/MoonlightCatalog.kt @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +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 + +// 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, + Feature.ANALOG_TRIGGERS, + Feature.MOTION, + Feature.TOUCHPAD, + Feature.RUMBLE, + 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 -> + padType(Feature.RUMBLE, Feature.MOTION, Feature.TOUCHPAD, Feature.LIGHTBAR) + 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 + if (Feature.RUMBLE in caps) { + bits = bits or MoonlightControlProtocol.CAP_RUMBLE or MoonlightControlProtocol.CAP_TRIGGER_RUMBLE + } + if (Feature.TOUCHPAD in caps) bits = bits or MoonlightControlProtocol.CAP_TOUCHPAD + if (Feature.MOTION in caps) { + bits = bits or MoonlightControlProtocol.CAP_ACCELEROMETER or MoonlightControlProtocol.CAP_GYRO + } + if (Feature.LIGHTBAR in caps) bits = bits or MoonlightControlProtocol.CAP_RGB_LED + return bits + } + + fun capabilityBits( + type: Int, + 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/composer/MoonlightSessionController.kt b/app/src/main/java/com/tinkernorth/dish/composer/MoonlightSessionController.kt new file mode 100644 index 00000000..186864bb --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/composer/MoonlightSessionController.kt @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +package com.tinkernorth.dish.composer + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.lifecycle.LifecycleOwner +import com.tinkernorth.dish.architecture.abstracts.AbstractController +import com.tinkernorth.dish.core.model.Feature +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 dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import javax.inject.Inject +import javax.inject.Singleton + +// The pads every Moonlight host is being asked to carry, keyed by host id. A host with an +// entry has at least one binding pointing at it and therefore wants a session; a host with +// none wants its session gone. +typealias MoonlightDesiredPads = Map> + +/** + * Turns bindings into Moonlight sessions. A host's session is reference counted by the + * bindings pointing at it: the first one starts (or joins) it and settles the app, later + * ones only announce their own pad, and the last one leaving is what cancels it. + * + * Deliberately NOT stopped when the app leaves the foreground. The session belongs to the + * binding, not to the screen, and [MoonlightSessionService] keeps the process able to hold + * it up while the phone is face down. + */ +@Singleton +class MoonlightSessionController + @Inject + constructor( + @ApplicationContext private val context: Context, + private val hub: ConnectionCoordinator, + private val moonlight: MoonlightConnectionManager, + private val capabilities: CapabilityComposer, + scope: CoroutineScope, + ) : AbstractController(scope) { + private var serviceRunning = false + + override fun upstream(): Flow = + combine(hub.bindings, hub.connections, hub.satTypes) { bindings, conns, types -> + desiredPads(bindings, conns, types) + }.distinctUntilChanged() + + // The service goes up before the sockets do and comes down after the last + // /cancel, so the process is never holding a live stream unprotected. + override fun apply(value: MoonlightDesiredPads) { + val wanted = value.values.any { it.isNotEmpty() } + if (wanted && !serviceRunning) startService() + moonlight.applyDesired(value) + if (!wanted && serviceRunning) stopService() + } + + // The service may have been stopped while collection was down, so re-derive + // from the post-start emission rather than from what was recorded before it. + override fun onStarting() { + serviceRunning = false + } + + override fun onStop(owner: LifecycleOwner) = Unit + + private fun desiredPads( + bindings: Map, + conns: List, + types: Map, Int>, + ): MoonlightDesiredPads { + val moonlightIds = conns.filter { it.kind == ConnectionKind.MOONLIGHT }.mapTo(mutableSetOf()) { it.id } + if (moonlightIds.isEmpty()) return emptyMap() + val out = mutableMapOf>() + for ((slotId, hostId) in bindings) { + if (hostId !in moonlightIds) continue + out.getOrPut(hostId) { mutableListOf() } += padRequest(slotId, hostId, types[hostId to slotId]) + } + return out + } + + private fun padRequest( + slotId: String, + hostId: String, + storedType: Int?, + ): MoonlightPadRequest { + val resolved = resolvedTypeFor(slotId, hostId, storedType) + val caps = + capabilities.capabilityForCandidate( + slotId = slotId, + candidateType = resolved, + candidateHostKind = ConnectionKind.MOONLIGHT, + candidateHostId = hostId, + ) + val bits = MoonlightCatalog.capabilityBits(resolved, caps.available) + return MoonlightPadRequest( + slotId = slotId, + emulatedType = resolved, + capabilities = bits, + supportedButtons = MoonlightEmulatedType.supportedButtons(bits), + ) + } + + // Auto resolves here, on the client, before the wire: a source with motion asks for a + // PlayStation pad because that is the only one the host gives a gyro to. + private fun resolvedTypeFor( + slotId: String, + hostId: String, + storedType: Int?, + ): Int { + val picked = MoonlightEmulatedType.fromStored(storedType ?: MoonlightEmulatedType.AUTO) + if (picked != MoonlightEmulatedType.AUTO) return picked + val source = + capabilities.capabilityForCandidate( + slotId = slotId, + candidateType = MoonlightEmulatedType.XBOX, + candidateHostKind = ConnectionKind.MOONLIGHT, + candidateHostId = hostId, + ) + return MoonlightEmulatedType.resolve(picked, source.inputOk(Feature.MOTION)) + } + + private fun startService() { + val intent = Intent(context, MoonlightSessionService::class.java) + serviceRunning = + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + true + } catch (e: IllegalStateException) { + Log.w(TAG, "foreground service start refused: ${e.message}") + false + } + } + + private fun stopService() { + context.stopService(Intent(context, MoonlightSessionService::class.java)) + serviceRunning = false + } + + private companion object { + const val TAG = "MoonlightSessionCtl" + } + } diff --git a/app/src/main/java/com/tinkernorth/dish/composer/MoonlightSessionService.kt b/app/src/main/java/com/tinkernorth/dish/composer/MoonlightSessionService.kt new file mode 100644 index 00000000..ecf98467 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/composer/MoonlightSessionService.kt @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +package com.tinkernorth.dish.composer + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.wifi.WifiManager +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import android.util.Log +import androidx.core.app.NotificationCompat +import com.tinkernorth.dish.DishApplication +import com.tinkernorth.dish.R +import com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionManager +import com.tinkernorth.dish.ui.main.MainActivity +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +/** + * Keeps a Moonlight session alive for as long as a binding points at it. + * + * The satellite path's [StreamingService] is scoped to the app being in the + * foreground, because there the phone IS the pad and a dark screen means nobody + * is playing. A Moonlight session is the opposite: the binding owns a long-lived + * stream to a PC, the user puts the phone down, and without a foreground service + * the idle network restrictions cut the socket about forty seconds later and the + * host times the session out. So this one follows the session, not the app: it + * holds the partial wake lock the control pump needs to keep ticking with the + * screen off, and the low-latency Wi-Fi lock the input packets need. + */ +@AndroidEntryPoint +class MoonlightSessionService : Service() { + @Inject lateinit var moonlight: MoonlightConnectionManager + + @Inject lateinit var hub: ConnectionCoordinator + + private var observerJob: Job? = null + private var wakeLock: PowerManager.WakeLock? = null + private var wifiLock: WifiManager.WifiLock? = null + + override fun onCreate() { + super.onCreate() + ensureChannel() + if (!startForegroundInitial()) return + acquireLocks() + observerJob = + moonlight.sessionHostIds + .onEach(::refresh) + .launchIn((applicationContext as DishApplication).processScope) + } + + override fun onDestroy() { + observerJob?.cancel() + observerJob = null + releaseLocks() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { + // A repeat startForegroundService against a live service obliges another + // startForeground call; here it just refreshes the notification. + if (observerJob != null) startForegroundInitial() + return START_NOT_STICKY + } + + private fun startForegroundInitial(): Boolean = startInForeground(build(moonlight.sessionHostIds.value)) + + private fun refresh(hostIds: Set) { + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.notify(NOTIFICATION_ID, build(hostIds)) + } + + private fun build(hostIds: Set): Notification { + val openIntent = + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val primary = hostIds.firstOrNull() + val label = primary?.let { hub.summary(it)?.label } + val pads = primary?.let { moonlight.get(it)?.padCount } ?: 0 + val body = + when { + label == null -> getString(R.string.ml_service_body_idle) + pads > 0 -> resources.getQuantityString(R.plurals.ml_service_body, pads, pads, label) + else -> getString(R.string.ml_service_body_starting, label) + } + return NotificationCompat + .Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_dish_connected) + .setContentTitle(getString(R.string.ml_service_title)) + .setContentText(body) + .setContentIntent(openIntent) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .build() + } + + private fun startInForeground(notification: Notification): Boolean = + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE) + } else { + startForeground(NOTIFICATION_ID, notification) + } + true + } catch (e: IllegalStateException) { + Log.w(TAG, "foreground start refused: ${e.message}") + stopSelf() + false + } + + // The foreground service exempts the process from the idle network restrictions + // that were killing the socket; the wake lock is what keeps the control pump and + // the media pings running once the screen is off. + private fun acquireLocks() { + val power = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = + power + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG) + .apply { acquire(WAKE_LOCK_TIMEOUT_MS) } + val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + wifiLock = wifi.createWifiLock(wifiLockMode(Build.VERSION.SDK_INT), WIFI_LOCK_TAG).apply { acquire() } + } + + private fun releaseLocks() { + wakeLock?.let { if (it.isHeld) it.release() } + wakeLock = null + wifiLock?.let { if (it.isHeld) it.release() } + wifiLock = null + } + + private fun ensureChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (nm.getNotificationChannel(CHANNEL_ID) != null) return + val channel = + NotificationChannel( + CHANNEL_ID, + getString(R.string.ml_service_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = getString(R.string.ml_service_channel_description) + setShowBadge(false) + } + nm.createNotificationChannel(channel) + } + + companion object { + private const val CHANNEL_ID = "dish.moonlight" + private const val NOTIFICATION_ID = 0x1D16 + private const val TAG = "MoonlightSessionService" + private const val WAKE_LOCK_TAG = "Dish::MoonlightSession" + private const val WIFI_LOCK_TAG = "Dish::MoonlightWifi" + + // OS safety-net release; the service lifetime is the actual session keep-alive. + private const val WAKE_LOCK_TIMEOUT_MS = 6L * 60L * 60L * 1000L + } +} 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..c2abe114 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,17 @@ 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) + // The Moonlight control stream carries the emulated pad whole: input out, and + // rumble/trigger/motion/LED events back. What it does not carry is the satellite's + // mouse/keyboard host-injection surface, which is a different feature entirely. + ConnectionKind.MOONLIGHT -> + CapabilitySet.of( + Feature.GAMEPAD, + Feature.ANALOG_TRIGGERS, + Feature.MOTION, + Feature.TOUCHPAD, + Feature.RUMBLE, + Feature.LIGHTBAR, + ) } } 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..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,14 @@ 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, + controllerNumber: Int, + ) + external fun unbindPhysicalSlot(deviceId: Int) external fun clearAllPhysicalSlots() 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..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 @@ -39,6 +39,21 @@ 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() + +// 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) { + 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..4d5d58aa --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightControlSession.kt @@ -0,0 +1,229 @@ +// 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. + * + * ONE LOCK OVER THE WHOLE PROTOCOL STATE, and it has to be. Input arrives on the + * dispatch thread while [pump] runs the receive/ping loop on an IO thread, and + * both reach the same [EnetClient] and the same [MoonlightHotSealer]. Neither is + * thread-safe, and the sealer's counter is the AES-GCM IV: two threads sealing at + * once can hand the same IV to two packets, which is a real key-recovery bug and + * not merely a lost input. The blocking receive is deliberately left OUTSIDE the + * lock, so a quiet link never stalls the input thread behind a socket timeout. + */ +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) + + /** Guards [enet], [sealer], [opener] and [state]; see the class comment. */ + private val lock = Any() + + private var lastPingMs = 0L + + /** Why the ENet layer gave up, once it has. For the session log. */ + val disconnectReason: String? get() = synchronized(lock) { enet.disconnectReason } + + /** A one-line account of what the link did, for the session log. */ + fun linkStats(): String = + synchronized(lock) { + "acks ${enet.acksSent}, retransmits ${enet.retransmits}, unknown commands ${enet.unknownCommands}" + } + + /** + * 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 { + synchronized(lock) { + state = State.CONNECTING + transport.send(enet.connect()) + } + val deadline = nowMs() + handshakeTimeoutMs + while (nowMs() < deadline && enetState() == EnetClient.State.CONNECTING) { + val datagram = transport.receive(HANDSHAKE_POLL_MS) + synchronized(lock) { + if (datagram == null) { + enet.tick().forEach(transport::send) + } else { + enet.onDatagram(datagram).forEach(transport::send) + } + } + } + return synchronized(lock) { + if (enet.state == EnetClient.State.CONNECTED) { + state = State.CONNECTED + true + } else { + state = State.CLOSED + false + } + } + } + + private fun enetState(): EnetClient.State = synchronized(lock) { enet.state } + + /** + * 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, + ) { + val datagram = + synchronized(lock) { + if (state != State.CONNECTED) return + val sealed = + sealer.sealControllerMulti( + controllerNumber, + activeMask, + buttons, + leftTrigger, + rightTrigger, + leftStickX, + leftStickY, + rightStickX, + rightStickY, + ) + enet.sendReliable(sealed) + } ?: return + transport.send(datagram) + } + + /** Announce a virtual controller with its emulated type and capabilities. */ + fun sendControllerArrival( + controllerNumber: Int, + emulatedType: Int, + capabilities: Int, + supportedButtons: Int, + ) { + synchronized(lock) { + sendControlPlaintextLocked( + 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 + val events = mutableListOf() + while (handled < budget) { + val datagram = transport.receive(RECEIVE_POLL_MS) ?: break + synchronized(lock) { + enet.onDatagram(datagram).forEach(transport::send) + drainEventsLocked(events) + } + handled += 1 + } + synchronized(lock) { + enet.tick().forEach(transport::send) + maybePingLocked() + if (enet.state == EnetClient.State.DISCONNECTED && state == State.CONNECTED) { + state = State.CLOSED + } + } + // Dispatched outside the lock: a rumble sink is somebody else's code and + // must never be able to hold up the input thread. + events.forEach(onEvent) + } + + private fun drainEventsLocked(into: MutableList) { + while (enet.received.isNotEmpty()) { + val payload = enet.received.removeFirst() + val plaintext = runCatching { opener.open(payload) }.getOrNull() ?: continue + MoonlightEventDecoder.decode(plaintext)?.let(into::add) + } + } + + /** + * The protocol's own keepalive, independent of whether input is changing: a + * host that hears nothing at this layer ends the session even while the ENet + * layer underneath is healthy. + */ + private fun maybePingLocked() { + val now = nowMs() + if (state == State.CONNECTED && now - lastPingMs >= PING_INTERVAL_MS) { + lastPingMs = now + sendControlPlaintextLocked(MoonlightInputEncoder.periodicPing()) + } + } + + private fun sendControlPlaintextLocked(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() { + synchronized(lock) { + if (state == State.CONNECTED) { + runCatching { sendControlPlaintextLocked(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..4bf75d72 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightCrypto.kt @@ -0,0 +1,186 @@ +// 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) + } + + /** + * The control-stream GCM IV: sixteen zero bytes with the LOW BYTE of [seq] + * in byte 0, and nothing else. + * + * ONLY THE LOW BYTE, however wrong that looks. The host builds the same IV + * with `std::array iv_data = {0}; iv_data[0] = seq;` + * (Wolf control.hpp encrypt_packet and decrypt_packet), where assigning a + * u32 into a u8 element drops the top three bytes. The packet header still + * carries the full 32-bit sequence, so only the IV wraps. Writing all four + * bytes here, as this used to, agrees with the host for the first 256 + * packets and disagrees forever after: a live Sunshine host accepted 256 + * sealed control packets and answered the 257th with "Failed to verify tag", + * then ended the session. At two packets a second that is a session that + * dies after about two minutes, every time, which is exactly why it hid + * behind the faults that used to end the session in six. + * + * The IV therefore repeats every 256 packets on one session key. That is the + * protocol's property and not a choice available to a client that wants to + * interoperate. What limits it is that the key is the rikey, minted fresh + * for every /launch and never reused across sessions. + */ + private fun controlIv(seq: Int): ByteArray { + val iv = ByteArray(CONTROL_IV_LEN) + iv[0] = (seq 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..7a25d3d9 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightEventDecoder.kt @@ -0,0 +1,136 @@ +// 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 + + /** + * TERMINATION 0x0100 {reason u32 BE}: the host is ending the session. Nothing + * is recoverable after it; a new session has to be launched. + */ + data class Termination( + val reason: 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) + MoonlightControlProtocol.CTRL_TERMINATION -> decodeTermination(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) + } + + // The reason is the one big-endian field in this family (Wolf control.hpp). + private fun decodeTermination(buf: ByteBuffer): MoonlightEvent? { + if (buf.remaining() < TERMINATION_BODY) return null + return MoonlightEvent.Termination(buf.order(ByteOrder.BIG_ENDIAN).int) + } + + 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 + private const val TERMINATION_BODY = 4 +} 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..b60b99b1 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHostModels.kt @@ -0,0 +1,119 @@ +// 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 and title the session creator settled on. Per host, not per + // binding: every controller on this host shares the one session. + val lastAppId: String = "", + 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( + 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. + * It is 0xFF and never 0, because 0 is CONTROLLER_TYPE_UNKNOWN on the wire and + * the satellite's own CONTROLLER_TYPE_XBOX as well, so a stored 0 is ambiguous + * twice over; [fromStored] migrates one back to Auto on read. + */ +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 + + val ORDER = listOf(AUTO, XBOX, PLAYSTATION, NINTENDO) + + fun fromStored(stored: Int): Int = if (stored == MoonlightControlProtocol.CONTROLLER_TYPE_UNKNOWN) AUTO else stored + + fun resolve( + picked: Int, + sourceHasMotion: Boolean, + ): Int = + when { + picked != AUTO -> picked + sourceHasMotion -> PLAYSTATION + else -> XBOX + } + + fun typeMaximum(type: Int): Int = if (type == PLAYSTATION) PLAYSTATION_MAXIMUM else BASE_MAXIMUM + + fun capabilityBits( + type: Int, + sourceBits: Int, + ): Int = typeMaximum(type) and sourceBits + + fun supportedButtons(capabilities: Int): Int = + if (capabilities and MoonlightControlProtocol.CAP_TOUCHPAD != 0) { + BASE_BUTTONS or MoonlightControlProtocol.BTN_TOUCHPAD + } else { + BASE_BUTTONS + } + + private const val BASE_MAXIMUM = + MoonlightControlProtocol.CAP_ANALOG_TRIGGERS or MoonlightControlProtocol.CAP_RUMBLE + + private const val PLAYSTATION_MAXIMUM = 0xFF + + private const val BASE_BUTTONS = 0xFFFF +} 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..98ff03ef --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHotSealer.kt @@ -0,0 +1,128 @@ +// 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() + } + + /** The low byte of the seq and nothing else; see MoonlightCrypto.controlIv. */ + private fun writeIv(currentSeq: Int) { + iv.fill(0) + iv[0] = (currentSeq 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..bd638f75 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightInputEncoder.kt @@ -0,0 +1,222 @@ +// 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 + + // 4 control header + 8 wrapper + 8 arrival body. See [controllerArrival] for + // why the body is 8 bytes and not the 7 its fields add up to. + const val CONTROLLER_ARRIVAL_LEN = 20 + 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 = 12 + 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() + } + + /** + * CONTROLLER_ARRIVAL: which pad turned up, what it should be emulated as, + * and what it can do. + * + * THE BODY IS EIGHT BYTES, NOT SEVEN. Its fields are a u8 number, a u8 type, + * a u8 capabilities bitfield and a u32 button mask, which add up to seven; + * but the host reads them out of a naturally aligned struct, so the u32 + * starts at offset 4 and there is a reserved byte at offset 3. Sending seven + * shifts everything after the type by one: a live Sunshine host read our + * capabilities 0x03 as 0xFF03, claiming a touchpad, gyro, accelerometer, + * battery and RGB LED this pad does not have, and read our 0xFFFF button + * mask as 0x000000FF. Its log said `capabilities [FF03] supportedButtonFlags + * [000000FF]` and that is exactly the tell. + */ + 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()) + buf.put(0) // reserved, and the struct's alignment padding + // 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/MoonlightMediaPing.kt b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightMediaPing.kt new file mode 100644 index 00000000..e65534a1 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightMediaPing.kt @@ -0,0 +1,73 @@ +// 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 datagram a client sends to a host's video and audio RTP ports so the host + * stops waiting and starts the stream. Pure byte work; the socket half lives in + * [com.tinkernorth.dish.source.connection.moonlight.UdpMediaPinger]. + * + * LENGTH IS THE PROTOCOL HERE, not content. Wolf's listener + * (src/moonlight-server/rtp/udp-ping.cpp, handle_receive) dispatches purely on + * how many bytes arrived: + * + * - exactly 4 bytes: the legacy ping. Its contents are never looked at. The + * host matches the session by source IP and port instead, which is why this + * form only works from a socket the host can already place. + * - at least 20 bytes: an `SS_PING` (data-structures.hpp), which is a 16-byte + * payload followed by a u32 sequence number. The host matches the session by + * comparing those 16 bytes against the per-session secret it handed out in + * the SETUP reply's `X-SS-Ping-Payload`, so neither address nor port has to + * match anything. + * - anything from 5 to 19 bytes: DROPPED, silently, with no host-side log line + * beyond a trace of the byte count. + * + * That dead zone is what cost us the session for days. `X-SS-Ping-Payload` looks + * like hex (a live Sunshine host sent `68A75BBEEEA86826`) but it is not: the + * host mints 16 random printable ASCII characters and expects those same 16 + * bytes back. Hex-decoding it produced an 8-byte datagram and sending the text + * alone produced a 16-byte one, both inside the dead zone, both discarded + * without a word. The host then reported `Initial Ping Timeout` and ended the + * session ten seconds after it began. + */ +object MoonlightMediaPing { + /** The `X-SS-Ping-Payload` secret is exactly this many bytes, verbatim. */ + const val PAYLOAD_LEN = 16 + + /** 16-byte payload + u32 sequence number. */ + const val SS_PING_LEN = 20 + + const val LEGACY_LEN = 4 + + /** + * The modern ping: [payload] as raw bytes (padded or truncated to + * [PAYLOAD_LEN]) followed by [sequence]. The host ignores the sequence + * number; it is there because the struct has the field. + */ + fun ssPing( + payload: String, + sequence: Int, + ): ByteArray { + val buf = ByteBuffer.allocate(SS_PING_LEN).order(ByteOrder.LITTLE_ENDIAN) + val raw = payload.toByteArray(Charsets.US_ASCII) + buf.put(raw, 0, minOf(raw.size, PAYLOAD_LEN)) + buf.position(PAYLOAD_LEN) + buf.putInt(sequence) + return buf.array() + } + + /** + * The legacy 4-byte ping, for a host that named no payload. The bytes are + * not inspected by the host, but "PING" is what the wire has always carried. + */ + fun legacy(): ByteArray = LEGACY.copyOf() + + /** Whether [payload] can key the modern ping at all. */ + fun usable(payload: String): Boolean = payload.isNotEmpty() + + private val LEGACY = "PING".toByteArray(Charsets.US_ASCII) +} 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..b7b34a1e --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightRtsp.kt @@ -0,0 +1,254 @@ +// 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() + } + + /** + * The ENet connect token from the control SETUP reply (rtsp.adoc setup: + * X-SS-Connect-Data), as the 32 bits [enet.EnetClient] puts on the wire. + * + * READ WIDE, THEN NARROW. The token is unsigned 32-bit and a real host's + * routinely sits above Int.MAX_VALUE: 4270471497 came off a live Sunshine + * host. Parsing it straight into an Int fails for exactly those values + * and, defaulted, handed the control stream a token of 0. + */ + fun enetConnectData(): Int? = + options[CONNECT_DATA] + ?.trim() + ?.toLongOrNull() + ?.toInt() + + /** + * The media-stream ping payload from an audio or video SETUP reply + * (rtsp.adoc setup: X-SS-Ping-Payload), hex. + * + * The host will not wait for media it has not heard from: unless these + * bytes reach the ports those replies named, it logs "Initial Ping + * Timeout" and ends the session seconds after PLAY, taking the control + * channel with it. It is minted per session, so it cannot be carried + * over from an earlier launch. + */ + fun pingPayload(): String? = + options[PING_PAYLOAD] + ?.trim() + ?.takeIf { it.isNotEmpty() } + } + + 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) + } + + /** + * The ANNOUNCE session description: the lowest video/audio settings the + * protocol will express, since the dish negotiates the streams and then + * discards their payloads without decoding anything. + * + * IT HAS TO BE THE WHOLE SET. A host builds its stream configuration by + * looking each of these attributes up by name, and a lookup that misses is + * a fatal one: measured against a live Sunshine host, an ANNOUNCE carrying + * only the seven attributes the dish itself cares about is answered + * `400 BAD REQUEST`, while the same handshake carrying this set is answered + * `200 OK`, with either line ending. Nothing here is decoration, and an + * attribute dropped as unused is a host that stops talking to us. + */ + @Suppress("LongMethod") // one attribute per line; the list is the point + fun announceSdp( + width: Int, + height: Int, + fps: Int, + ): String = + listOf( + "v=0", + "o=android 0 14 IN IPv4 0.0.0.0", + "s=NVIDIA Streaming Client", + "a=x-nv-video[0].clientViewportWd:$width", + "a=x-nv-video[0].clientViewportHt:$height", + "a=x-nv-video[0].maxFPS:$fps", + "a=x-nv-video[0].packetSize:1024", + "a=x-nv-video[0].rateControlMode:4", + "a=x-nv-video[0].timeoutLengthMs:7000", + "a=x-nv-video[0].framesWithInvalidRefThreshold:0", + "a=x-nv-video[0].refPicInvalidation:0", + "a=x-nv-video[0].encoderCscMode:0", + "a=x-nv-video[0].dynamicRangeMode:0", + "a=x-nv-video[0].maxNumReferenceFrames:1", + "a=x-nv-video[0].videoEncoderSlicesPerFrame:1", + "a=x-nv-video[0].clientRefreshRateX100:${fps * FPS_HUNDREDTHS}", + "a=x-nv-vqos[0].bitStreamFormat:0", + "a=x-nv-vqos[0].bw.minimumBitrateKbps:$MIN_BITRATE_KBPS", + "a=x-nv-vqos[0].bw.maximumBitrateKbps:$MIN_BITRATE_KBPS", + "a=x-nv-vqos[0].fec.enable:1", + "a=x-nv-vqos[0].fec.minRequiredFecPackets:2", + "a=x-nv-vqos[0].fec.repairPercent:20", + "a=x-nv-vqos[0].drc.enable:0", + "a=x-nv-vqos[0].videoQualityScoreUpdateTime:5000", + "a=x-nv-vqos[0].qosTrafficType:5", + "a=x-nv-aqos.qosTrafficType:4", + "a=x-nv-aqos.packetDuration:5", + "a=x-nv-audio.surround.numChannels:2", + "a=x-nv-audio.surround.channelMask:3", + "a=x-nv-audio.surround.enable:0", + "a=x-nv-audio.surround.AudioQuality:0", + "a=x-nv-general.useReliableUdp:13", + "a=x-nv-general.featureFlags:167", + "a=x-ml-general.featureFlags:3", + "a=x-ss-general.encryptionEnabled:0", + "t=0 0", + ).joinToString("") { it + CRLF } + + const val CONNECT_DATA = "X-SS-Connect-Data" + const val PING_PAYLOAD = "X-SS-Ping-Payload" + + private const val CLIENT_VERSION = "14" + + // clientRefreshRateX100 is hundredths of a frame per second. + private const val FPS_HUNDREDTHS = 100 + + // The floor the protocol lets us ask for: no payload is ever decoded, so + // the only thing bitrate buys here is host-side encoder work we throw away. + private const val MIN_BITRATE_KBPS = 500 +} 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..38e716ae --- /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 "1", + "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..ed709882 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/MoonlightXml.kt @@ -0,0 +1,197 @@ +// 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 org.xml.sax.InputSource +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, + ) + + /** + * The application-level result every Moonlight XML reply carries on its root + * element, independent of the HTTP status the transport reported. + * + * A HOST SAYS NO IN THE BODY, NOT IN THE STATUS LINE. Measured against a + * live Sunshine host: asking /launch to start a second app answers HTTP 200 + * with `0`. Code that reads only the HTTP status + * treats that refusal as a success and then fails further downstream on the + * missing sessionUrl0, naming the wrong thing. Read this instead. + */ + data class Status( + val code: Int, + val message: String, + val resume: Boolean, + ) { + val ok: Boolean get() = code in 200..299 + + /** + * The host already has an app running, so it will not start another. + * Either /resume that session (when [resume] is set) or /cancel it. + */ + val appAlreadyRunning: Boolean get() = !ok && message.contains(ALREADY_RUNNING, ignoreCase = true) + } + + /** + * The root element's status, or null when the reply is not parsable XML. A + * reply with no status_code attribute at all is read as success, which is + * what a host that answers plainly (Wolf's /applist) sends. + */ + fun parseStatus(xml: String): Status? { + val root = rootOf(xml) ?: return null + return Status( + code = root.getAttribute("status_code").toIntOrNull() ?: DEFAULT_OK, + message = root.getAttribute("status_message").orEmpty(), + resume = (intText(root, "resume") ?: 0) == 1, + ) + } + + /** 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. 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 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) } + } + + // A reply that names no status_code is a plain success. + private const val DEFAULT_OK = 200 + private const val ALREADY_RUNNING = "already running" + + 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, + ): 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..480f9e22 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/enet/EnetClient.kt @@ -0,0 +1,482 @@ +// 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 and unsequenced delivery are not *produced* by this + * client, because the Moonlight control payloads are all small single-fragment + * reliable messages. They are still parsed on receive, along with every other + * command number, for the reason spelled out in [onDatagram]. + * + * 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. + * + * NOT THREAD SAFE. One session's calls must be serialized by its owner; see + * [com.tinkernorth.dish.core.net.moonlight.MoonlightControlSession]. + */ +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 + + /** Why the client gave up, for the session log. Null while healthy. */ + var disconnectReason: String? = null + private set + + /** Reliable payloads delivered by the host (the encrypted control events). */ + val received: ArrayDeque = ArrayDeque() + + /** Counters the session logs, so a live run says what the link actually did. */ + var acksSent: Int = 0 + private set + + var retransmits: Int = 0 + private set + + var unknownCommands: Int = 0 + private set + + // 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 + + // Round-trip bookkeeping, mirroring enet_protocol_handle_acknowledge. The + // retransmission timeout is derived from these, not from a fixed constant. + private var roundTripTimeMs = EnetProtocol.DEFAULT_ROUND_TRIP_TIME_MS.toLong() + private var roundTripTimeVarianceMs = 0L + private var sampledRtt = false + + /** + * The send time of the oldest reliable command still waiting for its + * acknowledgement, or 0 when nothing is overdue. Reset by ANY acknowledgement, + * exactly as protocol.c does: it is the clock the give-up rule runs on. + */ + private var earliestTimeoutMs = 0L + + private class Outgoing( + val channelId: Int, + val reliableSeq: Int, + val command: ByteArray, + var sentAtMs: Long, + var sendAttempts: Int, + var roundTripTimeout: Long, + ) + + // 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 + disconnectReason = null + connectId = random() + systemReliableSeq = 1 + val now = nowMs() + lastReceiveMs = now + lastPingMs = now + return trackAndWrap(EnetProtocol.SYSTEM_CHANNEL, systemReliableSeq, buildConnect(), now) + } + + /** + * 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 + return trackAndWrap(DATA_CHANNEL, seq, buildSendReliable(seq, payload), nowMs()) + } + + /** Graceful DISCONNECT (unsequenced, matches enet_peer_disconnect_now). */ + fun disconnect(): ByteArray? { + if (state == State.DISCONNECTED) return null + state = State.DISCONNECTED + disconnectReason = disconnectReason ?: "local teardown" + 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, nowMs()) + } + + /** + * Feed a received datagram. Returns any datagrams to send in response + * (acknowledgements). Delivered host payloads are appended to [received]. + * + * EVERY COMMAND IN THE DATAGRAM GETS WALKED, not just the ones this client + * knows what to do with. A peer packs acknowledgements and control commands + * into one datagram, so a command we cannot measure is not one command + * skipped, it is every command behind it in that datagram dropped. This + * cost us the whole session once already: a live Sunshine host sends a + * reliable BANDWIDTH_LIMIT (command 10) about a second after the peer + * connects, off enet_host_bandwidth_throttle's 1000 ms tick. An earlier + * revision of this parser bailed on it as unsupported and so never + * acknowledged it. The host's sent-reliable queue then never empties, which + * both stops its pings and freezes its lastReceiveTime, and + * enet_protocol_check_timeouts drops the peer ENET_PEER_TIMEOUT_MINIMUM + * (5000 ms) later. It read as "CLIENT DISCONNECTED about 6.4 seconds in" + * with controller input flowing right up to the cut. + */ + 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 + } + val now = nowMs() + lastReceiveMs = now + 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, now)) break + } + return acks + } + + /** + * Advance time: retransmit overdue reliable commands, give up on a peer that + * has stopped acknowledging, and ping when the link is idle. + */ + fun tick(): List { + val now = nowMs() + val out = mutableListOf() + for (cmd in sentReliable.values) { + if (now - cmd.sentAtMs < cmd.roundTripTimeout) continue + if (earliestTimeoutMs == 0L || cmd.sentAtMs < earliestTimeoutMs) earliestTimeoutMs = cmd.sentAtMs + if (hasTimedOut(cmd, now)) { + state = State.DISCONNECTED + disconnectReason = + "peer stopped acknowledging: channel ${cmd.channelId} seq ${cmd.reliableSeq} " + + "unacked for ${now - earliestTimeoutMs} ms over ${cmd.sendAttempts} sends" + return out + } + cmd.sendAttempts += 1 + cmd.roundTripTimeout = retryTimeoutFor(cmd.sendAttempts) + cmd.sentAtMs = now + retransmits += 1 + // Re-wrap rather than replay: the header's sent time is what the peer + // echoes back to measure the round trip, so a stale one poisons its RTT. + out += wrapRaw(cmd.command, now) + } + if (state == State.CONNECTED && + now - lastReceiveMs >= EnetProtocol.PING_INTERVAL_MS && + now - lastPingMs >= EnetProtocol.PING_INTERVAL_MS + ) { + lastPingMs = now + out += buildPing(now) + } + return out + } + + /** + * protocol.c enet_protocol_check_timeouts: give up either after + * timeoutMaximum outright, or after timeoutMinimum once the command has been + * resent enough times that the doubling window has passed timeoutLimit. + */ + private fun hasTimedOut( + cmd: Outgoing, + now: Long, + ): Boolean { + val waited = now - earliestTimeoutMs + if (waited >= EnetProtocol.TIMEOUT_MAXIMUM_MS) return true + val attemptWindow = 1L shl (cmd.sendAttempts - 1).coerceIn(0, MAX_ATTEMPT_SHIFT) + return attemptWindow >= EnetProtocol.TIMEOUT_LIMIT && waited >= EnetProtocol.TIMEOUT_MINIMUM_MS + } + + /** The peer-level retransmission timeout, scaled by how often we have resent. */ + private fun retryTimeoutFor(sendAttempts: Int): Long { + val base = peerRoundTripTimeout() + val scale = if (sendAttempts < EnetProtocol.TIMEOUT_LIMIT) sendAttempts else EnetProtocol.TIMEOUT_LIMIT + return base * scale.coerceAtLeast(1) + } + + private fun peerRoundTripTimeout(): Long { + val variance = 4 * maxOf(1L, roundTripTimeVarianceMs) + val timeout = roundTripTimeMs + minOf(roundTripTimeMs, variance) + return timeout.coerceIn(1L, (EnetProtocol.TIMEOUT_MAXIMUM_MS / RTO_CAP_DIVISOR).toLong()) + } + + // Each early return is a distinct malformed/short-command bail; splitting them would + // obscure the one-command-per-branch parse. + @Suppress("ReturnCount", "LongParameterList", "CyclomaticComplexMethod") + private fun handleCommand( + header: EnetProtocol.CommandHeader, + buf: ByteBuffer, + sentTime: Int, + hasSentTime: Boolean, + acks: MutableList, + now: Long, + ): Boolean { + val number = header.commandNumber + val fixed = EnetProtocol.sizeForCommand(number) + if (fixed == 0) { + unknownCommands += 1 + return false + } + val bodyLen = fixed - EnetProtocol.COMMAND_HEADER_LEN + if (buf.remaining() < bodyLen) return false + val body = ByteArray(bodyLen).also { if (bodyLen > 0) buf.get(it) } + val payload = readPayload(number, body, buf) ?: return false + + when (number) { + EnetProtocol.COMMAND_VERIFY_CONNECT -> consumeVerifyConnect(body) + EnetProtocol.COMMAND_ACKNOWLEDGE -> consumeAcknowledge(header, body, now) + EnetProtocol.COMMAND_SEND_RELIABLE -> deliverReliable(header.reliableSequenceNumber, payload) + EnetProtocol.COMMAND_DISCONNECT -> { + state = State.DISCONNECTED + disconnectReason = "peer sent DISCONNECT" + } + // PING, the throttle/bandwidth advisories and the delivery modes this + // client does not produce all need nothing beyond the acknowledgement + // below, which is the whole point of measuring them correctly. + else -> Unit + } + + if (header.wantsAck) { + // protocol.c refuses to acknowledge a command that arrived without a + // sent time and abandons the rest of the datagram; keep to that so + // both ends agree on what was and was not acknowledged. + if (!hasSentTime) return false + acks += buildAcknowledge(header, sentTime) + acksSent += 1 + } + return true + } + + /** + * Consume the `dataLength`-counted payload the SEND_* commands carry after + * their fixed header. Returns an empty array for the commands that carry + * none, or null when the datagram is truncated. + */ + private fun readPayload( + commandNumber: Int, + body: ByteArray, + buf: ByteBuffer, + ): ByteArray? { + val offset = EnetProtocol.dataLengthOffset(commandNumber) + if (offset < 0) return EMPTY + val at = offset - EnetProtocol.COMMAND_HEADER_LEN + if (at + 2 > body.size) return null + val dataLength = ((body[at].toInt() and 0xFF) shl 8) or (body[at + 1].toInt() and 0xFF) + if (buf.remaining() < dataLength) return null + return ByteArray(dataLength).also { if (dataLength > 0) buf.get(it) } + } + + private fun consumeVerifyConnect(body: ByteArray) { + val buf = ByteBuffer.wrap(body).order(ByteOrder.BIG_ENDIAN) + 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 + 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)) + earliestTimeoutMs = 0 + state = State.CONNECTED + } + + private fun consumeAcknowledge( + header: EnetProtocol.CommandHeader, + body: ByteArray, + now: Long, + ) { + val buf = ByteBuffer.wrap(body).order(ByteOrder.BIG_ENDIAN) + val recvReliableSeq = buf.short.toInt() and 0xFFFF + val recvSentTime = buf.short.toInt() and 0xFFFF + val acked = sentReliable.remove(key(header.channelId, recvReliableSeq)) + // An acknowledgement is the only thing that proves the peer is still + // there, so it clears the give-up clock outright (protocol.c does the same). + earliestTimeoutMs = 0 + sampleRoundTrip(acked?.let { now - it.sentAtMs } ?: ((now.toInt() - recvSentTime) and 0xFFFF).toLong()) + } + + /** enet_protocol_handle_acknowledge's smoothed round-trip estimate. */ + private fun sampleRoundTrip(rawSample: Long) { + val sample = rawSample.coerceIn(1L, EnetProtocol.TIMEOUT_MAXIMUM_MS.toLong()) + if (!sampledRtt) { + roundTripTimeMs = sample + roundTripTimeVarianceMs = (sample + 1) / 2 + sampledRtt = true + return + } + roundTripTimeVarianceMs -= (roundTripTimeVarianceMs + 3) / 4 + if (sample >= roundTripTimeMs) { + val diff = sample - roundTripTimeMs + roundTripTimeVarianceMs += (diff + 3) / 4 + roundTripTimeMs += (diff + 7) / 8 + } else { + val diff = roundTripTimeMs - sample + roundTripTimeVarianceMs += (diff + 3) / 4 + roundTripTimeMs -= (diff + 7) / 8 + } + } + + private fun deliverReliable( + reliableSeq: Int, + payload: ByteArray, + ) { + // In-order gate on the 16-bit sequence space: a retransmitted command + // (seq already delivered) is acked again by the caller but not + // re-delivered. The signed difference keeps that true across the wrap. + val ahead = ((reliableSeq - incomingReliableSeq) and 0xFFFF).let { if (it > 0x7FFF) it - 0x10000 else it } + if (ahead <= 0) 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, + ) + return trackAndWrap(EnetProtocol.SYSTEM_CHANNEL, systemReliableSeq, w.toByteArray(), now) + } + + // 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 trackAndWrap( + channelId: Int, + reliableSeq: Int, + command: ByteArray, + now: Long, + ): ByteArray { + sentReliable[key(channelId, reliableSeq)] = + Outgoing(channelId, reliableSeq, command, now, sendAttempts = 1, roundTripTimeout = peerRoundTripTimeout()) + return wrapRaw(command, now) + } + + 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 val EMPTY = ByteArray(0) + + // protocol.c caps a command's retransmission timeout at a fifth of the + // peer's maximum timeout. + private const val RTO_CAP_DIVISOR = 5 + + // Keeps the attempt-window shift inside a Long once a command has been + // resent absurdly often; by then it has long since passed timeoutLimit. + private const val MAX_ATTEMPT_SHIFT = 30 + } +} 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..b11cce7e --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/core/net/moonlight/enet/EnetProtocol.kt @@ -0,0 +1,189 @@ +// 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_SEND_UNRELIABLE = 7 + const val COMMAND_SEND_FRAGMENT = 8 + const val COMMAND_SEND_UNSEQUENCED = 9 + const val COMMAND_BANDWIDTH_LIMIT = 10 + const val COMMAND_THROTTLE_CONFIGURE = 11 + const val COMMAND_SEND_UNRELIABLE_FRAGMENT = 12 + const val COMMAND_COUNT = 13 + 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 + const val SEND_UNRELIABLE_HEADER_LEN = 8 + const val SEND_UNSEQUENCED_HEADER_LEN = 8 + const val SEND_FRAGMENT_HEADER_LEN = 24 + const val BANDWIDTH_LIMIT_LEN = 12 + const val THROTTLE_CONFIGURE_LEN = 16 + + // 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 + + // Peer liveness (enet.h ENET_PEER_*). These are the numbers the host on the + // other end is using, so the client has to keep to the same clock or one + // side gives up while the other still thinks the session is healthy. + const val DEFAULT_ROUND_TRIP_TIME_MS = 500 + const val TIMEOUT_LIMIT = 32 + const val TIMEOUT_MINIMUM_MS = 5000 + const val TIMEOUT_MAXIMUM_MS = 30000 + + 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) + } + + /** + * The fixed on-wire size of one command, mirroring protocol.c's + * `commandSizes` table exactly. For the SEND_* commands this is the header + * only: a `dataLength` payload follows it. + * + * EVERY COMMAND NUMBER HAS TO BE IN HERE, including the ones this client + * never sends. A peer bundles several commands into one datagram, so a + * command whose size we do not know is not one command lost, it is the rest + * of that datagram lost, acknowledgements included. See [EnetClient.onDatagram]. + */ + 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 + COMMAND_SEND_UNRELIABLE -> SEND_UNRELIABLE_HEADER_LEN + COMMAND_SEND_FRAGMENT -> SEND_FRAGMENT_HEADER_LEN + COMMAND_SEND_UNSEQUENCED -> SEND_UNSEQUENCED_HEADER_LEN + COMMAND_BANDWIDTH_LIMIT -> BANDWIDTH_LIMIT_LEN + COMMAND_THROTTLE_CONFIGURE -> THROTTLE_CONFIGURE_LEN + COMMAND_SEND_UNRELIABLE_FRAGMENT -> SEND_FRAGMENT_HEADER_LEN + else -> 0 + } + + /** + * Whether a command carries a `dataLength`-counted payload after its fixed + * header, and at what offset within that header the count sits. + */ + fun dataLengthOffset(commandNumber: Int): Int = + when (commandNumber) { + COMMAND_SEND_RELIABLE -> COMMAND_HEADER_LEN + COMMAND_SEND_UNRELIABLE, COMMAND_SEND_UNSEQUENCED -> COMMAND_HEADER_LEN + 2 + COMMAND_SEND_FRAGMENT, COMMAND_SEND_UNRELIABLE_FRAGMENT -> COMMAND_HEADER_LEN + 2 + else -> -1 + } +} 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/MoonlightGamepadBridge.kt b/app/src/main/java/com/tinkernorth/dish/hotpath/input/MoonlightGamepadBridge.kt new file mode 100644 index 00000000..60b77059 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/hotpath/input/MoonlightGamepadBridge.kt @@ -0,0 +1,56 @@ +// 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, + controllerNumber: Int, + wButtons: Int, + bLT: Int, + bRT: Int, + sLX: Int, + sLY: Int, + sRX: Int, + sRY: Int, + ) { + val m = manager ?: return + m.get(connectionId)?.sendControllerState( + controllerNumber = controllerNumber, + 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 6caf0817..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 @@ -39,6 +39,12 @@ sealed interface BindOp { val connectionId: String, ) : BindOp + data class BindMoonlight( + val deviceId: Int, + val connectionId: String, + val controllerNumber: Int, + ) : BindOp + data class Unbind( val deviceId: Int, ) : BindOp @@ -69,6 +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. +@Suppress("LongParameterList", "CyclomaticComplexMethod") // one flat snapshot per source, one branch per kind fun reconcileSlots( present: Set, lastBound: Set, @@ -76,6 +83,8 @@ fun reconcileSlots( summaries: List, perConnectionSlotInfo: Map, btConnectedIds: Set, + moonlightLiveIds: Set = emptySet(), + moonlightPadNumbers: Map = emptyMap(), ): List { val ops = mutableListOf() val staleBound = bindings.keys.mapNotNull { it.toIntOrNull() }.filter { it !in present } @@ -112,6 +121,18 @@ fun reconcileSlots( } else { 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. + // The pad number comes with it: one session carries four controllers, and a report + // that cannot name which one belongs to nobody. + val pad = moonlightPadNumbers[slotId] + if (cid in moonlightLiveIds && pad != null) { + ops += BindOp.BindMoonlight(id, cid, pad) + } else { + ops += BindOp.Unbind(id) + } + } } } return ops @@ -146,6 +167,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 @@ -169,6 +195,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( @@ -220,6 +247,21 @@ 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 moonlightPadNumbers = + moonlightLiveIds + .flatMap { cid -> + moonlight + .get(cid) + ?.pads + ?.value + .orEmpty() + .values + }.associate { it.slotId to it.number } val ops = reconcileSlots( present = present, @@ -228,6 +270,8 @@ class PhysicalSlotBindingObserver summaries = state.summaries, perConnectionSlotInfo = slotInfo, btConnectedIds = btConnectedIds, + moonlightLiveIds = moonlightLiveIds, + moonlightPadNumbers = moonlightPadNumbers, ) // 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 @@ -248,6 +292,8 @@ 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, op.controllerNumber) } } } 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..15b1dede --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnection.kt @@ -0,0 +1,308 @@ +// 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.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.currentCoroutineContext +import kotlinx.coroutines.delay +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. + * + * ONE SESSION PER HOST, REFERENCE COUNTED BY THE BINDINGS POINTING AT IT. A + * Moonlight session carries up to [MAX_PADS] controllers on one stream, so the + * first binding starts (or joins) it and settles the app, later bindings only + * announce their own pad, and the last unbind is what tears it down. + */ +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() + + private val _pads = MutableStateFlow>(emptyMap()) + val pads: StateFlow> = _pads.asStateFlow() + + @Volatile private var session: MoonlightControlSession? = null + private var pumpJob: Job? = null + + @Volatile private var pinger: UdpMediaPinger? = null + private var pingJob: Job? = null + + private val padLock = Any() + + // The app the session actually settled on, so a later binding can say what it + // is joining instead of guessing from the remembered pick. + @Volatile var sessionAppId: String? = null + private set + + @Volatile var sessionAppName: String? = null + private set + + // 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 + } + + /** + * Take the lowest free controller number in `0..3` for [slotId], or null when + * the host already carries its four. A slot that already holds one keeps it: + * the host skips a CONTROLLER_ARRIVAL for a number it has seen, so a live + * index is never handed out twice. + */ + fun acquirePad( + slotId: String, + emulatedType: Int, + capabilities: Int, + supportedButtons: Int, + ): MoonlightPad? { + val pad = + synchronized(padLock) { + _pads.value[slotId]?.let { return@synchronized it } + val taken = _pads.value.values.mapTo(mutableSetOf()) { it.number } + val free = (0 until MAX_PADS).firstOrNull { it !in taken } ?: return@synchronized null + val fresh = + MoonlightPad( + slotId = slotId, + number = free, + emulatedType = emulatedType, + capabilities = capabilities, + supportedButtons = supportedButtons, + ) + _pads.value = _pads.value + (slotId to fresh) + fresh + } ?: return null + announce(pad) + return pad + } + + /** Drop [slotId] from the session and report how many pads remain. */ + fun releasePad(slotId: String): Int { + val remaining = + synchronized(padLock) { + if (_pads.value[slotId] == null) return@synchronized _pads.value.size + _pads.value = _pads.value - slotId + _pads.value.size + } + withdraw() + return remaining + } + + fun padFor(slotId: String): MoonlightPad? = _pads.value[slotId] + + val padCount: Int get() = _pads.value.size + + val hasRoom: Boolean get() = _pads.value.size < MAX_PADS + + fun activeMask(): Int = _pads.value.values.fold(0) { mask, pad -> mask or (1 shl pad.number) } + + /** + * Start pinging the host's media ports. Runs from the moment the stream + * setup names them, because the host's initial-ping deadline is counted from + * its own session start and not from when our control channel comes up. + */ + fun startMediaPings(pinger: UdpMediaPinger) { + this.pinger?.let { old -> old.close() } + this.pinger = pinger + Log.i(TAG, "media pings for $id as ${pinger.mode} from ${pinger.localPorts}") + pingJob = + scope.launch(ioDispatcher) { + while (isActive) { + runCatching { + pinger.ping() + pinger.drain() + } + delay(MEDIA_PING_INTERVAL_MS) + } + } + } + + /** + * 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 reports the drop rather than a clean idle. + */ + fun markLive( + session: MoonlightControlSession, + appId: String?, + appName: String?, + ) { + this.session = session + sessionAppId = appId + sessionAppName = appName + _state.value = MoonlightSessionState.Live + _pads.value.values.forEach(::announce) + pumpJob = + scope.launch(ioDispatcher) { + // A throw in here would strand the session Live with nothing + // acknowledging the host, so it is caught and reported rather + // than left to kill the coroutine silently. + val failure = runCatching { pumpUntilClosed(session) }.exceptionOrNull() + if (failure != null) Log.w(TAG, "control pump for $id stopped: ${failure.message}", failure) + Log.i(TAG, "control link for $id ended: ${session.disconnectReason ?: "closed"} (${session.linkStats()})") + // Deliberately no /cancel here. The host will be left holding the + // app it started for us, but a control stream that drops after + // going live is as likely to be a blip as a real end, and closing + // somebody's game out from under them is worse than the tidying is + // worth. The binding screen offers the cancel explicitly. + if (_state.value == MoonlightSessionState.Live) markDropped() + } + } + + private fun announce(pad: MoonlightPad) { + val live = session ?: return + live.sendControllerArrival(pad.number, pad.emulatedType, pad.capabilities, pad.supportedButtons) + live.sendControllerState( + controllerNumber = pad.number, + activeMask = activeMask(), + buttons = 0, + leftTrigger = 0, + rightTrigger = 0, + leftStickX = 0, + leftStickY = 0, + rightStickX = 0, + rightStickY = 0, + ) + } + + // Clearing the pad's bit from the active mask is how the host is told to + // unplug it; the number is only free once that has gone out. + private fun withdraw() { + val live = session ?: return + val mask = activeMask() + val survivor = + _pads.value.values + .firstOrNull() + ?.number ?: 0 + live.sendControllerState( + controllerNumber = survivor, + activeMask = mask, + buttons = 0, + leftTrigger = 0, + rightTrigger = 0, + leftStickX = 0, + leftStickY = 0, + rightStickX = 0, + rightStickY = 0, + ) + } + + private suspend fun pumpUntilClosed(session: MoonlightControlSession) { + while (currentCoroutineContext().isActive && session.state == MoonlightControlSession.State.CONNECTED) { + session.pump() + } + } + + /** HOT PATH: forward one pad's controller state to the live session. */ + @Suppress("LongParameterList") + fun sendControllerState( + controllerNumber: Int, + buttons: Int, + leftTrigger: Int, + rightTrigger: Int, + leftX: Int, + leftY: Int, + rightX: Int, + rightY: Int, + ) { + val live = session ?: return + live.sendControllerState( + controllerNumber = controllerNumber, + activeMask = activeMask(), + buttons = buttons and 0xFFFF, + leftTrigger = leftTrigger, + rightTrigger = rightTrigger, + leftStickX = leftX, + leftStickY = leftY, + rightStickX = rightX, + rightStickY = rightY, + ) + } + + fun dispatchFeedback(event: MoonlightEvent) { + onFeedback(event) + } + + fun markDisconnected() { + teardown() + _state.value = MoonlightSessionState.Idle + } + + fun markDropped() { + teardown() + _state.value = MoonlightSessionState.Dropped + } + + fun markEnded() { + teardown() + _state.value = MoonlightSessionState.Ended + } + + private fun teardown() { + pumpJob?.cancel() + pumpJob = null + pingJob?.cancel() + pingJob = null + pinger?.close() + pinger = null + session?.let { s -> scope.launch(ioDispatcher) { runCatching { s.stop() } } } + session = null + } + + companion object { + private const val TAG = "MoonlightConnection" + + // Comfortably inside every host deadline we have measured, and cheap. + private const val MEDIA_PING_INTERVAL_MS = 500L + + const val ID_PREFIX = MoonlightHost.ID_PREFIX + + // The controller number is four bits of a 16-bit active mask, but a + // Moonlight session carries four pads and no more. + const val MAX_PADS = 4 + + const val SUPPORTED_BUTTONS = 0xFFFF + + val DEFAULT_TYPE = MoonlightEmulatedType.XBOX + } +} + +data class MoonlightPad( + val slotId: String, + val number: Int, + val emulatedType: Int, + val capabilities: Int, + val supportedButtons: Int, +) + +enum class MoonlightSessionState { Idle, Launching, Live, Dropped, Ended } 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..c4e37ff2 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightConnectionManager.kt @@ -0,0 +1,958 @@ +// 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.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 +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 kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +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() + + /** Something went right and the user should hear about it. */ + data class Notice( + val message: String, + ) : MoonlightConnectionEvent() + + data class Paired( + val host: MoonlightHost, + ) : MoonlightConnectionEvent() + + /** + * 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() + + /** + * The host refused to start an app because one is already running. When + * [resumable] the dish can take that session over; when it is not, the app + * belongs to somebody else and the only way forward is to quit it (see + * [MoonlightConnectionManager.quitHostApp]). + */ + data class AppAlreadyRunning( + val host: MoonlightHost, + val resumable: Boolean, + ) : MoonlightConnectionEvent() + + /** The host said it would hand its session back and then would not. */ + data class RejoinRefused( + val host: MoonlightHost, + ) : MoonlightConnectionEvent() + + /** The host refused for a reason of its own; [message] is its own wording. */ + data class LaunchRefused( + val host: MoonlightHost, + val message: String, + ) : MoonlightConnectionEvent() + + /** The app started and the stream did not come up, so it has been cancelled again. */ + data class SetupFailed( + val host: MoonlightHost, + ) : MoonlightConnectionEvent() + + /** The host already carries the four controllers a session can hold. */ + data class HostFull( + val host: MoonlightHost, + ) : MoonlightConnectionEvent() + + /** The host answered under a different uniqueid, so the old pairing is dead. */ + data class HostReplaced( + val host: MoonlightHost, + ) : MoonlightConnectionEvent() + + /** The host ended the session; nothing is recoverable without starting a new one. */ + data class EndedByHost( + val host: MoonlightHost, + ) : MoonlightConnectionEvent() +} + +/** What a host's session must do next, pulled out of the converge for testability. */ +internal enum class MoonlightConverge { OPEN, ANNOUNCE, WAIT, RELEASE, CANCEL } + +/** + * The reference count, as one rule. The first pad on a host opens the stream, later + * pads only announce themselves on the one already up, a launch in flight is left + * alone, and losing the last pad releases the host, closing the app it started only + * when a session actually came up. + */ +internal fun moonlightConverge( + state: MoonlightSessionState, + wantedPads: Int, +): MoonlightConverge = + when { + wantedPads == 0 && state == MoonlightSessionState.Live -> MoonlightConverge.CANCEL + wantedPads == 0 -> MoonlightConverge.RELEASE + state == MoonlightSessionState.Live -> MoonlightConverge.ANNOUNCE + state == MoonlightSessionState.Launching -> MoonlightConverge.WAIT + else -> MoonlightConverge.OPEN + } + +/** One binding's claim on a host session: which slot, and what pad to announce for it. */ +data class MoonlightPadRequest( + val slotId: String, + val emulatedType: Int, + val capabilities: Int, + val supportedButtons: Int, +) + +/** + * 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. + * + * ONE SESSION PER HOST, OWNED BY THE BINDINGS. [applyDesired] is the whole + * lifecycle: the first pad on a host launches (or resumes) and streams, later + * pads only announce themselves on the live stream, and the last pad leaving is + * what sends /cancel. Nothing else starts or stops a session. + * + * The launch/stream flow runs against a live Sunshine host end to end: /launch + * (or /resume when the host already has our session), the RTSP handshake, the + * media-port pings that stop the host's initial-ping deadline, the ENet connect + * and the live control stream. 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() + + /** + * 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. */ + val sessionHostIds: StateFlow> = _sessionHostIds.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() } + + // Serialises the whole converge so two emissions cannot both decide they are + // the first pad on a host and launch it twice. + private val convergeLock = Mutex() + + @Volatile private var desired: Map> = emptyMap() + + 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 { + 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}") + // 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 + } + } + + /** 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) { + Log.w(TAG, "manual add: nothing answered /serverinfo at $address") + _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, + ) + 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>.mergeHost(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]!! + } + + /** + * Re-verify what we know about [host] without touching a session. The + * plaintext probe answers reachability and PairStatus; the mutual-TLS probe + * is the only proof the pairing still stands, and its own currentgame is + * the only thing that tells us whether the session on this host is ours. + */ + suspend fun probe(host: MoonlightHost): MoonlightProbe = + withContext(ioDispatcher) { + val plain = + gateway + .getHttp(MoonlightUrls.serverInfoHttp(host.address, host.httpPort, deviceId)) + .takeIf { it.ok } + ?.let { MoonlightXml.parseServerInfo(it.body) } + // "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 (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) + } + // 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) { + 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) { + 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) + MoonlightProbe( + trust = MoonlightTrustState.PAIRED, + apps = apps.orEmpty(), + appsFetched = apps != null, + appsFailed = apps == null, + ownSession = info.currentGame != 0, + currentAppId = info.currentGame.takeIf { it != 0 }?.toString(), + ) + } + + /** + * Converge every host's session on the pads its bindings ask for. The only + * entry point into the session lifecycle: a host that gains its first pad is + * launched, a host that keeps pads only gains and loses them on the live + * stream, and a host that loses its last pad is cancelled. + */ + fun applyDesired(desired: Map>) { + this.desired = desired + Log.i(TAG, "desired pads: ${desired.entries.joinToString { "${it.key}=${it.value.size}" }.ifEmpty { "none" }}") + converge() + } + + /** + * Re-run the converge against the pads the bindings already asked for. The + * retry behind every failed-session action: nothing about the binding changed, + * so nothing new is desired, only another attempt at what already is. + */ + fun retrySessions() = converge() + + private fun converge() { + val desired = this.desired + scope.launch(ioDispatcher) { + convergeLock.withLock { + for ((hostId, pads) in desired) { + if (pads.isEmpty()) continue + runCatching { convergeHost(hostId, pads) } + .onFailure { Log.w(TAG, "converge failed for $hostId: ${it.message}", it) } + } + for (hostId in _connections.value.keys - desired.filterValues { it.isNotEmpty() }.keys) { + runCatching { releaseHost(hostId) } + .onFailure { Log.w(TAG, "release failed for $hostId: ${it.message}", it) } + } + publishSessionHosts() + } + } + } + + private suspend fun convergeHost( + hostId: String, + pads: List, + ) { + 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 } + for (slotId in conn.pads.value.keys - wanted.keys) conn.releasePad(slotId) + when (moonlightConverge(conn.state.value, wanted.size)) { + MoonlightConverge.WAIT -> Unit + MoonlightConverge.OPEN -> { + seedPads(conn, wanted.values) + openStream(conn, host) + } + MoonlightConverge.ANNOUNCE -> announcePads(conn, host, wanted.values) + MoonlightConverge.RELEASE, MoonlightConverge.CANCEL -> releaseHost(hostId) + } + } + + private suspend fun announcePads( + conn: MoonlightConnection, + host: MoonlightHost, + pads: Collection, + ) { + for (pad in pads) { + if (conn.padFor(pad.slotId) != null) continue + if (!conn.hasRoom) { + _events.emit(MoonlightConnectionEvent.HostFull(host)) + continue + } + conn.acquirePad(pad.slotId, pad.emulatedType, pad.capabilities, pad.supportedButtons) + } + } + + private fun seedPads( + conn: MoonlightConnection, + pads: Collection, + ) { + for (pad in pads) { + if (!conn.hasRoom) break + conn.acquirePad(pad.slotId, pad.emulatedType, pad.capabilities, pad.supportedButtons) + } + } + + private suspend fun releaseHost(hostId: String) { + val conn = _connections.value[hostId] ?: return + conn.pads.value.keys + .toList() + .forEach(conn::releasePad) + val cancels = moonlightConverge(conn.state.value, wantedPads = 0) == MoonlightConverge.CANCEL + conn.markDisconnected() + if (cancels) runCatching { cancelHostApp(conn.host.value) } + } + + private fun publishSessionHosts() { + _sessionHostIds.value = + _connections.value + .filterValues { it.pads.value.isNotEmpty() && it.state.value != MoonlightSessionState.Idle } + .keys + } + + private fun hostFor(hostId: String): MoonlightHost? = + _connections.value[hostId]?.host?.value + ?: store.get(hostId)?.toHost() + ?: _discovered.value.firstOrNull { it.id == hostId } + + // Re-probe immediately before starting a session: the pairing is remembered trust + // and the host may have dropped it, or come back as a different machine entirely, + // since the last time anything asked. + private suspend fun openStream( + conn: MoonlightConnection, + host: MoonlightHost, + ) { + conn.markLaunching() + 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)) + } + return + } + val remembered = store.get(host.id) + val appId = remembered?.lastAppId?.takeIf { it.isNotEmpty() } ?: probe.apps.firstOrNull()?.id + if (appId == null) { + conn.markDisconnected() + _events.emit(MoonlightConnectionEvent.Error("No apps available on ${host.name}.")) + return + } + val appName = + remembered + ?.lastAppName + .orEmpty() + .ifEmpty { + probe.apps + .firstOrNull { it.id == appId } + ?.title + .orEmpty() + } + launchAndStream(conn, host, appId, appName) + } + + /** + * Pair with [host]: emits [MoonlightConnectionEvent.PairingPinReady] with + * the generated PIN, runs the 5 phases, and returns true when paired. + * Public so the binding screen can await pairing before fetching the app list. + */ + 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 { + pair(host) + } + } + + /** Fetch the host's app list (empty when unreachable/unpaired). */ + suspend fun fetchApps(host: MoonlightHost): List = withContext(ioDispatcher) { fetchAppList(host) } + + private fun fetchAppList(host: MoonlightHost): List { + val reply = gateway.getHttps(MoonlightUrls.appList(host.address, host.httpsPort, deviceId), host.id) + if (!reply.ok) throw java.io.IOException("applist refused by ${host.address}: HTTP ${reply.status}") + return 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) { + 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 { + // 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 pairingRefused(host, "phase 1 returned no host certificate (HTTP ${p1.status})") + 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 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, "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, "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) + Log.i(TAG, "paired with ${host.name} at ${host.address}") + rememberPaired(host, paired = true) + _events.emit(MoonlightConnectionEvent.Paired(host)) + true + }.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, + reason: String, + ): Boolean { + Log.w(TAG, "pairing refused by ${host.address}: $reason") + _events.emit(MoonlightConnectionEvent.PairingFailed(host, reason)) + return false + } + + private suspend fun launchAndStream( + conn: MoonlightConnection, + host: MoonlightHost, + appId: String, + appName: String, + ) { + 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 rtspPort = openSession(conn, host, appId, bytesToHex(rikey), rikeyId) ?: return + val rtsp = MoonlightRtspClient(host.address, rtspPort).handshake(LAUNCH_WIDTH, LAUNCH_HEIGHT, LAUNCH_FPS) + if (rtsp == null) { + // MoonlightRtspClient has already said which step failed and how. + Log.w(TAG, "RTSP setup failed on ${host.address}:$rtspPort") + giveUp(conn, host) + return + } + // Before the control channel, not after: the host counts its initial + // ping deadline from its own session start, so the media ports get + // their first datagram at the earliest moment we know their numbers. + runCatching { UdpMediaPinger(host.address, rtsp.videoPort, rtsp.audioPort, rtsp.pingPayload) } + .onSuccess(conn::startMediaPings) + .onFailure { Log.w(TAG, "no media ping sockets for ${host.address}: ${it.message}") } + val transport = + runCatching { UdpControlTransport(host.address, rtsp.controlPort) } + .onFailure { Log.w(TAG, "no control socket to ${host.address}:${rtsp.controlPort}: ${it.message}") } + .getOrNull() + if (transport == null) { + giveUp(conn, host) + return + } + val session = + MoonlightControlSession(rikey, rtsp.enetConnectData, transport, System::currentTimeMillis) { event -> + if (event is com.tinkernorth.dish.core.net.moonlight.MoonlightEvent.Termination) onHostTerminated(conn, host) + conn.dispatchFeedback(event) + } + if (!session.connect()) { + Log.w(TAG, "control channel refused on ${host.address}:${rtsp.controlPort}") + giveUp(conn, host) + return + } + 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, paired = true) + publishSessionHosts() + } + + private fun appTitleFor( + host: MoonlightHost, + appId: String, + ): String? = fetchAppList(host).firstOrNull { it.id == appId }?.title + + // The host ended it, so there is nothing to rejoin: the pads stay claimed by + // their bindings and the next use starts a new session rather than resuming. + private fun onHostTerminated( + conn: MoonlightConnection, + host: MoonlightHost, + ) { + conn.markEnded() + publishSessionHosts() + scope.launch { _events.emit(MoonlightConnectionEvent.EndedByHost(host)) } + } + + /** + * Ask the host to start [appId] and hand back the RTSP port it named, or + * null when it would not. + * + * A MOONLIGHT HOST REFUSES IN THE BODY, NOT IN THE STATUS LINE. Sunshine + * answers a second /launch with HTTP 200 carrying + * `status_code="400" status_message="An app is already running on this + * host"`, so the transport succeeded and the call did not. Reading only + * the HTTP status turned that into "RTSP port null" and a generic + * failure, which named the symptom and hid the cause. + */ + private suspend fun openSession( + conn: MoonlightConnection, + host: MoonlightHost, + appId: String, + rikeyHex: String, + rikeyId: Int, + ): Int? { + val url = MoonlightUrls.launch(host.address, host.httpsPort, deviceId, appId, rikeyHex, rikeyId, LAUNCH_MODE) + val reply = gateway.getHttps(url, host.id) + val status = MoonlightXml.parseStatus(reply.body) + val rtspPort = parseRtspPort(reply.body) + Log.i( + TAG, + "launch $appId on ${host.address}: HTTP ${reply.status}, " + + "host ${status?.code ?: "?"} ${status?.message.orEmpty()}, RTSP port $rtspPort", + ) + if (reply.ok && status?.ok != false && rtspPort != null) return rtspPort + if (status?.appAlreadyRunning == true) return resumeSession(conn, host, status, rikeyHex, rikeyId) + Log.w(TAG, "launch refused by ${host.address}: ${reply.body.take(BODY_LOG_CHARS)}") + conn.markDisconnected() + _events.emit(MoonlightConnectionEvent.LaunchRefused(host, status?.message.orEmpty())) + return null + } + + /** + * Take over the session the host already has, when it says we may. A host + * that says we may not is holding somebody else's app and the only way + * past it is [quitHostApp], so say so instead of failing vaguely. + */ + private suspend fun resumeSession( + conn: MoonlightConnection, + host: MoonlightHost, + launchStatus: MoonlightXml.Status, + rikeyHex: String, + rikeyId: Int, + ): Int? { + if (!launchStatus.resume) { + Log.i(TAG, "${host.address} has an app running and will not resume it") + conn.markDisconnected() + _events.emit(MoonlightConnectionEvent.AppAlreadyRunning(host, resumable = false)) + return null + } + val reply = + gateway.getHttps(MoonlightUrls.resume(host.address, host.httpsPort, deviceId, rikeyHex, rikeyId), host.id) + val status = MoonlightXml.parseStatus(reply.body) + val rtspPort = parseRtspPort(reply.body) + Log.i( + TAG, + "resume on ${host.address}: HTTP ${reply.status}, " + + "host ${status?.code ?: "?"} ${status?.message.orEmpty()}, RTSP port $rtspPort", + ) + if (reply.ok && status?.ok != false && rtspPort != null) return rtspPort + Log.w(TAG, "resume refused by ${host.address}: ${reply.body.take(BODY_LOG_CHARS)}") + conn.markDisconnected() + _events.emit(MoonlightConnectionEvent.RejoinRefused(host)) + return null + } + + /** + * Tell [host] to end the app it is running. The protocol's own way out of + * "an app is already running", and the only one when the host will not + * resume that session for us. /cancel answers 200 whether or not anything + * was running, so the caller re-probes rather than believing it. + */ + fun quitHostApp(host: MoonlightHost) { + scope.launch(ioDispatcher) { + _connections.value[host.id]?.let { conn -> + conn.pads.value.keys + .toList() + .forEach(conn::releasePad) + conn.markDisconnected() + } + cancelHostApp(host) + publishSessionHosts() + _events.emit(MoonlightConnectionEvent.Notice("Asked ${host.name} to close the app it is running.")) + } + } + + private fun cancelHostApp(host: MoonlightHost): Boolean { + val reply = gateway.getHttps(MoonlightUrls.cancel(host.address, host.httpsPort, deviceId), host.id) + val status = MoonlightXml.parseStatus(reply.body) + Log.i(TAG, "cancel on ${host.address}: HTTP ${reply.status}, host ${status?.code ?: "?"}") + return reply.ok && status?.ok != false + } + + /** + * Abandon a launch we asked for and could not use. The host started an app + * on our behalf, so we take it back down rather than strand it: every later + * attempt would otherwise be refused by the app we ourselves left running. + * + * Only for the setup path. A control stream that drops after going live is + * left alone, because the host will let us /resume it and the user would + * rather have that than have their game closed under them. + */ + private suspend fun giveUp( + conn: MoonlightConnection, + host: MoonlightHost, + ) { + conn.markDisconnected() + runCatching { cancelHostApp(host) } + _events.emit(MoonlightConnectionEvent.SetupFailed(host)) + } + + fun disconnect(id: String) { + _connections.value[id]?.markDisconnected() + 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) { + // 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) { + _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) + 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( + id = host.id, + name = host.name, + address = host.address, + httpPort = host.httpPort, + httpsPort = host.httpsPort, + uniqueId = host.uniqueId, + 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, + ), + ) + } + + /** The remembered emulated-device pick for [hostId], defaulting to Auto. */ + fun rememberedEmulatedType(hostId: String): Int = + MoonlightEmulatedType.fromStored(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 remembered last-launched app title for [hostId], or empty. */ + fun rememberedAppName(hostId: String): String = store.get(hostId)?.lastAppName.orEmpty() + + fun rememberedHost(hostId: String): MoonlightHost? = hostFor(hostId) + + // 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 + 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/MoonlightHostProbe.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHostProbe.kt new file mode 100644 index 00000000..602a0440 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHostProbe.kt @@ -0,0 +1,23 @@ +// 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.MoonlightXml + +/** + * How much a Moonlight host is trusted, as of the last time we asked it. There is + * no bidirectional liveness in this protocol: pairing is one-time trust, the host + * never tells us it revoked one, and a successful mutual-TLS call is itself the + * only proof. So this is a remembered word verified lazily, never a live link. + */ +enum class MoonlightTrustState { CHECKING, PAIRED, NOT_PAIRED, UNREACHABLE, REMEMBERED, TRUST_LOST, REPLACED } + +data class MoonlightProbe( + val trust: MoonlightTrustState = MoonlightTrustState.CHECKING, + val apps: List = emptyList(), + val appsFetched: Boolean = false, + val appsFailed: Boolean = false, + val ownSession: Boolean = false, + val currentAppId: String? = null, +) diff --git a/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttp11Client.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttp11Client.kt new file mode 100644 index 00000000..c88ce470 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttp11Client.kt @@ -0,0 +1,308 @@ +// 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], 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. 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 + * 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 + * [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, TLS + * handshake failures and a refused certificate pin all come back as + * `Reply(0, "")`. + */ +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. + * + * [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 { + 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)) + flush() + } + readReply(socket.getInputStream().buffered()) + } + } + + /** 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) + 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))) + } + + /** + * 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 = "MoonlightHttp11" + 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 STATUS_LOG_LEN = 64 + 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/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..114cb8a9 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGateway.kt @@ -0,0 +1,256 @@ +// 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.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.KeyManager +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +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 + +/** + * 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. + * + * 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. + */ +@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 + } + + private val plain = MoonlightHttp11Client(TIMEOUT_MS, TIMEOUT_MS) + + /** + * The dish's client credential, resolved once: a keystore load and a + * KeyManagerFactory init are not cheap, and the identity behind them + * never changes for the life of the process. The SSLContext that + * presents them is deliberately not cached; see [openTls]. + */ + private val clientCredential: Array by lazy { clientKeyManagers() } + + /** + * 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. [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 + * open on purpose; see [PAIR_PIN_TIMEOUT_MS]. + */ + fun getHttp( + url: String, + readTimeoutMs: Int = TIMEOUT_MS, + ): Reply = plain.get(url, readTimeoutMs) + + /** + * 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. + * + * Holding nothing of ours has to include the TLS session itself, which + * is what [openTls] is careful about. + */ + fun getHttps( + urlString: String, + hostId: String, + ): Reply = + MoonlightHttp11Client(HTTPS_TIMEOUT_MS, HTTPS_TIMEOUT_MS) { socket, host, port -> + 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. + * Throwing is the rejection: [MoonlightHttp11Client] never writes a + * request through a socket it did not get back. + * + * A FRESH SSLContext PER CONNECTION, and that is the whole point of + * building it here rather than once. An SSLContext owns the client + * session cache, so a cached factory hands every call after the first + * one a session to resume, and a resumed session is the one thing a + * Moonlight host cannot survive: it carries the peer identity forward + * instead of asking for the certificate again, so the host's verify + * callback never runs and Sunshine answers with a fatal + * `internal_error` alert (RFC 8446 alert 80) and logs nothing at all. + * Measured against a live Sunshine 2026.x host, at TLS 1.2 as well as + * TLS 1.3: a full handshake is served, a resumed one is killed. The + * gateway is a singleton and the identity outlives the process, so + * without this every mutual-TLS call but the very first one failed. + * (The HttpsURLConnection version this replaced never hit it only by + * accident: it built a whole SSLContext per call.) Cheap, too, since + * [clientCredential] carries the part that is not. + */ + private fun openTls( + socket: Socket, + host: String, + port: Int, + hostId: String, + ): Socket { + val tls = mutualTlsFactory().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 + } + + /** A context of its own, and with it a session cache that is always empty. */ + private fun mutualTlsFactory(): SSLSocketFactory = + SSLContext + .getInstance("TLS") + .apply { init(clientCredential, arrayOf(trustAll), SecureRandom()) } + .socketFactory + + // Present the client certificate; the host authorises by it after pairing. + private fun clientKeyManagers(): Array { + 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), + ), + ) + } + return KeyManagerFactory + .getInstance(KeyManagerFactory.getDefaultAlgorithm()) + .apply { init(keyStore, CharArray(0)) } + .keyManagers + } + + // 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 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 + } + } + + @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() + } + + companion object { + 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 + * (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/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 new file mode 100644 index 00000000..662e30ff --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightIdentityProvider.kt @@ -0,0 +1,151 @@ +// 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.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 +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], and + * the same key authenticates the dish on every mutual-TLS call afterwards, so + * it is generated with the authorizations both of those need. + * + * 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 + @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) } + 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) } + val spec = + KeyGenParameterSpec + .Builder(ALIAS, KeyProperties.PURPOSE_SIGN) + .setKeySize(RSA_KEY_SIZE) + // 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) + .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 TAG = "MoonlightIdentity" + 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..3f318937 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightRtspClient.kt @@ -0,0 +1,201 @@ +// 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.io.IOException +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. + * + * ONE CONNECTION PER MESSAGE, and it has to be. A Moonlight host answers exactly + * one RTSP message per TCP connection and then hangs up on its own. Measured + * against a live Sunshine host: an idle read taken straight after the OPTIONS + * reply, with nothing further written, returns end-of-stream, and so does the + * same read after a DESCRIBE reply, so it is having answered that ends the + * connection and not which command was asked. A second message written into that + * socket is never seen at all: the host's own debug log recorded our OPTIONS and + * nothing after it, and pipelining OPTIONS and DESCRIBE into a single write got + * one answer and one hang-up. Reusing the socket cost us the whole stream setup, + * which failed at DESCRIBE with the host already gone. So each request opens its + * own socket and closes it, the same shape [MoonlightHttp11Client] gives the + * HTTP half. + * + * The reply body is framed by that hang-up as much as by Content-length: the + * host sends the DESCRIBE SDP with no length header at all and simply closes. + * + * Message framing is delegated to the pure [MoonlightRtsp] codec; this class + * owns only the sockets 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, + /** + * The host's per-session media-ping secret, 16 raw characters and NOT + * hex however much it looks like it. It goes back to the host verbatim + * inside a 20-byte datagram; see + * [com.tinkernorth.dish.core.net.moonlight.MoonlightMediaPing]. + */ + val pingPayload: String, + ) + + private var cseq = 0 + + /** + * The step in flight, as it would be named in a log line. A host that hangs + * up mid-handshake reaches us as a bare write or read failure with no reply + * attached, so the step it died on is the only thing that identifies it. + */ + private var stage = "connect" + + @Suppress("ReturnCount") // each early return is a distinct RTSP step failing + fun handshake( + width: Int, + height: Int, + fps: Int, + ): StreamPorts? { + val target = "rtsp://$address:$rtspPort" + if (send(MoonlightRtsp.options(target, nextCseq())) == null) return null + if (send(MoonlightRtsp.describe(target, nextCseq())) == null) return null + + val audioResp = setup("audio") ?: return null + val audio = audioResp.serverPort() ?: return null + val videoResp = setup("video") ?: return null + val video = videoResp.serverPort() ?: return null + val controlResp = setup("control") ?: return null + val controlPort = controlResp.serverPort() ?: return null + val connectData = controlResp.enetConnectData() ?: 0 + val ping = audioResp.pingPayload() ?: videoResp.pingPayload().orEmpty() + + val sdp = MoonlightRtsp.announceSdp(width, height, fps) + if (send(MoonlightRtsp.announce(target, nextCseq(), sdp)) == null) return null + if (send(MoonlightRtsp.play(target, nextCseq())) == null) return null + + Log.i( + TAG, + "negotiated ports on $address: control $controlPort, video $video, audio $audio; " + + "connect-data $connectData, ping payload ${ping.length} chars", + ) + if (ping.isEmpty()) Log.w(TAG, "host named no ping payload; falling back to the legacy 4-byte media ping") + return StreamPorts(controlPort, video, audio, connectData, ping) + } + + private fun setup(streamId: String): MoonlightRtsp.Response? { + val response = send(MoonlightRtsp.setup(streamId, nextCseq())) ?: return null + if (response.serverPort() == null) { + Log.w(TAG, "SETUP $streamId carried no server_port, options ${response.options}") + } + return response + } + + /** + * One request over one socket: connect, ask, read the answer, close. Says on + * the way out how it went, so a handshake that dies somewhere in the middle + * names the step it died on. + */ + private fun send(request: MoonlightRtsp.Request): MoonlightRtsp.Response? { + stage = "${request.command} (CSeq ${request.cseq})" + return try { + Socket().use { socket -> + socket.connect(InetSocketAddress(address, rtspPort), CONNECT_TIMEOUT_MS) + socket.soTimeout = READ_TIMEOUT_MS + Log.d(TAG, "-> $stage") + socket.getOutputStream().apply { + write(request.encode().toByteArray()) + flush() + } + accept(readResponse(socket.getInputStream().bufferedReader())) + } + } catch (e: IOException) { + Log.w(TAG, "$stage failed: ${e.javaClass.simpleName}: ${e.message}") + null + } + } + + private fun accept(response: MoonlightRtsp.Response?): MoonlightRtsp.Response? { + if (response == null) return null + if (!response.ok) { + Log.w(TAG, "<- $stage refused: ${response.statusCode} ${response.statusMessage}") + return null + } + Log.d(TAG, "<- $stage ${response.statusCode}, options ${response.options.keys}") + return response + } + + /** + * Read one RTSP response: status + headers until a blank line, then the + * body. Content-length frames it when the host sends one; the host does not + * on DESCRIBE, and since it closes the connection once it has answered, the + * rest of the stream is the body. + */ + private fun readResponse(reader: BufferedReader): MoonlightRtsp.Response? { + val header = StringBuilder() + var line = reader.readLine() + if (line == null) { + Log.w(TAG, "host closed the connection during $stage, before answering") + return null + } + while (line != null && line.isNotEmpty()) { + header.append(line).append(MoonlightRtsp.CRLF) + line = reader.readLine() + } + header.append(MoonlightRtsp.CRLF) + val declared = + Regex("(?i)content-length:\\s*(\\d+)") + .find(header) + ?.groupValues + ?.get(1) + ?.toIntOrNull() + val raw = header.toString() + if (declared != null) readExactly(reader, declared) else reader.readText() + return MoonlightRtsp.parseResponse(raw).also { + if (it == null) Log.w(TAG, "unparsable reply to $stage: ${escape(raw)}") + } + } + + /** Hands back what arrived even when the host stops short of its own count. */ + private fun readExactly( + reader: BufferedReader, + count: Int, + ): String { + val out = CharArray(count.coerceIn(0, MAX_BODY_CHARS)) + var filled = 0 + while (filled < out.size) { + val n = reader.read(out, filled, out.size - filled) + if (n < 0) break + filled += n + } + return out.concatToString(0, filled) + } + + /** Line ends spelled out, so a framing bug is readable in a log line. */ + private fun escape(raw: String): String = + raw + .take(RAW_LOG_CHARS) + .replace("\r", "\\r") + .replace("\n", "\\n") + + 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 + const val RAW_LOG_CHARS = 512 + const val MAX_BODY_CHARS = 256 * 1024 + } +} 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/source/connection/moonlight/UdpMediaPinger.kt b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/UdpMediaPinger.kt new file mode 100644 index 00000000..ce1d310e --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/UdpMediaPinger.kt @@ -0,0 +1,128 @@ +// 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.MoonlightMediaPing +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.SocketException +import java.net.SocketTimeoutException + +/** + * Keeps the host's video and audio RTP streams alive by pinging the ports its + * SETUP replies named. See [MoonlightMediaPing] for what the datagram has to + * look like and why. + * + * ONE LONG-LIVED SOCKET PER STREAM, and it has to be. The host learns where to + * send RTP from the source address of this datagram and then streams back to + * that exact socket, so a throwaway socket per ping tells it a new port every + * time and closes the one it just learned. Keeping the socket open also means + * the legacy 4-byte form has somewhere to work: that path is matched by source + * address rather than by payload, so the socket is bound to the same port number + * it sends to whenever the OS will allow it. + * + * Whatever arrives on these sockets is read and thrown away. This path + * negotiates the media streams and decodes nothing, but an unread socket fills + * its receive buffer and starts dropping, which on some stacks is visible to the + * far end as a dead peer. + */ +class UdpMediaPinger( + address: String, + private val videoPort: Int, + private val audioPort: Int, + private val payload: String, +) { + private val host = InetAddress.getByName(address) + private val video = bind(videoPort) + private val audio = bind(audioPort) + private val drainBuffer = ByteArray(MAX_DATAGRAM) + + private var sequence = 0 + + /** The local ports the host will stream to, for the session log. */ + val localPorts: String get() = "video ${video.localPort}, audio ${audio.localPort}" + + val mode: String get() = if (MoonlightMediaPing.usable(payload)) "SS_PING" else "legacy PING" + + /** Send one ping to each media port. Safe to call after [close]. */ + fun ping() { + val datagram = + if (MoonlightMediaPing.usable(payload)) { + MoonlightMediaPing.ssPing(payload, sequence) + } else { + MoonlightMediaPing.legacy() + } + sequence += 1 + send(video, videoPort, datagram) + send(audio, audioPort, datagram) + } + + /** + * Read and discard what the host has sent us, up to a bounded number of + * datagrams per stream. The bound is the point: once the host is streaming, + * an unbounded drain would keep finding more and the next ping would never + * go out. + */ + fun drain() { + drain(video) + drain(audio) + } + + fun close() { + runCatching { video.close() } + runCatching { audio.close() } + } + + // A closed or unreachable media socket must not take the control stream with + // it: the session is still usable without the streams we discard anyway. + @Suppress("SwallowedException") + private fun send( + socket: DatagramSocket, + port: Int, + datagram: ByteArray, + ) { + try { + socket.send(DatagramPacket(datagram, datagram.size, host, port)) + } catch (e: SocketException) { + Log.w(TAG, "media ping to $host:$port failed: ${e.message}") + } catch (e: java.io.IOException) { + Log.w(TAG, "media ping to $host:$port failed: ${e.message}") + } + } + + @Suppress("SwallowedException") + private fun drain(socket: DatagramSocket) { + try { + socket.soTimeout = 1 + repeat(DRAIN_BUDGET) { + socket.receive(DatagramPacket(drainBuffer, drainBuffer.size)) + } + } catch (timeout: SocketTimeoutException) { + // Nothing left this round; that is the normal exit. + } catch (e: java.io.IOException) { + // Closed underneath us, or nothing listening. Either way we discard. + } + } + + /** + * Bind to [port] so the legacy match by source port can work, falling back to + * an ephemeral port when it is taken (the payload path does not care). + */ + private fun bind(port: Int): DatagramSocket = + runCatching { DatagramSocket(port) }.getOrElse { + Log.i(TAG, "local port $port unavailable, using an ephemeral one") + DatagramSocket() + } + + private companion object { + const val TAG = "MoonlightMediaPing" + const val MAX_DATAGRAM = 2048 + + // Enough to keep the receive buffer from filling between pings, few + // enough that a host mid-stream cannot hold the ping loop here. + const val DRAIN_BUDGET = 64 + } +} 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/common/ControllerTypeLabels.kt b/app/src/main/java/com/tinkernorth/dish/ui/common/ControllerTypeLabels.kt index 0b0b48f3..00c0ce92 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/common/ControllerTypeLabels.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/common/ControllerTypeLabels.kt @@ -7,6 +7,7 @@ import com.tinkernorth.dish.R import com.tinkernorth.dish.composer.CONTROLLER_TYPE_DUALSENSE import com.tinkernorth.dish.composer.CONTROLLER_TYPE_PLAYSTATION import com.tinkernorth.dish.composer.CONTROLLER_TYPE_SWITCHPRO +import com.tinkernorth.dish.core.net.moonlight.MoonlightEmulatedType // Bundled label for a catalog id; the live catalog name wins where available // (ConfigureBindingsViewModel.typeLabel), this is the offline/diagnostic fallback. @@ -18,3 +19,14 @@ fun bundledControllerTypeLabelRes(type: Int): Int = CONTROLLER_TYPE_SWITCHPRO -> R.string.picker_type_switchpro else -> R.string.picker_type_xbox } + +// A Moonlight host runs its own type table whose ids overlap the catalog's, so the two +// never share a label mapper: CONTROLLER_TYPE_XBOX is 1 here and 0 there. +@StringRes +fun moonlightTypeLabelRes(type: Int): Int = + when (type) { + MoonlightEmulatedType.XBOX -> R.string.ml_type_xbox + MoonlightEmulatedType.PLAYSTATION -> R.string.ml_type_playstation + MoonlightEmulatedType.NINTENDO -> R.string.ml_type_nintendo + else -> R.string.ml_type_auto + } 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..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 @@ -80,6 +82,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 +99,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 +181,43 @@ 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) { + 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) { + startMoonlightPairing(host) + } + + override fun onQuitSession(id: String) { + val host = hostFor(id) + if (host == null) { + reportMoonlightHostGone(id, id) + return + } + moonlight.quitHostApp(host) + } + + override fun onForget(id: String) { + 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(), @@ -217,6 +260,7 @@ class ConnectionsActivity : BaseGamepadHostActivity() { if (granted) { dismissLocalNetworkBanner() satellite.startDiscovery() + moonlight.startDiscovery() } else { showLocalNetworkBanner() } @@ -246,6 +290,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 +306,59 @@ 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) + // 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), + body = getString(R.string.ml_pair_failed_body), + ) + } + is com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionEvent.Notice -> + notifications.info( + glyph = R.drawable.ic_pc_monitor, + title = getString(R.string.section_moonlight_hosts), + body = ev.message, + key = "moonlight-notice", + ) + 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, + ) + } + // Every remaining event belongs to a session, and a session belongs to a + // binding; the binding screen renders them where the user can act on them. + else -> Unit + } } private fun observeSystemStateBanners() { @@ -343,8 +441,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 +459,9 @@ class ConnectionsActivity : BaseGamepadHostActivity() { satelliteHeader, satelliteList, StaticViewAdapter(R.layout.item_connection_divider), + moonlightHeader, + moonlightList, + StaticViewAdapter(R.layout.item_connection_divider), bluetoothHeader, bluetoothList, ), @@ -394,6 +504,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 +855,97 @@ class ConnectionsActivity : BaseGamepadHostActivity() { dialog.show() } + // ── Moonlight host flow ───────────────────────────────────────────────── + + // The hosts screen owns trust and nothing else: pairing, forgetting, and the + // 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) { + 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? = + moonlight.get(id)?.host?.value + ?: moonlight.remembered.value + .firstOrNull { it.id == id } + ?.toHost() + ?: moonlight.discovered.value.firstOrNull { it.id == id } + + private fun showMoonlightPinDialog( + host: com.tinkernorth.dish.core.net.moonlight.MoonlightHost, + pin: String, + ) { + moonlightPinDialog?.dismiss() + val message = getString(R.string.ml_pair_pin_body, pin, host.name) + "\n\n" + getString(R.string.ml_pair_waiting) + moonlightPinDialog = + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.moonlight_pin_title, host.name)) + .setMessage(message) + .setNegativeButton(R.string.action_cancel) { _, _ -> cancelMoonlightPairing() } + .setOnDismissListener { moonlightPinDialog = null } + .show() + } + + private fun showAddMoonlightDialog() { + 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(view) + .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 +1190,7 @@ class ConnectionsActivity : BaseGamepadHostActivity() { if (LocalNetworkAccess.isGranted(this)) { dismissLocalNetworkBanner() satellite.startDiscovery() + moonlight.startDiscovery() return } if (userInitiated || !localNetworkPrompted) { @@ -1079,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/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..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 @@ -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,54 @@ 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, ) + } + + // 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( + moonlight.discovered, + moonlight.isScanning, + moonlight.remembered, + 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, ml.discovered, ml.pairedIds, ml.verifiedIds), + rememberedBtIds = slice.rememberedBtIds, + scanning = slice.scanning, + 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 new file mode 100644 index 00000000..24cf4596 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/ui/connections/MoonlightListAdapter.kt @@ -0,0 +1,215 @@ +// 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.source.connection.moonlight.MoonlightTrustState +import com.tinkernorth.dish.ui.common.setLoading +import com.tinkernorth.dish.ui.main.chipTextRes + +/** Rows for the Moonlight-hosts section, the sibling of [SatelliteRow]. */ +sealed interface MoonlightRow { + data class Known( + val summary: ConnectionSummary, + val trust: MoonlightTrustState, + val controllerCount: Int, + ) : MoonlightRow + + data class Discovered( + val host: MoonlightHost, + ) : MoonlightRow + + data class Empty( + val message: String, + ) : MoonlightRow +} + +interface MoonlightRowListener { + fun onPairKnown(summary: ConnectionSummary) + + fun onPairDiscovered(host: MoonlightHost) + + fun onQuitSession(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 + } + } + + // Pairing is remembered trust, not a live link, so the chip says which of the + // three trust words applies and never lights up as though the host were online. + // The session lives in the binding; all this screen offers is the way out of one. + private fun bindKnown(row: MoonlightRow.Known) { + val c = row.summary + b.paintConnection(c.label, detailFor(row), ctx.getString(row.trust.chipTextRes()), ConnectionKind.MOONLIGHT, c.live) + if (row.controllerCount > 0) { + b.btnRowAction.setLoading(false, "", ctx.getString(R.string.ml_action_quit_session)) + b.btnRowAction.setOnClickListener { listener.onQuitSession(c.id) } + } else { + val label = + if (row.trust == MoonlightTrustState.PAIRED) { + ctx.getString(R.string.ml_action_pair_again) + } else { + ctx.getString(R.string.ml_action_pair) + } + b.btnRowAction.setLoading(false, "", label) + b.btnRowAction.setOnClickListener { listener.onPairKnown(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 detailFor(row: MoonlightRow.Known): String { + if (row.controllerCount == 0) return row.summary.detail + val count = + ctx.resources.getQuantityString( + R.plurals.ml_host_in_use_count, + row.controllerCount, + row.controllerCount, + ) + return row.summary.detail + " · " + ctx.getString(R.string.ml_host_in_use, count) + } + + 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.ml_trust_not_paired), + ConnectionKind.MOONLIGHT, + LinkState.Found, + ) + b.btnRowAction.setLoading(false, "", ctx.getString(R.string.ml_action_pair)) + b.btnRowAction.setOnClickListener { listener.onPairDiscovered(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. +// 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, + pairedIds: Set = emptySet(), + verifiedIds: Set = emptySet(), +): List { + val known = conns.filter { it.kind == ConnectionKind.MOONLIGHT } + val knownIds = known.mapTo(mutableSetOf()) { it.id } + return buildList { + known.forEach { summary -> + add( + MoonlightRow.Known( + summary = summary, + trust = moonlightTrustFor(summary, summary.id in pairedIds, summary.id in verifiedIds), + controllerCount = summary.boundSlotIds.size, + ), + ) + } + discovered.forEach { host -> + if (host.id !in knownIds) add(MoonlightRow.Discovered(host)) + } + } +} + +// "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, + paired: Boolean, + verified: Boolean = false, +): MoonlightTrustState = + when { + summary.live == LinkState.Connected || summary.live == LinkState.Unstable -> MoonlightTrustState.PAIRED + verified -> MoonlightTrustState.PAIRED + paired -> MoonlightTrustState.REMEMBERED + else -> MoonlightTrustState.NOT_PAIRED + } diff --git a/app/src/main/java/com/tinkernorth/dish/ui/main/ConfigureBindingsActivity.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/ConfigureBindingsActivity.kt index f0edbba5..5b237430 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/main/ConfigureBindingsActivity.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/main/ConfigureBindingsActivity.kt @@ -21,6 +21,7 @@ import com.tinkernorth.dish.composer.ConnectionKind import com.tinkernorth.dish.core.model.DishNotification 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.databinding.ActivityConfigureBindingsBinding import com.tinkernorth.dish.databinding.BindingApplyStepBinding import com.tinkernorth.dish.databinding.BindingValueNoneBinding @@ -32,6 +33,7 @@ import com.tinkernorth.dish.ui.common.BaseGamepadHostActivity import com.tinkernorth.dish.ui.common.DishNavigator import com.tinkernorth.dish.ui.common.applyDishActivityTransitions import com.tinkernorth.dish.ui.common.applyDishSystemBars +import com.tinkernorth.dish.ui.common.moonlightTypeLabelRes import com.tinkernorth.dish.ui.donate.wireDonateButton import com.tinkernorth.dish.ui.setup.ReviewFlow import com.tinkernorth.dish.ui.setup.bindCapabilityRows @@ -73,6 +75,13 @@ class ConfigureBindingsActivity : BaseGamepadHostActivity() { observe() } + // Re-verify on entering the screen: a Moonlight pairing is remembered trust, and the + // only way to learn the host revoked it is to ask. + override fun onStart() { + super.onStart() + viewModel.refreshMoonlight() + } + private fun observe() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -102,10 +111,26 @@ class ConfigureBindingsActivity : BaseGamepadHostActivity() { bindInputSection(state, snapshot) bindDestinationSection(state, snapshot) + val session = state.moonlightSession + binding.sectionMoonlight.root.visibility = if (session == null) View.GONE else View.VISIBLE + if (session != null) bindMoonlightSection(state, session) binding.sectionBinding.root.visibility = if (state.hostChosen) View.VISIBLE else View.GONE if (state.hostChosen) bindBindingSection(state) } + private fun bindMoonlightSection( + state: ConfigUiState, + session: MoonlightSessionUi, + ) { + binding.sectionMoonlight.bindMoonlightSession( + session = session, + hostLabel = state.selectedHost?.label.orEmpty(), + onPickApp = viewModel::selectMoonlightApp, + ) { action -> + if (action == MoonlightAction.SEE_BINDINGS) nav.toConnections() else viewModel.onMoonlightAction(action) + } + } + private fun bindInputSection( state: ConfigUiState, snapshot: BindingSnapshot, @@ -160,7 +185,7 @@ class ConfigureBindingsActivity : BaseGamepadHostActivity() { snapshot: BindingSnapshot, ) { val d = binding.sectionDestination - d.ivDestIcon.setImageResource(if (state.isBluetoothHost) R.drawable.ic_bluetooth else R.drawable.ic_satellite) + d.ivDestIcon.setImageResource(destinationGlyph(state.selectedHost?.kind)) d.tvDestLabel.text = getString(R.string.binding_label_destination) val noHosts = state.noHosts @@ -171,7 +196,8 @@ class ConfigureBindingsActivity : BaseGamepadHostActivity() { d.hostDropdown.setTextColor(getColor(if (host != null) R.color.colorOnSurface else R.color.colorMuted)) d.hostDropdown.setOnClickListener { showHostMenu() } } - d.legendSatellite.visibility = if (state.hostChosen && !state.isBluetoothHost) View.VISIBLE else View.GONE + val plainSatellite = state.hostChosen && !state.isBluetoothHost && !state.isMoonlightHost + d.legendSatellite.visibility = if (plainSatellite) View.VISIBLE else View.GONE d.legendBt.visibility = if (state.hostChosen && state.isBluetoothHost) View.VISIBLE else View.GONE d.noHostsGroup.visibility = if (noHosts) View.VISIBLE else View.GONE if (noHosts) { @@ -396,15 +422,13 @@ class ConfigureBindingsActivity : BaseGamepadHostActivity() { .setNegativeButton(android.R.string.cancel, null) .create() state.hosts.forEach { host -> - val caps = viewModel.capabilityForCandidate(snapshot.slotId, type, host.kind, host.id) - val bt = host.kind == ConnectionKind.BLUETOOTH + val candidate = if (host.kind == ConnectionKind.MOONLIGHT) viewModel.moonlightResolvedType(type) else type + val caps = viewModel.capabilityForCandidate(snapshot.slotId, candidate, host.kind, host.id) val card = SetupReviewCardBinding.inflate(layoutInflater, container, false) - card.reviewIcon.setImageResource(if (bt) R.drawable.ic_bluetooth else R.drawable.ic_satellite) + card.reviewIcon.setImageResource(destinationGlyph(host.kind)) card.reviewKind.setText(R.string.binding_label_destination) card.reviewName.text = host.label - card.reviewSublabel.setText( - if (bt) R.string.setup_cfg_dest_bluetooth else R.string.setup_cfg_dest_satellite, - ) + card.reviewSublabel.text = destinationSublabel(host) bindReviewFlows(card.reviewSendsRow, card.reviewSendsChips, destinationSends(caps)) bindReviewFlows(card.reviewGetsRow, card.reviewGetsChips, destinationGets(caps)) card.reviewCard.isClickable = true @@ -417,6 +441,22 @@ class ConfigureBindingsActivity : BaseGamepadHostActivity() { dialog.show() } + // One silhouette per destination kind, everywhere the destination is drawn. + @DrawableRes + private fun destinationGlyph(kind: ConnectionKind?): Int = + when (kind) { + ConnectionKind.BLUETOOTH -> R.drawable.ic_bluetooth + ConnectionKind.MOONLIGHT -> R.drawable.ic_pc_monitor + else -> R.drawable.ic_satellite + } + + private fun destinationSublabel(host: BindingHost): String = + when (host.kind) { + ConnectionKind.BLUETOOTH -> getString(R.string.setup_cfg_dest_bluetooth) + ConnectionKind.MOONLIGHT -> getString(R.string.ml_dest_sublabel, viewModel.moonlightAddress(host.id)) + ConnectionKind.SATELLITE -> getString(R.string.setup_cfg_dest_satellite) + } + // A destination gets the inputs the path can carry; what is shown is gated by what // is actually deliverable end to end for the current type. private fun destinationGets(caps: SlotCapabilities): List = @@ -450,12 +490,25 @@ class ConfigureBindingsActivity : BaseGamepadHostActivity() { .setView(list.root) .setNegativeButton(android.R.string.cancel, null) .create() + val moonlight = host.kind == ConnectionKind.MOONLIGHT state.typeOptions.forEach { option -> val card = SetupTypeCardBinding.inflate(layoutInflater, container, false) + val candidate = if (moonlight) viewModel.moonlightResolvedType(option.id) else option.id card.typeTitle.text = option.label card.typeChevron.visibility = View.GONE + card.typeCard.isChecked = option.id == state.draft?.type + // Auto is resolved here, on the client: the card shows the rows of the type it + // will actually send, and says which one that is rather than implying a fifth type. + val isAuto = moonlight && option.id == MoonlightEmulatedType.AUTO + card.typeBadge.visibility = if (isAuto) View.VISIBLE else View.GONE + if (isAuto) card.typeBadge.setText(R.string.ml_type_auto_badge) + card.typeCaption.visibility = if (isAuto) View.VISIBLE else View.GONE + if (isAuto) { + card.typeCaption.text = + getString(R.string.ml_type_auto_resolved, getString(moonlightTypeLabelRes(candidate))) + } card.capabilityContainer.bindCapabilityRows( - capabilityRows(viewModel.capabilityForCandidate(snapshot.slotId, option.id, host.kind, host.id)), + capabilityRows(viewModel.capabilityForCandidate(snapshot.slotId, candidate, host.kind, host.id)), ) card.typeCard.setOnClickListener { viewModel.setType(option.id) 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 a4ce13d4..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 @@ -19,12 +20,18 @@ import com.tinkernorth.dish.core.jni.PhysicalInputNative import com.tinkernorth.dish.core.model.CatalogTypeDto 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.hotpath.input.PhysicalGamepadRegistry import com.tinkernorth.dish.hotpath.input.Transport import com.tinkernorth.dish.repository.SatelliteCapabilitiesRepository import com.tinkernorth.dish.repository.SatelliteCatalogRepository import com.tinkernorth.dish.repository.TouchpadModeValue import com.tinkernorth.dish.source.connection.SatelliteConnectionManager +import com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionEvent +import com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionManager +import com.tinkernorth.dish.source.connection.moonlight.MoonlightProbe +import com.tinkernorth.dish.source.connection.moonlight.MoonlightSessionState +import com.tinkernorth.dish.source.connection.moonlight.MoonlightTrustState import com.tinkernorth.dish.source.store.MotionEnabledStore import com.tinkernorth.dish.source.store.RumbleEnabledStore import com.tinkernorth.dish.source.store.TouchpadModeStore @@ -32,6 +39,7 @@ import com.tinkernorth.dish.source.usb.PathChoice import com.tinkernorth.dish.source.usb.UsbGamepadManager import com.tinkernorth.dish.source.usb.UsbPhase import com.tinkernorth.dish.ui.common.bundledControllerTypeLabelRes +import com.tinkernorth.dish.ui.common.moonlightTypeLabelRes import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow @@ -133,6 +141,8 @@ data class ConfigUiState( // Set only when a satellite catalog fetch failed with nothing cached, so Loading (fetch in // flight) and Error (fetch failed) are distinguishable — neither is derivable from the draft alone. val typeFetchFailed: Boolean = false, + // What the chosen Moonlight host last told us. Null for every other kind of destination. + val moonlight: MoonlightSessionInput? = null, ) { val selectedHost: BindingHost? get() = hosts.firstOrNull { it.id == draft?.hostId } val noHosts: Boolean get() = hosts.isEmpty() @@ -140,6 +150,8 @@ data class ConfigUiState( // A Bluetooth host is profile-driven (always Ready); a resolved type (remembered/manual/catalog) // is Ready; a failed fetch with nothing cached is Error; otherwise the catalog is still loading. + // A Moonlight host has no catalog to fetch: its four types are known here, so it is Ready as soon + // as the draft carries one, which is immediately (Auto is the seeded default). val typeLoad: TypeLoad get() = when { @@ -149,13 +161,26 @@ data class ConfigUiState( else -> TypeLoad.Loading } - // Apply must never send an unresolved type: a satellite host needs its type resolved first. - val canApply: Boolean get() = hostChosen && (isBluetoothHost || draft?.type != null) + // A binding is a durable intent and pairing is trust verified lazily, so no Moonlight host + // state blocks Apply: the session is attempted when the controller is used, not when the + // binding is saved. The one exception is a host already carrying its four controllers, which + // is a hard protocol limit and says so. + val canApply: Boolean get() = hostChosen && (isBluetoothHost || draft?.type != null) && !moonlightBlocked + // Rendered by the Moonlight session section; every state it can be in is in MoonlightSessionUi. + val moonlightSession: MoonlightSessionUi? + get() = if (isMoonlightHost) moonlightSessionUi(moonlight ?: MoonlightSessionInput()) else null + + private val moonlightBlocked: Boolean get() = moonlightSession?.blocksApply == true + + // A Moonlight host is never "lost": there is no live link to lose, only remembered trust + // that the session section reports honestly. Blocking the screen on it would also block + // the very actions that recover it. val blocker: BindingBlocker? get() { if (!loaded) return null if (!controllerPresent) return BindingBlocker.InputLost + if (isMoonlightHost) return null val hostId = draft?.hostId ?: return null val summary = connections.firstOrNull { it.id == hostId } return when { @@ -185,6 +210,8 @@ data class ConfigUiState( get() = capabilities.isAvailable(Feature.MOUSE) val isBluetoothHost: Boolean get() = selectedHost?.kind == ConnectionKind.BLUETOOTH + + val isMoonlightHost: Boolean get() = selectedHost?.kind == ConnectionKind.MOONLIGHT } data class ApplyStep( @@ -221,6 +248,7 @@ class ConfigureBindingsViewModel private val capabilityComposer: CapabilityComposer, private val touchpadModeStore: TouchpadModeStore, private val satellite: SatelliteConnectionManager, + private val moonlight: MoonlightConnectionManager, private val usbGamepadManager: UsbGamepadManager, private val catalogRepo: SatelliteCatalogRepository, private val capabilitiesRepo: SatelliteCapabilitiesRepository, @@ -234,6 +262,10 @@ class ConfigureBindingsViewModel private var loadedSlotId: String? = null + private var moonlightPairing: MoonlightPairingUi? = null + private var moonlightFailure: MoonlightFailure? = null + private var pairingJob: kotlinx.coroutines.Job? = null + fun load(slotId: String) { if (loadedSlotId == slotId) return loadedSlotId = slotId @@ -268,10 +300,11 @@ class ConfigureBindingsViewModel gamepadRegistry.devices .onEach { _ui.update { state -> state.copy(controllerPresent = controllerPresent(state.snapshot)).withCapabilities() } } .launchIn(viewModelScope) + observeMoonlightEvents() } fun setHost(hostId: String) { - _ui.update { it.copy(draft = it.draft?.copy(hostId = hostId)).withCapabilities() } + _ui.update { it.copy(draft = it.draft?.copy(hostId = hostId), moonlight = null).withCapabilities() } refreshTypeOptions(hostId) } @@ -283,6 +316,185 @@ class ConfigureBindingsViewModel refreshTypeOptions(hostId) } + /** + * Auto is resolved on the client, before the wire: an input with motion asks the host + * for a PlayStation pad, which is the only type its emulator gives a gyro to. The Auto + * card renders the resolved type's rows for exactly this reason. + */ + fun moonlightResolvedType(type: Int): Int { + val picked = MoonlightEmulatedType.fromStored(type) + if (picked != MoonlightEmulatedType.AUTO) return picked + val slotId = loadedSlotId ?: return MoonlightEmulatedType.XBOX + val caps = + capabilityComposer.capabilityForCandidate( + slotId = slotId, + candidateType = MoonlightEmulatedType.XBOX, + candidateHostKind = ConnectionKind.MOONLIGHT, + candidateHostId = _ui.value.draft?.hostId, + ) + return MoonlightEmulatedType.resolve(picked, caps.inputOk(Feature.MOTION)) + } + + /** Re-verify the chosen Moonlight host: on entering the screen, and before a session. */ + fun refreshMoonlight() { + val hostId = _ui.value.draft?.hostId ?: return + if (!_ui.value.isMoonlightHost) 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) + _ui.update { state -> state.copy(moonlight = moonlightInputFrom(probe, hostId)) } + } + } + + private fun moonlightInputFrom( + probe: MoonlightProbe, + hostId: String, + ): MoonlightSessionInput { + val conn = moonlight.get(hostId) + val slotId = loadedSlotId + val pad = slotId?.let { conn?.padFor(it) } + val appName = conn?.sessionAppName?.takeIf { it.isNotBlank() } ?: moonlight.rememberedAppName(hostId) + val phase = + when { + conn?.state?.value == MoonlightSessionState.Live && pad != null -> + MoonlightPhase.Live(pad.number + 1, appName) + conn?.state?.value == MoonlightSessionState.Live -> + MoonlightPhase.Joining(conn.padCount + 1, appName) + conn?.state?.value == MoonlightSessionState.Dropped -> MoonlightPhase.Dropped + conn?.state?.value == MoonlightSessionState.Ended -> MoonlightPhase.Ended + probe.ownSession -> MoonlightPhase.Joining((conn?.padCount ?: 0) + 1, appName) + else -> MoonlightPhase.Idle + } + val full = (conn?.padCount ?: 0) >= MOONLIGHT_MAX_PADS && pad == null + return MoonlightSessionInput( + trust = probe.trust, + pairing = moonlightPairing, + apps = appsUiFrom(probe), + phase = phase, + failure = if (full) MoonlightFailure.HostFull else moonlightFailure, + selectedAppId = moonlight.rememberedAppId(hostId).takeIf { it.isNotEmpty() }, + ) + } + + private fun appsUiFrom(probe: MoonlightProbe): MoonlightApps = + when { + probe.trust != MoonlightTrustState.PAIRED -> MoonlightApps.Loading + probe.appsFailed -> MoonlightApps.Failed + !probe.appsFetched -> MoonlightApps.Loading + probe.apps.isEmpty() -> MoonlightApps.Empty + else -> MoonlightApps.Ready(probe.apps.map { MoonlightAppUi(it.id, it.title.ifEmpty { it.id }) }) + } + + fun moonlightAddress(hostId: String): String = moonlight.rememberedHost(hostId)?.address.orEmpty() + + /** Persist the app this session will start; the next binding on the host inherits it. */ + fun selectMoonlightApp(app: MoonlightAppUi) { + val hostId = _ui.value.draft?.hostId ?: return + moonlight.rememberApp(hostId, app.id, app.title) + _ui.update { state -> + state.copy(moonlight = state.moonlight?.copy(selectedAppId = app.id)) + } + } + + fun onMoonlightAction(action: MoonlightAction) { + 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() + } + MoonlightAction.QUIT_APP -> { + moonlight.quitHostApp(host) + moonlightFailure = null + refreshMoonlight() + } + MoonlightAction.RETRY, MoonlightAction.RECONNECT, MoonlightAction.START_SESSION -> { + moonlightFailure = null + moonlight.disconnect(hostId) + moonlight.retrySessions() + refreshMoonlight() + } + MoonlightAction.SEE_BINDINGS -> Unit + } + } + + // 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) { + cancelMoonlightPairing() + pairingJob = + viewModelScope.launch { + moonlight.pairHost(host) + refreshMoonlight() + } + } + + private fun cancelMoonlightPairing() { + pairingJob?.cancel() + pairingJob = null + } + + private fun observeMoonlightEvents() { + moonlight.events + .onEach { event -> onMoonlightEvent(event) } + .launchIn(viewModelScope) + } + + private fun onMoonlightEvent(event: MoonlightConnectionEvent) { + when (event) { + is MoonlightConnectionEvent.PairingPinReady -> moonlightPairing = MoonlightPairingUi.Pin(event.pin) + 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 + is MoonlightConnectionEvent.RejoinRefused -> moonlightFailure = MoonlightFailure.ResumeFailed + is MoonlightConnectionEvent.LaunchRefused -> moonlightFailure = MoonlightFailure.Refused(event.message) + is MoonlightConnectionEvent.SetupFailed -> moonlightFailure = MoonlightFailure.SetupFailed + is MoonlightConnectionEvent.HostFull -> moonlightFailure = MoonlightFailure.HostFull + is MoonlightConnectionEvent.HostReplaced, is MoonlightConnectionEvent.EndedByHost -> Unit + is MoonlightConnectionEvent.Error, is MoonlightConnectionEvent.Notice -> Unit + } + _ui.update { state -> + if (!state.isMoonlightHost) { + state + } else { + state.copy( + moonlight = + state.moonlight?.copy(pairing = moonlightPairing, failure = moonlightFailure) + ?: MoonlightSessionInput(pairing = moonlightPairing, failure = moonlightFailure), + ) + } + } + } + fun setDirect(on: Boolean) = _ui.update { it.copy(draft = it.draft?.copy(directOn = on)).withCapabilities() } fun setMotion(on: Boolean) = _ui.update { it.copy(draft = it.draft?.copy(motionOn = on)).withCapabilities() } @@ -309,12 +521,13 @@ class ConfigureBindingsViewModel val slotId = loadedSlotId ?: return copy(capabilities = SlotCapabilities.NONE) val d = draft ?: return copy(capabilities = SlotCapabilities.NONE) // An unresolved type has no known capabilities yet: the caps rows stay hidden behind the loader. + val kind = selectedHost?.kind ?: ConnectionKind.SATELLITE val caps = d.type?.let { capabilityComposer.capabilityForCandidate( slotId = slotId, - candidateType = it, - candidateHostKind = selectedHost?.kind ?: ConnectionKind.SATELLITE, + candidateType = if (kind == ConnectionKind.MOONLIGHT) moonlightResolvedType(it) else it, + candidateHostKind = kind, candidateHostId = d.hostId, ) } ?: SlotCapabilities.NONE @@ -370,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 @@ -506,6 +729,10 @@ class ConfigureBindingsViewModel host: BindingHost, slotId: String, ): Boolean { + // A Moonlight binding is a durable intent, not a handshake: the session is started + // by the binding itself and is allowed to take its time, so applying never waits + // on a link that does not exist yet. + if (host.kind == ConnectionKind.MOONLIGHT) return true val hostUp = withTimeoutOrNull(CONNECT_TIMEOUT_MS) { hub.connections.first { conns -> @@ -533,7 +760,30 @@ class ConfigureBindingsViewModel TypeOption(CONTROLLER_TYPE_SWITCHPRO, context.getString(R.string.picker_type_switchpro)), ) + // The four types a Moonlight host can be asked to plug in. Hard-coded because no host + // reports them: the type byte travels client to host in CONTROLLER_ARRIVAL and nothing + // comes back the other way. + private fun moonlightTypeOptions(): List = + MoonlightEmulatedType.ORDER.map { TypeOption(it, context.getString(moonlightTypeLabelRes(it))) } + + // A Moonlight host owns its own four types and has no catalog to fetch, so it must be + // answered before the satellite lookup: satellite.get() is null for a Moonlight id, which + // used to leave the type unresolved forever and Apply disabled with it. private fun refreshTypeOptions(hostId: String) { + if (hub.summary(hostId)?.kind == ConnectionKind.MOONLIGHT) { + val stored = loadedSlotId?.let { hub.satTypes.value[hostId to it] } + val seeded = MoonlightEmulatedType.fromStored(stored ?: moonlight.rememberedEmulatedType(hostId)) + _ui.update { state -> + state + .copy( + typeOptions = moonlightTypeOptions(), + typeFetchFailed = false, + draft = state.draft?.copy(type = state.draft.type ?: seeded), + ).withCapabilities() + } + refreshMoonlight() + return + } val conn = satellite.get(hostId) if (conn == null) { _ui.update { it.copy(typeOptions = bundledTypeOptions()) } @@ -659,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/main/ControllerAdapter.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/ControllerAdapter.kt index 83cd2aea..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 @@ -26,6 +26,7 @@ import com.tinkernorth.dish.composer.ConnectionSummary import com.tinkernorth.dish.composer.LinkState 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.databinding.BindingDecisionRowBinding import com.tinkernorth.dish.databinding.BindingPillBinding import com.tinkernorth.dish.databinding.BindingValueMonoBinding @@ -36,6 +37,7 @@ import com.tinkernorth.dish.hotpath.input.Transport import com.tinkernorth.dish.repository.TouchpadModeValue import com.tinkernorth.dish.source.inputrate.SlotInputRates import com.tinkernorth.dish.ui.common.bundledControllerTypeLabelRes +import com.tinkernorth.dish.ui.common.moonlightTypeLabelRes interface SlotActionListener { fun onConfigure(slotId: String) @@ -64,10 +66,35 @@ internal fun LinkState.isAvailableForPicker(): Boolean = -> false } +// 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, +// and the session is started by the binding itself. Its state is reported in the binding +// screen where the actions that recover it live, so the dashboard stays quiet. +internal fun slotEdgeState(slot: ControllerSlot): EdgeState { + val bound = slot.boundStatus + if (bound == null || slot.boundConnectionId == null) return EdgeState.NONE + if (slot.isDisconnecting) return EdgeState.INPUT_LOST + if (bound.kind == ConnectionKind.MOONLIGHT) return EdgeState.NONE + return when (bound.live) { + LinkState.Unstable -> EdgeState.UNSTEADY + LinkState.Connected -> EdgeState.NONE + // Connecting (incl. a global reconnect in flight) keeps showing "lost" so the badge doesn't flicker off. + else -> EdgeState.HOST_LOST + } +} + +// 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?, -): List = all.filter { it.live.isAvailableForPicker() || it.id == boundConnectionId } +): List = + all.filter { + it.live.isAvailableForPicker() || it.kind == ConnectionKind.MOONLIGHT || it.id == boundConnectionId + } // Unstable is degraded but still routing, so it counts as live alongside Connected. internal fun LinkState.isLiveLink(): Boolean = this == LinkState.Connected || this == LinkState.Unstable @@ -190,7 +217,7 @@ class ControllerAdapter( b.tvControllerName.text = slot.name bindBattery(slot.battery) - val edge = edgeOf(slot) + val edge = slotEdgeState(slot) if (edge != EdgeState.UNSTEADY) dismissedUnsteady.remove(slot.id) val showEdge = edge != EdgeState.NONE && !(edge == EdgeState.UNSTEADY && slot.id in dismissedUnsteady) @@ -290,6 +317,12 @@ class ControllerAdapter( ctx.getString(bundledControllerTypeLabelRes(type)) } ConnectionKind.BLUETOOTH -> bound.btProfile + // A Moonlight host has its own type table; its ids overlap the catalog's, so + // the label comes from the Moonlight mapper and never the bundled one. + ConnectionKind.MOONLIGHT -> { + val stored = bound.satelliteControllerTypes[row.slot.id] + ctx.getString(moonlightTypeLabelRes(MoonlightEmulatedType.fromStored(stored ?: MoonlightEmulatedType.AUTO))) + } } private fun bindFunctionPills(specs: List) { @@ -601,18 +634,6 @@ class ControllerAdapter( } } - private fun edgeOf(slot: ControllerSlot): EdgeState { - val bound = slot.boundStatus - if (bound == null || slot.boundConnectionId == null) return EdgeState.NONE - if (slot.isDisconnecting) return EdgeState.INPUT_LOST - return when (bound.live) { - LinkState.Unstable -> EdgeState.UNSTEADY - LinkState.Connected -> EdgeState.NONE - // Connecting (incl. a global reconnect in flight) keeps showing "lost" so the badge doesn't flicker off. - else -> EdgeState.HOST_LOST - } - } - private fun bindEdge( edge: EdgeState, row: Row, @@ -704,8 +725,6 @@ class ControllerAdapter( private enum class ActionKind { GAMEPAD, TOUCHPAD, SWITCH_DIRECT, SETUP_WIRED, CONFIGURE, FIND_HOSTS } - private enum class EdgeState { NONE, HOST_LOST, INPUT_LOST, UNSTEADY } - override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, 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..b999b65b 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,28 @@ 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) + val conn = moonlight.get(connectionId) ?: return + val pad = conn.padFor(VIRTUAL_SLOT_ID) ?: return + conn.sendControllerState( + controllerNumber = pad.number, + 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/java/com/tinkernorth/dish/ui/main/MoonlightSectionView.kt b/app/src/main/java/com/tinkernorth/dish/ui/main/MoonlightSectionView.kt new file mode 100644 index 00000000..e2238499 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/ui/main/MoonlightSectionView.kt @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +package com.tinkernorth.dish.ui.main + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import androidx.annotation.StringRes +import com.google.android.material.button.MaterialButton +import com.tinkernorth.dish.R +import com.tinkernorth.dish.databinding.BindingSectionMoonlightBinding +import com.tinkernorth.dish.databinding.SetupChoiceRowBinding + +// Renders the Moonlight session section so the binding screen and the setup tutorial +// draw the same states from the same code, the way bindCapabilityRows is shared. +// Exactly one state renders at a time; nothing here is ever modal, because a flapping +// session would raise a dialog the user cannot outrun. +fun BindingSectionMoonlightBinding.bindMoonlightSession( + session: MoonlightSessionUi, + hostLabel: String, + onPickApp: (MoonlightAppUi) -> Unit, + onAction: (MoonlightAction) -> Unit, +) { + val ctx = root.context + sessionSpinner.visibility = if (session.showsSpinner) View.VISIBLE else View.GONE + + val titleRes = session.titleRes() + tvSessionTitle.visibility = if (titleRes == 0) View.GONE else View.VISIBLE + if (titleRes != 0) { + tvSessionTitle.text = ctx.formatted(titleRes, session.titleArgs(hostLabel)) + tvSessionTitle.setTextColor(ctx.getColor(session.tone().colorRes())) + } + tvSessionBody.text = ctx.formatted(session.bodyRes(), session.bodyArgs(hostLabel)) + + val noteRes = session.noteRes() + tvSessionNote.visibility = if (noteRes == 0) View.GONE else View.VISIBLE + if (noteRes != 0) tvSessionNote.text = ctx.getString(noteRes, hostLabel) + + bindApps(session, onPickApp) + bindActions(session, hostLabel, onAction) +} + +// A pick-one list, one row per app, and only where the choice actually exists: a binding +// that joins a session already running gets no list rather than a disabled one implying +// a choice it does not have. +private fun BindingSectionMoonlightBinding.bindApps( + session: MoonlightSessionUi, + onPickApp: (MoonlightAppUi) -> Unit, +) { + val inflater = LayoutInflater.from(root.context) + sessionAppList.removeAllViews() + val newSession = session as? MoonlightSessionUi.NewSession + sessionAppList.visibility = if (newSession == null) View.GONE else View.VISIBLE + newSession?.apps?.forEach { app -> + val row = SetupChoiceRowBinding.inflate(inflater, sessionAppList, false) + row.choiceIcon.setImageResource(R.drawable.ic_pc_monitor) + row.choiceTitle.text = app.title + row.choiceBody.visibility = View.GONE + row.choiceBadge.visibility = View.GONE + row.choiceChevron.visibility = View.GONE + row.choiceCard.isCheckable = true + row.choiceCard.isChecked = app.id == newSession.selectedAppId + row.choiceCard.setOnClickListener { onPickApp(app) } + sessionAppList.addView(row.root) + } +} + +// 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, + onAction: (MoonlightAction) -> Unit, +) { + val ctx = root.context + val inflater = LayoutInflater.from(ctx) + sessionActions.removeAllViews() + val actions = session.actions() + sessionActions.visibility = if (actions.isEmpty()) View.GONE else View.VISIBLE + actions.forEachIndexed { index, action -> + val layout = if (index == 0) R.layout.binding_action_button else R.layout.binding_action_button_outlined + val button = inflater.inflate(layout, sessionActions, false) as MaterialButton + button.text = ctx.formatted(action.labelRes(), action.labelArgs(hostLabel)) + button.setOnClickListener { onAction(action) } + sessionActions.addView(button) + } +} 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 new file mode 100644 index 00000000..cb34f294 --- /dev/null +++ b/app/src/main/java/com/tinkernorth/dish/ui/main/MoonlightSessionUi.kt @@ -0,0 +1,429 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +@file:Suppress("TooManyFunctions") + +package com.tinkernorth.dish.ui.main + +import androidx.annotation.ColorRes +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, + NEW_CODE, + CANCEL, + TRY_AGAIN, + RETRY, + QUIT_APP, + RECONNECT, + START_SESSION, + SEE_BINDINGS, +} + +data class MoonlightAppUi( + val id: String, + 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, + ) : MoonlightPairingUi + + data object Failed : MoonlightPairingUi +} + +sealed interface MoonlightApps { + data object Loading : MoonlightApps + + data class Ready( + val apps: List, + ) : MoonlightApps + + data object Empty : 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 + + data class Joining( + val controllerNumber: Int, + val appName: String?, + ) : MoonlightPhase + + data class Live( + val controllerNumber: Int, + val appName: String?, + ) : MoonlightPhase + + data object Dropped : 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 + + data object BusyOther : MoonlightFailure + + data object ResumeFailed : MoonlightFailure + + data class Refused( + val hostMessage: String, + ) : 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, + val apps: MoonlightApps = MoonlightApps.Loading, + val phase: MoonlightPhase = MoonlightPhase.Idle, + val failure: MoonlightFailure? = null, + 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 + + data object NotPaired : MoonlightSessionUi + + data class PairingPin( + val pin: String, + ) : MoonlightSessionUi + + data object PairFailed : MoonlightSessionUi + + data object Unreachable : MoonlightSessionUi + + data object Remembered : MoonlightSessionUi + + data object TrustLost : MoonlightSessionUi + + data object HostReplaced : MoonlightSessionUi + + data object AppsLoading : MoonlightSessionUi + + data class NewSession( + val apps: List, + val selectedAppId: String?, + ) : MoonlightSessionUi + + data object AppsEmpty : MoonlightSessionUi + + data object AppsFailed : MoonlightSessionUi + + data class Joining( + val controllerNumber: Int, + val appName: String?, + ) : MoonlightSessionUi + + data object HostFull : MoonlightSessionUi + + data object BusyOther : MoonlightSessionUi + + data object ResumeFailed : MoonlightSessionUi + + data class Refused( + val hostMessage: String, + ) : MoonlightSessionUi + + data object SetupFailed : MoonlightSessionUi + + data class Live( + val controllerNumber: Int, + val appName: String?, + ) : MoonlightSessionUi + + data object Dropped : 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. 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) + ?: appsUi(input) + ?: joiningUi(input.phase) + ?: failureUi(input.failure) + ?: liveUi(input.phase) + ?: MoonlightSessionUi.Checking + +private fun pairingUi(pairing: MoonlightPairingUi?): MoonlightSessionUi? = + when (pairing) { + is MoonlightPairingUi.Pin -> MoonlightSessionUi.PairingPin(pairing.pin) + MoonlightPairingUi.Failed -> MoonlightSessionUi.PairFailed + 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 + MoonlightTrustState.NOT_PAIRED -> MoonlightSessionUi.NotPaired + MoonlightTrustState.UNREACHABLE -> MoonlightSessionUi.Unreachable + MoonlightTrustState.REMEMBERED -> MoonlightSessionUi.Remembered + MoonlightTrustState.TRUST_LOST -> MoonlightSessionUi.TrustLost + MoonlightTrustState.REPLACED -> MoonlightSessionUi.HostReplaced + 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) { + MoonlightApps.Loading -> MoonlightSessionUi.AppsLoading + is MoonlightApps.Ready -> + if (apps.apps.isEmpty()) { + MoonlightSessionUi.AppsEmpty + } else { + MoonlightSessionUi.NewSession(apps.apps, input.selectedAppId) + } + MoonlightApps.Empty -> MoonlightSessionUi.AppsEmpty + MoonlightApps.Failed -> MoonlightSessionUi.AppsFailed + } +} + +private fun joiningUi(phase: MoonlightPhase): MoonlightSessionUi? = + (phase as? MoonlightPhase.Joining)?.let { MoonlightSessionUi.Joining(it.controllerNumber, it.appName) } + +private fun failureUi(failure: MoonlightFailure?): MoonlightSessionUi? = + when (failure) { + MoonlightFailure.HostFull -> MoonlightSessionUi.HostFull + MoonlightFailure.BusyOther -> MoonlightSessionUi.BusyOther + MoonlightFailure.ResumeFailed -> MoonlightSessionUi.ResumeFailed + is MoonlightFailure.Refused -> MoonlightSessionUi.Refused(failure.hostMessage) + MoonlightFailure.SetupFailed -> MoonlightSessionUi.SetupFailed + null -> null + } + +private fun liveUi(phase: MoonlightPhase): MoonlightSessionUi? = + when (phase) { + is MoonlightPhase.Live -> MoonlightSessionUi.Live(phase.controllerNumber, phase.appName) + MoonlightPhase.Dropped -> MoonlightSessionUi.Dropped + MoonlightPhase.Ended -> MoonlightSessionUi.EndedByHost + else -> null + } + +// One exhaustive branch per state is the render contract itself; splitting it would +// hide which state carries which string rather than simplify anything. +@StringRes +@Suppress("CyclomaticComplexMethod") +fun MoonlightSessionUi.titleRes(): Int = + when (this) { + MoonlightSessionUi.Checking, is MoonlightSessionUi.PairingPin, MoonlightSessionUi.AppsLoading -> 0 + MoonlightSessionUi.NotPaired -> R.string.ml_state_unpaired_title + MoonlightSessionUi.PairFailed -> R.string.ml_pair_failed_title + MoonlightSessionUi.Unreachable, MoonlightSessionUi.Remembered -> R.string.ml_state_unreachable_title + MoonlightSessionUi.TrustLost -> R.string.ml_state_trust_lost_title + MoonlightSessionUi.HostReplaced -> R.string.ml_state_replaced_title + is MoonlightSessionUi.NewSession -> R.string.ml_session_new_title + MoonlightSessionUi.AppsEmpty -> R.string.ml_apps_empty_title + MoonlightSessionUi.AppsFailed -> R.string.ml_apps_failed_title + is MoonlightSessionUi.Joining -> + if (appName.isNullOrBlank()) R.string.ml_session_join_title_unnamed else R.string.ml_session_join_title + MoonlightSessionUi.HostFull -> R.string.ml_full_title + MoonlightSessionUi.BusyOther -> R.string.ml_busy_other_title + MoonlightSessionUi.ResumeFailed -> R.string.ml_resume_failed_title + is MoonlightSessionUi.Refused -> R.string.ml_refused_title + MoonlightSessionUi.SetupFailed -> R.string.ml_setup_failed_title + is MoonlightSessionUi.Live -> R.string.ml_session_live_title + MoonlightSessionUi.Dropped -> R.string.ml_dropped_title + MoonlightSessionUi.EndedByHost -> R.string.ml_ended_title + } + +@StringRes +@Suppress("CyclomaticComplexMethod") +fun MoonlightSessionUi.bodyRes(): Int = + when (this) { + MoonlightSessionUi.Checking -> R.string.ml_state_checking + MoonlightSessionUi.NotPaired -> R.string.ml_state_unpaired_body + is MoonlightSessionUi.PairingPin -> R.string.ml_pair_pin_body + MoonlightSessionUi.PairFailed -> R.string.ml_pair_failed_body + MoonlightSessionUi.Unreachable -> R.string.ml_state_unreachable_body + MoonlightSessionUi.Remembered -> R.string.ml_state_remembered_body + MoonlightSessionUi.TrustLost -> R.string.ml_state_trust_lost_body + MoonlightSessionUi.HostReplaced -> R.string.ml_state_replaced_body + MoonlightSessionUi.AppsLoading -> R.string.ml_apps_loading + is MoonlightSessionUi.NewSession -> R.string.ml_session_new_body + MoonlightSessionUi.AppsEmpty -> R.string.ml_apps_empty_body + MoonlightSessionUi.AppsFailed -> R.string.ml_apps_failed_body + is MoonlightSessionUi.Joining -> R.string.ml_session_join_body + MoonlightSessionUi.HostFull -> R.string.ml_full_body + MoonlightSessionUi.BusyOther -> R.string.ml_busy_other_body + MoonlightSessionUi.ResumeFailed -> R.string.ml_resume_failed_body + is MoonlightSessionUi.Refused -> R.string.ml_refused_body + MoonlightSessionUi.SetupFailed -> R.string.ml_setup_failed_body + is MoonlightSessionUi.Live -> R.string.ml_session_live_body + MoonlightSessionUi.Dropped -> R.string.ml_dropped_body + 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 { + this is MoonlightSessionUi.PairingPin -> R.string.ml_pair_waiting + this is MoonlightSessionUi.NewSession && selectedAppId == null -> R.string.ml_session_default_note + else -> 0 + } + +fun MoonlightSessionUi.titleArgs(hostLabel: String): List = + when (this) { + is MoonlightSessionUi.Joining -> listOf(appName?.takeIf { it.isNotBlank() } ?: hostLabel) + is MoonlightSessionUi.Refused -> listOf(hostLabel, hostMessage) + else -> listOf(hostLabel) + } + +fun MoonlightSessionUi.bodyArgs(hostLabel: String): List = + when (this) { + is MoonlightSessionUi.PairingPin -> listOf(pin, hostLabel) + is MoonlightSessionUi.Joining -> listOf(hostLabel, controllerNumber) + is MoonlightSessionUi.Live -> listOf(appName?.takeIf { it.isNotBlank() } ?: hostLabel, controllerNumber) + 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() + is MoonlightSessionUi.NewSession, is MoonlightSessionUi.Joining -> emptyList() + MoonlightSessionUi.NotPaired -> listOf(MoonlightAction.PAIR) + is MoonlightSessionUi.PairingPin -> listOf(MoonlightAction.NEW_CODE, MoonlightAction.CANCEL) + MoonlightSessionUi.PairFailed -> listOf(MoonlightAction.TRY_AGAIN) + MoonlightSessionUi.Unreachable, MoonlightSessionUi.Remembered -> listOf(MoonlightAction.RETRY) + MoonlightSessionUi.TrustLost, MoonlightSessionUi.HostReplaced -> listOf(MoonlightAction.PAIR_AGAIN) + MoonlightSessionUi.AppsEmpty, MoonlightSessionUi.AppsFailed -> listOf(MoonlightAction.RETRY) + MoonlightSessionUi.HostFull -> listOf(MoonlightAction.SEE_BINDINGS) + MoonlightSessionUi.BusyOther, MoonlightSessionUi.ResumeFailed -> + listOf(MoonlightAction.QUIT_APP, MoonlightAction.RETRY) + is MoonlightSessionUi.Refused, MoonlightSessionUi.SetupFailed -> listOf(MoonlightAction.RETRY) + is MoonlightSessionUi.Live -> listOf(MoonlightAction.QUIT_APP) + MoonlightSessionUi.Dropped -> listOf(MoonlightAction.RECONNECT) + MoonlightSessionUi.EndedByHost -> listOf(MoonlightAction.START_SESSION) + } + +fun MoonlightSessionUi.tone(): MoonlightTone = + when (this) { + MoonlightSessionUi.Checking, is MoonlightSessionUi.PairingPin, MoonlightSessionUi.AppsLoading -> + MoonlightTone.PROGRESS + MoonlightSessionUi.NotPaired, is MoonlightSessionUi.NewSession, + MoonlightSessionUi.AppsEmpty, is MoonlightSessionUi.Joining, + -> MoonlightTone.NEUTRAL + MoonlightSessionUi.PairFailed, MoonlightSessionUi.AppsFailed, + is MoonlightSessionUi.Refused, MoonlightSessionUi.SetupFailed, + -> MoonlightTone.ERROR + is MoonlightSessionUi.Live -> MoonlightTone.SUCCESS + else -> MoonlightTone.WARN + } + +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 + +@StringRes +fun MoonlightAction.labelRes(): Int = + when (this) { + MoonlightAction.PAIR -> R.string.ml_action_pair + MoonlightAction.PAIR_AGAIN -> R.string.ml_action_pair_again + MoonlightAction.NEW_CODE -> R.string.ml_action_new_code + MoonlightAction.CANCEL -> R.string.ml_action_cancel + MoonlightAction.TRY_AGAIN -> R.string.ml_action_try_again + MoonlightAction.RETRY -> R.string.ml_action_retry + MoonlightAction.QUIT_APP -> R.string.ml_action_quit_app + MoonlightAction.RECONNECT -> R.string.ml_action_reconnect + MoonlightAction.START_SESSION -> R.string.ml_action_start_session + MoonlightAction.SEE_BINDINGS -> R.string.ml_action_see_bindings + } + +fun MoonlightAction.labelArgs(hostLabel: String): List = + when (this) { + MoonlightAction.QUIT_APP, MoonlightAction.SEE_BINDINGS -> listOf(hostLabel) + else -> emptyList() + } + +@ColorRes +fun MoonlightTone.colorRes(): Int = + when (this) { + MoonlightTone.NEUTRAL -> R.color.colorOnSurfaceVariant + MoonlightTone.PROGRESS -> R.color.colorPrimary + MoonlightTone.WARN -> R.color.colorWarning + MoonlightTone.ERROR -> R.color.colorError + 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. "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) { + MoonlightTrustState.PAIRED -> R.string.ml_trust_paired + MoonlightTrustState.REMEMBERED, MoonlightTrustState.CHECKING -> R.string.ml_trust_remembered + MoonlightTrustState.UNREACHABLE -> R.string.ml_trust_remembered + MoonlightTrustState.NOT_PAIRED, MoonlightTrustState.TRUST_LOST, MoonlightTrustState.REPLACED -> R.string.ml_trust_not_paired + } diff --git a/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupConfigureActivity.kt b/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupConfigureActivity.kt index cb8c6e4a..10c991db 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupConfigureActivity.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupConfigureActivity.kt @@ -19,6 +19,7 @@ import com.tinkernorth.dish.composer.CONTROLLER_TYPE_XBOX import com.tinkernorth.dish.composer.ConnectionKind import com.tinkernorth.dish.core.model.DishNotification import com.tinkernorth.dish.core.model.Feature +import com.tinkernorth.dish.core.net.moonlight.MoonlightEmulatedType import com.tinkernorth.dish.databinding.ActivitySetupConfigureBinding import com.tinkernorth.dish.databinding.SetupReviewCardBinding import com.tinkernorth.dish.databinding.SetupTypeCardBinding @@ -26,13 +27,16 @@ import com.tinkernorth.dish.repository.TouchpadModeValue import com.tinkernorth.dish.source.store.OnboardingPreferenceStore import com.tinkernorth.dish.ui.common.BaseGamepadHostActivity import com.tinkernorth.dish.ui.common.DishNavigator +import com.tinkernorth.dish.ui.common.moonlightTypeLabelRes import com.tinkernorth.dish.ui.common.setupDishToolbar import com.tinkernorth.dish.ui.main.ApplyState import com.tinkernorth.dish.ui.main.BindingLink import com.tinkernorth.dish.ui.main.BindingSnapshot import com.tinkernorth.dish.ui.main.ConfigUiState import com.tinkernorth.dish.ui.main.ConfigureBindingsViewModel +import com.tinkernorth.dish.ui.main.MoonlightAction import com.tinkernorth.dish.ui.main.VIRTUAL_SLOT_ID +import com.tinkernorth.dish.ui.main.bindMoonlightSession import com.tinkernorth.dish.ui.main.iconRes import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @@ -61,7 +65,9 @@ class SetupConfigureActivity : BaseGamepadHostActivity() { // step); the type cards resolve their candidate capabilities against this same id. private var resolvedSlotId = VIRTUAL_SLOT_ID - private enum class Step { TYPE, FEEL, REVIEW } + // SESSION only exists for a Moonlight destination: it is where the app that host will + // run is settled, which is a different question from how the pad feels. + private enum class Step { TYPE, SESSION, FEEL, REVIEW } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -95,6 +101,11 @@ class SetupConfigureActivity : BaseGamepadHostActivity() { binding.cardTypeDualsense.typeCard.setOnClickListener { pickType(CONTROLLER_TYPE_DUALSENSE) } binding.cardTypeSwitchpro.typeCard.setOnClickListener { pickType(CONTROLLER_TYPE_SWITCHPRO) } + binding.cardMlAuto.typeCard.setOnClickListener { pickType(MoonlightEmulatedType.AUTO) } + binding.cardMlXbox.typeCard.setOnClickListener { pickType(MoonlightEmulatedType.XBOX) } + binding.cardMlPlaystation.typeCard.setOnClickListener { pickType(MoonlightEmulatedType.PLAYSTATION) } + binding.cardMlNintendo.typeCard.setOnClickListener { pickType(MoonlightEmulatedType.NINTENDO) } + binding.segOff.setOnClickListener { viewModel.setTouchpad(TouchpadModeValue.OFF) } binding.segPad.setOnClickListener { viewModel.setTouchpad(TouchpadModeValue.DS4) } binding.segMouse.setOnClickListener { viewModel.setTouchpad(TouchpadModeValue.MOUSE) } @@ -119,10 +130,12 @@ class SetupConfigureActivity : BaseGamepadHostActivity() { private fun render(state: ConfigUiState) { val snapshot = state.snapshot ?: return binding.groupType.visibility = visibleIf(step == Step.TYPE) + binding.groupMoonlightSession.root.visibility = visibleIf(step == Step.SESSION) binding.groupFeel.visibility = visibleIf(step == Step.FEEL) binding.groupReview.visibility = visibleIf(step == Step.REVIEW) when (step) { Step.TYPE -> renderType(state) + Step.SESSION -> renderSession(state) Step.FEEL -> renderFeel(state) Step.REVIEW -> renderReview(state, snapshot) } @@ -133,10 +146,14 @@ class SetupConfigureActivity : BaseGamepadHostActivity() { // pick. A Bluetooth host has its type fixed upstream, so only the chosen one // shows and the cards stop being tappable. private fun renderType(state: ConfigUiState) { - binding.tvTitle.setText(R.string.setup_cfg_type_title) - binding.tvSubtitle.setText( - if (state.isBluetoothHost) R.string.setup_cfg_type_locked_subtitle else R.string.setup_cfg_type_subtitle, - ) + val moonlight = state.isMoonlightHost + binding.tvTitle.setText(if (moonlight) R.string.ml_type_title else R.string.setup_cfg_type_title) + binding.tvSubtitle.text = + when { + moonlight -> getString(R.string.ml_type_caption, state.selectedHost?.label.orEmpty()) + state.isBluetoothHost -> getString(R.string.setup_cfg_type_locked_subtitle) + else -> getString(R.string.setup_cfg_type_subtitle) + } binding.btnContinue.setText(R.string.setup_cfg_continue) // Tapping a type commits and advances; only the locked Bluetooth-host case, // where the cards aren't tappable, needs the Next button. @@ -149,10 +166,18 @@ class SetupConfigureActivity : BaseGamepadHostActivity() { bindTypeCard(binding.cardTypeDualsense, state, CONTROLLER_TYPE_DUALSENSE, locked) bindTypeCard(binding.cardTypeSwitchpro, state, CONTROLLER_TYPE_SWITCHPRO, locked) // A Bluetooth host locks to Xbox/PlayStation, so its other cards never show. - binding.cardTypeXbox.typeCard.visibility = visibleIf(!locked || selectedType == CONTROLLER_TYPE_XBOX) - binding.cardTypePlaystation.typeCard.visibility = visibleIf(!locked || selectedType == CONTROLLER_TYPE_PLAYSTATION) - binding.cardTypeDualsense.typeCard.visibility = visibleIf(!locked || selectedType == CONTROLLER_TYPE_DUALSENSE) - binding.cardTypeSwitchpro.typeCard.visibility = visibleIf(!locked || selectedType == CONTROLLER_TYPE_SWITCHPRO) + binding.cardTypeXbox.typeCard.visibility = visibleIf(!moonlight && (!locked || selectedType == CONTROLLER_TYPE_XBOX)) + binding.cardTypePlaystation.typeCard.visibility = + visibleIf(!moonlight && (!locked || selectedType == CONTROLLER_TYPE_PLAYSTATION)) + binding.cardTypeDualsense.typeCard.visibility = + visibleIf(!moonlight && (!locked || selectedType == CONTROLLER_TYPE_DUALSENSE)) + binding.cardTypeSwitchpro.typeCard.visibility = + visibleIf(!moonlight && (!locked || selectedType == CONTROLLER_TYPE_SWITCHPRO)) + + bindMoonlightTypeCard(binding.cardMlAuto, state, MoonlightEmulatedType.AUTO, moonlight) + bindMoonlightTypeCard(binding.cardMlXbox, state, MoonlightEmulatedType.XBOX, moonlight) + bindMoonlightTypeCard(binding.cardMlPlaystation, state, MoonlightEmulatedType.PLAYSTATION, moonlight) + bindMoonlightTypeCard(binding.cardMlNintendo, state, MoonlightEmulatedType.NINTENDO, moonlight) } private fun bindTypeCard( @@ -164,6 +189,7 @@ class SetupConfigureActivity : BaseGamepadHostActivity() { card.typeTitle.text = viewModel.typeLabel(candidateType) card.typeChevron.visibility = visibleIf(!locked) card.typeCard.isClickable = !locked + card.typeCard.isChecked = state.draft?.type == candidateType card.capabilityContainer.bindCapabilityRows( capabilityRows( viewModel.capabilityForCandidate( @@ -176,6 +202,59 @@ class SetupConfigureActivity : BaseGamepadHostActivity() { ) } + // The Moonlight type table is the host emulator's, not the satellite catalog's, and + // Auto resolves here on the client so its card can show the rows it will really send. + private fun bindMoonlightTypeCard( + card: SetupTypeCardBinding, + state: ConfigUiState, + candidateType: Int, + visible: Boolean, + ) { + card.typeCard.visibility = visibleIf(visible) + if (!visible) return + val resolved = viewModel.moonlightResolvedType(candidateType) + card.typeTitle.setText(moonlightTypeLabelRes(candidateType)) + card.typeChevron.visibility = View.GONE + card.typeCard.isClickable = true + card.typeCard.isChecked = state.draft?.type == candidateType + val auto = candidateType == MoonlightEmulatedType.AUTO + card.typeBadge.visibility = visibleIf(auto) + card.typeCaption.visibility = visibleIf(auto) + if (auto) { + card.typeBadge.setText(R.string.ml_type_auto_badge) + card.typeCaption.text = + getString(R.string.ml_type_auto_resolved, getString(moonlightTypeLabelRes(resolved))) + } + card.capabilityContainer.bindCapabilityRows( + capabilityRows( + viewModel.capabilityForCandidate( + slotId = resolvedSlotId, + candidateType = resolved, + candidateHostKind = ConnectionKind.MOONLIGHT, + candidateHostId = state.draft?.hostId, + ), + ), + ) + } + + // The app the host will run is settled once per session, so this step is the same + // section the binding screen draws and it never blocks the wizard: with nothing + // picked the session starts whatever the host lists first. + private fun renderSession(state: ConfigUiState) { + binding.tvTitle.setText(R.string.setup_cfg_session_title) + binding.tvSubtitle.setText(R.string.setup_cfg_session_subtitle) + binding.btnContinue.setText(R.string.setup_cfg_continue) + binding.btnContinue.visibility = View.VISIBLE + val session = state.moonlightSession ?: return + binding.groupMoonlightSession.bindMoonlightSession( + session = session, + hostLabel = state.selectedHost?.label.orEmpty(), + onPickApp = viewModel::selectMoonlightApp, + ) { action -> + if (action == MoonlightAction.SEE_BINDINGS) nav.toConnections() else viewModel.onMoonlightAction(action) + } + } + // 4B mirrors ConfigureBindingsActivity.bindBindingSection gating exactly: only // the rows the current input/destination/type combination supports appear, and // listeners are nulled before state is written so re-render never echoes back. @@ -347,6 +426,7 @@ class SetupConfigureActivity : BaseGamepadHostActivity() { ), ) } + if (state.isMoonlightHost) return moonlightDestinationNodes(state, model) // Satellite injects the mouse itself; the virtual pad it creates carries the // gamepad, motion, DS4 touchpad, and the rumble it sends back. val mouse = ReviewFlow(R.drawable.ic_mouse, R.string.touchpad_mode_mouse) @@ -376,6 +456,42 @@ class SetupConfigureActivity : BaseGamepadHostActivity() { ) } + // The host runs an emulated pad of its own, so it reads like the satellite pair: the + // PC itself, then the controller it plugs in for us and the feedback that comes back. + private fun moonlightDestinationNodes( + state: ConfigUiState, + model: ReviewModel, + ): List { + val gamepad = ReviewFlow(R.drawable.ic_gamepad, R.string.setup_cfg_flow_controller) + val motion = ReviewFlow(R.drawable.ic_motion, R.string.binding_func_gyro) + val rumble = ReviewFlow(R.drawable.ic_rumble, R.string.binding_func_rumble) + val touchpad = ReviewFlow(R.drawable.ic_touchpad, R.string.touchpad_mode_pad) + val stored = state.draft?.type ?: MoonlightEmulatedType.AUTO + return listOf( + ReviewNode( + kind = R.string.binding_label_destination, + icon = R.drawable.ic_pc_monitor, + name = state.selectedHost?.label.orEmpty(), + sublabel = getString(R.string.ml_dest_sublabel, viewModel.moonlightAddress(state.draft?.hostId.orEmpty())), + sends = emptyList(), + gets = emptyList(), + ), + ReviewNode( + kind = R.string.binding_label_destination, + icon = R.drawable.ic_gamepad, + name = getString(moonlightTypeLabelRes(viewModel.moonlightResolvedType(stored))), + sublabel = getString(R.string.setup_cfg_virtual_sublabel), + sends = if (model.rumbleOn) listOf(rumble) else emptyList(), + gets = + buildList { + add(gamepad) + if (model.motionOn) add(motion) + if (model.padMode) add(touchpad) + }, + ), + ) + } + private data class ReviewModel( val motionOn: Boolean, val touchpadOn: Boolean, @@ -442,12 +558,13 @@ class SetupConfigureActivity : BaseGamepadHostActivity() { // (its cards aren't tappable), so it advances via the Next button instead. private fun pickType(type: Int) { viewModel.setType(type) - if (!current.isBluetoothHost) goTo(Step.FEEL) + if (!current.isBluetoothHost) goTo(afterType()) } private fun advance() { when (step) { - Step.TYPE -> goTo(Step.FEEL) + Step.TYPE -> goTo(afterType()) + Step.SESSION -> goTo(Step.FEEL) Step.FEEL -> goTo(Step.REVIEW) Step.REVIEW -> viewModel.apply() } @@ -456,11 +573,16 @@ class SetupConfigureActivity : BaseGamepadHostActivity() { private fun handleBack() { when (step) { Step.REVIEW -> goTo(Step.FEEL) - Step.FEEL -> goTo(Step.TYPE) + Step.FEEL -> goTo(if (current.isMoonlightHost) Step.SESSION else Step.TYPE) + Step.SESSION -> goTo(Step.TYPE) Step.TYPE -> finish() } } + // Only a Moonlight destination has a session to settle, so every other host walks + // straight from the type to the feel step as it always did. + private fun afterType(): Step = if (current.isMoonlightHost) Step.SESSION else Step.FEEL + private fun goTo(next: Step) { step = next binding.scroll.scrollTo(0, 0) diff --git a/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupConnectionActivity.kt b/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupConnectionActivity.kt index f464f8e6..d100926f 100644 --- a/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupConnectionActivity.kt +++ b/app/src/main/java/com/tinkernorth/dish/ui/setup/SetupConnectionActivity.kt @@ -27,6 +27,7 @@ import com.tinkernorth.dish.ui.common.BaseGamepadHostActivity import com.tinkernorth.dish.ui.common.DishNavigator import com.tinkernorth.dish.ui.common.setupDishToolbar import com.tinkernorth.dish.ui.connections.PairPinDialog +import com.tinkernorth.dish.ui.main.chipTextRes import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import javax.inject.Inject @@ -84,6 +85,13 @@ class SetupConnectionActivity : BaseGamepadHostActivity() { R.string.setup_conn_satellite_body, R.string.setup_conn_satellite_badge, ) { withLocalNetwork { viewModel.chooseSatellite() } } + bindChoice( + binding.cardMoonlight, + R.drawable.ic_pc_monitor, + R.string.ml_dest_section, + R.string.setup_conn_moonlight_body, + badge = null, + ) { withLocalNetwork { viewModel.chooseMoonlight() } } bindChoice( binding.cardBluetoothHost, R.drawable.ic_bluetooth, @@ -93,7 +101,7 @@ class SetupConnectionActivity : BaseGamepadHostActivity() { ) { nav.toSetupBluetoothHost(inputType, slotId) } binding.btnBack.setOnClickListener { handleBack() } - binding.btnRescan.setOnClickListener { withLocalNetwork { viewModel.startDiscovery() } } + binding.btnRescan.setOnClickListener { withLocalNetwork { rescan() } } binding.btnGetSatellite.setOnClickListener { openGitHub() } onBackPressedDispatcher.addCallback(this) { handleBack() } @@ -129,14 +137,47 @@ class SetupConnectionActivity : BaseGamepadHostActivity() { private fun render(state: SetupConnectionViewModel.State) { val onSatellite = state.step == SetupConnectionViewModel.Step.SATELLITE - binding.loader.visibility = if (state.scanning) View.VISIBLE else View.INVISIBLE - binding.groupPath.visibility = visibleIf(!onSatellite) + val onMoonlight = state.step == SetupConnectionViewModel.Step.MOONLIGHT + val scanning = if (onMoonlight) state.moonlightScanning else state.scanning + binding.loader.visibility = if (scanning) View.VISIBLE else View.INVISIBLE + binding.groupPath.visibility = visibleIf(!onSatellite && !onMoonlight) binding.groupSatellite.visibility = visibleIf(onSatellite) - binding.btnRescan.visibility = visibleIf(onSatellite) + binding.groupMoonlight.visibility = visibleIf(onMoonlight) + binding.btnRescan.visibility = visibleIf(onSatellite || onMoonlight) binding.tvTitle.setText( - if (onSatellite) R.string.setup_conn_satellite_pick_title else R.string.setup_conn_path_title, + when { + onSatellite -> R.string.setup_conn_satellite_pick_title + onMoonlight -> R.string.ml_dest_section + else -> R.string.setup_conn_path_title + }, ) if (onSatellite) renderHosts(state) + if (onMoonlight) renderMoonlightHosts(state) + } + + private fun rescan() { + if (viewModel.state.value.step == SetupConnectionViewModel.Step.MOONLIGHT) { + viewModel.startMoonlightDiscovery() + } else { + viewModel.startDiscovery() + } + } + + // The row says which of the three trust words applies and never a live link: a + // Moonlight host has no way to tell us it is up, and the binding is what starts a session. + private fun renderMoonlightHosts(state: SetupConnectionViewModel.State) { + binding.tvMoonlightEyebrow.visibility = visibleIf(state.moonlightScanning) + binding.groupNoMoonlight.visibility = visibleIf(state.moonlightHosts.isEmpty()) + + val list = binding.moonlightList + list.removeAllViews() + state.moonlightHosts.forEach { host -> + val row = SetupHostRowBinding.inflate(layoutInflater, list, false) + row.hostName.text = host.name.ifBlank { getString(R.string.setup_conn_host_unnamed) } + row.hostStatus.setText(host.trust.chipTextRes()) + row.hostCard.setOnClickListener { viewModel.onMoonlightHostTapped(host.id) } + list.addView(row.root) + } } private fun renderHosts(state: SetupConnectionViewModel.State) { 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 80be240d..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 @@ -14,6 +14,9 @@ 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 com.tinkernorth.dish.ui.connections.moonlightTrustFor import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -41,9 +44,10 @@ class SetupConnectionViewModel @Inject constructor( private val satellite: SatelliteConnectionManager, + private val moonlight: MoonlightConnectionManager, private val hub: ConnectionCoordinator, ) : ViewModel() { - enum class Step { PATH, SATELLITE } + enum class Step { PATH, SATELLITE, MOONLIGHT } data class Host( val id: String, @@ -52,10 +56,20 @@ class SetupConnectionViewModel val server: DiscoveredServer, ) + // A Moonlight host is picked, never connected: pairing is remembered trust and the + // session belongs to the binding, so the row carries the trust word and nothing live. + data class MoonlightRow( + val id: String, + val name: String, + val trust: MoonlightTrustState, + ) + data class State( val step: Step = Step.PATH, val scanning: Boolean = false, val hosts: List = emptyList(), + val moonlightHosts: List = emptyList(), + val moonlightScanning: Boolean = false, ) sealed interface Event { @@ -110,6 +124,19 @@ class SetupConnectionViewModel satellite.events .onEach { onConnectionEvent(it) } .launchIn(viewModelScope) + + 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) + + moonlight.isScanning + .onEach { scanning -> _state.update { it.copy(moonlightScanning = scanning) } } + .launchIn(viewModelScope) } // 3A: Satellite path stays on this screen and starts discovery; the @@ -122,6 +149,19 @@ class SetupConnectionViewModel fun startDiscovery() = satellite.startDiscovery() + // 3A: the Moonlight path lists hosts here and hands the picked one straight to + // configure. Pairing is not a gate: a binding is a durable intent and the session + // section on the next screen is where trust is verified and repaired. + fun chooseMoonlight() { + if (_state.value.step == Step.MOONLIGHT) return + _state.update { it.copy(step = Step.MOONLIGHT) } + startMoonlightDiscovery() + } + + fun startMoonlightDiscovery() = moonlight.startDiscovery() + + fun onMoonlightHostTapped(id: String) = emit(Event.Connected(id)) + // 3B/3C: a paired/ready host connects; an unpaired one promotes to the // PIN dialog. A live host short-circuits straight to the hand-off. fun onHostTapped(id: String) { @@ -154,7 +194,7 @@ class SetupConnectionViewModel // itself is the screen's root (Activity finishes). fun back(): Boolean = when (_state.value.step) { - Step.SATELLITE -> { + Step.SATELLITE, Step.MOONLIGHT -> { _state.update { it.copy(step = Step.PATH) } true } @@ -218,6 +258,17 @@ 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, + pairedIds: Set, + verifiedIds: Set, + ): List = + summaries + .filter { it.kind == ConnectionKind.MOONLIGHT } + .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/color/type_card_background.xml b/app/src/main/res/color/type_card_background.xml new file mode 100644 index 00000000..8664347d --- /dev/null +++ b/app/src/main/res/color/type_card_background.xml @@ -0,0 +1,7 @@ + + + + + + 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/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/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 b744ddc7..e893f7e1 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,105 @@ %1$s • %2$s + Moonlight • %1$s + MOONLIGHT HOSTOVI + Još nema Moonlight hostova. Skenirajte mrežu ili dodajte adresu. + Upari s %1$s + 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 + + 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 + + %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 2c460e21..5b5edfa3 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,103 @@ %1$s • %2$s + Moonlight • %1$s + MOONLIGHT-HOSTS + Noch keine Moonlight-Hosts. Netzwerk scannen oder per Adresse hinzufügen. + Mit %1$s koppeln + 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 + + 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 + + %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 9023d391..38379a2e 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,105 @@ %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 + 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 + + 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 + + %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 537dd2c6..06f447f8 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,105 @@ %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 + 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 + + 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 + + %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 bd92984e..820ec3f4 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,105 @@ %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 + 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 + + 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 + + %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 8b215090..2ad7e060 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,114 @@ %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 + 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 + + 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 + + %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/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 6f6820e7..53fc3b56 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,13 +1,33 @@ 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..8a67e08e 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, @@ -162,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/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/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/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/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..997f09de --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightCryptoTest.kt @@ -0,0 +1,115 @@ +// 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) + } + } + + /** + * The host derives the IV from the LOW BYTE of the sequence number alone + * (Wolf control.hpp assigns a u32 seq into a u8 array element). A client + * that uses the whole 32 bits agrees for 256 packets and then diverges: a + * live Sunshine host accepted 256 sealed control packets and answered the + * 257th with "Failed to verify tag", ending the session about two minutes + * in. These two tests pin the wrap so that can never come back. + */ + @Test + fun `the control IV wraps every 256 packets, as the host's does`() { + val key = hexToBytes("edf04a215c4fbea20934120c8480d855") + val plaintext = "keepalive".toByteArray() + assertEquals( + bytesToHex(MoonlightCrypto.controlSeal(key, seq = 0, plaintext = plaintext)), + bytesToHex(MoonlightCrypto.controlSeal(key, seq = 256, plaintext = plaintext)), + ) + assertEquals( + bytesToHex(MoonlightCrypto.controlSeal(key, seq = 7, plaintext = plaintext)), + bytesToHex(MoonlightCrypto.controlSeal(key, seq = 0x0A0B0C07, plaintext = plaintext)), + ) + } + + @Test + fun `a packet sealed past the wrap still opens`() { + val key = hexToBytes("edf04a215c4fbea20934120c8480d855") + val sealed = MoonlightCrypto.controlSeal(key, seq = 257, plaintext = "past the wrap".toByteArray()) + assertEquals("past the wrap", String(MoonlightCrypto.controlOpen(key, seq = 257, 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/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/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..0ba600e7 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHostModelsTest.kt @@ -0,0 +1,48 @@ +// 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, sourceHasMotion = false), + ) + assertEquals( + MoonlightControlProtocol.CONTROLLER_TYPE_PS, + MoonlightEmulatedType.resolve(MoonlightEmulatedType.PLAYSTATION, sourceHasMotion = false), + ) + 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..f3b73664 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightHotSealerTest.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.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), + ) + } + + /** + * The 257th packet of a session is where the control stream used to die: the + * IV wraps at 256 on the host and the hot path has to wrap with it. Two + * packets a second made that a session that ended after about two minutes. + */ + @Test + fun `the hot path keeps opening past the 256-packet IV wrap`() { + val key = ByteArray(16) { it.toByte() } + val sealer = MoonlightHotSealer(key) + val receiver = MoonlightControlPacket(key) + var last = ByteArray(0) + repeat(PAST_THE_WRAP) { + last = sealer.sealControllerMulti(0, 1, MoonlightControlProtocol.BTN_A, 0, 0, 0, 0, 0, 0) + } + assertEquals(PAST_THE_WRAP, sealer.nextSeq) + assertEquals( + bytesToHex(MoonlightInputEncoder.controllerMulti(0, 1, MoonlightControlProtocol.BTN_A, 0, 0, 0, 0, 0, 0)), + bytesToHex(receiver.open(last)!!), + ) + } + + /** + * The periodic ping rides the same seq as the hot path, and it is the packet + * that actually reaches 256 on an idle session. It has to survive the wrap + * too. + */ + @Test + fun `the periodic ping keeps opening past the wrap`() { + val key = ByteArray(16) { it.toByte() } + val sealer = MoonlightHotSealer(key) + val receiver = MoonlightControlPacket(key) + var last = ByteArray(0) + repeat(PAST_THE_WRAP) { + last = sealer.seal(MoonlightInputEncoder.periodicPing()) + } + assertEquals(PAST_THE_WRAP, sealer.nextSeq) + assertEquals(bytesToHex(MoonlightInputEncoder.periodicPing()), bytesToHex(receiver.open(last)!!)) + } + + private companion object { + const val PAST_THE_WRAP = 257 + } +} 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..ccbefaa7 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightInputEncoderTest.kt @@ -0,0 +1,126 @@ +// 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 8-byte arrival body. + assertEquals(12, 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()) + // The reserved byte the host's struct alignment puts here. Omitting it + // shifted the button mask a byte left and left the host reading our + // capabilities as 0xFF03. + assertEquals(0, buf.get().toInt()) + assertEquals(0xFFFF, buf.int) + } + + @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/MoonlightMediaPingTest.kt b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightMediaPingTest.kt new file mode 100644 index 00000000..e4de097a --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightMediaPingTest.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The host decides whether a media ping is a ping by counting its bytes, so + * these tests are about length first and content second. See + * [MoonlightMediaPing] for the dead zone between the two accepted sizes. + */ +class MoonlightMediaPingTest { + // The payload a live Sunshine host handed out in a video SETUP reply. It + // reads like hex and is not: these are sixteen ASCII characters. + private val livePayload = "68A75BBEEEA86826" + + @Test + fun `an SS_PING is the payload verbatim followed by the sequence number`() { + // The sixteen characters as ASCII, then the sequence number little-endian. + val expected = livePayload.toByteArray(Charsets.US_ASCII) + byteArrayOf(0x07, 0x00, 0x00, 0x00) + assertArrayEquals(expected, MoonlightMediaPing.ssPing(livePayload, sequence = 7)) + } + + @Test + fun `an SS_PING is exactly twenty bytes`() { + // Nineteen would be silently dropped by the host with no log line. + assertEquals(MoonlightMediaPing.SS_PING_LEN, MoonlightMediaPing.ssPing(livePayload, sequence = 0).size) + } + + @Test + fun `the payload is never hex decoded`() { + // Hex-decoding this payload yields eight bytes, which lands in the dead + // zone between the legacy and modern forms and is discarded in silence. + // That mistake read as "Initial Ping Timeout" for days. + val ping = MoonlightMediaPing.ssPing(livePayload, sequence = 0) + assertEquals(livePayload, String(ping, 0, MoonlightMediaPing.PAYLOAD_LEN, Charsets.US_ASCII)) + } + + @Test + fun `a short payload is padded and a long one truncated to sixteen bytes`() { + assertEquals(MoonlightMediaPing.SS_PING_LEN, MoonlightMediaPing.ssPing("short", sequence = 1).size) + val long = MoonlightMediaPing.ssPing("0123456789ABCDEFTRAILING", sequence = 1) + assertEquals(MoonlightMediaPing.SS_PING_LEN, long.size) + assertEquals("0123456789ABCDEF", String(long, 0, MoonlightMediaPing.PAYLOAD_LEN, Charsets.US_ASCII)) + } + + @Test + fun `a padded short payload leaves the sequence number where the host reads it`() { + val ping = MoonlightMediaPing.ssPing("short", sequence = 0x01020304) + assertArrayEquals(byteArrayOf(0x04, 0x03, 0x02, 0x01), ping.copyOfRange(MoonlightMediaPing.PAYLOAD_LEN, ping.size)) + } + + @Test + fun `the legacy ping is exactly four bytes`() { + val legacy = MoonlightMediaPing.legacy() + assertEquals(MoonlightMediaPing.LEGACY_LEN, legacy.size) + assertEquals("PING", String(legacy, Charsets.US_ASCII)) + } + + @Test + fun `a host that named no payload falls back to the legacy form`() { + assertFalse(MoonlightMediaPing.usable("")) + assertTrue(MoonlightMediaPing.usable(livePayload)) + } +} 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..f45ee310 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightPairingTest.kt @@ -0,0 +1,129 @@ +// 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 + +/** + * 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. + * + * 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 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() } + 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) } + + private fun newServer(pin: String) = ReferenceServer(pin, serverIdentity, clientIdentity.certificatePem) + + @Test + fun `full pairing round-trip authenticates both ends`() { + val pin = "0451" + val server = newServer(pin) + 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 = newServer("0451") + 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 identity: MoonlightIdentity, + private val clientCertPem: String, + ) { + 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 identity.certificatePem + } + + fun challengeResponse(clientChallengeHex: String): String { + clientChallenge = MoonlightCrypto.aesEcbDecrypt(aesKey, hexToBytes(clientChallengeHex)) + 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(identity.privateKey, 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 expected = + MoonlightCrypto.sha256(serverChallenge, MoonlightCert.signatureOf(clientCertPem), clientSecret) + if (!MoonlightCrypto.constantTimeEquals(expected, storedClientHash)) return false + return MoonlightCrypto.verifyRsaSha256(MoonlightCert.publicKeyOf(clientCertPem), clientSecret, clientSignature) + } + } + + private companion object { + // 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. */ + fun throwawayIdentity(commonName: String): MoonlightIdentity = ThrowawayIdentity.named(commonName) + } +} 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..da3b2093 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightRtspTest.kt @@ -0,0 +1,152 @@ +// 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.announceSdp(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()) + } + + @Test + fun `reads an ENet connect token that does not fit in a signed int`() { + // A live Sunshine host handed back exactly this. Read straight into an + // Int it is out of range, and the control stream then connected with a + // token of 0. + val response = + MoonlightRtsp.parseResponse( + "RTSP/1.0 200 OK\r\nCSeq: 5\r\nX-SS-Connect-Data: 4270471497\r\n\r\n", + )!! + assertEquals(4270471497L.toInt(), response.enetConnectData()) + assertEquals(-24495799, response.enetConnectData()) + } + + @Test + fun `reads a connect token that does fit, and reports an absent one`() { + val small = MoonlightRtsp.parseResponse("RTSP/1.0 200 OK\r\nCSeq: 5\r\nX-SS-Connect-Data: 12345\r\n\r\n")!! + assertEquals(12345, small.enetConnectData()) + assertNull(MoonlightRtsp.parseResponse("RTSP/1.0 200 OK\r\nCSeq: 5\r\n\r\n")!!.enetConnectData()) + } + + @Test + fun `reads the media ping payload the host wants echoed`() { + val response = + MoonlightRtsp.parseResponse( + "RTSP/1.0 200 OK\r\nCSeq: 3\r\nX-SS-Ping-Payload: 9A615601970AEC19\r\n\r\n", + )!! + assertEquals("9A615601970AEC19", response.pingPayload()) + assertNull(MoonlightRtsp.parseResponse("RTSP/1.0 200 OK\r\nCSeq: 3\r\n\r\n")!!.pingPayload()) + } + + @Test + fun `the ANNOUNCE description carries every attribute a host looks up`() { + val sdp = MoonlightRtsp.announceSdp(1280, 720, 30) + + // Carrying only the handful the dish itself cares about is answered + // 400 BAD REQUEST by a real host: it looks each of these up by name and + // a miss is fatal. Dropping one because nothing here reads it is how the + // stream setup breaks again. + listOf( + "x-nv-video[0].clientViewportWd:1280", + "x-nv-video[0].clientViewportHt:720", + "x-nv-video[0].maxFPS:30", + "x-nv-video[0].packetSize:", + "x-nv-video[0].rateControlMode:", + "x-nv-video[0].timeoutLengthMs:", + "x-nv-video[0].framesWithInvalidRefThreshold:", + "x-nv-video[0].refPicInvalidation:", + "x-nv-video[0].encoderCscMode:", + "x-nv-video[0].dynamicRangeMode:", + "x-nv-video[0].maxNumReferenceFrames:", + "x-nv-video[0].videoEncoderSlicesPerFrame:", + "x-nv-video[0].clientRefreshRateX100:3000", + "x-nv-vqos[0].bitStreamFormat:", + "x-nv-vqos[0].bw.minimumBitrateKbps:", + "x-nv-vqos[0].bw.maximumBitrateKbps:", + "x-nv-vqos[0].fec.enable:", + "x-nv-vqos[0].fec.minRequiredFecPackets:", + "x-nv-vqos[0].fec.repairPercent:", + "x-nv-vqos[0].drc.enable:", + "x-nv-vqos[0].videoQualityScoreUpdateTime:", + "x-nv-vqos[0].qosTrafficType:", + "x-nv-aqos.qosTrafficType:", + "x-nv-aqos.packetDuration:", + "x-nv-audio.surround.numChannels:", + "x-nv-audio.surround.channelMask:", + "x-nv-audio.surround.enable:", + "x-nv-audio.surround.AudioQuality:", + "x-nv-general.useReliableUdp:", + "x-nv-general.featureFlags:", + "x-ml-general.featureFlags:", + "x-ss-general.encryptionEnabled:", + ).forEach { attribute -> + assertTrue("SDP is missing a=$attribute", sdp.contains("a=$attribute")) + } + assertTrue(sdp.startsWith("v=0\r\n")) + assertTrue(sdp.endsWith("t=0 0\r\n")) + } +} 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..4361e513 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/MoonlightXmlTest.kt @@ -0,0 +1,198 @@ +// 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 +import java.io.File + +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()) + } + + /** + * 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) + } + + /** + * Byte for byte what a live Sunshine host answered a second /launch with, + * over an HTTP 200. Reading only the HTTP status called this a success and + * then failed downstream on the missing sessionUrl0. + */ + @Test + fun `an app-already-running refusal is read out of the body, not the status line`() { + val xml = + "" + + "" + + "0" + + val status = MoonlightXml.parseStatus(xml)!! + + assertEquals(400, status.code) + assertEquals("An app is already running on this host", status.message) + assertFalse(status.ok) + assertTrue(status.appAlreadyRunning) + assertFalse(status.resume) + } + + @Test + fun `a resumable session is flagged so the client takes it over instead of failing`() { + val xml = + "" + + "1" + + val status = MoonlightXml.parseStatus(xml)!! + + assertTrue(status.appAlreadyRunning) + assertTrue(status.resume) + } + + @Test + fun `a successful launch reads as ok`() { + val xml = + "rtsp://192.168.68.98:48010" + + "1" + + val status = MoonlightXml.parseStatus(xml)!! + + assertTrue(status.ok) + assertFalse(status.appAlreadyRunning) + } + + @Test + fun `a reply naming no status code at all is a plain success`() { + // Wolf answers /applist this way. + assertTrue(MoonlightXml.parseStatus("1")!!.ok) + } + + @Test + fun `an unparsable reply has no status`() { + assertNull(MoonlightXml.parseStatus("not xml at all")) + } + + @Test + fun `a refusal that is not about a running app is not mistaken for one`() { + val status = MoonlightXml.parseStatus("")!! + assertFalse(status.ok) + assertFalse(status.appAlreadyRunning) + } + + /** + * 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")) + } +} 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/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..7fb5dda3 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/core/net/moonlight/enet/EnetClientTest.kt @@ -0,0 +1,471 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +package com.tinkernorth.dish.core.net.moonlight.enet + +import com.tinkernorth.dish.core.net.hexToBytes +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +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 with a fresh sent time`() { + val client = newClient() + client.connect() + client.onDatagram(verifyConnectDatagram()) + val original = client.sendReliable("input".toByteArray())!! + clock += 600 + val retransmits = client.tick() + val resent = retransmits.single { firstCommand(it).command == EnetProtocol.COMMAND_SEND_RELIABLE } + // The command is byte-identical... + assertArrayEquals(firstCommand(original).body, firstCommand(resent).body) + assertEquals(firstCommand(original).reliableSeq, firstCommand(resent).reliableSeq) + // ...but the datagram is not, because the header's sent time is what the + // peer echoes back to measure the round trip. Replaying the original + // bytes would report a round trip of however long we spent waiting. + assertNotEquals(sentTimeOf(original), sentTimeOf(resent)) + assertEquals((clock and 0xFFFF).toInt(), sentTimeOf(resent)) + } + + @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()) + } + + // --- acknowledgement generation, byte for byte --- + + @Test + fun `a host ping is acknowledged with the echoed seq and sent time`() { + val client = newClient() + client.connect() + client.onDatagram(verifyConnectDatagram()) + clock = 0x4321 + val acks = client.onDatagram(pingDatagram(reliableSeq = 7, sentTime = 0x1234)) + assertEquals(1, acks.size) + // a042 peerID 0x042, session 2 in bits 12-13, sent-time flag set + // 4321 our own sent time, the low 16 bits of the clock + // 01ff ACKNOWLEDGE, on the system channel + // 0007 the reliable sequence number of the ping being acknowledged + // 0007 receivedReliableSequenceNumber, the same + // 1234 receivedSentTime, echoed back from the ping's own header + assertArrayEquals(hexToBytes("a042432101ff000700071234"), acks.first()) + } + + @Test + fun `a command wanting an ack but carrying no sent time is not acknowledged`() { + // protocol.c abandons the datagram in this case rather than guessing a + // round trip out of nothing; the port keeps to that. + val client = newClient() + client.connect() + client.onDatagram(verifyConnectDatagram()) + val w = EnetProtocol.Writer(EnetProtocol.NO_SENT_TIME_HEADER_LEN + EnetProtocol.PING_LEN) + w.u16(0) // no sent-time flag + EnetProtocol.commandHeader(w, EnetProtocol.COMMAND_PING or EnetProtocol.FLAG_ACKNOWLEDGE, EnetProtocol.SYSTEM_CHANNEL, 3) + assertTrue(client.onDatagram(w.toByteArray()).isEmpty()) + } + + // --- the fault that ended every live session at about 6.4 seconds --- + + @Test + fun `a reliable BANDWIDTH_LIMIT is acknowledged`() { + // A live Sunshine host sends this about a second after the peer connects, + // off enet_host_bandwidth_throttle's 1000 ms tick. Leaving it + // unacknowledged freezes the host's sent-reliable queue, which stops its + // pings and drops the peer ENET_PEER_TIMEOUT_MINIMUM later. + val client = newClient() + client.connect() + client.onDatagram(verifyConnectDatagram()) + val acks = client.onDatagram(bandwidthLimitDatagram(reliableSeq = 2)) + assertEquals(1, acks.size) + val ack = firstCommand(acks.first()) + assertEquals(EnetProtocol.COMMAND_ACKNOWLEDGE, ack.command) + assertEquals(EnetProtocol.SYSTEM_CHANNEL, ack.channelId) + assertEquals(2, ack.reliableSeq) + assertEquals(0, client.unknownCommands) + } + + @Test + fun `a reliable THROTTLE_CONFIGURE is acknowledged`() { + val client = newClient() + client.connect() + client.onDatagram(verifyConnectDatagram()) + val acks = client.onDatagram(throttleConfigureDatagram(reliableSeq = 3)) + assertEquals(EnetProtocol.COMMAND_ACKNOWLEDGE, firstCommand(acks.single()).command) + assertEquals(0, client.unknownCommands) + } + + @Test + fun `a command we do not act on does not swallow the rest of its datagram`() { + // The real cost of mismeasuring a command: everything behind it in the + // same datagram is lost, acknowledgements included. + val client = newClient() + client.connect() + client.onDatagram(verifyConnectDatagram()) + client.sendReliable("input".toByteArray()) + val w = EnetProtocol.Writer(HEADER_AND_TWO_COMMANDS) + hostHeader(w, sentTime = 80) + bandwidthLimitCommand(w, reliableSeq = 2) + EnetProtocol.commandHeader(w, EnetProtocol.COMMAND_ACKNOWLEDGE, EnetClient.DATA_CHANNEL, 1) + w.u16(1) + w.u16(0) + + val acks = client.onDatagram(w.toByteArray()) + + // The bandwidth limit was acknowledged... + assertEquals(1, acks.size) + // ...and the acknowledgement riding behind it still cleared our send. + clock += 10_000 + assertTrue(client.tick().none { firstCommand(it).command == EnetProtocol.COMMAND_SEND_RELIABLE }) + } + + @Test + fun `an unreliable send's payload is skipped so a following ack still parses`() { + val client = newClient() + client.connect() + client.onDatagram(verifyConnectDatagram()) + client.sendReliable("input".toByteArray()) + val payload = "discarded".toByteArray() + val w = + EnetProtocol.Writer( + EnetProtocol.FULL_HEADER_LEN + EnetProtocol.SEND_UNRELIABLE_HEADER_LEN + payload.size + + EnetProtocol.ACKNOWLEDGE_LEN, + ) + hostHeader(w, sentTime = 90) + EnetProtocol.commandHeader(w, EnetProtocol.COMMAND_SEND_UNRELIABLE, EnetClient.DATA_CHANNEL, 5) + w.u16(1) // unreliableSequenceNumber + w.u16(payload.size) + w.bytes(payload) + EnetProtocol.commandHeader(w, EnetProtocol.COMMAND_ACKNOWLEDGE, EnetClient.DATA_CHANNEL, 1) + w.u16(1) + w.u16(0) + + client.onDatagram(w.toByteArray()) + + clock += 10_000 + assertTrue(client.tick().none { firstCommand(it).command == EnetProtocol.COMMAND_SEND_RELIABLE }) + } + + // --- giving up on the peer, on the same clock the peer uses --- + + @Test + fun `a peer that keeps acknowledging is never given up on`() { + val client = newClient() + client.connect() + client.onDatagram(verifyConnectDatagram()) + // Well past the old fixed retransmit budget, which ended the session at + // about five and a half seconds no matter how healthy the link was. + repeat(TALKATIVE_ROUNDS) { round -> + clock += EnetProtocol.PING_INTERVAL_MS + client.sendReliable("input".toByteArray()) + client.tick() + client.onDatagram(ackDatagram(EnetClient.DATA_CHANNEL, reliableSeq = round + 1)) + } + assertEquals(EnetClient.State.CONNECTED, client.state) + assertNull(client.disconnectReason) + } + + @Test + fun `a peer that stops acknowledging is given up on, but not before the ENet rule says so`() { + val client = newClient() + client.connect() + client.onDatagram(verifyConnectDatagram()) + client.sendReliable("input".toByteArray()) + val start = clock + + // Six seconds of silence is NOT yet a dead peer. The rule needs both + // ENET_PEER_TIMEOUT_MINIMUM of waiting and enough resends for the + // doubling window to pass ENET_PEER_TIMEOUT_LIMIT, and from a cold + // round-trip estimate that takes a good deal longer than the minimum. + // The revision this replaces gave up here, after a flat ten resends. + while (clock - start < BEFORE_GIVING_UP_MS) { + clock += TICK_MS + client.tick() + } + assertEquals(EnetClient.State.CONNECTED, client.state) + + // It does give up, well inside ENET_PEER_TIMEOUT_MAXIMUM. + while (clock - start < EnetProtocol.TIMEOUT_MAXIMUM_MS && client.state == EnetClient.State.CONNECTED) { + clock += TICK_MS + client.tick() + } + assertEquals(EnetClient.State.DISCONNECTED, client.state) + assertTrue(client.disconnectReason.orEmpty().contains("stopped acknowledging")) + } + + @Test + fun `an acknowledgement clears the give-up clock`() { + val client = newClient() + client.connect() + client.onDatagram(verifyConnectDatagram()) + client.sendReliable("first".toByteArray()) + repeat(BEFORE_MINIMUM_TICKS) { + clock += TICK_MS + client.tick() + } + client.onDatagram(ackDatagram(EnetClient.DATA_CHANNEL, reliableSeq = 1)) + client.sendReliable("second".toByteArray()) + // The clock restarts from that acknowledgement, so the same wait again + // is survivable. + repeat(BEFORE_MINIMUM_TICKS) { + clock += TICK_MS + client.tick() + } + assertEquals(EnetClient.State.CONNECTED, client.state) + } + + private fun sentTimeOf(datagram: ByteArray): Int { + val buf = ByteBuffer.wrap(datagram).order(ByteOrder.BIG_ENDIAN) + buf.short + return buf.short.toInt() and 0xFFFF + } + + private fun pingDatagram( + reliableSeq: Int, + sentTime: Int, + ): ByteArray { + val w = EnetProtocol.Writer(EnetProtocol.FULL_HEADER_LEN + EnetProtocol.PING_LEN) + hostHeader(w, sentTime) + EnetProtocol.commandHeader( + w, + EnetProtocol.COMMAND_PING or EnetProtocol.FLAG_ACKNOWLEDGE, + EnetProtocol.SYSTEM_CHANNEL, + reliableSeq, + ) + return w.toByteArray() + } + + private fun bandwidthLimitCommand( + w: EnetProtocol.Writer, + reliableSeq: Int, + ) { + EnetProtocol.commandHeader( + w, + EnetProtocol.COMMAND_BANDWIDTH_LIMIT or EnetProtocol.FLAG_ACKNOWLEDGE, + EnetProtocol.SYSTEM_CHANNEL, + reliableSeq, + ) + w.u32(0) // incomingBandwidth + w.u32(0) // outgoingBandwidth + } + + private fun bandwidthLimitDatagram(reliableSeq: Int): ByteArray { + val w = EnetProtocol.Writer(EnetProtocol.FULL_HEADER_LEN + EnetProtocol.BANDWIDTH_LIMIT_LEN) + hostHeader(w, sentTime = 80) + bandwidthLimitCommand(w, reliableSeq) + return w.toByteArray() + } + + private fun throttleConfigureDatagram(reliableSeq: Int): ByteArray { + val w = EnetProtocol.Writer(EnetProtocol.FULL_HEADER_LEN + EnetProtocol.THROTTLE_CONFIGURE_LEN) + hostHeader(w, sentTime = 85) + EnetProtocol.commandHeader( + w, + EnetProtocol.COMMAND_THROTTLE_CONFIGURE or EnetProtocol.FLAG_ACKNOWLEDGE, + EnetProtocol.SYSTEM_CHANNEL, + reliableSeq, + ) + w.u32(EnetProtocol.PACKET_THROTTLE_INTERVAL) + w.u32(EnetProtocol.PACKET_THROTTLE_ACCELERATION) + w.u32(EnetProtocol.PACKET_THROTTLE_DECELERATION) + return w.toByteArray() + } + + private companion object { + const val HEADER_AND_TWO_COMMANDS = + EnetProtocol.FULL_HEADER_LEN + EnetProtocol.BANDWIDTH_LIMIT_LEN + EnetProtocol.ACKNOWLEDGE_LEN + + // Thirty seconds of healthy traffic at the ping interval. + const val TALKATIVE_ROUNDS = 60 + + const val TICK_MS = 100L + const val BEFORE_MINIMUM_TICKS = 40 // 4.0 s + + // Past the 5.5 s at which the old fixed retransmit budget expired, and + // past the 6.4 s at which a live host was ending the session. + const val BEFORE_GIVING_UP_MS = 6_500L + } +} 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..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 @@ -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,56 @@ class PhysicalSlotBindingObserverTest { summaries: List = emptyList(), slotInfo: Map = emptyMap(), btConnectedIds: Set = emptySet(), - ) = reconcileSlots(present, lastBound, bindings, summaries, slotInfo, btConnectedIds) + moonlightLiveIds: Set = emptySet(), + moonlightPadNumbers: Map = emptyMap(), + ) = reconcileSlots( + present, + lastBound, + bindings, + summaries, + slotInfo, + btConnectedIds, + moonlightLiveIds, + moonlightPadNumbers, + ) + + @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"), + moonlightPadNumbers = mapOf("3" to 0), + ) + assertEquals(listOf(BindOp.BindMoonlight(deviceId = 3, connectionId = "moonlight:pc", controllerNumber = 0)), 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", 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", controllerNumber = 0) + 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/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/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/MoonlightHttp11ClientTest.kt b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttp11ClientTest.kt new file mode 100644 index 00000000..b9a315d4 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttp11ClientTest.kt @@ -0,0 +1,283 @@ +// 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 +import javax.net.ssl.SSLPeerUnverifiedException + +/** + * 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 MoonlightHttp11ClientTest { + 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() = MoonlightHttp11Client(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 = MoonlightHttp11Client(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 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 = MoonlightHttp11Client(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. + 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) + } + + @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..4204b72f --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightHttpGatewayTest.kt @@ -0,0 +1,294 @@ +// 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.security.cert.X509Certificate +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import javax.net.ssl.KeyManager +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLServerSocket +import javax.net.ssl.SSLSocket +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +/** + * 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; a full handshake on every one of + * them, never a resumed session; and a host certificate that has to survive the + * TOFU pin before any request is written. Real Sunshine hosts have broken on + * each of the first two in turn, the pooled HttpsURLConnection version by + * leaking a live session per call and the cached-factory version by offering + * one to resume. + */ +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() + val gateway = gateway() + + 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 `handshakes from scratch every call, never offering a session to resume`() { + val base = start() + val gateway = gateway() + + repeat(CALLS) { assertEquals(200, gateway.getHttps("$base/serverinfo?uniqueid=abc", HOST_ID).status) } + + assertEquals(CALLS, host.awaitHeads(CALLS).size) + // One client-certificate check per call. A resumed session skips the + // client's Certificate message altogether, so a host that authorises by + // that certificate never sees it: Sunshine answers such a connection + // with a fatal internal_error alert and logs nothing at all. The gateway + // must therefore never carry a session cache from one call to the next. + assertEquals(CALLS, host.clientChecks.get()) + } + + @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: X509Certificate, + ) { + private val credentials = + HandshakeCertificates + .Builder() + .heldCertificate(held) + .addTrustedCertificate(trustedClient) + .build() + + /** One per full handshake, none on a resumed session. See [CountingTrustManager]. */ + val clientChecks = AtomicInteger() + + private val server: SSLServerSocket = + SSLContext + .getInstance("TLS") + .apply { + init( + arrayOf(credentials.keyManager), + arrayOf(CountingTrustManager(credentials.trustManager, clientChecks)), + null, + ) + }.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() } + } + } + + /** + * Counts what a Moonlight host's own verify callback counts: JSSE runs one + * client-certificate check per full handshake and none at all on a resumed + * session, because a resumed session carries the peer identity forward + * instead of asking for the certificate again. + */ + private class CountingTrustManager( + private val delegate: X509TrustManager, + private val checks: AtomicInteger, + ) : X509TrustManager by delegate { + override fun checkClientTrusted( + chain: Array, + authType: String, + ) { + checks.incrementAndGet() + delegate.checkClientTrusted(chain, authType) + } + } + + 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 + } +} 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/MoonlightRtspClientTest.kt b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightRtspClientTest.kt new file mode 100644 index 00000000..45547c24 --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightRtspClientTest.kt @@ -0,0 +1,198 @@ +// 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.assertNull +import org.junit.Test +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit + +/** + * Drives [MoonlightRtspClient] against a loopback host that behaves the way a + * real Moonlight host does: it answers exactly ONE message per TCP connection + * and then hangs up, and it frames the DESCRIBE body by that hang-up rather than + * with a Content-length. + * + * That is the whole point of the fixture. A client that keeps the socket for a + * second message gets end-of-stream instead of a reply, which is exactly what + * broke stream setup against a live Sunshine host: it answered OPTIONS, closed, + * and never saw the DESCRIBE we wrote into the dead socket. + */ +class MoonlightRtspClientTest { + private lateinit var host: RtspHost + + @After + fun tearDown() { + if (::host.isInitialized) host.close() + } + + @Test + fun `completes the handshake, one connection per message`() { + host = RtspHost() + + val ports = MoonlightRtspClient("127.0.0.1", host.port).handshake(1280, 720, 30) + + assertEquals(MoonlightRtspClient.StreamPorts(47999, 47998, 48000, 4270471497L.toInt(), PING), ports) + // Seven messages, seven connections, and the host read one request on + // each. Reusing a connection would have stalled at the second message. + assertEquals( + listOf("OPTIONS", "DESCRIBE", "SETUP", "SETUP", "SETUP", "ANNOUNCE", "PLAY"), + host.awaitCommands(EXPECTED_MESSAGES), + ) + assertEquals(EXPECTED_MESSAGES, host.connections) + } + + @Test + fun `numbers CSeq across connections rather than restarting it`() { + host = RtspHost() + + MoonlightRtspClient("127.0.0.1", host.port).handshake(1280, 720, 30) + + assertEquals((1..EXPECTED_MESSAGES).toList(), host.awaitCseqs(EXPECTED_MESSAGES)) + } + + @Test + fun `gives up when the host refuses a step`() { + host = RtspHost(refuse = "SETUP") + + assertNull(MoonlightRtspClient("127.0.0.1", host.port).handshake(1280, 720, 30)) + // Stopped at the refusal instead of carrying on with the rest. + assertEquals(listOf("OPTIONS", "DESCRIBE", "SETUP"), host.awaitCommands(3)) + } + + @Test + fun `gives up when the host hangs up without answering`() { + host = RtspHost(silent = true) + + assertNull(MoonlightRtspClient("127.0.0.1", host.port).handshake(1280, 720, 30)) + } + + /** + * One message per accepted connection, then close. Replies mirror what a + * real host sends, including a DESCRIBE body with no Content-length. + */ + private class RtspHost( + private val refuse: String? = null, + private val silent: Boolean = false, + ) { + private val server = ServerSocket(0, BACKLOG, InetAddress.getByName("127.0.0.1")) + private val commands = CopyOnWriteArrayList() + private val cseqs = CopyOnWriteArrayList() + + @Volatile var connections = 0 + private set + + val port: Int get() = server.localPort + + init { + Thread { + runCatching { + while (true) serve(server.accept()) + } + }.apply { + isDaemon = true + start() + } + } + + private fun serve(socket: Socket) { + socket.use { + connections += 1 + val head = readHead(socket) + val command = head.substringBefore(' ') + val cseq = + Regex("(?i)cseq:\\s*(\\d+)") + .find(head) + ?.groupValues + ?.get(1) + ?.toIntOrNull() ?: 0 + if (silent) return + readBody(socket, head) + socket.getOutputStream().apply { + write(reply(command, cseq).toByteArray(Charsets.ISO_8859_1)) + flush() + } + cseqs += cseq + commands += command + } + } + + // The client sends the whole message in one write, so the ANNOUNCE body + // is already behind the head; drain it so the reply is not a race. + private fun readBody( + socket: Socket, + head: String, + ) { + val declared = + Regex("(?i)content-length:\\s*(\\d+)") + .find(head) + ?.groupValues + ?.get(1) + ?.toIntOrNull() ?: return + val input = socket.getInputStream() + repeat(declared) { if (input.read() < 0) return } + } + + private fun reply( + command: String, + cseq: Int, + ): String { + if (command == refuse) return "RTSP/1.0 500 INTERNAL SERVER ERROR\r\nCSeq: $cseq\r\n\r\n" + val head = "RTSP/1.0 200 OK\r\nCSeq: $cseq\r\n" + return when { + // No Content-length: the body runs to the close, as a real host sends it. + command == "DESCRIBE" -> head + "\r\n" + "a=x-nv-video[0].refPicInvalidation:1\n" + command != "SETUP" -> head + "\r\n" + cseq == AUDIO_CSEQ -> head + "Transport: server_port=48000\r\nX-SS-Ping-Payload: $PING\r\n\r\n" + cseq == VIDEO_CSEQ -> head + "Transport: server_port=47998\r\nX-SS-Ping-Payload: $PING\r\n\r\n" + else -> head + "Transport: server_port=47999\r\nX-SS-Connect-Data: 4270471497\r\n\r\n" + } + } + + 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() + } + + fun awaitCommands(count: Int): List { + waitFor(count) + return commands.toList() + } + + fun awaitCseqs(count: Int): List { + waitFor(count) + return cseqs.toList() + } + + private fun waitFor(count: Int) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(TIMEOUT_S) + while (commands.size < count && System.nanoTime() < deadline) Thread.sleep(POLL_MS) + } + + fun close() { + runCatching { server.close() } + } + } + + private companion object { + const val BACKLOG = 8 + const val EXPECTED_MESSAGES = 7 + const val AUDIO_CSEQ = 3 + const val VIDEO_CSEQ = 4 + const val PING = "9A615601970AEC19" + const val TIMEOUT_S = 10L + const val POLL_MS = 10L + } +} 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/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/source/connection/moonlight/MoonlightTrustFlowTest.kt b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightTrustFlowTest.kt new file mode 100644 index 00000000..0747a82f --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/source/connection/moonlight/MoonlightTrustFlowTest.kt @@ -0,0 +1,562 @@ +// 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 io.mockk.verifyOrder +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()) } + } + + // 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) { + 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 over mutual TLS with a pairing stored has lost trust`() = + runTest(dispatcher) { + manager.pairHost(host) + dispatcher.scheduler.advanceUntilIdle() + 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 over mutual TLS with nothing stored has never paired`() = + runTest(dispatcher) { + 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) { + 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 once it was paired`() = + 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 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) { + 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 new file mode 100644 index 00000000..d171813d --- /dev/null +++ b/app/src/test/java/com/tinkernorth/dish/ui/connections/MoonlightRowsTest.kt @@ -0,0 +1,98 @@ +// 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 com.tinkernorth.dish.source.connection.moonlight.MoonlightTrustState +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, + 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`() { + 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()) + } + + // 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, paired = false)) + assertEquals( + MoonlightTrustState.PAIRED, + moonlightTrustFor(summary("moonlight:uid:a", live = LinkState.Unstable), paired = true), + ) + 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 + 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(), + 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"))) + 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..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 @@ -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,9 @@ 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 verifiedMoonlight = MutableStateFlow>(emptySet()) private val events = MutableSharedFlow(extraBufferCapacity = 8) private val server = DiscoveredServer(name = "Living Room", ip = "10.0.0.5", machineId = "abc123") @@ -51,6 +58,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 +66,10 @@ 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.verifiedHostIds } returns verifiedMoonlight + every { moonlight.isScanning } returns moonlightScanning + vm = SetupConnectionViewModel(satellite, moonlight, hub) } @After @@ -74,6 +85,83 @@ 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 }, + ) + } + + // 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) { + 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 +309,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) } }