From b2e900ce407b2430f2dfe2e635dff583e2e7a432 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Thu, 9 Apr 2026 12:40:07 +0100 Subject: [PATCH] Show client connection errors and handle connection result If result is a success sent event to launch the next netplay screen. if it fails, clear up the netplay client --- .../dolphinemu/features/netplay/Netplay.kt | 49 ++++++++++++++- .../features/netplay/model/ConnectionRole.kt | 12 +++- .../netplay/model/NetplaySetupViewModel.kt | 44 +++++++++---- .../netplay/ui/NetplaySetupActivity.kt | 61 ++++++++++++++++++- .../app/src/main/res/values/strings.xml | 4 +- Source/Android/jni/AndroidCommon/IDCache.cpp | 7 +++ Source/Android/jni/AndroidCommon/IDCache.h | 1 + .../jni/NetPlay/NetPlayUICallbacks.cpp | 9 ++- Source/Android/jni/NetPlay/Netplay.cpp | 18 ++++++ 9 files changed, 184 insertions(+), 21 deletions(-) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/Netplay.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/Netplay.kt index 6f8532a15c..fcad9ddb3f 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/Netplay.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/Netplay.kt @@ -4,8 +4,11 @@ package org.dolphinemu.dolphinemu.features.netplay import androidx.annotation.Keep +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType object Netplay { @@ -21,6 +24,12 @@ object Netplay { private val _launchGame = Channel(Channel.CONFLATED) val launchGame = _launchGame.receiveAsFlow() + private val _connectionErrors = Channel(Channel.BUFFERED) + val connectionErrors = _connectionErrors.receiveAsFlow() + + @JvmStatic + external fun isClientConnected(): Boolean + @JvmStatic external fun getNickname(): String @@ -63,13 +72,13 @@ object Netplay { @JvmStatic external fun getIndexPassword(): String - fun saveSetup( + suspend fun saveSetup( nickname: String, connectionType: ConnectionType, address: String, hostCode: String, connectPort: Int, - ) { + ) = withContext(Dispatchers.IO) { SaveSetup( nickname = nickname, traversalChoice = connectionType.configValue, @@ -108,16 +117,50 @@ object Netplay { indexPassword: String, ) - fun join() { + suspend fun join(): Boolean = withContext(Dispatchers.IO) { netPlayClientPointer = Join() + val isConnected = netPlayClientPointer != 0L && isClientConnected() + + if (!isActive) { + releaseNetplayClient() + return@withContext false + } + + if (isConnected) { + return@withContext true + } + + releaseNetplayClient() + false + } + + private fun releaseNetplayClient() { + if (netPlayClientPointer != 0L) { + ReleaseNetplayClient() + netPlayClientPointer = 0 + } + _launchGame.flush() + _connectionErrors.flush() } @JvmStatic private external fun Join(): Long + @JvmStatic + private external fun ReleaseNetplayClient() + @JvmStatic fun onBootGame(gameFilePath: String, bootSessionDataPointer: Long) { this.bootSessionDataPointer = bootSessionDataPointer _launchGame.trySend(gameFilePath) } + + @JvmStatic + fun onConnectionError(message: String) { + _connectionErrors.trySend(message) + } +} + +private fun Channel.flush() { + while (this.tryReceive().isSuccess) Unit } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionRole.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionRole.kt index a54d082566..a9f86d5982 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionRole.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionRole.kt @@ -5,9 +5,17 @@ import org.dolphinemu.dolphinemu.R sealed class ConnectionRole( @StringRes val labelId: Int, + @StringRes val loadingLabelId: Int, ) { - object Connect : ConnectionRole(R.string.netplay_connection_role_connect) - object Host : ConnectionRole(R.string.netplay_connection_role_host) + object Connect : ConnectionRole( + labelId = R.string.netplay_connection_role_connect, + loadingLabelId = R.string.netplay_connection_role_connect_loading, + ) + + object Host : ConnectionRole( + labelId = R.string.netplay_connection_role_host, + loadingLabelId = R.string.netplay_connection_role_host_loading, + ) companion object { val all: List diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplaySetupViewModel.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplaySetupViewModel.kt index 5ede907e85..19175e4df8 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplaySetupViewModel.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplaySetupViewModel.kt @@ -3,8 +3,15 @@ package org.dolphinemu.dolphinemu.features.netplay.model import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.CONFLATED import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch import org.dolphinemu.dolphinemu.features.netplay.Netplay import org.dolphinemu.dolphinemu.services.GameFileCacheManager @@ -27,6 +34,14 @@ class NetplaySetupViewModel : ViewModel() { private val _connectPort = MutableStateFlow(Netplay.getConnectPort().toString()) val connectPort = _connectPort.asStateFlow() + private val _showNetplayScreen = Channel(CONFLATED) + val showNetplayScreen = _showNetplayScreen.receiveAsFlow() + + private val _connecting = MutableStateFlow(false) + val connecting = _connecting.asStateFlow() + + val errors = Netplay.connectionErrors + init { GameFileCacheManager.startLoad() } @@ -60,17 +75,24 @@ class NetplaySetupViewModel : ViewModel() { } fun connect() { - if (GameFileCacheManager.isLoading().value == true) { - return - } + _connecting.value = true - Netplay.saveSetup( - nickname = nickname.value, - connectionType = connectionType.value, - address = ipAddress.value, - hostCode = hostCode.value, - connectPort = connectPort.value.toInt(), - ) - Netplay.join() + viewModelScope.launch { + GameFileCacheManager.isLoading().asFlow().first { it == false } + + Netplay.saveSetup( + nickname = nickname.value, + connectionType = connectionType.value, + address = ipAddress.value, + hostCode = hostCode.value, + connectPort = connectPort.value.toInt(), + ) + + if (Netplay.join()) { + _showNetplayScreen.trySend(Unit) + } + + _connecting.value = false + } } } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupActivity.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupActivity.kt index ca1a892901..5032546cb6 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupActivity.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupActivity.kt @@ -11,19 +11,24 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -35,7 +40,9 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -45,8 +52,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.dolphinemu.dolphinemu.R @@ -60,6 +71,10 @@ import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer import org.dolphinemu.dolphinemu.utils.ThemeHelper +private data class ErrorDialogState(val message: String) { + val onDismissed = CompletableDeferred() +} + class NetplaySetupActivity : AppCompatActivity(), ThemeProvider { override var themeId: Int = 0 private lateinit var viewModel: NetplaySetupViewModel @@ -75,10 +90,16 @@ class NetplaySetupActivity : AppCompatActivity(), ThemeProvider { viewModel = ViewModelProvider(this)[NetplaySetupViewModel::class.java] + viewModel.showNetplayScreen + .onEach { /* launch NetplayActivity */ } + .launchIn(lifecycleScope) + setContent { DolphinTheme { NetplaySetupScreen( onBackClicked = { finish() }, + connecting = viewModel.connecting.collectAsState().value, + errors = viewModel.errors, nickname = viewModel.nickname.collectAsState().value, onNicknameChanged = viewModel::setNickname, connectionType = viewModel.connectionType.collectAsState().value, @@ -118,6 +139,8 @@ class NetplaySetupActivity : AppCompatActivity(), ThemeProvider { @Composable private fun NetplaySetupScreen( onBackClicked: () -> Unit, + connecting: Boolean, + errors: Flow, connectionRole: ConnectionRole, onConnectionRoleChanged: (ConnectionRole) -> Unit, nickname: String, @@ -150,10 +173,40 @@ private fun NetplaySetupScreen( ExtendedFloatingActionButton( onClick = onConnectClicked, ) { - Text(stringResource(connectionRole.labelId)) + if (connecting) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Spacer(Modifier.width(12.dp)) + Text(stringResource(connectionRole.loadingLabelId)) + } else { + Text(stringResource(connectionRole.labelId)) + } } } ) { innerPadding -> + var activeErrorDialog by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + errors.collect { message -> + activeErrorDialog = ErrorDialogState(message) + activeErrorDialog?.onDismissed?.await() + activeErrorDialog = null + } + } + activeErrorDialog?.let { activeErrorDialog -> + AlertDialog( + text = { Text(activeErrorDialog.message) }, + confirmButton = { + TextButton(onClick = { activeErrorDialog.onDismissed.complete(Unit) }) { + Text("Dismiss") + } + }, + onDismissRequest = { activeErrorDialog.onDismissed.complete(Unit) }, + ) + } + Column( modifier = Modifier .fillMaxSize() @@ -332,12 +385,14 @@ private fun NetplaySetupScreenPreview() { MaterialTheme { NetplaySetupScreen( onBackClicked = {}, + connecting = false, + errors = emptyFlow(), + connectionRole = ConnectionRole.Connect, + onConnectionRoleChanged = {}, nickname = "Preview nickname", onNicknameChanged = {}, connectionType = ConnectionType.DirectConnection, onConnectionTypeChanged = {}, - connectionRole = ConnectionRole.Connect, - onConnectionRoleChanged = {}, ipAddress = "127.0.0.1", onIpAddressChanged = {}, connectPort = "2626", diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 78c9c3b34c..1944cc4911 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -984,12 +984,14 @@ It can efficiently compress both junk data and encrypted Wii data. Netplay Setup - Nickname Connection type Direct connection Traversal server Connect + Connecting… Host + Starting… + Nickname IP address Host code Port diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index 9c41869f5b..8f74425efa 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -32,6 +32,7 @@ static jclass s_netplay_class; static jfieldID s_net_play_client_pointer; static jfieldID s_netplay_boot_session_data_pointer; static jmethodID s_netplay_on_boot_game; +static jmethodID s_netplay_on_connection_error; static jclass s_analytics_class; static jmethodID s_get_analytics_value; @@ -258,6 +259,11 @@ jmethodID GetNetplayOnBootGame() return s_netplay_on_boot_game; } +jmethodID GetNetplayOnConnectionError() +{ + return s_netplay_on_connection_error; +} + jclass GetPairClass() { return s_pair_class; @@ -668,6 +674,7 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) s_net_play_client_pointer = env->GetStaticFieldID(netplay_class, "netPlayClientPointer", "J"); s_netplay_boot_session_data_pointer = env->GetStaticFieldID(netplay_class, "bootSessionDataPointer", "J"); s_netplay_on_boot_game = env->GetStaticMethodID(netplay_class, "onBootGame", "(Ljava/lang/String;J)V"); + s_netplay_on_connection_error = env->GetStaticMethodID(netplay_class, "onConnectionError", "(Ljava/lang/String;)V"); env->DeleteLocalRef(netplay_class); const jclass analytics_class = env->FindClass("org/dolphinemu/dolphinemu/utils/Analytics"); diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h index 1879ff2afb..d59d1ca9f0 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -35,6 +35,7 @@ jclass GetNetplayClass(); jfieldID GetNetPlayClientPointer(); jfieldID GetNetplayBootSessionDataPointer(); jmethodID GetNetplayOnBootGame(); +jmethodID GetNetplayOnConnectionError(); jclass GetPairClass(); jmethodID GetPairConstructor(); diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index 27e7d36ee6..baa38de18d 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -56,7 +56,14 @@ void NetPlayUICallbacks::OnPadBufferChanged(u32) {} void NetPlayUICallbacks::OnHostInputAuthorityChanged(bool) {} void NetPlayUICallbacks::OnDesync(u32, const std::string&) {} void NetPlayUICallbacks::OnConnectionLost() {} -void NetPlayUICallbacks::OnConnectionError(const std::string&) {} + +void NetPlayUICallbacks::OnConnectionError(const std::string& message) +{ + JNIEnv* env = IDCache::GetEnvForThread(); + env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayOnConnectionError(), + ToJString(env, message)); +} + void NetPlayUICallbacks::OnTraversalError(Common::TraversalClient::FailureReason) {} void NetPlayUICallbacks::OnTraversalStateChanged(Common::TraversalClient::State) {} void NetPlayUICallbacks::OnGameStartAborted() {} diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index 2346b685e0..c5fdd21ad5 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -17,6 +17,12 @@ #include "jni/AndroidCommon/IDCache.h" #include "jni/NetPlay/NetPlayUICallbacks.h" +static NetPlay::NetPlayClient* GetPointer(JNIEnv* env) +{ + return reinterpret_cast( + env->GetStaticLongField(IDCache::GetNetplayClass(), IDCache::GetNetPlayClientPointer())); +} + extern "C" { JNIEXPORT jstring JNICALL @@ -124,6 +130,12 @@ Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_SaveSetup( Config::SetBaseOrCurrent(Config::NETPLAY_LISTEN_PORT, static_cast(listenPort)); } +JNIEXPORT jboolean JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_isClientConnected(JNIEnv* env, jclass) +{ + return static_cast(GetPointer(env)->IsConnected()); +} + JNIEXPORT jlong JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_Join(JNIEnv* env, jclass) { @@ -155,4 +167,10 @@ Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_Join(JNIEnv* env, jclass return reinterpret_cast(client); } +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_ReleaseNetplayClient(JNIEnv* env, jclass) +{ + delete GetPointer(env); +} + } // extern "C"