diff --git a/Source/Android/app/build.gradle.kts b/Source/Android/app/build.gradle.kts
index 0a952a0704..27f76b22a2 100644
--- a/Source/Android/app/build.gradle.kts
+++ b/Source/Android/app/build.gradle.kts
@@ -1,5 +1,6 @@
plugins {
alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.androidx.baselineprofile)
}
@@ -10,6 +11,7 @@ android {
ndkVersion = "29.0.14206865"
buildFeatures {
+ compose = true
viewBinding = true
buildConfig = true
resValues = true
@@ -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 {
diff --git a/Source/Android/app/src/main/AndroidManifest.xml b/Source/Android/app/src/main/AndroidManifest.xml
index 3218ed3ed8..96876acd46 100644
--- a/Source/Android/app/src/main/AndroidManifest.xml
+++ b/Source/Android/app/src/main/AndroidManifest.xml
@@ -133,6 +133,16 @@
android:label="@string/user_data_submenu"
android:theme="@style/Theme.Dolphin.Main" />
+
+
+
+
, riivolution: Boolean, bootSessionDataPointer: Long)
+
@JvmStatic
external fun ChangeDisc(path: String)
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplayManager.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplayManager.kt
new file mode 100644
index 0000000000..eae44e65be
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplayManager.kt
@@ -0,0 +1,38 @@
+// Copyright 2003 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.netplay
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+object NetplayManager {
+
+ private val mutex = Mutex()
+
+ @Volatile
+ private var closeComplete: CompletableDeferred? = null
+
+ @Volatile
+ var activeSession: NetplaySession? = null
+ private set
+
+ suspend fun createSession(): NetplaySession = mutex.withLock {
+ closeComplete?.await()
+
+ // Sessions should be closed by UI navigation, but just in case.
+ activeSession?.closeBlocking()
+
+ closeComplete = CompletableDeferred()
+
+ NetplaySession(
+ onClosed = {
+ activeSession = null
+ closeComplete?.complete(Unit)
+ }
+ ).also {
+ activeSession = it
+ }
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt
new file mode 100644
index 0000000000..0efabc7f0f
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt
@@ -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(Channel.CONFLATED)
+ val launchGame = _launchGame.receiveAsFlow()
+
+ private val _stopGame = Channel(Channel.CONFLATED)
+ val stopGame = _stopGame.receiveAsFlow()
+
+ private val _connectionLost = Channel(Channel.CONFLATED)
+ val connectionLost = _connectionLost.receiveAsFlow()
+
+ private val _connectionErrors = Channel(Channel.BUFFERED)
+ val connectionErrors = _connectionErrors.receiveAsFlow()
+
+ private val _messages = MutableSharedFlow>(
+ replay = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val messages = _messages.asSharedFlow()
+
+ private val _players = MutableSharedFlow>(
+ replay = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val players = _players.asSharedFlow().distinctUntilChanged()
+
+ private val _chatMessages = MutableSharedFlow(
+ extraBufferCapacity = 32,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val chatMessages = _chatMessages.asSharedFlow()
+
+ private val _game = MutableSharedFlow(
+ replay = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val game = _game.asSharedFlow()
+
+ private val _hostInputAuthorityEnabled = MutableSharedFlow(
+ replay = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val hostInputAuthorityEnabled = _hostInputAuthorityEnabled.asSharedFlow()
+
+ private val _padBuffer = MutableSharedFlow(
+ replay = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val padBuffer = _padBuffer.asSharedFlow()
+
+ private val _desyncMessages = MutableSharedFlow(
+ extraBufferCapacity = 32,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val desyncMessages = _desyncMessages.asSharedFlow()
+
+ private val _saveTransferProgress = MutableStateFlow(null)
+ val saveTransferProgress = _saveTransferProgress.asStateFlow()
+
+ private val _gameDigestProgress = MutableStateFlow(null)
+ val gameDigestProgress = _gameDigestProgress.asStateFlow()
+
+ private val _traversalState = MutableSharedFlow(
+ replay = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ val traversalState = _traversalState.asSharedFlow()
+
+ private val _fatalTraversalError = Channel(Channel.CONFLATED)
+ val fatalTraversalError = _fatalTraversalError.receiveAsFlow()
+
+ suspend fun join(): Boolean = withContext(Dispatchers.IO) {
+ mergeMessages()
+ .runningFold(emptyList()) { acc, msg -> listOf(msg) + acc }
+ .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 = 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) {
+ _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 Channel.flush() {
+ while (this.tryReceive().isSuccess) Unit
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionRole.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionRole.kt
new file mode 100644
index 0000000000..a9f86d5982
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionRole.kt
@@ -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
+ get() = listOf(Connect, Host)
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt
new file mode 100644
index 0000000000..52b4aa10b4
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/ConnectionType.kt
@@ -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
+ get() = listOf(DirectConnection, TraversalServer)
+
+ fun fromString(value: String): ConnectionType =
+ all.find { it.configValue == value } ?: throw IllegalArgumentException("Invalid connection type: $value")
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt
new file mode 100644
index 0000000000..57a9cae78b
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt
@@ -0,0 +1,14 @@
+package org.dolphinemu.dolphinemu.features.netplay.model
+
+data class GameDigestProgress(
+ val title: String,
+ val playerProgresses: List,
+ val matches: Boolean?,
+) {
+ data class PlayerProgress(
+ val playerId: Int,
+ val name: String,
+ val progress: Int,
+ val result: String?,
+ )
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/JoinInfo.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/JoinInfo.kt
new file mode 100644
index 0000000000..7403f06f32
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/JoinInfo.kt
@@ -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()
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayMessage.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayMessage.kt
new file mode 100644
index 0000000000..b8291c094b
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayMessage.kt
@@ -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)
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplaySetupViewModel.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplaySetupViewModel.kt
new file mode 100644
index 0000000000..9c667245e5
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplaySetupViewModel.kt
@@ -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.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(CONFLATED)
+ val showNetplayScreen = _showNetplayScreen.receiveAsFlow()
+
+ private val _connecting = MutableStateFlow(false)
+ val connecting = _connecting.asStateFlow()
+
+ private val _errors = MutableSharedFlow(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 create(modelClass: Class): T {
+ return NetplaySetupViewModel(netplayManager) as T
+ }
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt
new file mode 100644
index 0000000000..1e02936835
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt
@@ -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(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 create(modelClass: Class): T {
+ return NetplayViewModel(session, networkHelper) as T
+ }
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetworkMode.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetworkMode.kt
new file mode 100644
index 0000000000..55d29f5f17
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetworkMode.kt
@@ -0,0 +1,18 @@
+package org.dolphinemu.dolphinemu.features.netplay.model
+
+import androidx.annotation.StringRes
+import org.dolphinemu.dolphinemu.R
+
+enum class NetworkMode(val configValue: String, @StringRes val labelId: Int) {
+ FAIR_INPUT_DELAY("fixeddelay", R.string.netplay_network_mode_fair_input_delay),
+ HOST_INPUT_AUTHORITY("hostinputauthority", R.string.netplay_network_mode_host_input_authority),
+ GOLF("golf", R.string.netplay_network_mode_golf);
+
+ val isHostInputAuthority: Boolean
+ get() = this == HOST_INPUT_AUTHORITY || this == GOLF
+
+ companion object {
+ fun fromConfigValue(value: String): NetworkMode =
+ entries.find { it.configValue == value } ?: FAIR_INPUT_DELAY
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/Player.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/Player.kt
new file mode 100644
index 0000000000..70c228d1c9
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/Player.kt
@@ -0,0 +1,13 @@
+// Copyright 2003 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.netplay.model
+
+data class Player(
+ val pid: Int,
+ val name: String,
+ val revision: String,
+ val ping: Int,
+ val isHost: Boolean,
+ val mapping: String,
+)
\ No newline at end of file
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/SaveTransferProgress.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/SaveTransferProgress.kt
new file mode 100644
index 0000000000..e16cbab8ca
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/SaveTransferProgress.kt
@@ -0,0 +1,13 @@
+package org.dolphinemu.dolphinemu.features.netplay.model
+
+data class SaveTransferProgress(
+ val title: String,
+ val totalSize: Long,
+ val playerProgresses: List
+) {
+ data class PlayerProgress(
+ val playerId: Int,
+ val name: String,
+ val progress: Long,
+ )
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/TraversalState.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/TraversalState.kt
new file mode 100644
index 0000000000..9b574e4f63
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/TraversalState.kt
@@ -0,0 +1,18 @@
+package org.dolphinemu.dolphinemu.features.netplay.model
+
+import android.content.Context
+import org.dolphinemu.dolphinemu.R
+
+sealed class TraversalState {
+ data object Connecting : TraversalState()
+
+ data class Connected(val hostCode: String, val externalAddress: String) : TraversalState()
+
+ data class Failure(val reason: String) : TraversalState() {
+ fun message(context: Context) = when (reason) {
+ "BadHost" -> context.getString(R.string.netplay_traversal_error_bad_host)
+ "VersionTooOld" -> context.getString(R.string.netplay_traversal_error_version_too_old)
+ else -> reason
+ }
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt
new file mode 100644
index 0000000000..1905db24fa
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt
@@ -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))
+ }
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt
new file mode 100644
index 0000000000..15221a4bf3
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt
@@ -0,0 +1,1346 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.features.netplay.ui
+
+import android.content.Intent
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Remove
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MediumTopAppBar
+import androidx.compose.material3.MenuAnchorType
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SheetValue
+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.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coil.compose.AsyncImage
+import coil.request.ImageRequest
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import org.dolphinemu.dolphinemu.R
+import org.dolphinemu.dolphinemu.features.netplay.model.GameDigestProgress
+import org.dolphinemu.dolphinemu.features.netplay.model.JoinAddress
+import org.dolphinemu.dolphinemu.features.netplay.model.JoinInfoType
+import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage
+import org.dolphinemu.dolphinemu.features.netplay.model.NetworkMode
+import org.dolphinemu.dolphinemu.features.netplay.model.Player
+import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress
+import org.dolphinemu.dolphinemu.features.netplay.model.TraversalState
+import org.dolphinemu.dolphinemu.model.GameFile
+import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme
+import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer
+import org.dolphinemu.dolphinemu.ui.theme.OutlinedBox
+import org.dolphinemu.dolphinemu.ui.theme.PreviewTheme
+import org.dolphinemu.dolphinemu.ui.theme.ReadOnlyTextField
+import org.dolphinemu.dolphinemu.ui.theme.rememberSheetState
+import org.dolphinemu.dolphinemu.utils.CoilUtils
+import java.util.Locale
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NetplayScreen(
+ onBackClicked: () -> Unit,
+ isHosting: Boolean,
+ connectionLost: Flow,
+ fatalTraversalError: Flow,
+ messages: List,
+ onSendMessage: (String) -> Unit,
+ game: String,
+ onStartGame: () -> Unit,
+ onGameSelected: (GameFile) -> Unit,
+ gameFiles: List,
+ startGameWarning: Flow,
+ onConfirmStartGame: () -> Unit,
+ hostInputAuthorityEnabled: Boolean,
+ networkMode: NetworkMode,
+ onNetworkModeChanged: (NetworkMode) -> Unit,
+ buffer: Int,
+ onBufferChanged: (Int) -> Unit,
+ clientBuffer: Int,
+ onClientBufferChanged: (Int) -> Unit,
+ players: List,
+ saveTransferProgress: SaveTransferProgress?,
+ gameDigestProgress: GameDigestProgress?,
+ joinAddresses: Map,
+) {
+ Scaffold(
+ topBar = {
+ MediumTopAppBar(
+ title = { Text(stringResource(R.string.netplay_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBackClicked) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back",
+ )
+ }
+ },
+ )
+ },
+ floatingActionButton = {
+ if (isHosting) {
+ ExtendedFloatingActionButton(onClick = onStartGame) {
+ Text(stringResource(R.string.netplay_start))
+ }
+ }
+ },
+ ) { innerPadding ->
+ val modifier = Modifier
+ .fillMaxSize()
+ .consumeWindowInsets(innerPadding)
+ .padding(innerPadding)
+
+ // State which must live above the landscape/portrait split.
+ var showChat by rememberSaveable { mutableStateOf(false) }
+ var showGamePicker by rememberSaveable { mutableStateOf(false) }
+ var selectedJoinInfoType by rememberSaveable {
+ mutableStateOf(joinAddresses.keys.firstOrNull() ?: JoinInfoType.EXTERNAL)
+ }
+
+ if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ LandscapeContent(
+ isHosting = isHosting,
+ messages = messages,
+ onSendMessage = onSendMessage,
+ showChat = showChat,
+ onShowChatChanged = { showChat = it },
+ game = game,
+ gameFiles = gameFiles,
+ onGameSelected = onGameSelected,
+ showGamePicker = showGamePicker,
+ onShowGamePickerChanged = { showGamePicker = it },
+ players = players,
+ hostInputAuthorityEnabled = hostInputAuthorityEnabled,
+ networkMode = networkMode,
+ onNetworkModeChanged = onNetworkModeChanged,
+ buffer = buffer,
+ onBufferChanged = onBufferChanged,
+ clientBuffer = clientBuffer,
+ onClientBufferChanged = onClientBufferChanged,
+ joinAddresses = joinAddresses,
+ selectedJoinInfoType = selectedJoinInfoType,
+ onSelectedJoinInfoTypeChanged = { selectedJoinInfoType = it },
+ modifier = modifier
+ )
+ } else {
+ PortraitContent(
+ isHosting = isHosting,
+ messages = messages,
+ onSendMessage = onSendMessage,
+ showChat = showChat,
+ onShowChatChanged = { showChat = it },
+ game = game,
+ gameFiles = gameFiles,
+ onGameSelected = onGameSelected,
+ showGamePicker = showGamePicker,
+ onShowGamePickerChanged = { showGamePicker = it },
+ players = players,
+ hostInputAuthorityEnabled = hostInputAuthorityEnabled,
+ networkMode = networkMode,
+ onNetworkModeChanged = onNetworkModeChanged,
+ buffer = buffer,
+ onBufferChanged = onBufferChanged,
+ clientBuffer = clientBuffer,
+ onClientBufferChanged = onClientBufferChanged,
+ joinAddresses = joinAddresses,
+ selectedJoinInfoType = selectedJoinInfoType,
+ onSelectedJoinInfoTypeChanged = { selectedJoinInfoType = it },
+ modifier = modifier
+ )
+ }
+
+ var showConnectionLostDialog by rememberSaveable { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ connectionLost.collect { showConnectionLostDialog = true }
+ }
+
+ var traversalError by rememberSaveable { mutableStateOf(null) }
+ LaunchedEffect(Unit) {
+ fatalTraversalError.collect { traversalError = it }
+ }
+
+ var showStartGameWarning by rememberSaveable { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ startGameWarning.collect { showStartGameWarning = true }
+ }
+
+ var dismissSaveTransferProgressDialog by rememberSaveable { mutableStateOf(false) }
+ if (saveTransferProgress == null) {
+ dismissSaveTransferProgressDialog = false
+ }
+
+ var dismissGameDigestDialog by rememberSaveable { mutableStateOf(false) }
+ if (gameDigestProgress == null) {
+ dismissGameDigestDialog = false
+ }
+
+ val currentTraversalError = traversalError
+
+ when {
+ showConnectionLostDialog -> {
+ AlertDialog(
+ text = { Text(stringResource(R.string.netplay_connection_lost)) },
+ confirmButton = {
+ TextButton(onClick = onBackClicked) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ onDismissRequest = onBackClicked,
+ )
+ }
+
+ currentTraversalError != null -> {
+ AlertDialog(
+ text = { Text(currentTraversalError.message(LocalContext.current)) },
+ confirmButton = {
+ TextButton(onClick = onBackClicked) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ onDismissRequest = onBackClicked,
+ )
+ }
+
+ saveTransferProgress != null && !dismissSaveTransferProgressDialog -> {
+ SaveTransferProgressDialog(
+ saveTransferProgress = saveTransferProgress,
+ onDismiss = { dismissSaveTransferProgressDialog = true },
+ )
+ }
+
+ gameDigestProgress != null && !dismissGameDigestDialog -> {
+ GameDigestProgressDialog(
+ gameDigestProgress = gameDigestProgress,
+ onDismiss = { dismissGameDigestDialog = true },
+ )
+ }
+
+ showStartGameWarning -> {
+ AlertDialog(
+ title = { Text(stringResource(R.string.netplay_start_warning_title)) },
+ text = { Text(stringResource(R.string.netplay_start_warning_not_all_players_have_game)) },
+ confirmButton = {
+ TextButton(onClick = {
+ showStartGameWarning = false
+ onConfirmStartGame()
+ }) {
+ Text(stringResource(R.string.yes))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showStartGameWarning = false }) {
+ Text(stringResource(R.string.no))
+ }
+ },
+ onDismissRequest = { showStartGameWarning = false },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PortraitContent(
+ isHosting: Boolean,
+ messages: List,
+ onSendMessage: (String) -> Unit,
+ showChat: Boolean,
+ onShowChatChanged: (Boolean) -> Unit,
+ game: String,
+ gameFiles: List,
+ onGameSelected: (GameFile) -> Unit,
+ showGamePicker: Boolean,
+ onShowGamePickerChanged: (Boolean) -> Unit,
+ players: List,
+ hostInputAuthorityEnabled: Boolean,
+ networkMode: NetworkMode,
+ onNetworkModeChanged: (NetworkMode) -> Unit,
+ buffer: Int,
+ onBufferChanged: (Int) -> Unit,
+ clientBuffer: Int,
+ onClientBufferChanged: (Int) -> Unit,
+ joinAddresses: Map,
+ selectedJoinInfoType: JoinInfoType,
+ onSelectedJoinInfoTypeChanged: (JoinInfoType) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .verticalScroll(rememberScrollState())
+ ) {
+ Chat(
+ messages = messages,
+ onSendMessage = onSendMessage,
+ showBottomSheet = showChat,
+ onShowBottomSheetChanged = onShowChatChanged,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .padding(horizontal = DolphinTheme.scaffoldPadding)
+ )
+
+ MenuSpacer()
+
+ PLayersAndSettings(
+ game = game,
+ gameFiles = gameFiles,
+ onGameSelected = onGameSelected,
+ showGamePicker = showGamePicker,
+ onShowGamePickerChanged = onShowGamePickerChanged,
+ players = players,
+ hostInputAuthorityEnabled = hostInputAuthorityEnabled,
+ networkMode = networkMode,
+ onNetworkModeChanged = onNetworkModeChanged,
+ buffer = buffer,
+ onBufferChanged = onBufferChanged,
+ clientBuffer = clientBuffer,
+ onClientBufferChanged = onClientBufferChanged,
+ isHosting = isHosting,
+ joinAddresses = joinAddresses,
+ selectedJoinInfoType = selectedJoinInfoType,
+ onSelectedJoinInfoTypeChanged = onSelectedJoinInfoTypeChanged,
+ modifier = Modifier
+ .padding(horizontal = DolphinTheme.scaffoldPadding),
+ )
+
+ if (isHosting) {
+ Spacer(modifier = Modifier.height(DolphinTheme.fabClearancePadding))
+ }
+ }
+}
+
+@Composable
+private fun LandscapeContent(
+ isHosting: Boolean,
+ messages: List,
+ onSendMessage: (String) -> Unit,
+ showChat: Boolean,
+ onShowChatChanged: (Boolean) -> Unit,
+ game: String,
+ gameFiles: List,
+ onGameSelected: (GameFile) -> Unit,
+ showGamePicker: Boolean,
+ onShowGamePickerChanged: (Boolean) -> Unit,
+ players: List,
+ hostInputAuthorityEnabled: Boolean,
+ networkMode: NetworkMode,
+ onNetworkModeChanged: (NetworkMode) -> Unit,
+ buffer: Int,
+ onBufferChanged: (Int) -> Unit,
+ clientBuffer: Int,
+ onClientBufferChanged: (Int) -> Unit,
+ joinAddresses: Map,
+ selectedJoinInfoType: JoinInfoType,
+ onSelectedJoinInfoTypeChanged: (JoinInfoType) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .padding(horizontal = DolphinTheme.scaffoldPadding)
+ ) {
+ Chat(
+ messages = messages,
+ onSendMessage = onSendMessage,
+ showBottomSheet = showChat,
+ onShowBottomSheetChanged = onShowChatChanged,
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .verticalScroll(rememberScrollState())
+ ) {
+ PLayersAndSettings(
+ game = game,
+ gameFiles = gameFiles,
+ onGameSelected = onGameSelected,
+ showGamePicker = showGamePicker,
+ onShowGamePickerChanged = onShowGamePickerChanged,
+ players = players,
+ hostInputAuthorityEnabled = hostInputAuthorityEnabled,
+ networkMode = networkMode,
+ onNetworkModeChanged = onNetworkModeChanged,
+ buffer = buffer,
+ onBufferChanged = onBufferChanged,
+ clientBuffer = clientBuffer,
+ onClientBufferChanged = onClientBufferChanged,
+ isHosting = isHosting,
+ joinAddresses = joinAddresses,
+ selectedJoinInfoType = selectedJoinInfoType,
+ onSelectedJoinInfoTypeChanged = onSelectedJoinInfoTypeChanged,
+ modifier = Modifier
+ )
+
+ if (isHosting) {
+ Spacer(modifier = Modifier.height(DolphinTheme.fabClearancePadding))
+ }
+ }
+ }
+}
+
+@Composable
+private fun PLayersAndSettings(
+ game: String,
+ gameFiles: List,
+ onGameSelected: (GameFile) -> Unit,
+ showGamePicker: Boolean,
+ onShowGamePickerChanged: (Boolean) -> Unit,
+ players: List,
+ hostInputAuthorityEnabled: Boolean,
+ networkMode: NetworkMode,
+ onNetworkModeChanged: (NetworkMode) -> Unit,
+ buffer: Int,
+ onBufferChanged: (Int) -> Unit,
+ clientBuffer: Int,
+ onClientBufferChanged: (Int) -> Unit,
+ isHosting: Boolean,
+ joinAddresses: Map,
+ selectedJoinInfoType: JoinInfoType,
+ onSelectedJoinInfoTypeChanged: (JoinInfoType) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ ) {
+ GamePicker(
+ game = game,
+ gameFiles = gameFiles,
+ onGameSelected = onGameSelected,
+ showGamePicker = showGamePicker,
+ onShowGamePickerChanged = onShowGamePickerChanged,
+ isHosting = isHosting,
+ )
+
+ if (isHosting) {
+ MenuSpacer()
+
+ JoinAddressSection(
+ joinAddresses = joinAddresses,
+ selectedType = selectedJoinInfoType,
+ onSelectedTypeChanged = onSelectedJoinInfoTypeChanged,
+ )
+ }
+
+ MenuSpacer()
+
+ OutlinedBox(
+ label = { Text(stringResource(R.string.netplay_players_label)) },
+ ) {
+ PlayersTable(
+ rows = buildList {
+ add(
+ listOf(
+ stringResource(R.string.netplay_players_name),
+ stringResource(R.string.netplay_players_ping),
+ stringResource(R.string.netplay_players_mapping),
+ )
+ )
+ addAll(players.map { listOf(it.name, it.ping.toString(), it.mapping) })
+ repeat(4 - players.size) { add(listOf("", "", "")) }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ )
+ }
+
+ if (isHosting) {
+ MenuSpacer()
+
+ NetworkModeDropdown(
+ networkMode = networkMode,
+ onNetworkModeChanged = onNetworkModeChanged,
+ )
+ }
+
+ if (isHosting && !hostInputAuthorityEnabled) {
+ MenuSpacer()
+
+ BufferInput(
+ value = buffer,
+ onValueChange = onBufferChanged,
+ label = stringResource(R.string.netplay_buffer),
+ )
+ }
+
+ if (!isHosting && hostInputAuthorityEnabled) {
+ MenuSpacer()
+
+ BufferInput(
+ value = clientBuffer,
+ onValueChange = onClientBufferChanged,
+ label = stringResource(R.string.netplay_client_buffer),
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun Chat(
+ messages: List,
+ onSendMessage: (String) -> Unit,
+ showBottomSheet: Boolean,
+ onShowBottomSheetChanged: (Boolean) -> Unit,
+ modifier: Modifier,
+) {
+ val context = LocalContext.current
+
+ fun LazyListScope.messages() {
+ items(messages.size) { index ->
+ Text(
+ text = messages[index].message(context),
+ style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 18.sp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 2.dp)
+ )
+ }
+ }
+
+ var draftMessage by remember { mutableStateOf("") }
+ val submitMessage = {
+ onSendMessage(draftMessage)
+ draftMessage = ""
+ }
+
+ val bottomSheetState = rememberSheetState(
+ skipPartiallyExpanded = true,
+ initialValue = if (showBottomSheet) SheetValue.Expanded else SheetValue.Hidden,
+ )
+
+ if (showBottomSheet) {
+ ModalBottomSheet(
+ onDismissRequest = { onShowBottomSheetChanged(false) },
+ sheetState = bottomSheetState,
+ modifier = Modifier
+ .statusBarsPadding()
+ ) {
+ LazyColumn(
+ reverseLayout = true,
+ contentPadding = PaddingValues(bottom = 4.dp),
+ modifier = Modifier
+ .weight(1f, fill = false)
+ .padding(horizontal = DolphinTheme.scaffoldPadding)
+ ) {
+ messages()
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp)
+ ) {
+ OutlinedTextField(
+ value = draftMessage,
+ onValueChange = { draftMessage = it },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
+ keyboardActions = KeyboardActions(onSend = { submitMessage() }),
+ modifier = Modifier
+ .weight(1f)
+ )
+ IconButton(
+ onClick = submitMessage,
+ enabled = draftMessage.isNotBlank(),
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Send,
+ contentDescription = stringResource(R.string.netplay_chat_send),
+ )
+ }
+ }
+ }
+ }
+
+ OutlinedBox(
+ onClick = { onShowBottomSheetChanged(true) },
+ label = { Text(stringResource(R.string.netplay_chat_label)) },
+ fadeContentTop = true,
+ modifier = modifier
+ ) {
+ LazyColumn(
+ reverseLayout = true,
+ userScrollEnabled = false,
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ messages()
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun GamePicker(
+ game: String,
+ gameFiles: List,
+ onGameSelected: (GameFile) -> Unit,
+ showGamePicker: Boolean,
+ onShowGamePickerChanged: (Boolean) -> Unit,
+ isHosting: Boolean,
+) {
+ val bottomSheetState = rememberSheetState(
+ skipPartiallyExpanded = true,
+ initialValue = if (showGamePicker) SheetValue.Expanded else SheetValue.Hidden,
+ )
+
+ if (showGamePicker) {
+ ModalBottomSheet(
+ onDismissRequest = { onShowGamePickerChanged(false) },
+ sheetState = bottomSheetState,
+ modifier = Modifier.statusBarsPadding()
+ ) {
+ GameList(
+ gameFiles = gameFiles,
+ onGameSelected = { gameFile ->
+ onGameSelected(gameFile)
+ onShowGamePickerChanged(false)
+ },
+ contentPadding = PaddingValues(
+ start = DolphinTheme.scaffoldPadding,
+ end = DolphinTheme.scaffoldPadding,
+ bottom = 16.dp
+ ),
+ )
+ }
+ }
+
+ ReadOnlyTextField(
+ value = game,
+ label = stringResource(R.string.netplay_game_label),
+ onClick = if (isHosting) {
+ { onShowGamePickerChanged(true) }
+ } else {
+ null
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+}
+
+@Composable
+private fun GameList(
+ gameFiles: List,
+ onGameSelected: (GameFile) -> Unit,
+ contentPadding: PaddingValues = PaddingValues(),
+) {
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(minSize = 120.dp),
+ contentPadding = contentPadding,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(gameFiles, key = { it.getPath() }) { gameFile ->
+ GameGridItem(
+ gameFile = gameFile,
+ onClick = { onGameSelected(gameFile) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun GameGridItem(
+ gameFile: GameFile,
+ onClick: () -> Unit,
+) {
+ Card(
+ onClick = onClick,
+ ) {
+ Column {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(gameFile)
+ .error(R.drawable.no_banner)
+ .build(),
+ contentDescription = gameFile.getTitle(),
+ contentScale = ContentScale.Crop,
+ imageLoader = CoilUtils.imageLoader,
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(0.7f)
+ )
+ Text(
+ text = gameFile.getTitle(),
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 2,
+ minLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier
+ .padding(8.dp)
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun JoinAddressSection(
+ joinAddresses: Map,
+ selectedType: JoinInfoType,
+ onSelectedTypeChanged: (JoinInfoType) -> Unit,
+) {
+ val address = joinAddresses[selectedType] ?: joinAddresses.values.first()
+
+ @Suppress("UnusedBoxWithConstraintsScope")
+ BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
+ if (maxWidth > 392.dp) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ JoinInfoDropdown(
+ joinAddresses = joinAddresses,
+ selectedType = selectedType,
+ onSelectedTypeChanged = onSelectedTypeChanged,
+ modifier = Modifier.weight(0.39f),
+ )
+ AddressRow(
+ joinInfoType = selectedType,
+ address = address,
+ modifier = Modifier.weight(0.61f),
+ )
+ }
+ } else {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ JoinInfoDropdown(
+ joinAddresses = joinAddresses,
+ selectedType = selectedType,
+ onSelectedTypeChanged = onSelectedTypeChanged,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ MenuSpacer()
+ AddressRow(
+ joinInfoType = selectedType,
+ address = address,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun JoinInfoDropdown(
+ joinAddresses: Map,
+ selectedType: JoinInfoType,
+ onSelectedTypeChanged: (JoinInfoType) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var expanded by remember { mutableStateOf(false) }
+
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = { expanded = it },
+ modifier = modifier,
+ ) {
+ OutlinedTextField(
+ value = stringResource(selectedType.labelId),
+ onValueChange = {},
+ readOnly = true,
+ label = { Text(stringResource(R.string.netplay_host_address_label)) },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
+ modifier = Modifier
+ .menuAnchor(MenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth()
+ )
+
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ ) {
+ joinAddresses.keys.forEach { type ->
+ DropdownMenuItem(
+ text = { Text(stringResource(type.labelId)) },
+ onClick = {
+ onSelectedTypeChanged(type)
+ expanded = false
+ },
+ contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun AddressRow(
+ joinInfoType: JoinInfoType,
+ address: JoinAddress,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+
+ ReadOnlyTextField(
+ value = when (address) {
+ is JoinAddress.Loading -> stringResource(R.string.netplay_address_loading)
+ is JoinAddress.Loaded -> address.address
+ is JoinAddress.Unknown -> stringResource(R.string.netplay_address_unknown)
+ },
+ label = stringResource(
+ if (joinInfoType == JoinInfoType.ROOM_ID) R.string.netplay_code_label
+ else R.string.netplay_address_label
+ ),
+ onClick = when (address) {
+ is JoinAddress.Loaded -> {
+ {
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, address.address)
+ }
+ context.startActivity(Intent.createChooser(intent, null))
+ }
+ }
+
+ is JoinAddress.Unknown -> address.retry
+ is JoinAddress.Loading -> null
+ },
+ textStyle = if (address is JoinAddress.Loading) {
+ LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
+ } else {
+ null
+ },
+ trailingIcon = {
+ when (address) {
+ is JoinAddress.Loaded -> Icon(
+ imageVector = Icons.Filled.Share,
+ contentDescription = stringResource(R.string.netplay_address_share),
+ )
+
+ is JoinAddress.Unknown -> Icon(
+ imageVector = Icons.Filled.Refresh,
+ contentDescription = stringResource(R.string.netplay_address_retry),
+ )
+
+ is JoinAddress.Loading -> CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 2.dp,
+ )
+ }
+ },
+ modifier = modifier,
+ )
+}
+
+/**
+ * A table arranged into columns sized to wrap the largest item. Except the
+ * first column which takes up the remaining space left by the other columns.
+ * The first row is treated as the column titles.
+ */
+@Composable
+private fun PlayersTable(
+ rows: List>,
+ modifier: Modifier = Modifier,
+) {
+ rows.zipWithNext { a, b -> if (a.size != b.size) throw IllegalArgumentException("Rows must all contain the same number of elements.") }
+ val maxWidths = remember { List(rows.first().size) { mutableIntStateOf(0) } }
+ val density = LocalDensity.current
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = modifier
+ ) {
+ rows.forEachIndexed { rowIndex, row ->
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ row.forEachIndexed { itemIndex, text ->
+ Box(
+ modifier = Modifier
+ .then(
+ when {
+ itemIndex == 0 -> Modifier.weight(1f)
+
+ maxWidths[itemIndex].intValue > 0 -> Modifier
+ .width(with(density) { maxWidths[itemIndex].intValue.toDp() })
+
+ else -> Modifier
+ }
+ )
+ .onGloballyPositioned { coordinates ->
+ val width = coordinates.size.width
+ if (width > maxWidths[itemIndex].intValue) {
+ maxWidths[itemIndex].intValue = width
+ }
+ }
+ ) {
+ Text(
+ text = text,
+ fontWeight = if (rowIndex == 0) FontWeight.Medium else FontWeight.Normal,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+ }
+ if (rowIndex == 0) {
+ HorizontalDivider()
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun NetworkModeDropdown(
+ networkMode: NetworkMode,
+ onNetworkModeChanged: (NetworkMode) -> Unit,
+) {
+ var expanded by remember { mutableStateOf(false) }
+
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = { expanded = it },
+ ) {
+ OutlinedTextField(
+ value = stringResource(networkMode.labelId),
+ onValueChange = {},
+ readOnly = true,
+ label = { Text(stringResource(R.string.netplay_network_mode_label)) },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
+ colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
+ modifier = Modifier
+ .menuAnchor(MenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth(),
+ )
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ ) {
+ // No golf mode for now since it requires in game UI.
+ listOf(NetworkMode.FAIR_INPUT_DELAY, NetworkMode.HOST_INPUT_AUTHORITY).forEach { mode ->
+ DropdownMenuItem(
+ text = { Text(stringResource(mode.labelId)) },
+ onClick = {
+ onNetworkModeChanged(mode)
+ expanded = false
+ },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun BufferInput(
+ value: Int,
+ onValueChange: (Int) -> Unit,
+ label: String,
+) {
+ val range = 0..99
+ var maybeEmptyValue by remember(value) {
+ mutableStateOf("$value")
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ OutlinedTextField(
+ value = TextFieldValue(
+ text = maybeEmptyValue,
+ selection = TextRange(maybeEmptyValue.length)
+ ),
+ onValueChange = { newValue ->
+ if (newValue.text.isEmpty()) {
+ maybeEmptyValue = newValue.text
+ return@OutlinedTextField
+ }
+ newValue.text.toIntOrNull()?.let {
+ if (it in range) {
+ onValueChange(it)
+ }
+ }
+ },
+ label = { Text(label) },
+ textStyle = LocalTextStyle.current.copy(
+ textAlign = TextAlign.Center
+ ),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ ),
+ singleLine = true,
+ modifier = Modifier
+ .weight(1f)
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Button(
+ onClick = {
+ if (maybeEmptyValue.isEmpty()) {
+ maybeEmptyValue = "0"
+ onValueChange(0)
+ } else {
+ val newValue = value - 1
+ if (newValue in range) {
+ onValueChange(newValue)
+ }
+ }
+ },
+ shape = RoundedCornerShape(
+ topStartPercent = 50,
+ topEndPercent = 0,
+ bottomEndPercent = 0,
+ bottomStartPercent = 50,
+ ),
+ modifier = Modifier
+ .height(60.dp)
+ .padding(top = 8.dp)
+ ) {
+ Icon(Icons.Filled.Remove, contentDescription = "Back")
+ }
+
+ Spacer(modifier = Modifier.width(2.dp))
+
+ Button(
+ onClick = {
+ if (maybeEmptyValue.isEmpty()) {
+ maybeEmptyValue = "0"
+ onValueChange(0)
+ } else {
+ val newValue = value + 1
+ if (newValue in range) {
+ onValueChange(newValue)
+ }
+ }
+ },
+ shape = RoundedCornerShape(
+ topStartPercent = 0,
+ topEndPercent = 50,
+ bottomEndPercent = 50,
+ bottomStartPercent = 0,
+ ),
+ modifier = Modifier
+ .height(60.dp)
+ .padding(top = 8.dp)
+ ) {
+ Icon(Icons.Filled.Add, contentDescription = "Back")
+ }
+ }
+}
+
+@Composable
+private fun SaveTransferProgressDialog(
+ saveTransferProgress: SaveTransferProgress,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ title = { Text(saveTransferProgress.title) },
+ text = {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.verticalScroll(rememberScrollState()),
+ ) {
+ saveTransferProgress.playerProgresses.forEachIndexed { index, playerProgress ->
+ SaveTransferProgressRow(
+ playerProgress = playerProgress,
+ totalSize = saveTransferProgress.totalSize,
+ )
+
+ if (index < saveTransferProgress.playerProgresses.lastIndex) {
+ HorizontalDivider()
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.netplay_save_transfer_progress_close))
+ }
+ },
+ onDismissRequest = onDismiss,
+ )
+}
+
+@Composable
+private fun SaveTransferProgressRow(
+ playerProgress: SaveTransferProgress.PlayerProgress,
+ totalSize: Long,
+) {
+ fun formatMib(bytes: Long) = String.format(Locale.US, "%.2f", bytes / 1024f / 1024f)
+ val progressFraction = (playerProgress.progress.toFloat() / totalSize).coerceIn(0f, 1f)
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ LinearProgressIndicator(
+ progress = { progressFraction },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = playerProgress.name,
+ modifier = Modifier.weight(1f),
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(
+ text = "${formatMib(playerProgress.progress)}/${formatMib(totalSize)} MiB",
+ )
+ }
+ }
+}
+
+@Composable
+private fun GameDigestProgressDialog(
+ gameDigestProgress: GameDigestProgress,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ title = { Text(gameDigestProgress.title) },
+ text = {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.verticalScroll(rememberScrollState()),
+ ) {
+ gameDigestProgress.playerProgresses.forEachIndexed { index, playerProgress ->
+ GameDigestPlayerRow(playerProgress)
+ if (index < gameDigestProgress.playerProgresses.lastIndex) {
+ HorizontalDivider()
+ }
+ }
+ if (gameDigestProgress.matches != null) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = stringResource(
+ if (gameDigestProgress.matches) {
+ R.string.netplay_game_digest_match
+ } else {
+ R.string.netplay_game_digest_mismatch
+ }
+ ),
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ }
+ },
+ confirmButton = {
+ if (gameDigestProgress.matches != null) {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.netplay_game_digest_close))
+ }
+ }
+ },
+ onDismissRequest = { onDismiss() },
+ )
+}
+
+@Composable
+private fun GameDigestPlayerRow(
+ playerProgress: GameDigestProgress.PlayerProgress,
+) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ LinearProgressIndicator(
+ progress = { playerProgress.progress / 100f },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ if (playerProgress.result == null) {
+ Text(
+ text = playerProgress.name,
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Text(
+ text = "${playerProgress.progress}%",
+ )
+ } else {
+ Text(
+ text = "${playerProgress.name}:\u00A0${playerProgress.result}",
+ )
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun NetplayScreenPreview() {
+ PreviewTheme(darkTheme = false) {
+ PreviewNetplayScreen()
+ }
+}
+
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun NetplayScreenDarkPreview() {
+ PreviewTheme(darkTheme = true) {
+ PreviewNetplayScreen()
+ }
+}
+
+@Preview(widthDp = 891, heightDp = 411)
+@Composable
+private fun LandscapeNetplayScreenPreview() {
+ PreviewTheme(darkTheme = false) {
+ PreviewNetplayScreen()
+ }
+}
+
+@Preview(
+ widthDp = 891,
+ heightDp = 411,
+ uiMode = Configuration.UI_MODE_NIGHT_YES
+)
+
+@Composable
+private fun LandscapeNetplayScreenDarkPreview() {
+ PreviewTheme(darkTheme = true) {
+ PreviewNetplayScreen()
+ }
+}
+
+@Composable
+private fun PreviewNetplayScreen() {
+ NetplayScreen(
+ onBackClicked = {},
+ connectionLost = emptyFlow(),
+ fatalTraversalError = emptyFlow(),
+ players = listOf(
+ Player(
+ pid = 1,
+ name = "Player 1",
+ revision = "123",
+ ping = 2,
+ isHost = true,
+ mapping = "m1"
+ ),
+ Player(
+ pid = 2,
+ name = "Player 2",
+ revision = "123",
+ ping = 23,
+ isHost = false,
+ mapping = "m2"
+ ),
+ ),
+ messages = buildList {
+ repeat(5) {
+ add(NetplayMessage.Chat("Hello"))
+ }
+ },
+ onSendMessage = {},
+ game = "Game name",
+ isHosting = true,
+ onStartGame = {},
+ onGameSelected = {},
+ gameFiles = emptyList(),
+ startGameWarning = emptyFlow(),
+ onConfirmStartGame = {},
+ hostInputAuthorityEnabled = true,
+ networkMode = NetworkMode.HOST_INPUT_AUTHORITY,
+ onNetworkModeChanged = {},
+ buffer = 5,
+ onBufferChanged = {},
+ clientBuffer = 10,
+ onClientBufferChanged = {},
+ saveTransferProgress = null,
+ gameDigestProgress = null,
+ joinAddresses = mapOf(
+ JoinInfoType.EXTERNAL to JoinAddress.Loaded("203.0.113.1:2626"),
+ JoinInfoType.LOCAL to JoinAddress.Loaded("192.168.1.5:2626"),
+ ),
+// saveTransferProgress = SaveTransferProgress(
+// title = "Title",
+// totalSize = 1024L,
+// playerProgresses = listOf(
+// SaveTransferProgress.PlayerProgress(
+// playerId = 1,
+// name = "Player 1",
+// progress = 256,
+// ),
+// SaveTransferProgress.PlayerProgress(
+// playerId = 2,
+// name = "Player 2",
+// progress = 512,
+// ),
+// ),
+// ),
+ )
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupActivity.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupActivity.kt
new file mode 100644
index 0000000000..a82dbff15a
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupActivity.kt
@@ -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))
+ }
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt
new file mode 100644
index 0000000000..939a39524d
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplaySetupScreen.kt
@@ -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()
+}
+
+@Composable
+fun NetplaySetupScreen(
+ onBackClicked: () -> Unit,
+ connecting: Boolean,
+ errors: Flow,
+ connectionRole: ConnectionRole,
+ onConnectionRoleChanged: (ConnectionRole) -> Unit,
+ nickname: String,
+ onNicknameChanged: (String) -> Unit,
+ connectionType: ConnectionType,
+ onConnectionTypeChanged: (ConnectionType) -> Unit,
+ ipAddress: String,
+ onIpAddressChanged: (String) -> Unit,
+ connectPort: String,
+ onConnectPortChanged: (String) -> Unit,
+ hostCode: String,
+ onHostCodeChanged: (String) -> Unit,
+ 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(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 = {},
+ )
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt
index cd0ea42d94..4658814ec1 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt
@@ -933,7 +933,8 @@ enum class BooleanSetting(
Settings.SECTION_ACHIEVEMENTS,
"ProgressEnabled",
false
- );
+ ),
+ NETPLAY_USE_UPNP(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "UseUPNP", false);
override val isOverridden: Boolean
get() = NativeConfig.isOverridden(file, section, key)
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt
index faf9f71ea0..628e286b49 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/IntSetting.kt
@@ -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)
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt
index 96d40b214c..da54d81258 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/Settings.kt
@@ -112,6 +112,7 @@ class Settings : Closeable {
const val SECTION_INI_INTERFACE = "Interface"
const val SECTION_INI_DSP = "DSP"
const val SECTION_INI_GBA = "GBA"
+ const val SECTION_INI_NETPLAY = "NetPlay"
const val SECTION_LOGGER_LOGS = "Logs"
const val SECTION_LOGGER_OPTIONS = "Options"
const val SECTION_GFX_HARDWARE = "Hardware"
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt
index 512a33ddbb..72877429af 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/StringSetting.kt
@@ -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)
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt
index e4698ba43c..a005995bdb 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/EmulationFragment.kt
@@ -9,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) {
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.kt
index 893d6a16d4..1e1d55dbd7 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/main/MainPresenter.kt
@@ -21,6 +21,7 @@ import org.dolphinemu.dolphinemu.features.settings.ui.MenuTag
import org.dolphinemu.dolphinemu.features.sysupdate.ui.SystemMenuNotInstalledDialogFragment
import org.dolphinemu.dolphinemu.features.sysupdate.ui.SystemUpdateProgressBarDialogFragment
import org.dolphinemu.dolphinemu.features.sysupdate.ui.SystemUpdateViewModel
+import org.dolphinemu.dolphinemu.features.netplay.ui.NetplaySetupActivity
import org.dolphinemu.dolphinemu.fragments.AboutDialogFragment
import org.dolphinemu.dolphinemu.model.GameFileCache
import org.dolphinemu.dolphinemu.services.GameFileCacheManager
@@ -188,6 +189,11 @@ class MainPresenter(private val mainView: MainView, private val activity: Fragme
true
}
+ R.id.menu_netplay -> {
+ NetplaySetupActivity.launch(activity)
+ true
+ }
+
R.id.menu_about -> {
showAboutDialog()
false
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt
new file mode 100644
index 0000000000..37085b06d9
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/ui/theme/DolphinTheme.kt
@@ -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,
+ )
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/CoilUtils.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/CoilUtils.kt
index 846fcd564e..8c3fb2e3f3 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/CoilUtils.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/CoilUtils.kt
@@ -66,7 +66,7 @@ class GameCoverKeyer : Keyer {
}
object CoilUtils {
- private val imageLoader = ImageLoader.Builder(DolphinApplication.getAppContext())
+ val imageLoader = ImageLoader.Builder(DolphinApplication.getAppContext())
.components {
add(GameCoverKeyer())
add(GameCoverFetcher.Factory())
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/NetworkHelper.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/NetworkHelper.kt
index 7bd2068ee2..231f15fda1 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/NetworkHelper.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/NetworkHelper.kt
@@ -75,4 +75,9 @@ object NetworkHelper {
0
}
}
+
+ @JvmStatic
+ fun getLocalIpString(): String? {
+ return getIPv4Link()?.address?.hostAddress
+ }
}
diff --git a/Source/Android/app/src/main/res/menu/menu_game_grid.xml b/Source/Android/app/src/main/res/menu/menu_game_grid.xml
index 2a282198d1..c52c574c44 100644
--- a/Source/Android/app/src/main/res/menu/menu_game_grid.xml
+++ b/Source/Android/app/src/main/res/menu/menu_game_grid.xml
@@ -51,6 +51,11 @@
android:title="@string/grid_menu_online_system_update"
app:showAsAction="never"/>
+
+
- Import Wii Save
Import BootMii NAND Backup
Perform Online System Update
+ Netplay
Load Wii System Menu
Load Wii System Menu (%s)
Load vWii System Menu (%s)
@@ -980,4 +981,58 @@ It can efficiently compress both junk data and encrypted Wii data.
Log Out
Logging In
Login Failed
+
+
+ Netplay Setup
+ Connection type
+ Direct connection
+ Traversal server
+ Connect
+ Connecting…
+ Host
+ Starting…
+ Nickname
+ IP address
+ Host code
+ Port
+ Netplay
+ Start
+ Warning
+ Not all players have the game. Do you really want to start?
+ Chat
+ Send
+ Game changed to %1$s
+ Buffer size changed to %1$d
+ "Host input authority %1$s"
+ Possible desync detected: %1$s might have desynced at frame %2$d
+ Game
+ Players
+ Name
+ Ping
+ Mapping
+ Input mode
+ Fair Input Delay
+ Host Input Authority
+ Golf Mode
+ Buffer
+ Max buffer
+ Netplay connection lost
+ Close
+ The hashes match
+ The hashes do not match
+ Close
+ Port
+ Forward port (UPnP)
+ Join info
+ Address
+ Code
+ Room
+ External
+ Local
+ Couldn\'t look up central server
+ Dolphin is too old for traversal server
+ Loading…
+ Unknown
+ Share address
+ Retry
diff --git a/Source/Android/build.gradle.kts b/Source/Android/build.gradle.kts
index 5682b2f130..c74d069b44 100644
--- a/Source/Android/build.gradle.kts
+++ b/Source/Android/build.gradle.kts
@@ -4,6 +4,7 @@ plugins {
alias(libs.plugins.android.library) apply false
alias(libs.plugins.android.test) apply false
alias(libs.plugins.androidx.baselineprofile) apply false
+ alias(libs.plugins.kotlin.compose) apply false
}
buildscript {
diff --git a/Source/Android/gradle/libs.versions.toml b/Source/Android/gradle/libs.versions.toml
index 09fa7b09ca..0f91ab31b7 100644
--- a/Source/Android/gradle/libs.versions.toml
+++ b/Source/Android/gradle/libs.versions.toml
@@ -4,6 +4,7 @@ appcompat = "1.7.1"
benchmarkMacroJunit4 = "1.5.0-alpha04"
cardview = "1.0.0"
coil = "2.7.0"
+compose-bom = "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" }
diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp
index 18b486023a..db05bda5d5 100644
--- a/Source/Android/jni/AndroidCommon/IDCache.cpp
+++ b/Source/Android/jni/AndroidCommon/IDCache.cpp
@@ -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(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(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(env->NewGlobalRef(netplay_player_class));
+ s_netplay_player_constructor = env->GetMethodID(netplay_player_class, "", "(ILjava/lang/String;Ljava/lang/String;IZLjava/lang/String;)V");
+ env->DeleteLocalRef(netplay_player_class);
+
const jclass analytics_class = env->FindClass("org/dolphinemu/dolphinemu/utils/Analytics");
s_analytics_class = reinterpret_cast(env->NewGlobalRef(analytics_class));
s_get_analytics_value = env->GetStaticMethodID(s_analytics_class, "getValue",
@@ -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);
diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h
index 0aaa9feec3..064b37df44 100644
--- a/Source/Android/jni/AndroidCommon/IDCache.h
+++ b/Source/Android/jni/AndroidCommon/IDCache.h
@@ -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();
diff --git a/Source/Android/jni/CMakeLists.txt b/Source/Android/jni/CMakeLists.txt
index a15eb5c636..a3a741a433 100644
--- a/Source/Android/jni/CMakeLists.txt
+++ b/Source/Android/jni/CMakeLists.txt
@@ -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
diff --git a/Source/Android/jni/MainAndroid.cpp b/Source/Android/jni/MainAndroid.cpp
index 3855cb4c18..8177022475 100644
--- a/Source/Android/jni/MainAndroid.cpp
+++ b/Source/Android/jni/MainAndroid.cpp
@@ -35,6 +35,7 @@
#include "Core/AchievementManager.h"
#include "Core/Boot/Boot.h"
+#include "jni/NetPlay/NetPlayUICallbacks.h"
#include "Core/BootManager.h"
#include "Core/CommonTitles.h"
#include "Core/ConfigLoaders/GameConfigLoader.h"
@@ -614,6 +615,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(
+ reinterpret_cast(jBootSessionData));
+ if (!boot_session_data)
+ {
+ env->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(), IDCache::GetDisplayToastMsg(),
+ ToJString(env, "Netplay: no boot session data"), JNI_TRUE);
+ env->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(), IDCache::GetFinishEmulationActivity());
+ return;
+ }
+ Run(env, JStringArrayToVector(env, jPaths), jRiivolution, std::move(*boot_session_data));
+}
+
JNIEXPORT void JNICALL Java_org_dolphinemu_dolphinemu_NativeLibrary_RunSystemMenu(JNIEnv* env,
jclass)
{
diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp
new file mode 100644
index 0000000000..ce9336ff15
--- /dev/null
+++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp
@@ -0,0 +1,455 @@
+// Copyright 2003 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include
+
+#include "UICommon/GameFile.h"
+#include "UICommon/UICommon.h"
+#include "NetPlayUICallbacks.h"
+#include "Common/TraversalClient.h"
+#include "Core/Boot/Boot.h"
+#include "Core/Core.h"
+#include "Core/System.h"
+#include "jni/AndroidCommon/AndroidCommon.h"
+#include "jni/AndroidCommon/IDCache.h"
+
+namespace
+{
+std::string InetAddressToString(const Common::TraversalInetAddress& addr)
+{
+ std::string ip;
+
+ if (addr.isIPV6)
+ {
+ ip = "IPv6-Not-Implemented";
+ }
+ else
+ {
+ const auto ipv4 = reinterpret_cast(addr.address);
+ ip = std::to_string(ipv4[0]);
+ for (u32 i = 1; i != 4; ++i)
+ {
+ ip += ".";
+ ip += std::to_string(ipv4[i]);
+ }
+ }
+
+ return ip + ":" + std::to_string(ntohs(addr.port));
+}
+
+const char* FailureReasonToString(Common::TraversalClient::FailureReason reason)
+{
+ switch (reason)
+ {
+ case Common::TraversalClient::FailureReason::BadHost:
+ return "BadHost";
+ case Common::TraversalClient::FailureReason::VersionTooOld:
+ return "VersionTooOld";
+ case Common::TraversalClient::FailureReason::ServerForgotAboutUs:
+ return "ServerForgotAboutUs";
+ case Common::TraversalClient::FailureReason::SocketSendError:
+ return "SocketSendError";
+ case Common::TraversalClient::FailureReason::ResendTimeout:
+ return "ResendTimeout";
+ default:
+ return "Unknown";
+ }
+}
+} // namespace
+
+namespace NetPlay {
+
+NetPlayUICallbacks::NetPlayUICallbacks(jobject netplay_session,
+ std::vector> games)
+ : m_netplay_session(IDCache::GetEnvForThread()->NewWeakGlobalRef(netplay_session)),
+ m_games(std::move(games))
+{
+ m_state_changed_hook = Core::AddOnStateChangedCallback([this](Core::State state) {
+ if ((state == Core::State::Uninitialized || state == Core::State::Stopping) &&
+ !m_got_stop_request)
+ {
+ JNIEnv* env = IDCache::GetEnvForThread();
+ jobject netplay_session = GetNetplaySessionLocalRef(env);
+ if (!netplay_session)
+ return;
+
+ auto* client = reinterpret_cast(
+ env->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 boot_session_data)
+{
+ m_got_stop_request = false;
+
+ JNIEnv* env = IDCache::GetEnvForThread();
+ jobject netplay_session = GetNetplaySessionLocalRef(env);
+ if (!netplay_session)
+ return;
+
+ env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnBootGame(), ToJString(env, filename),
+ reinterpret_cast(boot_session_data.release()));
+ env->DeleteLocalRef(netplay_session);
+}
+
+void NetPlayUICallbacks::StopGame()
+{
+ 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(
+ env->GetLongField(netplay_session, IDCache::GetNetPlayClientPointer()));
+ if (!client)
+ {
+ env->DeleteLocalRef(netplay_session);
+ return;
+ }
+
+ const std::vector players = client->GetPlayers();
+
+ jobjectArray player_array =
+ env->NewObjectArray(static_cast(players.size()), IDCache::GetNetplayPlayerClass(), nullptr);
+
+ for (jsize i = 0; i < static_cast(players.size()); i++)
+ {
+ const NetPlay::Player* player = players[i];
+ const std::string mapping = NetPlay::GetPlayerMappingString(
+ player->pid, client->GetPadMapping(), client->GetGBAConfig(), client->GetWiimoteMapping());
+ jobject player_obj = env->NewObject(
+ IDCache::GetNetplayPlayerClass(), IDCache::GetNetplayPlayerConstructor(),
+ static_cast(player->pid),
+ ToJString(env, player->name),
+ ToJString(env, player->revision),
+ static_cast(player->ping),
+ static_cast(player->IsHost()),
+ ToJString(env, mapping));
+ env->SetObjectArrayElement(player_array, i, player_obj);
+ env->DeleteLocalRef(player_obj);
+ }
+
+ env->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(
+ 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(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(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(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(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
+NetPlayUICallbacks::FindGameFile(const NetPlay::SyncIdentifier& sync_identifier,
+ NetPlay::SyncIdentifierComparison* found)
+{
+ NetPlay::SyncIdentifierComparison temp;
+ if (!found)
+ found = &temp;
+
+ *found = NetPlay::SyncIdentifierComparison::DifferentGame;
+
+ std::shared_ptr result;
+ for (const auto& game : m_games)
+ {
+ const auto cmp = game->CompareSyncIdentifier(sync_identifier);
+ if (cmp < *found)
+ {
+ *found = cmp;
+ result = game;
+ }
+ }
+ return result;
+}
+
+std::string NetPlayUICallbacks::FindGBARomPath(const std::array&, std::string_view, int) { return {}; }
+
+void NetPlayUICallbacks::ShowGameDigestDialog(const std::string& title)
+{
+ JNIEnv* env = IDCache::GetEnvForThread();
+ jobject netplay_session = GetNetplaySessionLocalRef(env);
+ if (!netplay_session)
+ return;
+
+ env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnShowGameDigestDialog(),
+ ToJString(env, title));
+ env->DeleteLocalRef(netplay_session);
+}
+
+void NetPlayUICallbacks::SetGameDigestProgress(int pid, int progress)
+{
+ JNIEnv* env = IDCache::GetEnvForThread();
+ jobject netplay_session = GetNetplaySessionLocalRef(env);
+ if (!netplay_session)
+ return;
+
+ env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnSetGameDigestProgress(),
+ static_cast(pid), static_cast(progress));
+ env->DeleteLocalRef(netplay_session);
+}
+
+void NetPlayUICallbacks::SetGameDigestResult(int pid, const std::string& result)
+{
+ JNIEnv* env = IDCache::GetEnvForThread();
+ jobject netplay_session = GetNetplaySessionLocalRef(env);
+ if (!netplay_session)
+ return;
+
+ env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnSetGameDigestResult(),
+ static_cast(pid), ToJString(env, result));
+ env->DeleteLocalRef(netplay_session);
+}
+
+void NetPlayUICallbacks::AbortGameDigest()
+{
+ JNIEnv* env = IDCache::GetEnvForThread();
+ jobject netplay_session = GetNetplaySessionLocalRef(env);
+ if (!netplay_session)
+ return;
+
+ env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnAbortGameDigest());
+ env->DeleteLocalRef(netplay_session);
+}
+
+void NetPlayUICallbacks::ShowChunkedProgressDialog(const std::string& title, u64 data_size,
+ std::span players)
+{
+ JNIEnv* env = IDCache::GetEnvForThread();
+ jobject netplay_session = GetNetplaySessionLocalRef(env);
+ if (!netplay_session)
+ return;
+
+ jintArray j_players = env->NewIntArray(static_cast(players.size()));
+ env->SetIntArrayRegion(j_players, 0, static_cast(players.size()), players.data());
+
+ env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnShowChunkedProgressDialog(),
+ ToJString(env, title), static_cast(data_size), j_players);
+ env->DeleteLocalRef(j_players);
+ env->DeleteLocalRef(netplay_session);
+}
+
+void NetPlayUICallbacks::HideChunkedProgressDialog()
+{
+ JNIEnv* env = IDCache::GetEnvForThread();
+ 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(pid), static_cast(progress));
+ env->DeleteLocalRef(netplay_session);
+}
+
+void NetPlayUICallbacks::SetHostWiiSyncData(std::vector, std::string) {}
+
+} // namespace NetPlay
diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.h b/Source/Android/jni/NetPlay/NetPlayUICallbacks.h
new file mode 100644
index 0000000000..57eec55230
--- /dev/null
+++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.h
@@ -0,0 +1,75 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+
+#include
+
+#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> games);
+ ~NetPlayUICallbacks() override;
+
+ void BootGame(const std::string& filename,
+ std::unique_ptr boot_session_data) override;
+ void StopGame() override;
+ bool IsHosting() const override;
+ void Update() override;
+ void AppendChat(const std::string& msg) override;
+ void OnMsgChangeGame(const NetPlay::SyncIdentifier& sync_identifier,
+ const std::string& netplay_name) override;
+ void OnMsgChangeGBARom(int pad, const NetPlay::GBAConfig& config) override;
+ void OnMsgStartGame() override;
+ void OnMsgStopGame() override;
+ void OnMsgPowerButton() override;
+ void OnPlayerConnect(const std::string& player) override;
+ void OnPlayerDisconnect(const std::string& player) override;
+ void OnPadBufferChanged(u32 buffer) override;
+ void OnHostInputAuthorityChanged(bool enabled) override;
+ void OnDesync(u32 frame, const std::string& player) override;
+ void OnConnectionLost() override;
+ void OnConnectionError(const std::string& message) override;
+ void OnTraversalError(Common::TraversalClient::FailureReason error) override;
+ void OnTraversalStateChanged(Common::TraversalClient::State state) override;
+ void OnGameStartAborted() override;
+ void OnGolferChanged(bool is_golfer, const std::string& golfer_name) override;
+ void OnTtlDetermined(u8 ttl) override;
+ void OnIndexAdded(bool success, std::string error) override;
+ void OnIndexRefreshFailed(std::string error) override;
+ bool IsRecording() override;
+ std::shared_ptr
+ FindGameFile(const NetPlay::SyncIdentifier& sync_identifier,
+ NetPlay::SyncIdentifierComparison* found = nullptr) override;
+ std::string FindGBARomPath(const std::array& hash, std::string_view title,
+ int device_number) override;
+ void ShowGameDigestDialog(const std::string& title) override;
+ void SetGameDigestProgress(int pid, int progress) override;
+ void SetGameDigestResult(int pid, const std::string& result) override;
+ void AbortGameDigest() override;
+ void ShowChunkedProgressDialog(const std::string& title, u64 data_size,
+ std::span players) override;
+ void HideChunkedProgressDialog() override;
+ void SetChunkedProgress(int pid, u64 progress) override;
+ void SetHostWiiSyncData(std::vector titles, std::string redirect_folder) override;
+
+private:
+ jobject GetNetplaySessionLocalRef(JNIEnv* env) const;
+
+ jweak m_netplay_session;
+ std::vector> m_games;
+ NetPlay::SyncIdentifier m_current_game_identifier;
+ std::string m_current_game_name;
+ Common::EventHook m_state_changed_hook;
+ bool m_got_stop_request = true;
+};
+
+} // namespace NetPlay
diff --git a/Source/Android/jni/NetPlay/Netplay.cpp b/Source/Android/jni/NetPlay/Netplay.cpp
new file mode 100644
index 0000000000..736f4e0109
--- /dev/null
+++ b/Source/Android/jni/NetPlay/Netplay.cpp
@@ -0,0 +1,261 @@
+// Copyright 2003 Dolphin Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include
+#include
+#include
+
+#include
+
+#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(
+ env->GetLongField(obj, IDCache::GetNetPlayUICallbacksPointer()));
+}
+
+static NetPlay::NetPlayClient* GetClientPointer(JNIEnv* env, jobject obj)
+{
+ return reinterpret_cast(
+ env->GetLongField(obj, IDCache::GetNetPlayClientPointer()));
+}
+
+static NetPlay::NetPlayServer* GetServerPointer(JNIEnv* env, jobject obj)
+{
+ return reinterpret_cast(
+ env->GetLongField(obj, IDCache::GetNetPlayServerPointer()));
+}
+
+extern "C" {
+
+JNIEXPORT void JNICALL
+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(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(buffer));
+}
+
+JNIEXPORT void JNICALL
+Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeAdjustServerPadBufferSize(
+ JNIEnv* env, jobject obj, jint buffer)
+{
+ if (auto* server = GetServerPointer(env, obj))
+ server->AdjustPadBufferSize(static_cast(buffer));
+}
+
+JNIEXPORT jlong JNICALL
+Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeCreateUICallbacks(JNIEnv* env,
+ jobject obj)
+{
+ jobject jgame_file_cache = env->GetStaticObjectField(
+ IDCache::GetGameFileCacheManagerClass(), IDCache::GetGameFileCacheManagerInstance());
+ auto* game_file_cache = reinterpret_cast(
+ env->GetLongField(jgame_file_cache, IDCache::GetGameFileCachePointer()));
+
+ std::vector> games;
+ game_file_cache->ForEach(
+ [&games](const std::shared_ptr& game) { games.push_back(game); });
+
+ return reinterpret_cast(new NetPlay::NetPlayUICallbacks(obj, std::move(games)));
+}
+
+JNIEXPORT jlong JNICALL
+Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeJoin(JNIEnv* env, jobject obj)
+{
+ auto* ui = GetUICallbacksPointer(env, obj);
+
+ const std::string traversal_host = Config::Get(Config::NETPLAY_TRAVERSAL_SERVER);
+ const u16 traversal_port = Config::Get(Config::NETPLAY_TRAVERSAL_PORT);
+ const std::string nickname = Config::Get(Config::NETPLAY_NICKNAME);
+
+ 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(client);
+}
+
+JNIEXPORT jlong JNICALL
+Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeHost(JNIEnv* env, jobject obj)
+{
+ auto* ui = GetUICallbacksPointer(env, obj);
+
+ const std::string traversal_choice = Config::Get(Config::NETPLAY_TRAVERSAL_CHOICE);
+ const bool is_traversal = traversal_choice == "traversal";
+ const bool use_upnp = Config::Get(Config::NETPLAY_USE_UPNP);
+ const std::string traversal_host = Config::Get(Config::NETPLAY_TRAVERSAL_SERVER);
+ const u16 traversal_port = Config::Get(Config::NETPLAY_TRAVERSAL_PORT);
+ const u16 traversal_port_alt = Config::Get(Config::NETPLAY_TRAVERSAL_PORT_ALT);
+
+ const u16 host_port = is_traversal ? Config::Get(Config::NETPLAY_LISTEN_PORT)
+ : Config::Get(Config::NETPLAY_HOST_PORT);
+
+ auto* server = new NetPlay::NetPlayServer(
+ host_port, use_upnp, ui,
+ NetPlay::NetTraversalConfig{is_traversal, traversal_host, traversal_port, traversal_port_alt});
+
+ if (!server->is_connected)
+ {
+ delete server;
+ return 0;
+ }
+
+ const std::string network_mode = Config::Get(Config::NETPLAY_NETWORK_MODE);
+ const bool host_input_authority =
+ network_mode == "hostinputauthority" || network_mode == "golf";
+ server->SetHostInputAuthority(host_input_authority);
+ server->AdjustPadBufferSize(Config::Get(Config::NETPLAY_BUFFER_SIZE));
+
+ return reinterpret_cast(server);
+}
+
+JNIEXPORT void JNICALL
+Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeChangeGame(JNIEnv* env,
+ jobject obj,
+ jobject jgame_file)
+{
+ auto* server = GetServerPointer(env, obj);
+ if (!server)
+ return;
+
+ const auto& game_file = *reinterpret_cast*>(
+ env->GetLongField(jgame_file, IDCache::GetGameFilePointer()));
+
+ server->ChangeGame(game_file->GetSyncIdentifier(), game_file->GetLongName());
+}
+
+JNIEXPORT jboolean JNICALL
+Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeDoAllPlayersHaveGame(
+ JNIEnv* env, jobject obj)
+{
+ if (auto* client = GetClientPointer(env, obj))
+ return static_cast(client->DoAllPlayersHaveGame());
+ return JNI_TRUE;
+}
+
+JNIEXPORT void JNICALL
+Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeStartGame(JNIEnv* env,
+ jobject obj)
+{
+ 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(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(pointer);
+}
+
+JNIEXPORT void JNICALL
+Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseBootSessionData(JNIEnv*,
+ jobject,
+ jlong pointer)
+{
+ delete reinterpret_cast(pointer);
+}
+
+JNIEXPORT void JNICALL
+Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseClient(JNIEnv*, jobject,
+ jlong pointer)
+{
+ delete reinterpret_cast(pointer);
+}
+
+JNIEXPORT void JNICALL
+Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeReleaseServer(JNIEnv*, jobject,
+ jlong pointer)
+{
+ delete reinterpret_cast(pointer);
+}
+
+} // extern "C"
diff --git a/Source/Core/Core/NetPlayCommon.cpp b/Source/Core/Core/NetPlayCommon.cpp
index f1a580cf09..0265d82aaa 100644
--- a/Source/Core/Core/NetPlayCommon.cpp
+++ b/Source/Core/Core/NetPlayCommon.cpp
@@ -9,6 +9,7 @@
#include
#include "Common/FileUtil.h"
+#include "Common/HttpRequest.h"
#include "Common/IOFile.h"
#include "Common/MsgHandler.h"
#include "Common/SFMLHelper.h"
@@ -297,4 +298,18 @@ std::optional> DecompressPacketIntoBuffer(sf::Packet& packet)
return out_buffer;
}
+
+std::string GetExternalIPAddress()
+{
+ Common::HttpRequest request;
+ // ENet does not support IPv6, so IPv4 has to be used
+ request.UseIPv4();
+ Common::HttpRequest::Response response =
+ request.Get("https://ip.dolphin-emu.org/", {{"X-Is-Dolphin", "1"}});
+
+ if (response.has_value())
+ return std::string(response->begin(), response->end());
+ return "";
+}
+
} // namespace NetPlay
diff --git a/Source/Core/Core/NetPlayCommon.h b/Source/Core/Core/NetPlayCommon.h
index 0c95b49c2d..18b8744f06 100644
--- a/Source/Core/Core/NetPlayCommon.h
+++ b/Source/Core/Core/NetPlayCommon.h
@@ -20,6 +20,8 @@ using namespace std::chrono_literals;
// connection is disconnected
constexpr std::chrono::milliseconds PEER_TIMEOUT = 30s;
+std::string GetExternalIPAddress();
+
bool CompressFileIntoPacket(const std::string& file_path, sf::Packet& packet);
bool CompressFolderIntoPacket(const std::string& folder_path, sf::Packet& packet);
bool CompressBufferIntoPacket(std::span in_buffer, sf::Packet& packet);
diff --git a/Source/Core/Core/NetPlayServer.cpp b/Source/Core/Core/NetPlayServer.cpp
index d26cc399c1..2faa5df1fa 100644
--- a/Source/Core/Core/NetPlayServer.cpp
+++ b/Source/Core/Core/NetPlayServer.cpp
@@ -24,7 +24,6 @@
#include "Common/CommonPaths.h"
#include "Common/ENet.h"
#include "Common/FileUtil.h"
-#include "Common/HttpRequest.h"
#include "Common/Logging/Log.h"
#include "Common/MsgHandler.h"
#include "Common/SFMLHelper.h"
@@ -220,16 +219,9 @@ void NetPlayServer::SetupIndex()
}
else
{
- Common::HttpRequest request;
- // ENet does not support IPv6, so IPv4 has to be used
- request.UseIPv4();
- Common::HttpRequest::Response response =
- request.Get("https://ip.dolphin-emu.org/", {{"X-Is-Dolphin", "1"}});
-
- if (!response.has_value())
+ session.server_id = GetExternalIPAddress();
+ if (session.server_id.empty())
return;
-
- session.server_id = std::string(response->begin(), response->end());
}
session.EncryptID(Config::Get(Config::NETPLAY_INDEX_PASSWORD));
diff --git a/Source/Core/DolphinQt/NetPlay/NetPlayDialog.cpp b/Source/Core/DolphinQt/NetPlay/NetPlayDialog.cpp
index 6cadce9203..5282fdb9b9 100644
--- a/Source/Core/DolphinQt/NetPlay/NetPlayDialog.cpp
+++ b/Source/Core/DolphinQt/NetPlay/NetPlayDialog.cpp
@@ -30,7 +30,7 @@
#endif
#include "Common/Config/Config.h"
-#include "Common/HttpRequest.h"
+#include "Core/NetPlayCommon.h"
#include "Common/Logging/Log.h"
#include "Common/TraversalClient.h"
@@ -550,17 +550,8 @@ void NetPlayDialog::show(std::string nickname, bool use_traversal)
void NetPlayDialog::ResetExternalIP()
{
- m_external_ip_address = Common::Lazy([]() -> std::string {
- Common::HttpRequest request;
- // ENet does not support IPv6, so IPv4 has to be used
- request.UseIPv4();
- Common::HttpRequest::Response response =
- request.Get("https://ip.dolphin-emu.org/", {{"X-Is-Dolphin", "1"}});
-
- if (response.has_value())
- return std::string(response->begin(), response->end());
- return "";
- });
+ m_external_ip_address =
+ Common::Lazy([]() -> std::string { return NetPlay::GetExternalIPAddress(); });
}
void NetPlayDialog::UpdateDiscordPresence()