Handle traversal state changes and errors

Traversal connections show in the joining info UI.
Non fatal errors show the retry button.
Fatal errors end the netplay session.
This commit is contained in:
Tom Pratt
2026-05-05 18:50:04 +02:00
committed by Tom Pratt
parent 86a0f22887
commit 86de5ef4b0
11 changed files with 247 additions and 12 deletions

View File

@@ -25,11 +25,12 @@ import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.dolphinemu.dolphinemu.features.netplay.model.GameDigestProgress
import org.dolphinemu.dolphinemu.model.GameFile
import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage
import org.dolphinemu.dolphinemu.features.netplay.model.Player
import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress
import org.dolphinemu.dolphinemu.features.netplay.model.TraversalState
import org.dolphinemu.dolphinemu.features.settings.model.StringSetting
import org.dolphinemu.dolphinemu.model.GameFile
class NetplaySession(
private val onClosed: (NetplaySession) -> Unit,
@@ -117,6 +118,15 @@ class NetplaySession(
private val _gameDigestProgress = MutableStateFlow<GameDigestProgress?>(null)
val gameDigestProgress = _gameDigestProgress.asStateFlow()
private val _traversalState = MutableSharedFlow<TraversalState>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val traversalState = _traversalState.asSharedFlow()
private val _fatalTraversalError = Channel<TraversalState.Failure>(Channel.CONFLATED)
val fatalTraversalError = _fatalTraversalError.receiveAsFlow()
suspend fun join(): Boolean = withContext(Dispatchers.IO) {
if (isClosed) throw IllegalStateException("Cannot join a closed session")
@@ -148,7 +158,7 @@ class NetplaySession(
}
fun sendMessage(message: String) {
_chatMessages.tryEmit( "$nickName: $message")
_chatMessages.tryEmit("$nickName: $message")
nativeSendMessage(message)
}
@@ -162,6 +172,8 @@ class NetplaySession(
fun getExternalIpAddress(): String? = nativeGetExternalIpAddress()
fun reconnectTraversal() = nativeReconnectTraversal()
fun consumeBootSessionData(): Long {
return bootSessionDataPointer.also {
bootSessionDataPointer = 0
@@ -247,6 +259,8 @@ class NetplaySession(
private external fun nativeGetExternalIpAddress(): String?
private external fun nativeReconnectTraversal()
// NetPlayUI callbacks
@Keep
@@ -390,6 +404,26 @@ class NetplaySession(
fun onAbortGameDigest() {
_gameDigestProgress.value = null
}
@Keep
fun onTraversalStateChanged(
state: Int,
hostCode: String?,
externalAddress: String?,
failureReason: String?,
) {
val traversalState = when (state) {
0 -> TraversalState.Connecting
1 -> TraversalState.Connected(hostCode!!, externalAddress!!)
2 -> TraversalState.Failure(failureReason!!)
else -> return
}
_traversalState.tryEmit(traversalState)
if (failureReason == "BadHost" || failureReason == "VersionTooOld") {
_fatalTraversalError.trySend(TraversalState.Failure(failureReason))
}
}
}
private fun <T> Channel<T>.flush() {

View File

@@ -6,6 +6,7 @@ import androidx.annotation.StringRes
import org.dolphinemu.dolphinemu.R
enum class JoinInfoType(@StringRes val labelId: Int) {
ROOM_ID(R.string.netplay_address_type_room_id),
EXTERNAL(R.string.netplay_address_type_external),
LOCAL(R.string.netplay_address_type_local),
}

View File

@@ -12,7 +12,9 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.dolphinemu.dolphinemu.features.netplay.NetplaySession
@@ -28,20 +30,29 @@ class NetplayViewModel(
private val networkHelper: NetworkHelper,
) : ViewModel() {
private val isTraversal = StringSetting.NETPLAY_TRAVERSAL_CHOICE.string == "traversal"
val launchGame = netplaySession.launchGame
val isHosting = netplaySession.isHosting
private val _joinAddresses = MutableStateFlow(
mapOf(
JoinInfoType.EXTERNAL to JoinAddress.Loading,
JoinInfoType.LOCAL to getLocalIp(),
)
buildMap {
if (isHosting) {
if (isTraversal) {
put(JoinInfoType.ROOM_ID, JoinAddress.Loading)
}
put(JoinInfoType.EXTERNAL, JoinAddress.Loading)
put(JoinInfoType.LOCAL, getLocalIp())
}
}
)
val joinAddresses = _joinAddresses.asStateFlow()
val connectionLost = netplaySession.connectionLost
val fatalTraversalError = netplaySession.fatalTraversalError
val players = netplaySession.players
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
@@ -72,7 +83,11 @@ class NetplayViewModel(
init {
if (netplaySession.isHosting) {
setInitialGame()
fetchExternalIp()
if (isTraversal) {
collectTraversalState()
} else {
fetchExternalIp()
}
}
}
@@ -118,6 +133,32 @@ class NetplayViewModel(
}
}
private fun collectTraversalState() {
val retry = { netplaySession.reconnectTraversal() }
netplaySession.traversalState.onEach { state ->
when (state) {
is TraversalState.Connecting -> {
_joinAddresses.value += mapOf(
JoinInfoType.ROOM_ID to JoinAddress.Loading,
JoinInfoType.EXTERNAL to JoinAddress.Loading,
)
}
is TraversalState.Connected -> {
_joinAddresses.value += mapOf(
JoinInfoType.ROOM_ID to JoinAddress.Loaded(state.hostCode),
JoinInfoType.EXTERNAL to JoinAddress.Loaded(state.externalAddress),
)
}
is TraversalState.Failure -> {
_joinAddresses.value += mapOf(
JoinInfoType.ROOM_ID to JoinAddress.Unknown(retry),
JoinInfoType.EXTERNAL to JoinAddress.Unknown(retry),
)
}
}
}.launchIn(viewModelScope)
}
private fun setInitialGame() {
val game = gameFiles.value
.find { it.getGameId() == StringSetting.NETPLAY_GAME.string }

View File

@@ -0,0 +1,18 @@
package org.dolphinemu.dolphinemu.features.netplay.model
import android.content.Context
import org.dolphinemu.dolphinemu.R
sealed class TraversalState {
data object Connecting : TraversalState()
data class Connected(val hostCode: String, val externalAddress: String) : TraversalState()
data class Failure(val reason: String) : TraversalState() {
fun message(context: Context) = when (reason) {
"BadHost" -> context.getString(R.string.netplay_traversal_error_bad_host)
"VersionTooOld" -> context.getString(R.string.netplay_traversal_error_version_too_old)
else -> reason
}
}
}

View File

@@ -50,6 +50,7 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider {
NetplayScreen(
onBackClicked = { finish() },
connectionLost = viewModel.connectionLost,
fatalTraversalError = viewModel.fatalTraversalError,
messages = viewModel.messages.collectAsState().value,
onSendMessage = viewModel::sendMessage,
game = viewModel.game.collectAsState().value,

View File

@@ -97,6 +97,7 @@ import org.dolphinemu.dolphinemu.features.netplay.model.JoinInfoType
import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage
import org.dolphinemu.dolphinemu.features.netplay.model.Player
import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress
import org.dolphinemu.dolphinemu.features.netplay.model.TraversalState
import org.dolphinemu.dolphinemu.model.GameFile
import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme
import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer
@@ -112,6 +113,7 @@ fun NetplayScreen(
onBackClicked: () -> Unit,
isHosting: Boolean,
connectionLost: Flow<Unit>,
fatalTraversalError: Flow<TraversalState.Failure>,
messages: List<NetplayMessage>,
onSendMessage: (String) -> Unit,
game: String,
@@ -156,7 +158,9 @@ fun NetplayScreen(
// State which must live above the landscape/portrait split.
var showChat by rememberSaveable { mutableStateOf(false) }
var showGamePicker by rememberSaveable { mutableStateOf(false) }
var selectedJoinInfoType by rememberSaveable { mutableStateOf(JoinInfoType.EXTERNAL) }
var selectedJoinInfoType by rememberSaveable {
mutableStateOf(joinAddresses.keys.firstOrNull() ?: JoinInfoType.EXTERNAL)
}
if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) {
LandscapeContent(
@@ -207,6 +211,11 @@ fun NetplayScreen(
connectionLost.collect { showConnectionLostDialog = true }
}
var traversalError by rememberSaveable { mutableStateOf<TraversalState.Failure?>(null) }
LaunchedEffect(Unit) {
fatalTraversalError.collect { traversalError = it }
}
var dismissSaveTransferProgressDialog by rememberSaveable { mutableStateOf(false) }
if (saveTransferProgress == null) {
dismissSaveTransferProgressDialog = false
@@ -217,6 +226,8 @@ fun NetplayScreen(
dismissGameDigestDialog = false
}
val currentTraversalError = traversalError
when {
showConnectionLostDialog -> {
AlertDialog(
@@ -230,6 +241,18 @@ fun NetplayScreen(
)
}
currentTraversalError != null -> {
AlertDialog(
text = { Text(currentTraversalError.message(LocalContext.current)) },
confirmButton = {
TextButton(onClick = onBackClicked) {
Text(stringResource(R.string.ok))
}
},
onDismissRequest = onBackClicked,
)
}
saveTransferProgress != null && !dismissSaveTransferProgressDialog -> {
SaveTransferProgressDialog(
saveTransferProgress = saveTransferProgress,
@@ -669,6 +692,7 @@ private fun JoinAddressSection(
modifier = Modifier.weight(0.39f),
)
AddressRow(
joinInfoType = selectedType,
address = address,
modifier = Modifier.weight(0.61f),
)
@@ -683,6 +707,7 @@ private fun JoinAddressSection(
)
MenuSpacer()
AddressRow(
joinInfoType = selectedType,
address = address,
modifier = Modifier.fillMaxWidth(),
)
@@ -737,6 +762,7 @@ private fun JoinInfoDropdown(
@Composable
private fun AddressRow(
joinInfoType: JoinInfoType,
address: JoinAddress,
modifier: Modifier = Modifier,
) {
@@ -748,7 +774,10 @@ private fun AddressRow(
is JoinAddress.Loaded -> address.address
is JoinAddress.Unknown -> stringResource(R.string.netplay_address_unknown)
},
label = stringResource(R.string.netplay_address_label),
label = stringResource(
if (joinInfoType == JoinInfoType.ROOM_ID) R.string.netplay_code_label
else R.string.netplay_address_label
),
onClick = when (address) {
is JoinAddress.Loaded -> {
{
@@ -1128,6 +1157,7 @@ private fun PreviewNetplayScreen() {
NetplayScreen(
onBackClicked = {},
connectionLost = emptyFlow(),
fatalTraversalError = emptyFlow(),
players = listOf(
Player(
pid = 1,

View File

@@ -1018,8 +1018,12 @@ It can efficiently compress both junk data and encrypted Wii data.
<string name="netplay_use_upnp">Forward port (UPnP)</string>
<string name="netplay_host_address_label">Join info</string>
<string name="netplay_address_label">Address</string>
<string name="netplay_address_type_external">External IP</string>
<string name="netplay_address_type_local">Local IP</string>
<string name="netplay_code_label">Code</string>
<string name="netplay_address_type_room_id">Room</string>
<string name="netplay_address_type_external">External</string>
<string name="netplay_address_type_local">Local</string>
<string name="netplay_traversal_error_bad_host">Couldn\'t look up central server</string>
<string name="netplay_traversal_error_version_too_old">Dolphin is too old for traversal server</string>
<string name="netplay_address_loading">Loading…</string>
<string name="netplay_address_unknown">Unknown</string>
<string name="netplay_address_share">Share address</string>

View File

@@ -49,6 +49,7 @@ static jmethodID s_netplay_on_show_game_digest_dialog;
static jmethodID s_netplay_on_set_game_digest_progress;
static jmethodID s_netplay_on_set_game_digest_result;
static jmethodID s_netplay_on_abort_game_digest;
static jmethodID s_netplay_on_traversal_state_changed;
static jclass s_netplay_player_class;
static jmethodID s_netplay_player_constructor;
@@ -363,6 +364,11 @@ jmethodID GetNetplayOnAbortGameDigest()
return s_netplay_on_abort_game_digest;
}
jmethodID GetNetplayOnTraversalStateChanged()
{
return s_netplay_on_traversal_state_changed;
}
jclass GetNetplayPlayerClass()
{
return s_netplay_player_class;
@@ -813,6 +819,9 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved)
env->GetMethodID(netplay_class, "onSetGameDigestResult", "(ILjava/lang/String;)V");
s_netplay_on_abort_game_digest =
env->GetMethodID(netplay_class, "onAbortGameDigest", "()V");
s_netplay_on_traversal_state_changed = env->GetMethodID(
netplay_class, "onTraversalStateChanged",
"(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V");
env->DeleteLocalRef(netplay_class);
const jclass netplay_player_class =

View File

@@ -52,6 +52,7 @@ jmethodID GetNetplayOnShowGameDigestDialog();
jmethodID GetNetplayOnSetGameDigestProgress();
jmethodID GetNetplayOnSetGameDigestResult();
jmethodID GetNetplayOnAbortGameDigest();
jmethodID GetNetplayOnTraversalStateChanged();
jclass GetNetplayPlayerClass();
jmethodID GetNetplayPlayerConstructor();

View File

@@ -1,15 +1,62 @@
// Copyright 2003 Dolphin Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include <android/log.h>
#include "UICommon/GameFile.h"
#include "UICommon/UICommon.h"
#include "NetPlayUICallbacks.h"
#include "Common/TraversalClient.h"
#include "Core/Boot/Boot.h"
#include "Core/Core.h"
#include "Core/System.h"
#include "jni/AndroidCommon/AndroidCommon.h"
#include "jni/AndroidCommon/IDCache.h"
namespace
{
std::string InetAddressToString(const Common::TraversalInetAddress& addr)
{
std::string ip;
if (addr.isIPV6)
{
ip = "IPv6-Not-Implemented";
}
else
{
const auto ipv4 = reinterpret_cast<const u8*>(addr.address);
ip = std::to_string(ipv4[0]);
for (u32 i = 1; i != 4; ++i)
{
ip += ".";
ip += std::to_string(ipv4[i]);
}
}
return ip + ":" + std::to_string(ntohs(addr.port));
}
const char* FailureReasonToString(Common::TraversalClient::FailureReason reason)
{
switch (reason)
{
case Common::TraversalClient::FailureReason::BadHost:
return "BadHost";
case Common::TraversalClient::FailureReason::VersionTooOld:
return "VersionTooOld";
case Common::TraversalClient::FailureReason::ServerForgotAboutUs:
return "ServerForgotAboutUs";
case Common::TraversalClient::FailureReason::SocketSendError:
return "SocketSendError";
case Common::TraversalClient::FailureReason::ResendTimeout:
return "ResendTimeout";
default:
return "Unknown";
}
}
} // namespace
namespace NetPlay {
NetPlayUICallbacks::NetPlayUICallbacks(jobject netplay_session,
@@ -242,8 +289,48 @@ void NetPlayUICallbacks::OnConnectionError(const std::string& message)
env->DeleteLocalRef(netplay_session);
}
// No-op — all error info is captured by OnTraversalStateChanged which always fires alongside.
void NetPlayUICallbacks::OnTraversalError(Common::TraversalClient::FailureReason) {}
void NetPlayUICallbacks::OnTraversalStateChanged(Common::TraversalClient::State) {}
void NetPlayUICallbacks::OnTraversalStateChanged(Common::TraversalClient::State state)
{
JNIEnv* env = IDCache::GetEnvForThread();
jobject netplay_session = GetNetplaySessionLocalRef(env);
if (!netplay_session)
return;
jstring host_code = nullptr;
jstring external_address = nullptr;
jstring failure_reason = nullptr;
if (Common::g_TraversalClient)
{
if (state == Common::TraversalClient::State::Connected)
{
const auto host_id = Common::g_TraversalClient->GetHostID();
host_code = ToJString(env, std::string(host_id.begin(), host_id.end()));
external_address =
ToJString(env, InetAddressToString(Common::g_TraversalClient->GetExternalAddress()));
}
else if (state == Common::TraversalClient::State::Failure)
{
failure_reason =
ToJString(env, FailureReasonToString(Common::g_TraversalClient->GetFailureReason()));
}
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnTraversalStateChanged(),
static_cast<jint>(state), host_code, external_address, failure_reason);
}
if (host_code)
env->DeleteLocalRef(host_code);
if (external_address)
env->DeleteLocalRef(external_address);
if (failure_reason)
env->DeleteLocalRef(failure_reason);
env->DeleteLocalRef(netplay_session);
}
void NetPlayUICallbacks::OnGameStartAborted() {}
void NetPlayUICallbacks::OnGolferChanged(bool, const std::string&) {}
void NetPlayUICallbacks::OnTtlDetermined(u8) {}

View File

@@ -8,6 +8,7 @@
#include <jni.h>
#include "Common/CommonTypes.h"
#include "Common/TraversalClient.h"
#include "Core/NetPlayCommon.h"
#include "Core/Boot/Boot.h"
#include "Core/Config/NetplaySettings.h"
@@ -194,6 +195,14 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeGetExternal
return ToJString(env, ip);
}
JNIEXPORT void JNICALL
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReconnectTraversal(JNIEnv*,
jobject)
{
if (Common::g_TraversalClient)
Common::g_TraversalClient->ReconnectToServer();
}
JNIEXPORT void JNICALL
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseUICallbacks(JNIEnv*,
jobject,