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
+
+ 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) :
+ RecyclerView.Adapter() {
+
+ 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() {
+ val netPlayItems = mutableListOf()
+
+ 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(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,
+ private val onUnban: (String) -> Unit
+ ) : RecyclerView.Adapter() {
+
+ 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
+ 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
+ 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()
+ private var isChatOpen = false
+
+ fun addChatMessage(message: ChatMessage) {
+ chatMessages.add(message)
+ }
+
+ fun getChatMessages(): List = 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 {
+ 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
@@ -242,6 +247,27 @@ object NativeLibrary {
return coreErrorAlertResult
}
+ @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) {
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
@@ -118,6 +122,16 @@ class HomeSettingsFragment : Fragment() {
driverViewModel.selectedDriverTitle
)
)
+ add(
+ HomeSetting(
+ R.string.multiplayer,
+ R.string.multiplayer_description,
+ R.drawable.ic_two_users,
+ {
+ val action = mainActivity.displayMultiplayerDialog()
+ },
+ )
+ )
add(
HomeSetting(
R.string.applets,
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
#include
#include
@@ -20,6 +24,7 @@
#include
#include
+#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(
+ 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(
+ 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 @@
+
+
+
+
\ 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 @@
+
+
+
+
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 @@
+
+
+
\ 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 @@
+
+
+
+
\ 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 @@
+
+
+
+
\ 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 @@
+
+
+
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 @@
+
+
\ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
\ 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 @@
+
+
+
+
+
+
+
\ 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 @@
+
+
\ 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 @@
+
+
+
+
+
+
+
+
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 @@
-