aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBriar <205427297+icy-briar@users.noreply.github.com>2025-05-03 17:53:09 +0000
committericy-briar <briar@noreply.localhost>2025-05-03 17:53:09 +0000
commit54c3c4503adc502d7f860350e8daa39514960b5d (patch)
tree76df14613f5e356812912e55f084d07a920d5152
parentd9eea0dc72cd73d9cbeb91e275752fc39fcb536e (diff)
android: Add initial frontend for LAN network rooms (#76)
Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/76 Co-authored-by: Briar <205427297+icy-briar@users.noreply.github.com> Co-committed-by: Briar <205427297+icy-briar@users.noreply.github.com>
-rw-r--r--src/android/app/src/main/AndroidManifest.xml1
-rw-r--r--src/android/app/src/main/java/org/citron/citron_emu/dialogs/ChatDialog.kt137
-rw-r--r--src/android/app/src/main/java/org/citron/citron_emu/dialogs/NetPlayDialog.kt400
-rw-r--r--src/android/app/src/main/java/org/citron/citron_emu/network/NetPlayManager.kt226
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt26
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt16
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt11
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt14
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt11
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CompatUtils.kt22
-rw-r--r--src/android/app/src/main/jni/native.cpp84
-rw-r--r--src/android/app/src/main/res/drawable/ic_chat.xml10
-rw-r--r--src/android/app/src/main/res/drawable/ic_network.xml10
-rw-r--r--src/android/app/src/main/res/drawable/ic_send.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_system.xml10
-rw-r--r--src/android/app/src/main/res/drawable/ic_two_users.xml10
-rw-r--r--src/android/app/src/main/res/drawable/ic_user.xml9
-rw-r--r--src/android/app/src/main/res/layout/dialog_ban_list.xml7
-rw-r--r--src/android/app/src/main/res/layout/dialog_bottom_sheet.xml38
-rw-r--r--src/android/app/src/main/res/layout/dialog_chat.xml55
-rw-r--r--src/android/app/src/main/res/layout/dialog_multiplayer_connect.xml72
-rw-r--r--src/android/app/src/main/res/layout/dialog_multiplayer_lobby.xml75
-rw-r--r--src/android/app/src/main/res/layout/dialog_multiplayer_room.xml119
-rw-r--r--src/android/app/src/main/res/layout/item_ban_list.xml28
-rw-r--r--src/android/app/src/main/res/layout/item_button_netplay.xml25
-rw-r--r--src/android/app/src/main/res/layout/item_chat_message.xml36
-rw-r--r--src/android/app/src/main/res/layout/item_netplay_separator.xml5
-rw-r--r--src/android/app/src/main/res/layout/item_netplay_text.xml18
-rw-r--r--src/android/app/src/main/res/layout/item_separator_netplay.xml7
-rw-r--r--src/android/app/src/main/res/layout/item_text_netplay.xml24
-rw-r--r--src/android/app/src/main/res/menu/menu_in_game.xml10
-rw-r--r--src/android/app/src/main/res/menu/menu_netplay_member.xml13
-rw-r--r--src/android/app/src/main/res/values/strings.xml72
-rw-r--r--src/common/CMakeLists.txt2
-rw-r--r--src/common/android/android_common.cpp12
-rw-r--r--src/common/android/android_common.h5
-rw-r--r--src/common/android/id_cache.cpp24
-rw-r--r--src/common/android/id_cache.h7
-rw-r--r--src/common/android/multiplayer/multiplayer.cpp353
-rw-r--r--src/common/android/multiplayer/multiplayer.h68
-rw-r--r--src/common/announce_multiplayer_room.h4
-rw-r--r--src/dedicated_room/yuzu_room.cpp14
-rw-r--r--src/network/room.cpp19
-rw-r--r--src/network/room.h6
44 files changed, 2105 insertions, 19 deletions
diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml
index 9a792c662f..51a4705f52 100644
--- a/src/android/app/src/main/AndroidManifest.xml
+++ b/src/android/app/src/main/AndroidManifest.xml
@@ -14,6 +14,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+ <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
diff --git a/src/android/app/src/main/java/org/citron/citron_emu/dialogs/ChatDialog.kt b/src/android/app/src/main/java/org/citron/citron_emu/dialogs/ChatDialog.kt
new file mode 100644
index 0000000000..9f7e80e7d0
--- /dev/null
+++ b/src/android/app/src/main/java/org/citron/citron_emu/dialogs/ChatDialog.kt
@@ -0,0 +1,137 @@
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+
+package org.yuzu.yuzu_emu.dialogs
+
+import android.content.Context
+import android.content.res.Configuration
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.databinding.DialogChatBinding
+import org.yuzu.yuzu_emu.databinding.ItemChatMessageBinding
+import org.yuzu.yuzu_emu.network.NetPlayManager
+import java.text.SimpleDateFormat
+import java.util.*
+
+class ChatMessage(
+ val nickname: String, // This is the common name youll see on private servers
+ val username: String, // Username is the community/forum username
+ val message: String,
+ val timestamp: String = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date())
+) {
+}
+
+class ChatDialog(context: Context) : BottomSheetDialog(context) {
+ private lateinit var binding: DialogChatBinding
+ private lateinit var chatAdapter: ChatAdapter
+ private val handler = Handler(Looper.getMainLooper())
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = DialogChatBinding.inflate(LayoutInflater.from(context))
+ setContentView(binding.root)
+
+ NetPlayManager.setChatOpen(true)
+ setupRecyclerView()
+
+ behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ behavior.skipCollapsed = context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+
+
+ handler.post {
+ chatAdapter.notifyDataSetChanged()
+ binding.chatRecyclerView.post {
+ scrollToBottom()
+ }
+ }
+
+ NetPlayManager.setOnMessageReceivedListener { type, message ->
+ handler.post {
+ chatAdapter.notifyDataSetChanged()
+ scrollToBottom()
+ }
+ }
+
+ binding.sendButton.setOnClickListener {
+ val message = binding.chatInput.text.toString()
+ if (message.isNotBlank()) {
+ sendMessage(message)
+ binding.chatInput.text?.clear()
+ }
+ }
+ }
+
+ override fun dismiss() {
+ NetPlayManager.setChatOpen(false)
+ super.dismiss()
+ }
+
+ private fun sendMessage(message: String) {
+ val username = NetPlayManager.getUsername(context)
+ NetPlayManager.netPlaySendMessage(message)
+
+ val chatMessage = ChatMessage(
+ nickname = username,
+ username = "",
+ message = message,
+ timestamp = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date())
+ )
+
+ NetPlayManager.addChatMessage(chatMessage)
+ chatAdapter.notifyDataSetChanged()
+ scrollToBottom()
+ }
+
+ private fun setupRecyclerView() {
+ chatAdapter = ChatAdapter(NetPlayManager.getChatMessages())
+ binding.chatRecyclerView.layoutManager = LinearLayoutManager(context).apply {
+ stackFromEnd = true
+ }
+ binding.chatRecyclerView.adapter = chatAdapter
+ }
+
+ private fun scrollToBottom() {
+ binding.chatRecyclerView.scrollToPosition(chatAdapter.itemCount - 1)
+ }
+}
+
+class ChatAdapter(private val messages: List<ChatMessage>) :
+ RecyclerView.Adapter<ChatAdapter.ChatViewHolder>() {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChatViewHolder {
+ val binding = ItemChatMessageBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ return ChatViewHolder(binding)
+ }
+
+ override fun getItemCount(): Int = messages.size
+
+ override fun onBindViewHolder(holder: ChatViewHolder, position: Int) {
+ holder.bind(messages[position])
+ }
+
+ inner class ChatViewHolder(private val binding: ItemChatMessageBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ fun bind(message: ChatMessage) {
+ binding.usernameText.text = message.nickname
+ binding.messageText.text = message.message
+ binding.userIcon.setImageResource(when (message.nickname) {
+ "System" -> R.drawable.ic_system
+ else -> R.drawable.ic_user
+ })
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citron/citron_emu/dialogs/NetPlayDialog.kt b/src/android/app/src/main/java/org/citron/citron_emu/dialogs/NetPlayDialog.kt
new file mode 100644
index 0000000000..494b8524ff
--- /dev/null
+++ b/src/android/app/src/main/java/org/citron/citron_emu/dialogs/NetPlayDialog.kt
@@ -0,0 +1,400 @@
+// Copyright 2024 Mandarine Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package org.yuzu.yuzu_emu.dialogs
+
+import android.content.Context
+import org.yuzu.yuzu_emu.R
+import android.content.res.Configuration
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.PopupMenu
+import android.widget.Toast
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.databinding.DialogMultiplayerConnectBinding
+import org.yuzu.yuzu_emu.databinding.DialogMultiplayerLobbyBinding
+import org.yuzu.yuzu_emu.databinding.DialogMultiplayerRoomBinding
+import org.yuzu.yuzu_emu.databinding.ItemBanListBinding
+import org.yuzu.yuzu_emu.databinding.ItemButtonNetplayBinding
+import org.yuzu.yuzu_emu.databinding.ItemTextNetplayBinding
+import org.yuzu.yuzu_emu.utils.CompatUtils
+import org.yuzu.yuzu_emu.network.NetPlayManager
+
+class NetPlayDialog(context: Context) : BottomSheetDialog(context) {
+ private lateinit var adapter: NetPlayAdapter
+
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ behavior.skipCollapsed = context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+
+ when {
+ NetPlayManager.netPlayIsJoined() -> DialogMultiplayerLobbyBinding.inflate(layoutInflater)
+ .apply {
+ setContentView(root)
+ adapter = NetPlayAdapter()
+ listMultiplayer.layoutManager = LinearLayoutManager(context)
+ listMultiplayer.adapter = adapter
+ adapter.loadMultiplayerMenu()
+ btnLeave.setOnClickListener {
+ NetPlayManager.netPlayLeaveRoom()
+ dismiss()
+ }
+ btnChat.setOnClickListener {
+ ChatDialog(context).show()
+ }
+
+ refreshAdapterItems()
+
+ btnModeration.visibility = if (NetPlayManager.netPlayIsModerator()) View.VISIBLE else View.GONE
+ btnModeration.setOnClickListener {
+ showModerationDialog()
+ }
+
+ }
+ else -> {
+ DialogMultiplayerConnectBinding.inflate(layoutInflater).apply {
+ setContentView(root)
+ btnCreate.setOnClickListener {
+ showNetPlayInputDialog(true)
+ dismiss()
+ }
+ btnJoin.setOnClickListener {
+ showNetPlayInputDialog(false)
+ dismiss()
+ }
+ }
+ }
+ }
+ }
+
+ data class NetPlayItems(
+ val option: Int,
+ val name: String,
+ val type: Int,
+ val id: Int = 0
+ ) {
+ companion object {
+ const val MULTIPLAYER_ROOM_TEXT = 1
+ const val MULTIPLAYER_ROOM_MEMBER = 2
+ const val MULTIPLAYER_SEPARATOR = 3
+ const val MULTIPLAYER_ROOM_COUNT = 4
+ const val TYPE_BUTTON = 0
+ const val TYPE_TEXT = 1
+ const val TYPE_SEPARATOR = 2
+ }
+ }
+
+ inner class NetPlayAdapter : RecyclerView.Adapter<NetPlayAdapter.NetPlayViewHolder>() {
+ val netPlayItems = mutableListOf<NetPlayItems>()
+
+ abstract inner class NetPlayViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), View.OnClickListener {
+ init {
+ itemView.setOnClickListener(this)
+ }
+ abstract fun bind(item: NetPlayItems)
+ }
+
+ inner class TextViewHolder(private val binding: ItemTextNetplayBinding) : NetPlayViewHolder(binding.root) {
+ private lateinit var netPlayItem: NetPlayItems
+
+ override fun onClick(clicked: View) {}
+
+ override fun bind(item: NetPlayItems) {
+ netPlayItem = item
+ binding.itemTextNetplayName.text = item.name
+ binding.itemIcon.apply {
+ val iconRes = when (item.option) {
+ NetPlayItems.MULTIPLAYER_ROOM_TEXT -> R.drawable.ic_system
+ NetPlayItems.MULTIPLAYER_ROOM_COUNT -> R.drawable.ic_two_users
+ else -> 0
+ }
+ visibility = if (iconRes != 0) {
+ setImageResource(iconRes)
+ View.VISIBLE
+ } else View.GONE
+ }
+ }
+ }
+
+ inner class ButtonViewHolder(private val binding: ItemButtonNetplayBinding) : NetPlayViewHolder(binding.root) {
+ private lateinit var netPlayItems: NetPlayItems
+ private val isModerator = NetPlayManager.netPlayIsModerator()
+
+ init {
+ binding.itemButtonMore.apply {
+ visibility = View.VISIBLE
+ setOnClickListener { showPopupMenu(it) }
+ }
+ }
+
+ override fun onClick(clicked: View) {}
+
+
+ private fun showPopupMenu(view: View) {
+ PopupMenu(view.context, view).apply {
+ menuInflater.inflate(R.menu.menu_netplay_member, menu)
+ menu.findItem(R.id.action_kick).isEnabled = isModerator &&
+ netPlayItems.name != NetPlayManager.getUsername(context)
+ menu.findItem(R.id.action_ban).isEnabled = isModerator &&
+ netPlayItems.name != NetPlayManager.getUsername(context)
+ setOnMenuItemClickListener { item ->
+ if (item.itemId == R.id.action_kick) {
+ NetPlayManager.netPlayKickUser(netPlayItems.name)
+ true
+ } else if (item.itemId == R.id.action_ban) {
+ NetPlayManager.netPlayBanUser(netPlayItems.name)
+ true
+ } else false
+ }
+ show()
+ }
+ }
+
+ override fun bind(item: NetPlayItems) {
+ netPlayItems = item
+ binding.itemButtonNetplayName.text = netPlayItems.name
+ }
+ }
+
+ fun loadMultiplayerMenu() {
+ val infos = NetPlayManager.netPlayRoomInfo()
+ if (infos.isNotEmpty()) {
+ val roomInfo = infos[0].split("|")
+ netPlayItems.add(NetPlayItems(NetPlayItems.MULTIPLAYER_ROOM_TEXT, roomInfo[0], NetPlayItems.TYPE_TEXT))
+ netPlayItems.add(NetPlayItems(NetPlayItems.MULTIPLAYER_ROOM_COUNT, "${infos.size - 1}/${roomInfo[1]}", NetPlayItems.TYPE_TEXT))
+ netPlayItems.add(NetPlayItems(NetPlayItems.MULTIPLAYER_SEPARATOR, "", NetPlayItems.TYPE_SEPARATOR))
+ for (i in 1 until infos.size) {
+ netPlayItems.add(NetPlayItems(NetPlayItems.MULTIPLAYER_ROOM_MEMBER, infos[i], NetPlayItems.TYPE_BUTTON))
+ }
+ }
+ }
+
+ override fun getItemViewType(position: Int) = netPlayItems[position].type
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NetPlayViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ return when (viewType) {
+ NetPlayItems.TYPE_TEXT -> TextViewHolder(ItemTextNetplayBinding.inflate(inflater, parent, false))
+ NetPlayItems.TYPE_BUTTON -> ButtonViewHolder(ItemButtonNetplayBinding.inflate(inflater, parent, false))
+ NetPlayItems.TYPE_SEPARATOR -> object : NetPlayViewHolder(inflater.inflate(R.layout.item_separator_netplay, parent, false)) {
+ override fun bind(item: NetPlayItems) {}
+ override fun onClick(clicked: View) {}
+ }
+ else -> throw IllegalStateException("Unsupported view type")
+ }
+ }
+
+ override fun onBindViewHolder(holder: NetPlayViewHolder, position: Int) {
+ holder.bind(netPlayItems[position])
+ }
+
+ override fun getItemCount() = netPlayItems.size
+ }
+
+ fun refreshAdapterItems() {
+ val handler = Handler(Looper.getMainLooper())
+
+ NetPlayManager.setOnAdapterRefreshListener() { type, msg ->
+ handler.post {
+ adapter.netPlayItems.clear()
+ adapter.loadMultiplayerMenu()
+ adapter.notifyDataSetChanged()
+ }
+ }
+ }
+
+ private fun showNetPlayInputDialog(isCreateRoom: Boolean) {
+ val activity = CompatUtils.findActivity(context)
+ val dialog = BottomSheetDialog(activity)
+
+ dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ dialog.behavior.skipCollapsed = context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+
+
+ val binding = DialogMultiplayerRoomBinding.inflate(LayoutInflater.from(activity))
+ dialog.setContentView(binding.root)
+
+ binding.textTitle.text = activity.getString(
+ if (isCreateRoom) R.string.multiplayer_create_room
+ else R.string.multiplayer_join_room
+ )
+
+ binding.ipAddress.setText(
+ if (isCreateRoom) NetPlayManager.getIpAddressByWifi(activity)
+ else NetPlayManager.getRoomAddress(activity)
+ )
+ binding.ipPort.setText(NetPlayManager.getRoomPort(activity))
+ binding.username.setText(NetPlayManager.getUsername(activity))
+
+ binding.roomName.visibility = if (isCreateRoom) View.VISIBLE else View.GONE
+ binding.maxPlayersContainer.visibility = if (isCreateRoom) View.VISIBLE else View.GONE
+ binding.maxPlayersLabel.text = context.getString(R.string.multiplayer_max_players_value, binding.maxPlayers.value.toInt())
+
+ binding.maxPlayers.addOnChangeListener { _, value, _ ->
+ binding.maxPlayersLabel.text = context.getString(R.string.multiplayer_max_players_value, value.toInt())
+ }
+
+ binding.btnConfirm.setOnClickListener {
+ binding.btnConfirm.isEnabled = false
+ binding.btnConfirm.text = activity.getString(R.string.disabled_button_text)
+
+ val ipAddress = binding.ipAddress.text.toString()
+ val username = binding.username.text.toString()
+ val portStr = binding.ipPort.text.toString()
+ val password = binding.password.text.toString()
+ val port = portStr.toIntOrNull() ?: run {
+ Toast.makeText(activity, R.string.multiplayer_port_invalid, Toast.LENGTH_LONG).show()
+ binding.btnConfirm.isEnabled = true
+ binding.btnConfirm.text = activity.getString(R.string.original_button_text)
+ return@setOnClickListener
+ }
+ val roomName = binding.roomName.text.toString()
+ val maxPlayers = binding.maxPlayers.value.toInt()
+
+ if (isCreateRoom && (roomName.length !in 3..20)) {
+ Toast.makeText(activity, R.string.multiplayer_room_name_invalid, Toast.LENGTH_LONG).show()
+ binding.btnConfirm.isEnabled = true
+ binding.btnConfirm.text = activity.getString(R.string.original_button_text)
+ return@setOnClickListener
+ }
+
+ if (ipAddress.length < 7 || username.length < 5) {
+ Toast.makeText(activity, R.string.multiplayer_input_invalid, Toast.LENGTH_LONG).show()
+ binding.btnConfirm.isEnabled = true
+ binding.btnConfirm.text = activity.getString(R.string.original_button_text)
+ } else {
+ Handler(Looper.getMainLooper()).post {
+ val result = if (isCreateRoom) {
+ NetPlayManager.netPlayCreateRoom(ipAddress, port, username, password, roomName, maxPlayers)
+ } else {
+ NetPlayManager.netPlayJoinRoom(ipAddress, port, username, password)
+ }
+
+ if (result == 0) {
+ NetPlayManager.setUsername(activity, username)
+ NetPlayManager.setRoomPort(activity, portStr)
+ if (!isCreateRoom) NetPlayManager.setRoomAddress(activity, ipAddress)
+ Toast.makeText(
+ YuzuApplication.appContext,
+ if (isCreateRoom) R.string.multiplayer_create_room_success
+ else R.string.multiplayer_join_room_success,
+ Toast.LENGTH_LONG
+ ).show()
+ dialog.dismiss()
+ } else {
+ Toast.makeText(activity, R.string.multiplayer_could_not_connect, Toast.LENGTH_LONG).show()
+ binding.btnConfirm.isEnabled = true
+ binding.btnConfirm.text = activity.getString(R.string.original_button_text)
+ }
+ }
+ }
+ }
+
+ dialog.show()
+ }
+
+ private fun showModerationDialog() {
+ val activity = CompatUtils.findActivity(context)
+ val dialog = MaterialAlertDialogBuilder(activity)
+ dialog.setTitle(R.string.multiplayer_moderation_title)
+
+ val banList = NetPlayManager.getBanList()
+ if (banList.isEmpty()) {
+ dialog.setMessage(R.string.multiplayer_no_bans)
+ dialog.setPositiveButton(R.string.ok, null)
+ dialog.show()
+ return
+ }
+
+ val view = LayoutInflater.from(context).inflate(R.layout.dialog_ban_list, null)
+ val recyclerView = view.findViewById<RecyclerView>(R.id.ban_list_recycler)
+ recyclerView.layoutManager = LinearLayoutManager(context)
+
+ lateinit var adapter: BanListAdapter
+
+ val onUnban: (String) -> Unit = { bannedItem ->
+ MaterialAlertDialogBuilder(activity)
+ .setTitle(R.string.multiplayer_unban_title)
+ .setMessage(activity.getString(R.string.multiplayer_unban_message, bannedItem))
+ .setPositiveButton(R.string.multiplayer_unban) { _, _ ->
+ NetPlayManager.netPlayUnbanUser(bannedItem)
+ adapter.removeBan(bannedItem)
+ }
+ .setNegativeButton(R.string.cancel, null)
+ .show()
+ }
+
+ adapter = BanListAdapter(banList, onUnban)
+ recyclerView.adapter = adapter
+
+ dialog.setView(view)
+ dialog.setPositiveButton(R.string.ok, null)
+ dialog.show()
+ }
+
+ private class BanListAdapter(
+ banList: List<String>,
+ private val onUnban: (String) -> Unit
+ ) : RecyclerView.Adapter<BanListAdapter.ViewHolder>() {
+
+ private val usernameBans = banList.filter { !it.contains(".") }.toMutableList()
+ private val ipBans = banList.filter { it.contains(".") }.toMutableList()
+
+ class ViewHolder(val binding: ItemBanListBinding) : RecyclerView.ViewHolder(binding.root)
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+ val binding = ItemBanListBinding.inflate(
+ LayoutInflater.from(parent.context), parent, false)
+ return ViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ val isUsername = position < usernameBans.size
+ val item = if (isUsername) usernameBans[position] else ipBans[position - usernameBans.size]
+
+ holder.binding.apply {
+ banText.text = item
+ icon.setImageResource(if (isUsername) R.drawable.ic_user else R.drawable.ic_system)
+ btnUnban.setOnClickListener { onUnban(item) }
+ }
+ }
+
+ override fun getItemCount() = usernameBans.size + ipBans.size
+
+ fun removeBan(bannedItem: String) {
+ val position = if (bannedItem.contains(".")) {
+ ipBans.indexOf(bannedItem).let { if (it >= 0) it + usernameBans.size else it }
+ } else {
+ usernameBans.indexOf(bannedItem)
+ }
+
+ if (position >= 0) {
+ if (bannedItem.contains(".")) {
+ ipBans.remove(bannedItem)
+ } else {
+ usernameBans.remove(bannedItem)
+ }
+ notifyItemRemoved(position)
+ }
+ }
+
+ }
+} \ No newline at end of file
diff --git a/src/android/app/src/main/java/org/citron/citron_emu/network/NetPlayManager.kt b/src/android/app/src/main/java/org/citron/citron_emu/network/NetPlayManager.kt
new file mode 100644
index 0000000000..1570f04468
--- /dev/null
+++ b/src/android/app/src/main/java/org/citron/citron_emu/network/NetPlayManager.kt
@@ -0,0 +1,226 @@
+// Copyright 2024 Mandarine Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+
+package org.yuzu.yuzu_emu.network
+
+import android.app.Activity
+import android.content.Context
+import android.net.wifi.WifiManager
+import android.os.Handler
+import android.os.Looper
+import android.text.format.Formatter
+import android.widget.Toast
+import androidx.preference.PreferenceManager
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.dialogs.ChatMessage
+
+object NetPlayManager {
+ external fun netPlayCreateRoom(ipAddress: String, port: Int, username: String, password: String, roomName: String, maxPlayers: Int): Int
+ external fun netPlayJoinRoom(ipAddress: String, port: Int, username: String, password: String): Int
+ external fun netPlayRoomInfo(): Array<String>
+ external fun netPlayIsJoined(): Boolean
+ external fun netPlayIsHostedRoom(): Boolean
+ external fun netPlaySendMessage(msg: String)
+ external fun netPlayKickUser(username: String)
+ external fun netPlayLeaveRoom()
+ external fun netPlayIsModerator(): Boolean
+ external fun netPlayGetBanList(): Array<String>
+ external fun netPlayBanUser(username: String)
+ external fun netPlayUnbanUser(username: String)
+
+ private var messageListener: ((Int, String) -> Unit)? = null
+ private var adapterRefreshListener: ((Int, String) -> Unit)? = null
+
+ fun setOnMessageReceivedListener(listener: (Int, String) -> Unit) {
+ messageListener = listener
+ }
+
+ fun setOnAdapterRefreshListener(listener: (Int, String) -> Unit) {
+ adapterRefreshListener = listener
+ }
+
+ fun getUsername(activity: Context): String { val prefs = PreferenceManager.getDefaultSharedPreferences(activity)
+ val name = "Eden${(Math.random() * 100).toInt()}"
+ return prefs.getString("NetPlayUsername", name) ?: name
+ }
+
+ fun setUsername(activity: Activity, name: String) {
+ val prefs = PreferenceManager.getDefaultSharedPreferences(activity)
+ prefs.edit().putString("NetPlayUsername", name).apply()
+ }
+
+ fun getRoomAddress(activity: Activity): String {
+ val prefs = PreferenceManager.getDefaultSharedPreferences(activity)
+ val address = getIpAddressByWifi(activity)
+ return prefs.getString("NetPlayRoomAddress", address) ?: address
+ }
+
+ fun setRoomAddress(activity: Activity, address: String) {
+ val prefs = PreferenceManager.getDefaultSharedPreferences(activity)
+ prefs.edit().putString("NetPlayRoomAddress", address).apply()
+ }
+
+ fun getRoomPort(activity: Activity): String {
+ val prefs = PreferenceManager.getDefaultSharedPreferences(activity)
+ return prefs.getString("NetPlayRoomPort", "24872") ?: "24872"
+ }
+
+ fun setRoomPort(activity: Activity, port: String) {
+ val prefs = PreferenceManager.getDefaultSharedPreferences(activity)
+ prefs.edit().putString("NetPlayRoomPort", port).apply()
+ }
+
+ private val chatMessages = mutableListOf<ChatMessage>()
+ private var isChatOpen = false
+
+ fun addChatMessage(message: ChatMessage) {
+ chatMessages.add(message)
+ }
+
+ fun getChatMessages(): List<ChatMessage> = chatMessages
+
+ fun clearChat() {
+ chatMessages.clear()
+ }
+
+ fun setChatOpen(isOpen: Boolean) {
+ isChatOpen = isOpen
+ }
+
+ fun addNetPlayMessage(type: Int, msg: String) {
+ val context = YuzuApplication.appContext
+ val message = formatNetPlayStatus(context, type, msg)
+
+ when (type) {
+ NetPlayStatus.CHAT_MESSAGE -> {
+ val parts = msg.split(":", limit = 2)
+ if (parts.size == 2) {
+ val nickname = parts[0].trim()
+ val chatMessage = parts[1].trim()
+ addChatMessage(ChatMessage(
+ nickname = nickname,
+ username = "",
+ message = chatMessage
+ ))
+ }
+ }
+ NetPlayStatus.MEMBER_JOIN,
+ NetPlayStatus.MEMBER_LEAVE,
+ NetPlayStatus.MEMBER_KICKED,
+ NetPlayStatus.MEMBER_BANNED -> {
+ addChatMessage(ChatMessage(
+ nickname = "System",
+ username = "",
+ message = message
+ ))
+ }
+ }
+
+
+ Handler(Looper.getMainLooper()).post {
+ if (!isChatOpen) {
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+
+ messageListener?.invoke(type, msg)
+ adapterRefreshListener?.invoke(type, msg)
+ }
+
+ private fun formatNetPlayStatus(context: Context, type: Int, msg: String): String {
+ return when (type) {
+ NetPlayStatus.NETWORK_ERROR -> context.getString(R.string.multiplayer_network_error)
+ NetPlayStatus.LOST_CONNECTION -> context.getString(R.string.multiplayer_lost_connection)
+ NetPlayStatus.NAME_COLLISION -> context.getString(R.string.multiplayer_name_collision)
+ NetPlayStatus.MAC_COLLISION -> context.getString(R.string.multiplayer_mac_collision)
+ NetPlayStatus.CONSOLE_ID_COLLISION -> context.getString(R.string.multiplayer_console_id_collision)
+ NetPlayStatus.WRONG_VERSION -> context.getString(R.string.multiplayer_wrong_version)
+ NetPlayStatus.WRONG_PASSWORD -> context.getString(R.string.multiplayer_wrong_password)
+ NetPlayStatus.COULD_NOT_CONNECT -> context.getString(R.string.multiplayer_could_not_connect)
+ NetPlayStatus.ROOM_IS_FULL -> context.getString(R.string.multiplayer_room_is_full)
+ NetPlayStatus.HOST_BANNED -> context.getString(R.string.multiplayer_host_banned)
+ NetPlayStatus.PERMISSION_DENIED -> context.getString(R.string.multiplayer_permission_denied)
+ NetPlayStatus.NO_SUCH_USER -> context.getString(R.string.multiplayer_no_such_user)
+ NetPlayStatus.ALREADY_IN_ROOM -> context.getString(R.string.multiplayer_already_in_room)
+ NetPlayStatus.CREATE_ROOM_ERROR -> context.getString(R.string.multiplayer_create_room_error)
+ NetPlayStatus.HOST_KICKED -> context.getString(R.string.multiplayer_host_kicked)
+ NetPlayStatus.UNKNOWN_ERROR -> context.getString(R.string.multiplayer_unknown_error)
+ NetPlayStatus.ROOM_UNINITIALIZED -> context.getString(R.string.multiplayer_room_uninitialized)
+ NetPlayStatus.ROOM_IDLE -> context.getString(R.string.multiplayer_room_idle)
+ NetPlayStatus.ROOM_JOINING -> context.getString(R.string.multiplayer_room_joining)
+ NetPlayStatus.ROOM_JOINED -> context.getString(R.string.multiplayer_room_joined)
+ NetPlayStatus.ROOM_MODERATOR -> context.getString(R.string.multiplayer_room_moderator)
+ NetPlayStatus.MEMBER_JOIN -> context.getString(R.string.multiplayer_member_join, msg)
+ NetPlayStatus.MEMBER_LEAVE -> context.getString(R.string.multiplayer_member_leave, msg)
+ NetPlayStatus.MEMBER_KICKED -> context.getString(R.string.multiplayer_member_kicked, msg)
+ NetPlayStatus.MEMBER_BANNED -> context.getString(R.string.multiplayer_member_banned, msg)
+ NetPlayStatus.ADDRESS_UNBANNED -> context.getString(R.string.multiplayer_address_unbanned)
+ NetPlayStatus.CHAT_MESSAGE -> msg
+ else -> ""
+ }
+ }
+
+ fun getIpAddressByWifi(activity: Activity): String {
+ var ipAddress = 0
+ val wifiManager = activity.getSystemService(WifiManager::class.java)
+ val wifiInfo = wifiManager.connectionInfo
+ if (wifiInfo != null) {
+ ipAddress = wifiInfo.ipAddress
+ }
+
+ if (ipAddress == 0) {
+ val dhcpInfo = wifiManager.dhcpInfo
+ if (dhcpInfo != null) {
+ ipAddress = dhcpInfo.ipAddress
+ }
+ }
+
+ return if (ipAddress == 0) {
+ "192.168.0.1"
+ } else {
+ Formatter.formatIpAddress(ipAddress)
+ }
+ }
+
+ fun getBanList(): List<String> {
+ return netPlayGetBanList().toList()
+ }
+
+ object NetPlayStatus {
+ const val NO_ERROR = 0
+ const val NETWORK_ERROR = 1
+ const val LOST_CONNECTION = 2
+ const val NAME_COLLISION = 3
+ const val MAC_COLLISION = 4
+ const val CONSOLE_ID_COLLISION = 5
+ const val WRONG_VERSION = 6
+ const val WRONG_PASSWORD = 7
+ const val COULD_NOT_CONNECT = 8
+ const val ROOM_IS_FULL = 9
+ const val HOST_BANNED = 10
+ const val PERMISSION_DENIED = 11
+ const val NO_SUCH_USER = 12
+ const val ALREADY_IN_ROOM = 13
+ const val CREATE_ROOM_ERROR = 14
+ const val HOST_KICKED = 15
+ const val UNKNOWN_ERROR = 16
+ const val ROOM_UNINITIALIZED = 17
+ const val ROOM_IDLE = 18
+ const val ROOM_JOINING = 19
+ const val ROOM_JOINED = 20
+ const val ROOM_MODERATOR = 21
+ const val MEMBER_JOIN = 22
+ const val MEMBER_LEAVE = 23
+ const val MEMBER_KICKED = 24
+ const val MEMBER_BANNED = 25
+ const val ADDRESS_UNBANNED = 26
+ const val CHAT_MESSAGE = 27
+ }
+} \ No newline at end of file
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
index 02a20dacf7..1848ca9ef9 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
@@ -1,6 +1,10 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+
package org.yuzu.yuzu_emu
import android.content.DialogInterface
@@ -21,6 +25,7 @@ import org.yuzu.yuzu_emu.utils.Log
import org.yuzu.yuzu_emu.model.InstallResult
import org.yuzu.yuzu_emu.model.Patch
import org.yuzu.yuzu_emu.model.GameVerificationResult
+import org.yuzu.yuzu_emu.network.NetPlayManager
/**
* Class which contains methods that interact
@@ -244,6 +249,27 @@ object NativeLibrary {
@Keep
@JvmStatic
+ fun addNetPlayMessage(type: Int, message: String) {
+ val emulationActivity = sEmulationActivity.get()
+ if (emulationActivity != null) {
+ emulationActivity.addNetPlayMessages(type, message)
+ }
+ else {
+ NetPlayManager.addNetPlayMessage(type, message)
+ }
+ }
+
+ @Keep
+ @JvmStatic
+ fun clearChat() {
+ NetPlayManager.clearChat()
+ }
+
+
+ external fun netPlayInit()
+
+ @Keep
+ @JvmStatic
fun exitEmulationActivity(resultCode: Int) {
val Success = 0
val ErrorNotInitialized = 1
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt
index c962558a77..496fc99e4c 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt
@@ -1,6 +1,10 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+
package org.yuzu.yuzu_emu.activities
import android.annotation.SuppressLint
@@ -39,12 +43,14 @@ import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.databinding.ActivityEmulationBinding
+import org.yuzu.yuzu_emu.dialogs.NetPlayDialog
import org.yuzu.yuzu_emu.features.input.NativeInput
import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting
import org.yuzu.yuzu_emu.features.settings.model.IntSetting
import org.yuzu.yuzu_emu.features.settings.model.Settings
import org.yuzu.yuzu_emu.model.EmulationViewModel
import org.yuzu.yuzu_emu.model.Game
+import org.yuzu.yuzu_emu.network.NetPlayManager
import org.yuzu.yuzu_emu.utils.InputHandler
import org.yuzu.yuzu_emu.utils.Log
import org.yuzu.yuzu_emu.utils.MemoryUtil
@@ -405,6 +411,16 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener {
setPictureInPictureParams(pictureInPictureParamsBuilder.build())
}
+ fun displayMultiplayerDialog() {
+ val dialog = NetPlayDialog(this)
+ dialog.show()
+ }
+
+ fun addNetPlayMessages(type: Int, msg: String) {
+ NetPlayManager.addNetPlayMessage(type, msg)
+ }
+
+
private var pictureInPictureReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent) {
if (intent.action == actionPlay) {
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
index 4e51ae4902..219aae8c5c 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
@@ -1,6 +1,10 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+
package org.yuzu.yuzu_emu.fragments
import android.annotation.SuppressLint
@@ -279,6 +283,13 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
true
}
+
+ R.id.menu_multiplayer -> {
+ emulationActivity?.displayMultiplayerDialog()
+ true
+ }
+
+
R.id.menu_controls -> {
val action = HomeNavigationDirections.actionGlobalSettingsActivity(
null,
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
index 463caea421..2a0ed2f639 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
@@ -1,6 +1,10 @@
// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+
package org.yuzu.yuzu_emu.fragments
import android.Manifest
@@ -120,6 +124,16 @@ class HomeSettingsFragment : Fragment() {
)
add(
HomeSetting(
+ R.string.multiplayer,
+ R.string.multiplayer_description,
+ R.drawable.ic_two_users,
+ {
+ val action = mainActivity.displayMultiplayerDialog()
+ },
+ )
+ )
+ add(
+ HomeSetting(
R.string.applets,
R.string.applets_description,
R.drawable.ic_applet,
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
index f1f05e6b20..79780fa1f0 100644
--- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
@@ -1,6 +1,10 @@
// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+
package org.yuzu.yuzu_emu.ui.main
import android.content.Intent
@@ -31,6 +35,7 @@ import org.yuzu.yuzu_emu.HomeNavigationDirections
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.ActivityMainBinding
+import org.yuzu.yuzu_emu.dialogs.NetPlayDialog
import org.yuzu.yuzu_emu.features.settings.model.Settings
import org.yuzu.yuzu_emu.fragments.AddGameFolderDialogFragment
import org.yuzu.yuzu_emu.fragments.ProgressDialogFragment
@@ -70,6 +75,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
ThemeHelper.ThemeChangeListener(this)
ThemeHelper.setTheme(this)
+ NativeLibrary.netPlayInit()
super.onCreate(savedInstanceState)
@@ -162,7 +168,10 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
}
}
-
+ fun displayMultiplayerDialog() {
+ val dialog = NetPlayDialog(this)
+ dialog.show()
+ }
private fun checkKeys() {
if (!NativeLibrary.areKeysPresent()) {
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CompatUtils.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CompatUtils.kt
new file mode 100644
index 0000000000..6467f7c931
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CompatUtils.kt
@@ -0,0 +1,22 @@
+// Copyright 2024 Mandarine Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+
+object CompatUtils {
+ fun findActivity(context: Context): Activity {
+ return when (context) {
+ is Activity -> context
+ is ContextWrapper -> findActivity(context.baseContext)
+ else -> throw IllegalArgumentException("Context is not an Activity")
+ }
+ }
+}
diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp
index 591c574ff9..5d852fe92b 100644
--- a/src/android/app/src/main/jni/native.cpp
+++ b/src/android/app/src/main/jni/native.cpp
@@ -1,6 +1,10 @@
// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+
#include <codecvt>
#include <locale>
#include <string>
@@ -20,6 +24,7 @@
#include <frontend_common/content_manager.h>
#include <jni.h>
+#include "common/android/multiplayer/multiplayer.h"
#include "common/android/android_common.h"
#include "common/android/id_cache.h"
#include "common/detached_tasks.h"
@@ -870,4 +875,83 @@ jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_areKeysPresent(JNIEnv* env, jobje
return ContentManager::AreKeysPresent();
}
+JNIEXPORT jint JNICALL Java_org_yuzu_yuzu_1emu_network_NetPlayManager_netPlayCreateRoom(
+ JNIEnv* env, [[maybe_unused]] jobject obj, jstring ipaddress, jint port,
+ jstring username, jstring password, jstring room_name, jint max_players) {
+ return static_cast<jint>(
+ NetPlayCreateRoom(Common::Android::GetJString(env, ipaddress), port,
+ Common::Android::GetJString(env, username), Common::Android::GetJString(env, password),
+ Common::Android::GetJString(env, room_name), max_players));
+}
+
+JNIEXPORT jint JNICALL Java_org_yuzu_yuzu_1emu_network_NetPlayManager_netPlayJoinRoom(
+ JNIEnv* env, [[maybe_unused]] jobject obj, jstring ipaddress, jint port,
+ jstring username, jstring password) {
+ return static_cast<jint>(
+ NetPlayJoinRoom(Common::Android::GetJString(env, ipaddress), port,
+ Common::Android::GetJString(env, username), Common::Android::GetJString(env, password)));
+}
+
+JNIEXPORT jobjectArray JNICALL
+Java_org_yuzu_yuzu_1emu_network_NetPlayManager_netPlayRoomInfo(
+ JNIEnv* env, [[maybe_unused]] jobject obj) {
+ return Common::Android::ToJStringArray(env, NetPlayRoomInfo());
+}
+
+JNIEXPORT jboolean JNICALL
+Java_org_yuzu_yuzu_1emu_network_NetPlayManager_netPlayIsJoined(
+ [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj) {
+ return NetPlayIsJoined();
+}
+
+JNIEXPORT jboolean JNICALL
+Java_org_yuzu_yuzu_1emu_network_NetPlayManager_netPlayIsHostedRoom(
+ [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj) {
+ return NetPlayIsHostedRoom();
+}
+
+JNIEXPORT void JNICALL
+Java_org_yuzu_yuzu_1emu_network_NetPlayManager_netPlaySendMessage(
+ JNIEnv* env, [[maybe_unused]] jobject obj, jstring msg) {
+ NetPlaySendMessage(Common::Android::GetJString(env, msg));
+}
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_network_NetPlayManager_netPlayKickUser(
+ JNIEnv* env, [[maybe_unused]] jobject obj, jstring username) {
+ NetPlayKickUser(Common::Android::GetJString(env, username));
+}
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_network_NetPlayManager_netPlayLeaveRoom(
+ [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj) {
+ NetPlayLeaveRoom();
+}
+
+JNIEXPORT jboolean JNICALL
+Java_org_yuzu_yuzu_1emu_network_NetPlayManager_netPlayIsModerator(
+ [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj) {
+ return NetPlayIsModerator();
+}
+
+JNIEXPORT jobjectArray JNICALL
+Java_org_yuzu_yuzu_1emu_network_NetPlayManager_netPlayGetBanList(
+ JNIEnv* env, [[maybe_unused]] jobject obj) {
+ return Common::Android::ToJStringArray(env, NetPlayGetBanList());
+}
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_network_NetPlayManager_netPlayBanUser(
+ JNIEnv* env, [[maybe_unused]] jobject obj, jstring username) {
+ NetPlayBanUser(Common::Android::GetJString(env, username));
+}
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_network_NetPlayManager_netPlayUnbanUser(
+ JNIEnv* env, [[maybe_unused]] jobject obj, jstring username) {
+ NetPlayUnbanUser(Common::Android::GetJString(env, username));
+}
+
+JNIEXPORT void JNICALL
+Java_org_yuzu_yuzu_1emu_NativeLibrary_netPlayInit(
+ JNIEnv* env, [[maybe_unused]] jobject obj) {
+ NetworkInit(&EmulationSession::GetInstance().System().GetRoomNetwork());
+}
+
} // extern "C"
diff --git a/src/android/app/src/main/res/drawable/ic_chat.xml b/src/android/app/src/main/res/drawable/ic_chat.xml
new file mode 100644
index 0000000000..e0efa062b8
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_chat.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM18,14L6,14v-2h12v2zM18,11L6,11L6,9h12v2zM18,8L6,8L6,6h12v2z"/>
+</vector> \ No newline at end of file
diff --git a/src/android/app/src/main/res/drawable/ic_network.xml b/src/android/app/src/main/res/drawable/ic_network.xml
new file mode 100644
index 0000000000..eef8a0b43d
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_network.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorPrimary"
+ android:pathData="M1,9l2,2c4.97,-4.97 13.03,-4.97 18,0l2,-2C16.93,2.93 7.08,2.93 1,9zM9,17l3,3 3,-3c-1.65,-1.66 -4.34,-1.66 -6,0zM5,13l2,2c2.76,-2.76 7.24,-2.76 10,0l2,-2C15.14,9.14 8.87,9.14 5,13z"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_send.xml b/src/android/app/src/main/res/drawable/ic_send.xml
new file mode 100644
index 0000000000..fa20740577
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_send.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M2.01,21L23,12 2.01,3 2,10l15,2 -15,2z"/>
+</vector> \ No newline at end of file
diff --git a/src/android/app/src/main/res/drawable/ic_system.xml b/src/android/app/src/main/res/drawable/ic_system.xml
new file mode 100644
index 0000000000..63fd22d7d6
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_system.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M21,3L3,3c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h5v2h8v-2h5c1.1,0 1.99,-0.9 1.99,-2L23,5c0,-1.1 -0.9,-2 -2,-2zM21,17L3,17L3,5h18v12z"/>
+</vector> \ No newline at end of file
diff --git a/src/android/app/src/main/res/drawable/ic_two_users.xml b/src/android/app/src/main/res/drawable/ic_two_users.xml
new file mode 100644
index 0000000000..c86e96da44
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_two_users.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M16,11c1.66,0 2.99,-1.34 2.99,-3S17.66,5 16,5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3zM8,11c1.66,0 2.99,-1.34 2.99,-3S9.66,5 8,5C6.34,5 5,6.34 5,8s1.34,3 3,3zM8,13c-2.33,0 -7,1.17 -7,3.5L1,19h14v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5zM16,13c-0.29,0 -0.62,0.02 -0.97,0.05 1.16,0.84 1.97,1.97 1.97,3.45L17,19h6v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5z"/>
+</vector> \ No newline at end of file
diff --git a/src/android/app/src/main/res/drawable/ic_user.xml b/src/android/app/src/main/res/drawable/ic_user.xml
new file mode 100644
index 0000000000..606e966ca9
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_user.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
+</vector>
diff --git a/src/android/app/src/main/res/layout/dialog_ban_list.xml b/src/android/app/src/main/res/layout/dialog_ban_list.xml
new file mode 100644
index 0000000000..eb40827178
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_ban_list.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/ban_list_recycler"
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:padding="8dp"/> \ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/dialog_bottom_sheet.xml b/src/android/app/src/main/res/layout/dialog_bottom_sheet.xml
new file mode 100644
index 0000000000..6dd10d97ba
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_bottom_sheet.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?>
+<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:orientation="vertical"
+ android:gravity="center"
+ app:strokeWidth="0dp"
+ app:cardCornerRadius="24dp">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:background="?colorSurface">
+
+ <View
+ android:layout_width="128dp"
+ android:layout_height="4dp"
+ android:layout_marginVertical="8dp"
+ android:backgroundTint="?colorSurfaceVariant" />
+
+ <androidx.core.widget.NestedScrollView
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <LinearLayout
+ android:id="@+id/content"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical" />
+
+ </androidx.core.widget.NestedScrollView>
+
+ </LinearLayout>
+
+</com.google.android.material.card.MaterialCardView>
diff --git a/src/android/app/src/main/res/layout/dialog_chat.xml b/src/android/app/src/main/res/layout/dialog_chat.xml
new file mode 100644
index 0000000000..d62ef0802c
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_chat.xml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:orientation="vertical"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:padding="16dp">
+
+ <TextView
+ android:id="@+id/text_title"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/chat"
+ android:textAppearance="?attr/textAppearanceHeadline6"
+ android:gravity="center"
+ android:layout_marginBottom="16dp" />
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/chat_recycler_view"
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1"
+ android:layout_marginBottom="16dp"
+ android:transcriptMode="alwaysScroll" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal">
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:hint="@string/type_message">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/chat_input"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:inputType="text"
+ android:imeOptions="actionSend" />
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <ImageButton
+ android:id="@+id/send_button"
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:layout_gravity="bottom"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:src="@drawable/ic_send"
+ android:contentDescription="@string/send_message" />
+ </LinearLayout>
+</LinearLayout> \ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/dialog_multiplayer_connect.xml b/src/android/app/src/main/res/layout/dialog_multiplayer_connect.xml
new file mode 100644
index 0000000000..36a77d3951
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_multiplayer_connect.xml
@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:orientation="vertical"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <com.google.android.material.bottomsheet.BottomSheetDragHandleView
+ android:id="@+id/drag_handle"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:id="@+id/text_title"
+ android:text="@string/multiplayer"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAppearance="?attr/textAppearanceHeadline6"
+ android:gravity="center"
+ android:layout_marginTop="4dp"
+ android:textColor="?attr/colorOnSurface" />
+
+ <ImageView
+ android:layout_width="140dp"
+ android:layout_height="140dp"
+ android:layout_gravity="center"
+ android:layout_marginTop="16dp"
+ android:layout_marginBottom="24dp"
+ android:src="@drawable/ic_network"
+ app:tint="?attr/colorPrimary" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:layout_marginHorizontal="16dp"
+ android:layout_marginBottom="8dp">
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_join"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="@string/multiplayer_join_room"
+ app:icon="@drawable/ic_install"
+ app:cornerRadius="16dp" />
+
+ <Space
+ android:layout_width="16dp"
+ android:layout_height="match_parent" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_create"
+ style="@style/Widget.Material3.Button"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="@string/multiplayer_create_room"
+ app:icon="@drawable/ic_add"
+ app:cornerRadius="16dp" />
+
+ </LinearLayout>
+
+ </LinearLayout>
+</LinearLayout> \ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/dialog_multiplayer_lobby.xml b/src/android/app/src/main/res/layout/dialog_multiplayer_lobby.xml
new file mode 100644
index 0000000000..19368bc2cc
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_multiplayer_lobby.xml
@@ -0,0 +1,75 @@
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <LinearLayout
+ android:orientation="vertical"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <com.google.android.material.bottomsheet.BottomSheetDragHandleView
+ android:id="@+id/drag_handle"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:id="@+id/text_title"
+ android:text="@string/multiplayer"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAppearance="?attr/textAppearanceHeadline6"
+ android:gravity="center"
+ android:layout_marginTop="4dp"
+ android:textColor="?attr/colorOnSurface" />
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/list_multiplayer"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ android:layout_marginBottom="8dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_chat"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="16dp"
+ android:layout_marginBottom="8dp"
+ android:enabled="true"
+ android:text="@string/multiplayer_chat"
+ app:icon="@drawable/ic_chat"
+ app:cornerRadius="16dp" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_moderation"
+ style="@style/Widget.Material3.Button.TonalButton"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="16dp"
+ android:layout_marginBottom="8dp"
+ android:enabled="true"
+ android:text="@string/multiplayer_moderation"
+ app:cornerRadius="16dp"
+ app:icon="@drawable/ic_user" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_leave"
+ style="@style/Widget.Material3.Button"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="16dp"
+ android:text="@string/multiplayer_exit_room"
+ app:icon="@drawable/ic_exit"
+ app:cornerRadius="16dp" />
+
+ </LinearLayout>
+ </LinearLayout>
+</ScrollView> \ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/dialog_multiplayer_room.xml b/src/android/app/src/main/res/layout/dialog_multiplayer_room.xml
new file mode 100644
index 0000000000..53afda9319
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_multiplayer_room.xml
@@ -0,0 +1,119 @@
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <LinearLayout
+ android:orientation="vertical"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:padding="16dp"
+ android:clipToPadding="false"
+ android:clipChildren="false"
+ android:elevation="4dp">
+
+ <TextView
+ android:id="@+id/textTitle"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAppearance="?attr/textAppearanceHeadline6"
+ android:gravity="center"
+ android:paddingBottom="8dp"
+ android:textColor="?attr/colorOnSurface" />
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:hint="@string/multiplayer_ip_address"
+ android:padding="8dp">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/ip_address"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:inputType="text" />
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:hint="@string/multiplayer_ip_port"
+ android:padding="8dp">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/ip_port"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:inputType="number" />
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:hint="@string/multiplayer_username"
+ android:padding="8dp">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/username"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:inputType="text" />
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:hint="@string/multiplayer_password"
+ android:padding="8dp">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/password"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:inputType="textPassword" />
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:hint="@string/multiplayer_room_name"
+ android:padding="8dp">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/room_name"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:inputType="text" />
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <LinearLayout
+ android:id="@+id/max_players_container"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <com.google.android.material.slider.Slider
+ android:id="@+id/max_players"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_margin="8dp"
+ android:value="8"
+ android:valueFrom="2"
+ android:valueTo="16"
+ android:stepSize="1" />
+
+ <TextView
+ android:id="@+id/max_players_label"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:text="@string/multiplayer_max_players_value" />
+ </LinearLayout>
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_confirm"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@android:string/ok"
+ android:layout_gravity="center" />
+ </LinearLayout>
+</ScrollView> \ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/item_ban_list.xml b/src/android/app/src/main/res/layout/item_ban_list.xml
new file mode 100644
index 0000000000..32a1012773
--- /dev/null
+++ b/src/android/app/src/main/res/layout/item_ban_list.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:padding="16dp"
+ android:gravity="center_vertical">
+
+ <ImageView
+ android:id="@+id/icon"
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:src="@drawable/ic_user"
+ android:layout_marginEnd="16dp"/>
+
+ <TextView
+ android:id="@+id/ban_text"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"/>
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/btn_unban"
+ style="@style/Widget.Material3.Button.TextButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/multiplayer_unban"/>
+</LinearLayout> \ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/item_button_netplay.xml b/src/android/app/src/main/res/layout/item_button_netplay.xml
new file mode 100644
index 0000000000..494cc88786
--- /dev/null
+++ b/src/android/app/src/main/res/layout/item_button_netplay.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:padding="8dp">
+
+ <TextView
+ android:id="@+id/item_button_netplay_name"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:textAppearance="?attr/textAppearanceBodyLarge" />
+
+ <ImageButton
+ android:id="@+id/item_button_more"
+ android:layout_width="48dp"
+ android:layout_height="48dp"
+ android:background="?attr/selectableItemBackgroundBorderless"
+ android:contentDescription="@string/multiplayer_more_options"
+ android:src="@drawable/ic_more_vert"
+ android:padding="12dp" />
+
+</LinearLayout>
diff --git a/src/android/app/src/main/res/layout/item_chat_message.xml b/src/android/app/src/main/res/layout/item_chat_message.xml
new file mode 100644
index 0000000000..f4ce137e7f
--- /dev/null
+++ b/src/android/app/src/main/res/layout/item_chat_message.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:padding="8dp">
+
+ <ImageView
+ android:id="@+id/user_icon"
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_marginEnd="8dp" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <TextView
+ android:id="@+id/username_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textStyle="bold" />
+
+ <TextView
+ android:id="@+id/message_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content" />
+
+ <TextView
+ android:id="@+id/timestamp_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content" />
+
+ </LinearLayout>
+</LinearLayout> \ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/item_netplay_separator.xml b/src/android/app/src/main/res/layout/item_netplay_separator.xml
new file mode 100644
index 0000000000..38def7eed8
--- /dev/null
+++ b/src/android/app/src/main/res/layout/item_netplay_separator.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<View xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:background="?android:attr/listDivider"/> \ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/item_netplay_text.xml b/src/android/app/src/main/res/layout/item_netplay_text.xml
new file mode 100644
index 0000000000..ed4be66e7b
--- /dev/null
+++ b/src/android/app/src/main/res/layout/item_netplay_text.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:padding="16dp">
+
+ <ImageView
+ android:id="@+id/item_icon"
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_marginEnd="16dp"/>
+
+ <TextView
+ android:id="@+id/item_text_netplay_name"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"/>
+</LinearLayout> \ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/item_separator_netplay.xml b/src/android/app/src/main/res/layout/item_separator_netplay.xml
new file mode 100644
index 0000000000..99eb7d01ac
--- /dev/null
+++ b/src/android/app/src/main/res/layout/item_separator_netplay.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<com.google.android.material.divider.MaterialDivider
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="16dp"
+ android:layout_marginVertical="8dp" /> \ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/item_text_netplay.xml b/src/android/app/src/main/res/layout/item_text_netplay.xml
new file mode 100644
index 0000000000..f8039d8264
--- /dev/null
+++ b/src/android/app/src/main/res/layout/item_text_netplay.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:padding="12dp"
+ android:gravity="center_vertical">
+
+ <ImageView
+ android:id="@+id/item_icon"
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_marginEnd="16dp"
+ app:tint="?attr/colorPrimary" />
+
+ <TextView
+ android:id="@+id/item_text_netplay_name"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:textAppearance="?attr/textAppearanceBodyLarge" />
+
+</LinearLayout>
diff --git a/src/android/app/src/main/res/menu/menu_in_game.xml b/src/android/app/src/main/res/menu/menu_in_game.xml
index 867197ebc0..ce3f11f6e6 100644
--- a/src/android/app/src/main/res/menu/menu_in_game.xml
+++ b/src/android/app/src/main/res/menu/menu_in_game.xml
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
-<menu xmlns:android="http://schemas.android.com/apk/res/android">
+<menu xmlns:tools="http://schemas.android.com/tools"
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ tools:ignore="ExtraText">
<item
android:id="@+id/menu_pause_emulation"
@@ -22,6 +24,12 @@
android:title="@string/preferences_controls" />
<item
+ android:id="@+id/menu_multiplayer"
+ android:icon="@drawable/ic_two_users"
+ android:title="@string/multiplayer" />
+
+
+ <item
android:id="@+id/menu_overlay_controls"
android:icon="@drawable/ic_overlay"
android:title="@string/emulation_input_overlay" />
diff --git a/src/android/app/src/main/res/menu/menu_netplay_member.xml b/src/android/app/src/main/res/menu/menu_netplay_member.xml
new file mode 100644
index 0000000000..a6b542d904
--- /dev/null
+++ b/src/android/app/src/main/res/menu/menu_netplay_member.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+ <item
+ android:id="@+id/action_kick"
+ android:title="@string/multiplayer_kick_member"
+ android:enabled="false" />
+
+ <item
+ android:id="@+id/action_ban"
+ android:title="@string/multiplayer_ban"
+ android:enabled="false" />
+
+</menu> \ No newline at end of file
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index 2987ea91fc..6705660f9d 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -469,6 +469,78 @@
<string name="preferences_debug">Debug</string>
<string name="preferences_debug_description">CPU/GPU debugging, graphics API, fastmem</string>
+
+ <!-- Multiplayer -->
+ <string name="multiplayer">Multiplayer</string>
+ <string name="multiplayer_description">Host your own game room or join an existing one to play with people</string>
+ <string name="multiplayer_room_title">Room: %1$s</string>
+ <string name="multiplayer_console_id">Console ID:%1$s</string>
+ <string name="multiplayer_create_room">Create</string>
+ <string name="multiplayer_join_room">Join</string>
+ <string name="multiplayer_username">Username</string>
+ <string name="multiplayer_ip_address">IP Address</string>
+ <string name="multiplayer_ip_port">Port</string>
+ <string name="multiplayer_create_room_success">Room created successfully!</string>
+ <string name="multiplayer_join_room_success">Join the room successfully!</string>
+ <string name="multiplayer_create_room_failed">Failed to create room!</string>
+ <string name="multiplayer_join_room_failed">Failed to join room!</string>
+ <string name="multiplayer_input_invalid">Invalid address or name is too short!</string>
+ <string name="multiplayer_port_invalid">Invalid port!</string>
+ <string name="multiplayer_exit_room">Exit Room</string>
+ <string name="multiplayer_network_error">Network error</string>
+ <string name="multiplayer_lost_connection">Lost connection</string>
+ <string name="multiplayer_name_collision">Name collision</string>
+ <string name="multiplayer_mac_collision">Mac collision</string>
+ <string name="multiplayer_console_id_collision">Console ID collision</string>
+ <string name="multiplayer_wrong_version">Wrong version</string>
+ <string name="multiplayer_wrong_password">Wrong password</string>
+ <string name="multiplayer_could_not_connect">Could not connect</string>
+ <string name="multiplayer_room_is_full">Room is full</string>
+ <string name="multiplayer_host_banned">Host banned</string>
+ <string name="multiplayer_permission_denied">Permission denied</string>
+ <string name="multiplayer_no_such_user">No such user</string>
+ <string name="multiplayer_already_in_room">Already in room</string>
+ <string name="multiplayer_create_room_error">Create room error</string>
+ <string name="multiplayer_host_kicked">Host kicked</string>
+ <string name="multiplayer_unknown_error">unknown error</string>
+ <string name="multiplayer_room_uninitialized">Room uninitialized</string>
+ <string name="multiplayer_room_idle">Room idle</string>
+ <string name="multiplayer_room_joining">Room joining</string>
+ <string name="multiplayer_room_joined">Room joined</string>
+ <string name="multiplayer_room_moderator">Room moderator</string>
+ <string name="multiplayer_member_join">%1$s joined</string>
+ <string name="multiplayer_member_leave">%1$s left</string>
+ <string name="multiplayer_member_kicked">%1$s kicked</string>
+ <string name="multiplayer_member_banned">%1$s banned</string>
+ <string name="multiplayer_address_unbanned">address unbanned</string>
+ <string name="multiplayer_kick_member">Kick Out</string>
+ <string name="multiplayer_chat_input_hint">Send messages……</string>
+ <string name="multiplayer_password">Password</string>
+ <string name="original_button_text">Join</string>
+ <string name="disabled_button_text">Joining...</string>
+ <string name="multiplayer_room_name">Room Name</string>
+ <string name="multiplayer_room_name_invalid">Room name must be between 3 and 20 characters</string>
+ <string name="multiplayer_max_players">Max Players (16)</string>
+ <string name="multiplayer_max_players_value">Max Players: %d</string>
+ <string name="multiplayer_chat">Chat</string>
+ <string name="multiplayer_more_options">More Options</string>
+ <string name="multiplayer_ip_copied">IP Address copied to clipboard</string>
+ <string name="multiplayer_server_address">Server Address</string>
+ <string name="chat">Chat</string>
+ <string name="type_message">Type message……</string>
+ <string name="send">Send</string>
+ <string name="send_message">Send Message</string>
+ <string name="multiplayer_moderation">Moderation</string>
+ <string name="multiplayer_moderation_title">Ban List</string>
+ <string name="multiplayer_no_bans">No banned users</string>
+ <string name="multiplayer_unban_title">Unban User</string>
+ <string name="multiplayer_unban">Unban</string>
+ <string name="multiplayer_unban_message">Are you sure you want to unban %1$s?</string>
+ <string name="multiplayer_ban">Ban User</string>
+ <string name="emulation_multiplayer">Multiplayer</string>
+ <string name="cancel">Cancel</string>
+ <string name="ok">Ok</string>
+
<!-- Game properties -->
<string name="info">Info</string>
<string name="info_description">Program ID, developer, version</string>
diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt
index 0657c6e1ef..433b0908ad 100644
--- a/src/common/CMakeLists.txt
+++ b/src/common/CMakeLists.txt
@@ -187,6 +187,8 @@ if(ANDROID)
android/android_common.h
android/id_cache.cpp
android/id_cache.h
+ android/multiplayer/multiplayer.cpp
+ android/multiplayer/multiplayer.h
android/applets/software_keyboard.cpp
android/applets/software_keyboard.h
)
diff --git a/src/common/android/android_common.cpp b/src/common/android/android_common.cpp
index e79005658d..0df2dd7b35 100644
--- a/src/common/android/android_common.cpp
+++ b/src/common/android/android_common.cpp
@@ -1,6 +1,9 @@
// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
#include "android_common.h"
#include <string>
@@ -34,6 +37,15 @@ jstring ToJString(JNIEnv* env, std::string_view str) {
static_cast<jint>(converted_string.size()));
}
+jobjectArray ToJStringArray(JNIEnv* env, const std::vector<std::string>& strs) {
+ jobjectArray array =
+ env->NewObjectArray(static_cast<jsize>(strs.size()), env->FindClass("java/lang/String"), env->NewStringUTF(""));
+ for (std::size_t i = 0; i < strs.size(); ++i) {
+ env->SetObjectArrayElement(array, static_cast<jsize>(i), ToJString(env, strs[i]));
+ }
+ return array;
+}
+
jstring ToJString(JNIEnv* env, std::u16string_view str) {
return ToJString(env, Common::UTF16ToUTF8(str));
}
diff --git a/src/common/android/android_common.h b/src/common/android/android_common.h
index d0ccb4ec2f..05e2c2515b 100644
--- a/src/common/android/android_common.h
+++ b/src/common/android/android_common.h
@@ -1,6 +1,9 @@
// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
#pragma once
#include <string>
@@ -19,7 +22,7 @@ jobject ToJDouble(JNIEnv* env, double value);
s32 GetJInteger(JNIEnv* env, jobject jinteger);
jobject ToJInteger(JNIEnv* env, s32 value);
-
+jobjectArray ToJStringArray(JNIEnv* env, const std::vector<std::string>& strs);
bool GetJBoolean(JNIEnv* env, jobject jboolean);
jobject ToJBoolean(JNIEnv* env, bool value);
diff --git a/src/common/android/id_cache.cpp b/src/common/android/id_cache.cpp
index 1145cbdf28..649fafca09 100644
--- a/src/common/android/id_cache.cpp
+++ b/src/common/android/id_cache.cpp
@@ -1,6 +1,9 @@
// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
#include <jni.h>
#include "applets/software_keyboard.h"
@@ -8,6 +11,9 @@
#include "common/assert.h"
#include "common/fs/fs_android.h"
#include "video_core/rasterizer_interface.h"
+#include "common/android/multiplayer/multiplayer.h"
+#include <network/network.h>
+
static JavaVM* s_java_vm;
static jclass s_native_library_class;
@@ -88,6 +94,9 @@ static jmethodID s_yuzu_input_device_get_supports_vibration;
static jmethodID s_yuzu_input_device_vibrate;
static jmethodID s_yuzu_input_device_get_axes;
static jmethodID s_yuzu_input_device_has_keys;
+static jmethodID s_add_netplay_message;
+static jmethodID s_clear_chat;
+
static constexpr jint JNI_VERSION = JNI_VERSION_1_6;
@@ -388,6 +397,15 @@ jmethodID GetYuzuDeviceHasKeys() {
return s_yuzu_input_device_has_keys;
}
+jmethodID GetAddNetPlayMessage() {
+ return s_add_netplay_message;
+}
+
+jmethodID ClearChat() {
+ return s_clear_chat;
+}
+
+
#ifdef __cplusplus
extern "C" {
#endif
@@ -547,6 +565,10 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
s_yuzu_input_device_has_keys =
env->GetMethodID(yuzu_input_device_interface, "hasKeys", "([I)[Z");
env->DeleteLocalRef(yuzu_input_device_interface);
+ s_add_netplay_message = env->GetStaticMethodID(s_native_library_class, "addNetPlayMessage",
+ "(ILjava/lang/String;)V");
+ s_clear_chat = env->GetStaticMethodID(s_native_library_class, "clearChat", "()V");
+
// Initialize Android Storage
Common::FS::Android::RegisterCallbacks(env, s_native_library_class);
@@ -582,6 +604,8 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) {
// UnInitialize applets
SoftwareKeyboard::CleanupJNI(env);
+
+ NetworkShutdown();
}
#ifdef __cplusplus
diff --git a/src/common/android/id_cache.h b/src/common/android/id_cache.h
index cd2844dcc6..0b64bbc866 100644
--- a/src/common/android/id_cache.h
+++ b/src/common/android/id_cache.h
@@ -1,10 +1,14 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
#pragma once
#include <future>
#include <jni.h>
+#include <network/network.h>
#include "video_core/rasterizer_interface.h"
@@ -108,5 +112,8 @@ jmethodID GetYuzuDeviceGetSupportsVibration();
jmethodID GetYuzuDeviceVibrate();
jmethodID GetYuzuDeviceGetAxes();
jmethodID GetYuzuDeviceHasKeys();
+jmethodID GetAddNetPlayMessage();
+jmethodID ClearChat();
+
} // namespace Common::Android
diff --git a/src/common/android/multiplayer/multiplayer.cpp b/src/common/android/multiplayer/multiplayer.cpp
new file mode 100644
index 0000000000..41d46d71b3
--- /dev/null
+++ b/src/common/android/multiplayer/multiplayer.cpp
@@ -0,0 +1,353 @@
+// Copyright 2024 Mandarine Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "common/android/id_cache.h"
+#include "multiplayer.h"
+
+#include "common/android/android_common.h"
+
+#include "core/core.h"
+#include "network/network.h"
+#include "android/log.h"
+
+
+#include <thread>
+#include <chrono>
+
+namespace IDCache = Common::Android;
+Network::RoomNetwork* room_network;
+
+void AddNetPlayMessage(jint type, jstring msg) {
+ IDCache::GetEnvForThread()->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(),
+ IDCache::GetAddNetPlayMessage(), type, msg);
+}
+
+void AddNetPlayMessage(int type, const std::string& msg) {
+ JNIEnv* env = IDCache::GetEnvForThread();
+ AddNetPlayMessage(type, Common::Android::ToJString(env, msg));
+}
+
+void ClearChat() {
+ IDCache::GetEnvForThread()->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(),
+ IDCache::ClearChat());
+}
+
+
+bool NetworkInit(Network::RoomNetwork* room_network_) {
+ room_network = room_network_;
+ bool result = room_network->Init();
+
+ if (!result) {
+ return false;
+ }
+
+ if (auto member = room_network->GetRoomMember().lock()) {
+ // register the network structs to use in slots and signals
+ member->BindOnStateChanged([](const Network::RoomMember::State& state) {
+ if (state == Network::RoomMember::State::Joined ||
+ state == Network::RoomMember::State::Moderator) {
+ NetPlayStatus status;
+ std::string msg;
+ switch (state) {
+ case Network::RoomMember::State::Joined:
+ status = NetPlayStatus::ROOM_JOINED;
+ break;
+ case Network::RoomMember::State::Moderator:
+ status = NetPlayStatus::ROOM_MODERATOR;
+ break;
+ default:
+ return;
+ }
+ AddNetPlayMessage(static_cast<int>(status), msg);
+ }
+ });
+ member->BindOnError([](const Network::RoomMember::Error& error) {
+ NetPlayStatus status;
+ std::string msg;
+ switch (error) {
+ case Network::RoomMember::Error::LostConnection:
+ status = NetPlayStatus::LOST_CONNECTION;
+ break;
+ case Network::RoomMember::Error::HostKicked:
+ status = NetPlayStatus::HOST_KICKED;
+ break;
+ case Network::RoomMember::Error::UnknownError:
+ status = NetPlayStatus::UNKNOWN_ERROR;
+ break;
+ case Network::RoomMember::Error::NameCollision:
+ status = NetPlayStatus::NAME_COLLISION;
+ break;
+ case Network::RoomMember::Error::IpCollision:
+ status = NetPlayStatus::MAC_COLLISION;
+ break;
+ case Network::RoomMember::Error::WrongVersion:
+ status = NetPlayStatus::WRONG_VERSION;
+ break;
+ case Network::RoomMember::Error::WrongPassword:
+ status = NetPlayStatus::WRONG_PASSWORD;
+ break;
+ case Network::RoomMember::Error::CouldNotConnect:
+ status = NetPlayStatus::COULD_NOT_CONNECT;
+ break;
+ case Network::RoomMember::Error::RoomIsFull:
+ status = NetPlayStatus::ROOM_IS_FULL;
+ break;
+ case Network::RoomMember::Error::HostBanned:
+ status = NetPlayStatus::HOST_BANNED;
+ break;
+ case Network::RoomMember::Error::PermissionDenied:
+ status = NetPlayStatus::PERMISSION_DENIED;
+ break;
+ case Network::RoomMember::Error::NoSuchUser:
+ status = NetPlayStatus::NO_SUCH_USER;
+ break;
+ }
+ AddNetPlayMessage(static_cast<int>(status), msg);
+ });
+ member->BindOnStatusMessageReceived([](const Network::StatusMessageEntry& status_message) {
+ NetPlayStatus status = NetPlayStatus::NO_ERROR;
+ std::string msg(status_message.nickname);
+ switch (status_message.type) {
+ case Network::IdMemberJoin:
+ status = NetPlayStatus::MEMBER_JOIN;
+ break;
+ case Network::IdMemberLeave:
+ status = NetPlayStatus::MEMBER_LEAVE;
+ break;
+ case Network::IdMemberKicked:
+ status = NetPlayStatus::MEMBER_KICKED;
+ break;
+ case Network::IdMemberBanned:
+ status = NetPlayStatus::MEMBER_BANNED;
+ break;
+ case Network::IdAddressUnbanned:
+ status = NetPlayStatus::ADDRESS_UNBANNED;
+ break;
+ }
+ AddNetPlayMessage(static_cast<int>(status), msg);
+ });
+ member->BindOnChatMessageReceived([](const Network::ChatEntry& chat) {
+ NetPlayStatus status = NetPlayStatus::CHAT_MESSAGE;
+ std::string msg(chat.nickname);
+ msg += ": ";
+ msg += chat.message;
+ AddNetPlayMessage(static_cast<int>(status), msg);
+ });
+ }
+
+ return true;
+}
+NetPlayStatus NetPlayCreateRoom(const std::string& ipaddress, int port,
+ const std::string& username, const std::string& password,
+ const std::string& room_name, int max_players) {
+
+ __android_log_print(ANDROID_LOG_INFO, "NetPlay", "NetPlayCreateRoom called with ipaddress: %s, port: %d, username: %s, room_name: %s, max_players: %d", ipaddress.c_str(), port, username.c_str(), room_name.c_str(), max_players);
+
+ auto member = room_network->GetRoomMember().lock();
+ if (!member) {
+ return NetPlayStatus::NETWORK_ERROR;
+ }
+
+ if (member->GetState() == Network::RoomMember::State::Joining || member->IsConnected()) {
+ return NetPlayStatus::ALREADY_IN_ROOM;
+ }
+
+ auto room = room_network->GetRoom().lock();
+ if (!room) {
+ return NetPlayStatus::NETWORK_ERROR;
+ }
+
+ if (room_name.length() < 3 || room_name.length() > 20) {
+ return NetPlayStatus::CREATE_ROOM_ERROR;
+ }
+
+ // Placeholder game info
+ const AnnounceMultiplayerRoom::GameInfo game{
+ .name = "Default Game",
+ .id = 0, // Default program ID
+ };
+
+ port = (port == 0) ? Network::DefaultRoomPort : static_cast<u16>(port);
+
+ if (!room->Create(room_name, "", ipaddress, static_cast<u16>(port), password,
+ static_cast<u32>(std::min(max_players, 16)), username, game, nullptr, {})) {
+ return NetPlayStatus::CREATE_ROOM_ERROR;
+ }
+
+ // Failsafe timer to avoid joining before creation
+ std::this_thread::sleep_for(std::chrono::milliseconds(100));
+
+ member->Join(username, ipaddress.c_str(), static_cast<u16>(port), 0, Network::NoPreferredIP, password, "");
+
+ // Failsafe timer to avoid joining before creation
+ for (int i = 0; i < 5; i++) {
+ std::this_thread::sleep_for(std::chrono::milliseconds(100));
+ if (member->GetState() == Network::RoomMember::State::Joined ||
+ member->GetState() == Network::RoomMember::State::Moderator) {
+ return NetPlayStatus::NO_ERROR;
+ }
+ }
+
+ // If join failed while room is created, clean up the room
+ room->Destroy();
+ return NetPlayStatus::CREATE_ROOM_ERROR;
+}
+
+NetPlayStatus NetPlayJoinRoom(const std::string& ipaddress, int port,
+ const std::string& username, const std::string& password) {
+ auto member = room_network->GetRoomMember().lock();
+ if (!member) {
+ return NetPlayStatus::NETWORK_ERROR;
+ }
+
+ port =
+ (port == 0) ? Network::DefaultRoomPort : static_cast<u16>(port);
+
+
+ if (member->GetState() == Network::RoomMember::State::Joining || member->IsConnected()) {
+ return NetPlayStatus::ALREADY_IN_ROOM;
+ }
+
+ member->Join(username, ipaddress.c_str(), static_cast<u16>(port), 0, Network::NoPreferredIP, password, "");
+
+ // Wait a bit for the connection and join process to complete
+ std::this_thread::sleep_for(std::chrono::milliseconds(500));
+
+ if (member->GetState() == Network::RoomMember::State::Joined ||
+ member->GetState() == Network::RoomMember::State::Moderator) {
+ return NetPlayStatus::NO_ERROR;
+ }
+
+ if (!member->IsConnected()) {
+ return NetPlayStatus::COULD_NOT_CONNECT;
+ }
+
+ return NetPlayStatus::WRONG_PASSWORD;
+}
+
+void NetPlaySendMessage(const std::string& msg) {
+ if (auto room = room_network->GetRoomMember().lock()) {
+ if (room->GetState() != Network::RoomMember::State::Joined &&
+ room->GetState() != Network::RoomMember::State::Moderator) {
+
+ return;
+ }
+ room->SendChatMessage(msg);
+ }
+}
+
+void NetPlayKickUser(const std::string& username) {
+ if (auto room = room_network->GetRoomMember().lock()) {
+ auto members = room->GetMemberInformation();
+ auto it = std::find_if(members.begin(), members.end(),
+ [&username](const Network::RoomMember::MemberInformation& member) {
+ return member.nickname == username;
+ });
+ if (it != members.end()) {
+ room->SendModerationRequest(Network::RoomMessageTypes::IdModKick, username);
+ }
+ }
+}
+
+void NetPlayBanUser(const std::string& username) {
+ if (auto room = room_network->GetRoomMember().lock()) {
+ auto members = room->GetMemberInformation();
+ auto it = std::find_if(members.begin(), members.end(),
+ [&username](const Network::RoomMember::MemberInformation& member) {
+ return member.nickname == username;
+ });
+ if (it != members.end()) {
+ room->SendModerationRequest(Network::RoomMessageTypes::IdModBan, username);
+ }
+ }
+}
+
+void NetPlayUnbanUser(const std::string& username) {
+ if (auto room = room_network->GetRoomMember().lock()) {
+ room->SendModerationRequest(Network::RoomMessageTypes::IdModUnban, username);
+ }
+}
+
+std::vector<std::string> NetPlayRoomInfo() {
+ std::vector<std::string> info_list;
+ if (auto room = room_network->GetRoomMember().lock()) {
+ auto members = room->GetMemberInformation();
+ if (!members.empty()) {
+ // name and max players
+ auto room_info = room->GetRoomInformation();
+ info_list.push_back(room_info.name + "|" + std::to_string(room_info.member_slots));
+ // all members
+ for (const auto& member : members) {
+ info_list.push_back(member.nickname);
+ }
+ }
+ }
+ return info_list;
+}
+
+bool NetPlayIsJoined() {
+ auto member = room_network->GetRoomMember().lock();
+ if (!member) {
+ return false;
+ }
+
+ return (member->GetState() == Network::RoomMember::State::Joined ||
+ member->GetState() == Network::RoomMember::State::Moderator);
+}
+
+bool NetPlayIsHostedRoom() {
+ if (auto room = room_network->GetRoom().lock()) {
+ return room->GetState() == Network::Room::State::Open;
+ }
+ return false;
+}
+
+void NetPlayLeaveRoom() {
+ if (auto room = room_network->GetRoom().lock()) {
+ // if you are in a room, leave it
+ if (auto member = room_network->GetRoomMember().lock()) {
+ member->Leave();
+ }
+
+ ClearChat();
+
+ // if you are hosting a room, also stop hosting
+ if (room->GetState() == Network::Room::State::Open) {
+ room->Destroy();
+ }
+ }
+}
+
+void NetworkShutdown() {
+ room_network->Shutdown();
+}
+
+bool NetPlayIsModerator() {
+ auto member = room_network->GetRoomMember().lock();
+ if (!member) {
+ return false;
+ }
+ return member->GetState() == Network::RoomMember::State::Moderator;
+}
+
+std::vector<std::string> NetPlayGetBanList() {
+ std::vector<std::string> ban_list;
+ if (auto room = room_network->GetRoom().lock()) {
+ auto [username_bans, ip_bans] = room->GetBanList();
+
+ // Add username bans
+ for (const auto& username : username_bans) {
+ ban_list.push_back(username);
+ }
+
+ // Add IP bans
+ for (const auto& ip : ip_bans) {
+ ban_list.push_back(ip);
+ }
+ }
+ return ban_list;
+}
diff --git a/src/common/android/multiplayer/multiplayer.h b/src/common/android/multiplayer/multiplayer.h
new file mode 100644
index 0000000000..4f32c514e0
--- /dev/null
+++ b/src/common/android/multiplayer/multiplayer.h
@@ -0,0 +1,68 @@
+// Copyright 2024 Mandarine Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <string>
+#include <vector>
+
+#include <common/common_types.h>
+#include <network/network.h>
+
+enum class NetPlayStatus : s32 {
+ NO_ERROR,
+
+ NETWORK_ERROR,
+ LOST_CONNECTION,
+ NAME_COLLISION,
+ MAC_COLLISION,
+ CONSOLE_ID_COLLISION,
+ WRONG_VERSION,
+ WRONG_PASSWORD,
+ COULD_NOT_CONNECT,
+ ROOM_IS_FULL,
+ HOST_BANNED,
+ PERMISSION_DENIED,
+ NO_SUCH_USER,
+ ALREADY_IN_ROOM,
+ CREATE_ROOM_ERROR,
+ HOST_KICKED,
+ UNKNOWN_ERROR,
+
+ ROOM_UNINITIALIZED,
+ ROOM_IDLE,
+ ROOM_JOINING,
+ ROOM_JOINED,
+ ROOM_MODERATOR,
+
+ MEMBER_JOIN,
+ MEMBER_LEAVE,
+ MEMBER_KICKED,
+ MEMBER_BANNED,
+ ADDRESS_UNBANNED,
+
+ CHAT_MESSAGE,
+};
+
+bool NetworkInit(Network::RoomNetwork* room_network);
+NetPlayStatus NetPlayCreateRoom(const std::string& ipaddress, int port,
+ const std::string& username, const std::string& password,
+ const std::string& room_name, int max_players);
+NetPlayStatus NetPlayJoinRoom(const std::string& ipaddress, int port,
+ const std::string& username, const std::string& password);
+std::vector<std::string> NetPlayRoomInfo();
+bool NetPlayIsJoined();
+bool NetPlayIsHostedRoom();
+bool NetPlayIsModerator();
+void NetPlaySendMessage(const std::string& msg);
+void NetPlayKickUser(const std::string& username);
+void NetPlayBanUser(const std::string& username);
+void NetPlayLeaveRoom();
+std::string NetPlayGetConsoleId();
+void NetworkShutdown();
+std::vector<std::string> NetPlayGetBanList();
+void NetPlayUnbanUser(const std::string& username);
diff --git a/src/common/announce_multiplayer_room.h b/src/common/announce_multiplayer_room.h
index f320601967..6f7ccfa7f0 100644
--- a/src/common/announce_multiplayer_room.h
+++ b/src/common/announce_multiplayer_room.h
@@ -1,6 +1,9 @@
// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
#pragma once
#include <array>
@@ -35,7 +38,6 @@ struct RoomInformation {
u16 port; ///< The port of this room
GameInfo preferred_game; ///< Game to advertise that you want to play
std::string host_username; ///< Forum username of the host
- bool enable_yuzu_mods; ///< Allow yuzu Moderators to moderate on this room
};
struct Room {
diff --git a/src/dedicated_room/yuzu_room.cpp b/src/dedicated_room/yuzu_room.cpp
index 93038f161b..7604cf4276 100644
--- a/src/dedicated_room/yuzu_room.cpp
+++ b/src/dedicated_room/yuzu_room.cpp
@@ -1,6 +1,10 @@
// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+
#include <chrono>
#include <fstream>
#include <iostream>
@@ -200,7 +204,6 @@ int main(int argc, char** argv) {
u64 preferred_game_id = 0;
u32 port = Network::DefaultRoomPort;
u32 max_members = 16;
- bool enable_yuzu_mods = false;
static struct option long_options[] = {
{"room-name", required_argument, 0, 'n'},
@@ -268,9 +271,6 @@ int main(int argc, char** argv) {
case 'l':
log_file.assign(optarg);
break;
- case 'e':
- enable_yuzu_mods = true;
- break;
case 'h':
PrintHelp(argv[0]);
return 0;
@@ -338,10 +338,6 @@ int main(int argc, char** argv) {
Settings::values.yuzu_token = token;
}
}
- if (!announce && enable_yuzu_mods) {
- enable_yuzu_mods = false;
- LOG_INFO(Network, "Can not enable yuzu Moderators for private rooms");
- }
// Load the ban list
Network::Room::BanList ban_list;
@@ -370,7 +366,7 @@ int main(int argc, char** argv) {
.id = preferred_game_id};
if (!room->Create(room_name, room_description, bind_address, static_cast<u16>(port),
password, max_members, username, preferred_game_info,
- std::move(verify_backend), ban_list, enable_yuzu_mods)) {
+ std::move(verify_backend), ban_list)) {
LOG_INFO(Network, "Failed to create room: ");
return -1;
}
diff --git a/src/network/room.cpp b/src/network/room.cpp
index d87db37de6..49d7ed3313 100644
--- a/src/network/room.cpp
+++ b/src/network/room.cpp
@@ -1,6 +1,10 @@
// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+
#include <algorithm>
#include <atomic>
#include <iomanip>
@@ -355,7 +359,14 @@ void Room::RoomImpl::HandleJoinRequest(const ENetEvent* event) {
std::lock_guard lock(verify_uid_mutex);
uid = verify_uid;
}
- member.user_data = verify_backend->LoadUserData(uid, token);
+
+ if (verify_backend != nullptr)
+ member.user_data = verify_backend->LoadUserData(uid, token);
+
+ if (nickname == room_information.host_username) {
+ member.user_data.moderator = true;
+ LOG_INFO(Network, "User {} is a moderator", std::string(room_information.host_username));
+ }
std::string ip;
{
@@ -574,8 +585,7 @@ bool Room::RoomImpl::HasModPermission(const ENetPeer* client) const {
if (sending_member == members.end()) {
return false;
}
- if (room_information.enable_yuzu_mods &&
- sending_member->user_data.moderator) { // Community moderator
+ if (sending_member->user_data.moderator) { // Community moderator
return true;
}
@@ -1047,7 +1057,7 @@ bool Room::Create(const std::string& name, const std::string& description,
const u32 max_connections, const std::string& host_username,
const GameInfo preferred_game,
std::unique_ptr<VerifyUser::Backend> verify_backend,
- const Room::BanList& ban_list, bool enable_yuzu_mods) {
+ const Room::BanList& ban_list) {
ENetAddress address;
address.host = ENET_HOST_ANY;
if (!server_address.empty()) {
@@ -1069,7 +1079,6 @@ bool Room::Create(const std::string& name, const std::string& description,
room_impl->room_information.port = server_port;
room_impl->room_information.preferred_game = preferred_game;
room_impl->room_information.host_username = host_username;
- room_impl->room_information.enable_yuzu_mods = enable_yuzu_mods;
room_impl->password = password;
room_impl->verify_backend = std::move(verify_backend);
room_impl->username_ban_list = ban_list.first;
diff --git a/src/network/room.h b/src/network/room.h
index edbd3ecfb2..780629ada3 100644
--- a/src/network/room.h
+++ b/src/network/room.h
@@ -1,6 +1,10 @@
// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
+// SPDX-FileCopyrightText: Copyright yuzu/Citra Emulator Project / Eden Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+
#pragma once
#include <array>
@@ -123,7 +127,7 @@ public:
const u32 max_connections = MaxConcurrentConnections,
const std::string& host_username = "", const GameInfo = {},
std::unique_ptr<VerifyUser::Backend> verify_backend = nullptr,
- const BanList& ban_list = {}, bool enable_yuzu_mods = false);
+ const BanList& ban_list = {});
/**
* Sets the verification GUID of the room.