From 86de5ef4b0bd210aab15051a0e9a4098c5ecaa2a Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 5 May 2026 18:50:04 +0200 Subject: [PATCH] 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. --- .../features/netplay/NetplaySession.kt | 38 +++++++- .../features/netplay/model/JoinInfo.kt | 1 + .../netplay/model/NetplayViewModel.kt | 51 +++++++++-- .../features/netplay/model/TraversalState.kt | 18 ++++ .../features/netplay/ui/NetplayActivity.kt | 1 + .../features/netplay/ui/NetplayScreen.kt | 34 ++++++- .../app/src/main/res/values/strings.xml | 8 +- Source/Android/jni/AndroidCommon/IDCache.cpp | 9 ++ Source/Android/jni/AndroidCommon/IDCache.h | 1 + .../jni/NetPlay/NetPlayUICallbacks.cpp | 89 ++++++++++++++++++- Source/Android/jni/NetPlay/Netplay.cpp | 9 ++ 11 files changed, 247 insertions(+), 12 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/TraversalState.kt diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt index 8af9cf9806..d8f2ce8860 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt @@ -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(null) val gameDigestProgress = _gameDigestProgress.asStateFlow() + private val _traversalState = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val traversalState = _traversalState.asSharedFlow() + + private val _fatalTraversalError = Channel(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 Channel.flush() { diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/JoinInfo.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/JoinInfo.kt index ba350a3caf..7403f06f32 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/JoinInfo.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/JoinInfo.kt @@ -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), } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt index 923cfc314d..4ebea7e283 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt @@ -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 } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/TraversalState.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/TraversalState.kt new file mode 100644 index 0000000000..9b574e4f63 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/TraversalState.kt @@ -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 + } + } +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt index a9bf0309a5..e45ba7e7c1 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt @@ -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, diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt index eeba0f1541..71b427a8c8 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt @@ -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, + fatalTraversalError: Flow, messages: List, 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(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, diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 20c78eddc5..fbc664f4ef 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1018,8 +1018,12 @@ It can efficiently compress both junk data and encrypted Wii data. Forward port (UPnP) Join info Address - External IP - Local IP + Code + Room + External + Local + Couldn\'t look up central server + Dolphin is too old for traversal server Loading… Unknown Share address diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index 41d7055471..db05bda5d5 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -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 = diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h index 51a5ae02bc..064b37df44 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -52,6 +52,7 @@ jmethodID GetNetplayOnShowGameDigestDialog(); jmethodID GetNetplayOnSetGameDigestProgress(); jmethodID GetNetplayOnSetGameDigestResult(); jmethodID GetNetplayOnAbortGameDigest(); +jmethodID GetNetplayOnTraversalStateChanged(); jclass GetNetplayPlayerClass(); jmethodID GetNetplayPlayerConstructor(); diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index ecbf168fb5..ce9336ff15 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -1,15 +1,62 @@ // Copyright 2003 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include + #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(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(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) {} diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index 9192404042..8ed026c9c8 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -8,6 +8,7 @@ #include #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,