This commit is contained in:
Tom Pratt 2026-05-08 15:15:48 +02:00 committed by GitHub
commit 08cd85cf8b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 4542 additions and 27 deletions

View File

@ -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
@ -116,6 +118,12 @@ android {
}
}
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xannotation-default-target=param-property")
}
}
dependencies {
baselineProfile(project(":benchmark"))
coreLibraryDesugaring(libs.desugar.jdk.libs)
@ -133,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)
@ -143,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)
@ -150,6 +160,16 @@ 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.runtime.livedata)
implementation(libs.androidx.compose.ui)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.tooling.preview)
}
fun getGitVersion(): String {

View File

@ -133,6 +133,16 @@
android:label="@string/user_data_submenu"
android:theme="@style/Theme.Dolphin.Main" />
<activity
android:name=".features.netplay.ui.NetplaySetupActivity"
android:exported="false"
android:theme="@style/Theme.Dolphin.Main" />
<activity
android:name=".features.netplay.ui.NetplayActivity"
android:exported="false"
android:theme="@style/Theme.Dolphin.Main" />
<activity
android:name=".features.riivolution.ui.RiivolutionBootActivity"
android:exported="false"

View File

@ -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<String>, riivolution: Boolean, bootSessionDataPointer: Long)
@JvmStatic
external fun ChangeDisc(path: String)

View File

@ -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<Unit>? = 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
}
}
}

View File

