From 86de5ef4b0bd210aab15051a0e9a4098c5ecaa2a Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 5 May 2026 18:50:04 +0200 Subject: 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. --- .../dolphinemu/features/netplay/NetplaySession.kt | 38 +++++++++++++++- .../dolphinemu/features/netplay/model/JoinInfo.kt | 1 + .../features/netplay/model/NetplayViewModel.kt | 51 +++++++++++++++++++--- .../features/netplay/model/TraversalState.kt | 18 ++++++++ .../features/netplay/ui/NetplayActivity.kt | 1 + .../features/netplay/ui/NetplayScreen.kt | 34 ++++++++++++++- 6 files changed, 134 insertions(+), 9 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/TraversalState.kt (limited to 'Source/Android/app/src/main/java') 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, -- cgit v1.2.3