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
This commit is contained in:
Tom Pratt 2026-04-09 12:40:07 +01:00
parent 12343ebf86
commit b2e900ce40
9 changed files with 184 additions and 21 deletions

View File

@ -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<String>(Channel.CONFLATED)
val launchGame = _launchGame.receiveAsFlow()
private val _connectionErrors = Channel<String>(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<String>.flush() {
while (this.tryReceive().isSuccess) Unit
}

View File

@ -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<ConnectionRole>

View File

@ -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<Unit>(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
}
}
}

View File

@ -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<Unit>()
}
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<String>,
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<ErrorDialogState?>(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",

View File

@ -984,12 +984,14 @@ It can efficiently compress both junk data and encrypted Wii data.
<!-- Netplay -->
<string name="netplay_setup_title">Netplay Setup</string>
<string name="netplay_nickname_label">Nickname</string>
<string name="netplay_connection_type">Connection type</string>
<string name="netplay_connection_type_direct_connection">Direct connection</string>
<string name="netplay_connection_type_traversal_server">Traversal server</string>
<string name="netplay_connection_role_connect">Connect</string>
<string name="netplay_connection_role_connect_loading">Connecting…</string>
<string name="netplay_connection_role_host">Host</string>
<string name="netplay_connection_role_host_loading">Starting…</string>
<string name="netplay_nickname_label">Nickname</string>
<string name="netplay_ip_address_label">IP address</string>
<string name="netplay_host_code_label">Host code</string>
<string name="netplay_port_label">Port</string>

View File

@ -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");

View File

@ -35,6 +35,7 @@ jclass GetNetplayClass();
jfieldID GetNetPlayClientPointer();
jfieldID GetNetplayBootSessionDataPointer();
jmethodID GetNetplayOnBootGame();
jmethodID GetNetplayOnConnectionError();
jclass GetPairClass();
jmethodID GetPairConstructor();

View File

@ -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() {}

View File

@ -17,6 +17,12 @@
#include "jni/AndroidCommon/IDCache.h"
#include "jni/NetPlay/NetPlayUICallbacks.h"
static NetPlay::NetPlayClient* GetPointer(JNIEnv* env)
{
return reinterpret_cast<NetPlay::NetPlayClient*>(
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<u16>(listenPort));
}
JNIEXPORT jboolean JNICALL
Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_isClientConnected(JNIEnv* env, jclass)
{
return static_cast<jboolean>(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<jlong>(client);
}
JNIEXPORT void JNICALL
Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_ReleaseNetplayClient(JNIEnv* env, jclass)
{
delete GetPointer(env);
}
} // extern "C"