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