summaryrefslogtreecommitdiff
path: root/Source/Android/app/src/main/java
diff options
context:
space:
mode:
authorTom Pratt <tom.pratt@outlook.com>2026-05-05 18:50:04 +0200
committerTom Pratt <tompratt@squareup.com>2026-05-19 12:03:00 +0200
commit86de5ef4b0bd210aab15051a0e9a4098c5ecaa2a (patch)
treeb65e4cfe5fa08ce02689abb899a35478fcd97ff3 /Source/Android/app/src/main/java
parent86a0f228876a101dfb3eafb08a56138eaa4b7e68 (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/app/src/main/java')
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt38
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/JoinInfo.kt1
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt51
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/TraversalState.kt18
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt1
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt34
6 files changed, 134 insertions, 9 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,