diff options
| author | vampiric_x <vampiric_x@yuzu-emu.org> | 2025-03-10 22:37:56 +0100 |
|---|---|---|
| committer | Mike Lothian <mike@fireburn.co.uk> | 2025-05-12 12:46:19 +0100 |
| commit | 17c2000510c51d7b568f439240c86ee47a2667d5 (patch) | |
| tree | cfdd35e1dfa32bf1d131be419aaca92f42b1be41 | |
| parent | 481bf648aa34bb83d267e838471d44ab8b10618e (diff) | |
android: Initial multiplayer support
And give room owners mod access on both Android and QT
48 files changed, 2102 insertions, 19 deletions
diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml index b037fc055..922d4828b 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/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt index 6a2963caa..1542290c2 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 @@ -21,6 +21,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 import java.net.NetworkInterface /** @@ -245,6 +246,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 c962558a7..7839c26ca 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 @@ -39,12 +39,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 +407,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/dialogs/ChatDialog.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/dialogs/ChatDialog.kt new file mode 100644 index 000000000..593ae7924 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/dialogs/ChatDialog.kt @@ -0,0 +1,133 @@ +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/yuzu/yuzu_emu/dialogs/NetPlayDialog.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/dialogs/NetPlayDialog.kt new file mode 100644 index 000000000..7c1da7c8d --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/dialogs/NetPlayDialog.kt @@ -0,0 +1,397 @@ +// Copyright 2024 Mandarine Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +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_joined + 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_ip) + 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/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt index c10f20e1c..e9364466e 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 @@ -271,6 +271,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 d3d01558f..b052b9005 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 @@ -120,6 +120,16 @@ class HomeSettingsFragment : Fragment() { ) add( HomeSetting( + R.string.multiplayer, + R.string.multiplayer_description, + R.drawable.ic_multiplayer, + { + 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/network/NetPlayManager.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/network/NetPlayManager.kt new file mode 100644 index 000000000..34a6d0226 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/network/NetPlayManager.kt @@ -0,0 +1,222 @@ +// Copyright 2024 Mandarine Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +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 = "Lime3ds${(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/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt index 757463a0b..bf4827e29 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 @@ -31,6 +31,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 @@ -68,6 +69,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady } ThemeHelper.setTheme(this) + NativeLibrary.netPlayInit() super.onCreate(savedInstanceState) @@ -157,6 +159,11 @@ class MainActivity : AppCompatActivity(), ThemeProvider { setInsets() } + fun displayMultiplayerDialog() { + val dialog = NetPlayDialog(this) + dialog.show() + } + private fun checkKeys() { if (!NativeLibrary.areKeysPresent()) { MessageDialogFragment.newInstance( 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 000000000..8d21fb629 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/CompatUtils.kt @@ -0,0 +1,19 @@ +// Copyright 2024 Mandarine Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +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 849d8367c..b25c1f725 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -20,6 +20,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" @@ -876,4 +877,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 000000000..e0efa062b --- /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_ip.xml b/src/android/app/src/main/res/drawable/ic_ip.xml new file mode 100644 index 000000000..19f719b39 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_ip.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,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM11,19.93c-3.95,-0.49 -7,-3.85 -7,-7.93 0,-0.62 0.08,-1.21 0.21,-1.79L9,15v1c0,1.1 0.9,2 2,2v1.93zM17.9,17.39c-0.26,-0.81 -1,-1.39 -1.9,-1.39h-1v-3c0,-0.55 -0.45,-1 -1,-1L8,12v-2h2c0.55,0 1,-0.45 1,-1L11,7h2c1.1,0 2,-0.9 2,-2v-0.41c2.93,1.19 5,4.06 5,7.41 0,2.08 -0.8,3.97 -2.1,5.39z"/> +</vector>
\ No newline at end of file diff --git a/src/android/app/src/main/res/drawable/ic_joined.xml b/src/android/app/src/main/res/drawable/ic_joined.xml new file mode 100644 index 000000000..c86e96da4 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_joined.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_multiplayer.xml b/src/android/app/src/main/res/drawable/ic_multiplayer.xml new file mode 100644 index 000000000..cf3e49fcc --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_multiplayer.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="#FF000000" + 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_network.xml b/src/android/app/src/main/res/drawable/ic_network.xml new file mode 100644 index 000000000..eef8a0b43 --- /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 000000000..fa2074057 --- /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 000000000..63fd22d7d --- /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_user.xml b/src/android/app/src/main/res/drawable/ic_user.xml new file mode 100644 index 000000000..606e966ca --- /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 000000000..eb4082717 --- /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 000000000..6dd10d97b --- /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 000000000..d62ef0802 --- /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 000000000..36a77d395 --- /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 000000000..19368bc2c --- /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 000000000..53afda931 --- /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 000000000..32a101277 --- /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 000000000..494cc8878 --- /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 000000000..f4ce137e7 --- /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_button.xml b/src/android/app/src/main/res/layout/item_netplay_button.xml new file mode 100644 index 000000000..f324e8e26 --- /dev/null +++ b/src/android/app/src/main/res/layout/item_netplay_button.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:padding="16dp"> + + <TextView + android:id="@+id/item_button_netplay_name" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1"/> + + <Button + android:id="@+id/item_button_netplay" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/multiplayer_kick_member"/> + + <ImageButton + android:id="@+id/item_button_more" + android:layout_width="48dp" + android:layout_height="48dp" + android:src="@drawable/ic_more_vert"/> +</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 000000000..38def7eed --- /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 000000000..ed4be66e7 --- /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 000000000..99eb7d01a --- /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 000000000..f8039d826 --- /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/item_text_netplay.xml b/src/android/app/src/main/res/menu/item_text_netplay.xml new file mode 100644 index 000000000..f8039d826 --- /dev/null +++ b/src/android/app/src/main/res/menu/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 867197ebc..2f4285de8 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_multiplayer" + 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 000000000..a6b542d90 --- /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 819c4fa47..999933804 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -418,6 +418,75 @@ <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> + <!-- Game properties --> <string name="info">Info</string> <string name="info_description">Program ID, developer, version</string> @@ -1180,5 +1249,8 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. </string> + <string name="emulation_multiplayer">Multiplayer</string> + <string name="cancel">Cancel</string> + <string name="ok">Ok</string> </resources> diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt index ddd402d4d..5848026b2 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 8913413c0..8c4090928 100644 --- a/src/common/android/android_common.cpp +++ b/src/common/android/android_common.cpp @@ -34,6 +34,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 67e9b9e13..3e58550c6 100644 --- a/src/common/android/android_common.h +++ b/src/common/android/android_common.h @@ -26,4 +26,6 @@ jobject ToJLong(JNIEnv* env, s64 value); bool GetJBoolean(JNIEnv* env, jobject jboolean); jobject ToJBoolean(JNIEnv* env, bool value); +jobjectArray ToJStringArray(JNIEnv* env, const std::vector<std::string>& strs); + } // namespace Common::Android diff --git a/src/common/android/id_cache.cpp b/src/common/android/id_cache.cpp index 94bb5fab6..93968a05e 100644 --- a/src/common/android/id_cache.cpp +++ b/src/common/android/id_cache.cpp @@ -8,6 +8,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; @@ -92,6 +95,8 @@ 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; @@ -404,6 +409,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 @@ -569,6 +583,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); @@ -604,6 +622,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 b4da9b77a..8f685b830 100644 --- a/src/common/android/id_cache.h +++ b/src/common/android/id_cache.h @@ -5,6 +5,7 @@ #include <future> #include <jni.h> +#include <network/network.h> #include "video_core/rasterizer_interface.h" @@ -112,5 +113,6 @@ jmethodID GetYuzuDeviceGetSupportsVibration(); jmethodID GetYuzuDeviceVibrate(); jmethodID GetYuzuDeviceGetAxes(); jmethodID GetYuzuDeviceHasKeys(); - -} // namespace Common::Android +jmethodID GetAddNetPlayMessage(); +jmethodID ClearChat(); +} // namespace Android diff --git a/src/common/android/multiplayer/multiplayer.cpp b/src/common/android/multiplayer/multiplayer.cpp new file mode 100644 index 000000000..6da730ed5 --- /dev/null +++ b/src/common/android/multiplayer/multiplayer.cpp @@ -0,0 +1,350 @@ +// Copyright 2024 Mandarine Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#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 000000000..657624e45 --- /dev/null +++ b/src/common/android/multiplayer/multiplayer.h @@ -0,0 +1,65 @@ +// Copyright 2024 Mandarine Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#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 f32060196..0a4719427 100644 --- a/src/common/announce_multiplayer_room.h +++ b/src/common/announce_multiplayer_room.h @@ -35,7 +35,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 93038f161..b810a3d17 100644 --- a/src/dedicated_room/yuzu_room.cpp +++ b/src/dedicated_room/yuzu_room.cpp @@ -200,7 +200,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 +267,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 +334,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 +362,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 d87db37de..682de720e 100644 --- a/src/network/room.cpp +++ b/src/network/room.cpp @@ -355,7 +355,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 +581,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 +1053,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 +1075,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 edbd3ecfb..10cff928e 100644 --- a/src/network/room.h +++ b/src/network/room.h @@ -123,7 +123,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. |