@ -0,0 +1,439 @@
// Copyright 2003 Dolphin Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.dolphinemu.dolphinemu.features.netplay
import androidx.annotation.Keep
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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.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
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.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.netplay.model.TraversalState
import org.dolphinemu.dolphinemu.features.settings.model.StringSetting
import org.dolphinemu.dolphinemu.model.GameFile
class NetplaySession(
private val onClosed: (NetplaySession) -> Unit,
) {
private var netPlayUICallbacksPointer: Long = nativeCreateUICallbacks()
private var netPlayClientPointer: Long = 0
private var netPlayServerPointer: Long = 0
private var bootSessionDataPointer: Long = 0
private val sessionScope = CoroutineScope(SupervisorJob())
@Volatile
var isClosing = false
private set
val isHosting: Boolean
get() = netPlayServerPointer != 0L
val isLaunching: Boolean
get() = bootSessionDataPointer != 0L
val nickName by lazy { StringSetting.NETPLAY_NICKNAME.string }
private val _launchGame = Channel<String>(Channel.CONFLATED)
val launchGame = _launchGame.receiveAsFlow()
private val _stopGame = Channel<Unit>(Channel.CONFLATED)
val stopGame = _stopGame.receiveAsFlow()
private val _connectionLost = Channel<Unit>(Channel.CONFLATED)
val connectionLost = _connectionLost.receiveAsFlow()
private val _connectionErrors = Channel<String>(Channel.BUFFERED)
val connectionErrors = _connectionErrors.receiveAsFlow()
private val _messages = MutableSharedFlow<List<NetplayMessage>>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val messages = _messages.asSharedFlow()
private val _players = MutableSharedFlow<List<Player>>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val players = _players.asSharedFlow().distinctUntilChanged()
private val _chatMessages = MutableSharedFlow<String>(
extraBufferCapacity = 32,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val chatMessages = _chatMessages.asSharedFlow()
private val _game = MutableSharedFlow<String>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val game = _game.asSharedFlow()
private val _hostInputAuthorityEnabled = MutableSharedFlow<Boolean>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val hostInputAuthorityEnabled = _hostInputAuthorityEnabled.asSharedFlow()
private val _padBuffer = MutableSharedFlow<Int>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val padBuffer = _padBuffer.asSharedFlow()
private val _desyncMessages = MutableSharedFlow<NetplayMessage.Desync>(
extraBufferCapacity = 32,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val desyncMessages = _desyncMessages.asSharedFlow()
private val _saveTransferProgress = MutableStateFlow<SaveTransferProgress?>(null)
val saveTransferProgress = _saveTransferProgress.asStateFlow()
private val _gameDigestProgress = MutableStateFlow<GameDigestProgress?>(null)
val gameDigestProgress = _gameDigestProgress.asStateFlow()
private val _traversalState = MutableSharedFlow<TraversalState>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val traversalState = _traversalState.asSharedFlow()
private val _fatalTraversalError = Channel<TraversalState.Failure>(Channel.CONFLATED)
val fatalTraversalError = _fatalTraversalError.receiveAsFlow()
suspend fun join(): Boolean = withContext(Dispatchers.IO) {
mergeMessages()
.runningFold(emptyList<NetplayMessage>()) { acc, msg -> listOf(msg) + acc }
.onEach { _messages.tryEmit(it) }
.launchIn(sessionScope)
netPlayClientPointer = nativeJoin()
if (netPlayClientPointer == 0L || !isActive) {
closeBlocking()
return@withContext false
}
true
}
suspend fun host(): Boolean = withContext(Dispatchers.IO) {
netPlayServerPointer = nativeHost()
if (netPlayServerPointer == 0L || !isActive) {
closeBlocking()
return@withContext false
}
join()
}
fun sendMessage(message: String) {
_chatMessages.tryEmit("$nickName: $message")
nativeSendMessage(message)
}
fun setHostInputAuthority(enable: Boolean) = nativeSetHostInputAuthority(enable)
fun adjustClientPadBufferSize(buffer: Int) = nativeAdjustClientPadBufferSize(buffer)
fun adjustServerPadBufferSize(buffer: Int) = nativeAdjustServerPadBufferSize(buffer)
fun changeGame(gameFile: GameFile) = nativeChangeGame(gameFile)
fun doAllPlayersHaveGame(): Boolean = nativeDoAllPlayersHaveGame()
fun startGame() = nativeStartGame()
fun getPort(): Int = nativeGetPort()
fun getExternalIpAddress(): String? = nativeGetExternalIpAddress()
fun reconnectTraversal() = nativeReconnectTraversal()
fun consumeBootSessionData(): Long {
return bootSessionDataPointer.also {
bootSessionDataPointer = 0
}
}
suspend fun close() = withContext(Dispatchers.IO) {
closeBlocking()
}
@Synchronized
fun closeBlocking() {
if (isClosing) return
isClosing = true
sessionScope.cancel()
releaseNativeResources()
onClosed(this)
}
protected fun finalize() {
releaseNativeResources()
}
private fun mergeMessages(): Flow<NetplayMessage> = merge(
chatMessages.map { NetplayMessage.Chat(it) },
game.map { NetplayMessage.GameChanged(it) },
hostInputAuthorityEnabled.map { NetplayMessage.HostInputAuthorityChanged(it) },
padBuffer.map { NetplayMessage.BufferChanged(it) },
desyncMessages,
)
private fun releaseNativeResources() {
val currentBootSessionDataPointer = bootSessionDataPointer
if (currentBootSessionDataPointer != 0L) {
bootSessionDataPointer = 0
nativeReleaseBootSessionData(currentBootSessionDataPointer)
}
val currentNetPlayClientPointer = netPlayClientPointer
if (currentNetPlayClientPointer != 0L) {
netPlayClientPointer = 0
nativeReleaseClient(currentNetPlayClientPointer)
}
val currentNetPlayServerPointer = netPlayServerPointer
if (currentNetPlayServerPointer != 0L) {
netPlayServerPointer = 0
nativeReleaseServer(currentNetPlayServerPointer)
}
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 nativeHost(): Long
private external fun nativeSendMessage(message: String)
private external fun nativeSetHostInputAuthority(enable: Boolean)
private external fun nativeAdjustClientPadBufferSize(buffer: Int)
private external fun nativeAdjustServerPadBufferSize(buffer: Int)
private external fun nativeReleaseUICallbacks(pointer: Long)
private external fun nativeReleaseClient(pointer: Long)
private external fun nativeReleaseServer(pointer: Long)
private external fun nativeReleaseBootSessionData(pointer: Long)
private external fun nativeChangeGame(gameFile: GameFile)
private external fun nativeDoAllPlayersHaveGame(): Boolean
private external fun nativeStartGame()
private external fun nativeGetPort(): Int
private external fun nativeGetExternalIpAddress(): String?
private external fun nativeReconnectTraversal()
// NetPlayUI callbacks
@Keep
fun onBootGame(gameFilePath: String, bootSessionDataPointer: Long) {
this.bootSessionDataPointer = bootSessionDataPointer
_stopGame.flush()
_launchGame.trySend(gameFilePath)
}
@Keep
fun onStopGame() {
_stopGame.trySend(Unit)
}
@Keep
fun onConnectionLost() {
_connectionLost.trySend(Unit)
}
@Keep
fun onConnectionError(message: String) {
_connectionErrors.trySend(message)
}
@Keep
fun onUpdate(players: Array<Player>) {
_players.tryEmit(players.toList())
}
@Keep
fun onChatMessageReceived(message: String) {
_chatMessages.tryEmit(message)
}
@Keep
fun onHostInputAuthorityChanged(enabled: Boolean) {
_hostInputAuthorityEnabled.tryEmit(enabled)
}
@Keep
fun onGameChanged(game: String) {
_game.tryEmit(game)
}
@Keep
fun onPadBufferChanged(buffer: Int) {
if (_hostInputAuthorityEnabled.replayCache.firstOrNull() == true) return
_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()
_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
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
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
}
@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 <T> Channel<T>.flush() {
while (this.tryReceive().isSuccess) Unit
}

View File

@ -0,0 +1,24 @@
package org.dolphinemu.dolphinemu.features.netplay.model
import androidx.annotation.StringRes
import org.dolphinemu.dolphinemu.R
sealed class ConnectionRole(
@StringRes val labelId: Int,
@StringRes val loadingLabelId: Int,
) {
object Connect : ConnectionRole(
labelId = R.string.netplay_connection_role_connect,
loadingLabelId = R.string.netplay_connection_role_connect_loading,
)
object Host : ConnectionRole(
labelId = R.string.netplay_connection_role_host,
loadingLabelId = R.string.netplay_connection_role_host_loading,
)
companion object {
val all: List<ConnectionRole>
get() = listOf(Connect, Host)
}
}

View File

@ -0,0 +1,27 @@
package org.dolphinemu.dolphinemu.features.netplay.model
import androidx.annotation.StringRes
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 {
val all: List<ConnectionType>
get() = listOf(DirectConnection, TraversalServer)
fun fromString(value: String): ConnectionType =
all.find { it.configValue == value } ?: throw IllegalArgumentException("Invalid connection type: $value")
}
}

View File

@ -0,0 +1,14 @@
package org.dolphinemu.dolphinemu.features.netplay.model
data class GameDigestProgress(
val title: String,
val playerProgresses: List<PlayerProgress>,
val matches: Boolean?,
) {
data class PlayerProgress(
val playerId: Int,
val name: String,
val progress: Int,
val result: String?,
)
}

View File

@ -0,0 +1,18 @@
// 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) {
ROOM_ID(R.string.netplay_address_type_room_id),
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

@ -0,0 +1,34 @@
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)
}
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)
}
}

