From c716c32ce891b340f5b287f6ae589e43832c427e Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Mon, 6 Apr 2026 13:32:57 +0100 Subject: [PATCH 01/46] 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 f10022f2dc6e5e7d9636d3e91fe1dc3737dc703e Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 15 Apr 2026 17:33:09 +0200 Subject: [PATCH 02/46] 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 a1983e5fa2f66911b7151e0fc8f90a86b7cece52 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 8 Apr 2026 13:33:42 +0100 Subject: [PATCH 03/46] 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 1464fdfc2845817b52c204b719e16975e5182fe5 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 6 May 2026 13:35:34 +0200 Subject: [PATCH 04/46] 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 766374434c56bb8d77a5676870a6779b2ee1799f Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 6 May 2026 13:44:53 +0200 Subject: [PATCH 05/46] 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 3ed91742080194630bac072f4dbc9bdbffc94049 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 8 Apr 2026 15:20:19 +0100 Subject: [PATCH 06/46] 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 2ead4b534b..2e2c295c7d 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 a25d4a1d4a..13471a6cd0 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" @@ -616,6 +617,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 f52ca02ca24f06697564441730f86a03a8f3e7fe Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Thu, 9 Apr 2026 12:40:07 +0100 Subject: [PATCH 07/46] 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 5527e8138e90e03e2d5cd4fc3db78e3c7f0b523c Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 15 Apr 2026 18:40:24 +0200 Subject: [PATCH 08/46] 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 b72f3c1afc6a4d4ba3dfffa19de33a425c55d679 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Thu, 9 Apr 2026 13:43:57 +0100 Subject: [PATCH 09/46] 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 af2fda564954b975115b212221aa13f0308fe406 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Thu, 9 Apr 2026 23:26:58 +0200 Subject: [PATCH 10/46] 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 d9e2725e85eafecd9dac67dfdec8a3825d6933e1 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Fri, 10 Apr 2026 11:30:59 +0200 Subject: [PATCH 11/46] 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 dfa3384281876b2fdc1c4d08ae1f4996033fbcae Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 19 May 2026 08:36:33 +0200 Subject: [PATCH 12/46] 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..c3e4742d5d 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 c32732714ed8c782bfab7c621f41452469621c6f Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 14 Apr 2026 09:57:01 +0200 Subject: [PATCH 13/46] 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 c3e4742d5d..37cab3217c 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 e49451b28e9356e9378bf841ffdefa2ac3aa1b75 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 15 Apr 2026 22:23:37 +0200 Subject: [PATCH 14/46] 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 37cab3217c..528c0d8a0b 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 65e4c6015860c650d6afa844e86ca6f1ecd2c15a Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 15 Apr 2026 22:23:02 +0200 Subject: [PATCH 15/46] 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 528c0d8a0b..73db806337 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 86956b21bea7f6450f180fddf2ffcb2e1077a3b6 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Sun, 19 Apr 2026 20:07:37 +0200 Subject: [PATCH 16/46] 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 38b5c7370c47a6760bfce4cb8f9c45ab5d403dbb Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 22 Apr 2026 18:46:12 +0200 Subject: [PATCH 17/46] 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 b1b133b0361d5047b01d60dd492f0d5207921e42 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Sat, 25 Apr 2026 17:40:40 +0200 Subject: [PATCH 18/46] 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 73db806337..80914e952c 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 b52ffa3e8ba6a12d1321c1f1adc76cf7cdce834e Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Thu, 23 Apr 2026 15:25:07 +0200 Subject: [PATCH 19/46] 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 480968f05e03df0c860e84ac294593db91d74296 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Fri, 24 Apr 2026 13:18:36 +0200 Subject: [PATCH 20/46] 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 8141a746713b87be595c94fe4598eb198395b8da Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Sat, 25 Apr 2026 17:39:41 +0200 Subject: [PATCH 21/46] 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 80914e952c..40fe1fe5ec 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 183d6d778ca8a87dd5ad1ae3513f7fcfd561369d Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Mon, 27 Apr 2026 19:22:31 +0200 Subject: [PATCH 22/46] 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 abd324e98d735d836d8131cef91b58f3278bc6fa Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 12 May 2026 11:20:01 -0700 Subject: [PATCH 23/46] 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, 408 insertions(+), 217 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} (73%) 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 2e2c295c7d..b52b7b4fa4 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 73% 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..f47d9ab198 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 isClosed = false + private set val isLaunching: Boolean get() = bootSessionDataPointer != 0L @@ -93,85 +99,49 @@ object Netplay { val saveTransferProgress = _saveTransferProgress.asStateFlow() suspend fun join(): Boolean = withContext(Dispatchers.IO) { - val scope = createSessionScope() + if (isClosed) throw IllegalStateException("Cannot join a closed session") - // 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 (isClosed) return + isClosed = 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 +150,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 +196,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 +253,6 @@ object Netplay { } @Keep - @JvmStatic fun onSetChunkedProgress(playerId: Int, progress: Long) { val current = _saveTransferProgress.value _saveTransferProgress.value = current?.copy( @@ -273,11 +267,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 13471a6cd0..0d29ad3908 100644 --- a/Source/Android/jni/MainAndroid.cpp +++ b/Source/Android/jni/MainAndroid.cpp @@ -618,10 +618,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(), @@ -629,7 +629,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 117d1d71acc6b85b6c67396bd2222fbee7f0ff05 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 29 Apr 2026 14:31:25 +0200 Subject: [PATCH 24/46] 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 f47d9ab198..fbeba1510c 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() @@ -148,6 +154,7 @@ class NetplaySession( game.map { NetplayMessage.GameChanged(it) }, hostInputAuthorityEnabled.map { NetplayMessage.HostInputAuthorityChanged(it) }, padBuffer.map { NetplayMessage.BufferChanged(it) }, + desyncMessages, ) private fun releaseNativeResources() { @@ -236,6 +243,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 acbf9e155f8e0397e6bfa2f3d00bad0e5f5ecbcf Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Thu, 30 Apr 2026 10:16:20 +0200 Subject: [PATCH 25/46] 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 fbeba1510c..e0531a6477 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) { if (isClosed) throw IllegalStateException("Cannot join a closed session") @@ -282,6 +286,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 40fe1fe5ec..71595ddb43 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 f9baecd5eda524a0227ed4697b6853fb1a446b18 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Sat, 2 May 2026 15:21:44 +0200 Subject: [PATCH 26/46] 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 b63ccacdfa7355989e0ca971b9b06ef1dcfb3bbb Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 6 May 2026 10:43:26 +0200 Subject: [PATCH 27/46] 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 71595ddb43..45bd89efab 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 77f08364db41de05e06d02128c3107c2d45df105 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 12 May 2026 11:20:46 -0700 Subject: [PATCH 28/46] Create NetPlayServer and start game --- .../features/netplay/NetplaySession.kt | 31 +++++++ .../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, 137 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 e0531a6477..1ec663a1a5 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 isClosed = false private set + + val isHosting: Boolean + get() = netPlayServerPointer != 0L val isLaunching: Boolean get() = bootSessionDataPointer != 0L @@ -126,10 +131,24 @@ class NetplaySession( true } + suspend fun host(): Boolean = withContext(Dispatchers.IO) { + if (isClosed) throw IllegalStateException("Cannot host a closed session") + + 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 @@ -174,6 +193,12 @@ class NetplaySession( nativeReleaseClient(currentNetPlayClientPointer) } + val currentNetPlayServerPointer = netPlayServerPointer + if (currentNetPlayServerPointer != 0L) { + netPlayServerPointer = 0 + nativeReleaseServer(currentNetPlayServerPointer) + } + val currentNetPlayUICallbacksPointer = netPlayUICallbacksPointer if (currentNetPlayUICallbacksPointer != 0L) { netPlayUICallbacksPointer = 0 @@ -187,6 +212,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) @@ -195,8 +222,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 68e9fd6cde8168f591881b8d94b3f6ac4ee55d15 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Sat, 2 May 2026 22:57:35 +0200 Subject: [PATCH 29/46] 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 1ec663a1a5..ba8a0a321d 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() @@ -143,7 +146,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 6655672ee3d15cceb9fdb73f4b16df9553fefeb1 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Sun, 3 May 2026 10:05:08 +0200 Subject: [PATCH 30/46] 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 ba8a0a321d..3ec1dea43f 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 @@ -153,6 +154,8 @@ class NetplaySession( fun adjustPadBufferSize(buffer: Int) = nativeAdjustPadBufferSize(buffer) + fun changeGame(gameFile: GameFile) = nativeChangeGame(gameFile) + fun startGame() = nativeStartGame() fun consumeBootSessionData(): Long { @@ -232,6 +235,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 45bd89efab..86cbfe5129 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 eb02680cb6ad8a8b700ccf37608c064fbb24fb8c Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 5 May 2026 19:19:00 +0200 Subject: [PATCH 31/46] 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 86a0f228876a101dfb3eafb08a56138eaa4b7e68 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Sun, 3 May 2026 23:23:07 +0200 Subject: [PATCH 32/46] 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 3ec1dea43f..8af9cf9806 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,10 @@ class NetplaySession( fun startGame() = nativeStartGame() + fun getPort(): Int = nativeGetPort() + + fun getExternalIpAddress(): String? = nativeGetExternalIpAddress() + fun consumeBootSessionData(): Long { return bootSessionDataPointer.also { bootSessionDataPointer = 0 @@ -239,6 +243,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 86cbfe5129..eeba0f1541 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 86de5ef4b0bd210aab15051a0e9a4098c5ecaa2a Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 5 May 2026 18:50:04 +0200 Subject: [PATCH 33/46] 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 8af9cf9806..d8f2ce8860 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt @@ -25,11 +25,12 @@ import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import org.dolphinemu.dolphinemu.features.netplay.model.GameDigestProgress -import org.dolphinemu.dolphinemu.model.GameFile import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage import org.dolphinemu.dolphinemu.features.netplay.model.Player import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress +import org.dolphinemu.dolphinemu.features.netplay.model.TraversalState import org.dolphinemu.dolphinemu.features.settings.model.StringSetting +import org.dolphinemu.dolphinemu.model.GameFile class NetplaySession( private val onClosed: (NetplaySession) -> Unit, @@ -117,6 +118,15 @@ class NetplaySession( private val _gameDigestProgress = MutableStateFlow(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) { if (isClosed) throw IllegalStateException("Cannot join a closed session") @@ -148,7 +158,7 @@ class NetplaySession( } fun sendMessage(message: String) { - _chatMessages.tryEmit( "$nickName: $message") + _chatMessages.tryEmit("$nickName: $message") nativeSendMessage(message) } @@ -162,6 +172,8 @@ class NetplaySession( fun getExternalIpAddress(): String? = nativeGetExternalIpAddress() + fun reconnectTraversal() = nativeReconnectTraversal() + fun consumeBootSessionData(): Long { return bootSessionDataPointer.also { bootSessionDataPointer = 0 @@ -247,6 +259,8 @@ class NetplaySession( private external fun nativeGetExternalIpAddress(): String? + private external fun nativeReconnectTraversal() + // NetPlayUI callbacks @Keep @@ -390,6 +404,26 @@ class NetplaySession( fun onAbortGameDigest() { _gameDigestProgress.value = null } + + @Keep + fun onTraversalStateChanged( + state: Int, + hostCode: String?, + externalAddress: String?, + failureReason: String?, + ) { + val traversalState = when (state) { + 0 -> TraversalState.Connecting + 1 -> TraversalState.Connected(hostCode!!, externalAddress!!) + 2 -> TraversalState.Failure(failureReason!!) + else -> return + } + _traversalState.tryEmit(traversalState) + + if (failureReason == "BadHost" || failureReason == "VersionTooOld") { + _fatalTraversalError.trySend(TraversalState.Failure(failureReason)) + } + } } private fun 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 eeba0f1541..71b427a8c8 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt @@ -97,6 +97,7 @@ import org.dolphinemu.dolphinemu.features.netplay.model.JoinInfoType import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage import org.dolphinemu.dolphinemu.features.netplay.model.Player import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress +import org.dolphinemu.dolphinemu.features.netplay.model.TraversalState import org.dolphinemu.dolphinemu.model.GameFile import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer @@ -112,6 +113,7 @@ fun NetplayScreen( onBackClicked: () -> Unit, isHosting: Boolean, connectionLost: Flow, + 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 1c2806dfe9d6d644a1423ccced319d0ac7dea632 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 5 May 2026 21:50:04 +0200 Subject: [PATCH 34/46] 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 d8f2ce8860..c5795edf63 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 isClosed = false private set - + val isHosting: Boolean get() = netPlayServerPointer != 0L @@ -162,7 +162,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) @@ -241,7 +243,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 71b427a8c8..e68e6ff7d2 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 76ae44569415c0ffb674d184312ea898aab92a18 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Wed, 6 May 2026 09:46:33 +0200 Subject: [PATCH 35/46] 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 c5795edf63..0ba5d409b0 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 @@ -162,6 +162,8 @@ class NetplaySession( nativeSendMessage(message) } + fun setHostInputAuthority(enable: Boolean) = nativeSetHostInputAuthority(enable) + fun adjustClientPadBufferSize(buffer: Int) = nativeAdjustClientPadBufferSize(buffer) fun adjustServerPadBufferSize(buffer: Int) = nativeAdjustServerPadBufferSize(buffer) @@ -243,6 +245,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 e68e6ff7d2..3be5cb0618 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 4c0bf2944bedb428dd46db4cb39ea5dd9d5d8c98 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 12 May 2026 12:58:44 -0700 Subject: [PATCH 36/46] Implement DoAllPlayersHaveGame() check --- .../features/netplay/NetplaySession.kt | 4 +++ .../netplay/model/NetplayViewModel.kt | 13 ++++++++ .../features/netplay/ui/NetplayActivity.kt | 5 ++-- .../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(+), 2 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 0ba5d409b0..f06ec033dc 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 @@ -170,6 +170,8 @@ class NetplaySession( fun changeGame(gameFile: GameFile) = nativeChangeGame(gameFile) + fun doAllPlayersHaveGame(): Boolean = nativeDoAllPlayersHaveGame() + fun startGame() = nativeStartGame() fun getPort(): Int = nativeGetPort() @@ -261,6 +263,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..adea1a13a5 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 _notAllPlayersHaveGame = Channel(Channel.CONFLATED) + val notAllPlayersHaveGame = _notAllPlayersHaveGame.receiveAsFlow() + fun startGame() { + if (netplaySession.doAllPlayersHaveGame()) { + netplaySession.startGame() + } else { + _notAllPlayersHaveGame.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..eb9cecec20 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,7 +9,6 @@ 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 @@ -49,15 +48,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, + notAllPlayersHaveGame = viewModel.notAllPlayersHaveGame, + 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 3be5cb0618..5d94a29463 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, + notAllPlayersHaveGame: Flow, + onConfirmStartGame: () -> Unit, hostInputAuthorityEnabled: Boolean, networkMode: NetworkMode, onNetworkModeChanged: (NetworkMode) -> Unit, @@ -229,6 +231,11 @@ fun NetplayScreen( fatalTraversalError.collect { traversalError = it } } + var showNotAllPlayersHaveGame by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + notAllPlayersHaveGame.collect { showNotAllPlayersHaveGame = true } + } + var dismissSaveTransferProgressDialog by rememberSaveable { mutableStateOf(false) } if (saveTransferProgress == null) { dismissSaveTransferProgressDialog = false @@ -279,6 +286,27 @@ fun NetplayScreen( onDismiss = { dismissGameDigestDialog = true }, ) } + + showNotAllPlayersHaveGame -> { + 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 = { + showNotAllPlayersHaveGame = false + onConfirmStartGame() + }) { + Text(stringResource(R.string.yes)) + } + }, + dismissButton = { + TextButton(onClick = { showNotAllPlayersHaveGame = false }) { + Text(stringResource(R.string.no)) + } + }, + onDismissRequest = { showNotAllPlayersHaveGame = false }, + ) + } } } } @@ -1280,6 +1308,8 @@ private fun PreviewNetplayScreen() { onStartGame = {}, onGameSelected = {}, gameFiles = emptyList(), + notAllPlayersHaveGame = 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 df5e7a9767de04d1114f490b10b44d44a9c1c156 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Thu, 7 May 2026 16:35:18 +0200 Subject: [PATCH 37/46] 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 5d94a29463..8150f0b8e7 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" From 1bc64a0e6e63ba8584e4d919680cedaf06153369 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Sun, 17 May 2026 23:06:17 +0200 Subject: [PATCH 38/46] Fix netplay license headers --- .../dolphinemu/dolphinemu/features/netplay/NetplayManager.kt | 1 - .../dolphinemu/dolphinemu/features/netplay/NetplaySession.kt | 1 - .../dolphinemu/features/netplay/model/ConnectionRole.kt | 2 ++ .../dolphinemu/features/netplay/model/ConnectionType.kt | 2 ++ .../dolphinemu/features/netplay/model/GameDigestProgress.kt | 2 ++ .../dolphinemu/features/netplay/model/NetplayMessage.kt | 2 ++ .../dolphinemu/features/netplay/model/NetworkMode.kt | 2 ++ .../org/dolphinemu/dolphinemu/features/netplay/model/Player.kt | 1 - .../dolphinemu/features/netplay/model/SaveTransferProgress.kt | 2 ++ .../dolphinemu/features/netplay/model/TraversalState.kt | 2 ++ .../dolphinemu/features/netplay/ui/NetplaySetupScreen.kt | 2 ++ Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp | 2 +- Source/Android/jni/NetPlay/NetPlayUICallbacks.h | 3 +++ Source/Android/jni/NetPlay/Netplay.cpp | 2 +- 14 files changed, 21 insertions(+), 5 deletions(-) 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 index eae44e65be..32742c7d93 100644 --- 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 @@ -1,4 +1,3 @@ -// Copyright 2003 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later package org.dolphinemu.dolphinemu.features.netplay 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 f06ec033dc..9e530719d9 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 @@ -1,4 +1,3 @@ -// Copyright 2003 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later package org.dolphinemu.dolphinemu.features.netplay 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 a9f86d5982..d1e0fd71d8 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 @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + package org.dolphinemu.dolphinemu.features.netplay.model import androidx.annotation.StringRes 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 52b4aa10b4..f82018ea39 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 @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + package org.dolphinemu.dolphinemu.features.netplay.model import androidx.annotation.StringRes 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 index 57a9cae78b..923f03068a 100644 --- 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 @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + package org.dolphinemu.dolphinemu.features.netplay.model data class GameDigestProgress( 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 b8291c094b..c408d3be96 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 @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + package org.dolphinemu.dolphinemu.features.netplay.model import android.content.Context 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 index 55d29f5f17..6c5f53f06b 100644 --- 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 @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + package org.dolphinemu.dolphinemu.features.netplay.model import androidx.annotation.StringRes 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 index 70c228d1c9..fecc65c4b5 100644 --- 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 @@ -1,4 +1,3 @@ -// Copyright 2003 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later package org.dolphinemu.dolphinemu.features.netplay.model 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 index e16cbab8ca..8265101415 100644 --- 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 @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + package org.dolphinemu.dolphinemu.features.netplay.model data class SaveTransferProgress( 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 index 9b574e4f63..0c36fdf58a 100644 --- 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 @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + package org.dolphinemu.dolphinemu.features.netplay.model import android.content.Context 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 939a39524d..f7f660638d 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 @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + @file:OptIn(ExperimentalMaterial3Api::class) package org.dolphinemu.dolphinemu.features.netplay.ui diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index ce9336ff15..9f9c406846 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -1,4 +1,4 @@ -// Copyright 2003 Dolphin Emulator Project +// Copyright 2026 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.h b/Source/Android/jni/NetPlay/NetPlayUICallbacks.h index 57eec55230..83ea2c06bc 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.h +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.h @@ -1,3 +1,6 @@ +// Copyright 2026 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + #pragma once #include diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index 736f4e0109..06212e8c1d 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -1,4 +1,4 @@ -// Copyright 2003 Dolphin Emulator Project +// Copyright 2026 Dolphin Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include From 22863b37032e023631257400f9958f1988fdf514 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Mon, 18 May 2026 10:20:14 +0200 Subject: [PATCH 39/46] Add @Keep annotation to Player constructor Since it's only instantiated from c++. --- .../org/dolphinemu/dolphinemu/features/netplay/model/Player.kt | 3 +++ 1 file changed, 3 insertions(+) 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 index fecc65c4b5..31d0d0ccff 100644 --- 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 @@ -2,6 +2,9 @@ package org.dolphinemu.dolphinemu.features.netplay.model +import androidx.annotation.Keep + +@Keep data class Player( val pid: Int, val name: String, From f63b81dd3963b3e25be707c4e1df7fdb24f220ed Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Mon, 18 May 2026 10:25:39 +0200 Subject: [PATCH 40/46] Use unique_ptr when creating netplay client / server --- Source/Android/jni/NetPlay/Netplay.cpp | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index 06212e8c1d..e98dc28742 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -119,17 +119,14 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeJoin(JNIEnv host_port = Config::Get(Config::NETPLAY_CONNECT_PORT); } - auto* client = new NetPlay::NetPlayClient( + auto client = std::make_unique( 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); + return reinterpret_cast(client.release()); } JNIEXPORT jlong JNICALL @@ -147,15 +144,12 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeHost(JNIEnv const u16 host_port = is_traversal ? Config::Get(Config::NETPLAY_LISTEN_PORT) : Config::Get(Config::NETPLAY_HOST_PORT); - auto* server = new NetPlay::NetPlayServer( + auto server = std::make_unique( 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 = @@ -163,7 +157,7 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeHost(JNIEnv server->SetHostInputAuthority(host_input_authority); server->AdjustPadBufferSize(Config::Get(Config::NETPLAY_BUFFER_SIZE)); - return reinterpret_cast(server); + return reinterpret_cast(server.release()); } JNIEXPORT void JNICALL From 06cc09cf09d27de28715a68e58fe8731bc311603 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 19 May 2026 10:26:16 +0200 Subject: [PATCH 41/46] Pass immutable games list to NetplayUICallbacks Remove corresponding jni config. Rename Netplay to NetplaySession in jni config to match new kotlin name. --- .../features/netplay/NetplaySession.kt | 10 ++- Source/Android/jni/AndroidCommon/IDCache.cpp | 79 +++++++------------ Source/Android/jni/AndroidCommon/IDCache.h | 5 +- Source/Android/jni/NetPlay/Netplay.cpp | 23 +++--- 4 files changed, 50 insertions(+), 67 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 9e530719d9..56676c4d3f 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 @@ -30,17 +30,23 @@ 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 +import org.dolphinemu.dolphinemu.services.GameFileCacheManager class NetplaySession( private val onClosed: (NetplaySession) -> Unit, ) { - private var netPlayUICallbacksPointer: Long = nativeCreateUICallbacks() + @Keep + private var netPlayUICallbacksPointer: Long = + nativeCreateUICallbacks(GameFileCacheManager.getGameFiles().value ?: emptyArray()) + @Keep private var netPlayClientPointer: Long = 0 + @Keep private var netPlayServerPointer: Long = 0 + @Keep private var bootSessionDataPointer: Long = 0 private val sessionScope = CoroutineScope(SupervisorJob()) @@ -238,7 +244,7 @@ class NetplaySession( // JNI methods - private external fun nativeCreateUICallbacks(): Long + private external fun nativeCreateUICallbacks(gameFiles: Array): Long private external fun nativeJoin(): Long diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index db05bda5d5..a2c0e14392 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -25,10 +25,7 @@ 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 jclass s_netplay_session_class; static jfieldID s_net_play_ui_callbacks_pointer; static jfieldID s_net_play_client_pointer; static jfieldID s_net_play_server_pointer; @@ -249,19 +246,9 @@ jfieldID GetGameFileCachePointer() return s_game_file_cache_pointer; } -jclass GetGameFileCacheManagerClass() +jclass GetNetplaySessionClass() { - return s_game_file_cache_manager_class; -} - -jfieldID GetGameFileCacheManagerInstance() -{ - return s_game_file_cache_manager_instance; -} - -jclass GetNetplayClass() -{ - return s_netplay_class; + return s_netplay_session_class; } jfieldID GetNetPlayUICallbacksPointer() @@ -774,55 +761,46 @@ 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 = + const jclass netplay_session_class = env->FindClass("org/dolphinemu/dolphinemu/features/netplay/NetplaySession"); - s_netplay_class = reinterpret_cast(env->NewGlobalRef(netplay_class)); + s_netplay_session_class = reinterpret_cast(env->NewGlobalRef(netplay_session_class)); 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"); - s_netplay_on_connection_error = env->GetMethodID(netplay_class, "onConnectionError", "(Ljava/lang/String;)V"); + env->GetFieldID(netplay_session_class, "netPlayUICallbacksPointer", "J"); + s_net_play_client_pointer = env->GetFieldID(netplay_session_class, "netPlayClientPointer", "J"); + s_net_play_server_pointer = env->GetFieldID(netplay_session_class, "netPlayServerPointer", "J"); + s_netplay_on_boot_game = env->GetMethodID(netplay_session_class, "onBootGame", "(Ljava/lang/String;J)V"); + s_netplay_on_stop_game = env->GetMethodID(netplay_session_class, "onStopGame", "()V"); + s_netplay_on_connection_lost = env->GetMethodID(netplay_session_class, "onConnectionLost", "()V"); + s_netplay_on_connection_error = env->GetMethodID(netplay_session_class, "onConnectionError", "(Ljava/lang/String;)V"); s_netplay_on_game_changed = - env->GetMethodID(netplay_class, "onGameChanged", "(Ljava/lang/String;)V"); + env->GetMethodID(netplay_session_class, "onGameChanged", "(Ljava/lang/String;)V"); s_netplay_on_host_input_authority_changed = - env->GetMethodID(netplay_class, "onHostInputAuthorityChanged", "(Z)V"); + env->GetMethodID(netplay_session_class, "onHostInputAuthorityChanged", "(Z)V"); s_netplay_on_pad_buffer_changed = - env->GetMethodID(netplay_class, "onPadBufferChanged", "(I)V"); + env->GetMethodID(netplay_session_class, "onPadBufferChanged", "(I)V"); s_netplay_on_chat_message_received = - 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"); + env->GetMethodID(netplay_session_class, "onChatMessageReceived", "(Ljava/lang/String;)V"); + s_netplay_update = env->GetMethodID(netplay_session_class, "onUpdate", "([Lorg/dolphinemu/dolphinemu/features/netplay/model/Player;)V"); s_netplay_on_show_chunked_progress_dialog = - env->GetMethodID(netplay_class, "onShowChunkedProgressDialog", "(Ljava/lang/String;J[I)V"); + env->GetMethodID(netplay_session_class, "onShowChunkedProgressDialog", "(Ljava/lang/String;J[I)V"); s_netplay_on_set_chunked_progress = - env->GetMethodID(netplay_class, "onSetChunkedProgress", "(IJ)V"); + env->GetMethodID(netplay_session_class, "onSetChunkedProgress", "(IJ)V"); s_netplay_on_hide_chunked_progress_dialog = - env->GetMethodID(netplay_class, "onHideChunkedProgressDialog", "()V"); + env->GetMethodID(netplay_session_class, "onHideChunkedProgressDialog", "()V"); s_netplay_on_desync = - env->GetMethodID(netplay_class, "onDesync", "(ILjava/lang/String;)V"); + env->GetMethodID(netplay_session_class, "onDesync", "(ILjava/lang/String;)V"); s_netplay_on_show_game_digest_dialog = - env->GetMethodID(netplay_class, "onShowGameDigestDialog", "(Ljava/lang/String;)V"); + env->GetMethodID(netplay_session_class, "onShowGameDigestDialog", "(Ljava/lang/String;)V"); s_netplay_on_set_game_digest_progress = - env->GetMethodID(netplay_class, "onSetGameDigestProgress", "(II)V"); + env->GetMethodID(netplay_session_class, "onSetGameDigestProgress", "(II)V"); s_netplay_on_set_game_digest_result = - env->GetMethodID(netplay_class, "onSetGameDigestResult", "(ILjava/lang/String;)V"); + env->GetMethodID(netplay_session_class, "onSetGameDigestResult", "(ILjava/lang/String;)V"); s_netplay_on_abort_game_digest = - env->GetMethodID(netplay_class, "onAbortGameDigest", "()V"); + env->GetMethodID(netplay_session_class, "onAbortGameDigest", "()V"); s_netplay_on_traversal_state_changed = env->GetMethodID( - netplay_class, "onTraversalStateChanged", + netplay_session_class, "onTraversalStateChanged", "(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V"); - env->DeleteLocalRef(netplay_class); + env->DeleteLocalRef(netplay_session_class); const jclass netplay_player_class = env->FindClass("org/dolphinemu/dolphinemu/features/netplay/model/Player"); @@ -1043,8 +1021,7 @@ 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_netplay_session_class); env->DeleteGlobalRef(s_netplay_player_class); env->DeleteGlobalRef(s_analytics_class); env->DeleteGlobalRef(s_pair_class); diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h index 064b37df44..43c4f43585 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -28,10 +28,7 @@ jmethodID GetGameFileConstructor(); jclass GetGameFileCacheClass(); jfieldID GetGameFileCachePointer(); -jclass GetGameFileCacheManagerClass(); -jfieldID GetGameFileCacheManagerInstance(); - -jclass GetNetplayClass(); +jclass GetNetplaySessionClass(); jfieldID GetNetPlayUICallbacksPointer(); jfieldID GetNetPlayClientPointer(); jfieldID GetNetPlayServerPointer(); diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index e98dc28742..20ebffe42c 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -15,7 +15,6 @@ #include "Core/NetPlayClient.h" #include "Core/NetPlayServer.h" #include "UICommon/GameFile.h" -#include "UICommon/GameFileCache.h" #include "jni/AndroidCommon/AndroidCommon.h" #include "jni/AndroidCommon/IDCache.h" @@ -75,17 +74,21 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeAdjustServe } JNIEXPORT jlong JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeCreateUICallbacks(JNIEnv* env, - jobject obj) +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeCreateUICallbacks( + JNIEnv* env, jobject obj, jobjectArray jgame_files) { - 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); }); + const jsize count = env->GetArrayLength(jgame_files); + games.reserve(count); + for (jsize i = 0; i < count; i++) + { + jobject jgame_file = env->GetObjectArrayElement(jgame_files, i); + auto* game_ptr = reinterpret_cast*>( + env->GetLongField(jgame_file, IDCache::GetGameFilePointer())); + if (game_ptr) + games.push_back(*game_ptr); + env->DeleteLocalRef(jgame_file); + } return reinterpret_cast(new NetPlay::NetPlayUICallbacks(obj, std::move(games))); } From 4d1e74997749bca7744c1bbe78225e76f57a260c Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Mon, 18 May 2026 21:31:39 +0200 Subject: [PATCH 42/46] WithSession helper function Handles the repeated boiler plate of using a weak reference to NetplaySession from NetplayUICallbacks --- .../jni/NetPlay/NetPlayUICallbacks.cpp | 321 +++++++----------- .../Android/jni/NetPlay/NetPlayUICallbacks.h | 12 + 2 files changed, 129 insertions(+), 204 deletions(-) diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index 9f9c406846..de119b43c8 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -68,17 +68,12 @@ NetPlayUICallbacks::NetPlayUICallbacks(jobject netplay_session, 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->GetLongField(netplay_session, IDCache::GetNetPlayClientPointer())); - if (client) - client->RequestStopGame(); - - env->DeleteLocalRef(netplay_session); + WithSession([](JNIEnv* env, jobject session) { + auto* client = reinterpret_cast( + env->GetLongField(session, IDCache::GetNetPlayClientPointer())); + if (client) + client->RequestStopGame(); + }); } }); } @@ -99,14 +94,10 @@ void NetPlayUICallbacks::BootGame(const std::string& filename, { 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); + WithSession([&](JNIEnv* env, jobject session) { + env->CallVoidMethod(session, IDCache::GetNetplayOnBootGame(), ToJString(env, filename), + reinterpret_cast(boot_session_data.release())); + }); } void NetPlayUICallbacks::StopGame() @@ -116,13 +107,9 @@ void NetPlayUICallbacks::StopGame() m_got_stop_request = true; - JNIEnv* env = IDCache::GetEnvForThread(); - jobject netplay_session = GetNetplaySessionLocalRef(env); - if (!netplay_session) - return; - - env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnStopGame()); - env->DeleteLocalRef(netplay_session); + WithSession([](JNIEnv* env, jobject session) { + env->CallVoidMethod(session, IDCache::GetNetplayOnStopGame()); + }); } // Only used by Qt UI code, never by the C++ core. On Android, hosting state @@ -131,56 +118,43 @@ bool NetPlayUICallbacks::IsHosting() const { return false; } void NetPlayUICallbacks::Update() { - JNIEnv* env = IDCache::GetEnvForThread(); - jobject netplay_session = GetNetplaySessionLocalRef(env); - if (!netplay_session) - return; + WithSession([](JNIEnv* env, jobject session) { + auto* client = reinterpret_cast( + env->GetLongField(session, IDCache::GetNetPlayClientPointer())); + if (!client) + return; - auto* client = reinterpret_cast( - env->GetLongField(netplay_session, IDCache::GetNetPlayClientPointer())); - if (!client) - { - env->DeleteLocalRef(netplay_session); - return; - } + const std::vector players = client->GetPlayers(); - const std::vector players = client->GetPlayers(); + jobjectArray player_array = env->NewObjectArray( + static_cast(players.size()), IDCache::GetNetplayPlayerClass(), nullptr); - 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); + } - 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->CallVoidMethod(netplay_session, IDCache::GetNetplayUpdate(), player_array); - env->DeleteLocalRef(player_array); - env->DeleteLocalRef(netplay_session); + env->CallVoidMethod(session, IDCache::GetNetplayUpdate(), player_array); + env->DeleteLocalRef(player_array); + }); } void NetPlayUICallbacks::AppendChat(const std::string& message) { - JNIEnv* env = IDCache::GetEnvForThread(); - jobject netplay_session = GetNetplaySessionLocalRef(env); - if (!netplay_session) - return; - - env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnChatMessageReceived(), - ToJString(env, message)); - env->DeleteLocalRef(netplay_session); + WithSession([&](JNIEnv* env, jobject session) { + env->CallVoidMethod(session, IDCache::GetNetplayOnChatMessageReceived(), + ToJString(env, message)); + }); } void NetPlayUICallbacks::OnMsgChangeGame(const NetPlay::SyncIdentifier& sync_identifier, @@ -189,34 +163,25 @@ void NetPlayUICallbacks::OnMsgChangeGame(const NetPlay::SyncIdentifier& sync_ide m_current_game_identifier = sync_identifier; m_current_game_name = netplay_name; - JNIEnv* env = IDCache::GetEnvForThread(); - jobject netplay_session = GetNetplaySessionLocalRef(env); - if (!netplay_session) - return; - - env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnGameChanged(), - ToJString(env, netplay_name)); - env->DeleteLocalRef(netplay_session); + WithSession([&](JNIEnv* env, jobject session) { + env->CallVoidMethod(session, IDCache::GetNetplayOnGameChanged(), + ToJString(env, netplay_name)); + }); } 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->GetLongField(netplay_session, IDCache::GetNetPlayClientPointer())); - if (client) - { - if (const auto game = FindGameFile(m_current_game_identifier)) - client->StartGame(game->GetFilePath()); - } - - env->DeleteLocalRef(netplay_session); + WithSession([this](JNIEnv* env, jobject session) { + auto* client = reinterpret_cast( + env->GetLongField(session, IDCache::GetNetPlayClientPointer())); + if (client) + { + if (const auto game = FindGameFile(m_current_game_identifier)) + client->StartGame(game->GetFilePath()); + } + }); } void NetPlayUICallbacks::OnMsgStopGame() {} @@ -232,61 +197,41 @@ void NetPlayUICallbacks::OnPlayerDisconnect(const std::string&) {} void NetPlayUICallbacks::OnPadBufferChanged(u32 buffer) { - JNIEnv* env = IDCache::GetEnvForThread(); - jobject netplay_session = GetNetplaySessionLocalRef(env); - if (!netplay_session) - return; - - env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnPadBufferChanged(), - static_cast(buffer)); - env->DeleteLocalRef(netplay_session); + WithSession([&](JNIEnv* env, jobject session) { + env->CallVoidMethod(session, IDCache::GetNetplayOnPadBufferChanged(), + static_cast(buffer)); + }); } void NetPlayUICallbacks::OnHostInputAuthorityChanged(bool enabled) { - JNIEnv* env = IDCache::GetEnvForThread(); - jobject netplay_session = GetNetplaySessionLocalRef(env); - if (!netplay_session) - return; - - env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnHostInputAuthorityChanged(), - static_cast(enabled)); - env->DeleteLocalRef(netplay_session); + WithSession([&](JNIEnv* env, jobject session) { + env->CallVoidMethod(session, IDCache::GetNetplayOnHostInputAuthorityChanged(), + static_cast(enabled)); + }); } 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); + WithSession([&](JNIEnv* env, jobject session) { + env->CallVoidMethod(session, IDCache::GetNetplayOnDesync(), + static_cast(frame), ToJString(env, player)); + }); } void NetPlayUICallbacks::OnConnectionLost() { - JNIEnv* env = IDCache::GetEnvForThread(); - jobject netplay_session = GetNetplaySessionLocalRef(env); - if (!netplay_session) - return; - - env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnConnectionLost()); - env->DeleteLocalRef(netplay_session); + WithSession([](JNIEnv* env, jobject session) { + env->CallVoidMethod(session, IDCache::GetNetplayOnConnectionLost()); + }); } void NetPlayUICallbacks::OnConnectionError(const std::string& message) { - JNIEnv* env = IDCache::GetEnvForThread(); - jobject netplay_session = GetNetplaySessionLocalRef(env); - if (!netplay_session) - return; - - env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnConnectionError(), - ToJString(env, message)); - env->DeleteLocalRef(netplay_session); + WithSession([&](JNIEnv* env, jobject session) { + env->CallVoidMethod(session, IDCache::GetNetplayOnConnectionError(), + ToJString(env, message)); + }); } // No-op — all error info is captured by OnTraversalStateChanged which always fires alongside. @@ -294,17 +239,14 @@ void NetPlayUICallbacks::OnTraversalError(Common::TraversalClient::FailureReason void NetPlayUICallbacks::OnTraversalStateChanged(Common::TraversalClient::State state) { - JNIEnv* env = IDCache::GetEnvForThread(); - jobject netplay_session = GetNetplaySessionLocalRef(env); - if (!netplay_session) - return; + WithSession([&](JNIEnv* env, jobject session) { + if (!Common::g_TraversalClient) + return; - jstring host_code = nullptr; - jstring external_address = nullptr; - jstring failure_reason = nullptr; + 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(); @@ -318,17 +260,16 @@ void NetPlayUICallbacks::OnTraversalStateChanged(Common::TraversalClient::State ToJString(env, FailureReasonToString(Common::g_TraversalClient->GetFailureReason())); } - env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnTraversalStateChanged(), + env->CallVoidMethod(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); + if (host_code) + env->DeleteLocalRef(host_code); + if (external_address) + env->DeleteLocalRef(external_address); + if (failure_reason) + env->DeleteLocalRef(failure_reason); + }); } void NetPlayUICallbacks::OnGameStartAborted() {} @@ -365,89 +306,61 @@ std::string NetPlayUICallbacks::FindGBARomPath(const std::array&, std::s 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); + WithSession([&](JNIEnv* env, jobject session) { + env->CallVoidMethod(session, IDCache::GetNetplayOnShowGameDigestDialog(), + ToJString(env, title)); + }); } 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); + WithSession([&](JNIEnv* env, jobject session) { + env->CallVoidMethod(session, IDCache::GetNetplayOnSetGameDigestProgress(), + static_cast(pid), static_cast(progress)); + }); } 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); + WithSession([&](JNIEnv* env, jobject session) { + env->CallVoidMethod(session, IDCache::GetNetplayOnSetGameDigestResult(), + static_cast(pid), ToJString(env, result)); + }); } 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); + WithSession([](JNIEnv* env, jobject session) { + env->CallVoidMethod(session, IDCache::GetNetplayOnAbortGameDigest()); + }); } void NetPlayUICallbacks::ShowChunkedProgressDialog(const std::string& title, u64 data_size, std::span players) { - JNIEnv* env = IDCache::GetEnvForThread(); - jobject netplay_session = GetNetplaySessionLocalRef(env); - if (!netplay_session) - return; + WithSession([&](JNIEnv* env, jobject session) { + jintArray j_players = env->NewIntArray(static_cast(players.size())); + env->SetIntArrayRegion(j_players, 0, static_cast(players.size()), players.data()); - jintArray j_players = env->NewIntArray(static_cast(players.size())); - env->SetIntArrayRegion(j_players, 0, static_cast(players.size()), players.data()); - - env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnShowChunkedProgressDialog(), - ToJString(env, title), static_cast(data_size), j_players); - env->DeleteLocalRef(j_players); - env->DeleteLocalRef(netplay_session); + env->CallVoidMethod(session, IDCache::GetNetplayOnShowChunkedProgressDialog(), + ToJString(env, title), static_cast(data_size), j_players); + env->DeleteLocalRef(j_players); + }); } void NetPlayUICallbacks::HideChunkedProgressDialog() { - JNIEnv* env = IDCache::GetEnvForThread(); - jobject netplay_session = GetNetplaySessionLocalRef(env); - if (!netplay_session) - return; - - env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnHideChunkedProgressDialog()); - env->DeleteLocalRef(netplay_session); + WithSession([](JNIEnv* env, jobject session) { + env->CallVoidMethod(session, IDCache::GetNetplayOnHideChunkedProgressDialog()); + }); } void NetPlayUICallbacks::SetChunkedProgress(int pid, u64 progress) { - JNIEnv* env = IDCache::GetEnvForThread(); - 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); + WithSession([&](JNIEnv* env, jobject session) { + env->CallVoidMethod(session, IDCache::GetNetplayOnSetChunkedProgress(), + static_cast(pid), static_cast(progress)); + }); } void NetPlayUICallbacks::SetHostWiiSyncData(std::vector, std::string) {} diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.h b/Source/Android/jni/NetPlay/NetPlayUICallbacks.h index 83ea2c06bc..602512e3b8 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.h +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.h @@ -13,6 +13,7 @@ #include "Common/HookableEvent.h" #include "Core/NetPlayClient.h" #include "UICommon/GameFile.h" +#include "jni/AndroidCommon/IDCache.h" namespace NetPlay { @@ -67,6 +68,17 @@ public: private: jobject GetNetplaySessionLocalRef(JNIEnv* env) const; + template + void WithSession(F&& callback) const + { + JNIEnv* env = IDCache::GetEnvForThread(); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + callback(env, netplay_session); + env->DeleteLocalRef(netplay_session); + } + jweak m_netplay_session; std::vector> m_games; NetPlay::SyncIdentifier m_current_game_identifier; From 8d565e0331cf198dddc6d26d8308f8f582e80b13 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Mon, 18 May 2026 21:35:13 +0200 Subject: [PATCH 43/46] Use the dual pane netplay screen layout only if the width is sufficient To avoid using dual pane layout on very small devices, ensure that the current width available for display is at least medium (600dp). It also has to be landscape, the dual pane mode looks weird on a tablet in portrait mode since there's not that much content. --- Source/Android/app/build.gradle.kts | 1 + .../dolphinemu/features/netplay/ui/NetplayScreen.kt | 10 +++++++++- Source/Android/gradle/libs.versions.toml | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Source/Android/app/build.gradle.kts b/Source/Android/app/build.gradle.kts index 27f76b22a2..11433e8160 100644 --- a/Source/Android/app/build.gradle.kts +++ b/Source/Android/app/build.gradle.kts @@ -166,6 +166,7 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.material.icons) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material3.adaptive) implementation(libs.androidx.compose.runtime.livedata) implementation(libs.androidx.compose.ui) debugImplementation(libs.androidx.compose.ui.tooling) 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 8150f0b8e7..34958176f7 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 @@ -61,6 +61,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -87,6 +88,7 @@ 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 androidx.window.core.layout.WindowSizeClass import coil.compose.AsyncImage import coil.request.ImageRequest import kotlinx.coroutines.flow.Flow @@ -171,7 +173,13 @@ fun NetplayScreen( mutableStateOf(joinAddresses.keys.firstOrNull() ?: JoinInfoType.EXTERNAL) } - if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) { + val isLandscape = + LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val isWidthAtLeastMedium = currentWindowAdaptiveInfo().windowSizeClass + .isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) + val useWideLayout = isLandscape && isWidthAtLeastMedium + + if (useWideLayout) { LandscapeContent( isHosting = isHosting, messages = messages, diff --git a/Source/Android/gradle/libs.versions.toml b/Source/Android/gradle/libs.versions.toml index 0f91ab31b7..f9c4992ccd 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-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive" } 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" } From fde035dc0c716e86173478fb30751f349a168e1e Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 19 May 2026 08:46:16 +0200 Subject: [PATCH 44/46] Replace hardcoded save progress string --- .../dolphinemu/features/netplay/ui/NetplayScreen.kt | 6 +++++- Source/Android/app/src/main/res/values/strings.xml | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) 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 34958176f7..c4831391a5 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 @@ -1165,7 +1165,11 @@ private fun SaveTransferProgressRow( ) Spacer(modifier = Modifier.width(16.dp)) Text( - text = "${formatMib(playerProgress.progress)}/${formatMib(totalSize)} MiB", + text = stringResource( + R.string.netplay_transfer_progress, + formatMib(playerProgress.progress), + formatMib(totalSize) + ), ) } } diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 58bce8c595..fd72abd12b 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1035,4 +1035,5 @@ It can efficiently compress both junk data and encrypted Wii data. Unknown Share address Retry + %1$s/%2$s MiB From 5b49060a9d8f091cc4d1ebe967e01fb3e0d67515 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 19 May 2026 09:46:22 +0200 Subject: [PATCH 45/46] Fix NetplayScreen edge to edge NetplayScreen was not drawing under the bottom nav bar like other screens. In landscape mode half of the screen scrolls and half is fixed, to make this look natural with edge to edge, provide a fade on the bottom of the scrolling side so things look natural when the screens loads and is resting at 0 scroll offset. --- .../features/netplay/ui/NetplayScreen.kt | 24 +++++++-- .../dolphinemu/ui/theme/DolphinTheme.kt | 49 +++++++++++++++++++ 2 files changed, 68 insertions(+), 5 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 c4831391a5..54ea403eeb 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 @@ -108,6 +108,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.bottomFadeOverlay import org.dolphinemu.dolphinemu.ui.theme.rememberSheetState import org.dolphinemu.dolphinemu.utils.CoilUtils import java.util.Locale @@ -164,7 +165,6 @@ fun NetplayScreen( val modifier = Modifier .fillMaxSize() .consumeWindowInsets(innerPadding) - .padding(innerPadding) // State which must live above the landscape/portrait split. var showChat by rememberSaveable { mutableStateOf(false) } @@ -202,6 +202,7 @@ fun NetplayScreen( joinAddresses = joinAddresses, selectedJoinInfoType = selectedJoinInfoType, onSelectedJoinInfoTypeChanged = { selectedJoinInfoType = it }, + contentPadding = innerPadding, modifier = modifier ) } else { @@ -227,6 +228,7 @@ fun NetplayScreen( joinAddresses = joinAddresses, selectedJoinInfoType = selectedJoinInfoType, onSelectedJoinInfoTypeChanged = { selectedJoinInfoType = it }, + contentPadding = innerPadding, modifier = modifier ) } @@ -344,11 +346,13 @@ private fun PortraitContent( joinAddresses: Map, selectedJoinInfoType: JoinInfoType, onSelectedJoinInfoTypeChanged: (JoinInfoType) -> Unit, + contentPadding: PaddingValues, modifier: Modifier = Modifier, ) { Column( modifier = modifier .verticalScroll(rememberScrollState()) + .padding(contentPadding) ) { Chat( messages = messages, @@ -414,11 +418,11 @@ private fun LandscapeContent( joinAddresses: Map, selectedJoinInfoType: JoinInfoType, onSelectedJoinInfoTypeChanged: (JoinInfoType) -> Unit, + contentPadding: PaddingValues, modifier: Modifier = Modifier, ) { Row( modifier = modifier - .padding(horizontal = DolphinTheme.scaffoldPadding) ) { Chat( messages = messages, @@ -426,16 +430,26 @@ private fun LandscapeContent( showBottomSheet = showChat, onShowBottomSheetChanged = onShowChatChanged, modifier = Modifier + .padding(contentPadding) + .padding( + start = DolphinTheme.scaffoldPadding, + end = DolphinTheme.scaffoldPadding / 2, + ) .weight(1f) .fillMaxHeight() ) - Spacer(modifier = Modifier.width(16.dp)) - + val scrollState = rememberScrollState() Column( modifier = Modifier .weight(1f) - .verticalScroll(rememberScrollState()) + .bottomFadeOverlay(scrollState, contentPadding.calculateBottomPadding()) + .verticalScroll(scrollState) + .padding(contentPadding) + .padding( + start = DolphinTheme.scaffoldPadding / 2, + end = DolphinTheme.scaffoldPadding + ) ) { PlayersAndSettings( game = game, 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 37085b06d9..c142a44d73 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,11 +5,13 @@ package org.dolphinemu.dolphinemu.ui.theme import android.content.Context import androidx.annotation.AttrRes import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.ScrollState 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.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -28,10 +30,15 @@ import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue 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.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -286,3 +293,45 @@ fun rememberSheetState( ) } } + +@Composable +fun Modifier.bottomFadeOverlay( + scrollState: ScrollState, + fadeHeight: Dp, +): Modifier { + val surfaceColor = MaterialTheme.colorScheme.surface + val bottomPaddingPx = with(LocalDensity.current) { + fadeHeight.toPx() + } + val fadeDelayPx = with(LocalDensity.current) { 8.dp.toPx() } + val fadeDurationPx = with(LocalDensity.current) { 48.dp.toPx() } + val scrollProgress by remember { + derivedStateOf { + if (bottomPaddingPx <= 0f) 1f + else ((scrollState.value.coerceAtLeast(0) - fadeDelayPx) / fadeDurationPx) + .coerceIn(0f, 1f) + } + } + val surfaceStopFraction = (scrollProgress * 2f).coerceIn(0f, 1f) + val transparentStopFraction = ((scrollProgress * 2f) - 1f).coerceIn(0f, 1f) + + return drawWithContent { + drawContent() + if (transparentStopFraction < 1f) { + drawRect( + brush = Brush.verticalGradient( + colorStops = arrayOf( + 0f to Color.Transparent, + transparentStopFraction to Color.Transparent, + surfaceStopFraction to surfaceColor, + 1f to surfaceColor, + ), + startY = size.height - bottomPaddingPx, + endY = size.height, + ), + topLeft = Offset(0f, size.height - bottomPaddingPx), + size = Size(size.width, bottomPaddingPx), + ) + } + } +} From 1d1c9696aeee312e003d200fde105ab9da6a409c Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 19 May 2026 09:56:49 +0200 Subject: [PATCH 46/46] lint formatting --- Source/Android/jni/AndroidCommon/IDCache.cpp | 24 ++-- Source/Android/jni/MainAndroid.cpp | 9 +- .../jni/NetPlay/NetPlayUICallbacks.cpp | 108 +++++++++++------- .../Android/jni/NetPlay/NetPlayUICallbacks.h | 6 +- Source/Android/jni/NetPlay/Netplay.cpp | 57 +++++---- .../Core/DolphinQt/NetPlay/NetPlayDialog.cpp | 2 +- 6 files changed, 121 insertions(+), 85 deletions(-) diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index a2c0e14392..58cec02308 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -768,10 +768,12 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) env->GetFieldID(netplay_session_class, "netPlayUICallbacksPointer", "J"); s_net_play_client_pointer = env->GetFieldID(netplay_session_class, "netPlayClientPointer", "J"); s_net_play_server_pointer = env->GetFieldID(netplay_session_class, "netPlayServerPointer", "J"); - s_netplay_on_boot_game = env->GetMethodID(netplay_session_class, "onBootGame", "(Ljava/lang/String;J)V"); + s_netplay_on_boot_game = + env->GetMethodID(netplay_session_class, "onBootGame", "(Ljava/lang/String;J)V"); s_netplay_on_stop_game = env->GetMethodID(netplay_session_class, "onStopGame", "()V"); s_netplay_on_connection_lost = env->GetMethodID(netplay_session_class, "onConnectionLost", "()V"); - s_netplay_on_connection_error = env->GetMethodID(netplay_session_class, "onConnectionError", "(Ljava/lang/String;)V"); + s_netplay_on_connection_error = + env->GetMethodID(netplay_session_class, "onConnectionError", "(Ljava/lang/String;)V"); s_netplay_on_game_changed = env->GetMethodID(netplay_session_class, "onGameChanged", "(Ljava/lang/String;)V"); s_netplay_on_host_input_authority_changed = @@ -780,9 +782,11 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) env->GetMethodID(netplay_session_class, "onPadBufferChanged", "(I)V"); s_netplay_on_chat_message_received = env->GetMethodID(netplay_session_class, "onChatMessageReceived", "(Ljava/lang/String;)V"); - s_netplay_update = env->GetMethodID(netplay_session_class, "onUpdate", "([Lorg/dolphinemu/dolphinemu/features/netplay/model/Player;)V"); - s_netplay_on_show_chunked_progress_dialog = - env->GetMethodID(netplay_session_class, "onShowChunkedProgressDialog", "(Ljava/lang/String;J[I)V"); + s_netplay_update = + env->GetMethodID(netplay_session_class, "onUpdate", + "([Lorg/dolphinemu/dolphinemu/features/netplay/model/Player;)V"); + s_netplay_on_show_chunked_progress_dialog = env->GetMethodID( + netplay_session_class, "onShowChunkedProgressDialog", "(Ljava/lang/String;J[I)V"); s_netplay_on_set_chunked_progress = env->GetMethodID(netplay_session_class, "onSetChunkedProgress", "(IJ)V"); s_netplay_on_hide_chunked_progress_dialog = @@ -797,15 +801,17 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) env->GetMethodID(netplay_session_class, "onSetGameDigestResult", "(ILjava/lang/String;)V"); s_netplay_on_abort_game_digest = env->GetMethodID(netplay_session_class, "onAbortGameDigest", "()V"); - s_netplay_on_traversal_state_changed = env->GetMethodID( - netplay_session_class, "onTraversalStateChanged", - "(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V"); + s_netplay_on_traversal_state_changed = + env->GetMethodID(netplay_session_class, "onTraversalStateChanged", + "(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V"); env->DeleteLocalRef(netplay_session_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"); + 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"); diff --git a/Source/Android/jni/MainAndroid.cpp b/Source/Android/jni/MainAndroid.cpp index 0d29ad3908..bbf65230f2 100644 --- a/Source/Android/jni/MainAndroid.cpp +++ b/Source/Android/jni/MainAndroid.cpp @@ -35,7 +35,6 @@ #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" @@ -50,6 +49,7 @@ #include "Core/PowerPC/PowerPC.h" #include "Core/State.h" #include "Core/System.h" +#include "jni/NetPlay/NetPlayUICallbacks.h" #include "DiscIO/Blob.h" #include "DiscIO/Enums.h" @@ -620,13 +620,14 @@ 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, jlong jBootSessionData) { - auto boot_session_data = std::unique_ptr( - reinterpret_cast(jBootSessionData)); + auto boot_session_data = + std::unique_ptr(reinterpret_cast(jBootSessionData)); 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()); + env->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(), + IDCache::GetFinishEmulationActivity()); return; } 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 de119b43c8..4b61495f7f 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -3,13 +3,13 @@ #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 "NetPlayUICallbacks.h" +#include "UICommon/GameFile.h" +#include "UICommon/UICommon.h" #include "jni/AndroidCommon/AndroidCommon.h" #include "jni/AndroidCommon/IDCache.h" @@ -57,7 +57,8 @@ const char* FailureReasonToString(Common::TraversalClient::FailureReason reason) } } // namespace -namespace NetPlay { +namespace NetPlay +{ NetPlayUICallbacks::NetPlayUICallbacks(jobject netplay_session, std::vector> games) @@ -114,7 +115,10 @@ void NetPlayUICallbacks::StopGame() // 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; } +bool NetPlayUICallbacks::IsHosting() const +{ + return false; +} void NetPlayUICallbacks::Update() { @@ -126,20 +130,20 @@ void NetPlayUICallbacks::Update() const std::vector players = client->GetPlayers(); - jobjectArray player_array = env->NewObjectArray( - static_cast(players.size()), IDCache::GetNetplayPlayerClass(), nullptr); + 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)); + 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); } @@ -164,12 +168,13 @@ void NetPlayUICallbacks::OnMsgChangeGame(const NetPlay::SyncIdentifier& sync_ide m_current_game_name = netplay_name; WithSession([&](JNIEnv* env, jobject session) { - env->CallVoidMethod(session, IDCache::GetNetplayOnGameChanged(), - ToJString(env, netplay_name)); + env->CallVoidMethod(session, IDCache::GetNetplayOnGameChanged(), ToJString(env, netplay_name)); }); } -void NetPlayUICallbacks::OnMsgChangeGBARom(int, const NetPlay::GBAConfig&) {} +void NetPlayUICallbacks::OnMsgChangeGBARom(int, const NetPlay::GBAConfig&) +{ +} void NetPlayUICallbacks::OnMsgStartGame() { @@ -184,7 +189,9 @@ void NetPlayUICallbacks::OnMsgStartGame() }); } -void NetPlayUICallbacks::OnMsgStopGame() {} +void NetPlayUICallbacks::OnMsgStopGame() +{ +} void NetPlayUICallbacks::OnMsgPowerButton() { @@ -192,8 +199,12 @@ void NetPlayUICallbacks::OnMsgPowerButton() UICommon::TriggerSTMPowerEvent(); } -void NetPlayUICallbacks::OnPlayerConnect(const std::string&) {} -void NetPlayUICallbacks::OnPlayerDisconnect(const std::string&) {} +void NetPlayUICallbacks::OnPlayerConnect(const std::string&) +{ +} +void NetPlayUICallbacks::OnPlayerDisconnect(const std::string&) +{ +} void NetPlayUICallbacks::OnPadBufferChanged(u32 buffer) { @@ -214,8 +225,8 @@ void NetPlayUICallbacks::OnHostInputAuthorityChanged(bool enabled) void NetPlayUICallbacks::OnDesync(u32 frame, const std::string& player) { WithSession([&](JNIEnv* env, jobject session) { - env->CallVoidMethod(session, IDCache::GetNetplayOnDesync(), - static_cast(frame), ToJString(env, player)); + env->CallVoidMethod(session, IDCache::GetNetplayOnDesync(), static_cast(frame), + ToJString(env, player)); }); } @@ -229,13 +240,14 @@ void NetPlayUICallbacks::OnConnectionLost() void NetPlayUICallbacks::OnConnectionError(const std::string& message) { WithSession([&](JNIEnv* env, jobject session) { - env->CallVoidMethod(session, IDCache::GetNetplayOnConnectionError(), - ToJString(env, message)); + env->CallVoidMethod(session, IDCache::GetNetplayOnConnectionError(), ToJString(env, message)); }); } // No-op — all error info is captured by OnTraversalStateChanged which always fires alongside. -void NetPlayUICallbacks::OnTraversalError(Common::TraversalClient::FailureReason) {} +void NetPlayUICallbacks::OnTraversalError(Common::TraversalClient::FailureReason) +{ +} void NetPlayUICallbacks::OnTraversalStateChanged(Common::TraversalClient::State state) { @@ -272,12 +284,25 @@ 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; } +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& sync_identifier, @@ -302,7 +327,10 @@ NetPlayUICallbacks::FindGameFile(const NetPlay::SyncIdentifier& sync_identifier, return result; } -std::string NetPlayUICallbacks::FindGBARomPath(const std::array&, std::string_view, int) { return {}; } +std::string NetPlayUICallbacks::FindGBARomPath(const std::array&, std::string_view, int) +{ + return {}; +} void NetPlayUICallbacks::ShowGameDigestDialog(const std::string& title) { @@ -323,8 +351,8 @@ void NetPlayUICallbacks::SetGameDigestProgress(int pid, int progress) void NetPlayUICallbacks::SetGameDigestResult(int pid, const std::string& result) { WithSession([&](JNIEnv* env, jobject session) { - env->CallVoidMethod(session, IDCache::GetNetplayOnSetGameDigestResult(), - static_cast(pid), ToJString(env, result)); + env->CallVoidMethod(session, IDCache::GetNetplayOnSetGameDigestResult(), static_cast(pid), + ToJString(env, result)); }); } @@ -336,7 +364,7 @@ void NetPlayUICallbacks::AbortGameDigest() } void NetPlayUICallbacks::ShowChunkedProgressDialog(const std::string& title, u64 data_size, - std::span players) + std::span players) { WithSession([&](JNIEnv* env, jobject session) { jintArray j_players = env->NewIntArray(static_cast(players.size())); @@ -358,11 +386,13 @@ void NetPlayUICallbacks::HideChunkedProgressDialog() void NetPlayUICallbacks::SetChunkedProgress(int pid, u64 progress) { WithSession([&](JNIEnv* env, jobject session) { - env->CallVoidMethod(session, IDCache::GetNetplayOnSetChunkedProgress(), - static_cast(pid), static_cast(progress)); + env->CallVoidMethod(session, IDCache::GetNetplayOnSetChunkedProgress(), static_cast(pid), + static_cast(progress)); }); } -void NetPlayUICallbacks::SetHostWiiSyncData(std::vector, std::string) {} +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 index 602512e3b8..946948d2db 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.h +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.h @@ -15,9 +15,11 @@ #include "UICommon/GameFile.h" #include "jni/AndroidCommon/IDCache.h" -namespace NetPlay { +namespace NetPlay +{ -class NetPlayUICallbacks : public NetPlay::NetPlayUI { +class NetPlayUICallbacks : public NetPlay::NetPlayUI +{ public: NetPlayUICallbacks(jobject netplay_session, std::vector> games); diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp index 20ebffe42c..d6e24d250b 100644 --- a/Source/Android/jni/NetPlay/Netplay.cpp +++ b/Source/Android/jni/NetPlay/Netplay.cpp @@ -9,10 +9,10 @@ #include "Common/CommonTypes.h" #include "Common/TraversalClient.h" -#include "Core/NetPlayCommon.h" #include "Core/Boot/Boot.h" #include "Core/Config/NetplaySettings.h" #include "Core/NetPlayClient.h" +#include "Core/NetPlayCommon.h" #include "Core/NetPlayServer.h" #include "UICommon/GameFile.h" @@ -22,8 +22,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) @@ -41,8 +41,9 @@ static NetPlay::NetPlayServer* GetServerPointer(JNIEnv* env, jobject obj) extern "C" { JNIEXPORT void JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeSendMessage(JNIEnv* env, jobject obj, - jstring jmessage) +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeSendMessage(JNIEnv* env, + jobject obj, + jstring jmessage) { if (auto* client = GetClientPointer(env, obj)) client->SendChatMessage(GetJString(env, jmessage)); @@ -50,16 +51,15 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeSendMessage JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeSetHostInputAuthority( - JNIEnv* env, jobject obj, jboolean enable) + JNIEnv* env, jobject obj, jboolean enable) { - if (auto* server = GetServerPointer(env, obj)) - server->SetHostInputAuthority(static_cast(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, - jint buffer) +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeAdjustClientPadBufferSize( + JNIEnv* env, jobject obj, jint buffer) { if (auto* client = GetClientPointer(env, obj)) client->AdjustPadBufferSize(static_cast(buffer)); @@ -118,7 +118,7 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeJoin(JNIEnv 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); + Config::Get(Config::NETPLAY_ADDRESS); host_port = Config::Get(Config::NETPLAY_CONNECT_PORT); } @@ -144,19 +144,19 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeHost(JNIEnv 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); + const u16 host_port = is_traversal ? Config::Get(Config::NETPLAY_LISTEN_PORT) : + Config::Get(Config::NETPLAY_HOST_PORT); auto server = std::make_unique( host_port, use_upnp, ui, - NetPlay::NetTraversalConfig{is_traversal, traversal_host, traversal_port, traversal_port_alt}); + NetPlay::NetTraversalConfig{is_traversal, traversal_host, traversal_port, + traversal_port_alt}); if (!server->is_connected) return 0; const std::string network_mode = Config::Get(Config::NETPLAY_NETWORK_MODE); - const bool host_input_authority = - network_mode == "hostinputauthority" || network_mode == "golf"; + const bool host_input_authority = network_mode == "hostinputauthority" || network_mode == "golf"; server->SetHostInputAuthority(host_input_authority); server->AdjustPadBufferSize(Config::Get(Config::NETPLAY_BUFFER_SIZE)); @@ -189,7 +189,7 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeDoAllPlayer JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeStartGame(JNIEnv* env, - jobject obj) + jobject obj) { auto* server = GetServerPointer(env, obj); if (!server) @@ -198,9 +198,8 @@ 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) +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()); @@ -219,38 +218,36 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeGetExternal JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReconnectTraversal(JNIEnv*, - jobject) + jobject) { if (Common::g_TraversalClient) Common::g_TraversalClient->ReconnectToServer(); } JNIEXPORT void JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseUICallbacks(JNIEnv*, - jobject, - jlong pointer) +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseUICallbacks( + JNIEnv*, jobject, jlong pointer) { delete reinterpret_cast(pointer); } JNIEXPORT void JNICALL -Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseBootSessionData(JNIEnv*, - jobject, - jlong pointer) +Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseBootSessionData( + JNIEnv*, jobject, jlong pointer) { delete reinterpret_cast(pointer); } JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseClient(JNIEnv*, jobject, - jlong pointer) + jlong pointer) { delete reinterpret_cast(pointer); } JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseServer(JNIEnv*, jobject, - jlong pointer) + jlong pointer) { delete reinterpret_cast(pointer); } diff --git a/Source/Core/DolphinQt/NetPlay/NetPlayDialog.cpp b/Source/Core/DolphinQt/NetPlay/NetPlayDialog.cpp index 5282fdb9b9..733ba76bd1 100644 --- a/Source/Core/DolphinQt/NetPlay/NetPlayDialog.cpp +++ b/Source/Core/DolphinQt/NetPlay/NetPlayDialog.cpp @@ -30,9 +30,9 @@ #endif #include "Common/Config/Config.h" -#include "Core/NetPlayCommon.h" #include "Common/Logging/Log.h" #include "Common/TraversalClient.h" +#include "Core/NetPlayCommon.h" #include "Core/Boot/Boot.h" #include "Core/Config/GraphicsSettings.h"