diff options
| author | Tom Pratt <tom.pratt@outlook.com> | 2026-05-05 18:50:04 +0200 |
|---|---|---|
| committer | Tom Pratt <tompratt@squareup.com> | 2026-05-19 12:03:00 +0200 |
| commit | 86de5ef4b0bd210aab15051a0e9a4098c5ecaa2a (patch) | |
| tree | b65e4cfe5fa08ce02689abb899a35478fcd97ff3 /Source/Android | |
| parent | 86a0f228876a101dfb3eafb08a56138eaa4b7e68 (diff) | |
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.
Diffstat (limited to 'Source/Android')
11 files changed, 247 insertions, 12 deletions
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<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() { 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<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, 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. <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> 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 <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) {} 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 <jni.h> #include "Common/CommonTypes.h" +#include "Common/TraversalClient.h" #include "Core/NetPlayCommon.h" #include "Core/Boot/Boot.h" #include "Core/Config/NetplaySettings.h" @@ -195,6 +196,14 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeGetExternal } 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, jlong pointer) |