View File

@ -0,0 +1,168 @@
// SPDX-License-Identifier: GPL-2.0-or-later
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.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
import org.dolphinemu.dolphinemu.services.GameFileCacheManager
class NetplaySetupViewModel(
private val netplayManager: NetplayManager,
) : ViewModel() {
private val _connectionRole = MutableStateFlow<ConnectionRole>(ConnectionRole.Connect)
val connectionRole = _connectionRole.asStateFlow()
private val _nickname = MutableStateFlow(StringSetting.NETPLAY_NICKNAME.string)
val nickname = _nickname.asStateFlow()
private val _connectionType = MutableStateFlow(
ConnectionType.fromString(StringSetting.NETPLAY_TRAVERSAL_CHOICE.string)
)
val connectionType = _connectionType.asStateFlow()
private val _ipAddress = MutableStateFlow(StringSetting.NETPLAY_ADDRESS.string)
val ipAddress = _ipAddress.asStateFlow()
private val _hostCode = MutableStateFlow(StringSetting.NETPLAY_HOST_CODE.string)
val hostCode = _hostCode.asStateFlow()
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<Unit>(CONFLATED)
val showNetplayScreen = _showNetplayScreen.receiveAsFlow()
private val _connecting = MutableStateFlow(false)
val connecting = _connecting.asStateFlow()
private val _errors = MutableSharedFlow<String>(extraBufferCapacity = 8)
val errors = _errors.asSharedFlow()
init {
GameFileCacheManager.startLoad()
}
fun setConnectionRole(connectionRole: ConnectionRole) {
_connectionRole.value = connectionRole
}
fun setNickname(nickname: String) {
_nickname.value = nickname
StringSetting.NETPLAY_NICKNAME.setString(NativeConfig.LAYER_BASE, nickname)
}
fun setConnectionType(connectionType: ConnectionType) {
_connectionType.value = connectionType
StringSetting.NETPLAY_TRAVERSAL_CHOICE.setString(
NativeConfig.LAYER_BASE, connectionType.configValue
)
}
fun setIpAddress(ipAddress: String) {
if (ipAddress.all { it.isDigit() || it == '.' }) {
_ipAddress.value = ipAddress
StringSetting.NETPLAY_ADDRESS.setString(NativeConfig.LAYER_BASE, ipAddress)
}
}
fun setHostCode(hostCode: String) {
_hostCode.value = 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 {
IntSetting.NETPLAY_CONNECT_PORT.setInt(NativeConfig.LAYER_BASE, it)
}
}
}
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() = connect(host = true)
fun connect(host: Boolean = false) {
if (_connecting.value) return
_connecting.value = true
viewModelScope.launch {
var errorForwarding: Job? = null
try {
GameFileCacheManager.isLoading().asFlow().first { it == false }
val session = netplayManager.createSession()
errorForwarding = session.connectionErrors
.onEach { _errors.emit(it) }
.launchIn(this)
val success = if (host) {
session.host()
} else {
session.join()
}
if (success) {
_showNetplayScreen.trySend(Unit)
}
} finally {
errorForwarding?.cancel()
_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 <T : ViewModel> create(modelClass: Class<T>): T {
return NetplaySetupViewModel(netplayManager) as T
}
}
}

View File

@ -0,0 +1,224 @@
// SPDX-License-Identifier: GPL-2.0-or-later
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.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
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
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
import org.dolphinemu.dolphinemu.utils.NetworkHelper
class NetplayViewModel(
private val netplaySession: NetplaySession,
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(
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())
val messages = netplaySession.messages
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
val game = netplaySession.game
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "")
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()
private val _clientBuffer = MutableStateFlow(IntSetting.NETPLAY_CLIENT_BUFFER_SIZE.int)
val clientBuffer = _clientBuffer.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()
if (isTraversal) {
collectTraversalState()
} else {
fetchExternalIp()
}
}
}
private val _startGameWarning = Channel<Unit>(Channel.CONFLATED)
val startGameWarning = _startGameWarning.receiveAsFlow()
fun startGame() {
if (netplaySession.doAllPlayersHaveGame()) {
netplaySession.startGame()
} else {
_startGameWarning.trySend(Unit)
}
}
fun confirmStartGame() {
netplaySession.startGame()
}
fun sendMessage(message: String) {
val trimmedMessage = message.trim()
if (trimmedMessage.isEmpty()) {
return
}
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)
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) {
StringSetting.NETPLAY_GAME.setString(NativeConfig.LAYER_BASE, gameFile.getGameId())
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 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 }
?: gameFiles.value.firstOrNull()
if (game != null) {
changeGame(game)
}
}
@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 {
netplaySession.close()
}
}
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, networkHelper) as T
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,13 @@
package org.dolphinemu.dolphinemu.features.netplay.model
data class SaveTransferProgress(
val title: String,
val totalSize: Long,
val playerProgresses: List<PlayerProgress>
) {
data class PlayerProgress(
val playerId: Int,
val name: String,
val progress: Long,
)
}

View File

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

View File

