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