diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml
index 84c9877e5..043805bc1 100644
--- a/src/android/app/src/main/AndroidManifest.xml
+++ b/src/android/app/src/main/AndroidManifest.xml
@@ -11,6 +11,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
+
diff --git a/src/android/app/src/main/java/org/citron/citron_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/citron/citron_emu/NativeLibrary.kt
index fc63dc276..e2404b8d8 100644
--- a/src/android/app/src/main/java/org/citron/citron_emu/NativeLibrary.kt
+++ b/src/android/app/src/main/java/org/citron/citron_emu/NativeLibrary.kt
@@ -21,6 +21,7 @@ import org.citron.citron_emu.utils.Log
import org.citron.citron_emu.model.InstallResult
import org.citron.citron_emu.model.Patch
import org.citron.citron_emu.model.GameVerificationResult
+import org.citron.citron_emu.utils.NetPlayManager
/**
* Class which contains methods that interact
@@ -307,6 +308,24 @@ object NativeLibrary {
sEmulationActivity.clear()
}
+ @Keep
+ @JvmStatic
+ fun addNetPlayMessage(type: Int, message: String) {
+ val emulationActivity = sEmulationActivity.get()
+ if (emulationActivity != null) {
+ emulationActivity.addNetPlayMessages(type, message)
+ }
+ else {
+ NetPlayManager.addNetPlayMessage(type, message)
+ }
+ }
+
+ @Keep
+ @JvmStatic
+ fun clearChat() {
+ NetPlayManager.clearChat()
+ }
+
@Keep
@JvmStatic
fun onEmulationStarted() {
diff --git a/src/android/app/src/main/java/org/citron/citron_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/citron/citron_emu/activities/EmulationActivity.kt
index 816194a9a..7c0a5a4ab 100644
--- a/src/android/app/src/main/java/org/citron/citron_emu/activities/EmulationActivity.kt
+++ b/src/android/app/src/main/java/org/citron/citron_emu/activities/EmulationActivity.kt
@@ -39,6 +39,7 @@ import org.citron.citron_emu.NativeLibrary
import org.citron.citron_emu.R
import org.citron.citron_emu.CitronApplication
import org.citron.citron_emu.databinding.ActivityEmulationBinding
+import org.citron.citron_emu.dialogs.NetPlayDialog
import org.citron.citron_emu.features.input.NativeInput
import org.citron.citron_emu.features.settings.model.BooleanSetting
import org.citron.citron_emu.features.settings.model.IntSetting
@@ -49,6 +50,7 @@ import org.citron.citron_emu.utils.InputHandler
import org.citron.citron_emu.utils.Log
import org.citron.citron_emu.utils.MemoryUtil
import org.citron.citron_emu.utils.NativeConfig
+import org.citron.citron_emu.utils.NetPlayManager
import org.citron.citron_emu.utils.NfcReader
import org.citron.citron_emu.utils.ParamPackage
import org.citron.citron_emu.utils.ThemeHelper
@@ -317,6 +319,15 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener {
return this.apply { aspectRatio?.let { setAspectRatio(it) } }
}
+ fun displayMultiplayerDialog() {
+ val dialog = NetPlayDialog(this)
+ dialog.show()
+ }
+
+ fun addNetPlayMessages(type: Int, msg: String) {
+ NetPlayManager.addNetPlayMessage(type, msg)
+ }
+
private fun PictureInPictureParams.Builder.getPictureInPictureActionsBuilder():
PictureInPictureParams.Builder {
val pictureInPictureActions: MutableList = mutableListOf()
diff --git a/src/android/app/src/main/java/org/citron/citron_emu/dialogs/ChatDialog.kt b/src/android/app/src/main/java/org/citron/citron_emu/dialogs/ChatDialog.kt
new file mode 100644
index 000000000..c70ccee0f
--- /dev/null
+++ b/src/android/app/src/main/java/org/citron/citron_emu/dialogs/ChatDialog.kt
@@ -0,0 +1,134 @@
+package org.citron.citron_emu.dialogs
+
+import android.content.Context
+import android.content.res.Configuration
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import org.citron.citron_emu.R
+import org.citron.citron_emu.databinding.DialogChatBinding
+import org.citron.citron_emu.databinding.ItemChatMessageBinding
+import org.citron.citron_emu.utils.NetPlayManager
+import java.text.SimpleDateFormat
+import java.util.*
+
+class ChatMessage(
+ val nickname: String, // This is the common name youll see on private servers
+ val username: String, // Username is the community/forum username
+ val message: String,
+ val timestamp: String = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date())
+) {
+}
+
+class ChatDialog(context: Context) : BottomSheetDialog(context) {
+ private lateinit var binding: DialogChatBinding
+ private lateinit var chatAdapter: ChatAdapter
+ private val handler = Handler(Looper.getMainLooper())
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = DialogChatBinding.inflate(LayoutInflater.from(context))
+ setContentView(binding.root)
+
+ NetPlayManager.setChatOpen(true)
+ setupRecyclerView()
+
+ behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ behavior.skipCollapsed = context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+
+
+ handler.post {
+ chatAdapter.notifyDataSetChanged()
+ binding.chatRecyclerView.post {
+ scrollToBottom()
+ }
+ }
+
+ NetPlayManager.setOnMessageReceivedListener { type, message ->
+ handler.post {
+ chatAdapter.notifyDataSetChanged()
+ scrollToBottom()
+ }
+ }
+
+ binding.sendButton.setOnClickListener {
+ val message = binding.chatInput.text.toString()
+ if (message.isNotBlank()) {
+ sendMessage(message)
+ binding.chatInput.text?.clear()
+ }
+ }
+ }
+
+ override fun dismiss() {
+ NetPlayManager.setChatOpen(false)
+ super.dismiss()
+ }
+
+ private fun sendMessage(message: String) {
+ val username = NetPlayManager.getUsername(context)
+ NetPlayManager.netPlaySendMessage(message)
+
+ val chatMessage = ChatMessage(
+ nickname = username,
+ username = "",
+ message = message,
+ timestamp = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date())
+ )
+
+ NetPlayManager.addChatMessage(chatMessage)
+ chatAdapter.notifyDataSetChanged()
+ scrollToBottom()
+ }
+
+ private fun setupRecyclerView() {
+ chatAdapter = ChatAdapter(NetPlayManager.getChatMessages())
+ binding.chatRecyclerView.layoutManager = LinearLayoutManager(context).apply {
+ stackFromEnd = true
+ }
+ binding.chatRecyclerView.adapter = chatAdapter
+ }
+
+ private fun scrollToBottom() {
+ binding.chatRecyclerView.scrollToPosition(chatAdapter.itemCount - 1)
+ }
+}
+
+class ChatAdapter(private val messages: List) :
+ 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.timestampText.text = message.timestamp
+ 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..ea212f118
--- /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 android.content.res.Configuration
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+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.R
+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.utils.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 {
+ inflate(R.menu.menu_netplay_member)
+ 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(android.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(android.R.string.cancel, null)
+ .show()
+ }
+
+ adapter = BanListAdapter(banList, onUnban)
+ recyclerView.adapter = adapter
+
+ dialog.setView(view)
+ dialog.setPositiveButton(android.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 93a15def9..90b2b9e8a 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
@@ -279,6 +279,11 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
true
}
+ R.id.menu_multiplayer -> {
+ emulationActivity?.displayMultiplayerDialog()
+ true
+ }
+
R.id.menu_lock_drawer -> {
when (IntSetting.LOCK_DRAWER.getInt()) {
DrawerLayout.LOCK_MODE_UNLOCKED -> {
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..634a2a4fa 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
@@ -155,6 +155,14 @@ class HomeSettingsFragment : Fragment() {
}
)
)
+ add(
+ HomeSetting(
+ R.string.multiplayer,
+ R.string.multiplayer_description,
+ R.drawable.ic_multiplayer,
+ { mainActivity.displayMultiplayerDialog() }
+ ),
+ )
add(
HomeSetting(
R.string.verify_installed_content,
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..05bca8107 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
@@ -178,6 +179,11 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
showNavigation(visible = true, animated = true)
}
+ fun displayMultiplayerDialog() {
+ val dialog = NetPlayDialog(this)
+ dialog.show()
+ }
+
private fun setUpNavigation(navController: NavController) {
val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext)
.getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true)
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/java/org/citron/citron_emu/utils/NetPlayManager.kt b/src/android/app/src/main/java/org/citron/citron_emu/utils/NetPlayManager.kt
new file mode 100644
index 000000000..1437e438b
--- /dev/null
+++ b/src/android/app/src/main/java/org/citron/citron_emu/utils/NetPlayManager.kt
@@ -0,0 +1,223 @@
+// 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.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 = "Citron${(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 {
+ Log.info("Netplay Ban ${netPlayGetBanList()}.toList()")
+ 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/jni/CMakeLists.txt b/src/android/app/src/main/jni/CMakeLists.txt
index c9c695125..23b5fef91 100644
--- a/src/android/app/src/main/jni/CMakeLists.txt
+++ b/src/android/app/src/main/jni/CMakeLists.txt
@@ -13,6 +13,8 @@ add_library(citron-android SHARED
android_config.cpp
android_config.h
native_input.cpp
+ multiplayer.cpp
+ multiplayer.h
)
set_property(TARGET citron-android PROPERTY IMPORTED_LOCATION ${FFmpeg_LIBRARY_DIR})
diff --git a/src/android/app/src/main/jni/multiplayer.cpp b/src/android/app/src/main/jni/multiplayer.cpp
new file mode 100644
index 000000000..1b41ca1f1
--- /dev/null
+++ b/src/android/app/src/main/jni/multiplayer.cpp
@@ -0,0 +1,334 @@
+// Copyright 2025 Citra Project / Mandarine Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+#include "multiplayer.h"
+#include "core/core.h"
+#include "network/network.h"
+#include "common/android/android_common.h"
+#include "common/android/id_cache.h"
+#include
+#include
+
+void AddNetPlayMessage(jint type, jstring msg) {
+ Common::Android::GetEnvForThread()->CallStaticVoidMethod(Common::Android::GetNativeLibraryClass(),
+ reinterpret_cast(AddNetPlayMessage), type, msg);
+}
+
+void AddNetPlayMessage(int type, const std::string& msg) {
+ JNIEnv* env = Common::Android::GetEnvForThread();
+ jmethodID method_id = env->GetStaticMethodID(Common::Android::GetNativeLibraryClass(), "addNetPlayMessage", "(ILjava/lang/String;)V");
+ env->CallStaticVoidMethod(Common::Android::GetNativeLibraryClass(), method_id, type, Common::Android::ToJString(env, msg));
+}
+
+void ClearChat() {
+ JNIEnv* env = Common::Android::GetEnvForThread();
+ jmethodID method_id = env->GetStaticMethodID(Common::Android::GetNativeLibraryClass(), "clearChat", "()V");
+ env->CallStaticVoidMethod(Common::Android::GetNativeLibraryClass(), method_id);
+}
+
+bool NetworkInit() {
+ bool result = Network::RoomNetwork().Init();
+
+ if (!result) {
+ return false;
+ }
+
+ if (auto member = Network::RoomNetwork().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::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;
+ case Network::RoomMember::Error::IpCollision:
+ status = NetPlayStatus::MAC_COLLISION;
+ 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;
+}
+
+const AnnounceMultiplayerRoom::GameInfo game{
+ "",
+ 0,
+ ""
+};
+
+NetPlayStatus NetPlayCreateRoom(const std::string& ipaddress, int port, const std::string& username,
+ const std::string& password, const std::string& room_name,
+ int max_players) {
+
+ auto member = Network::RoomNetwork().GetRoomMember().lock();
+ if (!member) {
+ return NetPlayStatus::NETWORK_ERROR;
+ }
+
+ if (member->GetState() == Network::RoomMember::State::Joining || member->IsConnected()) {
+ return NetPlayStatus::ALREADY_IN_ROOM;
+ }
+
+ auto room = Network::RoomNetwork().GetRoom().lock();
+ if (!room) {
+ return NetPlayStatus::NETWORK_ERROR;
+ }
+
+ if (room_name.length() < 3 || room_name.length() > 20) {
+ return NetPlayStatus::CREATE_ROOM_ERROR;
+ }
+
+ if (!room->Create(room_name, "", ipaddress, port, password, std::min(max_players, 16), username,
+ game, std::make_unique(), {})) {
+ 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(), 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 = Network::RoomNetwork().GetRoomMember().lock();
+ if (!member) {
+ return NetPlayStatus::NETWORK_ERROR;
+ }
+
+ if (member->GetState() == Network::RoomMember::State::Joining || member->IsConnected()) {
+ return NetPlayStatus::ALREADY_IN_ROOM;
+ }
+
+ member->Join(username, ipaddress.c_str(), 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 = Network::RoomNetwork().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 = Network::RoomNetwork().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 = Network::RoomNetwork().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 = Network::RoomNetwork().GetRoomMember().lock()) {
+ room->SendModerationRequest(Network::RoomMessageTypes::IdModUnban, username);
+ }
+}
+
+std::vector NetPlayRoomInfo() {
+ std::vector info_list;
+ if (auto room = Network::RoomNetwork().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 = Network::RoomNetwork().GetRoomMember().lock();
+ if (!member) {
+ return false;
+ }
+
+ return (member->GetState() == Network::RoomMember::State::Joined ||
+ member->GetState() == Network::RoomMember::State::Moderator);
+}
+
+bool NetPlayIsHostedRoom() {
+ if (auto room = Network::RoomNetwork().GetRoom().lock()) {
+ return room->GetState() == Network::Room::State::Open;
+ }
+ return false;
+}
+
+void NetPlayLeaveRoom() {
+ if (auto room = Network::RoomNetwork().GetRoom().lock()) {
+ // if you are in a room, leave it
+ if (auto member = Network::RoomNetwork().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() {
+ Network::RoomNetwork().Shutdown();
+}
+
+bool NetPlayIsModerator() {
+ auto member = Network::RoomNetwork().GetRoomMember().lock();
+ if (!member) {
+ return false;
+ }
+ return member->GetState() == Network::RoomMember::State::Moderator;
+}
+
+std::vector NetPlayGetBanList() {
+ std::vector ban_list;
+ if (auto room = Network::RoomNetwork().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/android/app/src/main/jni/multiplayer.h b/src/android/app/src/main/jni/multiplayer.h
new file mode 100644
index 000000000..a456a4b76
--- /dev/null
+++ b/src/android/app/src/main/jni/multiplayer.h
@@ -0,0 +1,64 @@
+// Copyright 2025 Citra Project / Mandarine Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#pragma once
+
+#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();
+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/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp
index eae276e96..db38232b1 100644
--- a/src/android/app/src/main/jni/native.cpp
+++ b/src/android/app/src/main/jni/native.cpp
@@ -62,6 +62,7 @@
#include "video_core/renderer_vulkan/renderer_vulkan.h"
#include "video_core/vulkan_common/vulkan_instance.h"
#include "video_core/vulkan_common/vulkan_surface.h"
+#include "multiplayer.h"
#define jconst [[maybe_unused]] const auto
#define jauto [[maybe_unused]] auto
@@ -705,6 +706,71 @@ void Java_org_citron_citron_1emu_NativeLibrary_setCabinetMode(JNIEnv* env, jclas
static_cast(jcabinetMode));
}
+JNIEXPORT jint JNICALL Java_org_citron_citron_1emu_utils_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_utils_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_utils_NetPlayManager_netPlayRoomInfo(
+ JNIEnv* env, [[maybe_unused]] jobject obj) {
+ return Common::Android::ToJStringArray(env, NetPlayRoomInfo());
+}
+JNIEXPORT jboolean JNICALL
+Java_org_citron_citron_1emu_utils_NetPlayManager_netPlayIsJoined(
+ [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj) {
+ return NetPlayIsJoined();
+}
+JNIEXPORT jboolean JNICALL
+Java_org_citron_citron_1emu_utils_NetPlayManager_netPlayIsHostedRoom(
+ [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj) {
+ return NetPlayIsHostedRoom();
+}
+JNIEXPORT void JNICALL
+Java_org_citron_citron_1emu_utils_NetPlayManager_netPlaySendMessage(
+ JNIEnv* env, [[maybe_unused]] jobject obj, jstring msg) {
+ NetPlaySendMessage(Common::Android::GetJString(env, msg));
+}
+JNIEXPORT void JNICALL Java_org_citron_citron_1emu_utils_NetPlayManager_netPlayKickUser(
+ JNIEnv* env, [[maybe_unused]] jobject obj, jstring username) {
+ NetPlayKickUser(Common::Android::GetJString(env, username));
+}
+JNIEXPORT void JNICALL Java_org_citron_citron_1emu_utils_NetPlayManager_netPlayLeaveRoom(
+ [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj) {
+ NetPlayLeaveRoom();
+}
+
+JNIEXPORT jboolean JNICALL
+Java_org_citron_citron_1emu_utils_NetPlayManager_netPlayIsModerator(
+ [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj) {
+ return NetPlayIsModerator();
+}
+
+JNIEXPORT jobjectArray JNICALL
+Java_org_citron_citron_1emu_utils_NetPlayManager_netPlayGetBanList(
+ JNIEnv* env, [[maybe_unused]] jobject obj) {
+ return Common::Android::ToJStringArray(env, NetPlayGetBanList());
+}
+
+JNIEXPORT void JNICALL Java_org_citron_citron_1emu_utils_NetPlayManager_netPlayBanUser(
+ JNIEnv* env, [[maybe_unused]] jobject obj, jstring username) {
+ NetPlayBanUser(Common::Android::GetJString(env, username));
+}
+
+JNIEXPORT void JNICALL Java_org_citron_citron_1emu_utils_NetPlayManager_netPlayUnbanUser(
+ JNIEnv* env, [[maybe_unused]] jobject obj, jstring username) {
+ NetPlayUnbanUser(Common::Android::GetJString(env, username));
+}
+
+
jboolean Java_org_citron_citron_1emu_NativeLibrary_isFirmwareAvailable(JNIEnv* env, jclass clazz) {
auto bis_system =
EmulationSession::GetInstance().System().GetFileSystemController().GetSystemNANDContents();
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_more_vert.xml b/src/android/app/src/main/res/drawable/ic_more_vert.xml
index 9f62ac595..9be1f70a4 100644
--- a/src/android/app/src/main/res/drawable/ic_more_vert.xml
+++ b/src/android/app/src/main/res/drawable/ic_more_vert.xml
@@ -1,9 +1,10 @@
+
+ android:viewportHeight="24">
-
+ android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
+
\ 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..bb3d4616e
--- /dev/null
+++ b/src/android/app/src/main/res/layout/item_chat_message.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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/menu_in_game.xml b/src/android/app/src/main/res/menu/menu_in_game.xml
index 867197ebc..fb8a79741 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
@@ -26,6 +26,11 @@
android:icon="@drawable/ic_overlay"
android:title="@string/emulation_input_overlay" />
+
+
-
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index 5133fa98e..c795afa88 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -649,6 +649,74 @@
8x
16x
+
+ Multiplayer
+ Host your own game room or join an existing one to play with people
+ Room: %1$s
+ Console ID:%1$s
+ Create
+ Join
+ Username
+ IP Address
+ Port
+ Room created successfully!
+ Join the room successfully!
+ Failed to create room!
+ Failed to join room!
+ Invalid address or name is too short!
+ Invalid port!
+ Exit Room
+ 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
+ %1$s joined
+ %1$s left
+ %1$s kicked
+ %1$s banned
+ address unbanned
+ Kick Out
+ Send messages……
+ Password
+ Join
+ Joining...
+ Room Name
+ Room name must be between 3 and 20 characters
+ Max Players (16)
+ Max Players: %d
+ Chat
+ More Options
+ IP Address copied to clipboard
+ Server Address
+ Chat
+ Type message……
+ Send
+ Send Message
+ Moderation
+ Ban List
+ No banned users
+ Unban User
+ Unban
+ Are you sure you want to unban %1$s?
+ Ban User
+
Black backgrounds
When using the dark theme, apply black backgrounds.
diff --git a/src/common/android/android_common.cpp b/src/common/android/android_common.cpp
index e79005658..c52242b32 100644
--- a/src/common/android/android_common.cpp
+++ b/src/common/android/android_common.cpp
@@ -38,6 +38,15 @@ jstring ToJString(JNIEnv* env, std::u16string_view str) {
return ToJString(env, Common::UTF16ToUTF8(str));
}
+jobjectArray ToJStringArray(JNIEnv* env, const std::vector& strs) {
+ jobjectArray array =
+ env->NewObjectArray(static_cast(strs.size()), env->FindClass("java/lang/String"), env->NewStringUTF(""));
+ for (size_t i = 0; i < strs.size(); ++i) {
+ env->SetObjectArrayElement(array, static_cast(i), ToJString(env, strs[i]));
+ }
+ return array;
+}
+
double GetJDouble(JNIEnv* env, jobject jdouble) {
return env->GetDoubleField(jdouble, GetDoubleValueField());
}
diff --git a/src/common/android/android_common.h b/src/common/android/android_common.h
index d0ccb4ec2..3c4fc693e 100644
--- a/src/common/android/android_common.h
+++ b/src/common/android/android_common.h
@@ -11,6 +11,7 @@
namespace Common::Android {
std::string GetJString(JNIEnv* env, jstring jstr);
+jobjectArray ToJStringArray(JNIEnv* env, const std::vector& strs);
jstring ToJString(JNIEnv* env, std::string_view str);
jstring ToJString(JNIEnv* env, std::u16string_view str);
diff --git a/src/common/android/id_cache.cpp b/src/common/android/id_cache.cpp
index cc837c535..cffaef637 100644
--- a/src/common/android/id_cache.cpp
+++ b/src/common/android/id_cache.cpp
@@ -8,6 +8,7 @@
#include "common/assert.h"
#include "common/fs/fs_android.h"
#include "video_core/rasterizer_interface.h"
+#include "android\app\src\main\jni\multiplayer.h"
static JavaVM* s_java_vm;
static jclass s_native_library_class;
@@ -88,6 +89,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;
@@ -144,6 +147,15 @@ jmethodID GetOnEmulationStarted() {
return s_on_emulation_started;
}
+jmethodID GetAddNetPlayMessage() {
+ return s_add_netplay_message;
+}
+
+jmethodID ClearChat() {
+ return s_clear_chat;
+}
+
+
jmethodID GetOnEmulationStopped() {
return s_on_emulation_stopped;
}
@@ -547,6 +559,9 @@ 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);
@@ -554,6 +569,9 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
// Initialize applets
Common::Android::SoftwareKeyboard::InitJNI(env);
+ // Init network for multiplayer
+ NetworkInit();
+
return JNI_VERSION;
}
@@ -582,6 +600,9 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) {
// UnInitialize applets
SoftwareKeyboard::CleanupJNI(env);
+
+ // Shutdown network for multiplayer
+ NetworkShutdown();
}
#ifdef __cplusplus