@ -0,0 +1,95 @@
// 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.compose.runtime.collectAsState
import androidx.compose.runtime.livedata.observeAsState
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.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 {
override var themeId: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.setTheme(this)
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val session = NetplayManager.activeSession
if (session == null) {
finish()
return
}
val viewModel = ViewModelProvider(this, NetplayViewModel.Factory(session, NetworkHelper))[NetplayViewModel::class.java]
viewModel.launchGame
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.onEach { EmulationActivity.launch(this, it, false) }
.launchIn(lifecycleScope)
setContent {
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,
onStartGame = viewModel::startGame,
onGameSelected = viewModel::changeGame,
gameFiles = viewModel.gameFiles.collectAsState().value,
startGameWarning = viewModel.startGameWarning,
onConfirmStartGame = viewModel::confirmStartGame,
players = viewModel.players.collectAsState().value,
hostInputAuthorityEnabled = viewModel.hostInputAuthority.collectAsState().value,
networkMode = viewModel.networkMode.collectAsState().value,
onNetworkModeChanged = viewModel::setNetworkMode,
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,
)
}
}
}
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))
}
}
}

View File

@ -0,0 +1,87 @@
// 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.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.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
import org.dolphinemu.dolphinemu.utils.ThemeHelper
class NetplaySetupActivity : AppCompatActivity(), ThemeProvider {
override var themeId: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.setTheme(this)
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val viewModel = ViewModelProvider(
this,
NetplaySetupViewModel.Factory(NetplayManager)
)[NetplaySetupViewModel::class.java]
viewModel.showNetplayScreen
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.onEach { NetplayActivity.launch(this) }
.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,
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,
hostPort = viewModel.hostPort.collectAsState().value,
onHostPortChanged = viewModel::setHostPort,
useUpnp = viewModel.useUpnp.collectAsState().value,
onUseUpnpChanged = viewModel::setUseUpnp,
onHostClicked = viewModel::host,
onConnectClicked = viewModel::connect,
)
}
}
}
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, NetplaySetupActivity::class.java))
}
}
}

View File

@ -0,0 +1,413 @@
@file:OptIn(ExperimentalMaterial3Api::class)
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
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.Checkbox
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.OutlinedButton
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.Alignment
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<Unit>()
}
@Composable
fun NetplaySetupScreen(
onBackClicked: () -> Unit,
connecting: Boolean,
errors: Flow<String>,
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,
hostPort: String,
onHostPortChanged: (String) -> Unit,
useUpnp: Boolean,
onUseUpnpChanged: (Boolean) -> Unit,
onHostClicked: () -> 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 = when(connectionRole) {
ConnectionRole.Host -> onHostClicked
ConnectionRole.Connect -> 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<ErrorDialogState?>(null) }
LaunchedEffect(Unit) {
errors.collect { message ->
activeErrorDialog = ErrorDialogState(message)
activeErrorDialog?.onDismissed?.await()
activeErrorDialog = null
}
}
activeErrorDialog?.let { activeErrorDialog ->
AlertDialog(
text = { Text(activeErrorDialog.message) },
confirmButton = {
TextButton(onClick = { activeErrorDialog.onDismissed.complete(Unit) }) {
Text("Dismiss")
}
},
onDismissRequest = { activeErrorDialog.onDismissed.complete(Unit) },
)
}
Column(
modifier = Modifier
.fillMaxSize()
.consumeWindowInsets(innerPadding)
.verticalScroll(rememberScrollState())
.padding(innerPadding)
.padding(bottom = DolphinTheme.fabClearancePadding)
) {
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,
hostPort = hostPort,
onHostPortChanged = onHostPortChanged,
useUpnp = useUpnp,
onUseUpnpChanged = onUseUpnpChanged,
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,
hostPort: String,
onHostPortChanged: (String) -> Unit,
useUpnp: Boolean,
onUseUpnpChanged: (Boolean) -> 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 -> HostMenu(
connectionType = connectionType,
hostPort = hostPort,
onHostPortChanged = onHostPortChanged,
useUpnp = useUpnp,
onUseUpnpChanged = onUseUpnpChanged,
)
}
}
@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()
)
}
}
@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() {
MaterialTheme {
NetplaySetupScreen(
onBackClicked = {},
connecting = false,
errors = emptyFlow(),
connectionRole = ConnectionRole.Host,
onConnectionRoleChanged = {},
nickname = "Preview nickname",
onNicknameChanged = {},
connectionType = ConnectionType.DirectConnection,
onConnectionTypeChanged = {},
ipAddress = "127.0.0.1",
onIpAddressChanged = {},
connectPort = "2626",
onConnectPortChanged = {},
hostCode = "",
onHostCodeChanged = {},
hostPort = "2626",
onHostPortChanged = {},
useUpnp = false,
onUseUpnpChanged = {},
onHostClicked = {},
onConnectClicked = {},
)
}
}

View File

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

View File

@ -138,7 +138,21 @@ 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_HOST_PORT(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "HostPort", 2626),
NETPLAY_CLIENT_BUFFER_SIZE(
Settings.FILE_DOLPHIN,
Settings.SECTION_INI_NETPLAY,
"BufferSizeClient",
1
),
NETPLAY_BUFFER_SIZE(
Settings.FILE_DOLPHIN,
Settings.SECTION_INI_NETPLAY,
"BufferSize",
5
);
override val isOverridden: Boolean
get() = NativeConfig.isOverridden(file, section, key)

View File

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

View File

@ -101,7 +101,23 @@ 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"),
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)

View File

