summaryrefslogtreecommitdiff
path: root/Source/Android/app/src
diff options
context:
space:
mode:
authorTom Pratt <tom.pratt@outlook.com>2026-04-09 12:40:07 +0100
committerTom Pratt <tompratt@squareup.com>2026-05-19 12:02:54 +0200
commitf52ca02ca24f06697564441730f86a03a8f3e7fe (patch)
treef784885127e110f2212ccbb7c52c0326034539ff /Source/Android/app/src
parent3ed91742080194630bac072f4dbc9bdbffc94049 (diff)
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
Diffstat (limited to 'Source/Android/app/src')
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/Netplay.kt49
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionRole.kt12
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplaySetupViewModel.kt44
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupActivity.kt61
-rw-r--r--Source/Android/app/src/main/res/values/strings.xml4
5 files changed, 150 insertions, 20 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<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
}
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<ConnectionRole>
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<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
+ }
}
}
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<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",
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 -->
<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>