diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml index 84c9877e5..e58b93ddd 100644 --- a/src/android/app/src/main/AndroidManifest.xml +++ b/src/android/app/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ SPDX-License-Identifier: GPL-3.0-or-later + + handler.post { + chatAdapter.notifyDataSetChanged() + scrollToBottom() + } + } + + binding.sendButton.setOnClickListener { + val message = binding.chatInput.text.toString() + if (message.isNotBlank()) { + sendMessage(message) + binding.chatInput.text?.clear() + } + } + } + + override fun dismiss() { + NetPlayManager.setChatOpen(false) + super.dismiss() + } + + private fun sendMessage(message: String) { + val username = NetPlayManager.getUsername(context) + NetPlayManager.netPlaySendMessage(message) + + val chatMessage = ChatMessage( + nickname = username, + username = "", + message = message, + timestamp = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date()) + ) + + NetPlayManager.addChatMessage(chatMessage) + chatAdapter.notifyDataSetChanged() + scrollToBottom() + } + + private fun setupRecyclerView() { + chatAdapter = ChatAdapter(NetPlayManager.getChatMessages()) + binding.chatRecyclerView.layoutManager = LinearLayoutManager(context).apply { + stackFromEnd = true + } + binding.chatRecyclerView.adapter = chatAdapter + } + + private fun scrollToBottom() { + binding.chatRecyclerView.scrollToPosition(chatAdapter.itemCount - 1) + } +} + +class ChatAdapter(private val messages: List) : + RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChatViewHolder { + val binding = ItemChatMessageBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return ChatViewHolder(binding) + } + + override fun getItemCount(): Int = messages.size + + override fun onBindViewHolder(holder: ChatViewHolder, position: Int) { + holder.bind(messages[position]) + } + + inner class ChatViewHolder(private val binding: ItemChatMessageBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(message: ChatMessage) { + binding.usernameText.text = message.nickname + binding.messageText.text = message.message + binding.userIcon.setImageResource(when (message.nickname) { + "System" -> R.drawable.ic_system + else -> R.drawable.ic_user + }) + } + } +} diff --git a/src/android/app/src/main/java/org/citron/citron_emu/dialogs/NetPlayDialog.kt b/src/android/app/src/main/java/org/citron/citron_emu/dialogs/NetPlayDialog.kt new file mode 100644 index 000000000..13ba8360a --- /dev/null +++ b/src/android/app/src/main/java/org/citron/citron_emu/dialogs/NetPlayDialog.kt @@ -0,0 +1,397 @@ +// Copyright 2024 Mandarine Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citron.citron_emu.dialogs + +import android.content.Context +import org.citron.citron_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.citron.citron_emu.CitronApplication +import org.citron.citron_emu.databinding.DialogMultiplayerConnectBinding +import org.citron.citron_emu.databinding.DialogMultiplayerLobbyBinding +import org.citron.citron_emu.databinding.DialogMultiplayerRoomBinding +import org.citron.citron_emu.databinding.ItemBanListBinding +import org.citron.citron_emu.databinding.ItemButtonNetplayBinding +import org.citron.citron_emu.databinding.ItemTextNetplayBinding +import org.citron.citron_emu.utils.CompatUtils +import org.citron.citron_emu.network.NetPlayManager + +class NetPlayDialog(context: Context) : BottomSheetDialog(context) { + private lateinit var adapter: NetPlayAdapter + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + behavior.state = BottomSheetBehavior.STATE_EXPANDED + behavior.state = BottomSheetBehavior.STATE_EXPANDED + behavior.skipCollapsed = context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + + when { + NetPlayManager.netPlayIsJoined() -> DialogMultiplayerLobbyBinding.inflate(layoutInflater) + .apply { + setContentView(root) + adapter = NetPlayAdapter() + listMultiplayer.layoutManager = LinearLayoutManager(context) + listMultiplayer.adapter = adapter + adapter.loadMultiplayerMenu() + btnLeave.setOnClickListener { + NetPlayManager.netPlayLeaveRoom() + dismiss() + } + btnChat.setOnClickListener { + ChatDialog(context).show() + } + + refreshAdapterItems() + + btnModeration.visibility = if (NetPlayManager.netPlayIsModerator()) View.VISIBLE else View.GONE + btnModeration.setOnClickListener { + showModerationDialog() + } + + } + else -> { + DialogMultiplayerConnectBinding.inflate(layoutInflater).apply { + setContentView(root) + btnCreate.setOnClickListener { + showNetPlayInputDialog(true) + dismiss() + } + btnJoin.setOnClickListener { + showNetPlayInputDialog(false) + dismiss() + } + } + } + } + } + + data class NetPlayItems( + val option: Int, + val name: String, + val type: Int, + val id: Int = 0 + ) { + companion object { + const val MULTIPLAYER_ROOM_TEXT = 1 + const val MULTIPLAYER_ROOM_MEMBER = 2 + const val MULTIPLAYER_SEPARATOR = 3 + const val MULTIPLAYER_ROOM_COUNT = 4 + const val TYPE_BUTTON = 0 + const val TYPE_TEXT = 1 + const val TYPE_SEPARATOR = 2 + } + } + + inner class NetPlayAdapter : RecyclerView.Adapter() { + val netPlayItems = mutableListOf() + + abstract inner class NetPlayViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), View.OnClickListener { + init { + itemView.setOnClickListener(this) + } + abstract fun bind(item: NetPlayItems) + } + + inner class TextViewHolder(private val binding: ItemTextNetplayBinding) : NetPlayViewHolder(binding.root) { + private lateinit var netPlayItem: NetPlayItems + + override fun onClick(clicked: View) {} + + override fun bind(item: NetPlayItems) { + netPlayItem = item + binding.itemTextNetplayName.text = item.name + binding.itemIcon.apply { + val iconRes = when (item.option) { + NetPlayItems.MULTIPLAYER_ROOM_TEXT -> R.drawable.ic_system + NetPlayItems.MULTIPLAYER_ROOM_COUNT -> R.drawable.ic_joined + else -> 0 + } + visibility = if (iconRes != 0) { + setImageResource(iconRes) + View.VISIBLE + } else View.GONE + } + } + } + + inner class ButtonViewHolder(private val binding: ItemButtonNetplayBinding) : NetPlayViewHolder(binding.root) { + private lateinit var netPlayItems: NetPlayItems + private val isModerator = NetPlayManager.netPlayIsModerator() + + init { + binding.itemButtonMore.apply { + visibility = View.VISIBLE + setOnClickListener { showPopupMenu(it) } + } + } + + override fun onClick(clicked: View) {} + + + private fun showPopupMenu(view: View) { + PopupMenu(view.context, view).apply { + menuInflater.inflate(R.menu.menu_netplay_member, menu) + menu.findItem(R.id.action_kick).isEnabled = isModerator && + netPlayItems.name != NetPlayManager.getUsername(context) + menu.findItem(R.id.action_ban).isEnabled = isModerator && + netPlayItems.name != NetPlayManager.getUsername(context) + setOnMenuItemClickListener { item -> + if (item.itemId == R.id.action_kick) { + NetPlayManager.netPlayKickUser(netPlayItems.name) + true + } else if (item.itemId == R.id.action_ban) { + NetPlayManager.netPlayBanUser(netPlayItems.name) + true + } else false + } + show() + } + } + + override fun bind(item: NetPlayItems) { + netPlayItems = item + binding.itemButtonNetplayName.text = netPlayItems.name + } + } + + fun loadMultiplayerMenu() { + val infos = NetPlayManager.netPlayRoomInfo() + if (infos.isNotEmpty()) { + val roomInfo = infos[0].split("|") + netPlayItems.add(NetPlayItems(NetPlayItems.MULTIPLAYER_ROOM_TEXT, roomInfo[0], NetPlayItems.TYPE_TEXT)) + netPlayItems.add(NetPlayItems(NetPlayItems.MULTIPLAYER_ROOM_COUNT, "${infos.size - 1}/${roomInfo[1]}", NetPlayItems.TYPE_TEXT)) + netPlayItems.add(NetPlayItems(NetPlayItems.MULTIPLAYER_SEPARATOR, "", NetPlayItems.TYPE_SEPARATOR)) + for (i in 1 until infos.size) { + netPlayItems.add(NetPlayItems(NetPlayItems.MULTIPLAYER_ROOM_MEMBER, infos[i], NetPlayItems.TYPE_BUTTON)) + } + } + } + + override fun getItemViewType(position: Int) = netPlayItems[position].type + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NetPlayViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + NetPlayItems.TYPE_TEXT -> TextViewHolder(ItemTextNetplayBinding.inflate(inflater, parent, false)) + NetPlayItems.TYPE_BUTTON -> ButtonViewHolder(ItemButtonNetplayBinding.inflate(inflater, parent, false)) + NetPlayItems.TYPE_SEPARATOR -> object : NetPlayViewHolder(inflater.inflate(R.layout.item_separator_netplay, parent, false)) { + override fun bind(item: NetPlayItems) {} + override fun onClick(clicked: View) {} + } + else -> throw IllegalStateException("Unsupported view type") + } + } + + override fun onBindViewHolder(holder: NetPlayViewHolder, position: Int) { + holder.bind(netPlayItems[position]) + } + + override fun getItemCount() = netPlayItems.size + } + + fun refreshAdapterItems() { + val handler = Handler(Looper.getMainLooper()) + + NetPlayManager.setOnAdapterRefreshListener() { type, msg -> + handler.post { + adapter.netPlayItems.clear() + adapter.loadMultiplayerMenu() + adapter.notifyDataSetChanged() + } + } + } + + private fun showNetPlayInputDialog(isCreateRoom: Boolean) { + val activity = CompatUtils.findActivity(context) + val dialog = BottomSheetDialog(activity) + + dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + dialog.behavior.skipCollapsed = context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + + + val binding = DialogMultiplayerRoomBinding.inflate(LayoutInflater.from(activity)) + dialog.setContentView(binding.root) + + binding.textTitle.text = activity.getString( + if (isCreateRoom) R.string.multiplayer_create_room + else R.string.multiplayer_join_room + ) + + binding.ipAddress.setText( + if (isCreateRoom) NetPlayManager.getIpAddressByWifi(activity) + else NetPlayManager.getRoomAddress(activity) + ) + binding.ipPort.setText(NetPlayManager.getRoomPort(activity)) + binding.username.setText(NetPlayManager.getUsername(activity)) + + binding.roomName.visibility = if (isCreateRoom) View.VISIBLE else View.GONE + binding.maxPlayersContainer.visibility = if (isCreateRoom) View.VISIBLE else View.GONE + binding.maxPlayersLabel.text = context.getString(R.string.multiplayer_max_players_value, binding.maxPlayers.value.toInt()) + + binding.maxPlayers.addOnChangeListener { _, value, _ -> + binding.maxPlayersLabel.text = context.getString(R.string.multiplayer_max_players_value, value.toInt()) + } + + binding.btnConfirm.setOnClickListener { + binding.btnConfirm.isEnabled = false + binding.btnConfirm.text = activity.getString(R.string.disabled_button_text) + + val ipAddress = binding.ipAddress.text.toString() + val username = binding.username.text.toString() + val portStr = binding.ipPort.text.toString() + val password = binding.password.text.toString() + val port = portStr.toIntOrNull() ?: run { + Toast.makeText(activity, R.string.multiplayer_port_invalid, Toast.LENGTH_LONG).show() + binding.btnConfirm.isEnabled = true + binding.btnConfirm.text = activity.getString(R.string.original_button_text) + return@setOnClickListener + } + val roomName = binding.roomName.text.toString() + val maxPlayers = binding.maxPlayers.value.toInt() + + if (isCreateRoom && (roomName.length !in 3..20)) { + Toast.makeText(activity, R.string.multiplayer_room_name_invalid, Toast.LENGTH_LONG).show() + binding.btnConfirm.isEnabled = true + binding.btnConfirm.text = activity.getString(R.string.original_button_text) + return@setOnClickListener + } + + if (ipAddress.length < 7 || username.length < 5) { + Toast.makeText(activity, R.string.multiplayer_input_invalid, Toast.LENGTH_LONG).show() + binding.btnConfirm.isEnabled = true + binding.btnConfirm.text = activity.getString(R.string.original_button_text) + } else { + Handler(Looper.getMainLooper()).post { + val result = if (isCreateRoom) { + NetPlayManager.netPlayCreateRoom(ipAddress, port, username, password, roomName, maxPlayers) + } else { + NetPlayManager.netPlayJoinRoom(ipAddress, port, username, password) + } + + if (result == 0) { + NetPlayManager.setUsername(activity, username) + NetPlayManager.setRoomPort(activity, portStr) + if (!isCreateRoom) NetPlayManager.setRoomAddress(activity, ipAddress) + Toast.makeText( + CitronApplication.appContext, + if (isCreateRoom) R.string.multiplayer_create_room_success + else R.string.multiplayer_join_room_success, + Toast.LENGTH_LONG + ).show() + dialog.dismiss() + } else { + Toast.makeText(activity, R.string.multiplayer_could_not_connect, Toast.LENGTH_LONG).show() + binding.btnConfirm.isEnabled = true + binding.btnConfirm.text = activity.getString(R.string.original_button_text) + } + } + } + } + + dialog.show() + } + + private fun showModerationDialog() { + val activity = CompatUtils.findActivity(context) + val dialog = MaterialAlertDialogBuilder(activity) + dialog.setTitle(R.string.multiplayer_moderation_title) + + val banList = NetPlayManager.getBanList() + if (banList.isEmpty()) { + dialog.setMessage(R.string.multiplayer_no_bans) + dialog.setPositiveButton(R.string.ok, null) + dialog.show() + return + } + + val view = LayoutInflater.from(context).inflate(R.layout.dialog_ban_list, null) + val recyclerView = view.findViewById(R.id.ban_list_recycler) + recyclerView.layoutManager = LinearLayoutManager(context) + + lateinit var adapter: BanListAdapter + + val onUnban: (String) -> Unit = { bannedItem -> + MaterialAlertDialogBuilder(activity) + .setTitle(R.string.multiplayer_unban_title) + .setMessage(activity.getString(R.string.multiplayer_unban_message, bannedItem)) + .setPositiveButton(R.string.multiplayer_unban) { _, _ -> + NetPlayManager.netPlayUnbanUser(bannedItem) + adapter.removeBan(bannedItem) + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + adapter = BanListAdapter(banList, onUnban) + recyclerView.adapter = adapter + + dialog.setView(view) + dialog.setPositiveButton(R.string.ok, null) + dialog.show() + } + + private class BanListAdapter( + banList: List, + private val onUnban: (String) -> Unit + ) : RecyclerView.Adapter() { + + private val usernameBans = banList.filter { !it.contains(".") }.toMutableList() + private val ipBans = banList.filter { it.contains(".") }.toMutableList() + + class ViewHolder(val binding: ItemBanListBinding) : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ItemBanListBinding.inflate( + LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val isUsername = position < usernameBans.size + val item = if (isUsername) usernameBans[position] else ipBans[position - usernameBans.size] + + holder.binding.apply { + banText.text = item + icon.setImageResource(if (isUsername) R.drawable.ic_user else R.drawable.ic_ip) + btnUnban.setOnClickListener { onUnban(item) } + } + } + + override fun getItemCount() = usernameBans.size + ipBans.size + + fun removeBan(bannedItem: String) { + val position = if (bannedItem.contains(".")) { + ipBans.indexOf(bannedItem).let { if (it >= 0) it + usernameBans.size else it } + } else { + usernameBans.indexOf(bannedItem) + } + + if (position >= 0) { + if (bannedItem.contains(".")) { + ipBans.remove(bannedItem) + } else { + usernameBans.remove(bannedItem) + } + notifyItemRemoved(position) + } + } + + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citron/citron_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/citron/citron_emu/fragments/EmulationFragment.kt index f4678b603..68b4166ee 100644 --- a/src/android/app/src/main/java/org/citron/citron_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/citron/citron_emu/fragments/EmulationFragment.kt @@ -271,6 +271,13 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback { true } + + R.id.menu_multiplayer -> { + emulationActivity?.displayMultiplayerDialog() + true + } + + R.id.menu_controls -> { val action = HomeNavigationDirections.actionGlobalSettingsActivity( null, diff --git a/src/android/app/src/main/java/org/citron/citron_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/citron/citron_emu/fragments/HomeSettingsFragment.kt index 17d3b91a3..b92fc5afd 100644 --- a/src/android/app/src/main/java/org/citron/citron_emu/fragments/HomeSettingsFragment.kt +++ b/src/android/app/src/main/java/org/citron/citron_emu/fragments/HomeSettingsFragment.kt @@ -119,6 +119,16 @@ class HomeSettingsFragment : Fragment() { driverViewModel.selectedDriverTitle ) ) + add( + HomeSetting( + R.string.multiplayer, + R.string.multiplayer_description, + R.drawable.ic_multiplayer, + { + val action = mainActivity.displayMultiplayerDialog() + }, + ) + ) add( HomeSetting( R.string.applets, 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 000000000..52a6161ee --- /dev/null +++ b/src/android/app/src/main/java/org/citron/citron_emu/network/NetPlayManager.kt @@ -0,0 +1,222 @@ +// Copyright 2024 Mandarine Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citron.citron_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.citron.citron_emu.CitronApplication +import org.citron.citron_emu.R +import org.citron.citron_emu.dialogs.ChatMessage + +object NetPlayManager { + external fun netPlayCreateRoom(ipAddress: String, port: Int, username: String, password: String, roomName: String, maxPlayers: Int): Int + external fun netPlayJoinRoom(ipAddress: String, port: Int, username: String, password: String): Int + external fun netPlayRoomInfo(): Array + external fun netPlayIsJoined(): Boolean + external fun netPlayIsHostedRoom(): Boolean + external fun netPlaySendMessage(msg: String) + external fun netPlayKickUser(username: String) + external fun netPlayLeaveRoom() + external fun netPlayIsModerator(): Boolean + external fun netPlayGetBanList(): Array + external fun netPlayBanUser(username: String) + external fun netPlayUnbanUser(username: String) + + private var messageListener: ((Int, String) -> Unit)? = null + private var adapterRefreshListener: ((Int, String) -> Unit)? = null + + fun setOnMessageReceivedListener(listener: (Int, String) -> Unit) { + messageListener = listener + } + + fun setOnAdapterRefreshListener(listener: (Int, String) -> Unit) { + adapterRefreshListener = listener + } + + fun getUsername(activity: Context): String { val prefs = PreferenceManager.getDefaultSharedPreferences(activity) + val name = "Lime3ds${(Math.random() * 100).toInt()}" + return prefs.getString("NetPlayUsername", name) ?: name + } + + fun setUsername(activity: Activity, name: String) { + val prefs = PreferenceManager.getDefaultSharedPreferences(activity) + prefs.edit().putString("NetPlayUsername", name).apply() + } + + fun getRoomAddress(activity: Activity): String { + val prefs = PreferenceManager.getDefaultSharedPreferences(activity) + val address = getIpAddressByWifi(activity) + return prefs.getString("NetPlayRoomAddress", address) ?: address + } + + fun setRoomAddress(activity: Activity, address: String) { + val prefs = PreferenceManager.getDefaultSharedPreferences(activity) + prefs.edit().putString("NetPlayRoomAddress", address).apply() + } + + fun getRoomPort(activity: Activity): String { + val prefs = PreferenceManager.getDefaultSharedPreferences(activity) + return prefs.getString("NetPlayRoomPort", "24872") ?: "24872" + } + + fun setRoomPort(activity: Activity, port: String) { + val prefs = PreferenceManager.getDefaultSharedPreferences(activity) + prefs.edit().putString("NetPlayRoomPort", port).apply() + } + + private val chatMessages = mutableListOf() + private var isChatOpen = false + + fun addChatMessage(message: ChatMessage) { + chatMessages.add(message) + } + + fun getChatMessages(): List = chatMessages + + fun clearChat() { + chatMessages.clear() + } + + fun setChatOpen(isOpen: Boolean) { + isChatOpen = isOpen + } + + fun addNetPlayMessage(type: Int, msg: String) { + val context = CitronApplication.appContext + val message = formatNetPlayStatus(context, type, msg) + + when (type) { + NetPlayStatus.CHAT_MESSAGE -> { + val parts = msg.split(":", limit = 2) + if (parts.size == 2) { + val nickname = parts[0].trim() + val chatMessage = parts[1].trim() + addChatMessage(ChatMessage( + nickname = nickname, + username = "", + message = chatMessage + )) + } + } + NetPlayStatus.MEMBER_JOIN, + NetPlayStatus.MEMBER_LEAVE, + NetPlayStatus.MEMBER_KICKED, + NetPlayStatus.MEMBER_BANNED -> { + addChatMessage(ChatMessage( + nickname = "System", + username = "", + message = message + )) + } + } + + + Handler(Looper.getMainLooper()).post { + if (!isChatOpen) { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + + + messageListener?.invoke(type, msg) + adapterRefreshListener?.invoke(type, msg) + } + + private fun formatNetPlayStatus(context: Context, type: Int, msg: String): String { + return when (type) { + NetPlayStatus.NETWORK_ERROR -> context.getString(R.string.multiplayer_network_error) + NetPlayStatus.LOST_CONNECTION -> context.getString(R.string.multiplayer_lost_connection) + NetPlayStatus.NAME_COLLISION -> context.getString(R.string.multiplayer_name_collision) + NetPlayStatus.MAC_COLLISION -> context.getString(R.string.multiplayer_mac_collision) + NetPlayStatus.CONSOLE_ID_COLLISION -> context.getString(R.string.multiplayer_console_id_collision) + NetPlayStatus.WRONG_VERSION -> context.getString(R.string.multiplayer_wrong_version) + NetPlayStatus.WRONG_PASSWORD -> context.getString(R.string.multiplayer_wrong_password) + NetPlayStatus.COULD_NOT_CONNECT -> context.getString(R.string.multiplayer_could_not_connect) + NetPlayStatus.ROOM_IS_FULL -> context.getString(R.string.multiplayer_room_is_full) + NetPlayStatus.HOST_BANNED -> context.getString(R.string.multiplayer_host_banned) + NetPlayStatus.PERMISSION_DENIED -> context.getString(R.string.multiplayer_permission_denied) + NetPlayStatus.NO_SUCH_USER -> context.getString(R.string.multiplayer_no_such_user) + NetPlayStatus.ALREADY_IN_ROOM -> context.getString(R.string.multiplayer_already_in_room) + NetPlayStatus.CREATE_ROOM_ERROR -> context.getString(R.string.multiplayer_create_room_error) + NetPlayStatus.HOST_KICKED -> context.getString(R.string.multiplayer_host_kicked) + NetPlayStatus.UNKNOWN_ERROR -> context.getString(R.string.multiplayer_unknown_error) + NetPlayStatus.ROOM_UNINITIALIZED -> context.getString(R.string.multiplayer_room_uninitialized) + NetPlayStatus.ROOM_IDLE -> context.getString(R.string.multiplayer_room_idle) + NetPlayStatus.ROOM_JOINING -> context.getString(R.string.multiplayer_room_joining) + NetPlayStatus.ROOM_JOINED -> context.getString(R.string.multiplayer_room_joined) + NetPlayStatus.ROOM_MODERATOR -> context.getString(R.string.multiplayer_room_moderator) + NetPlayStatus.MEMBER_JOIN -> context.getString(R.string.multiplayer_member_join, msg) + NetPlayStatus.MEMBER_LEAVE -> context.getString(R.string.multiplayer_member_leave, msg) + NetPlayStatus.MEMBER_KICKED -> context.getString(R.string.multiplayer_member_kicked, msg) + NetPlayStatus.MEMBER_BANNED -> context.getString(R.string.multiplayer_member_banned, msg) + NetPlayStatus.ADDRESS_UNBANNED -> context.getString(R.string.multiplayer_address_unbanned) + NetPlayStatus.CHAT_MESSAGE -> msg + else -> "" + } + } + + fun getIpAddressByWifi(activity: Activity): String { + var ipAddress = 0 + val wifiManager = activity.getSystemService(WifiManager::class.java) + val wifiInfo = wifiManager.connectionInfo + if (wifiInfo != null) { + ipAddress = wifiInfo.ipAddress + } + + if (ipAddress == 0) { + val dhcpInfo = wifiManager.dhcpInfo + if (dhcpInfo != null) { + ipAddress = dhcpInfo.ipAddress + } + } + + return if (ipAddress == 0) { + "192.168.0.1" + } else { + Formatter.formatIpAddress(ipAddress) + } + } + + fun getBanList(): List { + return netPlayGetBanList().toList() + } + + object NetPlayStatus { + const val NO_ERROR = 0 + const val NETWORK_ERROR = 1 + const val LOST_CONNECTION = 2 + const val NAME_COLLISION = 3 + const val MAC_COLLISION = 4 + const val CONSOLE_ID_COLLISION = 5 + const val WRONG_VERSION = 6 + const val WRONG_PASSWORD = 7 + const val COULD_NOT_CONNECT = 8 + const val ROOM_IS_FULL = 9 + const val HOST_BANNED = 10 + const val PERMISSION_DENIED = 11 + const val NO_SUCH_USER = 12 + const val ALREADY_IN_ROOM = 13 + const val CREATE_ROOM_ERROR = 14 + const val HOST_KICKED = 15 + const val UNKNOWN_ERROR = 16 + const val ROOM_UNINITIALIZED = 17 + const val ROOM_IDLE = 18 + const val ROOM_JOINING = 19 + const val ROOM_JOINED = 20 + const val ROOM_MODERATOR = 21 + const val MEMBER_JOIN = 22 + const val MEMBER_LEAVE = 23 + const val MEMBER_KICKED = 24 + const val MEMBER_BANNED = 25 + const val ADDRESS_UNBANNED = 26 + const val CHAT_MESSAGE = 27 + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citron/citron_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/citron/citron_emu/ui/main/MainActivity.kt index 5e2ac122d..85ac9bbfd 100644 --- a/src/android/app/src/main/java/org/citron/citron_emu/ui/main/MainActivity.kt +++ b/src/android/app/src/main/java/org/citron/citron_emu/ui/main/MainActivity.kt @@ -31,6 +31,7 @@ import org.citron.citron_emu.HomeNavigationDirections import org.citron.citron_emu.NativeLibrary import org.citron.citron_emu.R import org.citron.citron_emu.databinding.ActivityMainBinding +import org.citron.citron_emu.dialogs.NetPlayDialog import org.citron.citron_emu.features.settings.model.Settings import org.citron.citron_emu.fragments.AddGameFolderDialogFragment import org.citron.citron_emu.fragments.ProgressDialogFragment @@ -68,6 +69,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider { splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady } ThemeHelper.setTheme(this) + NativeLibrary.netPlayInit() super.onCreate(savedInstanceState) @@ -157,6 +159,11 @@ class MainActivity : AppCompatActivity(), ThemeProvider { setInsets() } + fun displayMultiplayerDialog() { + val dialog = NetPlayDialog(this) + dialog.show() + } + private fun checkKeys() { if (!NativeLibrary.areKeysPresent()) { MessageDialogFragment.newInstance( diff --git a/src/android/app/src/main/java/org/citron/citron_emu/utils/CompatUtils.kt b/src/android/app/src/main/java/org/citron/citron_emu/utils/CompatUtils.kt new file mode 100644 index 000000000..67ab65c74 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/citron_emu/utils/CompatUtils.kt @@ -0,0 +1,19 @@ +// Copyright 2024 Mandarine Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citron.citron_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 eae276e96..f2a6a64d4 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -20,6 +20,7 @@ #include #include +#include "common/android/multiplayer/multiplayer.h" #include "common/android/android_common.h" #include "common/android/id_cache.h" #include "common/detached_tasks.h" @@ -870,4 +871,83 @@ jboolean Java_org_citron_citron_1emu_NativeLibrary_areKeysPresent(JNIEnv* env, j return ContentManager::AreKeysPresent(); } +JNIEXPORT jint JNICALL Java_org_citron_citron_1emu_network_NetPlayManager_netPlayCreateRoom( + JNIEnv* env, [[maybe_unused]] jobject obj, jstring ipaddress, jint port, + jstring username, jstring password, jstring room_name, jint max_players) { + return static_cast( + NetPlayCreateRoom(Common::Android::GetJString(env, ipaddress), port, + Common::Android::GetJString(env, username), Common::Android::GetJString(env, password), + Common::Android::GetJString(env, room_name), max_players)); +} + +JNIEXPORT jint JNICALL Java_org_citron_citron_1emu_network_NetPlayManager_netPlayJoinRoom( + JNIEnv* env, [[maybe_unused]] jobject obj, jstring ipaddress, jint port, + jstring username, jstring password) { + return static_cast( + NetPlayJoinRoom(Common::Android::GetJString(env, ipaddress), port, + Common::Android::GetJString(env, username), Common::Android::GetJString(env, password))); +} + +JNIEXPORT jobjectArray JNICALL +Java_org_citron_citron_1emu_network_NetPlayManager_netPlayRoomInfo( + JNIEnv* env, [[maybe_unused]] jobject obj) { + return Common::Android::ToJStringArray(env, NetPlayRoomInfo()); +} + +JNIEXPORT jboolean JNICALL +Java_org_citron_citron_1emu_network_NetPlayManager_netPlayIsJoined( + [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj) { + return NetPlayIsJoined(); +} + +JNIEXPORT jboolean JNICALL +Java_org_citron_citron_1emu_network_NetPlayManager_netPlayIsHostedRoom( + [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj) { + return NetPlayIsHostedRoom(); +} + +JNIEXPORT void JNICALL +Java_org_citron_citron_1emu_network_NetPlayManager_netPlaySendMessage( + JNIEnv* env, [[maybe_unused]] jobject obj, jstring msg) { + NetPlaySendMessage(Common::Android::GetJString(env, msg)); +} + +JNIEXPORT void JNICALL Java_org_citron_citron_1emu_network_NetPlayManager_netPlayKickUser( + JNIEnv* env, [[maybe_unused]] jobject obj, jstring username) { + NetPlayKickUser(Common::Android::GetJString(env, username)); +} + +JNIEXPORT void JNICALL Java_org_citron_citron_1emu_network_NetPlayManager_netPlayLeaveRoom( + [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj) { + NetPlayLeaveRoom(); +} + +JNIEXPORT jboolean JNICALL +Java_org_citron_citron_1emu_network_NetPlayManager_netPlayIsModerator( + [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj) { + return NetPlayIsModerator(); +} + +JNIEXPORT jobjectArray JNICALL +Java_org_citron_citron_1emu_network_NetPlayManager_netPlayGetBanList( + JNIEnv* env, [[maybe_unused]] jobject obj) { + return Common::Android::ToJStringArray(env, NetPlayGetBanList()); +} + +JNIEXPORT void JNICALL Java_org_citron_citron_1emu_network_NetPlayManager_netPlayBanUser( + JNIEnv* env, [[maybe_unused]] jobject obj, jstring username) { + NetPlayBanUser(Common::Android::GetJString(env, username)); +} + +JNIEXPORT void JNICALL Java_org_citron_citron_1emu_network_NetPlayManager_netPlayUnbanUser( + JNIEnv* env, [[maybe_unused]] jobject obj, jstring username) { + NetPlayUnbanUser(Common::Android::GetJString(env, username)); +} + +JNIEXPORT void JNICALL +Java_org_citron_citron_1emu_NativeLibrary_netPlayInit( + JNIEnv* env, [[maybe_unused]] jobject obj) { + NetworkInit(&EmulationSession::GetInstance().System().GetRoomNetwork()); +} + } // extern "C" diff --git a/src/android/app/src/main/res/drawable/ic_chat.xml b/src/android/app/src/main/res/drawable/ic_chat.xml new file mode 100644 index 000000000..e0efa062b --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_chat.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/drawable/ic_ip.xml b/src/android/app/src/main/res/drawable/ic_ip.xml new file mode 100644 index 000000000..19f719b39 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_ip.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/drawable/ic_joined.xml b/src/android/app/src/main/res/drawable/ic_joined.xml new file mode 100644 index 000000000..c86e96da4 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_joined.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/drawable/ic_multiplayer.xml b/src/android/app/src/main/res/drawable/ic_multiplayer.xml new file mode 100644 index 000000000..cf3e49fcc --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_multiplayer.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/drawable/ic_network.xml b/src/android/app/src/main/res/drawable/ic_network.xml new file mode 100644 index 000000000..eef8a0b43 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_network.xml @@ -0,0 +1,10 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/ic_send.xml b/src/android/app/src/main/res/drawable/ic_send.xml new file mode 100644 index 000000000..fa2074057 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_send.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/drawable/ic_system.xml b/src/android/app/src/main/res/drawable/ic_system.xml new file mode 100644 index 000000000..63fd22d7d --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_system.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/drawable/ic_user.xml b/src/android/app/src/main/res/drawable/ic_user.xml new file mode 100644 index 000000000..606e966ca --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_user.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/layout/dialog_ban_list.xml b/src/android/app/src/main/res/layout/dialog_ban_list.xml new file mode 100644 index 000000000..eb4082717 --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_ban_list.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/src/android/app/src/main/res/layout/dialog_bottom_sheet.xml b/src/android/app/src/main/res/layout/dialog_bottom_sheet.xml new file mode 100644 index 000000000..6dd10d97b --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_bottom_sheet.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/dialog_chat.xml b/src/android/app/src/main/res/layout/dialog_chat.xml new file mode 100644 index 000000000..d62ef0802 --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_chat.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/layout/dialog_multiplayer_connect.xml b/src/android/app/src/main/res/layout/dialog_multiplayer_connect.xml new file mode 100644 index 000000000..36a77d395 --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_multiplayer_connect.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/layout/dialog_multiplayer_lobby.xml b/src/android/app/src/main/res/layout/dialog_multiplayer_lobby.xml new file mode 100644 index 000000000..19368bc2c --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_multiplayer_lobby.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/layout/dialog_multiplayer_room.xml b/src/android/app/src/main/res/layout/dialog_multiplayer_room.xml new file mode 100644 index 000000000..53afda931 --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_multiplayer_room.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/layout/item_ban_list.xml b/src/android/app/src/main/res/layout/item_ban_list.xml new file mode 100644 index 000000000..32a101277 --- /dev/null +++ b/src/android/app/src/main/res/layout/item_ban_list.xml @@ -0,0 +1,28 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/layout/item_button_netplay.xml b/src/android/app/src/main/res/layout/item_button_netplay.xml new file mode 100644 index 000000000..494cc8878 --- /dev/null +++ b/src/android/app/src/main/res/layout/item_button_netplay.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/src/android/app/src/main/res/layout/item_chat_message.xml b/src/android/app/src/main/res/layout/item_chat_message.xml new file mode 100644 index 000000000..f4ce137e7 --- /dev/null +++ b/src/android/app/src/main/res/layout/item_chat_message.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/layout/item_netplay_button.xml b/src/android/app/src/main/res/layout/item_netplay_button.xml new file mode 100644 index 000000000..f324e8e26 --- /dev/null +++ b/src/android/app/src/main/res/layout/item_netplay_button.xml @@ -0,0 +1,25 @@ + + + + + +