From ec8253ebff2928b950f4e14d993efe9741271e74 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Mon, 6 Apr 2026 13:32:57 +0100 Subject: [PATCH 01/37] Add compose dependencies for Android and empty NetplaySetupActivity Derive compose colour theming from the existing XML styles already set at the activity level. --- Source/Android/app/build.gradle.kts | 11 ++ .../Android/app/src/main/AndroidManifest.xml | 5 + .../netplay/ui/NetplaySetupActivity.kt | 107 ++++++++++++++++++ .../dolphinemu/ui/main/MainPresenter.kt | 6 + .../dolphinemu/ui/theme/DolphinTheme.kt | 103 +++++++++++++++++ .../app/src/main/res/menu/menu_game_grid.xml | 5 + .../app/src/main/res/values/strings.xml | 5 + Source/Android/build.gradle.kts | 1 + Source/Android/gradle/libs.versions.toml | 9 ++ 9 files changed, 252 insertions(+) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupActivity.kt create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt diff --git a/Source/Android/app/build.gradle.kts b/Source/Android/app/build.gradle.kts index 0a952a0704..82196ff97b 100644 --- a/Source/Android/app/build.gradle.kts +++ b/Source/Android/app/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.androidx.baselineprofile) } @@ -10,6 +11,7 @@ android { ndkVersion = "29.0.14206865" buildFeatures { + compose = true viewBinding = true buildConfig = true resValues = true @@ -150,6 +152,15 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.filepicker) + + // Jetpack Compose + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material.icons) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.tooling.preview) } fun getGitVersion(): String { diff --git a/Source/Android/app/src/main/AndroidManifest.xml b/Source/Android/app/src/main/AndroidManifest.xml index 3218ed3ed8..e68fa9f757 100644 --- a/Source/Android/app/src/main/AndroidManifest.xml +++ b/Source/Android/app/src/main/AndroidManifest.xml @@ -133,6 +133,11 @@ android:label="@string/user_data_submenu" android:theme="@style/Theme.Dolphin.Main" /> + + Unit, +) { + Scaffold( + topBar = { + MediumTopAppBar( + title = { Text(stringResource(R.string.netplay_setup_title)) }, + navigationIcon = { + IconButton(onClick = onBackClicked) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .consumeWindowInsets(innerPadding) + .padding(innerPadding) + .padding(horizontal = DolphinTheme.scaffoldPadding) + .verticalScroll(rememberScrollState()), + ) { + } + } +} + +@Preview +@Composable +private fun NetplaySetupScreenPreview() { + MaterialTheme { + NetplayScreen(onBackClicked = {}) + } +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.kt index 893d6a16d4..1e1d55dbd7 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.kt @@ -21,6 +21,7 @@ import org.dolphinemu.dolphinemu.features.settings.ui.MenuTag import org.dolphinemu.dolphinemu.features.sysupdate.ui.SystemMenuNotInstalledDialogFragment import org.dolphinemu.dolphinemu.features.sysupdate.ui.SystemUpdateProgressBarDialogFragment import org.dolphinemu.dolphinemu.features.sysupdate.ui.SystemUpdateViewModel +import org.dolphinemu.dolphinemu.features.netplay.ui.NetplaySetupActivity import org.dolphinemu.dolphinemu.fragments.AboutDialogFragment import org.dolphinemu.dolphinemu.model.GameFileCache import org.dolphinemu.dolphinemu.services.GameFileCacheManager @@ -188,6 +189,11 @@ class MainPresenter(private val mainView: MainView, private val activity: Fragme true } + R.id.menu_netplay -> { + NetplaySetupActivity.launch(activity) + true + } + R.id.menu_about -> { showAboutDialog() false diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt new file mode 100644 index 0000000000..58eb52b347 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.ui.theme + +import android.content.Context +import androidx.annotation.AttrRes +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.google.android.material.color.MaterialColors +import androidx.appcompat.R as AppCompatR +import com.google.android.material.R as MaterialR + +object DolphinTheme { + val scaffoldPadding = 16.dp +} + +@Composable +fun DolphinTheme(content: @Composable () -> Unit) { + val context = LocalContext.current + val isDark = isSystemInDarkTheme() + val colorScheme = remember(context, isDark) { context.toDolphinColorScheme(isDark) } + + MaterialTheme( + colorScheme = colorScheme, + content = content + ) +} + +private fun Context.toDolphinColorScheme(isDark: Boolean): ColorScheme { + fun attr(@AttrRes attr: Int) = Color(MaterialColors.getColor(this, attr, 0)) + + val background = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground)).use { + Color(it.getColor(0, 0)) + } + + return if (isDark) { + darkColorScheme( + primary = attr(AppCompatR.attr.colorPrimary), + onPrimary = attr(MaterialR.attr.colorOnPrimary), + primaryContainer = attr(MaterialR.attr.colorPrimaryContainer), + onPrimaryContainer = attr(MaterialR.attr.colorOnPrimaryContainer), + secondary = attr(MaterialR.attr.colorSecondary), + onSecondary = attr(MaterialR.attr.colorOnSecondary), + secondaryContainer = attr(MaterialR.attr.colorSecondaryContainer), + onSecondaryContainer = attr(MaterialR.attr.colorOnSecondaryContainer), + tertiary = attr(MaterialR.attr.colorTertiary), + onTertiary = attr(MaterialR.attr.colorOnTertiary), + tertiaryContainer = attr(MaterialR.attr.colorTertiaryContainer), + onTertiaryContainer = attr(MaterialR.attr.colorOnTertiaryContainer), + error = attr(AppCompatR.attr.colorError), + onError = attr(MaterialR.attr.colorOnError), + errorContainer = attr(MaterialR.attr.colorErrorContainer), + onErrorContainer = attr(MaterialR.attr.colorOnErrorContainer), + background = background, + onBackground = attr(MaterialR.attr.colorOnBackground), + surface = attr(MaterialR.attr.colorSurface), + onSurface = attr(MaterialR.attr.colorOnSurface), + surfaceVariant = attr(MaterialR.attr.colorSurfaceVariant), + onSurfaceVariant = attr(MaterialR.attr.colorOnSurfaceVariant), + outline = attr(MaterialR.attr.colorOutline), + inverseSurface = attr(MaterialR.attr.colorSurfaceInverse), + inverseOnSurface = attr(MaterialR.attr.colorOnSurfaceInverse), + inversePrimary = attr(MaterialR.attr.colorPrimaryInverse), + ) + } else { + lightColorScheme( + primary = attr(AppCompatR.attr.colorPrimary), + onPrimary = attr(MaterialR.attr.colorOnPrimary), + primaryContainer = attr(MaterialR.attr.colorPrimaryContainer), + onPrimaryContainer = attr(MaterialR.attr.colorOnPrimaryContainer), + secondary = attr(MaterialR.attr.colorSecondary), + onSecondary = attr(MaterialR.attr.colorOnSecondary), + secondaryContainer = attr(MaterialR.attr.colorSecondaryContainer), + onSecondaryContainer = attr(MaterialR.attr.colorOnSecondaryContainer), + tertiary = attr(MaterialR.attr.colorTertiary), + onTertiary = attr(MaterialR.attr.colorOnTertiary), + tertiaryContainer = attr(MaterialR.attr.colorTertiaryContainer), + onTertiaryContainer = attr(MaterialR.attr.colorOnTertiaryContainer), + error = attr(AppCompatR.attr.colorError), + onError = attr(MaterialR.attr.colorOnError), + errorContainer = attr(MaterialR.attr.colorErrorContainer), + onErrorContainer = attr(MaterialR.attr.colorOnErrorContainer), + background = background, + onBackground = attr(MaterialR.attr.colorOnBackground), + surface = attr(MaterialR.attr.colorSurface), + onSurface = attr(MaterialR.attr.colorOnSurface), + surfaceVariant = attr(MaterialR.attr.colorSurfaceVariant), + onSurfaceVariant = attr(MaterialR.attr.colorOnSurfaceVariant), + outline = attr(MaterialR.attr.colorOutline), + inverseSurface = attr(MaterialR.attr.colorSurfaceInverse), + inverseOnSurface = attr(MaterialR.attr.colorOnSurfaceInverse), + inversePrimary = attr(MaterialR.attr.colorPrimaryInverse), + ) + } +} diff --git a/Source/Android/app/src/main/res/menu/menu_game_grid.xml b/Source/Android/app/src/main/res/menu/menu_game_grid.xml index 2a282198d1..c52c574c44 100644 --- a/Source/Android/app/src/main/res/menu/menu_game_grid.xml +++ b/Source/Android/app/src/main/res/menu/menu_game_grid.xml @@ -51,6 +51,11 @@ android:title="@string/grid_menu_online_system_update" app:showAsAction="never"/> + + Import Wii Save Import BootMii NAND Backup Perform Online System Update + Netplay Load Wii System Menu Load Wii System Menu (%s) Load vWii System Menu (%s) @@ -980,4 +981,8 @@ It can efficiently compress both junk data and encrypted Wii data. Log Out Logging In Login Failed + + + Netplay Setup + Nickname diff --git a/Source/Android/build.gradle.kts b/Source/Android/build.gradle.kts index 5682b2f130..c74d069b44 100644 --- a/Source/Android/build.gradle.kts +++ b/Source/Android/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.android.test) apply false alias(libs.plugins.androidx.baselineprofile) apply false + alias(libs.plugins.kotlin.compose) apply false } buildscript { diff --git a/Source/Android/gradle/libs.versions.toml b/Source/Android/gradle/libs.versions.toml index 09fa7b09ca..e29e0c2e10 100644 --- a/Source/Android/gradle/libs.versions.toml +++ b/Source/Android/gradle/libs.versions.toml @@ -4,6 +4,7 @@ appcompat = "1.7.1" benchmarkMacroJunit4 = "1.5.0-alpha04" cardview = "1.0.0" coil = "2.7.0" +compose-bom = "2025.04.00" constraintlayout = "2.2.1" coreKtx = "1.18.0" coreSplashscreen = "1.2.0" @@ -27,9 +28,16 @@ tvprovider = "1.1.0" uiautomator = "2.3.0" [libraries] +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" } androidx-cardview = { group = "androidx.cardview", name = "cardview", version.ref = "cardview" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } +androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" } @@ -58,4 +66,5 @@ android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } android-test = { id = "com.android.test", version.ref = "agp" } androidx-baselineprofile = { id = "androidx.baselineprofile", version.ref = "benchmarkMacroJunit4" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } From 23f5f02c11dbd41cc77011eb281115e9927a9811 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 15 Apr 2026 17:33:09 +0200 Subject: [PATCH 02/37] Netplay setup UI Only for connecting, no hosting yet. --- Source/Android/app/build.gradle.kts | 6 + .../features/netplay/model/ConnectionRole.kt | 16 ++ .../features/netplay/model/ConnectionType.kt | 21 ++ .../netplay/model/NetplaySetupViewModel.kt | 58 +++++ .../netplay/ui/NetplaySetupActivity.kt | 240 +++++++++++++++++- .../dolphinemu/ui/theme/DolphinTheme.kt | 6 + .../app/src/main/res/values/strings.xml | 8 + 7 files changed, 352 insertions(+), 3 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionRole.kt create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplaySetupViewModel.kt diff --git a/Source/Android/app/build.gradle.kts b/Source/Android/app/build.gradle.kts index 82196ff97b..912906d2d8 100644 --- a/Source/Android/app/build.gradle.kts +++ b/Source/Android/app/build.gradle.kts @@ -118,6 +118,12 @@ android { } } +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xannotation-default-target=param-property") + } +} + dependencies { baselineProfile(project(":benchmark")) coreLibraryDesugaring(libs.desugar.jdk.libs) 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 new file mode 100644 index 0000000000..a54d082566 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionRole.kt @@ -0,0 +1,16 @@ +package org.dolphinemu.dolphinemu.features.netplay.model + +import androidx.annotation.StringRes +import org.dolphinemu.dolphinemu.R + +sealed class ConnectionRole( + @StringRes val labelId: Int, +) { + object Connect : ConnectionRole(R.string.netplay_connection_role_connect) + object Host : ConnectionRole(R.string.netplay_connection_role_host) + + companion object { + val all: List + get() = listOf(Connect, Host) + } +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt new file mode 100644 index 0000000000..aed03f89cc --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt @@ -0,0 +1,21 @@ +package org.dolphinemu.dolphinemu.features.netplay.model + +import androidx.annotation.StringRes +import org.dolphinemu.dolphinemu.R + +sealed class ConnectionType( + @StringRes val labelId: Int, +) { + object DirectConnection : ConnectionType( + labelId = R.string.netplay_connection_type_direct_connection, + ) + + object TraversalServer : ConnectionType( + labelId = R.string.netplay_connection_type_traversal_server, + ) + + companion object { + val all: List + get() = listOf(DirectConnection, TraversalServer) + } +} 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 new file mode 100644 index 0000000000..8d0dd05c79 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplaySetupViewModel.kt @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.features.netplay.model + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class NetplaySetupViewModel : ViewModel() { + private val _connectionRole = MutableStateFlow(ConnectionRole.Connect) + val connectionRole = _connectionRole.asStateFlow() + + private val _nickname = MutableStateFlow("") + val nickname = _nickname.asStateFlow() + + private val _connectionType = MutableStateFlow(ConnectionType.DirectConnection) + val connectionType = _connectionType.asStateFlow() + + private val _ipAddress = MutableStateFlow("") + val ipAddress = _ipAddress.asStateFlow() + + private val _hostCode = MutableStateFlow("") + val hostCode = _hostCode.asStateFlow() + + private val _connectPort = MutableStateFlow(0.toString()) + val connectPort = _connectPort.asStateFlow() + + fun setConnectionRole(connectionRole: ConnectionRole) { + _connectionRole.value = connectionRole + } + + fun setNickname(nickname: String) { + _nickname.value = nickname + } + + fun setConnectionType(connectionType: ConnectionType) { + _connectionType.value = connectionType + } + + fun setIpAddress(ipAddress: String) { + if (ipAddress.all { it.isDigit() || it == '.' }) { + _ipAddress.value = ipAddress + } + } + + fun setHostCode(hostCode: String) { + _hostCode.value = hostCode + } + + fun setConnectPort(port: String) { + if (port.all { it.isDigit() }) { + _connectPort.value = port + } + } + + fun connect() { + } +} 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 f79584c484..71d83ddf81 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 @@ -12,39 +12,77 @@ import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.Column 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.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.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue 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.lifecycle.ViewModelProvider import org.dolphinemu.dolphinemu.R +import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionRole +import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType +import org.dolphinemu.dolphinemu.features.netplay.model.NetplaySetupViewModel import org.dolphinemu.dolphinemu.ui.main.ThemeProvider import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme +import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer import org.dolphinemu.dolphinemu.utils.ThemeHelper class NetplaySetupActivity : AppCompatActivity(), ThemeProvider { override var themeId: Int = 0 + private lateinit var viewModel: NetplaySetupViewModel override fun onCreate(savedInstanceState: Bundle?) { ThemeHelper.setTheme(this) enableEdgeToEdge() super.onCreate(savedInstanceState) + viewModel = ViewModelProvider(this)[NetplaySetupViewModel::class.java] + setContent { DolphinTheme { NetplaySetupScreen( onBackClicked = { finish() }, + nickname = viewModel.nickname.collectAsState().value, + onNicknameChanged = viewModel::setNickname, + connectionType = viewModel.connectionType.collectAsState().value, + onConnectionTypeChanged = viewModel::setConnectionType, + connectionRole = viewModel.connectionRole.collectAsState().value, + onConnectionRoleChanged = viewModel::setConnectionRole, + ipAddress = viewModel.ipAddress.collectAsState().value, + onIpAddressChanged = viewModel::setIpAddress, + connectPort = viewModel.connectPort.collectAsState().value, + onConnectPortChanged = viewModel::setConnectPort, + hostCode = viewModel.hostCode.collectAsState().value, + onHostCodeChanged = viewModel::setHostCode, + onConnectClicked = viewModel::connect, ) } } @@ -71,6 +109,19 @@ class NetplaySetupActivity : AppCompatActivity(), ThemeProvider { @Composable private fun NetplaySetupScreen( onBackClicked: () -> Unit, + connectionRole: ConnectionRole, + onConnectionRoleChanged: (ConnectionRole) -> Unit, + nickname: String, + onNicknameChanged: (String) -> Unit, + connectionType: ConnectionType, + onConnectionTypeChanged: (ConnectionType) -> Unit, + ipAddress: String, + onIpAddressChanged: (String) -> Unit, + connectPort: String, + onConnectPortChanged: (String) -> Unit, + hostCode: String, + onHostCodeChanged: (String) -> Unit, + onConnectClicked: () -> Unit, ) { Scaffold( topBar = { @@ -85,23 +136,206 @@ private fun NetplaySetupScreen( } }, ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = onConnectClicked, + ) { + Text(stringResource(connectionRole.labelId)) + } } ) { innerPadding -> Column( modifier = Modifier + .fillMaxSize() .consumeWindowInsets(innerPadding) + .verticalScroll(rememberScrollState()) .padding(innerPadding) - .padding(horizontal = DolphinTheme.scaffoldPadding) - .verticalScroll(rememberScrollState()), ) { + SecondaryTabRow(selectedTabIndex = ConnectionRole.all.indexOf(connectionRole)) { + ConnectionRole.all.forEach { role -> + Tab( + selected = connectionRole == role, + onClick = { onConnectionRoleChanged(role) }, + text = { Text(stringResource(role.labelId)) }, + ) + } + } + + MenuSpacer() + + Column( + modifier = Modifier + .padding(horizontal = DolphinTheme.scaffoldPadding) + ) { + NetplaySetupContent( + nickname = nickname, + onNicknameChanged = onNicknameChanged, + connectionType = connectionType, + onConnectionTypeChanged = onConnectionTypeChanged, + ipAddress = ipAddress, + onIpAddressChanged = onIpAddressChanged, + hostCode = hostCode, + onHostCodeChanged = onHostCodeChanged, + connectPort = connectPort, + onConnectPortChanged = onConnectPortChanged, + connectionRole = connectionRole, + ) + } } } } +@Composable +private fun NetplaySetupContent( + nickname: String, + onNicknameChanged: (String) -> Unit, + connectionType: ConnectionType, + onConnectionTypeChanged: (ConnectionType) -> Unit, + ipAddress: String, + onIpAddressChanged: (String) -> Unit, + hostCode: String, + onHostCodeChanged: (String) -> Unit, + connectPort: String, + onConnectPortChanged: (String) -> Unit, + connectionRole: ConnectionRole, +) { + OutlinedTextField( + value = nickname, + onValueChange = onNicknameChanged, + label = { Text(stringResource(R.string.netplay_nickname_label)) }, + modifier = Modifier.fillMaxWidth() + ) + + MenuSpacer() + + ConnectionTypePicker( + connectionType = connectionType, + onConnectionTypeChanged = onConnectionTypeChanged, + ) + + MenuSpacer() + + when (connectionRole) { + ConnectionRole.Connect -> ConnectMenu( + connectionType = connectionType, + ipAddress = ipAddress, + onIpAddressChanged = onIpAddressChanged, + hostCode = hostCode, + onHostCodeChanged = onHostCodeChanged, + port = connectPort, + onPortChanged = onConnectPortChanged, + ) + + ConnectionRole.Host -> {} + } +} + +@Composable +private fun ConnectionTypePicker( + connectionType: ConnectionType, + onConnectionTypeChanged: (ConnectionType) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + ConnectionType.all.forEach { connectionType -> + DropdownMenuItem( + text = { Text(stringResource(connectionType.labelId)) }, + onClick = { + onConnectionTypeChanged(connectionType) + expanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + OutlinedTextField( + value = stringResource(connectionType.labelId), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.netplay_connection_type)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth() + ) + } +} + +@Composable +fun ConnectMenu( + connectionType: ConnectionType, + ipAddress: String, + onIpAddressChanged: (String) -> Unit, + hostCode: String, + onHostCodeChanged: (String) -> Unit, + port: String, + onPortChanged: (String) -> Unit, +) { + when (connectionType) { + ConnectionType.DirectConnection -> { + OutlinedTextField( + value = ipAddress, + onValueChange = onIpAddressChanged, + label = { Text(stringResource(R.string.netplay_ip_address_label)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + ), + modifier = Modifier + .fillMaxWidth() + ) + + MenuSpacer() + + OutlinedTextField( + value = port, + onValueChange = onPortChanged, + label = { Text(stringResource(R.string.netplay_port_label)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + ), + modifier = Modifier + .fillMaxWidth() + ) + } + + ConnectionType.TraversalServer -> OutlinedTextField( + value = hostCode, + onValueChange = onHostCodeChanged, + label = { Text(stringResource(R.string.netplay_host_code_label)) }, + modifier = Modifier + .fillMaxWidth() + ) + } +} + @Preview @Composable private fun NetplaySetupScreenPreview() { MaterialTheme { - NetplayScreen(onBackClicked = {}) + NetplaySetupScreen( + onBackClicked = {}, + nickname = "Preview nickname", + onNicknameChanged = {}, + connectionType = ConnectionType.DirectConnection, + onConnectionTypeChanged = {}, + connectionRole = ConnectionRole.Connect, + onConnectionRoleChanged = {}, + ipAddress = "127.0.0.1", + onIpAddressChanged = {}, + connectPort = "2626", + onConnectPortChanged = {}, + hostCode = "", + onHostCodeChanged = {}, + onConnectClicked = {}, + ) } } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt index 58eb52b347..24b0f99c0a 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt @@ -5,12 +5,15 @@ package org.dolphinemu.dolphinemu.ui.theme import android.content.Context import androidx.annotation.AttrRes import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -101,3 +104,6 @@ private fun Context.toDolphinColorScheme(isDark: Boolean): ColorScheme { ) } } + +@Composable +fun MenuSpacer() = Spacer(modifier = Modifier.height(16.dp)) diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 712d4744cc..78c9c3b34c 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -985,4 +985,12 @@ It can efficiently compress both junk data and encrypted Wii data. Netplay Setup Nickname + Connection type + Direct connection + Traversal server + Connect + Host + IP address + Host code + Port From 00941050c73f12f542c175cdea7a2f67a1a0071c Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 8 Apr 2026 13:33:42 +0100 Subject: [PATCH 03/37] Add Netplay settings JNI layer and wire up NetplaySetupViewModel --- .../dolphinemu/features/netplay/Netplay.kt | 96 ++++++++++++++ .../features/netplay/model/ConnectionType.kt | 3 + .../netplay/model/NetplaySetupViewModel.kt | 18 ++- Source/Android/jni/CMakeLists.txt | 1 + Source/Android/jni/NetPlay/Netplay.cpp | 120 ++++++++++++++++++ 5 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/Netplay.kt create mode 100644 Source/Android/jni/NetPlay/Netplay.cpp 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 new file mode 100644 index 0000000000..ace7277a9a --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/Netplay.kt @@ -0,0 +1,96 @@ +// Copyright 2003 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.features.netplay + +import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType + +object Netplay { + @JvmStatic + external fun getNickname(): String + + fun getConnectionType(): ConnectionType = ConnectionType.all + .find { it.configValue == getTraversalChoice() } ?: throw IllegalStateException() + + @JvmStatic + external fun getTraversalChoice(): String + + @JvmStatic + external fun getAddress(): String + + @JvmStatic + external fun getHostCode(): String + + @JvmStatic + external fun getConnectPort(): Int + + @JvmStatic + external fun getHostPort(): Int + + @JvmStatic + external fun getUseUpnp(): Boolean + + @JvmStatic + external fun getEnableChunkedUploadLimit(): Boolean + + @JvmStatic + external fun getChunkedUploadLimit(): Int + + @JvmStatic + external fun getUseIndex(): Boolean + + @JvmStatic + external fun getIndexRegion(): String + + @JvmStatic + external fun getIndexName(): String + + @JvmStatic + external fun getIndexPassword(): String + + fun saveSetup( + nickname: String, + connectionType: ConnectionType, + address: String, + hostCode: String, + connectPort: Int, + ) { + SaveSetup( + nickname = nickname, + traversalChoice = connectionType.configValue, + address = address, + hostCode = hostCode, + connectPort = connectPort, + hostPort = 2626, + useUpnp = false, + useListenPort = false, + listenPort = 2626, + enableChunkedUploadLimit = false, + chunkedUploadLimit = 3000, + useIndex = false, + indexRegion = "", + indexName = "", + indexPassword = "", + ) + } + + @JvmStatic + external fun SaveSetup( + nickname: String, + traversalChoice: String, + address: String, + hostCode: String, + connectPort: Int, + hostPort: Int, + useUpnp: Boolean, + useListenPort: Boolean, + listenPort: Int, + enableChunkedUploadLimit: Boolean, + chunkedUploadLimit: Int, + useIndex: Boolean, + indexRegion: String, + indexName: String, + indexPassword: String, + ) + +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt index aed03f89cc..f6b3f0eca1 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt @@ -5,13 +5,16 @@ import org.dolphinemu.dolphinemu.R sealed class ConnectionType( @StringRes val labelId: Int, + val configValue: String, ) { object DirectConnection : ConnectionType( labelId = R.string.netplay_connection_type_direct_connection, + configValue = "direct", ) object TraversalServer : ConnectionType( labelId = R.string.netplay_connection_type_traversal_server, + configValue = "traversal", ) companion object { 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 8d0dd05c79..ecf0b2cff2 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 @@ -5,24 +5,25 @@ package org.dolphinemu.dolphinemu.features.netplay.model import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import org.dolphinemu.dolphinemu.features.netplay.Netplay class NetplaySetupViewModel : ViewModel() { private val _connectionRole = MutableStateFlow(ConnectionRole.Connect) val connectionRole = _connectionRole.asStateFlow() - private val _nickname = MutableStateFlow("") + private val _nickname = MutableStateFlow(Netplay.getNickname()) val nickname = _nickname.asStateFlow() - private val _connectionType = MutableStateFlow(ConnectionType.DirectConnection) + private val _connectionType = MutableStateFlow(Netplay.getConnectionType()) val connectionType = _connectionType.asStateFlow() - private val _ipAddress = MutableStateFlow("") + private val _ipAddress = MutableStateFlow(Netplay.getAddress()) val ipAddress = _ipAddress.asStateFlow() - private val _hostCode = MutableStateFlow("") + private val _hostCode = MutableStateFlow(Netplay.getHostCode()) val hostCode = _hostCode.asStateFlow() - private val _connectPort = MutableStateFlow(0.toString()) + private val _connectPort = MutableStateFlow(Netplay.getConnectPort().toString()) val connectPort = _connectPort.asStateFlow() fun setConnectionRole(connectionRole: ConnectionRole) { @@ -54,5 +55,12 @@ class NetplaySetupViewModel : ViewModel() { } fun connect() { + Netplay.saveSetup( + nickname = nickname.value, + connectionType = connectionType.value, + address = ipAddress.value, + hostCode = hostCode.value, + connectPort = connectPort.value.toInt(), + ) } } diff --git a/Source/Android/jni/CMakeLists.txt b/Source/Android/jni/CMakeLists.txt index a15eb5c636..a71668a758 100644 --- a/Source/Android/jni/CMakeLists.txt +++ b/Source/Android/jni/CMakeLists.txt @@ -30,6 +30,7 @@ add_library(main SHARED Input/MappingCommon.cpp Input/NumericSetting.cpp Input/NumericSetting.h + NetPlay/Netplay.cpp MainAndroid.cpp RiivolutionPatches.cpp SkylanderConfig.cpp diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp new file mode 100644 index 0000000000..245a24852c --- /dev/null +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -0,0 +1,120 @@ +// Copyright 2003 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include + +#include + +#include "Common/CommonTypes.h" +#include "Core/Config/NetplaySettings.h" + +#include "jni/AndroidCommon/AndroidCommon.h" + +extern "C" { + +JNIEXPORT jstring JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getNickname(JNIEnv* env, jclass) +{ + return ToJString(env, Config::Get(Config::NETPLAY_NICKNAME)); +} + +JNIEXPORT jstring JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getTraversalChoice(JNIEnv* env, jclass) +{ + return ToJString(env, Config::Get(Config::NETPLAY_TRAVERSAL_CHOICE)); +} + +JNIEXPORT jstring JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getAddress(JNIEnv* env, jclass) +{ + return ToJString(env, Config::Get(Config::NETPLAY_ADDRESS)); +} + +JNIEXPORT jstring JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getHostCode(JNIEnv* env, jclass) +{ + return ToJString(env, Config::Get(Config::NETPLAY_HOST_CODE)); +} + +JNIEXPORT jint JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getConnectPort(JNIEnv*, jclass) +{ + return static_cast(Config::Get(Config::NETPLAY_CONNECT_PORT)); +} + +JNIEXPORT jint JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getHostPort(JNIEnv*, jclass) +{ + return static_cast(Config::Get(Config::NETPLAY_HOST_PORT)); +} + +JNIEXPORT jboolean JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getUseUpnp(JNIEnv*, jclass) +{ + return static_cast(Config::Get(Config::NETPLAY_USE_UPNP)); +} + +JNIEXPORT jboolean JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getEnableChunkedUploadLimit(JNIEnv*, jclass) +{ + return static_cast(Config::Get(Config::NETPLAY_ENABLE_CHUNKED_UPLOAD_LIMIT)); +} + +JNIEXPORT jint JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getChunkedUploadLimit(JNIEnv*, jclass) +{ + return static_cast(Config::Get(Config::NETPLAY_CHUNKED_UPLOAD_LIMIT)); +} + +JNIEXPORT jboolean JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getUseIndex(JNIEnv*, jclass) +{ + return static_cast(Config::Get(Config::NETPLAY_USE_INDEX)); +} + +JNIEXPORT jstring JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getIndexRegion(JNIEnv* env, jclass) +{ + return ToJString(env, Config::Get(Config::NETPLAY_INDEX_REGION)); +} + +JNIEXPORT jstring JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getIndexName(JNIEnv* env, jclass) +{ + return ToJString(env, Config::Get(Config::NETPLAY_INDEX_NAME)); +} + +JNIEXPORT jstring JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getIndexPassword(JNIEnv* env, jclass) +{ + return ToJString(env, Config::Get(Config::NETPLAY_INDEX_PASSWORD)); +} + +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_SaveSetup( + JNIEnv* env, jclass, jstring jnickname, jstring traversalChoice, jstring jaddress, + jstring jhostCode, jint connectPort, jint hostPort, jboolean useUpnp, jboolean useListenPort, + jint listenPort, jboolean enableChunkedUploadLimit, jint chunkedUploadLimit, jboolean useIndex, + jstring jindexRegion, jstring jindexName, jstring jindexPassword) +{ + Config::ConfigChangeCallbackGuard config_guard; + + Config::SetBaseOrCurrent(Config::NETPLAY_NICKNAME, GetJString(env, jnickname)); + Config::SetBaseOrCurrent(Config::NETPLAY_TRAVERSAL_CHOICE, GetJString(env, traversalChoice)); + Config::SetBaseOrCurrent(Config::NETPLAY_ADDRESS, GetJString(env, jaddress)); + Config::SetBaseOrCurrent(Config::NETPLAY_HOST_CODE, GetJString(env, jhostCode)); + Config::SetBaseOrCurrent(Config::NETPLAY_CONNECT_PORT, static_cast(connectPort)); + Config::SetBaseOrCurrent(Config::NETPLAY_HOST_PORT, static_cast(hostPort)); + Config::SetBaseOrCurrent(Config::NETPLAY_USE_UPNP, static_cast(useUpnp)); + Config::SetBaseOrCurrent(Config::NETPLAY_ENABLE_CHUNKED_UPLOAD_LIMIT, + static_cast(enableChunkedUploadLimit)); + Config::SetBaseOrCurrent(Config::NETPLAY_CHUNKED_UPLOAD_LIMIT, + static_cast(chunkedUploadLimit)); + Config::SetBaseOrCurrent(Config::NETPLAY_USE_INDEX, static_cast(useIndex)); + Config::SetBaseOrCurrent(Config::NETPLAY_INDEX_REGION, GetJString(env, jindexRegion)); + Config::SetBaseOrCurrent(Config::NETPLAY_INDEX_NAME, GetJString(env, jindexName)); + Config::SetBaseOrCurrent(Config::NETPLAY_INDEX_PASSWORD, GetJString(env, jindexPassword)); + Config::SetBaseOrCurrent(Config::NETPLAY_LISTEN_PORT, static_cast(listenPort)); +} + +} // extern "C" From 2839a5d11b03a05bf5cab2238837ea2647b3ad80 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 6 May 2026 13:35:34 +0200 Subject: [PATCH 04/37] Add NetPlayClient join and stub NetPlayUICallbacks --- .../dolphinemu/features/netplay/Netplay.kt | 10 ++++ .../netplay/model/NetplaySetupViewModel.kt | 1 + Source/Android/jni/CMakeLists.txt | 2 + .../jni/NetPlay/NetPlayUICallbacks.cpp | 53 +++++++++++++++++ .../Android/jni/NetPlay/NetPlayUICallbacks.h | 57 +++++++++++++++++++ Source/Android/jni/NetPlay/Netplay.cpp | 25 ++++++++ 6 files changed, 148 insertions(+) create mode 100644 Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp create mode 100644 Source/Android/jni/NetPlay/NetPlayUICallbacks.h 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 ace7277a9a..aaaca2b4fb 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 @@ -3,9 +3,13 @@ package org.dolphinemu.dolphinemu.features.netplay +import androidx.annotation.Keep import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType object Netplay { + @Keep + private var netPlayClientPointer: Long = 0 + @JvmStatic external fun getNickname(): String @@ -93,4 +97,10 @@ object Netplay { indexPassword: String, ) + fun join() { + netPlayClientPointer = Join() + } + + @JvmStatic + private external fun Join(): Long } 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 ecf0b2cff2..34cee1e5bf 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 @@ -62,5 +62,6 @@ class NetplaySetupViewModel : ViewModel() { hostCode = hostCode.value, connectPort = connectPort.value.toInt(), ) + Netplay.join() } } diff --git a/Source/Android/jni/CMakeLists.txt b/Source/Android/jni/CMakeLists.txt index a71668a758..a3a741a433 100644 --- a/Source/Android/jni/CMakeLists.txt +++ b/Source/Android/jni/CMakeLists.txt @@ -31,6 +31,8 @@ add_library(main SHARED Input/NumericSetting.cpp Input/NumericSetting.h NetPlay/Netplay.cpp + NetPlay/NetPlayUICallbacks.cpp + NetPlay/NetPlayUICallbacks.h MainAndroid.cpp RiivolutionPatches.cpp SkylanderConfig.cpp diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp new file mode 100644 index 0000000000..ab56458eff --- /dev/null +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -0,0 +1,53 @@ +// Copyright 2003 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "jni/NetPlay/NetPlayUICallbacks.h" + +namespace NetPlay { + +NetPlayUICallbacks::NetPlayUICallbacks() = default; +NetPlayUICallbacks::~NetPlayUICallbacks() = default; + +void NetPlayUICallbacks::BootGame(const std::string&, std::unique_ptr) {} +void NetPlayUICallbacks::StopGame() {} +bool NetPlayUICallbacks::IsHosting() const { return false; } +void NetPlayUICallbacks::Update() {} +void NetPlayUICallbacks::AppendChat(const std::string&) {} +void NetPlayUICallbacks::OnMsgChangeGame(const NetPlay::SyncIdentifier&, const std::string&) {} +void NetPlayUICallbacks::OnMsgChangeGBARom(int, const NetPlay::GBAConfig&) {} +void NetPlayUICallbacks::OnMsgStartGame() {} +void NetPlayUICallbacks::OnMsgStopGame() {} +void NetPlayUICallbacks::OnMsgPowerButton() {} +void NetPlayUICallbacks::OnPlayerConnect(const std::string&) {} +void NetPlayUICallbacks::OnPlayerDisconnect(const std::string&) {} +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::OnTraversalError(Common::TraversalClient::FailureReason) {} +void NetPlayUICallbacks::OnTraversalStateChanged(Common::TraversalClient::State) {} +void NetPlayUICallbacks::OnGameStartAborted() {} +void NetPlayUICallbacks::OnGolferChanged(bool, const std::string&) {} +void NetPlayUICallbacks::OnTtlDetermined(u8) {} +void NetPlayUICallbacks::OnIndexAdded(bool, std::string) {} +void NetPlayUICallbacks::OnIndexRefreshFailed(std::string) {} +bool NetPlayUICallbacks::IsRecording() { return false; } + +std::shared_ptr +NetPlayUICallbacks::FindGameFile(const NetPlay::SyncIdentifier&, NetPlay::SyncIdentifierComparison*) +{ + return nullptr; +} + +std::string NetPlayUICallbacks::FindGBARomPath(const std::array&, std::string_view, int) { return {}; } +void NetPlayUICallbacks::ShowGameDigestDialog(const std::string&) {} +void NetPlayUICallbacks::SetGameDigestProgress(int, int) {} +void NetPlayUICallbacks::SetGameDigestResult(int, const std::string&) {} +void NetPlayUICallbacks::AbortGameDigest() {} +void NetPlayUICallbacks::ShowChunkedProgressDialog(const std::string&, u64, std::span) {} +void NetPlayUICallbacks::HideChunkedProgressDialog() {} +void NetPlayUICallbacks::SetChunkedProgress(int, u64) {} +void NetPlayUICallbacks::SetHostWiiSyncData(std::vector, std::string) {} + +} // namespace NetPlay diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.h b/Source/Android/jni/NetPlay/NetPlayUICallbacks.h new file mode 100644 index 0000000000..18fdf1df93 --- /dev/null +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.h @@ -0,0 +1,57 @@ +#pragma once + +#include + +#include "Core/NetPlayClient.h" + +namespace NetPlay { + +class NetPlayUICallbacks : public NetPlay::NetPlayUI { +public: + NetPlayUICallbacks(); + ~NetPlayUICallbacks() override; + + void BootGame(const std::string& filename, + std::unique_ptr boot_session_data) override; + void StopGame() override; + bool IsHosting() const override; + void Update() override; + void AppendChat(const std::string& msg) override; + void OnMsgChangeGame(const NetPlay::SyncIdentifier& sync_identifier, + const std::string& netplay_name) override; + void OnMsgChangeGBARom(int pad, const NetPlay::GBAConfig& config) override; + void OnMsgStartGame() override; + void OnMsgStopGame() override; + void OnMsgPowerButton() override; + void OnPlayerConnect(const std::string& player) override; + void OnPlayerDisconnect(const std::string& player) override; + void OnPadBufferChanged(u32 buffer) override; + void OnHostInputAuthorityChanged(bool enabled) override; + void OnDesync(u32 frame, const std::string& player) override; + void OnConnectionLost() override; + void OnConnectionError(const std::string& message) override; + void OnTraversalError(Common::TraversalClient::FailureReason error) override; + void OnTraversalStateChanged(Common::TraversalClient::State state) override; + void OnGameStartAborted() override; + void OnGolferChanged(bool is_golfer, const std::string& golfer_name) override; + void OnTtlDetermined(u8 ttl) override; + void OnIndexAdded(bool success, std::string error) override; + void OnIndexRefreshFailed(std::string error) override; + bool IsRecording() override; + std::shared_ptr + FindGameFile(const NetPlay::SyncIdentifier& sync_identifier, + NetPlay::SyncIdentifierComparison* found = nullptr) override; + std::string FindGBARomPath(const std::array& hash, std::string_view title, + int device_number) override; + void ShowGameDigestDialog(const std::string& title) override; + void SetGameDigestProgress(int pid, int progress) override; + void SetGameDigestResult(int pid, const std::string& result) override; + void AbortGameDigest() override; + void ShowChunkedProgressDialog(const std::string& title, u64 data_size, + std::span players) override; + void HideChunkedProgressDialog() override; + void SetChunkedProgress(int pid, u64 progress) override; + void SetHostWiiSyncData(std::vector titles, std::string redirect_folder) override; +}; + +} // namespace NetPlay diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index 245a24852c..42ed6483ce 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -1,14 +1,17 @@ // Copyright 2003 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include #include #include #include "Common/CommonTypes.h" #include "Core/Config/NetplaySettings.h" +#include "Core/NetPlayClient.h" #include "jni/AndroidCommon/AndroidCommon.h" +#include "jni/NetPlay/NetPlayUICallbacks.h" extern "C" { @@ -117,4 +120,26 @@ Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_SaveSetup( Config::SetBaseOrCurrent(Config::NETPLAY_LISTEN_PORT, static_cast(listenPort)); } +JNIEXPORT jlong JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_Join(JNIEnv*, jclass) +{ + const std::string traversal_choice = Config::Get(Config::NETPLAY_TRAVERSAL_CHOICE); + const bool is_traversal = traversal_choice == "traversal"; + + std::string host_ip; + host_ip = is_traversal ? Config::Get(Config::NETPLAY_HOST_CODE) : + Config::Get(Config::NETPLAY_ADDRESS); + + const u16 host_port = Config::Get(Config::NETPLAY_CONNECT_PORT); + const std::string traversal_host = Config::Get(Config::NETPLAY_TRAVERSAL_SERVER); + const u16 traversal_port = Config::Get(Config::NETPLAY_TRAVERSAL_PORT); + const std::string nickname = Config::Get(Config::NETPLAY_NICKNAME); + + auto* client = new NetPlay::NetPlayClient( + host_ip, host_port, new NetPlay::NetPlayUICallbacks(), nickname, + NetPlay::NetTraversalConfig{is_traversal, traversal_host, traversal_port}); + + return reinterpret_cast(client); +} + } // extern "C" From 01c8c4aee20bdf5bb4d82889dcad617aa857bc1c Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 6 May 2026 13:44:53 +0200 Subject: [PATCH 05/37] Pass game list to NetPlayUICallbacks and implement OnMsgChangeGame, OnMsgStartGame, FindGameFile --- .../netplay/model/NetplaySetupViewModel.kt | 9 ++++ Source/Android/jni/AndroidCommon/IDCache.cpp | 44 ++++++++++++++++ Source/Android/jni/AndroidCommon/IDCache.h | 6 +++ .../jni/NetPlay/NetPlayUICallbacks.cpp | 52 +++++++++++++++++-- .../Android/jni/NetPlay/NetPlayUICallbacks.h | 11 +++- Source/Android/jni/NetPlay/Netplay.cpp | 17 +++++- 6 files changed, 131 insertions(+), 8 deletions(-) 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 34cee1e5bf..5ede907e85 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 @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import org.dolphinemu.dolphinemu.features.netplay.Netplay +import org.dolphinemu.dolphinemu.services.GameFileCacheManager class NetplaySetupViewModel : ViewModel() { private val _connectionRole = MutableStateFlow(ConnectionRole.Connect) @@ -26,6 +27,10 @@ class NetplaySetupViewModel : ViewModel() { private val _connectPort = MutableStateFlow(Netplay.getConnectPort().toString()) val connectPort = _connectPort.asStateFlow() + init { + GameFileCacheManager.startLoad() + } + fun setConnectionRole(connectionRole: ConnectionRole) { _connectionRole.value = connectionRole } @@ -55,6 +60,10 @@ class NetplaySetupViewModel : ViewModel() { } fun connect() { + if (GameFileCacheManager.isLoading().value == true) { + return + } + Netplay.saveSetup( nickname = nickname.value, connectionType = connectionType.value, diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index 18b486023a..1cd6e257e6 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -25,6 +25,13 @@ static jmethodID s_game_file_constructor; static jclass s_game_file_cache_class; static jfieldID s_game_file_cache_pointer; +static jclass s_game_file_cache_manager_class; +static jfieldID s_game_file_cache_manager_instance; + +static jclass s_netplay_class; +static jfieldID s_net_play_client_pointer; +static jmethodID s_netplay_on_msg_start_game; + static jclass s_analytics_class; static jmethodID s_get_analytics_value; @@ -220,6 +227,26 @@ jfieldID GetGameFileCachePointer() return s_game_file_cache_pointer; } +jclass GetGameFileCacheManagerClass() +{ + return s_game_file_cache_manager_class; +} + +jfieldID GetGameFileCacheManagerInstance() +{ + return s_game_file_cache_manager_instance; +} + +jclass GetNetplayClass() +{ + return s_netplay_class; +} + +jfieldID GetNetPlayClientPointer() +{ + return s_net_play_client_pointer; +} + jclass GetPairClass() { return s_pair_class; @@ -615,6 +642,21 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) s_game_file_cache_pointer = env->GetFieldID(game_file_cache_class, "pointer", "J"); env->DeleteLocalRef(game_file_cache_class); + const jclass game_file_cache_manager_class = + env->FindClass("org/dolphinemu/dolphinemu/services/GameFileCacheManager"); + s_game_file_cache_manager_class = + reinterpret_cast(env->NewGlobalRef(game_file_cache_manager_class)); + s_game_file_cache_manager_instance = env->GetStaticFieldID( + game_file_cache_manager_class, "gameFileCache", + "Lorg/dolphinemu/dolphinemu/model/GameFileCache;"); + env->DeleteLocalRef(game_file_cache_manager_class); + + const jclass netplay_class = + env->FindClass("org/dolphinemu/dolphinemu/features/netplay/Netplay"); + s_netplay_class = reinterpret_cast(env->NewGlobalRef(netplay_class)); + s_net_play_client_pointer = env->GetStaticFieldID(netplay_class, "netPlayClientPointer", "J"); + env->DeleteLocalRef(netplay_class); + const jclass analytics_class = env->FindClass("org/dolphinemu/dolphinemu/utils/Analytics"); s_analytics_class = reinterpret_cast(env->NewGlobalRef(analytics_class)); s_get_analytics_value = env->GetStaticMethodID(s_analytics_class, "getValue", @@ -828,6 +870,8 @@ JNIEXPORT void JNI_OnUnload(JavaVM* vm, void* reserved) env->DeleteGlobalRef(s_native_library_class); env->DeleteGlobalRef(s_game_file_class); env->DeleteGlobalRef(s_game_file_cache_class); + env->DeleteGlobalRef(s_game_file_cache_manager_class); + env->DeleteGlobalRef(s_netplay_class); env->DeleteGlobalRef(s_analytics_class); env->DeleteGlobalRef(s_pair_class); env->DeleteGlobalRef(s_hash_map_class); diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h index 0aaa9feec3..e13646eb5a 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -28,6 +28,12 @@ jmethodID GetGameFileConstructor(); jclass GetGameFileCacheClass(); jfieldID GetGameFileCachePointer(); +jclass GetGameFileCacheManagerClass(); +jfieldID GetGameFileCacheManagerInstance(); + +jclass GetNetplayClass(); +jfieldID GetNetPlayClientPointer(); + jclass GetPairClass(); jmethodID GetPairConstructor(); diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index ab56458eff..31bc977e2a 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -3,9 +3,15 @@ #include "jni/NetPlay/NetPlayUICallbacks.h" +#include "jni/AndroidCommon/IDCache.h" + namespace NetPlay { -NetPlayUICallbacks::NetPlayUICallbacks() = default; +NetPlayUICallbacks::NetPlayUICallbacks(std::vector> games) + : m_games(std::move(games)) +{ +} + NetPlayUICallbacks::~NetPlayUICallbacks() = default; void NetPlayUICallbacks::BootGame(const std::string&, std::unique_ptr) {} @@ -13,9 +19,28 @@ void NetPlayUICallbacks::StopGame() {} bool NetPlayUICallbacks::IsHosting() const { return false; } void NetPlayUICallbacks::Update() {} void NetPlayUICallbacks::AppendChat(const std::string&) {} -void NetPlayUICallbacks::OnMsgChangeGame(const NetPlay::SyncIdentifier&, const std::string&) {} + +void NetPlayUICallbacks::OnMsgChangeGame(const NetPlay::SyncIdentifier& sync_identifier, + const std::string& netplay_name) +{ + m_current_game_identifier = sync_identifier; + m_current_game_name = netplay_name; +} + void NetPlayUICallbacks::OnMsgChangeGBARom(int, const NetPlay::GBAConfig&) {} -void NetPlayUICallbacks::OnMsgStartGame() {} + +void NetPlayUICallbacks::OnMsgStartGame() +{ + JNIEnv* env = IDCache::GetEnvForThread(); + auto* client = reinterpret_cast( + env->GetStaticLongField(IDCache::GetNetplayClass(), IDCache::GetNetPlayClientPointer())); + if (client) + { + if (const auto game = FindGameFile(m_current_game_identifier)) + client->StartGame(game->GetFilePath()); + } +} + void NetPlayUICallbacks::OnMsgStopGame() {} void NetPlayUICallbacks::OnMsgPowerButton() {} void NetPlayUICallbacks::OnPlayerConnect(const std::string&) {} @@ -35,9 +60,26 @@ void NetPlayUICallbacks::OnIndexRefreshFailed(std::string) {} bool NetPlayUICallbacks::IsRecording() { return false; } std::shared_ptr -NetPlayUICallbacks::FindGameFile(const NetPlay::SyncIdentifier&, NetPlay::SyncIdentifierComparison*) +NetPlayUICallbacks::FindGameFile(const NetPlay::SyncIdentifier& sync_identifier, + NetPlay::SyncIdentifierComparison* found) { - return nullptr; + NetPlay::SyncIdentifierComparison temp; + if (!found) + found = &temp; + + *found = NetPlay::SyncIdentifierComparison::DifferentGame; + + std::shared_ptr result; + for (const auto& game : m_games) + { + const auto cmp = game->CompareSyncIdentifier(sync_identifier); + if (cmp < *found) + { + *found = cmp; + result = game; + } + } + return result; } std::string NetPlayUICallbacks::FindGBARomPath(const std::array&, std::string_view, int) { return {}; } diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.h b/Source/Android/jni/NetPlay/NetPlayUICallbacks.h index 18fdf1df93..acccfccabf 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.h +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.h @@ -1,14 +1,18 @@ #pragma once +#include #include +#include +#include #include "Core/NetPlayClient.h" +#include "UICommon/GameFile.h" namespace NetPlay { class NetPlayUICallbacks : public NetPlay::NetPlayUI { public: - NetPlayUICallbacks(); + NetPlayUICallbacks(std::vector> games); ~NetPlayUICallbacks() override; void BootGame(const std::string& filename, @@ -52,6 +56,11 @@ public: void HideChunkedProgressDialog() override; void SetChunkedProgress(int pid, u64 progress) override; void SetHostWiiSyncData(std::vector titles, std::string redirect_folder) override; + +private: + std::vector> m_games; + NetPlay::SyncIdentifier m_current_game_identifier; + std::string m_current_game_name; }; } // namespace NetPlay diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index 42ed6483ce..2346b685e0 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -3,14 +3,18 @@ #include #include +#include #include #include "Common/CommonTypes.h" #include "Core/Config/NetplaySettings.h" #include "Core/NetPlayClient.h" +#include "UICommon/GameFile.h" +#include "UICommon/GameFileCache.h" #include "jni/AndroidCommon/AndroidCommon.h" +#include "jni/AndroidCommon/IDCache.h" #include "jni/NetPlay/NetPlayUICallbacks.h" extern "C" { @@ -121,7 +125,7 @@ Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_SaveSetup( } JNIEXPORT jlong JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_Join(JNIEnv*, jclass) +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_Join(JNIEnv* env, jclass) { const std::string traversal_choice = Config::Get(Config::NETPLAY_TRAVERSAL_CHOICE); const bool is_traversal = traversal_choice == "traversal"; @@ -135,8 +139,17 @@ Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_Join(JNIEnv*, jclass) const u16 traversal_port = Config::Get(Config::NETPLAY_TRAVERSAL_PORT); const std::string nickname = Config::Get(Config::NETPLAY_NICKNAME); + jobject jgame_file_cache = env->GetStaticObjectField( + IDCache::GetGameFileCacheManagerClass(), IDCache::GetGameFileCacheManagerInstance()); + auto* game_file_cache = reinterpret_cast( + env->GetLongField(jgame_file_cache, IDCache::GetGameFileCachePointer())); + + std::vector> games; + game_file_cache->ForEach( + [&games](const std::shared_ptr& game) { games.push_back(game); }); + auto* client = new NetPlay::NetPlayClient( - host_ip, host_port, new NetPlay::NetPlayUICallbacks(), nickname, + host_ip, host_port, new NetPlay::NetPlayUICallbacks(std::move(games)), nickname, NetPlay::NetTraversalConfig{is_traversal, traversal_host, traversal_port}); return reinterpret_cast(client); From 12343ebf86350fdf7436bb6aa9ac37bacedf26c4 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 8 Apr 2026 15:20:19 +0100 Subject: [PATCH 06/37] Store netplay BootSessionData and use it to run the netplay game --- .../org/dolphinemu/dolphinemu/NativeLibrary.kt | 6 ++++++ .../dolphinemu/features/netplay/Netplay.kt | 17 +++++++++++++++++ .../features/netplay/ui/NetplaySetupActivity.kt | 9 +++++++++ .../dolphinemu/fragments/EmulationFragment.kt | 7 +++++++ Source/Android/jni/AndroidCommon/IDCache.cpp | 15 ++++++++++++++- Source/Android/jni/AndroidCommon/IDCache.h | 2 ++ Source/Android/jni/MainAndroid.cpp | 17 +++++++++++++++++ .../Android/jni/NetPlay/NetPlayUICallbacks.cpp | 13 ++++++++++--- 8 files changed, 82 insertions(+), 4 deletions(-) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/NativeLibrary.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/NativeLibrary.kt index a4d9bac35a..c5224ec3eb 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/NativeLibrary.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/NativeLibrary.kt @@ -342,6 +342,12 @@ object NativeLibrary { @JvmStatic external fun RunSystemMenu() + /** + * Begins emulation for a netplay session, using the BootSessionData provided by the host. + */ + @JvmStatic + external fun RunNetPlay(paths: Array, riivolution: Boolean) + @JvmStatic external fun ChangeDisc(path: String) 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 aaaca2b4fb..6f8532a15c 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,12 +4,23 @@ package org.dolphinemu.dolphinemu.features.netplay import androidx.annotation.Keep +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType object Netplay { @Keep private var netPlayClientPointer: Long = 0 + @Keep + private var bootSessionDataPointer: Long = 0 + + val isLaunching: Boolean + get() = bootSessionDataPointer != 0L + + private val _launchGame = Channel(Channel.CONFLATED) + val launchGame = _launchGame.receiveAsFlow() + @JvmStatic external fun getNickname(): String @@ -103,4 +114,10 @@ object Netplay { @JvmStatic private external fun Join(): Long + + @JvmStatic + fun onBootGame(gameFilePath: String, bootSessionDataPointer: Long) { + this.bootSessionDataPointer = bootSessionDataPointer + _launchGame.trySend(gameFilePath) + } } 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 71d83ddf81..ca1a892901 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 @@ -46,7 +46,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import org.dolphinemu.dolphinemu.R +import org.dolphinemu.dolphinemu.activities.EmulationActivity +import org.dolphinemu.dolphinemu.features.netplay.Netplay import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionRole import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType import org.dolphinemu.dolphinemu.features.netplay.model.NetplaySetupViewModel @@ -64,6 +69,10 @@ class NetplaySetupActivity : AppCompatActivity(), ThemeProvider { enableEdgeToEdge() super.onCreate(savedInstanceState) + Netplay.launchGame + .onEach { EmulationActivity.launch(this, it, false) } + .launchIn(lifecycleScope) + viewModel = ViewModelProvider(this)[NetplaySetupViewModel::class.java] setContent { diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt index e4698ba43c..4ee4aee8aa 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt @@ -13,6 +13,7 @@ import androidx.fragment.app.Fragment import org.dolphinemu.dolphinemu.NativeLibrary import org.dolphinemu.dolphinemu.activities.EmulationActivity import org.dolphinemu.dolphinemu.databinding.FragmentEmulationBinding +import org.dolphinemu.dolphinemu.features.netplay.Netplay import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting import org.dolphinemu.dolphinemu.features.settings.model.Settings import org.dolphinemu.dolphinemu.overlay.InputOverlay @@ -214,6 +215,12 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { if (launchSystemMenu) { Log.debug("[EmulationFragment] Starting emulation thread for the Wii Menu.") NativeLibrary.RunSystemMenu() + } else if (Netplay.isLaunching) { + Log.debug("[EmulationFragment] Starting emulation thread for Netplay.") + val paths = requireNotNull(gamePaths) { + "Cannot start emulation without any game paths" + } + NativeLibrary.RunNetPlay(paths, riivolution) } else { Log.debug("[EmulationFragment] Starting emulation thread.") val paths = requireNotNull(gamePaths) { diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index 1cd6e257e6..9c41869f5b 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -30,7 +30,8 @@ static jfieldID s_game_file_cache_manager_instance; static jclass s_netplay_class; static jfieldID s_net_play_client_pointer; -static jmethodID s_netplay_on_msg_start_game; +static jfieldID s_netplay_boot_session_data_pointer; +static jmethodID s_netplay_on_boot_game; static jclass s_analytics_class; static jmethodID s_get_analytics_value; @@ -247,6 +248,16 @@ jfieldID GetNetPlayClientPointer() return s_net_play_client_pointer; } +jfieldID GetNetplayBootSessionDataPointer() +{ + return s_netplay_boot_session_data_pointer; +} + +jmethodID GetNetplayOnBootGame() +{ + return s_netplay_on_boot_game; +} + jclass GetPairClass() { return s_pair_class; @@ -655,6 +666,8 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) env->FindClass("org/dolphinemu/dolphinemu/features/netplay/Netplay"); s_netplay_class = reinterpret_cast(env->NewGlobalRef(netplay_class)); 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"); 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 e13646eb5a..1879ff2afb 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -33,6 +33,8 @@ jfieldID GetGameFileCacheManagerInstance(); jclass GetNetplayClass(); jfieldID GetNetPlayClientPointer(); +jfieldID GetNetplayBootSessionDataPointer(); +jmethodID GetNetplayOnBootGame(); jclass GetPairClass(); jmethodID GetPairConstructor(); diff --git a/Source/Android/jni/MainAndroid.cpp b/Source/Android/jni/MainAndroid.cpp index 3855cb4c18..b638bf260a 100644 --- a/Source/Android/jni/MainAndroid.cpp +++ b/Source/Android/jni/MainAndroid.cpp @@ -35,6 +35,7 @@ #include "Core/AchievementManager.h" #include "Core/Boot/Boot.h" +#include "jni/NetPlay/NetPlayUICallbacks.h" #include "Core/BootManager.h" #include "Core/CommonTitles.h" #include "Core/ConfigLoaders/GameConfigLoader.h" @@ -614,6 +615,22 @@ Java_org_dolphinemu_dolphinemu_NativeLibrary_Run___3Ljava_lang_String_2ZLjava_la BootSessionData(GetJString(env, jSavestate), delete_state)); } +JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_NativeLibrary_RunNetPlay( + JNIEnv* env, jclass, jobjectArray jPaths, jboolean jRiivolution) +{ + auto boot_session_data = std::unique_ptr(reinterpret_cast( + env->GetStaticLongField(IDCache::GetNetplayClass(), IDCache::GetNetplayBootSessionDataPointer()))); + if (!boot_session_data) + { + env->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(), IDCache::GetDisplayToastMsg(), + ToJString(env, "Netplay: no boot session data"), JNI_TRUE); + env->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(), IDCache::GetFinishEmulationActivity()); + return; + } + env->SetStaticLongField(IDCache::GetNetplayClass(), IDCache::GetNetplayBootSessionDataPointer(), 0); + Run(env, JStringArrayToVector(env, jPaths), jRiivolution, std::move(*boot_session_data)); +} + JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_NativeLibrary_RunSystemMenu(JNIEnv* env, jclass) { diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index 31bc977e2a..27e7d36ee6 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -1,8 +1,10 @@ // Copyright 2003 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later -#include "jni/NetPlay/NetPlayUICallbacks.h" - +#include "UICommon/GameFile.h" +#include "NetPlayUICallbacks.h" +#include "Core/Boot/Boot.h" +#include "jni/AndroidCommon/AndroidCommon.h" #include "jni/AndroidCommon/IDCache.h" namespace NetPlay { @@ -14,7 +16,12 @@ NetPlayUICallbacks::NetPlayUICallbacks(std::vector) {} +void NetPlayUICallbacks::BootGame(const std::string& filename, std::unique_ptr boot_session_data) { + JNIEnv* env = IDCache::GetEnvForThread(); + env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayOnBootGame(), + ToJString(env, filename), reinterpret_cast(boot_session_data.release())); +} + void NetPlayUICallbacks::StopGame() {} bool NetPlayUICallbacks::IsHosting() const { return false; } void NetPlayUICallbacks::Update() {} From b2e900ce407b2430f2dfe2e635dff583e2e7a432 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Thu, 9 Apr 2026 12:40:07 +0100 Subject: [PATCH 07/37] 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" From 43d592c912a135ca3453c81e75ac298e17e67e06 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 15 Apr 2026 18:40:24 +0200 Subject: [PATCH 08/37] Move NetplaySetupScreen to its own file --- .../netplay/ui/NetplaySetupActivity.kt | 326 +---------------- .../features/netplay/ui/NetplaySetupScreen.kt | 330 ++++++++++++++++++ 2 files changed, 331 insertions(+), 325 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt 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 5032546cb6..8e9cf05422 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 @@ -1,7 +1,5 @@ // SPDX-License-Identifier: GPL-2.0-or-later -@file:OptIn(ExperimentalMaterial3Api::class) - package org.dolphinemu.dolphinemu.features.netplay.ui import android.content.Context @@ -10,74 +8,20 @@ import android.os.Bundle 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 -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MediumTopAppBar -import androidx.compose.material3.MenuAnchorType -import androidx.compose.material3.OutlinedTextField -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 -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -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 import org.dolphinemu.dolphinemu.activities.EmulationActivity import org.dolphinemu.dolphinemu.features.netplay.Netplay -import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionRole -import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType import org.dolphinemu.dolphinemu.features.netplay.model.NetplaySetupViewModel import org.dolphinemu.dolphinemu.ui.main.ThemeProvider 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 override fun onCreate(savedInstanceState: Bundle?) { ThemeHelper.setTheme(this) @@ -88,7 +32,7 @@ class NetplaySetupActivity : AppCompatActivity(), ThemeProvider { .onEach { EmulationActivity.launch(this, it, false) } .launchIn(lifecycleScope) - viewModel = ViewModelProvider(this)[NetplaySetupViewModel::class.java] + val viewModel = ViewModelProvider(this)[NetplaySetupViewModel::class.java] viewModel.showNetplayScreen .onEach { /* launch NetplayActivity */ } @@ -135,271 +79,3 @@ class NetplaySetupActivity : AppCompatActivity(), ThemeProvider { } } } - -@Composable -private fun NetplaySetupScreen( - onBackClicked: () -> Unit, - connecting: Boolean, - errors: Flow, - connectionRole: ConnectionRole, - onConnectionRoleChanged: (ConnectionRole) -> Unit, - nickname: String, - onNicknameChanged: (String) -> Unit, - connectionType: ConnectionType, - onConnectionTypeChanged: (ConnectionType) -> Unit, - ipAddress: String, - onIpAddressChanged: (String) -> Unit, - connectPort: String, - onConnectPortChanged: (String) -> Unit, - hostCode: String, - onHostCodeChanged: (String) -> Unit, - onConnectClicked: () -> Unit, -) { - Scaffold( - topBar = { - MediumTopAppBar( - title = { Text(stringResource(R.string.netplay_setup_title)) }, - navigationIcon = { - IconButton(onClick = onBackClicked) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - ) - }, - floatingActionButton = { - ExtendedFloatingActionButton( - onClick = onConnectClicked, - ) { - 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() - .consumeWindowInsets(innerPadding) - .verticalScroll(rememberScrollState()) - .padding(innerPadding) - ) { - SecondaryTabRow(selectedTabIndex = ConnectionRole.all.indexOf(connectionRole)) { - ConnectionRole.all.forEach { role -> - Tab( - selected = connectionRole == role, - onClick = { onConnectionRoleChanged(role) }, - text = { Text(stringResource(role.labelId)) }, - ) - } - } - - MenuSpacer() - - Column( - modifier = Modifier - .padding(horizontal = DolphinTheme.scaffoldPadding) - ) { - NetplaySetupContent( - nickname = nickname, - onNicknameChanged = onNicknameChanged, - connectionType = connectionType, - onConnectionTypeChanged = onConnectionTypeChanged, - ipAddress = ipAddress, - onIpAddressChanged = onIpAddressChanged, - hostCode = hostCode, - onHostCodeChanged = onHostCodeChanged, - connectPort = connectPort, - onConnectPortChanged = onConnectPortChanged, - connectionRole = connectionRole, - ) - } - } - } -} - -@Composable -private fun NetplaySetupContent( - nickname: String, - onNicknameChanged: (String) -> Unit, - connectionType: ConnectionType, - onConnectionTypeChanged: (ConnectionType) -> Unit, - ipAddress: String, - onIpAddressChanged: (String) -> Unit, - hostCode: String, - onHostCodeChanged: (String) -> Unit, - connectPort: String, - onConnectPortChanged: (String) -> Unit, - connectionRole: ConnectionRole, -) { - OutlinedTextField( - value = nickname, - onValueChange = onNicknameChanged, - label = { Text(stringResource(R.string.netplay_nickname_label)) }, - modifier = Modifier.fillMaxWidth() - ) - - MenuSpacer() - - ConnectionTypePicker( - connectionType = connectionType, - onConnectionTypeChanged = onConnectionTypeChanged, - ) - - MenuSpacer() - - when (connectionRole) { - ConnectionRole.Connect -> ConnectMenu( - connectionType = connectionType, - ipAddress = ipAddress, - onIpAddressChanged = onIpAddressChanged, - hostCode = hostCode, - onHostCodeChanged = onHostCodeChanged, - port = connectPort, - onPortChanged = onConnectPortChanged, - ) - - ConnectionRole.Host -> {} - } -} - -@Composable -private fun ConnectionTypePicker( - connectionType: ConnectionType, - onConnectionTypeChanged: (ConnectionType) -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = it }, - ) { - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - ConnectionType.all.forEach { connectionType -> - DropdownMenuItem( - text = { Text(stringResource(connectionType.labelId)) }, - onClick = { - onConnectionTypeChanged(connectionType) - expanded = false - }, - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, - ) - } - } - OutlinedTextField( - value = stringResource(connectionType.labelId), - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(R.string.netplay_connection_type)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, - modifier = Modifier - .menuAnchor(MenuAnchorType.PrimaryNotEditable) - .fillMaxWidth() - ) - } -} - -@Composable -fun ConnectMenu( - connectionType: ConnectionType, - ipAddress: String, - onIpAddressChanged: (String) -> Unit, - hostCode: String, - onHostCodeChanged: (String) -> Unit, - port: String, - onPortChanged: (String) -> Unit, -) { - when (connectionType) { - ConnectionType.DirectConnection -> { - OutlinedTextField( - value = ipAddress, - onValueChange = onIpAddressChanged, - label = { Text(stringResource(R.string.netplay_ip_address_label)) }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - ), - modifier = Modifier - .fillMaxWidth() - ) - - MenuSpacer() - - OutlinedTextField( - value = port, - onValueChange = onPortChanged, - label = { Text(stringResource(R.string.netplay_port_label)) }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - ), - modifier = Modifier - .fillMaxWidth() - ) - } - - ConnectionType.TraversalServer -> OutlinedTextField( - value = hostCode, - onValueChange = onHostCodeChanged, - label = { Text(stringResource(R.string.netplay_host_code_label)) }, - modifier = Modifier - .fillMaxWidth() - ) - } -} - -@Preview -@Composable -private fun NetplaySetupScreenPreview() { - MaterialTheme { - NetplaySetupScreen( - onBackClicked = {}, - connecting = false, - errors = emptyFlow(), - connectionRole = ConnectionRole.Connect, - onConnectionRoleChanged = {}, - nickname = "Preview nickname", - onNicknameChanged = {}, - connectionType = ConnectionType.DirectConnection, - onConnectionTypeChanged = {}, - ipAddress = "127.0.0.1", - onIpAddressChanged = {}, - connectPort = "2626", - onConnectPortChanged = {}, - hostCode = "", - onHostCodeChanged = {}, - onConnectClicked = {}, - ) - } -} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt new file mode 100644 index 0000000000..bcc11ef4bb --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt @@ -0,0 +1,330 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.dolphinemu.dolphinemu.features.netplay.ui + +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.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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 kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import org.dolphinemu.dolphinemu.R +import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionRole +import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType +import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme +import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer + +private data class ErrorDialogState(val message: String) { + val onDismissed = CompletableDeferred() +} + +@Composable +fun NetplaySetupScreen( + onBackClicked: () -> Unit, + connecting: Boolean, + errors: Flow, + connectionRole: ConnectionRole, + onConnectionRoleChanged: (ConnectionRole) -> Unit, + nickname: String, + onNicknameChanged: (String) -> Unit, + connectionType: ConnectionType, + onConnectionTypeChanged: (ConnectionType) -> Unit, + ipAddress: String, + onIpAddressChanged: (String) -> Unit, + connectPort: String, + onConnectPortChanged: (String) -> Unit, + hostCode: String, + onHostCodeChanged: (String) -> Unit, + onConnectClicked: () -> Unit, +) { + Scaffold( + topBar = { + MediumTopAppBar( + title = { Text(stringResource(R.string.netplay_setup_title)) }, + navigationIcon = { + IconButton(onClick = onBackClicked) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = onConnectClicked, + ) { + 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() + .consumeWindowInsets(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(innerPadding) + ) { + SecondaryTabRow(selectedTabIndex = ConnectionRole.all.indexOf(connectionRole)) { + ConnectionRole.all.forEach { role -> + Tab( + selected = connectionRole == role, + onClick = { onConnectionRoleChanged(role) }, + text = { Text(stringResource(role.labelId)) }, + ) + } + } + + MenuSpacer() + + Column( + modifier = Modifier + .padding(horizontal = DolphinTheme.scaffoldPadding) + ) { + NetplaySetupContent( + nickname = nickname, + onNicknameChanged = onNicknameChanged, + connectionType = connectionType, + onConnectionTypeChanged = onConnectionTypeChanged, + ipAddress = ipAddress, + onIpAddressChanged = onIpAddressChanged, + hostCode = hostCode, + onHostCodeChanged = onHostCodeChanged, + connectPort = connectPort, + onConnectPortChanged = onConnectPortChanged, + connectionRole = connectionRole, + ) + } + } + } +} + +@Composable +private fun NetplaySetupContent( + nickname: String, + onNicknameChanged: (String) -> Unit, + connectionType: ConnectionType, + onConnectionTypeChanged: (ConnectionType) -> Unit, + ipAddress: String, + onIpAddressChanged: (String) -> Unit, + hostCode: String, + onHostCodeChanged: (String) -> Unit, + connectPort: String, + onConnectPortChanged: (String) -> Unit, + connectionRole: ConnectionRole, +) { + OutlinedTextField( + value = nickname, + onValueChange = onNicknameChanged, + label = { Text(stringResource(R.string.netplay_nickname_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + MenuSpacer() + + ConnectionTypePicker( + connectionType = connectionType, + onConnectionTypeChanged = onConnectionTypeChanged, + ) + + MenuSpacer() + + when (connectionRole) { + ConnectionRole.Connect -> ConnectMenu( + connectionType = connectionType, + ipAddress = ipAddress, + onIpAddressChanged = onIpAddressChanged, + hostCode = hostCode, + onHostCodeChanged = onHostCodeChanged, + port = connectPort, + onPortChanged = onConnectPortChanged, + ) + + ConnectionRole.Host -> {} + } +} + +@Composable +private fun ConnectionTypePicker( + connectionType: ConnectionType, + onConnectionTypeChanged: (ConnectionType) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + ConnectionType.all.forEach { connectionType -> + DropdownMenuItem( + text = { Text(stringResource(connectionType.labelId)) }, + onClick = { + onConnectionTypeChanged(connectionType) + expanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + OutlinedTextField( + value = stringResource(connectionType.labelId), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.netplay_connection_type)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth() + ) + } +} + +@Composable +fun ConnectMenu( + connectionType: ConnectionType, + ipAddress: String, + onIpAddressChanged: (String) -> Unit, + hostCode: String, + onHostCodeChanged: (String) -> Unit, + port: String, + onPortChanged: (String) -> Unit, +) { + when (connectionType) { + ConnectionType.DirectConnection -> { + OutlinedTextField( + value = ipAddress, + onValueChange = onIpAddressChanged, + label = { Text(stringResource(R.string.netplay_ip_address_label)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + ), + singleLine = true, + modifier = Modifier + .fillMaxWidth() + ) + + MenuSpacer() + + OutlinedTextField( + value = port, + onValueChange = onPortChanged, + label = { Text(stringResource(R.string.netplay_port_label)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + ), + singleLine = true, + modifier = Modifier + .fillMaxWidth() + ) + } + + ConnectionType.TraversalServer -> OutlinedTextField( + value = hostCode, + onValueChange = onHostCodeChanged, + label = { Text(stringResource(R.string.netplay_host_code_label)) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + ) + } +} + +@Preview +@Composable +private fun NetplaySetupScreenPreview() { + MaterialTheme { + NetplaySetupScreen( + onBackClicked = {}, + connecting = false, + errors = emptyFlow(), + connectionRole = ConnectionRole.Connect, + onConnectionRoleChanged = {}, + nickname = "Preview nickname", + onNicknameChanged = {}, + connectionType = ConnectionType.DirectConnection, + onConnectionTypeChanged = {}, + ipAddress = "127.0.0.1", + onIpAddressChanged = {}, + connectPort = "2626", + onConnectPortChanged = {}, + hostCode = "", + onHostCodeChanged = {}, + onConnectClicked = {}, + ) + } +} From 099243f2c615cef9c89b7e51f5aa05f7f5874754 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Thu, 9 Apr 2026 13:43:57 +0100 Subject: [PATCH 09/37] Add mostly empty Netplay screen, equivalent of NetPlayDialog in QT. All it can do at this point is quit the current netplay session when backing out of this screen. --- .../Android/app/src/main/AndroidManifest.xml | 5 ++ .../dolphinemu/features/netplay/Netplay.kt | 4 ++ .../netplay/model/NetplayViewModel.kt | 33 +++++++++ .../features/netplay/ui/NetplayActivity.kt | 68 +++++++++++++++++++ .../features/netplay/ui/NetplayScreen.kt | 46 +++++++++++++ .../netplay/ui/NetplaySetupActivity.kt | 11 ++- .../app/src/main/res/values/strings.xml | 1 + 7 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt diff --git a/Source/Android/app/src/main/AndroidManifest.xml b/Source/Android/app/src/main/AndroidManifest.xml index e68fa9f757..96876acd46 100644 --- a/Source/Android/app/src/main/AndroidManifest.xml +++ b/Source/Android/app/src/main/AndroidManifest.xml @@ -138,6 +138,11 @@ android:exported="false" android:theme="@style/Theme.Dolphin.Main" /> + + (CONFLATED) + val goBack = _goBack.receiveAsFlow() + + init { + if (!Netplay.isClientConnected()) { + _goBack.trySend(Unit) + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onCleared() { + super.onCleared() + GlobalScope.launch { + Netplay.quit() + } + } +} 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 new file mode 100644 index 0000000000..7f199bd45e --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.features.netplay.ui + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.dolphinemu.dolphinemu.activities.EmulationActivity +import org.dolphinemu.dolphinemu.features.netplay.Netplay +import org.dolphinemu.dolphinemu.features.netplay.model.NetplayViewModel +import org.dolphinemu.dolphinemu.ui.main.ThemeProvider +import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme +import org.dolphinemu.dolphinemu.utils.ThemeHelper + +class NetplayActivity : AppCompatActivity(), ThemeProvider { + override var themeId: Int = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + ThemeHelper.setTheme(this) + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + val viewModel = ViewModelProvider(this)[NetplayViewModel::class.java] + + viewModel.goBack + .onEach { finish() } + .launchIn(lifecycleScope) + + viewModel.launchGame + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { EmulationActivity.launch(this, it, false) } + .launchIn(lifecycleScope) + + setContent { + DolphinTheme { + NetplayScreen( + onBackClicked = { finish() }, + ) + } + } + } + + override fun setTheme(themeId: Int) { + super.setTheme(themeId) + this.themeId = themeId + } + + override fun onResume() { + ThemeHelper.setCorrectTheme(this) + super.onResume() + } + + companion object { + @JvmStatic + fun launch(context: Context) { + context.startActivity(Intent(context, NetplayActivity::class.java)) + } + } +} 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 new file mode 100644 index 0000000000..de3b89d6c1 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.features.netplay.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import org.dolphinemu.dolphinemu.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NetplayScreen( + onBackClicked: () -> Unit, +) { + Scaffold( + topBar = { + MediumTopAppBar( + title = { Text(stringResource(R.string.netplay_title)) }, + navigationIcon = { + IconButton(onClick = onBackClicked) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + ) + }, + ) { _ -> } +} + +@Preview +@Composable +private fun NetplayScreenPreview() { + NetplayScreen( + onBackClicked = {}, + ) +} \ No newline at end of file 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 8e9cf05422..8058b3ad96 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 @@ -9,12 +9,12 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.collectAsState +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import org.dolphinemu.dolphinemu.activities.EmulationActivity -import org.dolphinemu.dolphinemu.features.netplay.Netplay import org.dolphinemu.dolphinemu.features.netplay.model.NetplaySetupViewModel import org.dolphinemu.dolphinemu.ui.main.ThemeProvider import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme @@ -28,14 +28,11 @@ class NetplaySetupActivity : AppCompatActivity(), ThemeProvider { enableEdgeToEdge() super.onCreate(savedInstanceState) - Netplay.launchGame - .onEach { EmulationActivity.launch(this, it, false) } - .launchIn(lifecycleScope) - val viewModel = ViewModelProvider(this)[NetplaySetupViewModel::class.java] viewModel.showNetplayScreen - .onEach { /* launch NetplayActivity */ } + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { NetplayActivity.launch(this) } .launchIn(lifecycleScope) setContent { diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 1944cc4911..903a414af3 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -995,4 +995,5 @@ It can efficiently compress both junk data and encrypted Wii data. IP address Host code Port + Netplay From bfa68bf935aa9c034affc2d3fb16fb6531e48072 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Thu, 9 Apr 2026 23:26:58 +0200 Subject: [PATCH 10/37] Reorder netplay class Put all the boring settings at the bottom to reduce scrolling! --- .../dolphinemu/features/netplay/Netplay.kt | 98 ++++++++++--------- 1 file changed, 51 insertions(+), 47 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 41b2bb6f8a..9b19c9c2fd 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 @@ -27,9 +27,60 @@ object Netplay { private val _connectionErrors = Channel(Channel.BUFFERED) val connectionErrors = _connectionErrors.receiveAsFlow() + 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 + } + + suspend fun quit() = withContext(Dispatchers.IO) { + releaseNetplayClient() + } + + private fun releaseNetplayClient() { + if (netPlayClientPointer != 0L) { + ReleaseNetplayClient() + netPlayClientPointer = 0 + } + _launchGame.flush() + _connectionErrors.flush() + } + + @JvmStatic + private external fun Join(): Long + @JvmStatic external fun isClientConnected(): Boolean + @JvmStatic + private external fun ReleaseNetplayClient() + + // NetPlayUI callbacks + + @JvmStatic + fun onBootGame(gameFilePath: String, bootSessionDataPointer: Long) { + this.bootSessionDataPointer = bootSessionDataPointer + _launchGame.trySend(gameFilePath) + } + + @JvmStatic + fun onConnectionError(message: String) { + _connectionErrors.trySend(message) + } + + // Settings + @JvmStatic external fun getNickname(): String @@ -116,53 +167,6 @@ object Netplay { indexName: String, indexPassword: String, ) - - 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 - } - - suspend fun quit() = withContext(Dispatchers.IO) { - releaseNetplayClient() - } - - 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() { From 97279f24dd1be7e3e0319a9982254bf459669826 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Fri, 10 Apr 2026 11:30:59 +0200 Subject: [PATCH 11/37] Implement player list --- .../dolphinemu/features/netplay/Netplay.kt | 19 +++ .../netplay/model/NetplayViewModel.kt | 6 + .../features/netplay/model/Player.kt | 13 ++ .../features/netplay/ui/NetplayActivity.kt | 3 +- .../features/netplay/ui/NetplayScreen.kt | 119 +++++++++++++++++- Source/Android/jni/AndroidCommon/IDCache.cpp | 27 ++++ Source/Android/jni/AndroidCommon/IDCache.h | 4 + .../jni/NetPlay/NetPlayUICallbacks.cpp | 36 +++++- 8 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/Player.kt 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 9b19c9c2fd..ceda995051 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 @@ -5,11 +5,17 @@ package org.dolphinemu.dolphinemu.features.netplay import androidx.annotation.Keep import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType +import org.dolphinemu.dolphinemu.features.netplay.model.Player object Netplay { @Keep @@ -27,6 +33,12 @@ object Netplay { private val _connectionErrors = Channel(Channel.BUFFERED) val connectionErrors = _connectionErrors.receiveAsFlow() + private val _players = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val players = _players.asSharedFlow().distinctUntilChanged() + suspend fun join(): Boolean = withContext(Dispatchers.IO) { netPlayClientPointer = Join() val isConnected = netPlayClientPointer != 0L && isClientConnected() @@ -48,6 +60,7 @@ object Netplay { releaseNetplayClient() } + @OptIn(ExperimentalCoroutinesApi::class) private fun releaseNetplayClient() { if (netPlayClientPointer != 0L) { ReleaseNetplayClient() @@ -55,6 +68,7 @@ object Netplay { } _launchGame.flush() _connectionErrors.flush() + _players.resetReplayCache() } @JvmStatic @@ -79,6 +93,11 @@ object Netplay { _connectionErrors.trySend(message) } + @JvmStatic + fun onUpdate(players: Array) { + _players.tryEmit(players.toList()) + } + // Settings @JvmStatic 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 cf941aa01f..942dc0d0dc 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 @@ -3,11 +3,14 @@ package org.dolphinemu.dolphinemu.features.netplay.model import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.CONFLATED +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.dolphinemu.dolphinemu.features.netplay.Netplay @@ -17,6 +20,9 @@ class NetplayViewModel : ViewModel() { private val _goBack = Channel(CONFLATED) val goBack = _goBack.receiveAsFlow() + val players = Netplay.players + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + init { if (!Netplay.isClientConnected()) { _goBack.trySend(Unit) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/Player.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/Player.kt new file mode 100644 index 0000000000..70c228d1c9 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/Player.kt @@ -0,0 +1,13 @@ +// Copyright 2003 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.features.netplay.model + +data class Player( + val pid: Int, + val name: String, + val revision: String, + val ping: Int, + val isHost: Boolean, + val mapping: String, +) \ No newline at end of file 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 7f199bd45e..5a91ae5b91 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 @@ -8,6 +8,7 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.collectAsState import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.flowWithLifecycle @@ -15,7 +16,6 @@ import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.dolphinemu.dolphinemu.activities.EmulationActivity -import org.dolphinemu.dolphinemu.features.netplay.Netplay import org.dolphinemu.dolphinemu.features.netplay.model.NetplayViewModel import org.dolphinemu.dolphinemu.ui.main.ThemeProvider import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme @@ -44,6 +44,7 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { DolphinTheme { NetplayScreen( onBackClicked = { finish() }, + players = viewModel.players.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 de3b89d6c1..e4089dae45 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 @@ -2,23 +2,46 @@ package org.dolphinemu.dolphinemu.features.netplay.ui +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import org.dolphinemu.dolphinemu.R +import org.dolphinemu.dolphinemu.features.netplay.model.Player +import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme @OptIn(ExperimentalMaterial3Api::class) @Composable fun NetplayScreen( onBackClicked: () -> Unit, + players: List, ) { Scaffold( topBar = { @@ -34,7 +57,81 @@ fun NetplayScreen( }, ) }, - ) { _ -> } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .consumeWindowInsets(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(innerPadding) + .padding(horizontal = DolphinTheme.scaffoldPadding) + ) { + PlayersTable( + rows = buildList { + add(listOf("Player", "Ping", "Mapping")) + addAll(players.map { listOf(it.name, it.ping.toString(), it.mapping) }) + }, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +/** + * A table arranged into columns sized to wrap the largest item. Except the + * first column which takes up the remaining space left by the other columns. + * The first row is treated as the column titles. + */ +@Composable +private fun PlayersTable( + rows: List>, + modifier: Modifier = Modifier, +) { + rows.zipWithNext { a, b -> if (a.size != b.size) throw IllegalArgumentException("Rows must all contain the same number of elements.") } + val maxWidths = remember { List(rows.first().size) { mutableIntStateOf(0) } } + val density = LocalDensity.current + + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier + ) { + rows.forEachIndexed { rowIndex, row -> + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + row.forEachIndexed { itemIndex, text -> + Box( + modifier = Modifier + .then( + when { + itemIndex == 0 -> Modifier.weight(1f) + + maxWidths[itemIndex].intValue > 0 -> Modifier + .width(with(density) { maxWidths[itemIndex].intValue.toDp() }) + + else -> Modifier + } + ) + .onGloballyPositioned { coordinates -> + val width = coordinates.size.width + if (width > maxWidths[itemIndex].intValue) { + maxWidths[itemIndex].intValue = width + } + } + ) { + Text( + text = text, + fontWeight = if (rowIndex == 0) FontWeight.Medium else FontWeight.Normal, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + if (rowIndex == 0) { + HorizontalDivider() + } + } + } } @Preview @@ -42,5 +139,23 @@ fun NetplayScreen( private fun NetplayScreenPreview() { NetplayScreen( onBackClicked = {}, + players = listOf( + Player( + pid = 1, + name = "Player 1", + revision = "123", + ping = 2, + isHost = true, + mapping = "m1" + ), + Player( + pid = 2, + name = "Player 2", + revision = "123", + ping = 23, + isHost = false, + mapping = "m2" + ), + ), ) -} \ No newline at end of file +} diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index 8f74425efa..b45849bf5d 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -33,6 +33,10 @@ 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 jmethodID s_netplay_update; + +static jclass s_netplay_player_class; +static jmethodID s_netplay_player_constructor; static jclass s_analytics_class; static jmethodID s_get_analytics_value; @@ -264,6 +268,21 @@ jmethodID GetNetplayOnConnectionError() return s_netplay_on_connection_error; } +jmethodID GetNetplayUpdate() +{ + return s_netplay_update; +} + +jclass GetNetplayPlayerClass() +{ + return s_netplay_player_class; +} + +jmethodID GetNetplayPlayerConstructor() +{ + return s_netplay_player_constructor; +} + jclass GetPairClass() { return s_pair_class; @@ -675,8 +694,15 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) 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"); + s_netplay_update = env->GetStaticMethodID(netplay_class, "onUpdate", "([Lorg/dolphinemu/dolphinemu/features/netplay/model/Player;)V"); env->DeleteLocalRef(netplay_class); + const jclass netplay_player_class = + env->FindClass("org/dolphinemu/dolphinemu/features/netplay/model/Player"); + s_netplay_player_class = reinterpret_cast(env->NewGlobalRef(netplay_player_class)); + s_netplay_player_constructor = env->GetMethodID(netplay_player_class, "", "(ILjava/lang/String;Ljava/lang/String;IZLjava/lang/String;)V"); + env->DeleteLocalRef(netplay_player_class); + const jclass analytics_class = env->FindClass("org/dolphinemu/dolphinemu/utils/Analytics"); s_analytics_class = reinterpret_cast(env->NewGlobalRef(analytics_class)); s_get_analytics_value = env->GetStaticMethodID(s_analytics_class, "getValue", @@ -892,6 +918,7 @@ JNIEXPORT void JNI_OnUnload(JavaVM* vm, void* reserved) env->DeleteGlobalRef(s_game_file_cache_class); env->DeleteGlobalRef(s_game_file_cache_manager_class); env->DeleteGlobalRef(s_netplay_class); + env->DeleteGlobalRef(s_netplay_player_class); env->DeleteGlobalRef(s_analytics_class); env->DeleteGlobalRef(s_pair_class); env->DeleteGlobalRef(s_hash_map_class); diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h index d59d1ca9f0..0e393f919a 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -36,6 +36,10 @@ jfieldID GetNetPlayClientPointer(); jfieldID GetNetplayBootSessionDataPointer(); jmethodID GetNetplayOnBootGame(); jmethodID GetNetplayOnConnectionError(); +jmethodID GetNetplayUpdate(); + +jclass GetNetplayPlayerClass(); +jmethodID GetNetplayPlayerConstructor(); jclass GetPairClass(); jmethodID GetPairConstructor(); diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index baa38de18d..478052e36c 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -24,7 +24,41 @@ void NetPlayUICallbacks::BootGame(const std::string& filename, std::unique_ptr( + env->GetStaticLongField(IDCache::GetNetplayClass(), IDCache::GetNetPlayClientPointer())); + if (!client) + return; + + const std::vector players = client->GetPlayers(); + + jobjectArray player_array = + env->NewObjectArray(static_cast(players.size()), IDCache::GetNetplayPlayerClass(), nullptr); + + for (jsize i = 0; i < static_cast(players.size()); i++) + { + const NetPlay::Player* player = players[i]; + const std::string mapping = NetPlay::GetPlayerMappingString( + player->pid, client->GetPadMapping(), client->GetGBAConfig(), client->GetWiimoteMapping()); + jobject player_obj = env->NewObject( + IDCache::GetNetplayPlayerClass(), IDCache::GetNetplayPlayerConstructor(), + static_cast(player->pid), + ToJString(env, player->name), + ToJString(env, player->revision), + static_cast(player->ping), + static_cast(player->IsHost()), + ToJString(env, mapping)); + env->SetObjectArrayElement(player_array, i, player_obj); + env->DeleteLocalRef(player_obj); + } + + env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayUpdate(), player_array); + env->DeleteLocalRef(player_array); +} + void NetPlayUICallbacks::AppendChat(const std::string&) {} void NetPlayUICallbacks::OnMsgChangeGame(const NetPlay::SyncIdentifier& sync_identifier, From d82a9242a13df4beac7b95f29c59f08583eee5fd Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 15 Apr 2026 13:51:39 +0200 Subject: [PATCH 12/37] Netplay chat UI --- .../features/netplay/ui/NetplayScreen.kt | 259 +++++++++++++++--- .../dolphinemu/ui/theme/DolphinTheme.kt | 88 ++++++ .../app/src/main/res/values/strings.xml | 3 + 3 files changed, 315 insertions(+), 35 deletions(-) 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 e4089dae45..c94579d181 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 @@ -2,15 +2,20 @@ package org.dolphinemu.dolphinemu.features.netplay.ui +import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -21,13 +26,22 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -36,6 +50,9 @@ import androidx.compose.ui.unit.dp import org.dolphinemu.dolphinemu.R import org.dolphinemu.dolphinemu.features.netplay.model.Player import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme +import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer +import org.dolphinemu.dolphinemu.ui.theme.OutlinedBox +import org.dolphinemu.dolphinemu.ui.theme.PreviewTheme @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -50,29 +67,160 @@ fun NetplayScreen( navigationIcon = { IconButton(onClick = onBackClicked) { Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", ) } }, ) }, ) { innerPadding -> - Column( + val modifier = Modifier + .fillMaxSize() + .consumeWindowInsets(innerPadding) + .padding(innerPadding) + + if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) { + LandscapeContent( + players = players, + modifier = modifier + ) + } else { + PortraitContent( + players = players, + modifier = modifier + ) + } + } +} + +@Composable +private fun PortraitContent( + players: List, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + ) { + Chat( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.3f) + .padding(horizontal = DolphinTheme.scaffoldPadding) + ) + + MenuSpacer() + + PLayersAndSettings( + players = players, + modifier = Modifier + .weight(1f) + .padding(horizontal = DolphinTheme.scaffoldPadding) + ) + } +} + +@Composable +private fun LandscapeContent( + players: List, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + ) { + Chat( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = DolphinTheme.scaffoldPadding) + ) + + PLayersAndSettings( + players = players, + modifier = Modifier + .weight(1f) + .padding(horizontal = DolphinTheme.scaffoldPadding) + ) + } +} + +@Composable +private fun PLayersAndSettings( + players: List, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + ) { + PlayersTable( + rows = buildList { + add(listOf("Player", "Ping", "Mapping")) + addAll(players.map { listOf(it.name, it.ping.toString(), it.mapping) }) + repeat(4 - players.size) { add(listOf("", "", "")) } + }, + modifier = Modifier + .fillMaxWidth() + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun Chat( + modifier: Modifier, +) { + var showBottomSheet by remember { mutableStateOf(false) } + val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (showBottomSheet) { + ModalBottomSheet( + onDismissRequest = { showBottomSheet = false }, + sheetState = bottomSheetState, + modifier = Modifier + .statusBarsPadding() + ) { + LazyColumn( + reverseLayout = true, + contentPadding = PaddingValues(bottom = 4.dp), + modifier = Modifier + .weight(1f) + .padding(horizontal = DolphinTheme.scaffoldPadding) + ) { + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) { + OutlinedTextField( + value = "", + onValueChange = {}, + modifier = Modifier + .weight(1f) + ) + TextButton( + onClick = {}, + ) { + Text(stringResource(R.string.netplay_chat_send)) + } + } + } + } + OutlinedBox( + onClick = { showBottomSheet = true }, + label = { Text(stringResource(R.string.netplay_chat_label)) }, + modifier = modifier + ) { + LazyColumn( + reverseLayout = true, + userScrollEnabled = false, modifier = Modifier .fillMaxSize() - .consumeWindowInsets(innerPadding) - .verticalScroll(rememberScrollState()) - .padding(innerPadding) - .padding(horizontal = DolphinTheme.scaffoldPadding) ) { - PlayersTable( - rows = buildList { - add(listOf("Player", "Ping", "Mapping")) - addAll(players.map { listOf(it.name, it.ping.toString(), it.mapping) }) - }, - modifier = Modifier.fillMaxWidth() - ) } } } @@ -137,25 +285,66 @@ private fun PlayersTable( @Preview @Composable private fun NetplayScreenPreview() { - NetplayScreen( - onBackClicked = {}, - players = listOf( - Player( - pid = 1, - name = "Player 1", - revision = "123", - ping = 2, - isHost = true, - mapping = "m1" - ), - Player( - pid = 2, - name = "Player 2", - revision = "123", - ping = 23, - isHost = false, - mapping = "m2" - ), - ), - ) + PreviewTheme(darkTheme = false) { + NetplayScreen( + onBackClicked = {}, + players = previewPlayers, + ) + } } + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun NetplayScreenDarkPreview() { + PreviewTheme(darkTheme = true) { + NetplayScreen( + onBackClicked = {}, + players = previewPlayers, + ) + } +} + +@Preview(widthDp = 891, heightDp = 411) +@Composable +private fun LandscapeNetplayScreenPreview() { + PreviewTheme(darkTheme = false) { + NetplayScreen( + onBackClicked = {}, + players = previewPlayers, + ) + } +} + +@Preview( + widthDp = 891, + heightDp = 411, + uiMode = Configuration.UI_MODE_NIGHT_YES +) +@Composable +private fun LandscapeNetplayScreenDarkPreview() { + PreviewTheme(darkTheme = true) { + NetplayScreen( + onBackClicked = {}, + players = previewPlayers, + ) + } +} + +private val previewPlayers = listOf( + Player( + pid = 1, + name = "Player 1", + revision = "123", + ping = 2, + isHost = true, + mapping = "m1" + ), + Player( + pid = 2, + name = "Player 2", + revision = "123", + ping = 23, + isHost = false, + mapping = "m2" + ), +) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt index 24b0f99c0a..d24b3d834f 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt @@ -4,18 +4,31 @@ package org.dolphinemu.dolphinemu.ui.theme import android.content.Context import androidx.annotation.AttrRes +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import com.google.android.material.color.MaterialColors import androidx.appcompat.R as AppCompatR @@ -37,6 +50,17 @@ fun DolphinTheme(content: @Composable () -> Unit) { ) } +@Composable +fun PreviewTheme( + darkTheme: Boolean, + content: @Composable () -> Unit, +) { + MaterialTheme( + colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme(), + content = content + ) +} + private fun Context.toDolphinColorScheme(isDark: Boolean): ColorScheme { fun attr(@AttrRes attr: Int) = Color(MaterialColors.getColor(this, attr, 0)) @@ -107,3 +131,67 @@ private fun Context.toDolphinColorScheme(isDark: Boolean): ColorScheme { @Composable fun MenuSpacer() = Spacer(modifier = Modifier.height(16.dp)) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OutlinedBox( + onClick: () -> Unit, + label: @Composable () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .padding(top = 8.dp) + ) { + val interactionSource = remember { MutableInteractionSource() } + OutlinedTextFieldDefaults.DecorationBox( + value = "chatText", + innerTextField = { + Box( + modifier = Modifier + .fillMaxSize() + ) { + content() + Box( + modifier = Modifier + .fillMaxWidth() + .height(16.dp) + .background( + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.surface, + Color.Transparent + ) + ) + ) + ) + } + }, + enabled = true, + singleLine = false, + contentPadding = OutlinedTextFieldDefaults.contentPadding(top = 0.dp), + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + label = { label() }, + container = { + OutlinedTextFieldDefaults.Container( + enabled = true, + isError = false, + interactionSource = interactionSource, + colors = OutlinedTextFieldDefaults.colors(), + ) + } + ) + Box( + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.extraSmall) + .clickable( + interactionSource = interactionSource, + indication = LocalIndication.current, + onClick = onClick, + ) + ) + } +} diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 903a414af3..bd1acb0bfb 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -996,4 +996,7 @@ It can efficiently compress both junk data and encrypted Wii data. Host code Port Netplay + Start + Chat + Send From a5bd27d731c4de475550dbbfbf45ff9472b1c9f2 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 14 Apr 2026 09:57:01 +0200 Subject: [PATCH 13/37] Implement more NetPlayUICallbacks Includes chat, game changes, pad buffer changes and host input authority. Merges them all into the chat window. --- .../dolphinemu/features/netplay/Netplay.kt | 98 +++++++++++++ .../features/netplay/model/NetplayMessage.kt | 29 ++++ .../netplay/model/NetplayViewModel.kt | 18 +++ .../features/netplay/ui/NetplayActivity.kt | 3 + .../features/netplay/ui/NetplayScreen.kt | 137 +++++++++++++----- .../app/src/main/res/values/strings.xml | 4 + Source/Android/jni/AndroidCommon/IDCache.cpp | 32 ++++ Source/Android/jni/AndroidCommon/IDCache.h | 4 + .../jni/NetPlay/NetPlayUICallbacks.cpp | 31 +++- Source/Android/jni/NetPlay/Netplay.cpp | 8 + 10 files changed, 324 insertions(+), 40 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayMessage.kt 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 ceda995051..88baa9f495 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,19 +4,31 @@ package org.dolphinemu.dolphinemu.features.netplay import androidx.annotation.Keep +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType +import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage import org.dolphinemu.dolphinemu.features.netplay.model.Player +//TODO add other necessary @Keep annotations +//TODO clear boot session data at appropriate time object Netplay { @Keep private var netPlayClientPointer: Long = 0 @@ -24,6 +36,8 @@ object Netplay { @Keep private var bootSessionDataPointer: Long = 0 + private var sessionScope: CoroutineScope? = null + val isLaunching: Boolean get() = bootSessionDataPointer != 0L @@ -33,13 +47,51 @@ object Netplay { private val _connectionErrors = Channel(Channel.BUFFERED) val connectionErrors = _connectionErrors.receiveAsFlow() + private val _messages = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val messages = _messages.asSharedFlow() + private val _players = MutableSharedFlow>( replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) val players = _players.asSharedFlow().distinctUntilChanged() + private val _chatMessages = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val chatMessages = _chatMessages.asSharedFlow() + + private val _game = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val game = _game.asSharedFlow() + + private val _hostInputAuthorityEnabled = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val hostInputAuthorityEnabled = _hostInputAuthorityEnabled.asSharedFlow() + + private val _padBuffer = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val padBuffer = _padBuffer.asSharedFlow() + suspend fun join(): Boolean = withContext(Dispatchers.IO) { + val scope = createSessionScope() + + // Gather all messages that should appear in the chat window. + mergeMessages() + .runningFold(emptyList()) { acc, msg -> listOf(msg) + acc } + .onEach { _messages.tryEmit(it) } + .launchIn(scope) + netPlayClientPointer = Join() val isConnected = netPlayClientPointer != 0L && isClientConnected() @@ -62,13 +114,29 @@ object Netplay { @OptIn(ExperimentalCoroutinesApi::class) private fun releaseNetplayClient() { + sessionScope?.cancel() + sessionScope = null + if (netPlayClientPointer != 0L) { ReleaseNetplayClient() netPlayClientPointer = 0 } + _launchGame.flush() _connectionErrors.flush() _players.resetReplayCache() + _messages.resetReplayCache() + _chatMessages.resetReplayCache() + _game.resetReplayCache() + _hostInputAuthorityEnabled.resetReplayCache() + _padBuffer.resetReplayCache() + } + + private fun createSessionScope(): CoroutineScope { + sessionScope?.cancel() + return CoroutineScope(SupervisorJob() + Dispatchers.IO).also { + sessionScope = it + } } @JvmStatic @@ -77,9 +145,19 @@ object Netplay { @JvmStatic external fun isClientConnected(): Boolean + @JvmStatic + external fun sendMessage(message: String) + @JvmStatic private external fun ReleaseNetplayClient() + private fun mergeMessages(): Flow = merge( + chatMessages.map { NetplayMessage.Chat(it) }, + game.map { NetplayMessage.GameChanged(it) }, + hostInputAuthorityEnabled.map { NetplayMessage.HostInputAuthorityChanged(it) }, + padBuffer.map { NetplayMessage.BufferChanged(it) }, + ) + // NetPlayUI callbacks @JvmStatic @@ -98,6 +176,26 @@ object Netplay { _players.tryEmit(players.toList()) } + @JvmStatic + fun onChatMessageReceived(message: String) { + _chatMessages.tryEmit(message) + } + + @JvmStatic + fun onHostInputAuthorityChanged(enabled: Boolean) { + _hostInputAuthorityEnabled.tryEmit(enabled) + } + + @JvmStatic + fun onGameChanged(game: String) { + _game.tryEmit(game) + } + + @JvmStatic + fun onPadBufferChanged(buffer: Int) { + _padBuffer.tryEmit(buffer) + } + // Settings @JvmStatic diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayMessage.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayMessage.kt new file mode 100644 index 0000000000..41ab8997bd --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayMessage.kt @@ -0,0 +1,29 @@ +package org.dolphinemu.dolphinemu.features.netplay.model + +import android.content.Context +import org.dolphinemu.dolphinemu.R + +sealed class NetplayMessage { + abstract fun message(context: Context): String + + class Chat(private val chatMessage: String) : NetplayMessage() { + override fun message(context: Context) = chatMessage + } + + class GameChanged(private val game: String) : NetplayMessage() { + override fun message(context: Context) = + context.getString(R.string.netplay_message_game_changed, game) + } + + class HostInputAuthorityChanged(private val hostInputAuthorityEnabled: Boolean) : NetplayMessage() { + override fun message(context: Context) = context.getString( + R.string.netplay_message_host_input_authority_changed, + if (hostInputAuthorityEnabled) "enabled" else "disabled" + ) + } + + class BufferChanged(private val buffer: Int) : NetplayMessage() { + override fun message(context: Context) = + context.getString(R.string.netplay_message_buffer_changed, buffer) + } +} 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 942dc0d0dc..cc9dc00ae0 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 @@ -2,6 +2,8 @@ package org.dolphinemu.dolphinemu.features.netplay.model +import android.app.Application +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -14,6 +16,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.dolphinemu.dolphinemu.features.netplay.Netplay +//TODO save settings class NetplayViewModel : ViewModel() { val launchGame = Netplay.launchGame @@ -23,12 +26,27 @@ class NetplayViewModel : ViewModel() { val players = Netplay.players .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + val messages = Netplay.messages + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + + val game = Netplay.game + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") + init { if (!Netplay.isClientConnected()) { _goBack.trySend(Unit) } } + fun sendMessage(message: String) { + val trimmedMessage = message.trim() + if (trimmedMessage.isEmpty()) { + return + } + + Netplay.sendMessage(trimmedMessage) + } + @OptIn(DelicateCoroutinesApi::class) override fun onCleared() { super.onCleared() 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 5a91ae5b91..c4d0ded675 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 @@ -44,6 +44,9 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { DolphinTheme { NetplayScreen( onBackClicked = { finish() }, + messages = viewModel.messages.collectAsState().value, + onSendMessage = viewModel::sendMessage, + game = viewModel.game.collectAsState().value, players = viewModel.players.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 c94579d181..d5fdab900a 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 @@ -16,7 +16,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +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 @@ -42,12 +45,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.dolphinemu.dolphinemu.R +import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage import org.dolphinemu.dolphinemu.features.netplay.model.Player import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer @@ -58,6 +64,9 @@ import org.dolphinemu.dolphinemu.ui.theme.PreviewTheme @Composable fun NetplayScreen( onBackClicked: () -> Unit, + messages: List, + onSendMessage: (String) -> Unit, + game: String, players: List, ) { Scaffold( @@ -82,11 +91,17 @@ fun NetplayScreen( if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) { LandscapeContent( + messages = messages, + onSendMessage = onSendMessage, + game = game, players = players, modifier = modifier ) } else { PortraitContent( + messages = messages, + onSendMessage = onSendMessage, + game = game, players = players, modifier = modifier ) @@ -96,6 +111,9 @@ fun NetplayScreen( @Composable private fun PortraitContent( + messages: List, + onSendMessage: (String) -> Unit, + game: String, players: List, modifier: Modifier = Modifier, ) { @@ -103,6 +121,8 @@ private fun PortraitContent( modifier = modifier ) { Chat( + messages = messages, + onSendMessage = onSendMessage, modifier = Modifier .fillMaxWidth() .fillMaxHeight(0.3f) @@ -112,6 +132,7 @@ private fun PortraitContent( MenuSpacer() PLayersAndSettings( + game = game, players = players, modifier = Modifier .weight(1f) @@ -122,6 +143,9 @@ private fun PortraitContent( @Composable private fun LandscapeContent( + messages: List, + onSendMessage: (String) -> Unit, + game: String, players: List, modifier: Modifier = Modifier, ) { @@ -129,6 +153,8 @@ private fun LandscapeContent( modifier = modifier ) { Chat( + messages = messages, + onSendMessage = onSendMessage, modifier = Modifier .weight(1f) .fillMaxHeight() @@ -136,6 +162,7 @@ private fun LandscapeContent( ) PLayersAndSettings( + game = game, players = players, modifier = Modifier .weight(1f) @@ -146,6 +173,7 @@ private fun LandscapeContent( @Composable private fun PLayersAndSettings( + game: String, players: List, modifier: Modifier = Modifier, ) { @@ -153,6 +181,17 @@ private fun PLayersAndSettings( modifier = modifier .verticalScroll(rememberScrollState()) ) { + OutlinedTextField( + value = game, + onValueChange = {}, + label = { Text(stringResource(R.string.netplay_game_label)) }, + readOnly = true, + modifier = Modifier + .fillMaxWidth() + ) + + MenuSpacer() + PlayersTable( rows = buildList { add(listOf("Player", "Ping", "Mapping")) @@ -168,8 +207,24 @@ private fun PLayersAndSettings( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun Chat( + messages: List, + onSendMessage: (String) -> Unit, modifier: Modifier, ) { + val context = LocalContext.current + + fun LazyListScope.messages() { + items(messages.size) { index -> + Text(text = messages[index].message(context)) + } + } + + var draftMessage by remember { mutableStateOf("") } + val submitMessage = { + onSendMessage(draftMessage) + draftMessage = "" + } + var showBottomSheet by remember { mutableStateOf(false) } val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -187,6 +242,7 @@ private fun Chat( .weight(1f) .padding(horizontal = DolphinTheme.scaffoldPadding) ) { + messages() } Row( @@ -197,19 +253,23 @@ private fun Chat( .padding(horizontal = 8.dp) ) { OutlinedTextField( - value = "", - onValueChange = {}, + value = draftMessage, + onValueChange = { draftMessage = it }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { submitMessage() }), modifier = Modifier .weight(1f) ) TextButton( - onClick = {}, + onClick = submitMessage, + enabled = draftMessage.isNotBlank(), ) { Text(stringResource(R.string.netplay_chat_send)) } } } } + OutlinedBox( onClick = { showBottomSheet = true }, label = { Text(stringResource(R.string.netplay_chat_label)) }, @@ -221,6 +281,7 @@ private fun Chat( modifier = Modifier .fillMaxSize() ) { + messages() } } } @@ -286,10 +347,7 @@ private fun PlayersTable( @Composable private fun NetplayScreenPreview() { PreviewTheme(darkTheme = false) { - NetplayScreen( - onBackClicked = {}, - players = previewPlayers, - ) + PreviewNetplayScreen() } } @@ -297,10 +355,7 @@ private fun NetplayScreenPreview() { @Composable private fun NetplayScreenDarkPreview() { PreviewTheme(darkTheme = true) { - NetplayScreen( - onBackClicked = {}, - players = previewPlayers, - ) + PreviewNetplayScreen() } } @@ -308,10 +363,7 @@ private fun NetplayScreenDarkPreview() { @Composable private fun LandscapeNetplayScreenPreview() { PreviewTheme(darkTheme = false) { - NetplayScreen( - onBackClicked = {}, - players = previewPlayers, - ) + PreviewNetplayScreen() } } @@ -320,31 +372,42 @@ private fun LandscapeNetplayScreenPreview() { heightDp = 411, uiMode = Configuration.UI_MODE_NIGHT_YES ) + @Composable private fun LandscapeNetplayScreenDarkPreview() { PreviewTheme(darkTheme = true) { - NetplayScreen( - onBackClicked = {}, - players = previewPlayers, - ) + PreviewNetplayScreen() } } -private val previewPlayers = listOf( - Player( - pid = 1, - name = "Player 1", - revision = "123", - ping = 2, - isHost = true, - mapping = "m1" - ), - Player( - pid = 2, - name = "Player 2", - revision = "123", - ping = 23, - isHost = false, - mapping = "m2" - ), -) +@Composable +private fun PreviewNetplayScreen() { + NetplayScreen( + onBackClicked = {}, + players = listOf( + Player( + pid = 1, + name = "Player 1", + revision = "123", + ping = 2, + isHost = true, + mapping = "m1" + ), + Player( + pid = 2, + name = "Player 2", + revision = "123", + ping = 23, + isHost = false, + mapping = "m2" + ), + ), + messages = buildList { + repeat(5) { + add(NetplayMessage.Chat("Hello")) + } + }, + onSendMessage = {}, + game = "Game name", + ) +} diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index bd1acb0bfb..fa2d588652 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -999,4 +999,8 @@ It can efficiently compress both junk data and encrypted Wii data. Start Chat Send + Game changed to %1$s + Buffer size changed to %1$d + "Host input authority %1$s" + Game diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index b45849bf5d..0ea1b77c54 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -33,6 +33,10 @@ 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 jmethodID s_netplay_on_game_changed; +static jmethodID s_netplay_on_host_input_authority_changed; +static jmethodID s_netplay_on_pad_buffer_changed; +static jmethodID s_netplay_on_chat_message_received; static jmethodID s_netplay_update; static jclass s_netplay_player_class; @@ -268,6 +272,26 @@ jmethodID GetNetplayOnConnectionError() return s_netplay_on_connection_error; } +jmethodID GetNetplayOnGameChanged() +{ + return s_netplay_on_game_changed; +} + +jmethodID GetNetplayOnHostInputAuthorityChanged() +{ + return s_netplay_on_host_input_authority_changed; +} + +jmethodID GetNetplayOnPadBufferChanged() +{ + return s_netplay_on_pad_buffer_changed; +} + +jmethodID GetNetplayOnChatMessageReceived() +{ + return s_netplay_on_chat_message_received; +} + jmethodID GetNetplayUpdate() { return s_netplay_update; @@ -694,6 +718,14 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) 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"); + s_netplay_on_game_changed = + env->GetStaticMethodID(netplay_class, "onGameChanged", "(Ljava/lang/String;)V"); + s_netplay_on_host_input_authority_changed = + env->GetStaticMethodID(netplay_class, "onHostInputAuthorityChanged", "(Z)V"); + s_netplay_on_pad_buffer_changed = + env->GetStaticMethodID(netplay_class, "onPadBufferChanged", "(I)V"); + s_netplay_on_chat_message_received = + env->GetStaticMethodID(netplay_class, "onChatMessageReceived", "(Ljava/lang/String;)V"); s_netplay_update = env->GetStaticMethodID(netplay_class, "onUpdate", "([Lorg/dolphinemu/dolphinemu/features/netplay/model/Player;)V"); env->DeleteLocalRef(netplay_class); diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h index 0e393f919a..f61c0265d9 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -36,6 +36,10 @@ jfieldID GetNetPlayClientPointer(); jfieldID GetNetplayBootSessionDataPointer(); jmethodID GetNetplayOnBootGame(); jmethodID GetNetplayOnConnectionError(); +jmethodID GetNetplayOnGameChanged(); +jmethodID GetNetplayOnHostInputAuthorityChanged(); +jmethodID GetNetplayOnPadBufferChanged(); +jmethodID GetNetplayOnChatMessageReceived(); jmethodID GetNetplayUpdate(); jclass GetNetplayPlayerClass(); diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index 478052e36c..ee8443b958 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -59,13 +59,23 @@ void NetPlayUICallbacks::Update() env->DeleteLocalRef(player_array); } -void NetPlayUICallbacks::AppendChat(const std::string&) {} +void NetPlayUICallbacks::AppendChat(const std::string& message) +{ + JNIEnv* env = IDCache::GetEnvForThread(); + env->CallStaticVoidMethod(IDCache::GetNetplayClass(), + IDCache::GetNetplayOnChatMessageReceived(), + ToJString(env, message)); +} void NetPlayUICallbacks::OnMsgChangeGame(const NetPlay::SyncIdentifier& sync_identifier, const std::string& netplay_name) { m_current_game_identifier = sync_identifier; m_current_game_name = netplay_name; + + JNIEnv* env = IDCache::GetEnvForThread(); + env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayOnGameChanged(), + ToJString(env, netplay_name)); } void NetPlayUICallbacks::OnMsgChangeGBARom(int, const NetPlay::GBAConfig&) {} @@ -86,8 +96,23 @@ void NetPlayUICallbacks::OnMsgStopGame() {} void NetPlayUICallbacks::OnMsgPowerButton() {} void NetPlayUICallbacks::OnPlayerConnect(const std::string&) {} void NetPlayUICallbacks::OnPlayerDisconnect(const std::string&) {} -void NetPlayUICallbacks::OnPadBufferChanged(u32) {} -void NetPlayUICallbacks::OnHostInputAuthorityChanged(bool) {} + +void NetPlayUICallbacks::OnPadBufferChanged(u32 buffer) +{ + //TODO handle host input authority = true + JNIEnv* env = IDCache::GetEnvForThread(); + env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayOnPadBufferChanged(), + static_cast(buffer)); +} + +void NetPlayUICallbacks::OnHostInputAuthorityChanged(bool enabled) +{ + JNIEnv* env = IDCache::GetEnvForThread(); + env->CallStaticVoidMethod(IDCache::GetNetplayClass(), + IDCache::GetNetplayOnHostInputAuthorityChanged(), + static_cast(enabled)); +} + void NetPlayUICallbacks::OnDesync(u32, const std::string&) {} void NetPlayUICallbacks::OnConnectionLost() {} diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index c5fdd21ad5..75fb10ef31 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -136,6 +136,14 @@ Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_isClientConnected(JNIEnv return static_cast(GetPointer(env)->IsConnected()); } +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_sendMessage(JNIEnv* env, jclass, + jstring jmessage) +{ + if (auto* client = GetPointer(env)) + client->SendChatMessage(GetJString(env, jmessage)); +} + JNIEXPORT jlong JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_Join(JNIEnv* env, jclass) { From 64fd5de16b09fb724625090aa7e19f6f098c705f Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 15 Apr 2026 22:23:37 +0200 Subject: [PATCH 14/37] Put players table in an OutlinedBox, makes the UI look more consistent. --- .../features/netplay/ui/NetplayScreen.kt | 28 +++++++--- .../dolphinemu/ui/theme/DolphinTheme.kt | 55 +++++++++++-------- .../app/src/main/res/values/strings.xml | 4 ++ 3 files changed, 55 insertions(+), 32 deletions(-) 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 d5fdab900a..999746f0ea 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 @@ -192,15 +192,25 @@ private fun PLayersAndSettings( MenuSpacer() - PlayersTable( - rows = buildList { - add(listOf("Player", "Ping", "Mapping")) - addAll(players.map { listOf(it.name, it.ping.toString(), it.mapping) }) - repeat(4 - players.size) { add(listOf("", "", "")) } - }, - modifier = Modifier - .fillMaxWidth() - ) + OutlinedBox( + label = { Text(stringResource(R.string.netplay_players_label)) }, + ) { + PlayersTable( + rows = buildList { + add( + listOf( + stringResource(R.string.netplay_players_name), + stringResource(R.string.netplay_players_ping), + stringResource(R.string.netplay_players_mapping), + ) + ) + addAll(players.map { listOf(it.name, it.ping.toString(), it.mapping) }) + repeat(4 - players.size) { add(listOf("", "", "")) } + }, + modifier = Modifier + .fillMaxWidth() + ) + } } } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt index d24b3d834f..cf768e6dc0 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt @@ -135,9 +135,10 @@ fun MenuSpacer() = Spacer(modifier = Modifier.height(16.dp)) @OptIn(ExperimentalMaterial3Api::class) @Composable fun OutlinedBox( - onClick: () -> Unit, label: @Composable () -> Unit, modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + fadeContentTop: Boolean = false, content: @Composable () -> Unit, ) { Box( @@ -153,24 +154,30 @@ fun OutlinedBox( .fillMaxSize() ) { content() - Box( - modifier = Modifier - .fillMaxWidth() - .height(16.dp) - .background( - Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.surface, - Color.Transparent + if (fadeContentTop) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(16.dp) + .background( + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.surface, + Color.Transparent + ) ) ) - ) - ) + ) + } } }, enabled = true, singleLine = false, - contentPadding = OutlinedTextFieldDefaults.contentPadding(top = 0.dp), + contentPadding = if (fadeContentTop) { + OutlinedTextFieldDefaults.contentPadding(top = 0.dp) + } else { + OutlinedTextFieldDefaults.contentPadding() + }, visualTransformation = VisualTransformation.None, interactionSource = interactionSource, label = { label() }, @@ -183,15 +190,17 @@ fun OutlinedBox( ) } ) - Box( - modifier = Modifier - .fillMaxSize() - .clip(MaterialTheme.shapes.extraSmall) - .clickable( - interactionSource = interactionSource, - indication = LocalIndication.current, - onClick = onClick, - ) - ) + if (onClick != null) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.extraSmall) + .clickable( + interactionSource = interactionSource, + indication = LocalIndication.current, + onClick = onClick, + ) + ) + } } } diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index fa2d588652..8383824940 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1003,4 +1003,8 @@ It can efficiently compress both junk data and encrypted Wii data. Buffer size changed to %1$d "Host input authority %1$s" Game + Players + Name + Ping + Mapping From a87d31528363e169e8b482d36492bc2986427381 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 15 Apr 2026 22:23:02 +0200 Subject: [PATCH 15/37] Max buffer --- .../dolphinemu/features/netplay/Netplay.kt | 11 ++ .../netplay/model/NetplayViewModel.kt | 14 ++ .../features/netplay/ui/NetplayActivity.kt | 3 + .../features/netplay/ui/NetplayScreen.kt | 150 +++++++++++++++++- .../app/src/main/res/values/strings.xml | 1 + .../jni/NetPlay/NetPlayUICallbacks.cpp | 1 - Source/Android/jni/NetPlay/Netplay.cpp | 21 +++ 7 files changed, 199 insertions(+), 2 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 88baa9f495..319929e71e 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 @@ -148,6 +148,9 @@ object Netplay { @JvmStatic external fun sendMessage(message: String) + @JvmStatic + external fun adjustPadBufferSize(buffer: Int) + @JvmStatic private external fun ReleaseNetplayClient() @@ -193,6 +196,8 @@ object Netplay { @JvmStatic fun onPadBufferChanged(buffer: Int) { + // Only for remote pad buffer settings. Ignore local max buffer changes. + if (_hostInputAuthorityEnabled.replayCache.firstOrNull() == true) return _padBuffer.tryEmit(buffer) } @@ -240,6 +245,12 @@ object Netplay { @JvmStatic external fun getIndexPassword(): String + @JvmStatic + external fun getClientBufferSize(): Int + + @JvmStatic + external fun setClientBufferSize(buffer: Int) + suspend fun saveSetup( nickname: String, connectionType: ConnectionType, 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 cc9dc00ae0..9da56d0c48 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 @@ -10,7 +10,9 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.CONFLATED +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -32,6 +34,12 @@ class NetplayViewModel : ViewModel() { val game = Netplay.game .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") + val hostInputAuthority = Netplay.hostInputAuthorityEnabled + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) + + private val _maxBuffer = MutableStateFlow(Netplay.getClientBufferSize()) + val maxBuffer = _maxBuffer.asStateFlow() + init { if (!Netplay.isClientConnected()) { _goBack.trySend(Unit) @@ -47,6 +55,12 @@ class NetplayViewModel : ViewModel() { Netplay.sendMessage(trimmedMessage) } + fun setMaxBuffer(buffer: Int) { + _maxBuffer.value = buffer + Netplay.setClientBufferSize(buffer) + Netplay.adjustPadBufferSize(buffer) + } + @OptIn(DelicateCoroutinesApi::class) override fun onCleared() { super.onCleared() 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 c4d0ded675..fec0823456 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 @@ -48,6 +48,9 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { onSendMessage = viewModel::sendMessage, game = viewModel.game.collectAsState().value, players = viewModel.players.collectAsState().value, + hostInputAuthorityEnabled = viewModel.hostInputAuthority.collectAsState().value, + maxBuffer = viewModel.maxBuffer.collectAsState().value, + onMaxBufferChanged = viewModel::setMaxBuffer, ) } } 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 999746f0ea..e9181151e3 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 @@ -8,25 +8,32 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions 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.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.ModalBottomSheet @@ -48,8 +55,12 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.dolphinemu.dolphinemu.R @@ -67,6 +78,9 @@ fun NetplayScreen( messages: List, onSendMessage: (String) -> Unit, game: String, + hostInputAuthorityEnabled: Boolean, + maxBuffer: Int, + onMaxBufferChanged: (Int) -> Unit, players: List, ) { Scaffold( @@ -95,6 +109,9 @@ fun NetplayScreen( onSendMessage = onSendMessage, game = game, players = players, + hostInputAuthorityEnabled = hostInputAuthorityEnabled, + maxBuffer = maxBuffer, + onMaxBufferChanged = onMaxBufferChanged, modifier = modifier ) } else { @@ -103,6 +120,9 @@ fun NetplayScreen( onSendMessage = onSendMessage, game = game, players = players, + hostInputAuthorityEnabled = hostInputAuthorityEnabled, + maxBuffer = maxBuffer, + onMaxBufferChanged = onMaxBufferChanged, modifier = modifier ) } @@ -115,6 +135,9 @@ private fun PortraitContent( onSendMessage: (String) -> Unit, game: String, players: List, + hostInputAuthorityEnabled: Boolean, + maxBuffer: Int, + onMaxBufferChanged: (Int) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -134,9 +157,12 @@ private fun PortraitContent( PLayersAndSettings( game = game, players = players, + hostInputAuthorityEnabled = hostInputAuthorityEnabled, + maxBuffer = maxBuffer, + onMaxBufferChanged = onMaxBufferChanged, modifier = Modifier .weight(1f) - .padding(horizontal = DolphinTheme.scaffoldPadding) + .padding(horizontal = DolphinTheme.scaffoldPadding), ) } } @@ -147,6 +173,9 @@ private fun LandscapeContent( onSendMessage: (String) -> Unit, game: String, players: List, + hostInputAuthorityEnabled: Boolean, + maxBuffer: Int, + onMaxBufferChanged: (Int) -> Unit, modifier: Modifier = Modifier, ) { Row( @@ -164,6 +193,9 @@ private fun LandscapeContent( PLayersAndSettings( game = game, players = players, + hostInputAuthorityEnabled = hostInputAuthorityEnabled, + maxBuffer = maxBuffer, + onMaxBufferChanged = onMaxBufferChanged, modifier = Modifier .weight(1f) .padding(horizontal = DolphinTheme.scaffoldPadding) @@ -175,6 +207,9 @@ private fun LandscapeContent( private fun PLayersAndSettings( game: String, players: List, + hostInputAuthorityEnabled: Boolean, + maxBuffer: Int, + onMaxBufferChanged: (Int) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -211,6 +246,16 @@ private fun PLayersAndSettings( .fillMaxWidth() ) } + + if (hostInputAuthorityEnabled) { + MenuSpacer() + + BufferInput( + value = maxBuffer, + onValueChange = onMaxBufferChanged, + label = stringResource(R.string.netplay_max_buffer), + ) + } } } @@ -353,6 +398,106 @@ private fun PlayersTable( } } +@Composable +private fun BufferInput( + value: Int, + onValueChange: (Int) -> Unit, + label: String, +) { + val range = 0..99 + var maybeEmptyValue by remember(value) { + mutableStateOf("$value") + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + ) { + OutlinedTextField( + value = TextFieldValue( + text = maybeEmptyValue, + selection = TextRange(maybeEmptyValue.length) + ), + onValueChange = { newValue -> + if (newValue.text.isEmpty()) { + maybeEmptyValue = newValue.text + return@OutlinedTextField + } + newValue.text.toIntOrNull()?.let { + if (it in range) { + onValueChange(it) + } + } + }, + label = { Text(label) }, + textStyle = LocalTextStyle.current.copy( + textAlign = TextAlign.Center + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + ), + singleLine = true, + modifier = Modifier + .weight(1f) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Button( + onClick = { + if (maybeEmptyValue.isEmpty()) { + maybeEmptyValue = "0" + onValueChange(0) + } else { + val newValue = value - 1 + if (newValue in range) { + onValueChange(newValue) + } + } + }, + shape = RoundedCornerShape( + topStartPercent = 50, + topEndPercent = 0, + bottomEndPercent = 0, + bottomStartPercent = 50, + ), + modifier = Modifier + .height(60.dp) + .padding(top = 8.dp) + ) { + Icon(Icons.Filled.Remove, contentDescription = "Back") + } + + Spacer(modifier = Modifier.width(2.dp)) + + Button( + onClick = { + if (maybeEmptyValue.isEmpty()) { + maybeEmptyValue = "0" + onValueChange(0) + } else { + val newValue = value + 1 + if (newValue in range) { + onValueChange(newValue) + } + } + }, + shape = RoundedCornerShape( + topStartPercent = 0, + topEndPercent = 50, + bottomEndPercent = 50, + bottomStartPercent = 0, + ), + modifier = Modifier + .height(60.dp) + .padding(top = 8.dp) + ) { + Icon(Icons.Filled.Add, contentDescription = "Back") + } + } +} + @Preview @Composable private fun NetplayScreenPreview() { @@ -419,5 +564,8 @@ private fun PreviewNetplayScreen() { }, onSendMessage = {}, game = "Game name", + hostInputAuthorityEnabled = true, + maxBuffer = 10, + onMaxBufferChanged = {}, ) } diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 8383824940..0bd19d654b 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1007,4 +1007,5 @@ It can efficiently compress both junk data and encrypted Wii data. Name Ping Mapping + Max buffer diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index ee8443b958..8f80d336b0 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -99,7 +99,6 @@ void NetPlayUICallbacks::OnPlayerDisconnect(const std::string&) {} void NetPlayUICallbacks::OnPadBufferChanged(u32 buffer) { - //TODO handle host input authority = true JNIEnv* env = IDCache::GetEnvForThread(); env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayOnPadBufferChanged(), static_cast(buffer)); diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index 75fb10ef31..8b4c7fff65 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -103,6 +103,19 @@ Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getIndexPassword(JNIEnv* return ToJString(env, Config::Get(Config::NETPLAY_INDEX_PASSWORD)); } +JNIEXPORT jint JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getClientBufferSize(JNIEnv*, jclass) +{ + return static_cast(Config::Get(Config::NETPLAY_CLIENT_BUFFER_SIZE)); +} + +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_setClientBufferSize(JNIEnv*, jclass, + jint buffer) +{ + Config::SetBase(Config::NETPLAY_CLIENT_BUFFER_SIZE, static_cast(buffer)); +} + JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_SaveSetup( JNIEnv* env, jclass, jstring jnickname, jstring traversalChoice, jstring jaddress, @@ -144,6 +157,14 @@ Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_sendMessage(JNIEnv* env, client->SendChatMessage(GetJString(env, jmessage)); } +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_adjustPadBufferSize(JNIEnv* env, jclass, + jint buffer) +{ + if (auto* client = GetPointer(env)) + client->AdjustPadBufferSize(static_cast(buffer)); +} + JNIEXPORT jlong JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_Join(JNIEnv* env, jclass) { From fd21ca13ff365ac7dfb0fa673cbfd6bd6b7a24e3 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Sun, 19 Apr 2026 20:07:37 +0200 Subject: [PATCH 16/37] Settings refactor Remove the big saveSetup function and set individual settings immediately after being changed in the UI. Group them all under Netplay.Settings --- .../dolphinemu/features/netplay/Netplay.kt | 107 ++++---------- .../netplay/model/NetplaySetupViewModel.kt | 23 ++-- .../netplay/model/NetplayViewModel.kt | 4 +- Source/Android/jni/NetPlay/Netplay.cpp | 130 +++++++----------- 4 files changed, 89 insertions(+), 175 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 319929e71e..94de9bd98c 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 @@ -202,99 +202,46 @@ object Netplay { } // Settings + object Settings { + @JvmStatic + external fun getNickname(): String - @JvmStatic - external fun getNickname(): String + @JvmStatic + external fun setNickname(nickname: String) - fun getConnectionType(): ConnectionType = ConnectionType.all - .find { it.configValue == getTraversalChoice() } ?: throw IllegalStateException() + fun getConnectionType(): ConnectionType = ConnectionType.all + .find { it.configValue == getTraversalChoice() } ?: throw IllegalStateException() - @JvmStatic - external fun getTraversalChoice(): String + @JvmStatic + external fun getTraversalChoice(): String - @JvmStatic - external fun getAddress(): String + @JvmStatic + external fun setTraversalChoice(traversalChoice: String) - @JvmStatic - external fun getHostCode(): String + @JvmStatic + external fun getAddress(): String - @JvmStatic - external fun getConnectPort(): Int + @JvmStatic + external fun setAddress(address: String) - @JvmStatic - external fun getHostPort(): Int + @JvmStatic + external fun getHostCode(): String - @JvmStatic - external fun getUseUpnp(): Boolean + @JvmStatic + external fun setHostCode(hostCode: String) - @JvmStatic - external fun getEnableChunkedUploadLimit(): Boolean + @JvmStatic + external fun getConnectPort(): Int - @JvmStatic - external fun getChunkedUploadLimit(): Int + @JvmStatic + external fun setConnectPort(port: Int) - @JvmStatic - external fun getUseIndex(): Boolean + @JvmStatic + external fun getClientBufferSize(): Int - @JvmStatic - external fun getIndexRegion(): String - - @JvmStatic - external fun getIndexName(): String - - @JvmStatic - external fun getIndexPassword(): String - - @JvmStatic - external fun getClientBufferSize(): Int - - @JvmStatic - external fun setClientBufferSize(buffer: Int) - - suspend fun saveSetup( - nickname: String, - connectionType: ConnectionType, - address: String, - hostCode: String, - connectPort: Int, - ) = withContext(Dispatchers.IO) { - SaveSetup( - nickname = nickname, - traversalChoice = connectionType.configValue, - address = address, - hostCode = hostCode, - connectPort = connectPort, - hostPort = 2626, - useUpnp = false, - useListenPort = false, - listenPort = 2626, - enableChunkedUploadLimit = false, - chunkedUploadLimit = 3000, - useIndex = false, - indexRegion = "", - indexName = "", - indexPassword = "", - ) + @JvmStatic + external fun setClientBufferSize(buffer: Int) } - - @JvmStatic - external fun SaveSetup( - nickname: String, - traversalChoice: String, - address: String, - hostCode: String, - connectPort: Int, - hostPort: Int, - useUpnp: Boolean, - useListenPort: Boolean, - listenPort: Int, - enableChunkedUploadLimit: Boolean, - chunkedUploadLimit: Int, - useIndex: Boolean, - indexRegion: String, - indexName: String, - indexPassword: String, - ) } private fun Channel.flush() { 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 19175e4df8..d1bd202c2f 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 @@ -19,19 +19,19 @@ class NetplaySetupViewModel : ViewModel() { private val _connectionRole = MutableStateFlow(ConnectionRole.Connect) val connectionRole = _connectionRole.asStateFlow() - private val _nickname = MutableStateFlow(Netplay.getNickname()) + private val _nickname = MutableStateFlow(Netplay.Settings.getNickname()) val nickname = _nickname.asStateFlow() - private val _connectionType = MutableStateFlow(Netplay.getConnectionType()) + private val _connectionType = MutableStateFlow(Netplay.Settings.getConnectionType()) val connectionType = _connectionType.asStateFlow() - private val _ipAddress = MutableStateFlow(Netplay.getAddress()) + private val _ipAddress = MutableStateFlow(Netplay.Settings.getAddress()) val ipAddress = _ipAddress.asStateFlow() - private val _hostCode = MutableStateFlow(Netplay.getHostCode()) + private val _hostCode = MutableStateFlow(Netplay.Settings.getHostCode()) val hostCode = _hostCode.asStateFlow() - private val _connectPort = MutableStateFlow(Netplay.getConnectPort().toString()) + private val _connectPort = MutableStateFlow(Netplay.Settings.getConnectPort().toString()) val connectPort = _connectPort.asStateFlow() private val _showNetplayScreen = Channel(CONFLATED) @@ -52,25 +52,30 @@ class NetplaySetupViewModel : ViewModel() { fun setNickname(nickname: String) { _nickname.value = nickname + Netplay.Settings.setNickname(nickname) } fun setConnectionType(connectionType: ConnectionType) { _connectionType.value = connectionType + Netplay.Settings.setTraversalChoice(connectionType.configValue) } fun setIpAddress(ipAddress: String) { if (ipAddress.all { it.isDigit() || it == '.' }) { _ipAddress.value = ipAddress + Netplay.Settings.setAddress(ipAddress) } } fun setHostCode(hostCode: String) { _hostCode.value = hostCode + Netplay.Settings.setHostCode(hostCode) } fun setConnectPort(port: String) { if (port.all { it.isDigit() }) { _connectPort.value = port + port.toIntOrNull()?.let { Netplay.Settings.setConnectPort(it) } } } @@ -80,14 +85,6 @@ class NetplaySetupViewModel : ViewModel() { 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) } 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 9da56d0c48..217991f40c 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 @@ -37,7 +37,7 @@ class NetplayViewModel : ViewModel() { val hostInputAuthority = Netplay.hostInputAuthorityEnabled .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) - private val _maxBuffer = MutableStateFlow(Netplay.getClientBufferSize()) + private val _maxBuffer = MutableStateFlow(Netplay.Settings.getClientBufferSize()) val maxBuffer = _maxBuffer.asStateFlow() init { @@ -57,7 +57,7 @@ class NetplayViewModel : ViewModel() { fun setMaxBuffer(buffer: Int) { _maxBuffer.value = buffer - Netplay.setClientBufferSize(buffer) + Netplay.Settings.setClientBufferSize(buffer) Netplay.adjustPadBufferSize(buffer) } diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index 8b4c7fff65..9dd2743d53 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -26,123 +26,93 @@ static NetPlay::NetPlayClient* GetPointer(JNIEnv* env) extern "C" { JNIEXPORT jstring JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getNickname(JNIEnv* env, jclass) +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_getNickname(JNIEnv* env, + jclass) { return ToJString(env, Config::Get(Config::NETPLAY_NICKNAME)); } +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_setNickname(JNIEnv* env, + jclass, + jstring jnickname) +{ + Config::SetBase(Config::NETPLAY_NICKNAME, GetJString(env, jnickname)); +} + JNIEXPORT jstring JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getTraversalChoice(JNIEnv* env, jclass) +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_getTraversalChoice( + JNIEnv* env, jclass) { return ToJString(env, Config::Get(Config::NETPLAY_TRAVERSAL_CHOICE)); } +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_setTraversalChoice( + JNIEnv* env, jclass, jstring jtraversalChoice) +{ + Config::SetBase(Config::NETPLAY_TRAVERSAL_CHOICE, GetJString(env, jtraversalChoice)); +} + JNIEXPORT jstring JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getAddress(JNIEnv* env, jclass) +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_getAddress(JNIEnv* env, + jclass) { return ToJString(env, Config::Get(Config::NETPLAY_ADDRESS)); } +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_setAddress(JNIEnv* env, + jclass, + jstring jaddress) +{ + Config::SetBase(Config::NETPLAY_ADDRESS, GetJString(env, jaddress)); +} + JNIEXPORT jstring JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getHostCode(JNIEnv* env, jclass) +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_getHostCode(JNIEnv* env, + jclass) { return ToJString(env, Config::Get(Config::NETPLAY_HOST_CODE)); } +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_setHostCode(JNIEnv* env, + jclass, + jstring jhostCode) +{ + Config::SetBase(Config::NETPLAY_HOST_CODE, GetJString(env, jhostCode)); +} + JNIEXPORT jint JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getConnectPort(JNIEnv*, jclass) +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_getConnectPort(JNIEnv*, + jclass) { return static_cast(Config::Get(Config::NETPLAY_CONNECT_PORT)); } -JNIEXPORT jint JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getHostPort(JNIEnv*, jclass) +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_setConnectPort(JNIEnv*, + jclass, + jint port) { - return static_cast(Config::Get(Config::NETPLAY_HOST_PORT)); -} - -JNIEXPORT jboolean JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getUseUpnp(JNIEnv*, jclass) -{ - return static_cast(Config::Get(Config::NETPLAY_USE_UPNP)); -} - -JNIEXPORT jboolean JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getEnableChunkedUploadLimit(JNIEnv*, jclass) -{ - return static_cast(Config::Get(Config::NETPLAY_ENABLE_CHUNKED_UPLOAD_LIMIT)); + Config::SetBase(Config::NETPLAY_CONNECT_PORT, static_cast(port)); } JNIEXPORT jint JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getChunkedUploadLimit(JNIEnv*, jclass) -{ - return static_cast(Config::Get(Config::NETPLAY_CHUNKED_UPLOAD_LIMIT)); -} - -JNIEXPORT jboolean JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getUseIndex(JNIEnv*, jclass) -{ - return static_cast(Config::Get(Config::NETPLAY_USE_INDEX)); -} - -JNIEXPORT jstring JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getIndexRegion(JNIEnv* env, jclass) -{ - return ToJString(env, Config::Get(Config::NETPLAY_INDEX_REGION)); -} - -JNIEXPORT jstring JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getIndexName(JNIEnv* env, jclass) -{ - return ToJString(env, Config::Get(Config::NETPLAY_INDEX_NAME)); -} - -JNIEXPORT jstring JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getIndexPassword(JNIEnv* env, jclass) -{ - return ToJString(env, Config::Get(Config::NETPLAY_INDEX_PASSWORD)); -} - -JNIEXPORT jint JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_getClientBufferSize(JNIEnv*, jclass) +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_getClientBufferSize(JNIEnv*, + jclass) { return static_cast(Config::Get(Config::NETPLAY_CLIENT_BUFFER_SIZE)); } JNIEXPORT void JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_setClientBufferSize(JNIEnv*, jclass, - jint buffer) +Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_setClientBufferSize( + JNIEnv*, jclass, jint buffer) { Config::SetBase(Config::NETPLAY_CLIENT_BUFFER_SIZE, static_cast(buffer)); } -JNIEXPORT void JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_SaveSetup( - JNIEnv* env, jclass, jstring jnickname, jstring traversalChoice, jstring jaddress, - jstring jhostCode, jint connectPort, jint hostPort, jboolean useUpnp, jboolean useListenPort, - jint listenPort, jboolean enableChunkedUploadLimit, jint chunkedUploadLimit, jboolean useIndex, - jstring jindexRegion, jstring jindexName, jstring jindexPassword) -{ - Config::ConfigChangeCallbackGuard config_guard; - - Config::SetBaseOrCurrent(Config::NETPLAY_NICKNAME, GetJString(env, jnickname)); - Config::SetBaseOrCurrent(Config::NETPLAY_TRAVERSAL_CHOICE, GetJString(env, traversalChoice)); - Config::SetBaseOrCurrent(Config::NETPLAY_ADDRESS, GetJString(env, jaddress)); - Config::SetBaseOrCurrent(Config::NETPLAY_HOST_CODE, GetJString(env, jhostCode)); - Config::SetBaseOrCurrent(Config::NETPLAY_CONNECT_PORT, static_cast(connectPort)); - Config::SetBaseOrCurrent(Config::NETPLAY_HOST_PORT, static_cast(hostPort)); - Config::SetBaseOrCurrent(Config::NETPLAY_USE_UPNP, static_cast(useUpnp)); - Config::SetBaseOrCurrent(Config::NETPLAY_ENABLE_CHUNKED_UPLOAD_LIMIT, - static_cast(enableChunkedUploadLimit)); - Config::SetBaseOrCurrent(Config::NETPLAY_CHUNKED_UPLOAD_LIMIT, - static_cast(chunkedUploadLimit)); - Config::SetBaseOrCurrent(Config::NETPLAY_USE_INDEX, static_cast(useIndex)); - Config::SetBaseOrCurrent(Config::NETPLAY_INDEX_REGION, GetJString(env, jindexRegion)); - Config::SetBaseOrCurrent(Config::NETPLAY_INDEX_NAME, GetJString(env, jindexName)); - Config::SetBaseOrCurrent(Config::NETPLAY_INDEX_PASSWORD, GetJString(env, jindexPassword)); - Config::SetBaseOrCurrent(Config::NETPLAY_LISTEN_PORT, static_cast(listenPort)); -} - JNIEXPORT jboolean JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_isClientConnected(JNIEnv* env, jclass) { From b21cbc63f706e316fbbe9cab67be989f08ca6c9e Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 22 Apr 2026 18:46:12 +0200 Subject: [PATCH 17/37] Implement StopGame callback and use it to finish the emulation activity --- .../dolphinemu/features/netplay/Netplay.kt | 13 +++++++++- .../dolphinemu/fragments/EmulationFragment.kt | 7 ++++++ Source/Android/jni/AndroidCommon/IDCache.cpp | 7 ++++++ Source/Android/jni/AndroidCommon/IDCache.h | 1 + .../jni/NetPlay/NetPlayUICallbacks.cpp | 24 ++++++++++++++++++- .../Android/jni/NetPlay/NetPlayUICallbacks.h | 3 +++ 6 files changed, 53 insertions(+), 2 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 94de9bd98c..a90d1b00d8 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 @@ -44,6 +44,9 @@ object Netplay { private val _launchGame = Channel(Channel.CONFLATED) val launchGame = _launchGame.receiveAsFlow() + private val _stopGame = Channel(Channel.CONFLATED) + val stopGame = _stopGame.receiveAsFlow() + private val _connectionErrors = Channel(Channel.BUFFERED) val connectionErrors = _connectionErrors.receiveAsFlow() @@ -123,6 +126,7 @@ object Netplay { } _launchGame.flush() + _stopGame.flush() _connectionErrors.flush() _players.resetReplayCache() _messages.resetReplayCache() @@ -166,9 +170,16 @@ object Netplay { @JvmStatic fun onBootGame(gameFilePath: String, bootSessionDataPointer: Long) { this.bootSessionDataPointer = bootSessionDataPointer + _stopGame.flush() _launchGame.trySend(gameFilePath) } + @Keep + @JvmStatic + fun onStopGame() { + _stopGame.trySend(Unit) + } + @JvmStatic fun onConnectionError(message: String) { _connectionErrors.trySend(message) @@ -244,6 +255,6 @@ object Netplay { } } -private fun Channel.flush() { +private fun Channel.flush() { while (this.tryReceive().isSuccess) Unit } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt index 4ee4aee8aa..61a5493950 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt @@ -10,6 +10,9 @@ import android.view.SurfaceHolder import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import org.dolphinemu.dolphinemu.NativeLibrary import org.dolphinemu.dolphinemu.activities.EmulationActivity import org.dolphinemu.dolphinemu.databinding.FragmentEmulationBinding @@ -220,6 +223,10 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { val paths = requireNotNull(gamePaths) { "Cannot start emulation without any game paths" } + lifecycleScope.launch { + Netplay.stopGame.first() + stopEmulation() + } NativeLibrary.RunNetPlay(paths, riivolution) } else { Log.debug("[EmulationFragment] Starting emulation thread.") diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index 0ea1b77c54..bc59fe0053 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_stop_game; static jmethodID s_netplay_on_connection_error; static jmethodID s_netplay_on_game_changed; static jmethodID s_netplay_on_host_input_authority_changed; @@ -267,6 +268,11 @@ jmethodID GetNetplayOnBootGame() return s_netplay_on_boot_game; } +jmethodID GetNetplayOnStopGame() +{ + return s_netplay_on_stop_game; +} + jmethodID GetNetplayOnConnectionError() { return s_netplay_on_connection_error; @@ -717,6 +723,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_stop_game = env->GetStaticMethodID(netplay_class, "onStopGame", "()V"); s_netplay_on_connection_error = env->GetStaticMethodID(netplay_class, "onConnectionError", "(Ljava/lang/String;)V"); s_netplay_on_game_changed = env->GetStaticMethodID(netplay_class, "onGameChanged", "(Ljava/lang/String;)V"); diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h index f61c0265d9..5646669f18 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 GetNetplayOnStopGame(); jmethodID GetNetplayOnConnectionError(); jmethodID GetNetplayOnGameChanged(); jmethodID GetNetplayOnHostInputAuthorityChanged(); diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index 8f80d336b0..dd25d54280 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -4,6 +4,7 @@ #include "UICommon/GameFile.h" #include "NetPlayUICallbacks.h" #include "Core/Boot/Boot.h" +#include "Core/Core.h" #include "jni/AndroidCommon/AndroidCommon.h" #include "jni/AndroidCommon/IDCache.h" @@ -12,17 +13,38 @@ namespace NetPlay { NetPlayUICallbacks::NetPlayUICallbacks(std::vector> games) : m_games(std::move(games)) { + m_state_changed_hook = Core::AddOnStateChangedCallback([this](Core::State state) { + if ((state == Core::State::Uninitialized || state == Core::State::Stopping) && + !m_got_stop_request) + { + JNIEnv* env = IDCache::GetEnvForThread(); + auto* client = reinterpret_cast( + env->GetStaticLongField(IDCache::GetNetplayClass(), IDCache::GetNetPlayClientPointer())); + if (client) + client->RequestStopGame(); + } + }); } NetPlayUICallbacks::~NetPlayUICallbacks() = default; void NetPlayUICallbacks::BootGame(const std::string& filename, std::unique_ptr boot_session_data) { + m_got_stop_request = false; JNIEnv* env = IDCache::GetEnvForThread(); env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayOnBootGame(), ToJString(env, filename), reinterpret_cast(boot_session_data.release())); } -void NetPlayUICallbacks::StopGame() {} +void NetPlayUICallbacks::StopGame() +{ + if (m_got_stop_request) + return; + + m_got_stop_request = true; + JNIEnv* env = IDCache::GetEnvForThread(); + env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayOnStopGame()); +} + bool NetPlayUICallbacks::IsHosting() const { return false; } void NetPlayUICallbacks::Update() diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.h b/Source/Android/jni/NetPlay/NetPlayUICallbacks.h index acccfccabf..10c1b1ac22 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.h +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.h @@ -5,6 +5,7 @@ #include #include +#include "Common/HookableEvent.h" #include "Core/NetPlayClient.h" #include "UICommon/GameFile.h" @@ -61,6 +62,8 @@ private: std::vector> m_games; NetPlay::SyncIdentifier m_current_game_identifier; std::string m_current_game_name; + Common::EventHook m_state_changed_hook; + bool m_got_stop_request = true; }; } // namespace NetPlay From ee1271e5b2ea6c2855c1864d9b23988f6a09daa4 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Sat, 25 Apr 2026 17:40:40 +0200 Subject: [PATCH 18/37] Implement OnConnectionLost, show a dialog on the main netplay screen forcing the user to exit --- .../dolphinemu/features/netplay/Netplay.kt | 11 ++++++++- .../netplay/model/NetplayViewModel.kt | 2 ++ .../features/netplay/ui/NetplayActivity.kt | 1 + .../features/netplay/ui/NetplayScreen.kt | 24 ++++++++++++++++++- .../app/src/main/res/values/strings.xml | 1 + Source/Android/jni/AndroidCommon/IDCache.cpp | 7 ++++++ Source/Android/jni/AndroidCommon/IDCache.h | 1 + .../jni/NetPlay/NetPlayUICallbacks.cpp | 7 +++++- 8 files changed, 51 insertions(+), 3 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 a90d1b00d8..7298795d71 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 @@ -47,6 +47,9 @@ object Netplay { private val _stopGame = Channel(Channel.CONFLATED) val stopGame = _stopGame.receiveAsFlow() + private val _connectionLost = Channel(Channel.CONFLATED) + val connectionLost = _connectionLost.receiveAsFlow() + private val _connectionErrors = Channel(Channel.BUFFERED) val connectionErrors = _connectionErrors.receiveAsFlow() @@ -63,7 +66,7 @@ object Netplay { val players = _players.asSharedFlow().distinctUntilChanged() private val _chatMessages = MutableSharedFlow( - replay = 1, + extraBufferCapacity = 32, onBufferOverflow = BufferOverflow.DROP_OLDEST ) val chatMessages = _chatMessages.asSharedFlow() @@ -180,6 +183,12 @@ object Netplay { _stopGame.trySend(Unit) } + @Keep + @JvmStatic + fun onConnectionLost() { + _connectionLost.trySend(Unit) + } + @JvmStatic fun onConnectionError(message: String) { _connectionErrors.trySend(message) 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 217991f40c..955fb876dd 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 @@ -25,6 +25,8 @@ class NetplayViewModel : ViewModel() { private val _goBack = Channel(CONFLATED) val goBack = _goBack.receiveAsFlow() + val connectionLost = Netplay.connectionLost + val players = Netplay.players .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) 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 fec0823456..463ad31506 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 @@ -44,6 +44,7 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { DolphinTheme { NetplayScreen( onBackClicked = { finish() }, + connectionLost = viewModel.connectionLost, 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 e9181151e3..c47c8b8e3e 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 @@ -28,6 +28,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -43,6 +44,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -63,6 +65,8 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import org.dolphinemu.dolphinemu.R import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage import org.dolphinemu.dolphinemu.features.netplay.model.Player @@ -75,6 +79,7 @@ import org.dolphinemu.dolphinemu.ui.theme.PreviewTheme @Composable fun NetplayScreen( onBackClicked: () -> Unit, + connectionLost: Flow, messages: List, onSendMessage: (String) -> Unit, game: String, @@ -126,6 +131,22 @@ fun NetplayScreen( modifier = modifier ) } + + var showConnectionLostDialog by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + connectionLost.collect { showConnectionLostDialog = true } + } + if (showConnectionLostDialog) { + AlertDialog( + text = { Text(stringResource(R.string.netplay_connection_lost)) }, + confirmButton = { + TextButton(onClick = onBackClicked) { + Text(stringResource(R.string.ok)) + } + }, + onDismissRequest = onBackClicked, + ) + } } } @@ -246,7 +267,7 @@ private fun PLayersAndSettings( .fillMaxWidth() ) } - + if (hostInputAuthorityEnabled) { MenuSpacer() @@ -539,6 +560,7 @@ private fun LandscapeNetplayScreenDarkPreview() { private fun PreviewNetplayScreen() { NetplayScreen( onBackClicked = {}, + connectionLost = emptyFlow(), players = listOf( Player( pid = 1, diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 0bd19d654b..7b4311c996 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1008,4 +1008,5 @@ It can efficiently compress both junk data and encrypted Wii data. Ping Mapping Max buffer + Netplay connection lost diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index bc59fe0053..461576f217 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -33,6 +33,7 @@ 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_stop_game; +static jmethodID s_netplay_on_connection_lost; static jmethodID s_netplay_on_connection_error; static jmethodID s_netplay_on_game_changed; static jmethodID s_netplay_on_host_input_authority_changed; @@ -273,6 +274,11 @@ jmethodID GetNetplayOnStopGame() return s_netplay_on_stop_game; } +jmethodID GetNetplayOnConnectionLost() +{ + return s_netplay_on_connection_lost; +} + jmethodID GetNetplayOnConnectionError() { return s_netplay_on_connection_error; @@ -724,6 +730,7 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) 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_stop_game = env->GetStaticMethodID(netplay_class, "onStopGame", "()V"); + s_netplay_on_connection_lost = env->GetStaticMethodID(netplay_class, "onConnectionLost", "()V"); s_netplay_on_connection_error = env->GetStaticMethodID(netplay_class, "onConnectionError", "(Ljava/lang/String;)V"); s_netplay_on_game_changed = env->GetStaticMethodID(netplay_class, "onGameChanged", "(Ljava/lang/String;)V"); diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h index 5646669f18..c535fcb9ae 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -36,6 +36,7 @@ jfieldID GetNetPlayClientPointer(); jfieldID GetNetplayBootSessionDataPointer(); jmethodID GetNetplayOnBootGame(); jmethodID GetNetplayOnStopGame(); +jmethodID GetNetplayOnConnectionLost(); jmethodID GetNetplayOnConnectionError(); jmethodID GetNetplayOnGameChanged(); jmethodID GetNetplayOnHostInputAuthorityChanged(); diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index dd25d54280..21dd77d019 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -135,7 +135,12 @@ void NetPlayUICallbacks::OnHostInputAuthorityChanged(bool enabled) } void NetPlayUICallbacks::OnDesync(u32, const std::string&) {} -void NetPlayUICallbacks::OnConnectionLost() {} + +void NetPlayUICallbacks::OnConnectionLost() +{ + JNIEnv* env = IDCache::GetEnvForThread(); + env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayOnConnectionLost()); +} void NetPlayUICallbacks::OnConnectionError(const std::string& message) { From 05cfd16665b0af4354db59cdd30211fbf1e6f02e Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Thu, 23 Apr 2026 15:25:07 +0200 Subject: [PATCH 19/37] Release boot session data during Netplay cleanup Boot session data is already handled when the game is booted so this is just fallback in case the game launch fails in some weird way. Add missing @Keep annotations to functions called from C++ --- .../dolphinemu/features/netplay/Netplay.kt | 17 +++++++++++++++-- Source/Android/jni/NetPlay/Netplay.cpp | 10 ++++++++++ 2 files changed, 25 insertions(+), 2 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 7298795d71..f4eb178cb4 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 @@ -27,8 +27,6 @@ import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage import org.dolphinemu.dolphinemu.features.netplay.model.Player -//TODO add other necessary @Keep annotations -//TODO clear boot session data at appropriate time object Netplay { @Keep private var netPlayClientPointer: Long = 0 @@ -123,6 +121,11 @@ object Netplay { sessionScope?.cancel() sessionScope = null + if (bootSessionDataPointer != 0L) { + ReleaseBootSessionData() + bootSessionDataPointer = 0 + } + if (netPlayClientPointer != 0L) { ReleaseNetplayClient() netPlayClientPointer = 0 @@ -158,6 +161,9 @@ object Netplay { @JvmStatic external fun adjustPadBufferSize(buffer: Int) + @JvmStatic + private external fun ReleaseBootSessionData() + @JvmStatic private external fun ReleaseNetplayClient() @@ -170,6 +176,7 @@ object Netplay { // NetPlayUI callbacks + @Keep @JvmStatic fun onBootGame(gameFilePath: String, bootSessionDataPointer: Long) { this.bootSessionDataPointer = bootSessionDataPointer @@ -189,31 +196,37 @@ object Netplay { _connectionLost.trySend(Unit) } + @Keep @JvmStatic fun onConnectionError(message: String) { _connectionErrors.trySend(message) } + @Keep @JvmStatic fun onUpdate(players: Array) { _players.tryEmit(players.toList()) } + @Keep @JvmStatic fun onChatMessageReceived(message: String) { _chatMessages.tryEmit(message) } + @Keep @JvmStatic fun onHostInputAuthorityChanged(enabled: Boolean) { _hostInputAuthorityEnabled.tryEmit(enabled) } + @Keep @JvmStatic fun onGameChanged(game: String) { _game.tryEmit(game) } + @Keep @JvmStatic fun onPadBufferChanged(buffer: Int) { // Only for remote pad buffer settings. Ignore local max buffer changes. diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index 9dd2743d53..e8f3deaa17 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -8,6 +8,7 @@ #include #include "Common/CommonTypes.h" +#include "Core/Boot/Boot.h" #include "Core/Config/NetplaySettings.h" #include "Core/NetPlayClient.h" #include "UICommon/GameFile.h" @@ -166,6 +167,15 @@ 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_ReleaseBootSessionData(JNIEnv* env, jclass) +{ + auto* data = reinterpret_cast( + env->GetStaticLongField(IDCache::GetNetplayClass(), IDCache::GetNetplayBootSessionDataPointer())); + delete data; + env->SetStaticLongField(IDCache::GetNetplayClass(), IDCache::GetNetplayBootSessionDataPointer(), 0); +} + JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_ReleaseNetplayClient(JNIEnv* env, jclass) { From ab6c2d0d56275a6e99f8fc05299af997f75b1b96 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Fri, 24 Apr 2026 13:18:36 +0200 Subject: [PATCH 20/37] Dont try to load temporary save states when launching netplay An orientation change can trigger this code path immediately when a game starts. e.g. dolphin is portrait when launching the game but settings force gameplay to landscape. We want to avoid this and continue to the netplay launch code below. If the user backgrounds dolphin during netplay and then resumes after the process has died it will actually resume from the save state in single player mode, not sure if thats good or bad but fine for now. Netplay doesnt handle rotation very well, seems to go more and more out of sync the more rotations. --- .../org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt index 61a5493950..49331d8a4c 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt @@ -208,7 +208,10 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { if (NativeLibrary.IsUninitialized()) { NativeLibrary.SetIsBooting() val emulationThread = Thread({ - if (loadPreviousTemporaryState) { + // Don't load temporary saves when launching Netplay, this path can trigger + // when a game starts due to orientation changes caused by a mismatch in menu + // vs emulation activity orientations. + if (loadPreviousTemporaryState && !Netplay.isLaunching) { Log.debug("[EmulationFragment] Starting emulation thread from previous state.") val paths = requireNotNull(gamePaths) { "Cannot start emulation without any game paths" From 3572afcbbf241cb0534ea869800cb96faeac409a Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Sat, 25 Apr 2026 17:39:41 +0200 Subject: [PATCH 21/37] Show save transfer progress When transferring saves from the host. Equivalent of ChunkedProgressDialog in QT. --- .../dolphinemu/features/netplay/Netplay.kt | 45 +++++++ .../netplay/model/NetplayViewModel.kt | 4 +- .../netplay/model/SaveTransferProgress.kt | 13 ++ .../features/netplay/ui/NetplayActivity.kt | 1 + .../features/netplay/ui/NetplayScreen.kt | 118 ++++++++++++++++-- .../app/src/main/res/values/strings.xml | 1 + Source/Android/jni/AndroidCommon/IDCache.cpp | 24 ++++ Source/Android/jni/AndroidCommon/IDCache.h | 3 + .../jni/NetPlay/NetPlayUICallbacks.cpp | 33 ++++- 9 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/SaveTransferProgress.kt 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 f4eb178cb4..882016dfa9 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 @@ -13,7 +13,9 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -26,6 +28,7 @@ import kotlinx.coroutines.withContext import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage import org.dolphinemu.dolphinemu.features.netplay.model.Player +import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress object Netplay { @Keep @@ -87,6 +90,9 @@ object Netplay { ) val padBuffer = _padBuffer.asSharedFlow() + private val _saveTransferProgress = MutableStateFlow(null) + val saveTransferProgress = _saveTransferProgress.asStateFlow() + suspend fun join(): Boolean = withContext(Dispatchers.IO) { val scope = createSessionScope() @@ -140,6 +146,7 @@ object Netplay { _game.resetReplayCache() _hostInputAuthorityEnabled.resetReplayCache() _padBuffer.resetReplayCache() + _saveTransferProgress.value = null } private fun createSessionScope(): CoroutineScope { @@ -234,6 +241,44 @@ object Netplay { _padBuffer.tryEmit(buffer) } + @Keep + @JvmStatic + fun onShowChunkedProgressDialog(title: String, dataSize: Long, playerIds: IntArray) { + val players = _players.replayCache.firstOrNull() + _saveTransferProgress.value = SaveTransferProgress( + title = title, + totalSize = dataSize, + playerProgresses = playerIds.map { playerId -> + SaveTransferProgress.PlayerProgress( + playerId = playerId, + name = players?.find { it.pid == playerId }?.name ?: "Invalid Player ID", + progress = 0, + ) + }, + ) + } + + @Keep + @JvmStatic + fun onSetChunkedProgress(playerId: Int, progress: Long) { + val current = _saveTransferProgress.value + _saveTransferProgress.value = current?.copy( + playerProgresses = current.playerProgresses.map { + if (it.playerId == playerId) { + it.copy(progress = progress) + } else { + it + } + } + ) + } + + @Keep + @JvmStatic + fun onHideChunkedProgressDialog() { + _saveTransferProgress.value = null + } + // Settings object Settings { @JvmStatic 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 955fb876dd..620a66ff5a 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 @@ -2,8 +2,6 @@ package org.dolphinemu.dolphinemu.features.netplay.model -import android.app.Application -import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -42,6 +40,8 @@ class NetplayViewModel : ViewModel() { private val _maxBuffer = MutableStateFlow(Netplay.Settings.getClientBufferSize()) val maxBuffer = _maxBuffer.asStateFlow() + val saveTransferProgress = Netplay.saveTransferProgress + init { if (!Netplay.isClientConnected()) { _goBack.trySend(Unit) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/SaveTransferProgress.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/SaveTransferProgress.kt new file mode 100644 index 0000000000..e16cbab8ca --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/SaveTransferProgress.kt @@ -0,0 +1,13 @@ +package org.dolphinemu.dolphinemu.features.netplay.model + +data class SaveTransferProgress( + val title: String, + val totalSize: Long, + val playerProgresses: List +) { + data class PlayerProgress( + val playerId: Int, + val name: String, + val progress: Long, + ) +} 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 463ad31506..8a8f860825 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 @@ -52,6 +52,7 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { hostInputAuthorityEnabled = viewModel.hostInputAuthority.collectAsState().value, maxBuffer = viewModel.maxBuffer.collectAsState().value, onMaxBufferChanged = viewModel::setMaxBuffer, + saveTransferProgress = viewModel.saveTransferProgress.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 c47c8b8e3e..5608775bdc 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 @@ -34,6 +34,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar @@ -70,10 +71,12 @@ import kotlinx.coroutines.flow.emptyFlow import org.dolphinemu.dolphinemu.R 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.ui.theme.DolphinTheme import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer import org.dolphinemu.dolphinemu.ui.theme.OutlinedBox import org.dolphinemu.dolphinemu.ui.theme.PreviewTheme +import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -87,6 +90,7 @@ fun NetplayScreen( maxBuffer: Int, onMaxBufferChanged: (Int) -> Unit, players: List, + saveTransferProgress: SaveTransferProgress?, ) { Scaffold( topBar = { @@ -136,16 +140,31 @@ fun NetplayScreen( LaunchedEffect(Unit) { connectionLost.collect { showConnectionLostDialog = true } } - if (showConnectionLostDialog) { - AlertDialog( - text = { Text(stringResource(R.string.netplay_connection_lost)) }, - confirmButton = { - TextButton(onClick = onBackClicked) { - Text(stringResource(R.string.ok)) - } - }, - onDismissRequest = onBackClicked, - ) + + var dismissSaveTransferProgressDialog by remember { mutableStateOf(false) } + if (saveTransferProgress == null) { + dismissSaveTransferProgressDialog = false + } + + when { + showConnectionLostDialog -> { + AlertDialog( + text = { Text(stringResource(R.string.netplay_connection_lost)) }, + confirmButton = { + TextButton(onClick = onBackClicked) { + Text(stringResource(R.string.ok)) + } + }, + onDismissRequest = onBackClicked, + ) + } + + saveTransferProgress != null && !dismissSaveTransferProgressDialog -> { + SaveTransferProgressDialog( + saveTransferProgress = saveTransferProgress, + onDismiss = { dismissSaveTransferProgressDialog = true }, + ) + } } } } @@ -519,6 +538,68 @@ private fun BufferInput( } } +@Composable +private fun SaveTransferProgressDialog( + saveTransferProgress: SaveTransferProgress, + onDismiss: () -> Unit, +) { + AlertDialog( + title = { Text(saveTransferProgress.title) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + saveTransferProgress.playerProgresses.forEachIndexed { index, playerProgress -> + SaveTransferProgressRow( + playerProgress = playerProgress, + totalSize = saveTransferProgress.totalSize, + ) + + if (index < saveTransferProgress.playerProgresses.lastIndex) { + HorizontalDivider() + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.netplay_save_transfer_progress_close)) + } + }, + onDismissRequest = onDismiss, + ) +} + +@Composable +private fun SaveTransferProgressRow( + playerProgress: SaveTransferProgress.PlayerProgress, + totalSize: Long, +) { + fun formatMib(bytes: Long) = String.format(Locale.US, "%.2f", bytes / 1024f / 1024f) + val progressFraction = (playerProgress.progress.toFloat() / totalSize).coerceIn(0f, 1f) + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + LinearProgressIndicator( + progress = { progressFraction }, + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = playerProgress.name, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = "${formatMib(playerProgress.progress)}/${formatMib(totalSize)} MiB", + ) + } + } +} + @Preview @Composable private fun NetplayScreenPreview() { @@ -589,5 +670,22 @@ private fun PreviewNetplayScreen() { hostInputAuthorityEnabled = true, maxBuffer = 10, onMaxBufferChanged = {}, + saveTransferProgress = null, +// saveTransferProgress = SaveTransferProgress( +// title = "Title", +// totalSize = 1024L, +// playerProgresses = listOf( +// SaveTransferProgress.PlayerProgress( +// playerId = 1, +// name = "Player 1", +// progress = 256, +// ), +// SaveTransferProgress.PlayerProgress( +// playerId = 2, +// name = "Player 2", +// progress = 512, +// ), +// ), +// ), ) } diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 7b4311c996..cc1866d473 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1009,4 +1009,5 @@ It can efficiently compress both junk data and encrypted Wii data. Mapping Max buffer Netplay connection lost + Close diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index 461576f217..150cb8162e 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -40,6 +40,9 @@ static jmethodID s_netplay_on_host_input_authority_changed; static jmethodID s_netplay_on_pad_buffer_changed; static jmethodID s_netplay_on_chat_message_received; static jmethodID s_netplay_update; +static jmethodID s_netplay_on_show_chunked_progress_dialog; +static jmethodID s_netplay_on_set_chunked_progress; +static jmethodID s_netplay_on_hide_chunked_progress_dialog; static jclass s_netplay_player_class; static jmethodID s_netplay_player_constructor; @@ -309,6 +312,21 @@ jmethodID GetNetplayUpdate() return s_netplay_update; } +jmethodID GetNetplayOnShowChunkedProgressDialog() +{ + return s_netplay_on_show_chunked_progress_dialog; +} + +jmethodID GetNetplayOnSetChunkedProgress() +{ + return s_netplay_on_set_chunked_progress; +} + +jmethodID GetNetplayOnHideChunkedProgressDialog() +{ + return s_netplay_on_hide_chunked_progress_dialog; +} + jclass GetNetplayPlayerClass() { return s_netplay_player_class; @@ -741,6 +759,12 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) s_netplay_on_chat_message_received = env->GetStaticMethodID(netplay_class, "onChatMessageReceived", "(Ljava/lang/String;)V"); s_netplay_update = env->GetStaticMethodID(netplay_class, "onUpdate", "([Lorg/dolphinemu/dolphinemu/features/netplay/model/Player;)V"); + s_netplay_on_show_chunked_progress_dialog = + env->GetStaticMethodID(netplay_class, "onShowChunkedProgressDialog", "(Ljava/lang/String;J[I)V"); + s_netplay_on_set_chunked_progress = + env->GetStaticMethodID(netplay_class, "onSetChunkedProgress", "(IJ)V"); + s_netplay_on_hide_chunked_progress_dialog = + env->GetStaticMethodID(netplay_class, "onHideChunkedProgressDialog", "()V"); env->DeleteLocalRef(netplay_class); const jclass netplay_player_class = diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h index c535fcb9ae..4041b5060e 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -43,6 +43,9 @@ jmethodID GetNetplayOnHostInputAuthorityChanged(); jmethodID GetNetplayOnPadBufferChanged(); jmethodID GetNetplayOnChatMessageReceived(); jmethodID GetNetplayUpdate(); +jmethodID GetNetplayOnShowChunkedProgressDialog(); +jmethodID GetNetplayOnSetChunkedProgress(); +jmethodID GetNetplayOnHideChunkedProgressDialog(); jclass GetNetplayPlayerClass(); jmethodID GetNetplayPlayerConstructor(); diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index 21dd77d019..fa013017dc 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -186,9 +186,36 @@ void NetPlayUICallbacks::ShowGameDigestDialog(const std::string&) {} void NetPlayUICallbacks::SetGameDigestProgress(int, int) {} void NetPlayUICallbacks::SetGameDigestResult(int, const std::string&) {} void NetPlayUICallbacks::AbortGameDigest() {} -void NetPlayUICallbacks::ShowChunkedProgressDialog(const std::string&, u64, std::span) {} -void NetPlayUICallbacks::HideChunkedProgressDialog() {} -void NetPlayUICallbacks::SetChunkedProgress(int, u64) {} + +void NetPlayUICallbacks::ShowChunkedProgressDialog(const std::string& title, u64 data_size, + std::span players) +{ + JNIEnv* env = IDCache::GetEnvForThread(); + + jintArray j_players = env->NewIntArray(static_cast(players.size())); + env->SetIntArrayRegion(j_players, 0, static_cast(players.size()), players.data()); + + env->CallStaticVoidMethod(IDCache::GetNetplayClass(), + IDCache::GetNetplayOnShowChunkedProgressDialog(), + ToJString(env, title), static_cast(data_size), j_players); + env->DeleteLocalRef(j_players); +} + +void NetPlayUICallbacks::HideChunkedProgressDialog() +{ + JNIEnv* env = IDCache::GetEnvForThread(); + env->CallStaticVoidMethod(IDCache::GetNetplayClass(), + IDCache::GetNetplayOnHideChunkedProgressDialog()); +} + +void NetPlayUICallbacks::SetChunkedProgress(int pid, u64 progress) +{ + JNIEnv* env = IDCache::GetEnvForThread(); + env->CallStaticVoidMethod(IDCache::GetNetplayClass(), + IDCache::GetNetplayOnSetChunkedProgress(), + static_cast(pid), static_cast(progress)); +} + void NetPlayUICallbacks::SetHostWiiSyncData(std::vector, std::string) {} } // namespace NetPlay From 371fa1a250fd50f2d37afdbb584c6f4c7db07e60 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Mon, 27 Apr 2026 19:22:31 +0200 Subject: [PATCH 22/37] Use existing settings API instead of custom jni calls --- .../dolphinemu/features/netplay/Netplay.kt | 42 --------- .../features/netplay/model/ConnectionType.kt | 3 + .../netplay/model/NetplaySetupViewModel.kt | 29 +++--- .../netplay/model/NetplayViewModel.kt | 7 +- .../features/settings/model/IntSetting.kt | 9 +- .../features/settings/model/Settings.kt | 1 + .../features/settings/model/StringSetting.kt | 16 +++- Source/Android/jni/NetPlay/Netplay.cpp | 88 ------------------- 8 files changed, 50 insertions(+), 145 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 882016dfa9..1cec94f19d 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 @@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext -import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage import org.dolphinemu.dolphinemu.features.netplay.model.Player import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress @@ -279,47 +278,6 @@ object Netplay { _saveTransferProgress.value = null } - // Settings - object Settings { - @JvmStatic - external fun getNickname(): String - - @JvmStatic - external fun setNickname(nickname: String) - - fun getConnectionType(): ConnectionType = ConnectionType.all - .find { it.configValue == getTraversalChoice() } ?: throw IllegalStateException() - - @JvmStatic - external fun getTraversalChoice(): String - - @JvmStatic - external fun setTraversalChoice(traversalChoice: String) - - @JvmStatic - external fun getAddress(): String - - @JvmStatic - external fun setAddress(address: String) - - @JvmStatic - external fun getHostCode(): String - - @JvmStatic - external fun setHostCode(hostCode: String) - - @JvmStatic - external fun getConnectPort(): Int - - @JvmStatic - external fun setConnectPort(port: Int) - - @JvmStatic - external fun getClientBufferSize(): Int - - @JvmStatic - external fun setClientBufferSize(buffer: Int) - } } private fun Channel.flush() { diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt index f6b3f0eca1..52b4aa10b4 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt @@ -20,5 +20,8 @@ sealed class ConnectionType( companion object { val all: List get() = listOf(DirectConnection, TraversalServer) + + fun fromString(value: String): ConnectionType = + all.find { it.configValue == value } ?: throw IllegalArgumentException("Invalid connection type: $value") } } 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 d1bd202c2f..2a04e41c35 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 @@ -13,25 +13,30 @@ 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.features.settings.model.IntSetting +import org.dolphinemu.dolphinemu.features.settings.model.NativeConfig +import org.dolphinemu.dolphinemu.features.settings.model.StringSetting import org.dolphinemu.dolphinemu.services.GameFileCacheManager class NetplaySetupViewModel : ViewModel() { private val _connectionRole = MutableStateFlow(ConnectionRole.Connect) val connectionRole = _connectionRole.asStateFlow() - private val _nickname = MutableStateFlow(Netplay.Settings.getNickname()) + private val _nickname = MutableStateFlow(StringSetting.NETPLAY_NICKNAME.string) val nickname = _nickname.asStateFlow() - private val _connectionType = MutableStateFlow(Netplay.Settings.getConnectionType()) + private val _connectionType = MutableStateFlow( + ConnectionType.fromString(StringSetting.NETPLAY_TRAVERSAL_CHOICE.string) + ) val connectionType = _connectionType.asStateFlow() - private val _ipAddress = MutableStateFlow(Netplay.Settings.getAddress()) + private val _ipAddress = MutableStateFlow(StringSetting.NETPLAY_ADDRESS.string) val ipAddress = _ipAddress.asStateFlow() - private val _hostCode = MutableStateFlow(Netplay.Settings.getHostCode()) + private val _hostCode = MutableStateFlow(StringSetting.NETPLAY_HOST_CODE.string) val hostCode = _hostCode.asStateFlow() - private val _connectPort = MutableStateFlow(Netplay.Settings.getConnectPort().toString()) + private val _connectPort = MutableStateFlow(IntSetting.NETPLAY_CONNECT_PORT.int.toString()) val connectPort = _connectPort.asStateFlow() private val _showNetplayScreen = Channel(CONFLATED) @@ -52,30 +57,34 @@ class NetplaySetupViewModel : ViewModel() { fun setNickname(nickname: String) { _nickname.value = nickname - Netplay.Settings.setNickname(nickname) + StringSetting.NETPLAY_NICKNAME.setString(NativeConfig.LAYER_BASE, nickname) } fun setConnectionType(connectionType: ConnectionType) { _connectionType.value = connectionType - Netplay.Settings.setTraversalChoice(connectionType.configValue) + StringSetting.NETPLAY_TRAVERSAL_CHOICE.setString( + NativeConfig.LAYER_BASE, connectionType.configValue + ) } fun setIpAddress(ipAddress: String) { if (ipAddress.all { it.isDigit() || it == '.' }) { _ipAddress.value = ipAddress - Netplay.Settings.setAddress(ipAddress) + StringSetting.NETPLAY_ADDRESS.setString(NativeConfig.LAYER_BASE, ipAddress) } } fun setHostCode(hostCode: String) { _hostCode.value = hostCode - Netplay.Settings.setHostCode(hostCode) + StringSetting.NETPLAY_HOST_CODE.setString(NativeConfig.LAYER_BASE, hostCode) } fun setConnectPort(port: String) { if (port.all { it.isDigit() }) { _connectPort.value = port - port.toIntOrNull()?.let { Netplay.Settings.setConnectPort(it) } + port.toIntOrNull()?.let { + IntSetting.NETPLAY_CONNECT_PORT.setInt(NativeConfig.LAYER_BASE, it) + } } } 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 620a66ff5a..66ad26aaec 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 @@ -15,8 +15,9 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.dolphinemu.dolphinemu.features.netplay.Netplay +import org.dolphinemu.dolphinemu.features.settings.model.IntSetting +import org.dolphinemu.dolphinemu.features.settings.model.NativeConfig -//TODO save settings class NetplayViewModel : ViewModel() { val launchGame = Netplay.launchGame @@ -37,7 +38,7 @@ class NetplayViewModel : ViewModel() { val hostInputAuthority = Netplay.hostInputAuthorityEnabled .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) - private val _maxBuffer = MutableStateFlow(Netplay.Settings.getClientBufferSize()) + private val _maxBuffer = MutableStateFlow(IntSetting.NETPLAY_CLIENT_BUFFER_SIZE.int) val maxBuffer = _maxBuffer.asStateFlow() val saveTransferProgress = Netplay.saveTransferProgress @@ -59,7 +60,7 @@ class NetplayViewModel : ViewModel() { fun setMaxBuffer(buffer: Int) { _maxBuffer.value = buffer - Netplay.Settings.setClientBufferSize(buffer) + IntSetting.NETPLAY_CLIENT_BUFFER_SIZE.setInt(NativeConfig.LAYER_BASE, buffer) Netplay.adjustPadBufferSize(buffer) } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt index faf9f71ea0..d8bc8fbba5 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt @@ -138,7 +138,14 @@ enum class IntSetting( WIIMOTE_2_SOURCE(Settings.FILE_WIIMOTE, "Wiimote2", "Source", 0), WIIMOTE_3_SOURCE(Settings.FILE_WIIMOTE, "Wiimote3", "Source", 0), WIIMOTE_4_SOURCE(Settings.FILE_WIIMOTE, "Wiimote4", "Source", 0), - WIIMOTE_BB_SOURCE(Settings.FILE_WIIMOTE, "BalanceBoard", "Source", 0); + WIIMOTE_BB_SOURCE(Settings.FILE_WIIMOTE, "BalanceBoard", "Source", 0), + NETPLAY_CONNECT_PORT(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "ConnectPort", 2626), + NETPLAY_CLIENT_BUFFER_SIZE( + Settings.FILE_DOLPHIN, + Settings.SECTION_INI_NETPLAY, + "BufferSizeClient", + 1 + ); override val isOverridden: Boolean get() = NativeConfig.isOverridden(file, section, key) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt index 96d40b214c..da54d81258 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt @@ -112,6 +112,7 @@ class Settings : Closeable { const val SECTION_INI_INTERFACE = "Interface" const val SECTION_INI_DSP = "DSP" const val SECTION_INI_GBA = "GBA" + const val SECTION_INI_NETPLAY = "NetPlay" const val SECTION_LOGGER_LOGS = "Logs" const val SECTION_LOGGER_OPTIONS = "Options" const val SECTION_GFX_HARDWARE = "Hardware" diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt index 512a33ddbb..d3070a51ca 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt @@ -101,7 +101,21 @@ enum class StringSetting( Settings.SECTION_ACHIEVEMENTS, "ApiToken", "" - ); + ), + NETPLAY_TRAVERSAL_CHOICE( + Settings.FILE_DOLPHIN, + Settings.SECTION_INI_NETPLAY, + "TraversalChoice", + "direct" + ), + NETPLAY_HOST_CODE( + Settings.FILE_DOLPHIN, + Settings.SECTION_INI_NETPLAY, + "HostCode", + "" + ), + NETPLAY_ADDRESS(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "Address", "127.0.0.1"), + NETPLAY_NICKNAME(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "Nickname", "Player"); override val isOverridden: Boolean get() = NativeConfig.isOverridden(file, section, key) diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index e8f3deaa17..62905d203c 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -26,94 +26,6 @@ static NetPlay::NetPlayClient* GetPointer(JNIEnv* env) extern "C" { -JNIEXPORT jstring JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_getNickname(JNIEnv* env, - jclass) -{ - return ToJString(env, Config::Get(Config::NETPLAY_NICKNAME)); -} - -JNIEXPORT void JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_setNickname(JNIEnv* env, - jclass, - jstring jnickname) -{ - Config::SetBase(Config::NETPLAY_NICKNAME, GetJString(env, jnickname)); -} - -JNIEXPORT jstring JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_getTraversalChoice( - JNIEnv* env, jclass) -{ - return ToJString(env, Config::Get(Config::NETPLAY_TRAVERSAL_CHOICE)); -} - -JNIEXPORT void JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_setTraversalChoice( - JNIEnv* env, jclass, jstring jtraversalChoice) -{ - Config::SetBase(Config::NETPLAY_TRAVERSAL_CHOICE, GetJString(env, jtraversalChoice)); -} - -JNIEXPORT jstring JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_getAddress(JNIEnv* env, - jclass) -{ - return ToJString(env, Config::Get(Config::NETPLAY_ADDRESS)); -} - -JNIEXPORT void JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_setAddress(JNIEnv* env, - jclass, - jstring jaddress) -{ - Config::SetBase(Config::NETPLAY_ADDRESS, GetJString(env, jaddress)); -} - -JNIEXPORT jstring JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_getHostCode(JNIEnv* env, - jclass) -{ - return ToJString(env, Config::Get(Config::NETPLAY_HOST_CODE)); -} - -JNIEXPORT void JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_setHostCode(JNIEnv* env, - jclass, - jstring jhostCode) -{ - Config::SetBase(Config::NETPLAY_HOST_CODE, GetJString(env, jhostCode)); -} - -JNIEXPORT jint JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_getConnectPort(JNIEnv*, - jclass) -{ - return static_cast(Config::Get(Config::NETPLAY_CONNECT_PORT)); -} - -JNIEXPORT void JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_setConnectPort(JNIEnv*, - jclass, - jint port) -{ - Config::SetBase(Config::NETPLAY_CONNECT_PORT, static_cast(port)); -} - -JNIEXPORT jint JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_getClientBufferSize(JNIEnv*, - jclass) -{ - return static_cast(Config::Get(Config::NETPLAY_CLIENT_BUFFER_SIZE)); -} - -JNIEXPORT void JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_00024Settings_setClientBufferSize( - JNIEnv*, jclass, jint buffer) -{ - Config::SetBase(Config::NETPLAY_CLIENT_BUFFER_SIZE, static_cast(buffer)); -} - JNIEXPORT jboolean JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_isClientConnected(JNIEnv* env, jclass) { From 1285cb2282e41f4fe15339cdcc700c9fbab1254d Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 28 Apr 2026 17:37:40 +0200 Subject: [PATCH 23/37] Make NetplaySession not a singleton Create a new NetplaySession each time we try to join a netplay game. Hold onto it in NetplayManager so its available to the different activities that need to access it. Close the session when backing out of the netplay UI. Some guardrails in case things go out of sync: creating a session closes the old one if it is still around for some reason, finalizer in NetplaySession to release native resources if not closed explicitly for some reason. Profiling done to ensure all kotlin and native objects are successfully cleared / garbage collected. --- .../dolphinemu/dolphinemu/NativeLibrary.kt | 2 +- .../features/netplay/NetplayManager.kt | 38 +++++ .../netplay/{Netplay.kt => NetplaySession.kt} | 148 ++++++++--------- .../netplay/model/NetplaySetupViewModel.kt | 50 +++++- .../netplay/model/NetplayViewModel.kt | 47 +++--- .../features/netplay/ui/NetplayActivity.kt | 11 +- .../netplay/ui/NetplaySetupActivity.kt | 6 +- .../dolphinemu/fragments/EmulationFragment.kt | 15 +- Source/Android/jni/AndroidCommon/IDCache.cpp | 43 ++--- Source/Android/jni/AndroidCommon/IDCache.h | 2 +- Source/Android/jni/MainAndroid.cpp | 7 +- .../jni/NetPlay/NetPlayUICallbacks.cpp | 156 ++++++++++++++---- .../Android/jni/NetPlay/NetPlayUICallbacks.h | 8 +- Source/Android/jni/NetPlay/Netplay.cpp | 92 +++++++---- 14 files changed, 407 insertions(+), 218 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplayManager.kt rename Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/{Netplay.kt => NetplaySession.kt} (74%) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/NativeLibrary.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/NativeLibrary.kt index c5224ec3eb..cad1519e75 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/NativeLibrary.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/NativeLibrary.kt @@ -346,7 +346,7 @@ object NativeLibrary { * Begins emulation for a netplay session, using the BootSessionData provided by the host. */ @JvmStatic - external fun RunNetPlay(paths: Array, riivolution: Boolean) + external fun RunNetPlay(paths: Array, riivolution: Boolean, bootSessionDataPointer: Long) @JvmStatic external fun ChangeDisc(path: String) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplayManager.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplayManager.kt new file mode 100644 index 0000000000..eae44e65be --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplayManager.kt @@ -0,0 +1,38 @@ +// Copyright 2003 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.features.netplay + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +object NetplayManager { + + private val mutex = Mutex() + + @Volatile + private var closeComplete: CompletableDeferred? = null + + @Volatile + var activeSession: NetplaySession? = null + private set + + suspend fun createSession(): NetplaySession = mutex.withLock { + closeComplete?.await() + + // Sessions should be closed by UI navigation, but just in case. + activeSession?.closeBlocking() + + closeComplete = CompletableDeferred() + + NetplaySession( + onClosed = { + activeSession = null + closeComplete?.complete(Unit) + } + ).also { + activeSession = it + } + } +} 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/NetplaySession.kt similarity index 74% rename from Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/Netplay.kt rename to Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt index 1cec94f19d..6dde90653f 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/NetplaySession.kt @@ -6,7 +6,6 @@ package org.dolphinemu.dolphinemu.features.netplay import androidx.annotation.Keep import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.BufferOverflow @@ -29,14 +28,21 @@ import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage import org.dolphinemu.dolphinemu.features.netplay.model.Player import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress -object Netplay { - @Keep +class NetplaySession( + private val onClosed: (NetplaySession) -> Unit, +) { + + private var netPlayUICallbacksPointer: Long = nativeCreateUICallbacks() + private var netPlayClientPointer: Long = 0 - @Keep private var bootSessionDataPointer: Long = 0 - private var sessionScope: CoroutineScope? = null + private val sessionScope = CoroutineScope(SupervisorJob()) + + @Volatile + var isClosing = false + private set val isLaunching: Boolean get() = bootSessionDataPointer != 0L @@ -93,85 +99,47 @@ object Netplay { val saveTransferProgress = _saveTransferProgress.asStateFlow() suspend fun join(): Boolean = withContext(Dispatchers.IO) { - val scope = createSessionScope() - - // Gather all messages that should appear in the chat window. mergeMessages() .runningFold(emptyList()) { acc, msg -> listOf(msg) + acc } .onEach { _messages.tryEmit(it) } - .launchIn(scope) + .launchIn(sessionScope) - netPlayClientPointer = Join() - val isConnected = netPlayClientPointer != 0L && isClientConnected() + netPlayClientPointer = nativeJoin() - if (!isActive) { - releaseNetplayClient() + if (netPlayClientPointer == 0L || !isActive) { + closeBlocking() return@withContext false } - if (isConnected) { - return@withContext true - } - - releaseNetplayClient() - false + true } - suspend fun quit() = withContext(Dispatchers.IO) { - releaseNetplayClient() - } + fun sendMessage(message: String) = nativeSendMessage(message) - @OptIn(ExperimentalCoroutinesApi::class) - private fun releaseNetplayClient() { - sessionScope?.cancel() - sessionScope = null + fun adjustPadBufferSize(buffer: Int) = nativeAdjustPadBufferSize(buffer) - if (bootSessionDataPointer != 0L) { - ReleaseBootSessionData() + fun consumeBootSessionData(): Long { + return bootSessionDataPointer.also { bootSessionDataPointer = 0 } - - if (netPlayClientPointer != 0L) { - ReleaseNetplayClient() - netPlayClientPointer = 0 - } - - _launchGame.flush() - _stopGame.flush() - _connectionErrors.flush() - _players.resetReplayCache() - _messages.resetReplayCache() - _chatMessages.resetReplayCache() - _game.resetReplayCache() - _hostInputAuthorityEnabled.resetReplayCache() - _padBuffer.resetReplayCache() - _saveTransferProgress.value = null } - private fun createSessionScope(): CoroutineScope { - sessionScope?.cancel() - return CoroutineScope(SupervisorJob() + Dispatchers.IO).also { - sessionScope = it - } + suspend fun close() = withContext(Dispatchers.IO) { + closeBlocking() } - @JvmStatic - private external fun Join(): Long + @Synchronized + fun closeBlocking() { + if (isClosing) return + isClosing = true + sessionScope.cancel() + releaseNativeResources() + onClosed(this) + } - @JvmStatic - external fun isClientConnected(): Boolean - - @JvmStatic - external fun sendMessage(message: String) - - @JvmStatic - external fun adjustPadBufferSize(buffer: Int) - - @JvmStatic - private external fun ReleaseBootSessionData() - - @JvmStatic - private external fun ReleaseNetplayClient() + protected fun finalize() { + releaseNativeResources() + } private fun mergeMessages(): Flow = merge( chatMessages.map { NetplayMessage.Chat(it) }, @@ -180,10 +148,45 @@ object Netplay { padBuffer.map { NetplayMessage.BufferChanged(it) }, ) + private fun releaseNativeResources() { + val currentBootSessionDataPointer = bootSessionDataPointer + if (currentBootSessionDataPointer != 0L) { + bootSessionDataPointer = 0 + nativeReleaseBootSessionData(currentBootSessionDataPointer) + } + + val currentNetPlayClientPointer = netPlayClientPointer + if (currentNetPlayClientPointer != 0L) { + netPlayClientPointer = 0 + nativeReleaseClient(currentNetPlayClientPointer) + } + + val currentNetPlayUICallbacksPointer = netPlayUICallbacksPointer + if (currentNetPlayUICallbacksPointer != 0L) { + netPlayUICallbacksPointer = 0 + nativeReleaseUICallbacks(currentNetPlayUICallbacksPointer) + } + } + + // JNI methods + + private external fun nativeCreateUICallbacks(): Long + + private external fun nativeJoin(): Long + + private external fun nativeSendMessage(message: String) + + private external fun nativeAdjustPadBufferSize(buffer: Int) + + private external fun nativeReleaseUICallbacks(pointer: Long) + + private external fun nativeReleaseClient(pointer: Long) + + private external fun nativeReleaseBootSessionData(pointer: Long) + // NetPlayUI callbacks @Keep - @JvmStatic fun onBootGame(gameFilePath: String, bootSessionDataPointer: Long) { this.bootSessionDataPointer = bootSessionDataPointer _stopGame.flush() @@ -191,57 +194,47 @@ object Netplay { } @Keep - @JvmStatic fun onStopGame() { _stopGame.trySend(Unit) } @Keep - @JvmStatic fun onConnectionLost() { _connectionLost.trySend(Unit) } @Keep - @JvmStatic fun onConnectionError(message: String) { _connectionErrors.trySend(message) } @Keep - @JvmStatic fun onUpdate(players: Array) { _players.tryEmit(players.toList()) } @Keep - @JvmStatic fun onChatMessageReceived(message: String) { _chatMessages.tryEmit(message) } @Keep - @JvmStatic fun onHostInputAuthorityChanged(enabled: Boolean) { _hostInputAuthorityEnabled.tryEmit(enabled) } @Keep - @JvmStatic fun onGameChanged(game: String) { _game.tryEmit(game) } @Keep - @JvmStatic fun onPadBufferChanged(buffer: Int) { - // Only for remote pad buffer settings. Ignore local max buffer changes. if (_hostInputAuthorityEnabled.replayCache.firstOrNull() == true) return _padBuffer.tryEmit(buffer) } @Keep - @JvmStatic fun onShowChunkedProgressDialog(title: String, dataSize: Long, playerIds: IntArray) { val players = _players.replayCache.firstOrNull() _saveTransferProgress.value = SaveTransferProgress( @@ -258,7 +251,6 @@ object Netplay { } @Keep - @JvmStatic fun onSetChunkedProgress(playerId: Int, progress: Long) { val current = _saveTransferProgress.value _saveTransferProgress.value = current?.copy( @@ -273,11 +265,9 @@ object Netplay { } @Keep - @JvmStatic fun onHideChunkedProgressDialog() { _saveTransferProgress.value = null } - } private fun Channel.flush() { 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 2a04e41c35..a9b34d3147 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,22 +3,31 @@ package org.dolphinemu.dolphinemu.features.netplay.model import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.CONFLATED +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch -import org.dolphinemu.dolphinemu.features.netplay.Netplay +import org.dolphinemu.dolphinemu.features.netplay.NetplayManager import org.dolphinemu.dolphinemu.features.settings.model.IntSetting import org.dolphinemu.dolphinemu.features.settings.model.NativeConfig import org.dolphinemu.dolphinemu.features.settings.model.StringSetting import org.dolphinemu.dolphinemu.services.GameFileCacheManager -class NetplaySetupViewModel : ViewModel() { +class NetplaySetupViewModel( + private val netplayManager: NetplayManager, +) : ViewModel() { + private val _connectionRole = MutableStateFlow(ConnectionRole.Connect) val connectionRole = _connectionRole.asStateFlow() @@ -45,7 +54,8 @@ class NetplaySetupViewModel : ViewModel() { private val _connecting = MutableStateFlow(false) val connecting = _connecting.asStateFlow() - val errors = Netplay.connectionErrors + private val _errors = MutableSharedFlow(extraBufferCapacity = 8) + val errors = _errors.asSharedFlow() init { GameFileCacheManager.startLoad() @@ -89,16 +99,42 @@ class NetplaySetupViewModel : ViewModel() { } fun connect() { + if (_connecting.value) return + _connecting.value = true viewModelScope.launch { - GameFileCacheManager.isLoading().asFlow().first { it == false } + var errorForwarding: Job? = null - if (Netplay.join()) { - _showNetplayScreen.trySend(Unit) + try { + GameFileCacheManager.isLoading().asFlow().first { it == false } + + val session = netplayManager.createSession() + errorForwarding = session.connectionErrors + .onEach { _errors.emit(it) } + .launchIn(this) + + if (session.join()) { + _showNetplayScreen.trySend(Unit) + } + } finally { + errorForwarding?.cancel() + _connecting.value = false } + } + } - _connecting.value = false + override fun onCleared() { + super.onCleared() + // There should not be an active session at this point but in case one was created + // but launching the Netplay screen failed, close it. + netplayManager.activeSession?.closeBlocking() + } + + class Factory(private val netplayManager: NetplayManager) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return NetplaySetupViewModel(netplayManager) as T } } } 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 66ad26aaec..96f9d7328b 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 @@ -3,51 +3,43 @@ package org.dolphinemu.dolphinemu.features.netplay.model import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.Channel.Factory.CONFLATED import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.dolphinemu.dolphinemu.features.netplay.Netplay +import org.dolphinemu.dolphinemu.features.netplay.NetplaySession import org.dolphinemu.dolphinemu.features.settings.model.IntSetting import org.dolphinemu.dolphinemu.features.settings.model.NativeConfig -class NetplayViewModel : ViewModel() { - val launchGame = Netplay.launchGame +class NetplayViewModel( + private val netplaySession: NetplaySession, +) : ViewModel() { - private val _goBack = Channel(CONFLATED) - val goBack = _goBack.receiveAsFlow() + val launchGame = netplaySession.launchGame - val connectionLost = Netplay.connectionLost + val connectionLost = netplaySession.connectionLost - val players = Netplay.players + val players = netplaySession.players .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) - val messages = Netplay.messages + val messages = netplaySession.messages .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) - val game = Netplay.game + val game = netplaySession.game .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") - val hostInputAuthority = Netplay.hostInputAuthorityEnabled + val hostInputAuthority = netplaySession.hostInputAuthorityEnabled .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) private val _maxBuffer = MutableStateFlow(IntSetting.NETPLAY_CLIENT_BUFFER_SIZE.int) val maxBuffer = _maxBuffer.asStateFlow() - val saveTransferProgress = Netplay.saveTransferProgress - - init { - if (!Netplay.isClientConnected()) { - _goBack.trySend(Unit) - } - } + val saveTransferProgress = netplaySession.saveTransferProgress fun sendMessage(message: String) { val trimmedMessage = message.trim() @@ -55,20 +47,29 @@ class NetplayViewModel : ViewModel() { return } - Netplay.sendMessage(trimmedMessage) + netplaySession.sendMessage(trimmedMessage) } fun setMaxBuffer(buffer: Int) { _maxBuffer.value = buffer IntSetting.NETPLAY_CLIENT_BUFFER_SIZE.setInt(NativeConfig.LAYER_BASE, buffer) - Netplay.adjustPadBufferSize(buffer) + netplaySession.adjustPadBufferSize(buffer) } @OptIn(DelicateCoroutinesApi::class) override fun onCleared() { super.onCleared() + // Closing the netplay session is a bit slow for the main thread so launch in + // GlobalScope and allow the activity and view model to finish immediately. GlobalScope.launch { - Netplay.quit() + netplaySession.close() + } + } + + class Factory(private val session: NetplaySession) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return NetplayViewModel(session) as T } } } 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 8a8f860825..f2acf50107 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 @@ -16,6 +16,7 @@ import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.dolphinemu.dolphinemu.activities.EmulationActivity +import org.dolphinemu.dolphinemu.features.netplay.NetplayManager import org.dolphinemu.dolphinemu.features.netplay.model.NetplayViewModel import org.dolphinemu.dolphinemu.ui.main.ThemeProvider import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme @@ -29,11 +30,13 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { enableEdgeToEdge() super.onCreate(savedInstanceState) - val viewModel = ViewModelProvider(this)[NetplayViewModel::class.java] + val session = NetplayManager.activeSession + if (session == null) { + finish() + return + } - viewModel.goBack - .onEach { finish() } - .launchIn(lifecycleScope) + val viewModel = ViewModelProvider(this, NetplayViewModel.Factory(session))[NetplayViewModel::class.java] viewModel.launchGame .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) 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 8058b3ad96..145cf7d635 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 @@ -15,6 +15,7 @@ import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import org.dolphinemu.dolphinemu.features.netplay.NetplayManager import org.dolphinemu.dolphinemu.features.netplay.model.NetplaySetupViewModel import org.dolphinemu.dolphinemu.ui.main.ThemeProvider import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme @@ -28,7 +29,10 @@ class NetplaySetupActivity : AppCompatActivity(), ThemeProvider { enableEdgeToEdge() super.onCreate(savedInstanceState) - val viewModel = ViewModelProvider(this)[NetplaySetupViewModel::class.java] + val viewModel = ViewModelProvider( + this, + NetplaySetupViewModel.Factory(NetplayManager) + )[NetplaySetupViewModel::class.java] viewModel.showNetplayScreen .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt index 49331d8a4c..c989cdc780 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt @@ -16,7 +16,7 @@ import kotlinx.coroutines.launch import org.dolphinemu.dolphinemu.NativeLibrary import org.dolphinemu.dolphinemu.activities.EmulationActivity import org.dolphinemu.dolphinemu.databinding.FragmentEmulationBinding -import org.dolphinemu.dolphinemu.features.netplay.Netplay +import org.dolphinemu.dolphinemu.features.netplay.NetplayManager import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting import org.dolphinemu.dolphinemu.features.settings.model.Settings import org.dolphinemu.dolphinemu.overlay.InputOverlay @@ -211,7 +211,8 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { // Don't load temporary saves when launching Netplay, this path can trigger // when a game starts due to orientation changes caused by a mismatch in menu // vs emulation activity orientations. - if (loadPreviousTemporaryState && !Netplay.isLaunching) { + val netplaySession = NetplayManager.activeSession + if (loadPreviousTemporaryState && netplaySession?.isLaunching != true) { Log.debug("[EmulationFragment] Starting emulation thread from previous state.") val paths = requireNotNull(gamePaths) { "Cannot start emulation without any game paths" @@ -221,16 +222,20 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { if (launchSystemMenu) { Log.debug("[EmulationFragment] Starting emulation thread for the Wii Menu.") NativeLibrary.RunSystemMenu() - } else if (Netplay.isLaunching) { + } else if (netplaySession?.isLaunching == true) { Log.debug("[EmulationFragment] Starting emulation thread for Netplay.") val paths = requireNotNull(gamePaths) { "Cannot start emulation without any game paths" } lifecycleScope.launch { - Netplay.stopGame.first() + netplaySession.stopGame.first() stopEmulation() } - NativeLibrary.RunNetPlay(paths, riivolution) + NativeLibrary.RunNetPlay( + paths, + riivolution, + netplaySession.consumeBootSessionData() + ) } else { Log.debug("[EmulationFragment] Starting emulation thread.") val paths = requireNotNull(gamePaths) { diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index 150cb8162e..2bee9e2138 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -29,8 +29,8 @@ static jclass s_game_file_cache_manager_class; static jfieldID s_game_file_cache_manager_instance; static jclass s_netplay_class; +static jfieldID s_net_play_ui_callbacks_pointer; 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_stop_game; static jmethodID s_netplay_on_connection_lost; @@ -257,16 +257,16 @@ jclass GetNetplayClass() return s_netplay_class; } +jfieldID GetNetPlayUICallbacksPointer() +{ + return s_net_play_ui_callbacks_pointer; +} + jfieldID GetNetPlayClientPointer() { return s_net_play_client_pointer; } -jfieldID GetNetplayBootSessionDataPointer() -{ - return s_netplay_boot_session_data_pointer; -} - jmethodID GetNetplayOnBootGame() { return s_netplay_on_boot_game; @@ -742,29 +742,30 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) env->DeleteLocalRef(game_file_cache_manager_class); const jclass netplay_class = - env->FindClass("org/dolphinemu/dolphinemu/features/netplay/Netplay"); + env->FindClass("org/dolphinemu/dolphinemu/features/netplay/NetplaySession"); s_netplay_class = reinterpret_cast(env->NewGlobalRef(netplay_class)); - 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_stop_game = env->GetStaticMethodID(netplay_class, "onStopGame", "()V"); - s_netplay_on_connection_lost = env->GetStaticMethodID(netplay_class, "onConnectionLost", "()V"); - s_netplay_on_connection_error = env->GetStaticMethodID(netplay_class, "onConnectionError", "(Ljava/lang/String;)V"); + s_net_play_ui_callbacks_pointer = + env->GetFieldID(netplay_class, "netPlayUICallbacksPointer", "J"); + s_net_play_client_pointer = env->GetFieldID(netplay_class, "netPlayClientPointer", "J"); + s_netplay_on_boot_game = env->GetMethodID(netplay_class, "onBootGame", "(Ljava/lang/String;J)V"); + s_netplay_on_stop_game = env->GetMethodID(netplay_class, "onStopGame", "()V"); + s_netplay_on_connection_lost = env->GetMethodID(netplay_class, "onConnectionLost", "()V"); + s_netplay_on_connection_error = env->GetMethodID(netplay_class, "onConnectionError", "(Ljava/lang/String;)V"); s_netplay_on_game_changed = - env->GetStaticMethodID(netplay_class, "onGameChanged", "(Ljava/lang/String;)V"); + env->GetMethodID(netplay_class, "onGameChanged", "(Ljava/lang/String;)V"); s_netplay_on_host_input_authority_changed = - env->GetStaticMethodID(netplay_class, "onHostInputAuthorityChanged", "(Z)V"); + env->GetMethodID(netplay_class, "onHostInputAuthorityChanged", "(Z)V"); s_netplay_on_pad_buffer_changed = - env->GetStaticMethodID(netplay_class, "onPadBufferChanged", "(I)V"); + env->GetMethodID(netplay_class, "onPadBufferChanged", "(I)V"); s_netplay_on_chat_message_received = - env->GetStaticMethodID(netplay_class, "onChatMessageReceived", "(Ljava/lang/String;)V"); - s_netplay_update = env->GetStaticMethodID(netplay_class, "onUpdate", "([Lorg/dolphinemu/dolphinemu/features/netplay/model/Player;)V"); + env->GetMethodID(netplay_class, "onChatMessageReceived", "(Ljava/lang/String;)V"); + s_netplay_update = env->GetMethodID(netplay_class, "onUpdate", "([Lorg/dolphinemu/dolphinemu/features/netplay/model/Player;)V"); s_netplay_on_show_chunked_progress_dialog = - env->GetStaticMethodID(netplay_class, "onShowChunkedProgressDialog", "(Ljava/lang/String;J[I)V"); + env->GetMethodID(netplay_class, "onShowChunkedProgressDialog", "(Ljava/lang/String;J[I)V"); s_netplay_on_set_chunked_progress = - env->GetStaticMethodID(netplay_class, "onSetChunkedProgress", "(IJ)V"); + env->GetMethodID(netplay_class, "onSetChunkedProgress", "(IJ)V"); s_netplay_on_hide_chunked_progress_dialog = - env->GetStaticMethodID(netplay_class, "onHideChunkedProgressDialog", "()V"); + env->GetMethodID(netplay_class, "onHideChunkedProgressDialog", "()V"); env->DeleteLocalRef(netplay_class); const jclass netplay_player_class = diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h index 4041b5060e..c5f0a45573 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -32,8 +32,8 @@ jclass GetGameFileCacheManagerClass(); jfieldID GetGameFileCacheManagerInstance(); jclass GetNetplayClass(); +jfieldID GetNetPlayUICallbacksPointer(); jfieldID GetNetPlayClientPointer(); -jfieldID GetNetplayBootSessionDataPointer(); jmethodID GetNetplayOnBootGame(); jmethodID GetNetplayOnStopGame(); jmethodID GetNetplayOnConnectionLost(); diff --git a/Source/Android/jni/MainAndroid.cpp b/Source/Android/jni/MainAndroid.cpp index b638bf260a..8177022475 100644 --- a/Source/Android/jni/MainAndroid.cpp +++ b/Source/Android/jni/MainAndroid.cpp @@ -616,10 +616,10 @@ Java_org_dolphinemu_dolphinemu_NativeLibrary_Run___3Ljava_lang_String_2ZLjava_la } JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_NativeLibrary_RunNetPlay( - JNIEnv* env, jclass, jobjectArray jPaths, jboolean jRiivolution) + JNIEnv* env, jclass, jobjectArray jPaths, jboolean jRiivolution, jlong jBootSessionData) { - auto boot_session_data = std::unique_ptr(reinterpret_cast( - env->GetStaticLongField(IDCache::GetNetplayClass(), IDCache::GetNetplayBootSessionDataPointer()))); + auto boot_session_data = std::unique_ptr( + reinterpret_cast(jBootSessionData)); if (!boot_session_data) { env->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(), IDCache::GetDisplayToastMsg(), @@ -627,7 +627,6 @@ JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_NativeLibrary_RunNetPlay( env->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(), IDCache::GetFinishEmulationActivity()); return; } - env->SetStaticLongField(IDCache::GetNetplayClass(), IDCache::GetNetplayBootSessionDataPointer(), 0); Run(env, JStringArrayToVector(env, jPaths), jRiivolution, std::move(*boot_session_data)); } diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index fa013017dc..1f9e701edb 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -10,29 +10,54 @@ namespace NetPlay { -NetPlayUICallbacks::NetPlayUICallbacks(std::vector> games) - : m_games(std::move(games)) +NetPlayUICallbacks::NetPlayUICallbacks(jobject netplay_session, + std::vector> games) + : m_netplay_session(IDCache::GetEnvForThread()->NewWeakGlobalRef(netplay_session)), + m_games(std::move(games)) { m_state_changed_hook = Core::AddOnStateChangedCallback([this](Core::State state) { if ((state == Core::State::Uninitialized || state == Core::State::Stopping) && !m_got_stop_request) { JNIEnv* env = IDCache::GetEnvForThread(); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + auto* client = reinterpret_cast( - env->GetStaticLongField(IDCache::GetNetplayClass(), IDCache::GetNetPlayClientPointer())); + env->GetLongField(netplay_session, IDCache::GetNetPlayClientPointer())); if (client) client->RequestStopGame(); + + env->DeleteLocalRef(netplay_session); } }); } -NetPlayUICallbacks::~NetPlayUICallbacks() = default; +NetPlayUICallbacks::~NetPlayUICallbacks() +{ + JNIEnv* env = IDCache::GetEnvForThread(); + env->DeleteWeakGlobalRef(m_netplay_session); +} -void NetPlayUICallbacks::BootGame(const std::string& filename, std::unique_ptr boot_session_data) { - m_got_stop_request = false; - JNIEnv* env = IDCache::GetEnvForThread(); - env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayOnBootGame(), - ToJString(env, filename), reinterpret_cast(boot_session_data.release())); +jobject NetPlayUICallbacks::GetNetplaySessionLocalRef(JNIEnv* env) const +{ + return env->NewLocalRef(m_netplay_session); +} + +void NetPlayUICallbacks::BootGame(const std::string& filename, + std::unique_ptr boot_session_data) +{ + m_got_stop_request = false; + + JNIEnv* env = IDCache::GetEnvForThread(); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnBootGame(), ToJString(env, filename), + reinterpret_cast(boot_session_data.release())); + env->DeleteLocalRef(netplay_session); } void NetPlayUICallbacks::StopGame() @@ -41,8 +66,14 @@ void NetPlayUICallbacks::StopGame() return; m_got_stop_request = true; + JNIEnv* env = IDCache::GetEnvForThread(); - env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayOnStopGame()); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnStopGame()); + env->DeleteLocalRef(netplay_session); } bool NetPlayUICallbacks::IsHosting() const { return false; } @@ -50,11 +81,18 @@ bool NetPlayUICallbacks::IsHosting() const { return false; } void NetPlayUICallbacks::Update() { JNIEnv* env = IDCache::GetEnvForThread(); - auto* client = reinterpret_cast( - env->GetStaticLongField(IDCache::GetNetplayClass(), IDCache::GetNetPlayClientPointer())); - if (!client) + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) return; + auto* client = reinterpret_cast( + env->GetLongField(netplay_session, IDCache::GetNetPlayClientPointer())); + if (!client) + { + env->DeleteLocalRef(netplay_session); + return; + } + const std::vector players = client->GetPlayers(); jobjectArray player_array = @@ -77,16 +115,21 @@ void NetPlayUICallbacks::Update() env->DeleteLocalRef(player_obj); } - env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayUpdate(), player_array); + env->CallVoidMethod(netplay_session, IDCache::GetNetplayUpdate(), player_array); env->DeleteLocalRef(player_array); + env->DeleteLocalRef(netplay_session); } void NetPlayUICallbacks::AppendChat(const std::string& message) { JNIEnv* env = IDCache::GetEnvForThread(); - env->CallStaticVoidMethod(IDCache::GetNetplayClass(), - IDCache::GetNetplayOnChatMessageReceived(), - ToJString(env, message)); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnChatMessageReceived(), + ToJString(env, message)); + env->DeleteLocalRef(netplay_session); } void NetPlayUICallbacks::OnMsgChangeGame(const NetPlay::SyncIdentifier& sync_identifier, @@ -96,8 +139,13 @@ void NetPlayUICallbacks::OnMsgChangeGame(const NetPlay::SyncIdentifier& sync_ide m_current_game_name = netplay_name; JNIEnv* env = IDCache::GetEnvForThread(); - env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayOnGameChanged(), - ToJString(env, netplay_name)); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnGameChanged(), + ToJString(env, netplay_name)); + env->DeleteLocalRef(netplay_session); } void NetPlayUICallbacks::OnMsgChangeGBARom(int, const NetPlay::GBAConfig&) {} @@ -105,13 +153,19 @@ void NetPlayUICallbacks::OnMsgChangeGBARom(int, const NetPlay::GBAConfig&) {} void NetPlayUICallbacks::OnMsgStartGame() { JNIEnv* env = IDCache::GetEnvForThread(); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + auto* client = reinterpret_cast( - env->GetStaticLongField(IDCache::GetNetplayClass(), IDCache::GetNetPlayClientPointer())); + env->GetLongField(netplay_session, IDCache::GetNetPlayClientPointer())); if (client) { if (const auto game = FindGameFile(m_current_game_identifier)) client->StartGame(game->GetFilePath()); } + + env->DeleteLocalRef(netplay_session); } void NetPlayUICallbacks::OnMsgStopGame() {} @@ -122,16 +176,25 @@ void NetPlayUICallbacks::OnPlayerDisconnect(const std::string&) {} void NetPlayUICallbacks::OnPadBufferChanged(u32 buffer) { JNIEnv* env = IDCache::GetEnvForThread(); - env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayOnPadBufferChanged(), - static_cast(buffer)); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnPadBufferChanged(), + static_cast(buffer)); + env->DeleteLocalRef(netplay_session); } void NetPlayUICallbacks::OnHostInputAuthorityChanged(bool enabled) { JNIEnv* env = IDCache::GetEnvForThread(); - env->CallStaticVoidMethod(IDCache::GetNetplayClass(), - IDCache::GetNetplayOnHostInputAuthorityChanged(), - static_cast(enabled)); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnHostInputAuthorityChanged(), + static_cast(enabled)); + env->DeleteLocalRef(netplay_session); } void NetPlayUICallbacks::OnDesync(u32, const std::string&) {} @@ -139,14 +202,24 @@ void NetPlayUICallbacks::OnDesync(u32, const std::string&) {} void NetPlayUICallbacks::OnConnectionLost() { JNIEnv* env = IDCache::GetEnvForThread(); - env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayOnConnectionLost()); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnConnectionLost()); + env->DeleteLocalRef(netplay_session); } void NetPlayUICallbacks::OnConnectionError(const std::string& message) { JNIEnv* env = IDCache::GetEnvForThread(); - env->CallStaticVoidMethod(IDCache::GetNetplayClass(), IDCache::GetNetplayOnConnectionError(), - ToJString(env, message)); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnConnectionError(), + ToJString(env, message)); + env->DeleteLocalRef(netplay_session); } void NetPlayUICallbacks::OnTraversalError(Common::TraversalClient::FailureReason) {} @@ -191,29 +264,40 @@ void NetPlayUICallbacks::ShowChunkedProgressDialog(const std::string& title, u64 std::span players) { JNIEnv* env = IDCache::GetEnvForThread(); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; jintArray j_players = env->NewIntArray(static_cast(players.size())); env->SetIntArrayRegion(j_players, 0, static_cast(players.size()), players.data()); - env->CallStaticVoidMethod(IDCache::GetNetplayClass(), - IDCache::GetNetplayOnShowChunkedProgressDialog(), - ToJString(env, title), static_cast(data_size), j_players); + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnShowChunkedProgressDialog(), + ToJString(env, title), static_cast(data_size), j_players); env->DeleteLocalRef(j_players); + env->DeleteLocalRef(netplay_session); } void NetPlayUICallbacks::HideChunkedProgressDialog() { JNIEnv* env = IDCache::GetEnvForThread(); - env->CallStaticVoidMethod(IDCache::GetNetplayClass(), - IDCache::GetNetplayOnHideChunkedProgressDialog()); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnHideChunkedProgressDialog()); + env->DeleteLocalRef(netplay_session); } void NetPlayUICallbacks::SetChunkedProgress(int pid, u64 progress) { JNIEnv* env = IDCache::GetEnvForThread(); - env->CallStaticVoidMethod(IDCache::GetNetplayClass(), - IDCache::GetNetplayOnSetChunkedProgress(), - static_cast(pid), static_cast(progress)); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnSetChunkedProgress(), + static_cast(pid), static_cast(progress)); + env->DeleteLocalRef(netplay_session); } void NetPlayUICallbacks::SetHostWiiSyncData(std::vector, std::string) {} diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.h b/Source/Android/jni/NetPlay/NetPlayUICallbacks.h index 10c1b1ac22..57eec55230 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.h +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.h @@ -5,6 +5,8 @@ #include #include +#include + #include "Common/HookableEvent.h" #include "Core/NetPlayClient.h" #include "UICommon/GameFile.h" @@ -13,7 +15,8 @@ namespace NetPlay { class NetPlayUICallbacks : public NetPlay::NetPlayUI { public: - NetPlayUICallbacks(std::vector> games); + NetPlayUICallbacks(jobject netplay_session, + std::vector> games); ~NetPlayUICallbacks() override; void BootGame(const std::string& filename, @@ -59,6 +62,9 @@ public: void SetHostWiiSyncData(std::vector titles, std::string redirect_folder) override; private: + jobject GetNetplaySessionLocalRef(JNIEnv* env) const; + + jweak m_netplay_session; std::vector> m_games; NetPlay::SyncIdentifier m_current_game_identifier; std::string m_current_game_name; diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index 62905d203c..61c2e77b9f 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -18,51 +18,41 @@ #include "jni/AndroidCommon/IDCache.h" #include "jni/NetPlay/NetPlayUICallbacks.h" -static NetPlay::NetPlayClient* GetPointer(JNIEnv* env) +static NetPlay::NetPlayUICallbacks* GetUICallbacksPointer(JNIEnv* env, jobject obj) +{ + return reinterpret_cast( + env->GetLongField(obj, IDCache::GetNetPlayUICallbacksPointer())); +} + +static NetPlay::NetPlayClient* GetClientPointer(JNIEnv* env, jobject obj) { return reinterpret_cast( - env->GetStaticLongField(IDCache::GetNetplayClass(), IDCache::GetNetPlayClientPointer())); + env->GetLongField(obj, IDCache::GetNetPlayClientPointer())); } extern "C" { -JNIEXPORT jboolean JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_isClientConnected(JNIEnv* env, jclass) -{ - return static_cast(GetPointer(env)->IsConnected()); -} - JNIEXPORT void JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_sendMessage(JNIEnv* env, jclass, - jstring jmessage) +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeSendMessage(JNIEnv* env, jobject obj, + jstring jmessage) { - if (auto* client = GetPointer(env)) + if (auto* client = GetClientPointer(env, obj)) client->SendChatMessage(GetJString(env, jmessage)); } JNIEXPORT void JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_adjustPadBufferSize(JNIEnv* env, jclass, - jint buffer) +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeAdjustPadBufferSize(JNIEnv* env, + jobject obj, + jint buffer) { - if (auto* client = GetPointer(env)) + if (auto* client = GetClientPointer(env, obj)) client->AdjustPadBufferSize(static_cast(buffer)); } JNIEXPORT jlong JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_Join(JNIEnv* env, jclass) +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeCreateUICallbacks(JNIEnv* env, + jobject obj) { - const std::string traversal_choice = Config::Get(Config::NETPLAY_TRAVERSAL_CHOICE); - const bool is_traversal = traversal_choice == "traversal"; - - std::string host_ip; - host_ip = is_traversal ? Config::Get(Config::NETPLAY_HOST_CODE) : - Config::Get(Config::NETPLAY_ADDRESS); - - const u16 host_port = Config::Get(Config::NETPLAY_CONNECT_PORT); - const std::string traversal_host = Config::Get(Config::NETPLAY_TRAVERSAL_SERVER); - const u16 traversal_port = Config::Get(Config::NETPLAY_TRAVERSAL_PORT); - const std::string nickname = Config::Get(Config::NETPLAY_NICKNAME); - jobject jgame_file_cache = env->GetStaticObjectField( IDCache::GetGameFileCacheManagerClass(), IDCache::GetGameFileCacheManagerInstance()); auto* game_file_cache = reinterpret_cast( @@ -72,26 +62,58 @@ Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_Join(JNIEnv* env, jclass game_file_cache->ForEach( [&games](const std::shared_ptr& game) { games.push_back(game); }); + return reinterpret_cast(new NetPlay::NetPlayUICallbacks(obj, std::move(games))); +} + +JNIEXPORT jlong JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeJoin(JNIEnv* env, jobject obj) +{ + auto* ui = GetUICallbacksPointer(env, obj); + + const std::string traversal_host = Config::Get(Config::NETPLAY_TRAVERSAL_SERVER); + const u16 traversal_port = Config::Get(Config::NETPLAY_TRAVERSAL_PORT); + const std::string nickname = Config::Get(Config::NETPLAY_NICKNAME); + + const std::string traversal_choice = Config::Get(Config::NETPLAY_TRAVERSAL_CHOICE); + const bool is_traversal = traversal_choice == "traversal"; + const std::string host_ip = is_traversal ? Config::Get(Config::NETPLAY_HOST_CODE) : + Config::Get(Config::NETPLAY_ADDRESS); + const u16 host_port = Config::Get(Config::NETPLAY_CONNECT_PORT); + auto* client = new NetPlay::NetPlayClient( - host_ip, host_port, new NetPlay::NetPlayUICallbacks(std::move(games)), nickname, + host_ip, host_port, ui, nickname, NetPlay::NetTraversalConfig{is_traversal, traversal_host, traversal_port}); + if (!client->IsConnected()) + { + delete client; + return 0; + } + return reinterpret_cast(client); } JNIEXPORT void JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_ReleaseBootSessionData(JNIEnv* env, jclass) +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseUICallbacks(JNIEnv*, + jobject, + jlong pointer) { - auto* data = reinterpret_cast( - env->GetStaticLongField(IDCache::GetNetplayClass(), IDCache::GetNetplayBootSessionDataPointer())); - delete data; - env->SetStaticLongField(IDCache::GetNetplayClass(), IDCache::GetNetplayBootSessionDataPointer(), 0); + delete reinterpret_cast(pointer); } JNIEXPORT void JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_Netplay_ReleaseNetplayClient(JNIEnv* env, jclass) +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseBootSessionData(JNIEnv*, + jobject, + jlong pointer) { - delete GetPointer(env); + delete reinterpret_cast(pointer); +} + +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseClient(JNIEnv*, jobject, + jlong pointer) +{ + delete reinterpret_cast(pointer); } } // extern "C" From 8792a4b924ab7b9f4bd849ca8240e5f6536fd312 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 29 Apr 2026 14:31:25 +0200 Subject: [PATCH 24/37] Handle desync messages Show them in the chat window and also in a toast during game play. --- .../dolphinemu/features/netplay/NetplaySession.kt | 12 ++++++++++++ .../features/netplay/model/NetplayMessage.kt | 5 +++++ .../dolphinemu/fragments/EmulationFragment.kt | 7 +++++++ Source/Android/app/src/main/res/values/strings.xml | 1 + Source/Android/jni/AndroidCommon/IDCache.cpp | 8 ++++++++ Source/Android/jni/AndroidCommon/IDCache.h | 1 + Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp | 12 +++++++++++- 7 files changed, 45 insertions(+), 1 deletion(-) 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 6dde90653f..e781d8356c 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 @@ -95,6 +95,12 @@ class NetplaySession( ) val padBuffer = _padBuffer.asSharedFlow() + private val _desyncMessages = MutableSharedFlow( + extraBufferCapacity = 32, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val desyncMessages = _desyncMessages.asSharedFlow() + private val _saveTransferProgress = MutableStateFlow(null) val saveTransferProgress = _saveTransferProgress.asStateFlow() @@ -146,6 +152,7 @@ class NetplaySession( game.map { NetplayMessage.GameChanged(it) }, hostInputAuthorityEnabled.map { NetplayMessage.HostInputAuthorityChanged(it) }, padBuffer.map { NetplayMessage.BufferChanged(it) }, + desyncMessages, ) private fun releaseNativeResources() { @@ -234,6 +241,11 @@ class NetplaySession( _padBuffer.tryEmit(buffer) } + @Keep + fun onDesync(frame: Int, player: String) { + _desyncMessages.tryEmit(NetplayMessage.Desync(player, frame)) + } + @Keep fun onShowChunkedProgressDialog(title: String, dataSize: Long, playerIds: IntArray) { val players = _players.replayCache.firstOrNull() diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayMessage.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayMessage.kt index 41ab8997bd..b8291c094b 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayMessage.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayMessage.kt @@ -26,4 +26,9 @@ sealed class NetplayMessage { override fun message(context: Context) = context.getString(R.string.netplay_message_buffer_changed, buffer) } + + class Desync(private val player: String, private val frame: Int) : NetplayMessage() { + override fun message(context: Context) = + context.getString(R.string.netplay_message_desync, player, frame) + } } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt index c989cdc780..a005995bdb 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt @@ -9,9 +9,12 @@ import android.view.LayoutInflater import android.view.SurfaceHolder import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.dolphinemu.dolphinemu.NativeLibrary import org.dolphinemu.dolphinemu.activities.EmulationActivity @@ -231,6 +234,10 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { netplaySession.stopGame.first() stopEmulation() } + netplaySession + .desyncMessages + .onEach { Toast.makeText(requireContext(), it.message(requireContext()), Toast.LENGTH_SHORT).show() } + .launchIn(lifecycleScope) NativeLibrary.RunNetPlay( paths, riivolution, diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index cc1866d473..4e9c977faa 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1002,6 +1002,7 @@ It can efficiently compress both junk data and encrypted Wii data. Game changed to %1$s Buffer size changed to %1$d "Host input authority %1$s" + Possible desync detected: %1$s might have desynced at frame %2$d Game Players Name diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index 2bee9e2138..caf305fc9b 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -43,6 +43,7 @@ static jmethodID s_netplay_update; static jmethodID s_netplay_on_show_chunked_progress_dialog; static jmethodID s_netplay_on_set_chunked_progress; static jmethodID s_netplay_on_hide_chunked_progress_dialog; +static jmethodID s_netplay_on_desync; static jclass s_netplay_player_class; static jmethodID s_netplay_player_constructor; @@ -327,6 +328,11 @@ jmethodID GetNetplayOnHideChunkedProgressDialog() return s_netplay_on_hide_chunked_progress_dialog; } +jmethodID GetNetplayOnDesync() +{ + return s_netplay_on_desync; +} + jclass GetNetplayPlayerClass() { return s_netplay_player_class; @@ -766,6 +772,8 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) env->GetMethodID(netplay_class, "onSetChunkedProgress", "(IJ)V"); s_netplay_on_hide_chunked_progress_dialog = env->GetMethodID(netplay_class, "onHideChunkedProgressDialog", "()V"); + s_netplay_on_desync = + env->GetMethodID(netplay_class, "onDesync", "(ILjava/lang/String;)V"); env->DeleteLocalRef(netplay_class); const jclass netplay_player_class = diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h index c5f0a45573..ae86740f09 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -46,6 +46,7 @@ jmethodID GetNetplayUpdate(); jmethodID GetNetplayOnShowChunkedProgressDialog(); jmethodID GetNetplayOnSetChunkedProgress(); jmethodID GetNetplayOnHideChunkedProgressDialog(); +jmethodID GetNetplayOnDesync(); jclass GetNetplayPlayerClass(); jmethodID GetNetplayPlayerConstructor(); diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index 1f9e701edb..c411119ab9 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -197,7 +197,17 @@ void NetPlayUICallbacks::OnHostInputAuthorityChanged(bool enabled) env->DeleteLocalRef(netplay_session); } -void NetPlayUICallbacks::OnDesync(u32, const std::string&) {} +void NetPlayUICallbacks::OnDesync(u32 frame, const std::string& player) +{ + JNIEnv* env = IDCache::GetEnvForThread(); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnDesync(), + static_cast(frame), ToJString(env, player)); + env->DeleteLocalRef(netplay_session); +} void NetPlayUICallbacks::OnConnectionLost() { From 2c82e5188e8d85ecdb6955e4c569fd4d8f9ce669 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Thu, 30 Apr 2026 10:16:20 +0200 Subject: [PATCH 25/37] Game digest progress dialog We just about get away with using a StateFlow in NetplaySession since the host sends AbortGameDigest when closing their own dialog. Without that it would be harder for the UI to distinguish between subsequent dialogs. If that wasn't the case then NetplaySession might need to expose the individual progress and result updates and have the view model assemble it into the overall GameDigestProgress. --- .../features/netplay/NetplaySession.kt | 58 +++++++++++ .../netplay/model/GameDigestProgress.kt | 14 +++ .../netplay/model/NetplayViewModel.kt | 2 + .../features/netplay/ui/NetplayActivity.kt | 1 + .../features/netplay/ui/NetplayScreen.kt | 97 ++++++++++++++++++- .../app/src/main/res/values/strings.xml | 3 + Source/Android/jni/AndroidCommon/IDCache.cpp | 32 ++++++ Source/Android/jni/AndroidCommon/IDCache.h | 4 + .../jni/NetPlay/NetPlayUICallbacks.cpp | 51 +++++++++- 9 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt 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 e781d8356c..f94c4b75f1 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 @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.receiveAsFlow 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.features.netplay.model.NetplayMessage import org.dolphinemu.dolphinemu.features.netplay.model.Player import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress @@ -104,6 +105,9 @@ class NetplaySession( private val _saveTransferProgress = MutableStateFlow(null) val saveTransferProgress = _saveTransferProgress.asStateFlow() + private val _gameDigestProgress = MutableStateFlow(null) + val gameDigestProgress = _gameDigestProgress.asStateFlow() + suspend fun join(): Boolean = withContext(Dispatchers.IO) { mergeMessages() .runningFold(emptyList()) { acc, msg -> listOf(msg) + acc } @@ -280,6 +284,60 @@ class NetplaySession( fun onHideChunkedProgressDialog() { _saveTransferProgress.value = null } + + @Keep + fun onShowGameDigestDialog(title: String) { + val players = _players.replayCache.firstOrNull() + _gameDigestProgress.value = GameDigestProgress( + title = title, + playerProgresses = players?.map { player -> + GameDigestProgress.PlayerProgress( + playerId = player.pid, + name = player.name, + progress = 0, + result = null, + ) + } ?: emptyList(), + matches = null, + ) + } + + @Keep + fun onSetGameDigestProgress(playerId: Int, progress: Int) { + val current = _gameDigestProgress.value ?: return + _gameDigestProgress.value = current.copy( + playerProgresses = current.playerProgresses.map { + if (it.playerId == playerId) it.copy(progress = progress) else it + } + ) + } + + @Keep + fun onSetGameDigestResult(playerId: Int, result: String) { + val current = _gameDigestProgress.value ?: return + val updated = current.copy( + playerProgresses = current.playerProgresses.map { + if (it.playerId == playerId) it.copy(result = result) else it + } + ) + val finished = updated.playerProgresses.all { it.result != null } + _gameDigestProgress.value = if (finished) { + val results = updated.playerProgresses.map { it.result } + updated.copy(matches = results.distinct().size == 1) + } else { + updated + } + } + + /** + * Hosts send this when they dismiss their dialog even in a successful scenario. Ensuring + * that the value is cleared before a new game digest is started. Without this, StateFlow + * would not be a good choice. + */ + @Keep + fun onAbortGameDigest() { + _gameDigestProgress.value = null + } } private fun Channel.flush() { diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt new file mode 100644 index 0000000000..57a9cae78b --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt @@ -0,0 +1,14 @@ +package org.dolphinemu.dolphinemu.features.netplay.model + +data class GameDigestProgress( + val title: String, + val playerProgresses: List, + val matches: Boolean?, +) { + data class PlayerProgress( + val playerId: Int, + val name: String, + val progress: Int, + val result: String?, + ) +} 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 96f9d7328b..d0f86ed3f6 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 @@ -41,6 +41,8 @@ class NetplayViewModel( val saveTransferProgress = netplaySession.saveTransferProgress + val gameDigestProgress = netplaySession.gameDigestProgress + fun sendMessage(message: String) { val trimmedMessage = message.trim() if (trimmedMessage.isEmpty()) { 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 f2acf50107..d99c4da57a 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 @@ -56,6 +56,7 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { maxBuffer = viewModel.maxBuffer.collectAsState().value, onMaxBufferChanged = viewModel::setMaxBuffer, saveTransferProgress = viewModel.saveTransferProgress.collectAsState().value, + gameDigestProgress = viewModel.gameDigestProgress.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 5608775bdc..ce6b62ecae 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 @@ -50,6 +50,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -69,6 +70,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import org.dolphinemu.dolphinemu.R +import org.dolphinemu.dolphinemu.features.netplay.model.GameDigestProgress import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage import org.dolphinemu.dolphinemu.features.netplay.model.Player import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress @@ -91,6 +93,7 @@ fun NetplayScreen( onMaxBufferChanged: (Int) -> Unit, players: List, saveTransferProgress: SaveTransferProgress?, + gameDigestProgress: GameDigestProgress?, ) { Scaffold( topBar = { @@ -136,16 +139,21 @@ fun NetplayScreen( ) } - var showConnectionLostDialog by remember { mutableStateOf(false) } + var showConnectionLostDialog by rememberSaveable { mutableStateOf(false) } LaunchedEffect(Unit) { connectionLost.collect { showConnectionLostDialog = true } } - var dismissSaveTransferProgressDialog by remember { mutableStateOf(false) } + var dismissSaveTransferProgressDialog by rememberSaveable { mutableStateOf(false) } if (saveTransferProgress == null) { dismissSaveTransferProgressDialog = false } + var dismissGameDigestDialog by rememberSaveable { mutableStateOf(false) } + if (gameDigestProgress == null) { + dismissGameDigestDialog = false + } + when { showConnectionLostDialog -> { AlertDialog( @@ -165,6 +173,13 @@ fun NetplayScreen( onDismiss = { dismissSaveTransferProgressDialog = true }, ) } + + gameDigestProgress != null && !dismissGameDigestDialog -> { + GameDigestProgressDialog( + gameDigestProgress = gameDigestProgress, + onDismiss = { dismissGameDigestDialog = true }, + ) + } } } } @@ -548,6 +563,7 @@ private fun SaveTransferProgressDialog( text = { Column( verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.verticalScroll(rememberScrollState()), ) { saveTransferProgress.playerProgresses.forEachIndexed { index, playerProgress -> SaveTransferProgressRow( @@ -600,6 +616,82 @@ private fun SaveTransferProgressRow( } } +@Composable +private fun GameDigestProgressDialog( + gameDigestProgress: GameDigestProgress, + onDismiss: () -> Unit, +) { + AlertDialog( + title = { Text(gameDigestProgress.title) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + gameDigestProgress.playerProgresses.forEachIndexed { index, playerProgress -> + GameDigestPlayerRow(playerProgress) + if (index < gameDigestProgress.playerProgresses.lastIndex) { + HorizontalDivider() + } + } + if (gameDigestProgress.matches != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource( + if (gameDigestProgress.matches) { + R.string.netplay_game_digest_match + } else { + R.string.netplay_game_digest_mismatch + } + ), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + } + } + }, + confirmButton = { + if (gameDigestProgress.matches != null) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.netplay_game_digest_close)) + } + } + }, + onDismissRequest = { onDismiss() }, + ) +} + +@Composable +private fun GameDigestPlayerRow( + playerProgress: GameDigestProgress.PlayerProgress, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + LinearProgressIndicator( + progress = { playerProgress.progress / 100f }, + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + ) { + if (playerProgress.result == null) { + Text( + text = playerProgress.name, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "${playerProgress.progress}%", + ) + } else { + Text( + text = "${playerProgress.name}:\u00A0${playerProgress.result}", + ) + } + } + } +} + @Preview @Composable private fun NetplayScreenPreview() { @@ -671,6 +763,7 @@ private fun PreviewNetplayScreen() { maxBuffer = 10, onMaxBufferChanged = {}, saveTransferProgress = null, + gameDigestProgress = null, // saveTransferProgress = SaveTransferProgress( // title = "Title", // totalSize = 1024L, diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 4e9c977faa..ef103432aa 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1011,4 +1011,7 @@ It can efficiently compress both junk data and encrypted Wii data. Max buffer Netplay connection lost Close + The hashes match + The hashes do not match + Close diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index caf305fc9b..91f7599078 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -44,6 +44,10 @@ static jmethodID s_netplay_on_show_chunked_progress_dialog; static jmethodID s_netplay_on_set_chunked_progress; static jmethodID s_netplay_on_hide_chunked_progress_dialog; static jmethodID s_netplay_on_desync; +static jmethodID s_netplay_on_show_game_digest_dialog; +static jmethodID s_netplay_on_set_game_digest_progress; +static jmethodID s_netplay_on_set_game_digest_result; +static jmethodID s_netplay_on_abort_game_digest; static jclass s_netplay_player_class; static jmethodID s_netplay_player_constructor; @@ -333,6 +337,26 @@ jmethodID GetNetplayOnDesync() return s_netplay_on_desync; } +jmethodID GetNetplayOnShowGameDigestDialog() +{ + return s_netplay_on_show_game_digest_dialog; +} + +jmethodID GetNetplayOnSetGameDigestProgress() +{ + return s_netplay_on_set_game_digest_progress; +} + +jmethodID GetNetplayOnSetGameDigestResult() +{ + return s_netplay_on_set_game_digest_result; +} + +jmethodID GetNetplayOnAbortGameDigest() +{ + return s_netplay_on_abort_game_digest; +} + jclass GetNetplayPlayerClass() { return s_netplay_player_class; @@ -774,6 +798,14 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) env->GetMethodID(netplay_class, "onHideChunkedProgressDialog", "()V"); s_netplay_on_desync = env->GetMethodID(netplay_class, "onDesync", "(ILjava/lang/String;)V"); + s_netplay_on_show_game_digest_dialog = + env->GetMethodID(netplay_class, "onShowGameDigestDialog", "(Ljava/lang/String;)V"); + s_netplay_on_set_game_digest_progress = + env->GetMethodID(netplay_class, "onSetGameDigestProgress", "(II)V"); + s_netplay_on_set_game_digest_result = + env->GetMethodID(netplay_class, "onSetGameDigestResult", "(ILjava/lang/String;)V"); + s_netplay_on_abort_game_digest = + env->GetMethodID(netplay_class, "onAbortGameDigest", "()V"); env->DeleteLocalRef(netplay_class); const jclass netplay_player_class = diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h index ae86740f09..f610380908 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -47,6 +47,10 @@ jmethodID GetNetplayOnShowChunkedProgressDialog(); jmethodID GetNetplayOnSetChunkedProgress(); jmethodID GetNetplayOnHideChunkedProgressDialog(); jmethodID GetNetplayOnDesync(); +jmethodID GetNetplayOnShowGameDigestDialog(); +jmethodID GetNetplayOnSetGameDigestProgress(); +jmethodID GetNetplayOnSetGameDigestResult(); +jmethodID GetNetplayOnAbortGameDigest(); jclass GetNetplayPlayerClass(); jmethodID GetNetplayPlayerConstructor(); diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index c411119ab9..018d5556dd 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -265,10 +265,53 @@ NetPlayUICallbacks::FindGameFile(const NetPlay::SyncIdentifier& sync_identifier, } std::string NetPlayUICallbacks::FindGBARomPath(const std::array&, std::string_view, int) { return {}; } -void NetPlayUICallbacks::ShowGameDigestDialog(const std::string&) {} -void NetPlayUICallbacks::SetGameDigestProgress(int, int) {} -void NetPlayUICallbacks::SetGameDigestResult(int, const std::string&) {} -void NetPlayUICallbacks::AbortGameDigest() {} + +void NetPlayUICallbacks::ShowGameDigestDialog(const std::string& title) +{ + JNIEnv* env = IDCache::GetEnvForThread(); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnShowGameDigestDialog(), + ToJString(env, title)); + env->DeleteLocalRef(netplay_session); +} + +void NetPlayUICallbacks::SetGameDigestProgress(int pid, int progress) +{ + JNIEnv* env = IDCache::GetEnvForThread(); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnSetGameDigestProgress(), + static_cast(pid), static_cast(progress)); + env->DeleteLocalRef(netplay_session); +} + +void NetPlayUICallbacks::SetGameDigestResult(int pid, const std::string& result) +{ + JNIEnv* env = IDCache::GetEnvForThread(); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnSetGameDigestResult(), + static_cast(pid), ToJString(env, result)); + env->DeleteLocalRef(netplay_session); +} + +void NetPlayUICallbacks::AbortGameDigest() +{ + JNIEnv* env = IDCache::GetEnvForThread(); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnAbortGameDigest()); + env->DeleteLocalRef(netplay_session); +} void NetPlayUICallbacks::ShowChunkedProgressDialog(const std::string& title, u64 data_size, std::span players) From 7ea7e638bdc3455a3f46274d9206bbd8bb3e6ef4 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Sat, 2 May 2026 15:21:44 +0200 Subject: [PATCH 26/37] Implement OnMsgPowerButton --- Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index 018d5556dd..2ea0b814a7 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -2,9 +2,11 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include "UICommon/GameFile.h" +#include "UICommon/UICommon.h" #include "NetPlayUICallbacks.h" #include "Core/Boot/Boot.h" #include "Core/Core.h" +#include "Core/System.h" #include "jni/AndroidCommon/AndroidCommon.h" #include "jni/AndroidCommon/IDCache.h" @@ -169,7 +171,13 @@ void NetPlayUICallbacks::OnMsgStartGame() } void NetPlayUICallbacks::OnMsgStopGame() {} -void NetPlayUICallbacks::OnMsgPowerButton() {} + +void NetPlayUICallbacks::OnMsgPowerButton() +{ + if (Core::IsRunning(Core::System::GetInstance())) + UICommon::TriggerSTMPowerEvent(); +} + void NetPlayUICallbacks::OnPlayerConnect(const std::string&) {} void NetPlayUICallbacks::OnPlayerDisconnect(const std::string&) {} From ccce2b2e9a767582053bc82ff97653d64c58fe3c Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 6 May 2026 10:43:26 +0200 Subject: [PATCH 27/37] Hosting UI --- .../netplay/model/NetplaySetupViewModel.kt | 25 ++++++ .../features/netplay/ui/NetplayActivity.kt | 2 + .../features/netplay/ui/NetplayScreen.kt | 50 ++++++++--- .../netplay/ui/NetplaySetupActivity.kt | 5 ++ .../features/netplay/ui/NetplaySetupScreen.kt | 89 ++++++++++++++++++- .../features/settings/model/BooleanSetting.kt | 3 +- .../features/settings/model/IntSetting.kt | 1 + .../dolphinemu/ui/theme/DolphinTheme.kt | 1 + .../app/src/main/res/values/strings.xml | 2 + 9 files changed, 163 insertions(+), 15 deletions(-) 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 a9b34d3147..d51ae477ac 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 @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import org.dolphinemu.dolphinemu.features.netplay.NetplayManager +import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting import org.dolphinemu.dolphinemu.features.settings.model.IntSetting import org.dolphinemu.dolphinemu.features.settings.model.NativeConfig import org.dolphinemu.dolphinemu.features.settings.model.StringSetting @@ -48,6 +49,12 @@ class NetplaySetupViewModel( private val _connectPort = MutableStateFlow(IntSetting.NETPLAY_CONNECT_PORT.int.toString()) val connectPort = _connectPort.asStateFlow() + private val _hostPort = MutableStateFlow(IntSetting.NETPLAY_HOST_PORT.int.toString()) + val hostPort = _hostPort.asStateFlow() + + private val _useUpnp = MutableStateFlow(BooleanSetting.NETPLAY_USE_UPNP.boolean) + val useUpnp = _useUpnp.asStateFlow() + private val _showNetplayScreen = Channel(CONFLATED) val showNetplayScreen = _showNetplayScreen.receiveAsFlow() @@ -98,6 +105,24 @@ class NetplaySetupViewModel( } } + fun setHostPort(port: String) { + if (port.all { it.isDigit() }) { + _hostPort.value = port + port.toIntOrNull()?.let { + IntSetting.NETPLAY_HOST_PORT.setInt(NativeConfig.LAYER_BASE, it) + } + } + } + + fun setUseUpnp(useUpnp: Boolean) { + _useUpnp.value = useUpnp + BooleanSetting.NETPLAY_USE_UPNP.setBoolean(NativeConfig.LAYER_BASE, useUpnp) + } + + fun host() { + + } + fun connect() { if (_connecting.value) return 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 d99c4da57a..c01ec2a76c 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 @@ -51,6 +51,8 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { messages = viewModel.messages.collectAsState().value, onSendMessage = viewModel::sendMessage, game = viewModel.game.collectAsState().value, + isHosting = false, + onStartGame = {}, players = viewModel.players.collectAsState().value, hostInputAuthorityEnabled = viewModel.hostInputAuthority.collectAsState().value, maxBuffer = viewModel.maxBuffer.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 ce6b62ecae..f1413636e4 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 @@ -31,6 +31,7 @@ import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -84,10 +85,12 @@ import java.util.Locale @Composable fun NetplayScreen( onBackClicked: () -> Unit, + isHosting: Boolean, connectionLost: Flow, messages: List, onSendMessage: (String) -> Unit, game: String, + onStartGame: () -> Unit, hostInputAuthorityEnabled: Boolean, maxBuffer: Int, onMaxBufferChanged: (Int) -> Unit, @@ -109,6 +112,13 @@ fun NetplayScreen( }, ) }, + floatingActionButton = { + if (isHosting) { + ExtendedFloatingActionButton(onClick = onStartGame) { + Text(stringResource(R.string.netplay_start)) + } + } + }, ) { innerPadding -> val modifier = Modifier .fillMaxSize() @@ -117,6 +127,7 @@ fun NetplayScreen( if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) { LandscapeContent( + isHosting = isHosting, messages = messages, onSendMessage = onSendMessage, game = game, @@ -128,6 +139,7 @@ fun NetplayScreen( ) } else { PortraitContent( + isHosting = isHosting, messages = messages, onSendMessage = onSendMessage, game = game, @@ -186,6 +198,7 @@ fun NetplayScreen( @Composable private fun PortraitContent( + isHosting: Boolean, messages: List, onSendMessage: (String) -> Unit, game: String, @@ -197,13 +210,14 @@ private fun PortraitContent( ) { Column( modifier = modifier + .verticalScroll(rememberScrollState()) ) { Chat( messages = messages, onSendMessage = onSendMessage, modifier = Modifier .fillMaxWidth() - .fillMaxHeight(0.3f) + .height(200.dp) .padding(horizontal = DolphinTheme.scaffoldPadding) ) @@ -216,14 +230,18 @@ private fun PortraitContent( maxBuffer = maxBuffer, onMaxBufferChanged = onMaxBufferChanged, modifier = Modifier - .weight(1f) .padding(horizontal = DolphinTheme.scaffoldPadding), ) + + if (isHosting) { + Spacer(modifier = Modifier.height(DolphinTheme.fabClearancePadding)) + } } } @Composable private fun LandscapeContent( + isHosting: Boolean, messages: List, onSendMessage: (String) -> Unit, game: String, @@ -245,16 +263,25 @@ private fun LandscapeContent( .padding(horizontal = DolphinTheme.scaffoldPadding) ) - PLayersAndSettings( - game = game, - players = players, - hostInputAuthorityEnabled = hostInputAuthorityEnabled, - maxBuffer = maxBuffer, - onMaxBufferChanged = onMaxBufferChanged, + Column( modifier = Modifier .weight(1f) - .padding(horizontal = DolphinTheme.scaffoldPadding) - ) + .verticalScroll(rememberScrollState()) + ) { + PLayersAndSettings( + game = game, + players = players, + hostInputAuthorityEnabled = hostInputAuthorityEnabled, + maxBuffer = maxBuffer, + onMaxBufferChanged = onMaxBufferChanged, + modifier = Modifier + .padding(horizontal = DolphinTheme.scaffoldPadding) + ) + + if (isHosting) { + Spacer(modifier = Modifier.height(DolphinTheme.fabClearancePadding)) + } + } } } @@ -269,7 +296,6 @@ private fun PLayersAndSettings( ) { Column( modifier = modifier - .verticalScroll(rememberScrollState()) ) { OutlinedTextField( value = game, @@ -759,6 +785,8 @@ private fun PreviewNetplayScreen() { }, onSendMessage = {}, game = "Game name", + isHosting = true, + onStartGame = {}, hostInputAuthorityEnabled = true, maxBuffer = 10, onMaxBufferChanged = {}, 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 145cf7d635..a82dbff15a 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 @@ -57,6 +57,11 @@ class NetplaySetupActivity : AppCompatActivity(), ThemeProvider { onConnectPortChanged = viewModel::setConnectPort, hostCode = viewModel.hostCode.collectAsState().value, onHostCodeChanged = viewModel::setHostCode, + hostPort = viewModel.hostPort.collectAsState().value, + onHostPortChanged = viewModel::setHostPort, + useUpnp = viewModel.useUpnp.collectAsState().value, + onUseUpnpChanged = viewModel::setUseUpnp, + onHostClicked = viewModel::host, onConnectClicked = viewModel::connect, ) } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt index bcc11ef4bb..939a39524d 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt @@ -3,10 +3,12 @@ package org.dolphinemu.dolphinemu.features.netplay.ui import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -16,6 +18,7 @@ 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.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -27,6 +30,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SecondaryTabRow @@ -39,6 +43,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType @@ -74,6 +79,11 @@ fun NetplaySetupScreen( onConnectPortChanged: (String) -> Unit, hostCode: String, onHostCodeChanged: (String) -> Unit, + hostPort: String, + onHostPortChanged: (String) -> Unit, + useUpnp: Boolean, + onUseUpnpChanged: (Boolean) -> Unit, + onHostClicked: () -> Unit, onConnectClicked: () -> Unit, ) { Scaffold( @@ -92,7 +102,10 @@ fun NetplaySetupScreen( }, floatingActionButton = { ExtendedFloatingActionButton( - onClick = onConnectClicked, + onClick = when(connectionRole) { + ConnectionRole.Host -> onHostClicked + ConnectionRole.Connect -> onConnectClicked + }, ) { if (connecting) { CircularProgressIndicator( @@ -134,6 +147,7 @@ fun NetplaySetupScreen( .consumeWindowInsets(innerPadding) .verticalScroll(rememberScrollState()) .padding(innerPadding) + .padding(bottom = DolphinTheme.fabClearancePadding) ) { SecondaryTabRow(selectedTabIndex = ConnectionRole.all.indexOf(connectionRole)) { ConnectionRole.all.forEach { role -> @@ -162,6 +176,10 @@ fun NetplaySetupScreen( onHostCodeChanged = onHostCodeChanged, connectPort = connectPort, onConnectPortChanged = onConnectPortChanged, + hostPort = hostPort, + onHostPortChanged = onHostPortChanged, + useUpnp = useUpnp, + onUseUpnpChanged = onUseUpnpChanged, connectionRole = connectionRole, ) } @@ -181,6 +199,10 @@ private fun NetplaySetupContent( onHostCodeChanged: (String) -> Unit, connectPort: String, onConnectPortChanged: (String) -> Unit, + hostPort: String, + onHostPortChanged: (String) -> Unit, + useUpnp: Boolean, + onUseUpnpChanged: (Boolean) -> Unit, connectionRole: ConnectionRole, ) { OutlinedTextField( @@ -211,7 +233,13 @@ private fun NetplaySetupContent( onPortChanged = onConnectPortChanged, ) - ConnectionRole.Host -> {} + ConnectionRole.Host -> HostMenu( + connectionType = connectionType, + hostPort = hostPort, + onHostPortChanged = onHostPortChanged, + useUpnp = useUpnp, + onUseUpnpChanged = onUseUpnpChanged, + ) } } @@ -304,6 +332,56 @@ fun ConnectMenu( } } +@Composable +private fun HostMenu( + connectionType: ConnectionType, + hostPort: String, + onHostPortChanged: (String) -> Unit, + useUpnp: Boolean, + onUseUpnpChanged: (Boolean) -> Unit, +) { + if (connectionType == ConnectionType.DirectConnection) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = hostPort, + onValueChange = onHostPortChanged, + label = { Text(stringResource(R.string.netplay_host_port_label)) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + ), + singleLine = true, + modifier = Modifier + .weight(1f) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + OutlinedButton( + onClick = { onUseUpnpChanged(!useUpnp) }, + shape = MaterialTheme.shapes.extraSmall, + modifier = Modifier + .height(64.dp) + .padding(top = 8.dp) + ) { + Text( + text = stringResource(R.string.netplay_use_upnp), + ) + Spacer(modifier = Modifier.width(12.dp)) + Checkbox( + checked = useUpnp, + onCheckedChange = null, + modifier = Modifier.size(24.dp), + ) + } + } + + MenuSpacer() + } +} + @Preview @Composable private fun NetplaySetupScreenPreview() { @@ -312,7 +390,7 @@ private fun NetplaySetupScreenPreview() { onBackClicked = {}, connecting = false, errors = emptyFlow(), - connectionRole = ConnectionRole.Connect, + connectionRole = ConnectionRole.Host, onConnectionRoleChanged = {}, nickname = "Preview nickname", onNicknameChanged = {}, @@ -324,6 +402,11 @@ private fun NetplaySetupScreenPreview() { onConnectPortChanged = {}, hostCode = "", onHostCodeChanged = {}, + hostPort = "2626", + onHostPortChanged = {}, + useUpnp = false, + onUseUpnpChanged = {}, + onHostClicked = {}, onConnectClicked = {}, ) } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt index cd0ea42d94..4658814ec1 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt @@ -933,7 +933,8 @@ enum class BooleanSetting( Settings.SECTION_ACHIEVEMENTS, "ProgressEnabled", false - ); + ), + NETPLAY_USE_UPNP(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "UseUPNP", false); override val isOverridden: Boolean get() = NativeConfig.isOverridden(file, section, key) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt index d8bc8fbba5..85f265442b 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt @@ -140,6 +140,7 @@ enum class IntSetting( WIIMOTE_4_SOURCE(Settings.FILE_WIIMOTE, "Wiimote4", "Source", 0), WIIMOTE_BB_SOURCE(Settings.FILE_WIIMOTE, "BalanceBoard", "Source", 0), NETPLAY_CONNECT_PORT(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "ConnectPort", 2626), + NETPLAY_HOST_PORT(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "HostPort", 2626), NETPLAY_CLIENT_BUFFER_SIZE( Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt index cf768e6dc0..d25a345862 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt @@ -36,6 +36,7 @@ import com.google.android.material.R as MaterialR object DolphinTheme { val scaffoldPadding = 16.dp + val fabClearancePadding = 80.dp } @Composable diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index ef103432aa..9c89d3aed7 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1014,4 +1014,6 @@ It can efficiently compress both junk data and encrypted Wii data. The hashes match The hashes do not match Close + Port + Forward port (UPnP) From fa5facfdfb49ef276a8fba0e587f2a8dd5a29ce7 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Sat, 2 May 2026 20:04:06 +0200 Subject: [PATCH 28/37] Create NetPlayServer and start game --- .../features/netplay/NetplaySession.kt | 29 +++++++ .../netplay/model/NetplaySetupViewModel.kt | 13 +-- .../netplay/model/NetplayViewModel.kt | 6 ++ .../features/netplay/ui/NetplayActivity.kt | 4 +- Source/Android/jni/AndroidCommon/IDCache.cpp | 7 ++ Source/Android/jni/AndroidCommon/IDCache.h | 1 + .../jni/NetPlay/NetPlayUICallbacks.cpp | 2 + Source/Android/jni/NetPlay/Netplay.cpp | 87 +++++++++++++++++-- 8 files changed, 135 insertions(+), 14 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 f94c4b75f1..ab18379495 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 @@ -37,6 +37,8 @@ class NetplaySession( private var netPlayClientPointer: Long = 0 + private var netPlayServerPointer: Long = 0 + private var bootSessionDataPointer: Long = 0 private val sessionScope = CoroutineScope(SupervisorJob()) @@ -44,6 +46,9 @@ class NetplaySession( @Volatile var isClosing = false private set + + val isHosting: Boolean + get() = netPlayServerPointer != 0L val isLaunching: Boolean get() = bootSessionDataPointer != 0L @@ -124,10 +129,22 @@ class NetplaySession( true } + suspend fun host(): Boolean = withContext(Dispatchers.IO) { + netPlayServerPointer = nativeHost() + if (netPlayServerPointer == 0L || !isActive) { + closeBlocking() + return@withContext false + } + + join() + } + fun sendMessage(message: String) = nativeSendMessage(message) fun adjustPadBufferSize(buffer: Int) = nativeAdjustPadBufferSize(buffer) + fun startGame() = nativeStartGame() + fun consumeBootSessionData(): Long { return bootSessionDataPointer.also { bootSessionDataPointer = 0 @@ -172,6 +189,12 @@ class NetplaySession( nativeReleaseClient(currentNetPlayClientPointer) } + val currentNetPlayServerPointer = netPlayServerPointer + if (currentNetPlayServerPointer != 0L) { + netPlayServerPointer = 0 + nativeReleaseServer(currentNetPlayServerPointer) + } + val currentNetPlayUICallbacksPointer = netPlayUICallbacksPointer if (currentNetPlayUICallbacksPointer != 0L) { netPlayUICallbacksPointer = 0 @@ -185,6 +208,8 @@ class NetplaySession( private external fun nativeJoin(): Long + private external fun nativeHost(): Long + private external fun nativeSendMessage(message: String) private external fun nativeAdjustPadBufferSize(buffer: Int) @@ -193,8 +218,12 @@ class NetplaySession( private external fun nativeReleaseClient(pointer: Long) + private external fun nativeReleaseServer(pointer: Long) + private external fun nativeReleaseBootSessionData(pointer: Long) + private external fun nativeStartGame() + // NetPlayUI callbacks @Keep 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 d51ae477ac..9c667245e5 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 @@ -119,11 +119,9 @@ class NetplaySetupViewModel( BooleanSetting.NETPLAY_USE_UPNP.setBoolean(NativeConfig.LAYER_BASE, useUpnp) } - fun host() { + fun host() = connect(host = true) - } - - fun connect() { + fun connect(host: Boolean = false) { if (_connecting.value) return _connecting.value = true @@ -139,7 +137,12 @@ class NetplaySetupViewModel( .onEach { _errors.emit(it) } .launchIn(this) - if (session.join()) { + val success = if (host) { + session.host() + } else { + session.join() + } + if (success) { _showNetplayScreen.trySend(Unit) } } finally { 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 d0f86ed3f6..e7518bdf43 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 @@ -22,6 +22,8 @@ class NetplayViewModel( val launchGame = netplaySession.launchGame + val isHosting = netplaySession.isHosting + val connectionLost = netplaySession.connectionLost val players = netplaySession.players @@ -43,6 +45,10 @@ class NetplayViewModel( val gameDigestProgress = netplaySession.gameDigestProgress + fun startGame() { + netplaySession.startGame() + } + fun sendMessage(message: String) { val trimmedMessage = message.trim() if (trimmedMessage.isEmpty()) { 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 c01ec2a76c..ce1f7dec19 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 @@ -51,8 +51,8 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { messages = viewModel.messages.collectAsState().value, onSendMessage = viewModel::sendMessage, game = viewModel.game.collectAsState().value, - isHosting = false, - onStartGame = {}, + isHosting = viewModel.isHosting, + onStartGame = viewModel::startGame, players = viewModel.players.collectAsState().value, hostInputAuthorityEnabled = viewModel.hostInputAuthority.collectAsState().value, maxBuffer = viewModel.maxBuffer.collectAsState().value, diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index 91f7599078..41d7055471 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -31,6 +31,7 @@ static jfieldID s_game_file_cache_manager_instance; static jclass s_netplay_class; static jfieldID s_net_play_ui_callbacks_pointer; static jfieldID s_net_play_client_pointer; +static jfieldID s_net_play_server_pointer; static jmethodID s_netplay_on_boot_game; static jmethodID s_netplay_on_stop_game; static jmethodID s_netplay_on_connection_lost; @@ -272,6 +273,11 @@ jfieldID GetNetPlayClientPointer() return s_net_play_client_pointer; } +jfieldID GetNetPlayServerPointer() +{ + return s_net_play_server_pointer; +} + jmethodID GetNetplayOnBootGame() { return s_netplay_on_boot_game; @@ -777,6 +783,7 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) s_net_play_ui_callbacks_pointer = env->GetFieldID(netplay_class, "netPlayUICallbacksPointer", "J"); s_net_play_client_pointer = env->GetFieldID(netplay_class, "netPlayClientPointer", "J"); + s_net_play_server_pointer = env->GetFieldID(netplay_class, "netPlayServerPointer", "J"); s_netplay_on_boot_game = env->GetMethodID(netplay_class, "onBootGame", "(Ljava/lang/String;J)V"); s_netplay_on_stop_game = env->GetMethodID(netplay_class, "onStopGame", "()V"); s_netplay_on_connection_lost = env->GetMethodID(netplay_class, "onConnectionLost", "()V"); diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h index f610380908..51a5ae02bc 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -34,6 +34,7 @@ jfieldID GetGameFileCacheManagerInstance(); jclass GetNetplayClass(); jfieldID GetNetPlayUICallbacksPointer(); jfieldID GetNetPlayClientPointer(); +jfieldID GetNetPlayServerPointer(); jmethodID GetNetplayOnBootGame(); jmethodID GetNetplayOnStopGame(); jmethodID GetNetplayOnConnectionLost(); diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index 2ea0b814a7..ecbf168fb5 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -78,6 +78,8 @@ void NetPlayUICallbacks::StopGame() env->DeleteLocalRef(netplay_session); } +// Only used by Qt UI code, never by the C++ core. On Android, hosting state +// is tracked in Kotlin (NetplaySession.isHosting). bool NetPlayUICallbacks::IsHosting() const { return false; } void NetPlayUICallbacks::Update() diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index 61c2e77b9f..1dd511954f 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -11,6 +11,7 @@ #include "Core/Boot/Boot.h" #include "Core/Config/NetplaySettings.h" #include "Core/NetPlayClient.h" +#include "Core/NetPlayServer.h" #include "UICommon/GameFile.h" #include "UICommon/GameFileCache.h" @@ -20,8 +21,8 @@ static NetPlay::NetPlayUICallbacks* GetUICallbacksPointer(JNIEnv* env, jobject obj) { - return reinterpret_cast( - env->GetLongField(obj, IDCache::GetNetPlayUICallbacksPointer())); + return reinterpret_cast( + env->GetLongField(obj, IDCache::GetNetPlayUICallbacksPointer())); } static NetPlay::NetPlayClient* GetClientPointer(JNIEnv* env, jobject obj) @@ -30,6 +31,12 @@ static NetPlay::NetPlayClient* GetClientPointer(JNIEnv* env, jobject obj) env->GetLongField(obj, IDCache::GetNetPlayClientPointer())); } +static NetPlay::NetPlayServer* GetServerPointer(JNIEnv* env, jobject obj) +{ + return reinterpret_cast( + env->GetLongField(obj, IDCache::GetNetPlayServerPointer())); +} + extern "C" { JNIEXPORT void JNICALL @@ -74,11 +81,25 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeJoin(JNIEnv const u16 traversal_port = Config::Get(Config::NETPLAY_TRAVERSAL_PORT); const std::string nickname = Config::Get(Config::NETPLAY_NICKNAME); - const std::string traversal_choice = Config::Get(Config::NETPLAY_TRAVERSAL_CHOICE); - const bool is_traversal = traversal_choice == "traversal"; - const std::string host_ip = is_traversal ? Config::Get(Config::NETPLAY_HOST_CODE) : - Config::Get(Config::NETPLAY_ADDRESS); - const u16 host_port = Config::Get(Config::NETPLAY_CONNECT_PORT); + std::string host_ip; + u16 host_port; + bool is_traversal; + + // When hosting, join our own server on localhost + if (auto* server = GetServerPointer(env, obj)) + { + host_ip = "127.0.0.1"; + host_port = server->GetPort(); + is_traversal = false; + } + else + { + const std::string traversal_choice = Config::Get(Config::NETPLAY_TRAVERSAL_CHOICE); + is_traversal = traversal_choice == "traversal"; + host_ip = is_traversal ? Config::Get(Config::NETPLAY_HOST_CODE) : + Config::Get(Config::NETPLAY_ADDRESS); + host_port = Config::Get(Config::NETPLAY_CONNECT_PORT); + } auto* client = new NetPlay::NetPlayClient( host_ip, host_port, ui, nickname, @@ -93,6 +114,51 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeJoin(JNIEnv return reinterpret_cast(client); } +JNIEXPORT jlong JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeHost(JNIEnv* env, jobject obj) +{ + auto* ui = GetUICallbacksPointer(env, obj); + + const std::string traversal_choice = Config::Get(Config::NETPLAY_TRAVERSAL_CHOICE); + const bool is_traversal = traversal_choice == "traversal"; + const bool use_upnp = Config::Get(Config::NETPLAY_USE_UPNP); + const std::string traversal_host = Config::Get(Config::NETPLAY_TRAVERSAL_SERVER); + const u16 traversal_port = Config::Get(Config::NETPLAY_TRAVERSAL_PORT); + const u16 traversal_port_alt = Config::Get(Config::NETPLAY_TRAVERSAL_PORT_ALT); + + const u16 host_port = is_traversal ? Config::Get(Config::NETPLAY_LISTEN_PORT) + : Config::Get(Config::NETPLAY_HOST_PORT); + + auto* server = new NetPlay::NetPlayServer( + host_port, use_upnp, ui, + NetPlay::NetTraversalConfig{is_traversal, traversal_host, traversal_port, traversal_port_alt}); + + if (!server->is_connected) + { + delete server; + return 0; + } + + const std::string network_mode = Config::Get(Config::NETPLAY_NETWORK_MODE); + const bool host_input_authority = + network_mode == "hostinputauthority" || network_mode == "golf"; + server->SetHostInputAuthority(host_input_authority); + server->AdjustPadBufferSize(Config::Get(Config::NETPLAY_BUFFER_SIZE)); + + return reinterpret_cast(server); +} + +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeStartGame(JNIEnv* env, + jobject obj) +{ + auto* server = GetServerPointer(env, obj); + if (!server) + return; + + server->RequestStartGame(); +} + JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseUICallbacks(JNIEnv*, jobject, @@ -116,4 +182,11 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseClie delete reinterpret_cast(pointer); } +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseServer(JNIEnv*, jobject, + jlong pointer) +{ + delete reinterpret_cast(pointer); +} + } // extern "C" From 309090520eb777e2b8dee9be7a277183e092b5da Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Sat, 2 May 2026 22:57:35 +0200 Subject: [PATCH 29/37] Send own messages to chat A player's own messages don't come back via the server. --- .../dolphinemu/features/netplay/NetplaySession.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 ab18379495..68bae9e732 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 @@ -28,6 +28,7 @@ import org.dolphinemu.dolphinemu.features.netplay.model.GameDigestProgress 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.settings.model.StringSetting class NetplaySession( private val onClosed: (NetplaySession) -> Unit, @@ -53,6 +54,8 @@ class NetplaySession( val isLaunching: Boolean get() = bootSessionDataPointer != 0L + val nickName by lazy { StringSetting.NETPLAY_NICKNAME.string } + private val _launchGame = Channel(Channel.CONFLATED) val launchGame = _launchGame.receiveAsFlow() @@ -139,7 +142,10 @@ class NetplaySession( join() } - fun sendMessage(message: String) = nativeSendMessage(message) + fun sendMessage(message: String) { + _chatMessages.tryEmit( "$nickName: $message") + nativeSendMessage(message) + } fun adjustPadBufferSize(buffer: Int) = nativeAdjustPadBufferSize(buffer) From 39d17b2fafa576bfe580a7601d484009bbfe236b Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Sun, 3 May 2026 10:05:08 +0200 Subject: [PATCH 30/37] Add ability to choose game when hosting Also fix bottom sheets so they survive rotation --- Source/Android/app/build.gradle.kts | 3 + .../features/netplay/NetplaySession.kt | 5 + .../netplay/model/NetplayViewModel.kt | 34 +++ .../features/netplay/ui/NetplayActivity.kt | 3 + .../features/netplay/ui/NetplayScreen.kt | 213 +++++++++++++++++- .../features/settings/model/StringSetting.kt | 3 +- .../dolphinemu/dolphinemu/utils/CoilUtils.kt | 2 +- Source/Android/gradle/libs.versions.toml | 3 + Source/Android/jni/NetPlay/Netplay.cpp | 15 ++ 9 files changed, 267 insertions(+), 14 deletions(-) diff --git a/Source/Android/app/build.gradle.kts b/Source/Android/app/build.gradle.kts index 912906d2d8..27f76b22a2 100644 --- a/Source/Android/app/build.gradle.kts +++ b/Source/Android/app/build.gradle.kts @@ -141,6 +141,7 @@ dependencies { implementation(libs.androidx.profileinstaller) // Kotlin extensions for lifecycle components + implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) @@ -151,6 +152,7 @@ dependencies { // For loading game covers from disk and GameTDB implementation(libs.coil) + implementation(libs.coil.compose) // For loading custom GPU drivers implementation(libs.kotlinx.serialization.json) @@ -164,6 +166,7 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.material.icons) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.runtime.livedata) implementation(libs.androidx.compose.ui) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling.preview) 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 68bae9e732..e80a6d3c06 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,6 +25,7 @@ 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 @@ -149,6 +150,8 @@ class NetplaySession( fun adjustPadBufferSize(buffer: Int) = nativeAdjustPadBufferSize(buffer) + fun changeGame(gameFile: GameFile) = nativeChangeGame(gameFile) + fun startGame() = nativeStartGame() fun consumeBootSessionData(): Long { @@ -228,6 +231,8 @@ class NetplaySession( private external fun nativeReleaseBootSessionData(pointer: Long) + private external fun nativeChangeGame(gameFile: GameFile) + private external fun nativeStartGame() // NetPlayUI callbacks 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 e7518bdf43..50edaba597 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 @@ -4,17 +4,22 @@ package org.dolphinemu.dolphinemu.features.netplay.model import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.dolphinemu.dolphinemu.features.netplay.NetplaySession import org.dolphinemu.dolphinemu.features.settings.model.IntSetting import org.dolphinemu.dolphinemu.features.settings.model.NativeConfig +import org.dolphinemu.dolphinemu.features.settings.model.StringSetting +import org.dolphinemu.dolphinemu.model.GameFile +import org.dolphinemu.dolphinemu.services.GameFileCacheManager class NetplayViewModel( private val netplaySession: NetplaySession, @@ -41,10 +46,24 @@ class NetplayViewModel( private val _maxBuffer = MutableStateFlow(IntSetting.NETPLAY_CLIENT_BUFFER_SIZE.int) val maxBuffer = _maxBuffer.asStateFlow() + val gameFiles = GameFileCacheManager.getGameFiles().asFlow() + .map { it.toList() } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + GameFileCacheManager.getGameFiles().value?.toList() ?: emptyList() + ) + val saveTransferProgress = netplaySession.saveTransferProgress val gameDigestProgress = netplaySession.gameDigestProgress + init { + if (netplaySession.isHosting) { + setInitialGame() + } + } + fun startGame() { netplaySession.startGame() } @@ -64,6 +83,21 @@ class NetplayViewModel( netplaySession.adjustPadBufferSize(buffer) } + fun changeGame(gameFile: GameFile) { + StringSetting.NETPLAY_GAME.setString(NativeConfig.LAYER_BASE, gameFile.getGameId()) + netplaySession.changeGame(gameFile) + } + + private fun setInitialGame() { + val game = gameFiles.value + .find { it.getGameId() == StringSetting.NETPLAY_GAME.string } + ?: gameFiles.value.firstOrNull() + + if (game != null) { + changeGame(game) + } + } + @OptIn(DelicateCoroutinesApi::class) override fun onCleared() { super.onCleared() 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 ce1f7dec19..55888dde6d 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 @@ -9,6 +9,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.livedata.observeAsState import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.flowWithLifecycle @@ -53,6 +54,8 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { game = viewModel.game.collectAsState().value, isHosting = viewModel.isHosting, onStartGame = viewModel::startGame, + onGameSelected = viewModel::changeGame, + gameFiles = viewModel.gameFiles.collectAsState().value, players = viewModel.players.collectAsState().value, hostInputAuthorityEnabled = viewModel.hostInputAuthority.collectAsState().value, maxBuffer = viewModel.maxBuffer.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 f1413636e4..a5c20bdd22 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 @@ -3,12 +3,14 @@ package org.dolphinemu.dolphinemu.features.netplay.ui import android.content.res.Configuration +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -19,6 +21,9 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions @@ -30,6 +35,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.HorizontalDivider @@ -42,9 +48,10 @@ import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -55,6 +62,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext @@ -66,8 +75,11 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import org.dolphinemu.dolphinemu.R @@ -75,10 +87,12 @@ import org.dolphinemu.dolphinemu.features.netplay.model.GameDigestProgress 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.model.GameFile import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer import org.dolphinemu.dolphinemu.ui.theme.OutlinedBox import org.dolphinemu.dolphinemu.ui.theme.PreviewTheme +import org.dolphinemu.dolphinemu.utils.CoilUtils import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @@ -91,6 +105,8 @@ fun NetplayScreen( onSendMessage: (String) -> Unit, game: String, onStartGame: () -> Unit, + onGameSelected: (GameFile) -> Unit, + gameFiles: List, hostInputAuthorityEnabled: Boolean, maxBuffer: Int, onMaxBufferChanged: (Int) -> Unit, @@ -125,12 +141,21 @@ fun NetplayScreen( .consumeWindowInsets(innerPadding) .padding(innerPadding) + var showChat by rememberSaveable { mutableStateOf(false) } + var showGamePicker by rememberSaveable { mutableStateOf(false) } + if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) { LandscapeContent( isHosting = isHosting, messages = messages, onSendMessage = onSendMessage, + showChat = showChat, + onShowChatChanged = { showChat = it }, game = game, + gameFiles = gameFiles, + onGameSelected = onGameSelected, + showGamePicker = showGamePicker, + onShowGamePickerChanged = { showGamePicker = it }, players = players, hostInputAuthorityEnabled = hostInputAuthorityEnabled, maxBuffer = maxBuffer, @@ -142,7 +167,13 @@ fun NetplayScreen( isHosting = isHosting, messages = messages, onSendMessage = onSendMessage, + showChat = showChat, + onShowChatChanged = { showChat = it }, game = game, + gameFiles = gameFiles, + onGameSelected = onGameSelected, + showGamePicker = showGamePicker, + onShowGamePickerChanged = { showGamePicker = it }, players = players, hostInputAuthorityEnabled = hostInputAuthorityEnabled, maxBuffer = maxBuffer, @@ -201,7 +232,13 @@ private fun PortraitContent( isHosting: Boolean, messages: List, onSendMessage: (String) -> Unit, + showChat: Boolean, + onShowChatChanged: (Boolean) -> Unit, game: String, + gameFiles: List, + onGameSelected: (GameFile) -> Unit, + showGamePicker: Boolean, + onShowGamePickerChanged: (Boolean) -> Unit, players: List, hostInputAuthorityEnabled: Boolean, maxBuffer: Int, @@ -215,6 +252,8 @@ private fun PortraitContent( Chat( messages = messages, onSendMessage = onSendMessage, + showBottomSheet = showChat, + onShowBottomSheetChanged = onShowChatChanged, modifier = Modifier .fillMaxWidth() .height(200.dp) @@ -225,10 +264,15 @@ private fun PortraitContent( PLayersAndSettings( game = game, + gameFiles = gameFiles, + onGameSelected = onGameSelected, + showGamePicker = showGamePicker, + onShowGamePickerChanged = onShowGamePickerChanged, players = players, hostInputAuthorityEnabled = hostInputAuthorityEnabled, maxBuffer = maxBuffer, onMaxBufferChanged = onMaxBufferChanged, + isHosting = isHosting, modifier = Modifier .padding(horizontal = DolphinTheme.scaffoldPadding), ) @@ -244,7 +288,13 @@ private fun LandscapeContent( isHosting: Boolean, messages: List, onSendMessage: (String) -> Unit, + showChat: Boolean, + onShowChatChanged: (Boolean) -> Unit, game: String, + gameFiles: List, + onGameSelected: (GameFile) -> Unit, + showGamePicker: Boolean, + onShowGamePickerChanged: (Boolean) -> Unit, players: List, hostInputAuthorityEnabled: Boolean, maxBuffer: Int, @@ -257,6 +307,8 @@ private fun LandscapeContent( Chat( messages = messages, onSendMessage = onSendMessage, + showBottomSheet = showChat, + onShowBottomSheetChanged = onShowChatChanged, modifier = Modifier .weight(1f) .fillMaxHeight() @@ -270,10 +322,15 @@ private fun LandscapeContent( ) { PLayersAndSettings( game = game, + gameFiles = gameFiles, + onGameSelected = onGameSelected, + showGamePicker = showGamePicker, + onShowGamePickerChanged = onShowGamePickerChanged, players = players, hostInputAuthorityEnabled = hostInputAuthorityEnabled, maxBuffer = maxBuffer, onMaxBufferChanged = onMaxBufferChanged, + isHosting = isHosting, modifier = Modifier .padding(horizontal = DolphinTheme.scaffoldPadding) ) @@ -288,22 +345,27 @@ private fun LandscapeContent( @Composable private fun PLayersAndSettings( game: String, + gameFiles: List, + onGameSelected: (GameFile) -> Unit, + showGamePicker: Boolean, + onShowGamePickerChanged: (Boolean) -> Unit, players: List, hostInputAuthorityEnabled: Boolean, maxBuffer: Int, onMaxBufferChanged: (Int) -> Unit, + isHosting: Boolean, modifier: Modifier = Modifier, ) { Column( modifier = modifier ) { - OutlinedTextField( - value = game, - onValueChange = {}, - label = { Text(stringResource(R.string.netplay_game_label)) }, - readOnly = true, - modifier = Modifier - .fillMaxWidth() + GamePicker( + game = game, + gameFiles = gameFiles, + onGameSelected = onGameSelected, + showGamePicker = showGamePicker, + onShowGamePickerChanged = onShowGamePickerChanged, + isHosting = isHosting, ) MenuSpacer() @@ -345,6 +407,8 @@ private fun PLayersAndSettings( private fun Chat( messages: List, onSendMessage: (String) -> Unit, + showBottomSheet: Boolean, + onShowBottomSheetChanged: (Boolean) -> Unit, modifier: Modifier, ) { val context = LocalContext.current @@ -361,12 +425,18 @@ private fun Chat( draftMessage = "" } - var showBottomSheet by remember { mutableStateOf(false) } - val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val density = LocalDensity.current + val bottomSheetState = remember { + SheetState( + skipPartiallyExpanded = true, + density = density, + initialValue = if (showBottomSheet) SheetValue.Expanded else SheetValue.Hidden, + ) + } if (showBottomSheet) { ModalBottomSheet( - onDismissRequest = { showBottomSheet = false }, + onDismissRequest = { onShowBottomSheetChanged(false) }, sheetState = bottomSheetState, modifier = Modifier .statusBarsPadding() @@ -407,7 +477,7 @@ private fun Chat( } OutlinedBox( - onClick = { showBottomSheet = true }, + onClick = { onShowBottomSheetChanged(true) }, label = { Text(stringResource(R.string.netplay_chat_label)) }, modifier = modifier ) { @@ -422,6 +492,123 @@ private fun Chat( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun GamePicker( + game: String, + gameFiles: List, + onGameSelected: (GameFile) -> Unit, + showGamePicker: Boolean, + onShowGamePickerChanged: (Boolean) -> Unit, + isHosting: Boolean, +) { + val density = LocalDensity.current + val bottomSheetState = remember { + SheetState( + skipPartiallyExpanded = true, + density = density, + initialValue = if (showGamePicker) SheetValue.Expanded else SheetValue.Hidden, + ) + } + + if (showGamePicker) { + ModalBottomSheet( + onDismissRequest = { onShowGamePickerChanged(false) }, + sheetState = bottomSheetState, + modifier = Modifier.statusBarsPadding() + ) { + GameList( + gameFiles = gameFiles, + onGameSelected = { gameFile -> + onGameSelected(gameFile) + onShowGamePickerChanged(false) + }, + contentPadding = PaddingValues( + start = DolphinTheme.scaffoldPadding, + end = DolphinTheme.scaffoldPadding, + bottom = 16.dp + ), + ) + } + } + + Box( + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = game, + onValueChange = {}, + label = { Text(stringResource(R.string.netplay_game_label)) }, + readOnly = true, + modifier = Modifier.fillMaxWidth() + ) + if (isHosting) { + Box( + modifier = Modifier + .matchParentSize() + .padding(top = 8.dp) + .clip(MaterialTheme.shapes.extraSmall) + .clickable { onShowGamePickerChanged(true) } + ) + } + } +} + +@Composable +private fun GameList( + gameFiles: List, + onGameSelected: (GameFile) -> Unit, + contentPadding: PaddingValues = PaddingValues(), +) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 120.dp), + contentPadding = contentPadding, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(gameFiles, key = { it.getPath() }) { gameFile -> + GameGridItem( + gameFile = gameFile, + onClick = { onGameSelected(gameFile) }, + ) + } + } +} + +@Composable +private fun GameGridItem( + gameFile: GameFile, + onClick: () -> Unit, +) { + Card( + onClick = onClick, + ) { + Column { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(gameFile) + .error(R.drawable.no_banner) + .build(), + contentDescription = gameFile.getTitle(), + contentScale = ContentScale.Crop, + imageLoader = CoilUtils.imageLoader, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(0.7f) + ) + Text( + text = gameFile.getTitle(), + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + minLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(8.dp) + ) + } + } +} + /** * A table arranged into columns sized to wrap the largest item. Except the * first column which takes up the remaining space left by the other columns. @@ -787,6 +974,8 @@ private fun PreviewNetplayScreen() { game = "Game name", isHosting = true, onStartGame = {}, + onGameSelected = {}, + gameFiles = emptyList(), hostInputAuthorityEnabled = true, maxBuffer = 10, onMaxBufferChanged = {}, diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt index d3070a51ca..610f45b2a3 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt @@ -115,7 +115,8 @@ enum class StringSetting( "" ), NETPLAY_ADDRESS(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "Address", "127.0.0.1"), - NETPLAY_NICKNAME(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "Nickname", "Player"); + NETPLAY_NICKNAME(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "Nickname", "Player"), + NETPLAY_GAME(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "Game", ""); override val isOverridden: Boolean get() = NativeConfig.isOverridden(file, section, key) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/CoilUtils.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/CoilUtils.kt index 846fcd564e..8c3fb2e3f3 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/CoilUtils.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/CoilUtils.kt @@ -66,7 +66,7 @@ class GameCoverKeyer : Keyer { } object CoilUtils { - private val imageLoader = ImageLoader.Builder(DolphinApplication.getAppContext()) + val imageLoader = ImageLoader.Builder(DolphinApplication.getAppContext()) .components { add(GameCoverKeyer()) add(GameCoverFetcher.Factory()) diff --git a/Source/Android/gradle/libs.versions.toml b/Source/Android/gradle/libs.versions.toml index e29e0c2e10..f4811334ac 100644 --- a/Source/Android/gradle/libs.versions.toml +++ b/Source/Android/gradle/libs.versions.toml @@ -35,6 +35,7 @@ androidx-cardview = { group = "androidx.cardview", name = "cardview", version.re androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } @@ -45,6 +46,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" } androidx-leanback = { group = "androidx.leanback", name = "leanback", version.ref = "leanback" } +androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" } @@ -55,6 +57,7 @@ androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "s androidx-tvprovider = { group = "androidx.tvprovider", name = "tvprovider", version.ref = "tvprovider" } androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } desugar_jdk_libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugar_jdk_libs" } filepicker = { group = "com.nononsenseapps", name = "filepicker", version.ref = "filepicker" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index 1dd511954f..527ca40f40 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -148,6 +148,21 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeHost(JNIEnv return reinterpret_cast(server); } +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeChangeGame(JNIEnv* env, + jobject obj, + jobject jgame_file) +{ + auto* server = GetServerPointer(env, obj); + if (!server) + return; + + const auto& game_file = *reinterpret_cast*>( + env->GetLongField(jgame_file, IDCache::GetGameFilePointer())); + + server->ChangeGame(game_file->GetSyncIdentifier(), game_file->GetLongName()); +} + JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeStartGame(JNIEnv* env, jobject obj) From 3e34012148b284192581699dab12e8cf81b1d2b8 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 5 May 2026 19:19:00 +0200 Subject: [PATCH 31/37] Separate out GetExternalIPAddress helper function De-duplicates the two inline implementations and prepares it for use from Android. --- Source/Core/Core/NetPlayCommon.cpp | 15 +++++++++++++++ Source/Core/Core/NetPlayCommon.h | 2 ++ Source/Core/Core/NetPlayServer.cpp | 12 ++---------- Source/Core/DolphinQt/NetPlay/NetPlayDialog.cpp | 15 +++------------ 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Source/Core/Core/NetPlayCommon.cpp b/Source/Core/Core/NetPlayCommon.cpp index f1a580cf09..0265d82aaa 100644 --- a/Source/Core/Core/NetPlayCommon.cpp +++ b/Source/Core/Core/NetPlayCommon.cpp @@ -9,6 +9,7 @@ #include #include "Common/FileUtil.h" +#include "Common/HttpRequest.h" #include "Common/IOFile.h" #include "Common/MsgHandler.h" #include "Common/SFMLHelper.h" @@ -297,4 +298,18 @@ std::optional> DecompressPacketIntoBuffer(sf::Packet& packet) return out_buffer; } + +std::string GetExternalIPAddress() +{ + Common::HttpRequest request; + // ENet does not support IPv6, so IPv4 has to be used + request.UseIPv4(); + Common::HttpRequest::Response response = + request.Get("https://ip.dolphin-emu.org/", {{"X-Is-Dolphin", "1"}}); + + if (response.has_value()) + return std::string(response->begin(), response->end()); + return ""; +} + } // namespace NetPlay diff --git a/Source/Core/Core/NetPlayCommon.h b/Source/Core/Core/NetPlayCommon.h index 0c95b49c2d..18b8744f06 100644 --- a/Source/Core/Core/NetPlayCommon.h +++ b/Source/Core/Core/NetPlayCommon.h @@ -20,6 +20,8 @@ using namespace std::chrono_literals; // connection is disconnected constexpr std::chrono::milliseconds PEER_TIMEOUT = 30s; +std::string GetExternalIPAddress(); + bool CompressFileIntoPacket(const std::string& file_path, sf::Packet& packet); bool CompressFolderIntoPacket(const std::string& folder_path, sf::Packet& packet); bool CompressBufferIntoPacket(std::span in_buffer, sf::Packet& packet); diff --git a/Source/Core/Core/NetPlayServer.cpp b/Source/Core/Core/NetPlayServer.cpp index d26cc399c1..2faa5df1fa 100644 --- a/Source/Core/Core/NetPlayServer.cpp +++ b/Source/Core/Core/NetPlayServer.cpp @@ -24,7 +24,6 @@ #include "Common/CommonPaths.h" #include "Common/ENet.h" #include "Common/FileUtil.h" -#include "Common/HttpRequest.h" #include "Common/Logging/Log.h" #include "Common/MsgHandler.h" #include "Common/SFMLHelper.h" @@ -220,16 +219,9 @@ void NetPlayServer::SetupIndex() } else { - Common::HttpRequest request; - // ENet does not support IPv6, so IPv4 has to be used - request.UseIPv4(); - Common::HttpRequest::Response response = - request.Get("https://ip.dolphin-emu.org/", {{"X-Is-Dolphin", "1"}}); - - if (!response.has_value()) + session.server_id = GetExternalIPAddress(); + if (session.server_id.empty()) return; - - session.server_id = std::string(response->begin(), response->end()); } session.EncryptID(Config::Get(Config::NETPLAY_INDEX_PASSWORD)); diff --git a/Source/Core/DolphinQt/NetPlay/NetPlayDialog.cpp b/Source/Core/DolphinQt/NetPlay/NetPlayDialog.cpp index 6cadce9203..5282fdb9b9 100644 --- a/Source/Core/DolphinQt/NetPlay/NetPlayDialog.cpp +++ b/Source/Core/DolphinQt/NetPlay/NetPlayDialog.cpp @@ -30,7 +30,7 @@ #endif #include "Common/Config/Config.h" -#include "Common/HttpRequest.h" +#include "Core/NetPlayCommon.h" #include "Common/Logging/Log.h" #include "Common/TraversalClient.h" @@ -550,17 +550,8 @@ void NetPlayDialog::show(std::string nickname, bool use_traversal) void NetPlayDialog::ResetExternalIP() { - m_external_ip_address = Common::Lazy([]() -> std::string { - Common::HttpRequest request; - // ENet does not support IPv6, so IPv4 has to be used - request.UseIPv4(); - Common::HttpRequest::Response response = - request.Get("https://ip.dolphin-emu.org/", {{"X-Is-Dolphin", "1"}}); - - if (response.has_value()) - return std::string(response->begin(), response->end()); - return ""; - }); + m_external_ip_address = + Common::Lazy([]() -> std::string { return NetPlay::GetExternalIPAddress(); }); } void NetPlayDialog::UpdateDiscordPresence() From 4a52be09603c7735ca5eacac033e71f662c59e3f Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Sun, 3 May 2026 23:23:07 +0200 Subject: [PATCH 32/37] Show joining info for local and external IP addresses Doesn't support traversal yet --- .../features/netplay/NetplaySession.kt | 8 + .../features/netplay/model/JoinInfo.kt | 17 ++ .../netplay/model/NetplayViewModel.kt | 37 ++- .../features/netplay/ui/NetplayActivity.kt | 4 +- .../features/netplay/ui/NetplayScreen.kt | 231 ++++++++++++++++-- .../dolphinemu/ui/theme/DolphinTheme.kt | 36 +++ .../dolphinemu/utils/NetworkHelper.kt | 5 + .../app/src/main/res/values/strings.xml | 8 + Source/Android/jni/NetPlay/Netplay.cpp | 20 ++ 9 files changed, 340 insertions(+), 26 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/JoinInfo.kt 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 e80a6d3c06..16314e4076 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 @@ -154,6 +154,10 @@ class NetplaySession( fun startGame() = nativeStartGame() + fun getPort(): Int = nativeGetPort() + + fun getExternalIpAddress(): String? = nativeGetExternalIpAddress() + fun consumeBootSessionData(): Long { return bootSessionDataPointer.also { bootSessionDataPointer = 0 @@ -235,6 +239,10 @@ class NetplaySession( private external fun nativeStartGame() + private external fun nativeGetPort(): Int + + private external fun nativeGetExternalIpAddress(): String? + // NetPlayUI callbacks @Keep 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 new file mode 100644 index 0000000000..ba350a3caf --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/JoinInfo.kt @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.dolphinemu.dolphinemu.features.netplay.model + +import androidx.annotation.StringRes +import org.dolphinemu.dolphinemu.R + +enum class JoinInfoType(@StringRes val labelId: Int) { + EXTERNAL(R.string.netplay_address_type_external), + LOCAL(R.string.netplay_address_type_local), +} + +sealed class JoinAddress { + data object Loading : JoinAddress() + data class Loaded(val address: String) : JoinAddress() + data class Unknown(val retry: () -> Unit) : JoinAddress() +} 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 50edaba597..923cfc314d 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 @@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -20,15 +21,25 @@ import org.dolphinemu.dolphinemu.features.settings.model.NativeConfig import org.dolphinemu.dolphinemu.features.settings.model.StringSetting import org.dolphinemu.dolphinemu.model.GameFile import org.dolphinemu.dolphinemu.services.GameFileCacheManager +import org.dolphinemu.dolphinemu.utils.NetworkHelper class NetplayViewModel( private val netplaySession: NetplaySession, + private val networkHelper: NetworkHelper, ) : ViewModel() { val launchGame = netplaySession.launchGame val isHosting = netplaySession.isHosting + private val _joinAddresses = MutableStateFlow( + mapOf( + JoinInfoType.EXTERNAL to JoinAddress.Loading, + JoinInfoType.LOCAL to getLocalIp(), + ) + ) + val joinAddresses = _joinAddresses.asStateFlow() + val connectionLost = netplaySession.connectionLost val players = netplaySession.players @@ -61,6 +72,7 @@ class NetplayViewModel( init { if (netplaySession.isHosting) { setInitialGame() + fetchExternalIp() } } @@ -88,6 +100,24 @@ class NetplayViewModel( netplaySession.changeGame(gameFile) } + private fun getLocalIp(): JoinAddress { + val localIp = networkHelper.getLocalIpString() + ?: return JoinAddress.Unknown { _joinAddresses.value += JoinInfoType.LOCAL to getLocalIp() } + val port = netplaySession.getPort() + return JoinAddress.Loaded("$localIp:$port") + } + + private fun fetchExternalIp() { + _joinAddresses.value += JoinInfoType.EXTERNAL to JoinAddress.Loading + viewModelScope.launch(Dispatchers.IO) { + val ip = netplaySession.getExternalIpAddress() + val port = netplaySession.getPort() + val address = if (ip != null) JoinAddress.Loaded("$ip:$port") + else JoinAddress.Unknown { fetchExternalIp() } + _joinAddresses.value += JoinInfoType.EXTERNAL to address + } + } + private fun setInitialGame() { val game = gameFiles.value .find { it.getGameId() == StringSetting.NETPLAY_GAME.string } @@ -108,10 +138,13 @@ class NetplayViewModel( } } - class Factory(private val session: NetplaySession) : ViewModelProvider.Factory { + class Factory( + private val session: NetplaySession, + private val networkHelper: NetworkHelper, + ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - return NetplayViewModel(session) as T + return NetplayViewModel(session, networkHelper) as T } } } 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 55888dde6d..a9bf0309a5 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 @@ -21,6 +21,7 @@ import org.dolphinemu.dolphinemu.features.netplay.NetplayManager import org.dolphinemu.dolphinemu.features.netplay.model.NetplayViewModel import org.dolphinemu.dolphinemu.ui.main.ThemeProvider import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme +import org.dolphinemu.dolphinemu.utils.NetworkHelper import org.dolphinemu.dolphinemu.utils.ThemeHelper class NetplayActivity : AppCompatActivity(), ThemeProvider { @@ -37,7 +38,7 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { return } - val viewModel = ViewModelProvider(this, NetplayViewModel.Factory(session))[NetplayViewModel::class.java] + val viewModel = ViewModelProvider(this, NetplayViewModel.Factory(session, NetworkHelper))[NetplayViewModel::class.java] viewModel.launchGame .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) @@ -62,6 +63,7 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { onMaxBufferChanged = viewModel::setMaxBuffer, saveTransferProgress = viewModel.saveTransferProgress.collectAsState().value, gameDigestProgress = viewModel.gameDigestProgress.collectAsState().value, + joinAddresses = viewModel.joinAddresses.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 a5c20bdd22..db70f8a3e9 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 @@ -2,10 +2,11 @@ package org.dolphinemu.dolphinemu.features.netplay.ui +import android.content.Intent import android.content.res.Configuration -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -32,11 +34,17 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Remove +import androidx.compose.material.icons.filled.Share import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -45,6 +53,7 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -62,7 +71,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration @@ -84,6 +92,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import org.dolphinemu.dolphinemu.R import org.dolphinemu.dolphinemu.features.netplay.model.GameDigestProgress +import org.dolphinemu.dolphinemu.features.netplay.model.JoinAddress +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 @@ -92,6 +102,7 @@ import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer import org.dolphinemu.dolphinemu.ui.theme.OutlinedBox import org.dolphinemu.dolphinemu.ui.theme.PreviewTheme +import org.dolphinemu.dolphinemu.ui.theme.ReadOnlyTextField import org.dolphinemu.dolphinemu.utils.CoilUtils import java.util.Locale @@ -113,6 +124,7 @@ fun NetplayScreen( players: List, saveTransferProgress: SaveTransferProgress?, gameDigestProgress: GameDigestProgress?, + joinAddresses: Map, ) { Scaffold( topBar = { @@ -141,8 +153,10 @@ fun NetplayScreen( .consumeWindowInsets(innerPadding) .padding(innerPadding) + // 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) } if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) { LandscapeContent( @@ -160,6 +174,9 @@ fun NetplayScreen( hostInputAuthorityEnabled = hostInputAuthorityEnabled, maxBuffer = maxBuffer, onMaxBufferChanged = onMaxBufferChanged, + joinAddresses = joinAddresses, + selectedJoinInfoType = selectedJoinInfoType, + onSelectedJoinInfoTypeChanged = { selectedJoinInfoType = it }, modifier = modifier ) } else { @@ -178,6 +195,9 @@ fun NetplayScreen( hostInputAuthorityEnabled = hostInputAuthorityEnabled, maxBuffer = maxBuffer, onMaxBufferChanged = onMaxBufferChanged, + joinAddresses = joinAddresses, + selectedJoinInfoType = selectedJoinInfoType, + onSelectedJoinInfoTypeChanged = { selectedJoinInfoType = it }, modifier = modifier ) } @@ -243,6 +263,9 @@ private fun PortraitContent( hostInputAuthorityEnabled: Boolean, maxBuffer: Int, onMaxBufferChanged: (Int) -> Unit, + joinAddresses: Map, + selectedJoinInfoType: JoinInfoType, + onSelectedJoinInfoTypeChanged: (JoinInfoType) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -273,6 +296,9 @@ private fun PortraitContent( maxBuffer = maxBuffer, onMaxBufferChanged = onMaxBufferChanged, isHosting = isHosting, + joinAddresses = joinAddresses, + selectedJoinInfoType = selectedJoinInfoType, + onSelectedJoinInfoTypeChanged = onSelectedJoinInfoTypeChanged, modifier = Modifier .padding(horizontal = DolphinTheme.scaffoldPadding), ) @@ -299,10 +325,14 @@ private fun LandscapeContent( hostInputAuthorityEnabled: Boolean, maxBuffer: Int, onMaxBufferChanged: (Int) -> Unit, + joinAddresses: Map, + selectedJoinInfoType: JoinInfoType, + onSelectedJoinInfoTypeChanged: (JoinInfoType) -> Unit, modifier: Modifier = Modifier, ) { Row( modifier = modifier + .padding(horizontal = DolphinTheme.scaffoldPadding) ) { Chat( messages = messages, @@ -312,9 +342,10 @@ private fun LandscapeContent( modifier = Modifier .weight(1f) .fillMaxHeight() - .padding(horizontal = DolphinTheme.scaffoldPadding) ) + Spacer(modifier = Modifier.width(16.dp)) + Column( modifier = Modifier .weight(1f) @@ -331,8 +362,10 @@ private fun LandscapeContent( maxBuffer = maxBuffer, onMaxBufferChanged = onMaxBufferChanged, isHosting = isHosting, + joinAddresses = joinAddresses, + selectedJoinInfoType = selectedJoinInfoType, + onSelectedJoinInfoTypeChanged = onSelectedJoinInfoTypeChanged, modifier = Modifier - .padding(horizontal = DolphinTheme.scaffoldPadding) ) if (isHosting) { @@ -354,6 +387,9 @@ private fun PLayersAndSettings( maxBuffer: Int, onMaxBufferChanged: (Int) -> Unit, isHosting: Boolean, + joinAddresses: Map, + selectedJoinInfoType: JoinInfoType, + onSelectedJoinInfoTypeChanged: (JoinInfoType) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -368,6 +404,16 @@ private fun PLayersAndSettings( isHosting = isHosting, ) + if (isHosting) { + MenuSpacer() + + JoinAddressSection( + joinAddresses = joinAddresses, + selectedType = selectedJoinInfoType, + onSelectedTypeChanged = onSelectedJoinInfoTypeChanged, + ) + } + MenuSpacer() OutlinedBox( @@ -479,6 +525,7 @@ private fun Chat( OutlinedBox( onClick = { onShowBottomSheetChanged(true) }, label = { Text(stringResource(R.string.netplay_chat_label)) }, + fadeContentTop = true, modifier = modifier ) { LazyColumn( @@ -532,26 +579,16 @@ private fun GamePicker( } } - Box( + ReadOnlyTextField( + value = game, + label = stringResource(R.string.netplay_game_label), + onClick = if (isHosting) { + { onShowGamePickerChanged(true) } + } else { + null + }, modifier = Modifier.fillMaxWidth() - ) { - OutlinedTextField( - value = game, - onValueChange = {}, - label = { Text(stringResource(R.string.netplay_game_label)) }, - readOnly = true, - modifier = Modifier.fillMaxWidth() - ) - if (isHosting) { - Box( - modifier = Modifier - .matchParentSize() - .padding(top = 8.dp) - .clip(MaterialTheme.shapes.extraSmall) - .clickable { onShowGamePickerChanged(true) } - ) - } - } + ) } @Composable @@ -609,6 +646,150 @@ private fun GameGridItem( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun JoinAddressSection( + joinAddresses: Map, + selectedType: JoinInfoType, + onSelectedTypeChanged: (JoinInfoType) -> Unit, +) { + val address = joinAddresses[selectedType] ?: joinAddresses.values.first() + + @Suppress("UnusedBoxWithConstraintsScope") + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + if (maxWidth > 392.dp) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + JoinInfoDropdown( + joinAddresses = joinAddresses, + selectedType = selectedType, + onSelectedTypeChanged = onSelectedTypeChanged, + modifier = Modifier.weight(0.39f), + ) + AddressRow( + address = address, + modifier = Modifier.weight(0.61f), + ) + } + } else { + Column(modifier = Modifier.fillMaxWidth()) { + JoinInfoDropdown( + joinAddresses = joinAddresses, + selectedType = selectedType, + onSelectedTypeChanged = onSelectedTypeChanged, + modifier = Modifier.fillMaxWidth(), + ) + MenuSpacer() + AddressRow( + address = address, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun JoinInfoDropdown( + joinAddresses: Map, + selectedType: JoinInfoType, + onSelectedTypeChanged: (JoinInfoType) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = modifier, + ) { + OutlinedTextField( + value = stringResource(selectedType.labelId), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.netplay_host_address_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + joinAddresses.keys.forEach { type -> + DropdownMenuItem( + text = { Text(stringResource(type.labelId)) }, + onClick = { + onSelectedTypeChanged(type) + expanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } +} + +@Composable +private fun AddressRow( + address: JoinAddress, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + ReadOnlyTextField( + value = when (address) { + is JoinAddress.Loading -> stringResource(R.string.netplay_address_loading) + is JoinAddress.Loaded -> address.address + is JoinAddress.Unknown -> stringResource(R.string.netplay_address_unknown) + }, + label = stringResource(R.string.netplay_address_label), + onClick = when (address) { + is JoinAddress.Loaded -> { + { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, address.address) + } + context.startActivity(Intent.createChooser(intent, null)) + } + } + + is JoinAddress.Unknown -> address.retry + is JoinAddress.Loading -> null + }, + textStyle = if (address is JoinAddress.Loading) { + LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + null + }, + trailingIcon = { + when (address) { + is JoinAddress.Loaded -> Icon( + imageVector = Icons.Filled.Share, + contentDescription = stringResource(R.string.netplay_address_share), + ) + + is JoinAddress.Unknown -> Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = stringResource(R.string.netplay_address_retry), + ) + + is JoinAddress.Loading -> CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } + }, + modifier = modifier, + ) +} + /** * A table arranged into columns sized to wrap the largest item. Except the * first column which takes up the remaining space left by the other columns. @@ -981,6 +1162,10 @@ private fun PreviewNetplayScreen() { onMaxBufferChanged = {}, saveTransferProgress = null, gameDigestProgress = null, + joinAddresses = mapOf( + JoinInfoType.EXTERNAL to JoinAddress.Loaded("203.0.113.1:2626"), + JoinInfoType.LOCAL to JoinAddress.Loaded("192.168.1.5:2626"), + ), // saveTransferProgress = SaveTransferProgress( // title = "Title", // totalSize = 1024L, diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt index d25a345862..c5fa746346 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt @@ -17,8 +17,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.ColorScheme import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable @@ -28,6 +31,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import com.google.android.material.color.MaterialColors @@ -205,3 +209,35 @@ fun OutlinedBox( } } } + +@Composable +fun ReadOnlyTextField( + value: String, + label: String, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + trailingIcon: (@Composable () -> Unit)? = null, + textStyle: TextStyle? = null, +) { + Box(modifier = modifier) { + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + singleLine = true, + label = { Text(label) }, + trailingIcon = trailingIcon, + textStyle = textStyle ?: LocalTextStyle.current, + modifier = Modifier.fillMaxWidth() + ) + if (onClick != null) { + Box( + modifier = Modifier + .matchParentSize() + .padding(top = 8.dp) + .clip(MaterialTheme.shapes.extraSmall) + .clickable(onClick = onClick) + ) + } + } +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/NetworkHelper.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/NetworkHelper.kt index 7bd2068ee2..231f15fda1 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/NetworkHelper.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/NetworkHelper.kt @@ -75,4 +75,9 @@ object NetworkHelper { 0 } } + + @JvmStatic + fun getLocalIpString(): String? { + return getIPv4Link()?.address?.hostAddress + } } diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 9c89d3aed7..20c78eddc5 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1016,4 +1016,12 @@ It can efficiently compress both junk data and encrypted Wii data. Close Port Forward port (UPnP) + Join info + Address + External IP + Local IP + Loading… + Unknown + Share address + Retry diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index 527ca40f40..9192404042 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -8,6 +8,7 @@ #include #include "Common/CommonTypes.h" +#include "Core/NetPlayCommon.h" #include "Core/Boot/Boot.h" #include "Core/Config/NetplaySettings.h" #include "Core/NetPlayClient.h" @@ -174,6 +175,25 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeStartGame(J server->RequestStartGame(); } +JNIEXPORT jint JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeGetPort(JNIEnv* env, + jobject obj) +{ + if (auto* server = GetServerPointer(env, obj)) + return static_cast(server->GetPort()); + return 0; +} + +JNIEXPORT jstring JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeGetExternalIpAddress( + JNIEnv* env, jobject) +{ + std::string ip = NetPlay::GetExternalIPAddress(); + if (ip.empty()) + return nullptr; + return ToJString(env, ip); +} + JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseUICallbacks(JNIEnv*, jobject, From f38f61a6a06769452e329f79e2607a87963143b9 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 5 May 2026 18:50:04 +0200 Subject: [PATCH 33/37] 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. --- .../features/netplay/NetplaySession.kt | 38 +++++++- .../features/netplay/model/JoinInfo.kt | 1 + .../netplay/model/NetplayViewModel.kt | 51 +++++++++-- .../features/netplay/model/TraversalState.kt | 18 ++++ .../features/netplay/ui/NetplayActivity.kt | 1 + .../features/netplay/ui/NetplayScreen.kt | 34 ++++++- .../app/src/main/res/values/strings.xml | 8 +- Source/Android/jni/AndroidCommon/IDCache.cpp | 9 ++ Source/Android/jni/AndroidCommon/IDCache.h | 1 + .../jni/NetPlay/NetPlayUICallbacks.cpp | 89 ++++++++++++++++++- Source/Android/jni/NetPlay/Netplay.cpp | 9 ++ 11 files changed, 247 insertions(+), 12 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/TraversalState.kt 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 16314e4076..cc5fbdcea3 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) { mergeMessages() .runningFold(emptyList()) { acc, msg -> listOf(msg) + acc } @@ -144,7 +154,7 @@ class NetplaySession( } fun sendMessage(message: String) { - _chatMessages.tryEmit( "$nickName: $message") + _chatMessages.tryEmit("$nickName: $message") nativeSendMessage(message) } @@ -158,6 +168,8 @@ class NetplaySession( fun getExternalIpAddress(): String? = nativeGetExternalIpAddress() + fun reconnectTraversal() = nativeReconnectTraversal() + fun consumeBootSessionData(): Long { return bootSessionDataPointer.also { bootSessionDataPointer = 0 @@ -243,6 +255,8 @@ class NetplaySession( private external fun nativeGetExternalIpAddress(): String? + private external fun nativeReconnectTraversal() + // NetPlayUI callbacks @Keep @@ -386,6 +400,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 db70f8a3e9..dca01823fe 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, diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 20c78eddc5..fbc664f4ef 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1018,8 +1018,12 @@ It can efficiently compress both junk data and encrypted Wii data. Forward port (UPnP) Join info Address - External IP - Local IP + Code + Room + External + Local + Couldn\'t look up central server + Dolphin is too old for traversal server Loading… Unknown Share address diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index 41d7055471..db05bda5d5 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -49,6 +49,7 @@ static jmethodID s_netplay_on_show_game_digest_dialog; static jmethodID s_netplay_on_set_game_digest_progress; static jmethodID s_netplay_on_set_game_digest_result; static jmethodID s_netplay_on_abort_game_digest; +static jmethodID s_netplay_on_traversal_state_changed; static jclass s_netplay_player_class; static jmethodID s_netplay_player_constructor; @@ -363,6 +364,11 @@ jmethodID GetNetplayOnAbortGameDigest() return s_netplay_on_abort_game_digest; } +jmethodID GetNetplayOnTraversalStateChanged() +{ + return s_netplay_on_traversal_state_changed; +} + jclass GetNetplayPlayerClass() { return s_netplay_player_class; @@ -813,6 +819,9 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) env->GetMethodID(netplay_class, "onSetGameDigestResult", "(ILjava/lang/String;)V"); s_netplay_on_abort_game_digest = env->GetMethodID(netplay_class, "onAbortGameDigest", "()V"); + s_netplay_on_traversal_state_changed = env->GetMethodID( + netplay_class, "onTraversalStateChanged", + "(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V"); env->DeleteLocalRef(netplay_class); const jclass netplay_player_class = diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h index 51a5ae02bc..064b37df44 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -52,6 +52,7 @@ jmethodID GetNetplayOnShowGameDigestDialog(); jmethodID GetNetplayOnSetGameDigestProgress(); jmethodID GetNetplayOnSetGameDigestResult(); jmethodID GetNetplayOnAbortGameDigest(); +jmethodID GetNetplayOnTraversalStateChanged(); jclass GetNetplayPlayerClass(); jmethodID GetNetplayPlayerConstructor(); diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index ecbf168fb5..ce9336ff15 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -1,15 +1,62 @@ // Copyright 2003 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include + #include "UICommon/GameFile.h" #include "UICommon/UICommon.h" #include "NetPlayUICallbacks.h" +#include "Common/TraversalClient.h" #include "Core/Boot/Boot.h" #include "Core/Core.h" #include "Core/System.h" #include "jni/AndroidCommon/AndroidCommon.h" #include "jni/AndroidCommon/IDCache.h" +namespace +{ +std::string InetAddressToString(const Common::TraversalInetAddress& addr) +{ + std::string ip; + + if (addr.isIPV6) + { + ip = "IPv6-Not-Implemented"; + } + else + { + const auto ipv4 = reinterpret_cast(addr.address); + ip = std::to_string(ipv4[0]); + for (u32 i = 1; i != 4; ++i) + { + ip += "."; + ip += std::to_string(ipv4[i]); + } + } + + return ip + ":" + std::to_string(ntohs(addr.port)); +} + +const char* FailureReasonToString(Common::TraversalClient::FailureReason reason) +{ + switch (reason) + { + case Common::TraversalClient::FailureReason::BadHost: + return "BadHost"; + case Common::TraversalClient::FailureReason::VersionTooOld: + return "VersionTooOld"; + case Common::TraversalClient::FailureReason::ServerForgotAboutUs: + return "ServerForgotAboutUs"; + case Common::TraversalClient::FailureReason::SocketSendError: + return "SocketSendError"; + case Common::TraversalClient::FailureReason::ResendTimeout: + return "ResendTimeout"; + default: + return "Unknown"; + } +} +} // namespace + namespace NetPlay { NetPlayUICallbacks::NetPlayUICallbacks(jobject netplay_session, @@ -242,8 +289,48 @@ void NetPlayUICallbacks::OnConnectionError(const std::string& message) env->DeleteLocalRef(netplay_session); } +// No-op — all error info is captured by OnTraversalStateChanged which always fires alongside. void NetPlayUICallbacks::OnTraversalError(Common::TraversalClient::FailureReason) {} -void NetPlayUICallbacks::OnTraversalStateChanged(Common::TraversalClient::State) {} + +void NetPlayUICallbacks::OnTraversalStateChanged(Common::TraversalClient::State state) +{ + JNIEnv* env = IDCache::GetEnvForThread(); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + jstring host_code = nullptr; + jstring external_address = nullptr; + jstring failure_reason = nullptr; + + if (Common::g_TraversalClient) + { + if (state == Common::TraversalClient::State::Connected) + { + const auto host_id = Common::g_TraversalClient->GetHostID(); + host_code = ToJString(env, std::string(host_id.begin(), host_id.end())); + external_address = + ToJString(env, InetAddressToString(Common::g_TraversalClient->GetExternalAddress())); + } + else if (state == Common::TraversalClient::State::Failure) + { + failure_reason = + ToJString(env, FailureReasonToString(Common::g_TraversalClient->GetFailureReason())); + } + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnTraversalStateChanged(), + static_cast(state), host_code, external_address, failure_reason); + } + + if (host_code) + env->DeleteLocalRef(host_code); + if (external_address) + env->DeleteLocalRef(external_address); + if (failure_reason) + env->DeleteLocalRef(failure_reason); + env->DeleteLocalRef(netplay_session); +} + void NetPlayUICallbacks::OnGameStartAborted() {} void NetPlayUICallbacks::OnGolferChanged(bool, const std::string&) {} void NetPlayUICallbacks::OnTtlDetermined(u8) {} diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index 9192404042..8ed026c9c8 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -8,6 +8,7 @@ #include #include "Common/CommonTypes.h" +#include "Common/TraversalClient.h" #include "Core/NetPlayCommon.h" #include "Core/Boot/Boot.h" #include "Core/Config/NetplaySettings.h" @@ -194,6 +195,14 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeGetExternal return ToJString(env, ip); } +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReconnectTraversal(JNIEnv*, + jobject) +{ + if (Common::g_TraversalClient) + Common::g_TraversalClient->ReconnectToServer(); +} + JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseUICallbacks(JNIEnv*, jobject, From 5d54c161b773697455a29f42ff021738a7d001a8 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 5 May 2026 21:50:04 +0200 Subject: [PATCH 34/37] Add host buffer settings for fair input delay mode Also rename max buffer to client buffer for better consistency with settings and c++ --- .../features/netplay/NetplaySession.kt | 10 ++- .../netplay/model/NetplayViewModel.kt | 21 ++++-- .../features/netplay/ui/NetplayActivity.kt | 6 +- .../features/netplay/ui/NetplayScreen.kt | 72 +++++++++++++------ .../features/settings/model/IntSetting.kt | 6 ++ .../app/src/main/res/values/strings.xml | 3 +- Source/Android/jni/NetPlay/Netplay.cpp | 10 ++- 7 files changed, 93 insertions(+), 35 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 cc5fbdcea3..3edc520f03 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 @@ -49,7 +49,7 @@ class NetplaySession( @Volatile var isClosing = false private set - + val isHosting: Boolean get() = netPlayServerPointer != 0L @@ -158,7 +158,9 @@ class NetplaySession( nativeSendMessage(message) } - fun adjustPadBufferSize(buffer: Int) = nativeAdjustPadBufferSize(buffer) + fun adjustClientPadBufferSize(buffer: Int) = nativeAdjustClientPadBufferSize(buffer) + + fun adjustServerPadBufferSize(buffer: Int) = nativeAdjustServerPadBufferSize(buffer) fun changeGame(gameFile: GameFile) = nativeChangeGame(gameFile) @@ -237,7 +239,9 @@ class NetplaySession( private external fun nativeSendMessage(message: String) - private external fun nativeAdjustPadBufferSize(buffer: Int) + private external fun nativeAdjustClientPadBufferSize(buffer: Int) + + private external fun nativeAdjustServerPadBufferSize(buffer: Int) private external fun nativeReleaseUICallbacks(pointer: Long) 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 4ebea7e283..ea8ac9dea5 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 @@ -65,8 +65,11 @@ class NetplayViewModel( val hostInputAuthority = netplaySession.hostInputAuthorityEnabled .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) - private val _maxBuffer = MutableStateFlow(IntSetting.NETPLAY_CLIENT_BUFFER_SIZE.int) - val maxBuffer = _maxBuffer.asStateFlow() + private val _buffer = MutableStateFlow(IntSetting.NETPLAY_BUFFER_SIZE.int) + val buffer = _buffer.asStateFlow() + + private val _clientBuffer = MutableStateFlow(IntSetting.NETPLAY_CLIENT_BUFFER_SIZE.int) + val clientBuffer = _clientBuffer.asStateFlow() val gameFiles = GameFileCacheManager.getGameFiles().asFlow() .map { it.toList() } @@ -104,10 +107,16 @@ class NetplayViewModel( netplaySession.sendMessage(trimmedMessage) } - fun setMaxBuffer(buffer: Int) { - _maxBuffer.value = buffer - IntSetting.NETPLAY_CLIENT_BUFFER_SIZE.setInt(NativeConfig.LAYER_BASE, buffer) - netplaySession.adjustPadBufferSize(buffer) + fun setBuffer(value: Int) { + _buffer.value = value + IntSetting.NETPLAY_BUFFER_SIZE.setInt(NativeConfig.LAYER_BASE, value) + netplaySession.adjustServerPadBufferSize(value) + } + + fun setClientBuffer(value: Int) { + _clientBuffer.value = value + IntSetting.NETPLAY_CLIENT_BUFFER_SIZE.setInt(NativeConfig.LAYER_BASE, value) + netplaySession.adjustClientPadBufferSize(value) } fun changeGame(gameFile: GameFile) { 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 e45ba7e7c1..bae20aee46 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 @@ -60,8 +60,10 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { gameFiles = viewModel.gameFiles.collectAsState().value, players = viewModel.players.collectAsState().value, hostInputAuthorityEnabled = viewModel.hostInputAuthority.collectAsState().value, - maxBuffer = viewModel.maxBuffer.collectAsState().value, - onMaxBufferChanged = viewModel::setMaxBuffer, + buffer = viewModel.buffer.collectAsState().value, + onBufferChanged = viewModel::setBuffer, + clientBuffer = viewModel.clientBuffer.collectAsState().value, + onClientBufferChanged = viewModel::setClientBuffer, saveTransferProgress = viewModel.saveTransferProgress.collectAsState().value, gameDigestProgress = viewModel.gameDigestProgress.collectAsState().value, joinAddresses = viewModel.joinAddresses.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 dca01823fe..4240035d3f 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 @@ -121,8 +121,10 @@ fun NetplayScreen( onGameSelected: (GameFile) -> Unit, gameFiles: List, hostInputAuthorityEnabled: Boolean, - maxBuffer: Int, - onMaxBufferChanged: (Int) -> Unit, + buffer: Int, + onBufferChanged: (Int) -> Unit, + clientBuffer: Int, + onClientBufferChanged: (Int) -> Unit, players: List, saveTransferProgress: SaveTransferProgress?, gameDigestProgress: GameDigestProgress?, @@ -176,8 +178,10 @@ fun NetplayScreen( onShowGamePickerChanged = { showGamePicker = it }, players = players, hostInputAuthorityEnabled = hostInputAuthorityEnabled, - maxBuffer = maxBuffer, - onMaxBufferChanged = onMaxBufferChanged, + buffer = buffer, + onBufferChanged = onBufferChanged, + clientBuffer = clientBuffer, + onClientBufferChanged = onClientBufferChanged, joinAddresses = joinAddresses, selectedJoinInfoType = selectedJoinInfoType, onSelectedJoinInfoTypeChanged = { selectedJoinInfoType = it }, @@ -197,8 +201,10 @@ fun NetplayScreen( onShowGamePickerChanged = { showGamePicker = it }, players = players, hostInputAuthorityEnabled = hostInputAuthorityEnabled, - maxBuffer = maxBuffer, - onMaxBufferChanged = onMaxBufferChanged, + buffer = buffer, + onBufferChanged = onBufferChanged, + clientBuffer = clientBuffer, + onClientBufferChanged = onClientBufferChanged, joinAddresses = joinAddresses, selectedJoinInfoType = selectedJoinInfoType, onSelectedJoinInfoTypeChanged = { selectedJoinInfoType = it }, @@ -284,8 +290,10 @@ private fun PortraitContent( onShowGamePickerChanged: (Boolean) -> Unit, players: List, hostInputAuthorityEnabled: Boolean, - maxBuffer: Int, - onMaxBufferChanged: (Int) -> Unit, + buffer: Int, + onBufferChanged: (Int) -> Unit, + clientBuffer: Int, + onClientBufferChanged: (Int) -> Unit, joinAddresses: Map, selectedJoinInfoType: JoinInfoType, onSelectedJoinInfoTypeChanged: (JoinInfoType) -> Unit, @@ -316,8 +324,10 @@ private fun PortraitContent( onShowGamePickerChanged = onShowGamePickerChanged, players = players, hostInputAuthorityEnabled = hostInputAuthorityEnabled, - maxBuffer = maxBuffer, - onMaxBufferChanged = onMaxBufferChanged, + buffer = buffer, + onBufferChanged = onBufferChanged, + clientBuffer = clientBuffer, + onClientBufferChanged = onClientBufferChanged, isHosting = isHosting, joinAddresses = joinAddresses, selectedJoinInfoType = selectedJoinInfoType, @@ -346,8 +356,10 @@ private fun LandscapeContent( onShowGamePickerChanged: (Boolean) -> Unit, players: List, hostInputAuthorityEnabled: Boolean, - maxBuffer: Int, - onMaxBufferChanged: (Int) -> Unit, + buffer: Int, + onBufferChanged: (Int) -> Unit, + clientBuffer: Int, + onClientBufferChanged: (Int) -> Unit, joinAddresses: Map, selectedJoinInfoType: JoinInfoType, onSelectedJoinInfoTypeChanged: (JoinInfoType) -> Unit, @@ -382,8 +394,10 @@ private fun LandscapeContent( onShowGamePickerChanged = onShowGamePickerChanged, players = players, hostInputAuthorityEnabled = hostInputAuthorityEnabled, - maxBuffer = maxBuffer, - onMaxBufferChanged = onMaxBufferChanged, + buffer = buffer, + onBufferChanged = onBufferChanged, + clientBuffer = clientBuffer, + onClientBufferChanged = onClientBufferChanged, isHosting = isHosting, joinAddresses = joinAddresses, selectedJoinInfoType = selectedJoinInfoType, @@ -407,8 +421,10 @@ private fun PLayersAndSettings( onShowGamePickerChanged: (Boolean) -> Unit, players: List, hostInputAuthorityEnabled: Boolean, - maxBuffer: Int, - onMaxBufferChanged: (Int) -> Unit, + buffer: Int, + onBufferChanged: (Int) -> Unit, + clientBuffer: Int, + onClientBufferChanged: (Int) -> Unit, isHosting: Boolean, joinAddresses: Map, selectedJoinInfoType: JoinInfoType, @@ -459,13 +475,23 @@ private fun PLayersAndSettings( ) } - if (hostInputAuthorityEnabled) { + if (isHosting && !hostInputAuthorityEnabled) { MenuSpacer() BufferInput( - value = maxBuffer, - onValueChange = onMaxBufferChanged, - label = stringResource(R.string.netplay_max_buffer), + value = buffer, + onValueChange = onBufferChanged, + label = stringResource(R.string.netplay_buffer), + ) + } + + if (!isHosting && hostInputAuthorityEnabled) { + MenuSpacer() + + BufferInput( + value = clientBuffer, + onValueChange = onClientBufferChanged, + label = stringResource(R.string.netplay_client_buffer), ) } } @@ -1188,8 +1214,10 @@ private fun PreviewNetplayScreen() { onGameSelected = {}, gameFiles = emptyList(), hostInputAuthorityEnabled = true, - maxBuffer = 10, - onMaxBufferChanged = {}, + buffer = 5, + onBufferChanged = {}, + clientBuffer = 10, + onClientBufferChanged = {}, saveTransferProgress = null, gameDigestProgress = null, joinAddresses = mapOf( diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt index 85f265442b..628e286b49 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt @@ -146,6 +146,12 @@ enum class IntSetting( Settings.SECTION_INI_NETPLAY, "BufferSizeClient", 1 + ), + NETPLAY_BUFFER_SIZE( + Settings.FILE_DOLPHIN, + Settings.SECTION_INI_NETPLAY, + "BufferSize", + 5 ); override val isOverridden: Boolean diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index fbc664f4ef..09abe850fb 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1008,7 +1008,8 @@ It can efficiently compress both junk data and encrypted Wii data. Name Ping Mapping - Max buffer + Buffer + Max buffer Netplay connection lost Close The hashes match diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index 8ed026c9c8..e845f8ec60 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -50,7 +50,7 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeSendMessage } JNIEXPORT void JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeAdjustPadBufferSize(JNIEnv* env, +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeAdjustClientPadBufferSize(JNIEnv* env, jobject obj, jint buffer) { @@ -58,6 +58,14 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeAdjustPadBu client->AdjustPadBufferSize(static_cast(buffer)); } +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeAdjustServerPadBufferSize( + JNIEnv* env, jobject obj, jint buffer) +{ + if (auto* server = GetServerPointer(env, obj)) + server->AdjustPadBufferSize(static_cast(buffer)); +} + JNIEXPORT jlong JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeCreateUICallbacks(JNIEnv* env, jobject obj) From c0e44478a09e239801b015c72cf20a106f343294 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 6 May 2026 09:46:33 +0200 Subject: [PATCH 35/37] Add network mode picker for host --- .../features/netplay/NetplaySession.kt | 4 ++ .../netplay/model/NetplayViewModel.kt | 11 +++ .../features/netplay/model/NetworkMode.kt | 18 +++++ .../features/netplay/ui/NetplayActivity.kt | 2 + .../features/netplay/ui/NetplayScreen.kt | 69 +++++++++++++++++++ .../features/settings/model/StringSetting.kt | 3 +- .../app/src/main/res/values/strings.xml | 4 ++ Source/Android/jni/NetPlay/Netplay.cpp | 8 +++ 8 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetworkMode.kt 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 3edc520f03..65a7e2c10e 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 @@ -158,6 +158,8 @@ class NetplaySession( nativeSendMessage(message) } + fun setHostInputAuthority(enable: Boolean) = nativeSetHostInputAuthority(enable) + fun adjustClientPadBufferSize(buffer: Int) = nativeAdjustClientPadBufferSize(buffer) fun adjustServerPadBufferSize(buffer: Int) = nativeAdjustServerPadBufferSize(buffer) @@ -239,6 +241,8 @@ class NetplaySession( private external fun nativeSendMessage(message: String) + private external fun nativeSetHostInputAuthority(enable: Boolean) + private external fun nativeAdjustClientPadBufferSize(buffer: Int) private external fun nativeAdjustServerPadBufferSize(buffer: Int) 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 ea8ac9dea5..9f9b422f4b 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 @@ -65,6 +65,11 @@ class NetplayViewModel( val hostInputAuthority = netplaySession.hostInputAuthorityEnabled .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) + private val _networkMode = MutableStateFlow( + NetworkMode.fromConfigValue(StringSetting.NETPLAY_NETWORK_MODE.string) + ) + val networkMode = _networkMode.asStateFlow() + private val _buffer = MutableStateFlow(IntSetting.NETPLAY_BUFFER_SIZE.int) val buffer = _buffer.asStateFlow() @@ -107,6 +112,12 @@ class NetplayViewModel( netplaySession.sendMessage(trimmedMessage) } + fun setNetworkMode(mode: NetworkMode) { + _networkMode.value = mode + StringSetting.NETPLAY_NETWORK_MODE.setString(NativeConfig.LAYER_BASE, mode.configValue) + netplaySession.setHostInputAuthority(mode.isHostInputAuthority) + } + fun setBuffer(value: Int) { _buffer.value = value IntSetting.NETPLAY_BUFFER_SIZE.setInt(NativeConfig.LAYER_BASE, value) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetworkMode.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetworkMode.kt new file mode 100644 index 0000000000..55d29f5f17 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetworkMode.kt @@ -0,0 +1,18 @@ +package org.dolphinemu.dolphinemu.features.netplay.model + +import androidx.annotation.StringRes +import org.dolphinemu.dolphinemu.R + +enum class NetworkMode(val configValue: String, @StringRes val labelId: Int) { + FAIR_INPUT_DELAY("fixeddelay", R.string.netplay_network_mode_fair_input_delay), + HOST_INPUT_AUTHORITY("hostinputauthority", R.string.netplay_network_mode_host_input_authority), + GOLF("golf", R.string.netplay_network_mode_golf); + + val isHostInputAuthority: Boolean + get() = this == HOST_INPUT_AUTHORITY || this == GOLF + + companion object { + fun fromConfigValue(value: String): NetworkMode = + entries.find { it.configValue == value } ?: FAIR_INPUT_DELAY + } +} 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 bae20aee46..fe38cfe067 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 @@ -60,6 +60,8 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { gameFiles = viewModel.gameFiles.collectAsState().value, players = viewModel.players.collectAsState().value, hostInputAuthorityEnabled = viewModel.hostInputAuthority.collectAsState().value, + networkMode = viewModel.networkMode.collectAsState().value, + onNetworkModeChanged = viewModel::setNetworkMode, buffer = viewModel.buffer.collectAsState().value, onBufferChanged = viewModel::setBuffer, clientBuffer = viewModel.clientBuffer.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 4240035d3f..de1f0d16a4 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 @@ -95,6 +95,7 @@ import org.dolphinemu.dolphinemu.features.netplay.model.GameDigestProgress import org.dolphinemu.dolphinemu.features.netplay.model.JoinAddress import org.dolphinemu.dolphinemu.features.netplay.model.JoinInfoType import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage +import org.dolphinemu.dolphinemu.features.netplay.model.NetworkMode import org.dolphinemu.dolphinemu.features.netplay.model.Player import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress import org.dolphinemu.dolphinemu.features.netplay.model.TraversalState @@ -121,6 +122,8 @@ fun NetplayScreen( onGameSelected: (GameFile) -> Unit, gameFiles: List, hostInputAuthorityEnabled: Boolean, + networkMode: NetworkMode, + onNetworkModeChanged: (NetworkMode) -> Unit, buffer: Int, onBufferChanged: (Int) -> Unit, clientBuffer: Int, @@ -178,6 +181,8 @@ fun NetplayScreen( onShowGamePickerChanged = { showGamePicker = it }, players = players, hostInputAuthorityEnabled = hostInputAuthorityEnabled, + networkMode = networkMode, + onNetworkModeChanged = onNetworkModeChanged, buffer = buffer, onBufferChanged = onBufferChanged, clientBuffer = clientBuffer, @@ -201,6 +206,8 @@ fun NetplayScreen( onShowGamePickerChanged = { showGamePicker = it }, players = players, hostInputAuthorityEnabled = hostInputAuthorityEnabled, + networkMode = networkMode, + onNetworkModeChanged = onNetworkModeChanged, buffer = buffer, onBufferChanged = onBufferChanged, clientBuffer = clientBuffer, @@ -290,6 +297,8 @@ private fun PortraitContent( onShowGamePickerChanged: (Boolean) -> Unit, players: List, hostInputAuthorityEnabled: Boolean, + networkMode: NetworkMode, + onNetworkModeChanged: (NetworkMode) -> Unit, buffer: Int, onBufferChanged: (Int) -> Unit, clientBuffer: Int, @@ -324,6 +333,8 @@ private fun PortraitContent( onShowGamePickerChanged = onShowGamePickerChanged, players = players, hostInputAuthorityEnabled = hostInputAuthorityEnabled, + networkMode = networkMode, + onNetworkModeChanged = onNetworkModeChanged, buffer = buffer, onBufferChanged = onBufferChanged, clientBuffer = clientBuffer, @@ -356,6 +367,8 @@ private fun LandscapeContent( onShowGamePickerChanged: (Boolean) -> Unit, players: List, hostInputAuthorityEnabled: Boolean, + networkMode: NetworkMode, + onNetworkModeChanged: (NetworkMode) -> Unit, buffer: Int, onBufferChanged: (Int) -> Unit, clientBuffer: Int, @@ -394,6 +407,8 @@ private fun LandscapeContent( onShowGamePickerChanged = onShowGamePickerChanged, players = players, hostInputAuthorityEnabled = hostInputAuthorityEnabled, + networkMode = networkMode, + onNetworkModeChanged = onNetworkModeChanged, buffer = buffer, onBufferChanged = onBufferChanged, clientBuffer = clientBuffer, @@ -421,6 +436,8 @@ private fun PLayersAndSettings( onShowGamePickerChanged: (Boolean) -> Unit, players: List, hostInputAuthorityEnabled: Boolean, + networkMode: NetworkMode, + onNetworkModeChanged: (NetworkMode) -> Unit, buffer: Int, onBufferChanged: (Int) -> Unit, clientBuffer: Int, @@ -475,6 +492,15 @@ private fun PLayersAndSettings( ) } + if (isHosting) { + MenuSpacer() + + NetworkModeDropdown( + networkMode = networkMode, + onNetworkModeChanged = onNetworkModeChanged, + ) + } + if (isHosting && !hostInputAuthorityEnabled) { MenuSpacer() @@ -902,6 +928,47 @@ private fun PlayersTable( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NetworkModeDropdown( + networkMode: NetworkMode, + onNetworkModeChanged: (NetworkMode) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + value = stringResource(networkMode.labelId), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.netplay_network_mode_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + // No golf mode for now since it requires in game UI. + listOf(NetworkMode.FAIR_INPUT_DELAY, NetworkMode.HOST_INPUT_AUTHORITY).forEach { mode -> + DropdownMenuItem( + text = { Text(stringResource(mode.labelId)) }, + onClick = { + onNetworkModeChanged(mode) + expanded = false + }, + ) + } + } + } +} + @Composable private fun BufferInput( value: Int, @@ -1214,6 +1281,8 @@ private fun PreviewNetplayScreen() { onGameSelected = {}, gameFiles = emptyList(), hostInputAuthorityEnabled = true, + networkMode = NetworkMode.HOST_INPUT_AUTHORITY, + onNetworkModeChanged = {}, buffer = 5, onBufferChanged = {}, clientBuffer = 10, diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt index 610f45b2a3..72877429af 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt @@ -116,7 +116,8 @@ enum class StringSetting( ), NETPLAY_ADDRESS(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "Address", "127.0.0.1"), NETPLAY_NICKNAME(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "Nickname", "Player"), - NETPLAY_GAME(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "Game", ""); + NETPLAY_GAME(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "Game", ""), + NETPLAY_NETWORK_MODE(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "NetworkMode", "fixeddelay"); override val isOverridden: Boolean get() = NativeConfig.isOverridden(file, section, key) diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 09abe850fb..6837e951fe 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1008,6 +1008,10 @@ It can efficiently compress both junk data and encrypted Wii data. Name Ping Mapping + Input mode + Fair Input Delay + Host Input Authority + Golf Mode Buffer Max buffer Netplay connection lost diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index e845f8ec60..b19e1a3c9d 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -49,6 +49,14 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeSendMessage client->SendChatMessage(GetJString(env, jmessage)); } +JNIEXPORT void JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeSetHostInputAuthority( + JNIEnv* env, jobject obj, jboolean enable) +{ + if (auto* server = GetServerPointer(env, obj)) + server->SetHostInputAuthority(static_cast(enable)); +} + JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeAdjustClientPadBufferSize(JNIEnv* env, jobject obj, From 37e34af96a87534fb6051a1fa8e721659f350d7b Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 6 May 2026 11:49:31 +0200 Subject: [PATCH 36/37] Implement DoAllPlayersHaveGame() check --- .../features/netplay/NetplaySession.kt | 4 +++ .../netplay/model/NetplayViewModel.kt | 13 ++++++++ .../features/netplay/ui/NetplayActivity.kt | 4 ++- .../features/netplay/ui/NetplayScreen.kt | 30 +++++++++++++++++++ .../app/src/main/res/values/strings.xml | 2 ++ Source/Android/jni/NetPlay/Netplay.cpp | 9 ++++++ 6 files changed, 61 insertions(+), 1 deletion(-) 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 65a7e2c10e..0efabc7f0f 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 @@ -166,6 +166,8 @@ class NetplaySession( fun changeGame(gameFile: GameFile) = nativeChangeGame(gameFile) + fun doAllPlayersHaveGame(): Boolean = nativeDoAllPlayersHaveGame() + fun startGame() = nativeStartGame() fun getPort(): Int = nativeGetPort() @@ -257,6 +259,8 @@ class NetplaySession( private external fun nativeChangeGame(gameFile: GameFile) + private external fun nativeDoAllPlayersHaveGame(): Boolean + private external fun nativeStartGame() private external fun nativeGetPort(): Int 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 9f9b422f4b..1e02936835 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 @@ -9,7 +9,9 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn @@ -99,7 +101,18 @@ class NetplayViewModel( } } + private val _startGameWarning = Channel(Channel.CONFLATED) + val startGameWarning = _startGameWarning.receiveAsFlow() + fun startGame() { + if (netplaySession.doAllPlayersHaveGame()) { + netplaySession.startGame() + } else { + _startGameWarning.trySend(Unit) + } + } + + fun confirmStartGame() { netplaySession.startGame() } 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 fe38cfe067..1905db24fa 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 @@ -49,15 +49,17 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { DolphinTheme { NetplayScreen( onBackClicked = { finish() }, + isHosting = viewModel.isHosting, connectionLost = viewModel.connectionLost, fatalTraversalError = viewModel.fatalTraversalError, messages = viewModel.messages.collectAsState().value, onSendMessage = viewModel::sendMessage, game = viewModel.game.collectAsState().value, - isHosting = viewModel.isHosting, onStartGame = viewModel::startGame, onGameSelected = viewModel::changeGame, gameFiles = viewModel.gameFiles.collectAsState().value, + startGameWarning = viewModel.startGameWarning, + onConfirmStartGame = viewModel::confirmStartGame, players = viewModel.players.collectAsState().value, hostInputAuthorityEnabled = viewModel.hostInputAuthority.collectAsState().value, networkMode = viewModel.networkMode.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 de1f0d16a4..6613a719c0 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 @@ -121,6 +121,8 @@ fun NetplayScreen( onStartGame: () -> Unit, onGameSelected: (GameFile) -> Unit, gameFiles: List, + startGameWarning: Flow, + onConfirmStartGame: () -> Unit, hostInputAuthorityEnabled: Boolean, networkMode: NetworkMode, onNetworkModeChanged: (NetworkMode) -> Unit, @@ -229,6 +231,11 @@ fun NetplayScreen( fatalTraversalError.collect { traversalError = it } } + var showStartGameWarning by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + startGameWarning.collect { showStartGameWarning = true } + } + var dismissSaveTransferProgressDialog by rememberSaveable { mutableStateOf(false) } if (saveTransferProgress == null) { dismissSaveTransferProgressDialog = false @@ -279,6 +286,27 @@ fun NetplayScreen( onDismiss = { dismissGameDigestDialog = true }, ) } + + showStartGameWarning -> { + AlertDialog( + title = { Text(stringResource(R.string.netplay_start_warning_title)) }, + text = { Text(stringResource(R.string.netplay_start_warning_not_all_players_have_game)) }, + confirmButton = { + TextButton(onClick = { + showStartGameWarning = false + onConfirmStartGame() + }) { + Text(stringResource(R.string.yes)) + } + }, + dismissButton = { + TextButton(onClick = { showStartGameWarning = false }) { + Text(stringResource(R.string.no)) + } + }, + onDismissRequest = { showStartGameWarning = false }, + ) + } } } } @@ -1280,6 +1308,8 @@ private fun PreviewNetplayScreen() { onStartGame = {}, onGameSelected = {}, gameFiles = emptyList(), + startGameWarning = emptyFlow(), + onConfirmStartGame = {}, hostInputAuthorityEnabled = true, networkMode = NetworkMode.HOST_INPUT_AUTHORITY, onNetworkModeChanged = {}, diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 6837e951fe..58bce8c595 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -997,6 +997,8 @@ It can efficiently compress both junk data and encrypted Wii data. Port Netplay Start + Warning + Not all players have the game. Do you really want to start? Chat Send Game changed to %1$s diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index b19e1a3c9d..736f4e0109 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -181,6 +181,15 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeChangeGame( server->ChangeGame(game_file->GetSyncIdentifier(), game_file->GetLongName()); } +JNIEXPORT jboolean JNICALL +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeDoAllPlayersHaveGame( + JNIEnv* env, jobject obj) +{ + if (auto* client = GetClientPointer(env, obj)) + return static_cast(client->DoAllPlayersHaveGame()); + return JNI_TRUE; +} + JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeStartGame(JNIEnv* env, jobject obj) From fb03278049c254191529db3cfa6dee8b704116e7 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Thu, 7 May 2026 16:35:18 +0200 Subject: [PATCH 37/37] Update compose dependencies Was using 2025 instead of 2026! Fixed some buggy bottom sheet behaviour. Minor tidying of the chat UI in the bottom sheet --- .../features/netplay/ui/NetplayScreen.kt | 47 ++++++++++--------- .../dolphinemu/ui/theme/DolphinTheme.kt | 45 ++++++++++++++++++ Source/Android/gradle/libs.versions.toml | 2 +- 3 files changed, 71 insertions(+), 23 deletions(-) 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 6613a719c0..15221a4bf3 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 @@ -33,6 +33,7 @@ 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.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Remove @@ -57,7 +58,6 @@ import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -86,6 +86,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import coil.request.ImageRequest import kotlinx.coroutines.flow.Flow @@ -105,6 +106,7 @@ import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer import org.dolphinemu.dolphinemu.ui.theme.OutlinedBox import org.dolphinemu.dolphinemu.ui.theme.PreviewTheme import org.dolphinemu.dolphinemu.ui.theme.ReadOnlyTextField +import org.dolphinemu.dolphinemu.ui.theme.rememberSheetState import org.dolphinemu.dolphinemu.utils.CoilUtils import java.util.Locale @@ -564,7 +566,13 @@ private fun Chat( fun LazyListScope.messages() { items(messages.size) { index -> - Text(text = messages[index].message(context)) + Text( + text = messages[index].message(context), + style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 18.sp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + ) } } @@ -574,14 +582,10 @@ private fun Chat( draftMessage = "" } - val density = LocalDensity.current - val bottomSheetState = remember { - SheetState( - skipPartiallyExpanded = true, - density = density, - initialValue = if (showBottomSheet) SheetValue.Expanded else SheetValue.Hidden, - ) - } + val bottomSheetState = rememberSheetState( + skipPartiallyExpanded = true, + initialValue = if (showBottomSheet) SheetValue.Expanded else SheetValue.Hidden, + ) if (showBottomSheet) { ModalBottomSheet( @@ -594,7 +598,7 @@ private fun Chat( reverseLayout = true, contentPadding = PaddingValues(bottom = 4.dp), modifier = Modifier - .weight(1f) + .weight(1f, fill = false) .padding(horizontal = DolphinTheme.scaffoldPadding) ) { messages() @@ -605,7 +609,7 @@ private fun Chat( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp) + .padding(8.dp) ) { OutlinedTextField( value = draftMessage, @@ -615,11 +619,14 @@ private fun Chat( modifier = Modifier .weight(1f) ) - TextButton( + IconButton( onClick = submitMessage, enabled = draftMessage.isNotBlank(), ) { - Text(stringResource(R.string.netplay_chat_send)) + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = stringResource(R.string.netplay_chat_send), + ) } } } @@ -652,14 +659,10 @@ private fun GamePicker( onShowGamePickerChanged: (Boolean) -> Unit, isHosting: Boolean, ) { - val density = LocalDensity.current - val bottomSheetState = remember { - SheetState( - skipPartiallyExpanded = true, - density = density, - initialValue = if (showGamePicker) SheetValue.Expanded else SheetValue.Hidden, - ) - } + val bottomSheetState = rememberSheetState( + skipPartiallyExpanded = true, + initialValue = if (showGamePicker) SheetValue.Expanded else SheetValue.Hidden, + ) if (showGamePicker) { ModalBottomSheet( diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt index c5fa746346..37085b06d9 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt @@ -21,18 +21,24 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.SheetValue.Hidden import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.google.android.material.color.MaterialColors import androidx.appcompat.R as AppCompatR @@ -241,3 +247,42 @@ fun ReadOnlyTextField( } } } + +// A copy-paste of the internal function used in rememberModalBottomSheetState since +// rememberModalBottomSheetState doesn't expose a way to set the initial value. +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun rememberSheetState( + skipPartiallyExpanded: Boolean = false, + confirmValueChange: (SheetValue) -> Boolean = { true }, + initialValue: SheetValue = Hidden, + skipHiddenState: Boolean = false, + positionalThreshold: Dp = 56.dp, + velocityThreshold: Dp = 125.dp, +): SheetState { + val density = LocalDensity.current + val positionalThresholdToPx = { with(density) { positionalThreshold.toPx() } } + val velocityThresholdToPx = { with(density) { velocityThreshold.toPx() } } + return rememberSaveable( + skipPartiallyExpanded, + confirmValueChange, + skipHiddenState, + saver = + SheetState.Saver( + skipPartiallyExpanded = skipPartiallyExpanded, + positionalThreshold = positionalThresholdToPx, + velocityThreshold = velocityThresholdToPx, + confirmValueChange = confirmValueChange, + skipHiddenState = skipHiddenState, + ), + ) { + SheetState( + skipPartiallyExpanded, + positionalThresholdToPx, + velocityThresholdToPx, + initialValue, + confirmValueChange, + skipHiddenState, + ) + } +} diff --git a/Source/Android/gradle/libs.versions.toml b/Source/Android/gradle/libs.versions.toml index f4811334ac..0f91ab31b7 100644 --- a/Source/Android/gradle/libs.versions.toml +++ b/Source/Android/gradle/libs.versions.toml @@ -4,7 +4,7 @@ appcompat = "1.7.1" benchmarkMacroJunit4 = "1.5.0-alpha04" cardview = "1.0.0" coil = "2.7.0" -compose-bom = "2025.04.00" +compose-bom = "2026.04.01" constraintlayout = "2.2.1" coreKtx = "1.18.0" coreSplashscreen = "1.2.0"