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