@ -9,10 +9,17 @@ 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
import org.dolphinemu.dolphinemu.databinding.FragmentEmulationBinding
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
@ -204,7 +211,11 @@ 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.
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"
@ -214,6 +225,24 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
if (launchSystemMenu) {
Log.debug("[EmulationFragment] Starting emulation thread for the Wii Menu.")
NativeLibrary.RunSystemMenu()
} 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 {
netplaySession.stopGame.first()
stopEmulation()
}
netplaySession
.desyncMessages
.onEach { Toast.makeText(requireContext(), it.message(requireContext()), Toast.LENGTH_SHORT).show() }
.launchIn(lifecycleScope)
NativeLibrary.RunNetPlay(
paths,
riivolution,
netplaySession.consumeBootSessionData()
)
} else {
Log.debug("[EmulationFragment] Starting emulation thread.")
val paths = requireNotNull(gamePaths) {

View File

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

View File

@ -0,0 +1,288 @@
// 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.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.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
import com.google.android.material.R as MaterialR
object DolphinTheme {
val scaffoldPadding = 16.dp
val fabClearancePadding = 80.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
)
}
@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))
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),
)
}
}
@Composable
fun MenuSpacer() = Spacer(modifier = Modifier.height(16.dp))
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OutlinedBox(
label: @Composable () -> Unit,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
fadeContentTop: Boolean = false,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.padding(top = 8.dp)
) {
val interactionSource = remember { MutableInteractionSource() }
OutlinedTextFieldDefaults.DecorationBox(
value = "chatText",
innerTextField = {
Box(
modifier = Modifier
.fillMaxSize()
) {
content()
if (fadeContentTop) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(16.dp)
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.surface,
Color.Transparent
)
)
)
)
}
}
},
enabled = true,
singleLine = false,
contentPadding = if (fadeContentTop) {
OutlinedTextFieldDefaults.contentPadding(top = 0.dp)
} else {
OutlinedTextFieldDefaults.contentPadding()
},
visualTransformation = VisualTransformation.None,
interactionSource = interactionSource,
label = { label() },
container = {
OutlinedTextFieldDefaults.Container(
enabled = true,
isError = false,
interactionSource = interactionSource,
colors = OutlinedTextFieldDefaults.colors(),
)
}
)
if (onClick != null) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(MaterialTheme.shapes.extraSmall)
.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current,
onClick = onClick,
)
)
}
}
}
@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)
)
}
}
}
// 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,
)
}
}

View File

