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 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/item_netplay_separator.xml b/src/android/app/src/main/res/layout/item_netplay_separator.xml
new file mode 100644
index 000000000..38def7eed
--- /dev/null
+++ b/src/android/app/src/main/res/layout/item_netplay_separator.xml
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/item_netplay_text.xml b/src/android/app/src/main/res/layout/item_netplay_text.xml
new file mode 100644
index 000000000..ed4be66e7
--- /dev/null
+++ b/src/android/app/src/main/res/layout/item_netplay_text.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/item_separator_netplay.xml b/src/android/app/src/main/res/layout/item_separator_netplay.xml
new file mode 100644
index 000000000..99eb7d01a
--- /dev/null
+++ b/src/android/app/src/main/res/layout/item_separator_netplay.xml
@@ -0,0 +1,7 @@
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/item_text_netplay.xml b/src/android/app/src/main/res/layout/item_text_netplay.xml
new file mode 100644
index 000000000..f8039d826
--- /dev/null
+++ b/src/android/app/src/main/res/layout/item_text_netplay.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/menu/item_text_netplay.xml b/src/android/app/src/main/res/menu/item_text_netplay.xml
new file mode 100644
index 000000000..f8039d826
--- /dev/null
+++ b/src/android/app/src/main/res/menu/item_text_netplay.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/menu/menu_in_game.xml b/src/android/app/src/main/res/menu/menu_in_game.xml
index 867197ebc..2f4285de8 100644
--- a/src/android/app/src/main/res/menu/menu_in_game.xml
+++ b/src/android/app/src/main/res/menu/menu_in_game.xml
@@ -1,5 +1,7 @@
-
+ Multiplayer
+ Cancel
+ Ok
diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt
index 823db6d64..b1f55c4ff 100644
--- a/src/common/CMakeLists.txt
+++ b/src/common/CMakeLists.txt
@@ -189,6 +189,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 e79005658..e344752f8 100644
--- a/src/common/android/android_common.cpp
+++ b/src/common/android/android_common.cpp
@@ -34,6 +34,15 @@ jstring ToJString(JNIEnv* env, std::string_view str) {
static_cast(converted_string.size()));
}
+jobjectArray ToJStringArray(JNIEnv* env, const std::vector& strs) {
+ jobjectArray array =
+ env->NewObjectArray(static_cast(strs.size()), env->FindClass("java/lang/String"), env->NewStringUTF(""));
+ for (std::size_t i = 0; i < strs.size(); ++i) {
+ env->SetObjectArrayElement(array, static_cast(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 d0ccb4ec2..f0bdf62a7 100644
--- a/src/common/android/android_common.h
+++ b/src/common/android/android_common.h
@@ -19,7 +19,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& 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 cc837c535..b0db7b99b 100644
--- a/src/common/android/id_cache.cpp
+++ b/src/common/android/id_cache.cpp
@@ -8,6 +8,9 @@
#include "common/assert.h"
#include "common/fs/fs_android.h"
#include "video_core/rasterizer_interface.h"
+#include "common/android/multiplayer/multiplayer.h"
+#include
+
static JavaVM* s_java_vm;
static jclass s_native_library_class;
@@ -88,6 +91,8 @@ static jmethodID s_citron_input_device_get_supports_vibration;
static jmethodID s_citron_input_device_vibrate;
static jmethodID s_citron_input_device_get_axes;
static jmethodID s_citron_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 +393,15 @@ jmethodID GetCitronDeviceHasKeys() {
return s_citron_input_device_has_keys;
}
+jmethodID GetAddNetPlayMessage() {
+ return s_add_netplay_message;
+}
+
+jmethodID ClearChat() {
+ return s_clear_chat;
+}
+
+
#ifdef __cplusplus
extern "C" {
#endif
@@ -547,6 +561,10 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
s_citron_input_device_has_keys =
env->GetMethodID(citron_input_device_interface, "hasKeys", "([I)[Z");
env->DeleteLocalRef(citron_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 +600,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 ec30b5fbb..9ccc117e7 100644
--- a/src/common/android/id_cache.h
+++ b/src/common/android/id_cache.h
@@ -5,6 +5,7 @@
#include
#include
+#include
#include "video_core/rasterizer_interface.h"
@@ -108,5 +109,6 @@ jmethodID GetCitronDeviceGetSupportsVibration();
jmethodID GetCitronDeviceVibrate();
jmethodID GetCitronDeviceGetAxes();
jmethodID GetCitronDeviceHasKeys();
-
-} // namespace Common::Android
+jmethodID GetAddNetPlayMessage();
+jmethodID ClearChat();
+} // namespace Android
diff --git a/src/common/android/multiplayer/multiplayer.cpp b/src/common/android/multiplayer/multiplayer.cpp
new file mode 100644
index 000000000..6da730ed5
--- /dev/null
+++ b/src/common/android/multiplayer/multiplayer.cpp
@@ -0,0 +1,350 @@
+// Copyright 2024 Mandarine Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#include "common/android/id_cache.h"
+#include "multiplayer.h"
+
+#include "common/android/android_common.h"
+
+#include "core/core.h"
+#include "network/network.h"
+#include "android/log.h"
+
+
+#include
+#include
+
+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(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(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(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(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(port);
+
+ if (!room->Create(room_name, "", ipaddress, static_cast(port), password,
+ static_cast(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(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(port);
+
+
+ if (member->GetState() == Network::RoomMember::State::Joining || member->IsConnected()) {
+ return NetPlayStatus::ALREADY_IN_ROOM;
+ }
+
+ member->Join(username, ipaddress.c_str(), static_cast(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 NetPlayRoomInfo() {
+ std::vector 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 NetPlayGetBanList() {
+ std::vector ban_list;
+ if (auto room = room_network->GetRoom().lock()) {
+ auto [username_bans, ip_bans] = room->GetBanList();
+
+ // Add username bans
+ for (const auto& username : username_bans) {
+ ban_list.push_back(username);
+ }
+
+ // Add IP bans
+ for (const auto& ip : ip_bans) {
+ ban_list.push_back(ip);
+ }
+ }
+ return ban_list;
+}
diff --git a/src/common/android/multiplayer/multiplayer.h b/src/common/android/multiplayer/multiplayer.h
new file mode 100644
index 000000000..657624e45
--- /dev/null
+++ b/src/common/android/multiplayer/multiplayer.h
@@ -0,0 +1,65 @@
+// Copyright 2024 Mandarine Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#pragma once
+
+#include
+#include
+
+#include
+#include
+
+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 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 NetPlayGetBanList();
+void NetPlayUnbanUser(const std::string& username);
diff --git a/src/common/announce_multiplayer_room.h b/src/common/announce_multiplayer_room.h
index a80c903a2..0a4719427 100644
--- a/src/common/announce_multiplayer_room.h
+++ b/src/common/announce_multiplayer_room.h
@@ -35,7 +35,6 @@ struct RoomInformation {
u16 port; ///< The port of this room
GameInfo preferred_game; ///< Game to advertise that you want to play
std::string host_username; ///< Forum username of the host
- bool enable_citron_mods; ///< Allow citron Moderators to moderate on this room
};
struct Room {
diff --git a/src/dedicated_room/citron_room.cpp b/src/dedicated_room/citron_room.cpp
index 4ebc01f25..743a946cb 100644
--- a/src/dedicated_room/citron_room.cpp
+++ b/src/dedicated_room/citron_room.cpp
@@ -200,7 +200,6 @@ int main(int argc, char** argv) {
u64 preferred_game_id = 0;
u32 port = Network::DefaultRoomPort;
u32 max_members = 16;
- bool enable_citron_mods = false;
static struct option long_options[] = {
{"room-name", required_argument, 0, 'n'},
@@ -268,9 +267,6 @@ int main(int argc, char** argv) {
case 'l':
log_file.assign(optarg);
break;
- case 'e':
- enable_citron_mods = true;
- break;
case 'h':
PrintHelp(argv[0]);
return 0;
@@ -338,10 +334,6 @@ int main(int argc, char** argv) {
Settings::values.citron_token = token;
}
}
- if (!announce && enable_citron_mods) {
- enable_citron_mods = false;
- LOG_INFO(Network, "Can not enable citron Moderators for private rooms");
- }
// Load the ban list
Network::Room::BanList ban_list;
@@ -370,7 +362,7 @@ int main(int argc, char** argv) {
.id = preferred_game_id};
if (!room->Create(room_name, room_description, bind_address, static_cast(port),
password, max_members, username, preferred_game_info,
- std::move(verify_backend), ban_list, enable_citron_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 06d1c9509..682de720e 100644
--- a/src/network/room.cpp
+++ b/src/network/room.cpp
@@ -355,7 +355,14 @@ void Room::RoomImpl::HandleJoinRequest(const ENetEvent* event) {
std::lock_guard lock(verify_uid_mutex);
uid = verify_uid;
}
- member.user_data = verify_backend->LoadUserData(uid, token);
+
+ if (verify_backend != nullptr)
+ member.user_data = verify_backend->LoadUserData(uid, token);
+
+ if (nickname == room_information.host_username) {
+ member.user_data.moderator = true;
+ LOG_INFO(Network, "User {} is a moderator", std::string(room_information.host_username));
+ }
std::string ip;
{
@@ -574,8 +581,7 @@ bool Room::RoomImpl::HasModPermission(const ENetPeer* client) const {
if (sending_member == members.end()) {
return false;
}
- if (room_information.enable_citron_mods &&
- sending_member->user_data.moderator) { // Community moderator
+ if (sending_member->user_data.moderator) { // Community moderator
return true;
}
@@ -1047,7 +1053,7 @@ bool Room::Create(const std::string& name, const std::string& description,
const u32 max_connections, const std::string& host_username,
const GameInfo preferred_game,
std::unique_ptr verify_backend,
- const Room::BanList& ban_list, bool enable_citron_mods) {
+ const Room::BanList& ban_list) {
ENetAddress address;
address.host = ENET_HOST_ANY;
if (!server_address.empty()) {
@@ -1069,7 +1075,6 @@ bool Room::Create(const std::string& name, const std::string& description,
room_impl->room_information.port = server_port;
room_impl->room_information.preferred_game = preferred_game;
room_impl->room_information.host_username = host_username;
- room_impl->room_information.enable_citron_mods = enable_citron_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 9226ce874..10cff928e 100644
--- a/src/network/room.h
+++ b/src/network/room.h
@@ -123,7 +123,7 @@ public:
const u32 max_connections = MaxConcurrentConnections,
const std::string& host_username = "", const GameInfo = {},
std::unique_ptr verify_backend = nullptr,
- const BanList& ban_list = {}, bool enable_citron_mods = false);
+ const BanList& ban_list = {});
/**
* Sets the verification GUID of the room.