Show joining info for local and external IP addresses

Doesn't support traversal yet
This commit is contained in:
Tom Pratt 2026-05-03 23:23:07 +02:00
parent 3e34012148
commit 4a52be0960
9 changed files with 340 additions and 26 deletions

View File

@ -154,6 +154,10 @@ class NetplaySession(
fun startGame() = nativeStartGame()
fun getPort(): Int = nativeGetPort()
fun getExternalIpAddress(): String? = nativeGetExternalIpAddress()
fun consumeBootSessionData(): Long {
return bootSessionDataPointer.also {
bootSessionDataPointer = 0
@ -235,6 +239,10 @@ class NetplaySession(
private external fun nativeStartGame()
private external fun nativeGetPort(): Int
private external fun nativeGetExternalIpAddress(): String?
// NetPlayUI callbacks
@Keep

View File

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

View File

@ -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 <T : ViewModel> create(modelClass: Class<T>): T {
return NetplayViewModel(session) as T
return NetplayViewModel(session, networkHelper) as T
}
}
}

View File

@ -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,
)
}
}

View File

@ -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<Player>,
saveTransferProgress: SaveTransferProgress?,
gameDigestProgress: GameDigestProgress?,
joinAddresses: Map<JoinInfoType, JoinAddress>,
) {
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<JoinInfoType, JoinAddress>,
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<JoinInfoType, JoinAddress>,
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<JoinInfoType, JoinAddress>,
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<JoinInfoType, JoinAddress>,
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<JoinInfoType, JoinAddress>,
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,

View File

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

View File

@ -75,4 +75,9 @@ object NetworkHelper {
0
}
}
@JvmStatic
fun getLocalIpString(): String? {
return getIPv4Link()?.address?.hostAddress
}
}

View File

@ -1016,4 +1016,12 @@ It can efficiently compress both junk data and encrypted Wii data.
<string name="netplay_game_digest_close">Close</string>
<string name="netplay_host_port_label">Port</string>
<string name="netplay_use_upnp">Forward port (UPnP)</string>
<string name="netplay_host_address_label">Join info</string>
<string name="netplay_address_label">Address</string>
<string name="netplay_address_type_external">External IP</string>
<string name="netplay_address_type_local">Local IP</string>
<string name="netplay_address_loading">Loading…</string>
<string name="netplay_address_unknown">Unknown</string>
<string name="netplay_address_share">Share address</string>
<string name="netplay_address_retry">Retry</string>
</resources>

View File

@ -8,6 +8,7 @@
#include <jni.h>
#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<jint>(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,