@ -66,7 +66,7 @@ class GameCoverKeyer : Keyer<GameFile> {
}
object CoilUtils {
private val imageLoader = ImageLoader.Builder(DolphinApplication.getAppContext())
val imageLoader = ImageLoader.Builder(DolphinApplication.getAppContext())
.components {
add(GameCoverKeyer())
add(GameCoverFetcher.Factory())

View File

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

View File

@ -51,6 +51,11 @@
android:title="@string/grid_menu_online_system_update"
app:showAsAction="never"/>
<item
android:id="@+id/menu_netplay"
android:title="@string/grid_menu_netplay"
app:showAsAction="never"/>
<item
android:id="@+id/menu_about"
android:title="@string/grid_menu_about"

View File

@ -495,6 +495,7 @@
<string name="grid_menu_import_wii_save">Import Wii Save</string>
<string name="grid_menu_import_nand_backup">Import BootMii NAND Backup</string>
<string name="grid_menu_online_system_update">Perform Online System Update</string>
<string name="grid_menu_netplay">Netplay</string>
<string name="grid_menu_load_wii_system_menu">Load Wii System Menu</string>
<string name="grid_menu_load_wii_system_menu_installed">Load Wii System Menu (%s)</string>
<string name="grid_menu_load_vwii_system_menu_installed">Load vWii System Menu (%s)</string>
@ -980,4 +981,58 @@ It can efficiently compress both junk data and encrypted Wii data.
<string name="achievements_logout">Log Out</string>
<string name="achievements_login_in_progress">Logging In</string>
<string name="achievements_login_failed">Login Failed</string>
<!-- Netplay -->
<string name="netplay_setup_title">Netplay Setup</string>
<string name="netplay_connection_type">Connection type</string>
<string name="netplay_connection_type_direct_connection">Direct connection</string>
<string name="netplay_connection_type_traversal_server">Traversal server</string>
<string name="netplay_connection_role_connect">Connect</string>
<string name="netplay_connection_role_connect_loading">Connecting…</string>
<string name="netplay_connection_role_host">Host</string>
<string name="netplay_connection_role_host_loading">Starting…</string>
<string name="netplay_nickname_label">Nickname</string>
<string name="netplay_ip_address_label">IP address</string>
<string name="netplay_host_code_label">Host code</string>
<string name="netplay_port_label">Port</string>
<string name="netplay_title">Netplay</string>
<string name="netplay_start">Start</string>
<string name="netplay_start_warning_title">Warning</string>
<string name="netplay_start_warning_not_all_players_have_game">Not all players have the game. Do you really want to start?</string>
<string name="netplay_chat_label">Chat</string>
<string name="netplay_chat_send">Send</string>
<string name="netplay_message_game_changed">Game changed to %1$s</string>
<string name="netplay_message_buffer_changed">Buffer size changed to %1$d</string>
<string name="netplay_message_host_input_authority_changed">"Host input authority %1$s"</string>
<string name="netplay_message_desync">Possible desync detected: %1$s might have desynced at frame %2$d</string>
<string name="netplay_game_label">Game</string>
<string name="netplay_players_label">Players</string>
<string name="netplay_players_name">Name</string>
<string name="netplay_players_ping">Ping</string>
<string name="netplay_players_mapping">Mapping</string>
<string name="netplay_network_mode_label">Input mode</string>
<string name="netplay_network_mode_fair_input_delay">Fair Input Delay</string>
<string name="netplay_network_mode_host_input_authority">Host Input Authority</string>
<string name="netplay_network_mode_golf">Golf Mode</string>
<string name="netplay_buffer">Buffer</string>
<string name="netplay_client_buffer">Max buffer</string>
<string name="netplay_connection_lost">Netplay connection lost</string>
<string name="netplay_save_transfer_progress_close">Close</string>
<string name="netplay_game_digest_match">The hashes match</string>
<string name="netplay_game_digest_mismatch">The hashes do not match</string>
<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_code_label">Code</string>
<string name="netplay_address_type_room_id">Room</string>
<string name="netplay_address_type_external">External</string>
<string name="netplay_address_type_local">Local</string>
<string name="netplay_traversal_error_bad_host">Couldn\'t look up central server</string>
<string name="netplay_traversal_error_version_too_old">Dolphin is too old for traversal server</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

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

View File

@ -4,6 +4,7 @@ appcompat = "1.7.1"
benchmarkMacroJunit4 = "1.5.0-alpha04"
cardview = "1.0.0"
coil = "2.7.0"
compose-bom = "2026.04.01"
constraintlayout = "2.2.1"
coreKtx = "1.18.0"
coreSplashscreen = "1.2.0"
@ -27,9 +28,17 @@ 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-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" }
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" }
@ -37,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" }
@ -47,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" }
@ -58,4 +69,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" }

View File

@ -25,6 +25,35 @@ 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_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;
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 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 jmethodID s_netplay_on_traversal_state_changed;
static jclass s_netplay_player_class;
static jmethodID s_netplay_player_constructor;
static jclass s_analytics_class;
static jmethodID s_get_analytics_value;
@ -220,6 +249,136 @@ 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 GetNetPlayUICallbacksPointer()
{
return s_net_play_ui_callbacks_pointer;
}
jfieldID GetNetPlayClientPointer()
{
return s_net_play_client_pointer;
}
jfieldID GetNetPlayServerPointer()
{
return s_net_play_server_pointer;
}
jmethodID GetNetplayOnBootGame()
{
return s_netplay_on_boot_game;
}
jmethodID GetNetplayOnStopGame()
{
return s_netplay_on_stop_game;
}
jmethodID GetNetplayOnConnectionLost()
{
return s_netplay_on_connection_lost;
}
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;
}
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;
}
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;
}
jmethodID GetNetplayOnTraversalStateChanged()
{
return s_netplay_on_traversal_state_changed;
}
jclass GetNetplayPlayerClass()
{
return s_netplay_player_class;
}
jmethodID GetNetplayPlayerConstructor()
{
return s_netplay_player_constructor;
}
jclass GetPairClass()
{
return s_pair_class;
@ -615,6 +774,62 @@ 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<jclass>(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/NetplaySession");
s_netplay_class = reinterpret_cast<jclass>(env->NewGlobalRef(netplay_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");
s_netplay_on_game_changed =
env->GetMethodID(netplay_class, "onGameChanged", "(Ljava/lang/String;)V");
s_netplay_on_host_input_authority_changed =
env->GetMethodID(netplay_class, "onHostInputAuthorityChanged", "(Z)V");
s_netplay_on_pad_buffer_changed =
env->GetMethodID(netplay_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");
s_netplay_on_show_chunked_progress_dialog =
env->GetMethodID(netplay_class, "onShowChunkedProgressDialog", "(Ljava/lang/String;J[I)V");
s_netplay_on_set_chunked_progress =
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");
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");
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 =
env->FindClass("org/dolphinemu/dolphinemu/features/netplay/model/Player");
s_netplay_player_class = reinterpret_cast<jclass>(env->NewGlobalRef(netplay_player_class));
s_netplay_player_constructor = env->GetMethodID(netplay_player_class, "<init>", "(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<jclass>(env->NewGlobalRef(analytics_class));
s_get_analytics_value = env->GetStaticMethodID(s_analytics_class, "getValue",
@ -828,6 +1043,9 @@ 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_player_class);
env->DeleteGlobalRef(s_analytics_class);
env->DeleteGlobalRef(s_pair_class);
env->DeleteGlobalRef(s_hash_map_class);

View File

@ -28,6 +28,35 @@ jmethodID GetGameFileConstructor();
jclass GetGameFileCacheClass();
jfieldID GetGameFileCachePointer();
jclass GetGameFileCacheManagerClass();
jfieldID GetGameFileCacheManagerInstance();
jclass GetNetplayClass();
jfieldID GetNetPlayUICallbacksPointer();
jfieldID GetNetPlayClientPointer();
jfieldID GetNetPlayServerPointer();
jmethodID GetNetplayOnBootGame();
jmethodID GetNetplayOnStopGame();
jmethodID GetNetplayOnConnectionLost();
jmethodID GetNetplayOnConnectionError();
jmethodID GetNetplayOnGameChanged();
jmethodID GetNetplayOnHostInputAuthorityChanged();
jmethodID GetNetplayOnPadBufferChanged();
jmethodID GetNetplayOnChatMessageReceived();
jmethodID GetNetplayUpdate();
jmethodID GetNetplayOnShowChunkedProgressDialog();
jmethodID GetNetplayOnSetChunkedProgress();
jmethodID GetNetplayOnHideChunkedProgressDialog();
jmethodID GetNetplayOnDesync();
jmethodID GetNetplayOnShowGameDigestDialog();
jmethodID GetNetplayOnSetGameDigestProgress();
jmethodID GetNetplayOnSetGameDigestResult();
jmethodID GetNetplayOnAbortGameDigest();
jmethodID GetNetplayOnTraversalStateChanged();
jclass GetNetplayPlayerClass();
jmethodID GetNetplayPlayerConstructor();
jclass GetPairClass();
jmethodID GetPairConstructor();

View File

@ -30,6 +30,9 @@ add_library(main SHARED
Input/MappingCommon.cpp
Input/NumericSetting.cpp
Input/NumericSetting.h
NetPlay/Netplay.cpp
NetPlay/NetPlayUICallbacks.cpp
NetPlay/NetPlayUICallbacks.h
MainAndroid.cpp
RiivolutionPatches.cpp
SkylanderConfig.cpp

View File

@ -35,6 +35,7 @@
#include "Core/AchievementManager.h"
#include "Core/Boot/Boot.h"
#include "jni/NetPlay/NetPlayUICallbacks.h"
#include "Core/BootManager.h"
#include "Core/CommonTitles.h"
#include "Core/ConfigLoaders/GameConfigLoader.h"
@ -614,6 +615,21 @@ 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, jlong jBootSessionData)
{
auto boot_session_data = std::unique_ptr<BootSessionData>(
reinterpret_cast<BootSessionData*>(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());
return;
}
Run(env, JStringArrayToVector(env, jPaths), jRiivolution, std::move(*boot_session_data));
}
JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_NativeLibrary_RunSystemMenu(JNIEnv* env,
jclass)
{

View File

@ -0,0 +1,455 @@
// Copyright 2003 Dolphin Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include <android/log.h>
#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<const u8*>(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,
std::vector<std::shared_ptr<const UICommon::GameFile>> 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<NetPlay::NetPlayClient*>(
env->GetLongField(netplay_session, IDCache::GetNetPlayClientPointer()));
if (client)
client->RequestStopGame();
env->DeleteLocalRef(netplay_session);
}
});
}
NetPlayUICallbacks::~NetPlayUICallbacks()
{
JNIEnv* env = IDCache::GetEnvForThread();
env->DeleteWeakGlobalRef(m_netplay_session);
}
jobject NetPlayUICallbacks::GetNetplaySessionLocalRef(JNIEnv* env) const
{
return env->NewLocalRef(m_netplay_session);
}
void NetPlayUICallbacks::BootGame(const std::string& filename,
std::unique_ptr<BootSessionData> 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<jlong>(boot_session_data.release()));
env->DeleteLocalRef(netplay_session);
}
void NetPlayUICallbacks::StopGame()
{
if (m_got_stop_request)
return;
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);
}
// 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()
{
JNIEnv* env = IDCache::GetEnvForThread();
jobject netplay_session = GetNetplaySessionLocalRef(env);
if (!netplay_session)
return;
auto* client = reinterpret_cast<NetPlay::NetPlayClient*>(
env->GetLongField(netplay_session, IDCache::GetNetPlayClientPointer()));
if (!client)
{
env->DeleteLocalRef(netplay_session);
return;
}
const std::vector<const NetPlay::Player*> players = client->GetPlayers();
jobjectArray player_array =
env->NewObjectArray(static_cast<jsize>(players.size()), IDCache::GetNetplayPlayerClass(), nullptr);
for (jsize i = 0; i < static_cast<jsize>(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<jint>(player->pid),
ToJString(env, player->name),
ToJString(env, player->revision),
static_cast<jint>(player->ping),
static_cast<jboolean>(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);
}
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);
}
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();
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&) {}
void NetPlayUICallbacks::OnMsgStartGame()
{
JNIEnv* env = IDCache::GetEnvForThread();
jobject netplay_session = GetNetplaySessionLocalRef(env);
if (!netplay_session)
return;
auto* client = reinterpret_cast<NetPlay::NetPlayClient*>(
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() {}
void NetPlayUICallbacks::OnMsgPowerButton()
{
if (Core::IsRunning(Core::System::GetInstance()))
UICommon::TriggerSTMPowerEvent();
}
void NetPlayUICallbacks::OnPlayerConnect(const std::string&) {}
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<jint>(buffer));
env->DeleteLocalRef(netplay_session);
}
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<jboolean>(enabled));
env->DeleteLocalRef(netplay_session);
}
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<jint>(frame), ToJString(env, player));
env->DeleteLocalRef(netplay_session);
}
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);
}
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);
}
// 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 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<jint>(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) {}
void NetPlayUICallbacks::OnIndexAdded(bool, std::string) {}
void NetPlayUICallbacks::OnIndexRefreshFailed(std::string) {}
bool NetPlayUICallbacks::IsRecording() { return false; }
std::shared_ptr<const UICommon::GameFile>
NetPlayUICallbacks::FindGameFile(const NetPlay::SyncIdentifier& sync_identifier,
NetPlay::SyncIdentifierComparison* found)
{
NetPlay::SyncIdentifierComparison temp;
if (!found)
found = &temp;
*found = NetPlay::SyncIdentifierComparison::DifferentGame;
std::shared_ptr<const UICommon::GameFile> 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<u8, 20>&, std::string_view, int) { return {}; }
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<jint>(pid), static_cast<jint>(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<jint>(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<const int> players)
{
JNIEnv* env = IDCache::GetEnvForThread();
jobject netplay_session = GetNetplaySessionLocalRef(env);
if (!netplay_session)
return;
jintArray j_players = env->NewIntArray(static_cast<jsize>(players.size()));
env->SetIntArrayRegion(j_players, 0, static_cast<jsize>(players.size()), players.data());
env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnShowChunkedProgressDialog(),
ToJString(env, title), static_cast<jlong>(data_size), j_players);
env->DeleteLocalRef(j_players);
env->DeleteLocalRef(netplay_session);
}
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);
}
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<jint>(pid), static_cast<jlong>(progress));
env->DeleteLocalRef(netplay_session);
}
void NetPlayUICallbacks::SetHostWiiSyncData(std::vector<u64>, std::string) {}
} // namespace NetPlay

View File

@ -0,0 +1,75 @@
#pragma once
#include <memory>
#include <span>
#include <string>
#include <vector>
#include <jni.h>
#include "Common/HookableEvent.h"
#include "Core/NetPlayClient.h"
#include "UICommon/GameFile.h"
namespace NetPlay {
class NetPlayUICallbacks : public NetPlay::NetPlayUI {
public:
NetPlayUICallbacks(jobject netplay_session,
std::vector<std::shared_ptr<const UICommon::GameFile>> games);
~NetPlayUICallbacks() override;
void BootGame(const std::string& filename,
std::unique_ptr<BootSessionData> 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<const UICommon::GameFile>
FindGameFile(const NetPlay::SyncIdentifier& sync_identifier,
NetPlay::SyncIdentifierComparison* found = nullptr) override;
std::string FindGBARomPath(const std::array<u8, 20>& 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<const int> players) override;
void HideChunkedProgressDialog() override;
void SetChunkedProgress(int pid, u64 progress) override;
void SetHostWiiSyncData(std::vector<u64> titles, std::string redirect_folder) override;
private:
jobject GetNetplaySessionLocalRef(JNIEnv* env) const;
jweak m_netplay_session;
std::vector<std::shared_ptr<const UICommon::GameFile>> 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

View File

@ -0,0 +1,261 @@
// Copyright 2003 Dolphin Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include <memory>
#include <string>
#include <vector>
#include <jni.h>
#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/NetPlayServer.h"
#include "UICommon/GameFile.h"
#include "UICommon/GameFileCache.h"
#include "jni/AndroidCommon/AndroidCommon.h"
#include "jni/AndroidCommon/IDCache.h"
#include "jni/NetPlay/NetPlayUICallbacks.h"
static NetPlay::NetPlayUICallbacks* GetUICallbacksPointer(JNIEnv* env, jobject obj)
{
return reinterpret_cast<NetPlay::NetPlayUICallbacks*>(
env->GetLongField(obj, IDCache::GetNetPlayUICallbacksPointer()));
}
static NetPlay::NetPlayClient* GetClientPointer(JNIEnv* env, jobject obj)
{
return reinterpret_cast<NetPlay::NetPlayClient*>(
env->GetLongField(obj, IDCache::GetNetPlayClientPointer()));
}
static NetPlay::NetPlayServer* GetServerPointer(JNIEnv* env, jobject obj)
{
return reinterpret_cast<NetPlay::NetPlayServer*>(
env->GetLongField(obj, IDCache::GetNetPlayServerPointer()));
}
extern "C" {
JNIEXPORT void JNICALL
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));
}
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<bool>(enable));
}
JNIEXPORT void JNICALL
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeAdjustClientPadBufferSize(JNIEnv* env,
jobject obj,
jint buffer)
{
if (auto* client = GetClientPointer(env, obj))
client->AdjustPadBufferSize(static_cast<u32>(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<u32>(buffer));
}
JNIEXPORT jlong JNICALL
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeCreateUICallbacks(JNIEnv* env,
jobject obj)
{
jobject jgame_file_cache = env->GetStaticObjectField(
IDCache::GetGameFileCacheManagerClass(), IDCache::GetGameFileCacheManagerInstance());
auto* game_file_cache = reinterpret_cast<UICommon::GameFileCache*>(
env->GetLongField(jgame_file_cache, IDCache::GetGameFileCachePointer()));
std::vector<std::shared_ptr<const UICommon::GameFile>> games;
game_file_cache->ForEach(
[&games](const std::shared_ptr<const UICommon::GameFile>& game) { games.push_back(game); });
return reinterpret_cast<jlong>(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);
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,
NetPlay::NetTraversalConfig{is_traversal, traversal_host, traversal_port});
if (!client->IsConnected())
{
delete client;
return 0;
}
return reinterpret_cast<jlong>(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<jlong>(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<std::shared_ptr<const UICommon::GameFile>*>(
env->GetLongField(jgame_file, IDCache::GetGameFilePointer()));
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<jboolean>(client->DoAllPlayersHaveGame());
return JNI_TRUE;
}
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 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_nativeReconnectTraversal(JNIEnv*,
jobject)
{
if (Common::g_TraversalClient)
Common::g_TraversalClient->ReconnectToServer();
}
JNIEXPORT void JNICALL
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseUICallbacks(JNIEnv*,
jobject,
jlong pointer)
{
delete reinterpret_cast<NetPlay::NetPlayUICallbacks*>(pointer);
}
JNIEXPORT void JNICALL
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseBootSessionData(JNIEnv*,
jobject,
jlong pointer)
{
delete reinterpret_cast<BootSessionData*>(pointer);
}
JNIEXPORT void JNICALL
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseClient(JNIEnv*, jobject,
jlong pointer)
{
delete reinterpret_cast<NetPlay::NetPlayClient*>(pointer);
}
JNIEXPORT void JNICALL
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseServer(JNIEnv*, jobject,
jlong pointer)
{
delete reinterpret_cast<NetPlay::NetPlayServer*>(pointer);
}
} // extern "C"

View File

@ -9,6 +9,7 @@
#include <lzo/lzo1x.h>
#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<std::vector<u8>> 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

View File

@ -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<const u8> in_buffer, sf::Packet& packet);

View File

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

View File

@ -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>([]() -> 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>([]() -> std::string { return NetPlay::GetExternalIPAddress(); });
}
void NetPlayDialog::UpdateDiscordPresence()