android: Initial multiplayer support

And give room owners mod access on both Android and QT
This commit is contained in:
vampiric_x 2025-03-10 22:37:56 +01:00
parent ae75413cc3
commit e7e9453667
48 changed files with 2101 additions and 20 deletions

View file

@ -14,6 +14,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.NFC" /> <uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<application <application

View file

@ -21,6 +21,7 @@ import org.citron.citron_emu.utils.Log
import org.citron.citron_emu.model.InstallResult import org.citron.citron_emu.model.InstallResult
import org.citron.citron_emu.model.Patch import org.citron.citron_emu.model.Patch
import org.citron.citron_emu.model.GameVerificationResult import org.citron.citron_emu.model.GameVerificationResult
import org.citron.citron_emu.network.NetPlayManager
import java.net.NetworkInterface import java.net.NetworkInterface
/** /**
@ -243,6 +244,27 @@ object NativeLibrary {
return coreErrorAlertResult return coreErrorAlertResult
} }
@Keep
@JvmStatic
fun addNetPlayMessage(type: Int, message: String) {
val emulationActivity = sEmulationActivity.get()
if (emulationActivity != null) {
emulationActivity.addNetPlayMessages(type, message)
}
else {
NetPlayManager.addNetPlayMessage(type, message)
}
}
@Keep
@JvmStatic
fun clearChat() {
NetPlayManager.clearChat()
}
external fun netPlayInit()
@Keep @Keep
@JvmStatic @JvmStatic
fun exitEmulationActivity(resultCode: Int) { fun exitEmulationActivity(resultCode: Int) {

View file

@ -40,12 +40,14 @@ import org.citron.citron_emu.NativeLibrary
import org.citron.citron_emu.R import org.citron.citron_emu.R
import org.citron.citron_emu.CitronApplication import org.citron.citron_emu.CitronApplication
import org.citron.citron_emu.databinding.ActivityEmulationBinding 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.input.NativeInput
import org.citron.citron_emu.features.settings.model.BooleanSetting import org.citron.citron_emu.features.settings.model.BooleanSetting
import org.citron.citron_emu.features.settings.model.IntSetting import org.citron.citron_emu.features.settings.model.IntSetting
import org.citron.citron_emu.features.settings.model.Settings import org.citron.citron_emu.features.settings.model.Settings
import org.citron.citron_emu.model.EmulationViewModel import org.citron.citron_emu.model.EmulationViewModel
import org.citron.citron_emu.model.Game import org.citron.citron_emu.model.Game
import org.citron.citron_emu.network.NetPlayManager
import org.citron.citron_emu.utils.InputHandler import org.citron.citron_emu.utils.InputHandler
import org.citron.citron_emu.utils.Log import org.citron.citron_emu.utils.Log
import org.citron.citron_emu.utils.MemoryUtil import org.citron.citron_emu.utils.MemoryUtil
@ -423,6 +425,16 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener {
setPictureInPictureParams(pictureInPictureParamsBuilder.build()) setPictureInPictureParams(pictureInPictureParamsBuilder.build())
} }
fun displayMultiplayerDialog() {
val dialog = NetPlayDialog(this)
dialog.show()
}
fun addNetPlayMessages(type: Int, msg: String) {
NetPlayManager.addNetPlayMessage(type, msg)
}
private var pictureInPictureReceiver = object : BroadcastReceiver() { private var pictureInPictureReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent) { override fun onReceive(context: Context?, intent: Intent) {
if (intent.action == actionPlay) { if (intent.action == actionPlay) {

View file

@ -0,0 +1,133 @@
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.network.NetPlayManager
import java.text.SimpleDateFormat
import java.util.*
class ChatMessage(
val nickname: String, // This is the common name youll see on private servers
val username: String, // Username is the community/forum username
val message: String,
val timestamp: String = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date())
) {
}
class ChatDialog(context: Context) : BottomSheetDialog(context) {
private lateinit var binding: DialogChatBinding
private lateinit var chatAdapter: ChatAdapter
private val handler = Handler(Looper.getMainLooper())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DialogChatBinding.inflate(LayoutInflater.from(context))
setContentView(binding.root)
NetPlayManager.setChatOpen(true)
setupRecyclerView()
behavior.state = BottomSheetBehavior.STATE_EXPANDED
behavior.state = BottomSheetBehavior.STATE_EXPANDED
behavior.skipCollapsed = context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
handler.post {
chatAdapter.notifyDataSetChanged()
binding.chatRecyclerView.post {
scrollToBottom()
}
}
NetPlayManager.setOnMessageReceivedListener { type, message ->
handler.post {
chatAdapter.notifyDataSetChanged()
scrollToBottom()
}
}
binding.sendButton.setOnClickListener {
val message = binding.chatInput.text.toString()
if (message.isNotBlank()) {
sendMessage(message)
binding.chatInput.text?.clear()
}
}
}
override fun dismiss() {
NetPlayManager.setChatOpen(false)
super.dismiss()
}
private fun sendMessage(message: String) {
val username = NetPlayManager.getUsername(context)
NetPlayManager.netPlaySendMessage(message)
val chatMessage = ChatMessage(
nickname = username,
username = "",
message = message,
timestamp = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date())
)
NetPlayManager.addChatMessage(chatMessage)
chatAdapter.notifyDataSetChanged()
scrollToBottom()
}
private fun setupRecyclerView() {
chatAdapter = ChatAdapter(NetPlayManager.getChatMessages())
binding.chatRecyclerView.layoutManager = LinearLayoutManager(context).apply {
stackFromEnd = true
}
binding.chatRecyclerView.adapter = chatAdapter
}
private fun scrollToBottom() {
binding.chatRecyclerView.scrollToPosition(chatAdapter.itemCount - 1)
}
}
class ChatAdapter(private val messages: List<ChatMessage>) :
RecyclerView.Adapter<ChatAdapter.ChatViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChatViewHolder {
val binding = ItemChatMessageBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return ChatViewHolder(binding)
}
override fun getItemCount(): Int = messages.size
override fun onBindViewHolder(holder: ChatViewHolder, position: Int) {
holder.bind(messages[position])
}
inner class ChatViewHolder(private val binding: ItemChatMessageBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(message: ChatMessage) {
binding.usernameText.text = message.nickname
binding.messageText.text = message.message
binding.userIcon.setImageResource(when (message.nickname) {
"System" -> R.drawable.ic_system
else -> R.drawable.ic_user
})
}
}
}

View file

@ -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<NetPlayAdapter.NetPlayViewHolder>() {
val netPlayItems = mutableListOf<NetPlayItems>()
abstract inner class NetPlayViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), View.OnClickListener {
init {
itemView.setOnClickListener(this)
}
abstract fun bind(item: NetPlayItems)
}
inner class TextViewHolder(private val binding: ItemTextNetplayBinding) : NetPlayViewHolder(binding.root) {
private lateinit var netPlayItem: NetPlayItems
override fun onClick(clicked: View) {}
override fun bind(item: NetPlayItems) {
netPlayItem = item
binding.itemTextNetplayName.text = item.name
binding.itemIcon.apply {
val iconRes = when (item.option) {
NetPlayItems.MULTIPLAYER_ROOM_TEXT -> R.drawable.ic_system
NetPlayItems.MULTIPLAYER_ROOM_COUNT -> R.drawable.ic_joined
else -> 0
}
visibility = if (iconRes != 0) {
setImageResource(iconRes)
View.VISIBLE
} else View.GONE
}
}
}
inner class ButtonViewHolder(private val binding: ItemButtonNetplayBinding) : NetPlayViewHolder(binding.root) {
private lateinit var netPlayItems: NetPlayItems
private val isModerator = NetPlayManager.netPlayIsModerator()
init {
binding.itemButtonMore.apply {
visibility = View.VISIBLE
setOnClickListener { showPopupMenu(it) }
}
}
override fun onClick(clicked: View) {}
private fun showPopupMenu(view: View) {
PopupMenu(view.context, view).apply {
menuInflater.inflate(R.menu.menu_netplay_member, menu)
menu.findItem(R.id.action_kick).isEnabled = isModerator &&
netPlayItems.name != NetPlayManager.getUsername(context)
menu.findItem(R.id.action_ban).isEnabled = isModerator &&
netPlayItems.name != NetPlayManager.getUsername(context)
setOnMenuItemClickListener { item ->
if (item.itemId == R.id.action_kick) {
NetPlayManager.netPlayKickUser(netPlayItems.name)
true
} else if (item.itemId == R.id.action_ban) {
NetPlayManager.netPlayBanUser(netPlayItems.name)
true
} else false
}
show()
}
}
override fun bind(item: NetPlayItems) {
netPlayItems = item
binding.itemButtonNetplayName.text = netPlayItems.name
}
}
fun loadMultiplayerMenu() {
val infos = NetPlayManager.netPlayRoomInfo()
if (infos.isNotEmpty()) {
val roomInfo = infos[0].split("|")
netPlayItems.add(NetPlayItems(NetPlayItems.MULTIPLAYER_ROOM_TEXT, roomInfo[0], NetPlayItems.TYPE_TEXT))
netPlayItems.add(NetPlayItems(NetPlayItems.MULTIPLAYER_ROOM_COUNT, "${infos.size - 1}/${roomInfo[1]}", NetPlayItems.TYPE_TEXT))
netPlayItems.add(NetPlayItems(NetPlayItems.MULTIPLAYER_SEPARATOR, "", NetPlayItems.TYPE_SEPARATOR))
for (i in 1 until infos.size) {
netPlayItems.add(NetPlayItems(NetPlayItems.MULTIPLAYER_ROOM_MEMBER, infos[i], NetPlayItems.TYPE_BUTTON))
}
}
}
override fun getItemViewType(position: Int) = netPlayItems[position].type
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NetPlayViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
NetPlayItems.TYPE_TEXT -> TextViewHolder(ItemTextNetplayBinding.inflate(inflater, parent, false))
NetPlayItems.TYPE_BUTTON -> ButtonViewHolder(ItemButtonNetplayBinding.inflate(inflater, parent, false))
NetPlayItems.TYPE_SEPARATOR -> object : NetPlayViewHolder(inflater.inflate(R.layout.item_separator_netplay, parent, false)) {
override fun bind(item: NetPlayItems) {}
override fun onClick(clicked: View) {}
}
else -> throw IllegalStateException("Unsupported view type")
}
}
override fun onBindViewHolder(holder: NetPlayViewHolder, position: Int) {
holder.bind(netPlayItems[position])
}
override fun getItemCount() = netPlayItems.size
}
fun refreshAdapterItems() {
val handler = Handler(Looper.getMainLooper())
NetPlayManager.setOnAdapterRefreshListener() { type, msg ->
handler.post {
adapter.netPlayItems.clear()
adapter.loadMultiplayerMenu()
adapter.notifyDataSetChanged()
}
}
}
private fun showNetPlayInputDialog(isCreateRoom: Boolean) {
val activity = CompatUtils.findActivity(context)
val dialog = BottomSheetDialog(activity)
dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED
dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED
dialog.behavior.skipCollapsed = context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
val binding = DialogMultiplayerRoomBinding.inflate(LayoutInflater.from(activity))
dialog.setContentView(binding.root)
binding.textTitle.text = activity.getString(
if (isCreateRoom) R.string.multiplayer_create_room
else R.string.multiplayer_join_room
)
binding.ipAddress.setText(
if (isCreateRoom) NetPlayManager.getIpAddressByWifi(activity)
else NetPlayManager.getRoomAddress(activity)
)
binding.ipPort.setText(NetPlayManager.getRoomPort(activity))
binding.username.setText(NetPlayManager.getUsername(activity))
binding.roomName.visibility = if (isCreateRoom) View.VISIBLE else View.GONE
binding.maxPlayersContainer.visibility = if (isCreateRoom) View.VISIBLE else View.GONE
binding.maxPlayersLabel.text = context.getString(R.string.multiplayer_max_players_value, binding.maxPlayers.value.toInt())
binding.maxPlayers.addOnChangeListener { _, value, _ ->
binding.maxPlayersLabel.text = context.getString(R.string.multiplayer_max_players_value, value.toInt())
}
binding.btnConfirm.setOnClickListener {
binding.btnConfirm.isEnabled = false
binding.btnConfirm.text = activity.getString(R.string.disabled_button_text)
val ipAddress = binding.ipAddress.text.toString()
val username = binding.username.text.toString()
val portStr = binding.ipPort.text.toString()
val password = binding.password.text.toString()
val port = portStr.toIntOrNull() ?: run {
Toast.makeText(activity, R.string.multiplayer_port_invalid, Toast.LENGTH_LONG).show()
binding.btnConfirm.isEnabled = true
binding.btnConfirm.text = activity.getString(R.string.original_button_text)
return@setOnClickListener
}
val roomName = binding.roomName.text.toString()
val maxPlayers = binding.maxPlayers.value.toInt()
if (isCreateRoom && (roomName.length !in 3..20)) {
Toast.makeText(activity, R.string.multiplayer_room_name_invalid, Toast.LENGTH_LONG).show()
binding.btnConfirm.isEnabled = true
binding.btnConfirm.text = activity.getString(R.string.original_button_text)
return@setOnClickListener
}
if (ipAddress.length < 7 || username.length < 5) {
Toast.makeText(activity, R.string.multiplayer_input_invalid, Toast.LENGTH_LONG).show()
binding.btnConfirm.isEnabled = true
binding.btnConfirm.text = activity.getString(R.string.original_button_text)
} else {
Handler(Looper.getMainLooper()).post {
val result = if (isCreateRoom) {
NetPlayManager.netPlayCreateRoom(ipAddress, port, username, password, roomName, maxPlayers)
} else {
NetPlayManager.netPlayJoinRoom(ipAddress, port, username, password)
}
if (result == 0) {
NetPlayManager.setUsername(activity, username)
NetPlayManager.setRoomPort(activity, portStr)
if (!isCreateRoom) NetPlayManager.setRoomAddress(activity, ipAddress)
Toast.makeText(
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<RecyclerView>(R.id.ban_list_recycler)
recyclerView.layoutManager = LinearLayoutManager(context)
lateinit var adapter: BanListAdapter
val onUnban: (String) -> Unit = { bannedItem ->
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.multiplayer_unban_title)
.setMessage(activity.getString(R.string.multiplayer_unban_message, bannedItem))
.setPositiveButton(R.string.multiplayer_unban) { _, _ ->
NetPlayManager.netPlayUnbanUser(bannedItem)
adapter.removeBan(bannedItem)
}
.setNegativeButton(R.string.cancel, null)
.show()
}
adapter = BanListAdapter(banList, onUnban)
recyclerView.adapter = adapter
dialog.setView(view)
dialog.setPositiveButton(R.string.ok, null)
dialog.show()
}
private class BanListAdapter(
banList: List<String>,
private val onUnban: (String) -> Unit
) : RecyclerView.Adapter<BanListAdapter.ViewHolder>() {
private val usernameBans = banList.filter { !it.contains(".") }.toMutableList()
private val ipBans = banList.filter { it.contains(".") }.toMutableList()
class ViewHolder(val binding: ItemBanListBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemBanListBinding.inflate(
LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val isUsername = position < usernameBans.size
val item = if (isUsername) usernameBans[position] else ipBans[position - usernameBans.size]
holder.binding.apply {
banText.text = item
icon.setImageResource(if (isUsername) R.drawable.ic_user else R.drawable.ic_ip)
btnUnban.setOnClickListener { onUnban(item) }
}
}
override fun getItemCount() = usernameBans.size + ipBans.size
fun removeBan(bannedItem: String) {
val position = if (bannedItem.contains(".")) {
ipBans.indexOf(bannedItem).let { if (it >= 0) it + usernameBans.size else it }
} else {
usernameBans.indexOf(bannedItem)
}
if (position >= 0) {
if (bannedItem.contains(".")) {
ipBans.remove(bannedItem)
} else {
usernameBans.remove(bannedItem)
}
notifyItemRemoved(position)
}
}
}
}

View file

@ -271,6 +271,13 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
true true
} }
R.id.menu_multiplayer -> {
emulationActivity?.displayMultiplayerDialog()
true
}
R.id.menu_controls -> { R.id.menu_controls -> {
val action = HomeNavigationDirections.actionGlobalSettingsActivity( val action = HomeNavigationDirections.actionGlobalSettingsActivity(
null, null,

View file

@ -119,6 +119,16 @@ class HomeSettingsFragment : Fragment() {
driverViewModel.selectedDriverTitle driverViewModel.selectedDriverTitle
) )
) )
add(
HomeSetting(
R.string.multiplayer,
R.string.multiplayer_description,
R.drawable.ic_multiplayer,
{
val action = mainActivity.displayMultiplayerDialog()
},
)
)
add( add(
HomeSetting( HomeSetting(
R.string.applets, R.string.applets,

View file

@ -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<String>
external fun netPlayIsJoined(): Boolean
external fun netPlayIsHostedRoom(): Boolean
external fun netPlaySendMessage(msg: String)
external fun netPlayKickUser(username: String)
external fun netPlayLeaveRoom()
external fun netPlayIsModerator(): Boolean
external fun netPlayGetBanList(): Array<String>
external fun netPlayBanUser(username: String)
external fun netPlayUnbanUser(username: String)
private var messageListener: ((Int, String) -> Unit)? = null
private var adapterRefreshListener: ((Int, String) -> Unit)? = null
fun setOnMessageReceivedListener(listener: (Int, String) -> Unit) {
messageListener = listener
}
fun setOnAdapterRefreshListener(listener: (Int, String) -> Unit) {
adapterRefreshListener = listener
}
fun getUsername(activity: Context): String { val prefs = PreferenceManager.getDefaultSharedPreferences(activity)
val name = "Lime3ds${(Math.random() * 100).toInt()}"
return prefs.getString("NetPlayUsername", name) ?: name
}
fun setUsername(activity: Activity, name: String) {
val prefs = PreferenceManager.getDefaultSharedPreferences(activity)
prefs.edit().putString("NetPlayUsername", name).apply()
}
fun getRoomAddress(activity: Activity): String {
val prefs = PreferenceManager.getDefaultSharedPreferences(activity)
val address = getIpAddressByWifi(activity)
return prefs.getString("NetPlayRoomAddress", address) ?: address
}
fun setRoomAddress(activity: Activity, address: String) {
val prefs = PreferenceManager.getDefaultSharedPreferences(activity)
prefs.edit().putString("NetPlayRoomAddress", address).apply()
}
fun getRoomPort(activity: Activity): String {
val prefs = PreferenceManager.getDefaultSharedPreferences(activity)
return prefs.getString("NetPlayRoomPort", "24872") ?: "24872"
}
fun setRoomPort(activity: Activity, port: String) {
val prefs = PreferenceManager.getDefaultSharedPreferences(activity)
prefs.edit().putString("NetPlayRoomPort", port).apply()
}
private val chatMessages = mutableListOf<ChatMessage>()
private var isChatOpen = false
fun addChatMessage(message: ChatMessage) {
chatMessages.add(message)
}
fun getChatMessages(): List<ChatMessage> = chatMessages
fun clearChat() {
chatMessages.clear()
}
fun setChatOpen(isOpen: Boolean) {
isChatOpen = isOpen
}
fun addNetPlayMessage(type: Int, msg: String) {
val context = 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<String> {
return netPlayGetBanList().toList()
}
object NetPlayStatus {
const val NO_ERROR = 0
const val NETWORK_ERROR = 1
const val LOST_CONNECTION = 2
const val NAME_COLLISION = 3
const val MAC_COLLISION = 4
const val CONSOLE_ID_COLLISION = 5
const val WRONG_VERSION = 6
const val WRONG_PASSWORD = 7
const val COULD_NOT_CONNECT = 8
const val ROOM_IS_FULL = 9
const val HOST_BANNED = 10
const val PERMISSION_DENIED = 11
const val NO_SUCH_USER = 12
const val ALREADY_IN_ROOM = 13
const val CREATE_ROOM_ERROR = 14
const val HOST_KICKED = 15
const val UNKNOWN_ERROR = 16
const val ROOM_UNINITIALIZED = 17
const val ROOM_IDLE = 18
const val ROOM_JOINING = 19
const val ROOM_JOINED = 20
const val ROOM_MODERATOR = 21
const val MEMBER_JOIN = 22
const val MEMBER_LEAVE = 23
const val MEMBER_KICKED = 24
const val MEMBER_BANNED = 25
const val ADDRESS_UNBANNED = 26
const val CHAT_MESSAGE = 27
}
}

View file

@ -31,6 +31,7 @@ import org.citron.citron_emu.HomeNavigationDirections
import org.citron.citron_emu.NativeLibrary import org.citron.citron_emu.NativeLibrary
import org.citron.citron_emu.R import org.citron.citron_emu.R
import org.citron.citron_emu.databinding.ActivityMainBinding 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.features.settings.model.Settings
import org.citron.citron_emu.fragments.AddGameFolderDialogFragment import org.citron.citron_emu.fragments.AddGameFolderDialogFragment
import org.citron.citron_emu.fragments.ProgressDialogFragment import org.citron.citron_emu.fragments.ProgressDialogFragment
@ -68,6 +69,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady } splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady }
ThemeHelper.setTheme(this) ThemeHelper.setTheme(this)
NativeLibrary.netPlayInit()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -157,6 +159,11 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
setInsets() setInsets()
} }
fun displayMultiplayerDialog() {
val dialog = NetPlayDialog(this)
dialog.show()
}
private fun checkKeys() { private fun checkKeys() {
if (!NativeLibrary.areKeysPresent()) { if (!NativeLibrary.areKeysPresent()) {
MessageDialogFragment.newInstance( MessageDialogFragment.newInstance(

View file

@ -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")
}
}
}

View file

@ -20,6 +20,7 @@
#include <frontend_common/content_manager.h> #include <frontend_common/content_manager.h>
#include <jni.h> #include <jni.h>
#include "common/android/multiplayer/multiplayer.h"
#include "common/android/android_common.h" #include "common/android/android_common.h"
#include "common/android/id_cache.h" #include "common/android/id_cache.h"
#include "common/detached_tasks.h" #include "common/detached_tasks.h"
@ -870,4 +871,83 @@ jboolean Java_org_citron_citron_1emu_NativeLibrary_areKeysPresent(JNIEnv* env, j
return ContentManager::AreKeysPresent(); 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<jint>(
NetPlayCreateRoom(Common::Android::GetJString(env, ipaddress), port,
Common::Android::GetJString(env, username), Common::Android::GetJString(env, password),
Common::Android::GetJString(env, room_name), max_players));
}
JNIEXPORT jint JNICALL Java_org_citron_citron_1emu_network_NetPlayManager_netPlayJoinRoom(
JNIEnv* env, [[maybe_unused]] jobject obj, jstring ipaddress, jint port,
jstring username, jstring password) {
return static_cast<jint>(
NetPlayJoinRoom(Common::Android::GetJString(env, ipaddress), port,
Common::Android::GetJString(env, username), Common::Android::GetJString(env, password)));
}
JNIEXPORT jobjectArray JNICALL
Java_org_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" } // extern "C"

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM18,14L6,14v-2h12v2zM18,11L6,11L6,9h12v2zM18,8L6,8L6,6h12v2z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM11,19.93c-3.95,-0.49 -7,-3.85 -7,-7.93 0,-0.62 0.08,-1.21 0.21,-1.79L9,15v1c0,1.1 0.9,2 2,2v1.93zM17.9,17.39c-0.26,-0.81 -1,-1.39 -1.9,-1.39h-1v-3c0,-0.55 -0.45,-1 -1,-1L8,12v-2h2c0.55,0 1,-0.45 1,-1L11,7h2c1.1,0 2,-0.9 2,-2v-0.41c2.93,1.19 5,4.06 5,7.41 0,2.08 -0.8,3.97 -2.1,5.39z"/>
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M16,11c1.66,0 2.99,-1.34 2.99,-3S17.66,5 16,5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3zM8,11c1.66,0 2.99,-1.34 2.99,-3S9.66,5 8,5C6.34,5 5,6.34 5,8s1.34,3 3,3zM8,13c-2.33,0 -7,1.17 -7,3.5L1,19h14v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5zM16,13c-0.29,0 -0.62,0.02 -0.97,0.05 1.16,0.84 1.97,1.97 1.97,3.45L17,19h6v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M16,11c1.66,0 2.99,-1.34 2.99,-3S17.66,5 16,5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3zM8,11c1.66,0 2.99,-1.34 2.99,-3S9.66,5 8,5C6.34,5 5,6.34 5,8s1.34,3 3,3zM8,13c-2.33,0 -7,1.17 -7,3.5L1,19h14v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5zM16,13c-0.29,0 -0.62,0.02 -0.97,0.05 1.16,0.84 1.97,1.97 1.97,3.45L17,19h6v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5z"/>
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorPrimary"
android:pathData="M1,9l2,2c4.97,-4.97 13.03,-4.97 18,0l2,-2C16.93,2.93 7.08,2.93 1,9zM9,17l3,3 3,-3c-1.65,-1.66 -4.34,-1.66 -6,0zM5,13l2,2c2.76,-2.76 7.24,-2.76 10,0l2,-2C15.14,9.14 8.87,9.14 5,13z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M2.01,21L23,12 2.01,3 2,10l15,2 -15,2z"/>
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M21,3L3,3c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h5v2h8v-2h5c1.1,0 1.99,-0.9 1.99,-2L23,5c0,-1.1 -0.9,-2 -2,-2zM21,17L3,17L3,5h18v12z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
</vector>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/ban_list_recycler"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"/>

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:gravity="center"
app:strokeWidth="0dp"
app:cardCornerRadius="24dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:background="?colorSurface">
<View
android:layout_width="128dp"
android:layout_height="4dp"
android:layout_marginVertical="8dp"
android:backgroundTint="?colorSurfaceVariant" />
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</androidx.core.widget.NestedScrollView>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View file

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<TextView
android:id="@+id/text_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/chat"
android:textAppearance="?attr/textAppearanceHeadline6"
android:gravity="center"
android:layout_marginBottom="16dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chat_recycler_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginBottom="16dp"
android:transcriptMode="alwaysScroll" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/type_message">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/chat_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:imeOptions="actionSend" />
</com.google.android.material.textfield.TextInputLayout>
<ImageButton
android:id="@+id/send_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="bottom"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_send"
android:contentDescription="@string/send_message" />
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:id="@+id/drag_handle"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/text_title"
android:text="@string/multiplayer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceHeadline6"
android:gravity="center"
android:layout_marginTop="4dp"
android:textColor="?attr/colorOnSurface" />
<ImageView
android:layout_width="140dp"
android:layout_height="140dp"
android:layout_gravity="center"
android:layout_marginTop="16dp"
android:layout_marginBottom="24dp"
android:src="@drawable/ic_network"
app:tint="?attr/colorPrimary" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_join"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/multiplayer_join_room"
app:icon="@drawable/ic_install"
app:cornerRadius="16dp" />
<Space
android:layout_width="16dp"
android:layout_height="match_parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_create"
style="@style/Widget.Material3.Button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/multiplayer_create_room"
app:icon="@drawable/ic_add"
app:cornerRadius="16dp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,75 @@
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:id="@+id/drag_handle"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/text_title"
android:text="@string/multiplayer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceHeadline6"
android:gravity="center"
android:layout_marginTop="4dp"
android:textColor="?attr/colorOnSurface" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list_multiplayer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_chat"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="8dp"
android:enabled="true"
android:text="@string/multiplayer_chat"
app:icon="@drawable/ic_chat"
app:cornerRadius="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_moderation"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="8dp"
android:enabled="true"
android:text="@string/multiplayer_moderation"
app:cornerRadius="16dp"
app:icon="@drawable/ic_user" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_leave"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:text="@string/multiplayer_exit_room"
app:icon="@drawable/ic_exit"
app:cornerRadius="16dp" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,119 @@
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:clipToPadding="false"
android:clipChildren="false"
android:elevation="4dp">
<TextView
android:id="@+id/textTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceHeadline6"
android:gravity="center"
android:paddingBottom="8dp"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/multiplayer_ip_address"
android:padding="8dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/ip_address"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/multiplayer_ip_port"
android:padding="8dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/ip_port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/multiplayer_username"
android:padding="8dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/multiplayer_password"
android:padding="8dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/multiplayer_room_name"
android:padding="8dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/room_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:id="@+id/max_players_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.slider.Slider
android:id="@+id/max_players"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:value="8"
android:valueFrom="2"
android:valueTo="16"
android:stepSize="1" />
<TextView
android:id="@+id/max_players_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/multiplayer_max_players_value" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_confirm"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@android:string/ok"
android:layout_gravity="center" />
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center_vertical">
<ImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_user"
android:layout_marginEnd="16dp"/>
<TextView
android:id="@+id/ban_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_unban"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/multiplayer_unban"/>
</LinearLayout>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="8dp">
<TextView
android:id="@+id/item_button_netplay_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<ImageButton
android:id="@+id/item_button_more"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/multiplayer_more_options"
android:src="@drawable/ic_more_vert"
android:padding="12dp" />
</LinearLayout>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp">
<ImageView
android:id="@+id/user_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/username_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textStyle="bold" />
<TextView
android:id="@+id/message_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/timestamp_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp">
<TextView
android:id="@+id/item_button_netplay_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"/>
<Button
android:id="@+id/item_button_netplay"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/multiplayer_kick_member"/>
<ImageButton
android:id="@+id/item_button_more"
android:layout_width="48dp"
android:layout_height="48dp"
android:src="@drawable/ic_more_vert"/>
</LinearLayout>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<View xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?android:attr/listDivider"/>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp">
<ImageView
android:id="@+id/item_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="16dp"/>
<TextView
android:id="@+id/item_text_netplay_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.divider.MaterialDivider
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginVertical="8dp" />

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical">
<ImageView
android:id="@+id/item_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="16dp"
app:tint="?attr/colorPrimary" />
<TextView
android:id="@+id/item_text_netplay_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="?attr/textAppearanceBodyLarge" />
</LinearLayout>

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical">
<ImageView
android:id="@+id/item_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="16dp"
app:tint="?attr/colorPrimary" />
<TextView
android:id="@+id/item_text_netplay_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="?attr/textAppearanceBodyLarge" />
</LinearLayout>

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"> <menu xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
tools:ignore="ExtraText">
<item <item
android:id="@+id/menu_pause_emulation" android:id="@+id/menu_pause_emulation"
@ -21,6 +23,12 @@
android:icon="@drawable/ic_controller" android:icon="@drawable/ic_controller"
android:title="@string/preferences_controls" /> android:title="@string/preferences_controls" />
<item
android:id="@+id/menu_multiplayer"
android:icon="@drawable/ic_multiplayer"
android:title="@string/multiplayer" />
<item <item
android:id="@+id/menu_overlay_controls" android:id="@+id/menu_overlay_controls"
android:icon="@drawable/ic_overlay" android:icon="@drawable/ic_overlay"

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_kick"
android:title="@string/multiplayer_kick_member"
android:enabled="false" />
<item
android:id="@+id/action_ban"
android:title="@string/multiplayer_ban"
android:enabled="false" />
</menu>

View file

@ -421,6 +421,75 @@
<string name="preferences_debug">Debug</string> <string name="preferences_debug">Debug</string>
<string name="preferences_debug_description">CPU/GPU debugging, graphics API, fastmem</string> <string name="preferences_debug_description">CPU/GPU debugging, graphics API, fastmem</string>
<!-- Multiplayer -->
<string name="multiplayer">Multiplayer</string>
<string name="multiplayer_description">Host your own game room or join an existing one to play with people</string>
<string name="multiplayer_room_title">Room: %1$s</string>
<string name="multiplayer_console_id">Console ID%1$s</string>
<string name="multiplayer_create_room">Create</string>
<string name="multiplayer_join_room">Join</string>
<string name="multiplayer_username">Username</string>
<string name="multiplayer_ip_address">IP Address</string>
<string name="multiplayer_ip_port">Port</string>
<string name="multiplayer_create_room_success">Room created successfully!</string>
<string name="multiplayer_join_room_success">Join the room successfully!</string>
<string name="multiplayer_create_room_failed">Failed to create room!</string>
<string name="multiplayer_join_room_failed">Failed to join room!</string>
<string name="multiplayer_input_invalid">Invalid address or name is too short!</string>
<string name="multiplayer_port_invalid">Invalid port!</string>
<string name="multiplayer_exit_room">Exit Room</string>
<string name="multiplayer_network_error">Network error</string>
<string name="multiplayer_lost_connection">Lost connection</string>
<string name="multiplayer_name_collision">Name collision</string>
<string name="multiplayer_mac_collision">Mac collision</string>
<string name="multiplayer_console_id_collision">Console ID collision</string>
<string name="multiplayer_wrong_version">Wrong version</string>
<string name="multiplayer_wrong_password">Wrong password</string>
<string name="multiplayer_could_not_connect">Could not connect</string>
<string name="multiplayer_room_is_full">Room is full</string>
<string name="multiplayer_host_banned">Host banned</string>
<string name="multiplayer_permission_denied">Permission denied</string>
<string name="multiplayer_no_such_user">No such user</string>
<string name="multiplayer_already_in_room">Already in room</string>
<string name="multiplayer_create_room_error">Create room error</string>
<string name="multiplayer_host_kicked">Host kicked</string>
<string name="multiplayer_unknown_error">unknown error</string>
<string name="multiplayer_room_uninitialized">Room uninitialized</string>
<string name="multiplayer_room_idle">Room idle</string>
<string name="multiplayer_room_joining">Room joining</string>
<string name="multiplayer_room_joined">Room joined</string>
<string name="multiplayer_room_moderator">Room moderator</string>
<string name="multiplayer_member_join">%1$s joined</string>
<string name="multiplayer_member_leave">%1$s left</string>
<string name="multiplayer_member_kicked">%1$s kicked</string>
<string name="multiplayer_member_banned">%1$s banned</string>
<string name="multiplayer_address_unbanned">address unbanned</string>
<string name="multiplayer_kick_member">Kick Out</string>
<string name="multiplayer_chat_input_hint">Send messages……</string>
<string name="multiplayer_password">Password</string>
<string name="original_button_text">Join</string>
<string name="disabled_button_text">Joining...</string>
<string name="multiplayer_room_name">Room Name</string>
<string name="multiplayer_room_name_invalid">Room name must be between 3 and 20 characters</string>
<string name="multiplayer_max_players">Max Players (16)</string>
<string name="multiplayer_max_players_value">Max Players: %d</string>
<string name="multiplayer_chat">Chat</string>
<string name="multiplayer_more_options">More Options</string>
<string name="multiplayer_ip_copied">IP Address copied to clipboard</string>
<string name="multiplayer_server_address">Server Address</string>
<string name="chat">Chat</string>
<string name="type_message">Type message……</string>
<string name="send">Send</string>
<string name="send_message">Send Message</string>
<string name="multiplayer_moderation">Moderation</string>
<string name="multiplayer_moderation_title">Ban List</string>
<string name="multiplayer_no_bans">No banned users</string>
<string name="multiplayer_unban_title">Unban User</string>
<string name="multiplayer_unban">Unban</string>
<string name="multiplayer_unban_message">Are you sure you want to unban %1$s?</string>
<string name="multiplayer_ban">Ban User</string>
<!-- Game properties --> <!-- Game properties -->
<string name="info">Info</string> <string name="info">Info</string>
<string name="info_description">Program ID, developer, version</string> <string name="info_description">Program ID, developer, version</string>
@ -1177,5 +1246,8 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
</string> </string>
<string name="emulation_multiplayer">Multiplayer</string>
<string name="cancel">Cancel</string>
<string name="ok">Ok</string>
</resources> </resources>

View file

@ -189,6 +189,8 @@ if(ANDROID)
android/android_common.h android/android_common.h
android/id_cache.cpp android/id_cache.cpp
android/id_cache.h android/id_cache.h
android/multiplayer/multiplayer.cpp
android/multiplayer/multiplayer.h
android/applets/software_keyboard.cpp android/applets/software_keyboard.cpp
android/applets/software_keyboard.h android/applets/software_keyboard.h
) )

View file

@ -34,6 +34,15 @@ jstring ToJString(JNIEnv* env, std::string_view str) {
static_cast<jint>(converted_string.size())); static_cast<jint>(converted_string.size()));
} }
jobjectArray ToJStringArray(JNIEnv* env, const std::vector<std::string>& strs) {
jobjectArray array =
env->NewObjectArray(static_cast<jsize>(strs.size()), env->FindClass("java/lang/String"), env->NewStringUTF(""));
for (std::size_t i = 0; i < strs.size(); ++i) {
env->SetObjectArrayElement(array, static_cast<jsize>(i), ToJString(env, strs[i]));
}
return array;
}
jstring ToJString(JNIEnv* env, std::u16string_view str) { jstring ToJString(JNIEnv* env, std::u16string_view str) {
return ToJString(env, Common::UTF16ToUTF8(str)); return ToJString(env, Common::UTF16ToUTF8(str));
} }

View file

@ -19,7 +19,7 @@ jobject ToJDouble(JNIEnv* env, double value);
s32 GetJInteger(JNIEnv* env, jobject jinteger); s32 GetJInteger(JNIEnv* env, jobject jinteger);
jobject ToJInteger(JNIEnv* env, s32 value); jobject ToJInteger(JNIEnv* env, s32 value);
jobjectArray ToJStringArray(JNIEnv* env, const std::vector<std::string>& strs);
bool GetJBoolean(JNIEnv* env, jobject jboolean); bool GetJBoolean(JNIEnv* env, jobject jboolean);
jobject ToJBoolean(JNIEnv* env, bool value); jobject ToJBoolean(JNIEnv* env, bool value);

View file

@ -8,6 +8,9 @@
#include "common/assert.h" #include "common/assert.h"
#include "common/fs/fs_android.h" #include "common/fs/fs_android.h"
#include "video_core/rasterizer_interface.h" #include "video_core/rasterizer_interface.h"
#include "common/android/multiplayer/multiplayer.h"
#include <network/network.h>
static JavaVM* s_java_vm; static JavaVM* s_java_vm;
static jclass s_native_library_class; 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_vibrate;
static jmethodID s_citron_input_device_get_axes; static jmethodID s_citron_input_device_get_axes;
static jmethodID s_citron_input_device_has_keys; 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; static constexpr jint JNI_VERSION = JNI_VERSION_1_6;
@ -388,6 +393,15 @@ jmethodID GetCitronDeviceHasKeys() {
return s_citron_input_device_has_keys; return s_citron_input_device_has_keys;
} }
jmethodID GetAddNetPlayMessage() {
return s_add_netplay_message;
}
jmethodID ClearChat() {
return s_clear_chat;
}
#ifdef __cplusplus #ifdef __cplusplus
extern "C" { extern "C" {
#endif #endif
@ -547,6 +561,10 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
s_citron_input_device_has_keys = s_citron_input_device_has_keys =
env->GetMethodID(citron_input_device_interface, "hasKeys", "([I)[Z"); env->GetMethodID(citron_input_device_interface, "hasKeys", "([I)[Z");
env->DeleteLocalRef(citron_input_device_interface); 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 // Initialize Android Storage
Common::FS::Android::RegisterCallbacks(env, s_native_library_class); Common::FS::Android::RegisterCallbacks(env, s_native_library_class);
@ -582,6 +600,8 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) {
// UnInitialize applets // UnInitialize applets
SoftwareKeyboard::CleanupJNI(env); SoftwareKeyboard::CleanupJNI(env);
NetworkShutdown();
} }
#ifdef __cplusplus #ifdef __cplusplus

View file

@ -5,6 +5,7 @@
#include <future> #include <future>
#include <jni.h> #include <jni.h>
#include <network/network.h>
#include "video_core/rasterizer_interface.h" #include "video_core/rasterizer_interface.h"
@ -108,5 +109,6 @@ jmethodID GetCitronDeviceGetSupportsVibration();
jmethodID GetCitronDeviceVibrate(); jmethodID GetCitronDeviceVibrate();
jmethodID GetCitronDeviceGetAxes(); jmethodID GetCitronDeviceGetAxes();
jmethodID GetCitronDeviceHasKeys(); jmethodID GetCitronDeviceHasKeys();
jmethodID GetAddNetPlayMessage();
} // namespace Common::Android jmethodID ClearChat();
} // namespace Android

View file

@ -0,0 +1,350 @@
// Copyright 2024 Mandarine Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#include "common/android/id_cache.h"
#include "multiplayer.h"
#include "common/android/android_common.h"
#include "core/core.h"
#include "network/network.h"
#include "android/log.h"
#include <thread>
#include <chrono>
namespace IDCache = Common::Android;
Network::RoomNetwork* room_network;
void AddNetPlayMessage(jint type, jstring msg) {
IDCache::GetEnvForThread()->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(),
IDCache::GetAddNetPlayMessage(), type, msg);
}
void AddNetPlayMessage(int type, const std::string& msg) {
JNIEnv* env = IDCache::GetEnvForThread();
AddNetPlayMessage(type, Common::Android::ToJString(env, msg));
}
void ClearChat() {
IDCache::GetEnvForThread()->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(),
IDCache::ClearChat());
}
bool NetworkInit(Network::RoomNetwork* room_network_) {
room_network = room_network_;
bool result = room_network->Init();
if (!result) {
return false;
}
if (auto member = room_network->GetRoomMember().lock()) {
// register the network structs to use in slots and signals
member->BindOnStateChanged([](const Network::RoomMember::State& state) {
if (state == Network::RoomMember::State::Joined ||
state == Network::RoomMember::State::Moderator) {
NetPlayStatus status;
std::string msg;
switch (state) {
case Network::RoomMember::State::Joined:
status = NetPlayStatus::ROOM_JOINED;
break;
case Network::RoomMember::State::Moderator:
status = NetPlayStatus::ROOM_MODERATOR;
break;
default:
return;
}
AddNetPlayMessage(static_cast<int>(status), msg);
}
});
member->BindOnError([](const Network::RoomMember::Error& error) {
NetPlayStatus status;
std::string msg;
switch (error) {
case Network::RoomMember::Error::LostConnection:
status = NetPlayStatus::LOST_CONNECTION;
break;
case Network::RoomMember::Error::HostKicked:
status = NetPlayStatus::HOST_KICKED;
break;
case Network::RoomMember::Error::UnknownError:
status = NetPlayStatus::UNKNOWN_ERROR;
break;
case Network::RoomMember::Error::NameCollision:
status = NetPlayStatus::NAME_COLLISION;
break;
case Network::RoomMember::Error::IpCollision:
status = NetPlayStatus::MAC_COLLISION;
break;
case Network::RoomMember::Error::WrongVersion:
status = NetPlayStatus::WRONG_VERSION;
break;
case Network::RoomMember::Error::WrongPassword:
status = NetPlayStatus::WRONG_PASSWORD;
break;
case Network::RoomMember::Error::CouldNotConnect:
status = NetPlayStatus::COULD_NOT_CONNECT;
break;
case Network::RoomMember::Error::RoomIsFull:
status = NetPlayStatus::ROOM_IS_FULL;
break;
case Network::RoomMember::Error::HostBanned:
status = NetPlayStatus::HOST_BANNED;
break;
case Network::RoomMember::Error::PermissionDenied:
status = NetPlayStatus::PERMISSION_DENIED;
break;
case Network::RoomMember::Error::NoSuchUser:
status = NetPlayStatus::NO_SUCH_USER;
break;
}
AddNetPlayMessage(static_cast<int>(status), msg);
});
member->BindOnStatusMessageReceived([](const Network::StatusMessageEntry& status_message) {
NetPlayStatus status = NetPlayStatus::NO_ERROR;
std::string msg(status_message.nickname);
switch (status_message.type) {
case Network::IdMemberJoin:
status = NetPlayStatus::MEMBER_JOIN;
break;
case Network::IdMemberLeave:
status = NetPlayStatus::MEMBER_LEAVE;
break;
case Network::IdMemberKicked:
status = NetPlayStatus::MEMBER_KICKED;
break;
case Network::IdMemberBanned:
status = NetPlayStatus::MEMBER_BANNED;
break;
case Network::IdAddressUnbanned:
status = NetPlayStatus::ADDRESS_UNBANNED;
break;
}
AddNetPlayMessage(static_cast<int>(status), msg);
});
member->BindOnChatMessageReceived([](const Network::ChatEntry& chat) {
NetPlayStatus status = NetPlayStatus::CHAT_MESSAGE;
std::string msg(chat.nickname);
msg += ": ";
msg += chat.message;
AddNetPlayMessage(static_cast<int>(status), msg);
});
}
return true;
}
NetPlayStatus NetPlayCreateRoom(const std::string& ipaddress, int port,
const std::string& username, const std::string& password,
const std::string& room_name, int max_players) {
__android_log_print(ANDROID_LOG_INFO, "NetPlay", "NetPlayCreateRoom called with ipaddress: %s, port: %d, username: %s, room_name: %s, max_players: %d", ipaddress.c_str(), port, username.c_str(), room_name.c_str(), max_players);
auto member = room_network->GetRoomMember().lock();
if (!member) {
return NetPlayStatus::NETWORK_ERROR;
}
if (member->GetState() == Network::RoomMember::State::Joining || member->IsConnected()) {
return NetPlayStatus::ALREADY_IN_ROOM;
}
auto room = room_network->GetRoom().lock();
if (!room) {
return NetPlayStatus::NETWORK_ERROR;
}
if (room_name.length() < 3 || room_name.length() > 20) {
return NetPlayStatus::CREATE_ROOM_ERROR;
}
// Placeholder game info
const AnnounceMultiplayerRoom::GameInfo game{
.name = "Default Game",
.id = 0, // Default program ID
};
port = (port == 0) ? Network::DefaultRoomPort : static_cast<u16>(port);
if (!room->Create(room_name, "", ipaddress, static_cast<u16>(port), password,
static_cast<u32>(std::min(max_players, 16)), username, game, nullptr, {})) {
return NetPlayStatus::CREATE_ROOM_ERROR;
}
// Failsafe timer to avoid joining before creation
std::this_thread::sleep_for(std::chrono::milliseconds(100));
member->Join(username, ipaddress.c_str(), static_cast<u16>(port), 0, Network::NoPreferredIP, password, "");
// Failsafe timer to avoid joining before creation
for (int i = 0; i < 5; i++) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
if (member->GetState() == Network::RoomMember::State::Joined ||
member->GetState() == Network::RoomMember::State::Moderator) {
return NetPlayStatus::NO_ERROR;
}
}
// If join failed while room is created, clean up the room
room->Destroy();
return NetPlayStatus::CREATE_ROOM_ERROR;
}
NetPlayStatus NetPlayJoinRoom(const std::string& ipaddress, int port,
const std::string& username, const std::string& password) {
auto member = room_network->GetRoomMember().lock();
if (!member) {
return NetPlayStatus::NETWORK_ERROR;
}
port =
(port == 0) ? Network::DefaultRoomPort : static_cast<u16>(port);
if (member->GetState() == Network::RoomMember::State::Joining || member->IsConnected()) {
return NetPlayStatus::ALREADY_IN_ROOM;
}
member->Join(username, ipaddress.c_str(), static_cast<u16>(port), 0, Network::NoPreferredIP, password, "");
// Wait a bit for the connection and join process to complete
std::this_thread::sleep_for(std::chrono::milliseconds(500));
if (member->GetState() == Network::RoomMember::State::Joined ||
member->GetState() == Network::RoomMember::State::Moderator) {
return NetPlayStatus::NO_ERROR;
}
if (!member->IsConnected()) {
return NetPlayStatus::COULD_NOT_CONNECT;
}
return NetPlayStatus::WRONG_PASSWORD;
}
void NetPlaySendMessage(const std::string& msg) {
if (auto room = room_network->GetRoomMember().lock()) {
if (room->GetState() != Network::RoomMember::State::Joined &&
room->GetState() != Network::RoomMember::State::Moderator) {
return;
}
room->SendChatMessage(msg);
}
}
void NetPlayKickUser(const std::string& username) {
if (auto room = room_network->GetRoomMember().lock()) {
auto members = room->GetMemberInformation();
auto it = std::find_if(members.begin(), members.end(),
[&username](const Network::RoomMember::MemberInformation& member) {
return member.nickname == username;
});
if (it != members.end()) {
room->SendModerationRequest(Network::RoomMessageTypes::IdModKick, username);
}
}
}
void NetPlayBanUser(const std::string& username) {
if (auto room = room_network->GetRoomMember().lock()) {
auto members = room->GetMemberInformation();
auto it = std::find_if(members.begin(), members.end(),
[&username](const Network::RoomMember::MemberInformation& member) {
return member.nickname == username;
});
if (it != members.end()) {
room->SendModerationRequest(Network::RoomMessageTypes::IdModBan, username);
}
}
}
void NetPlayUnbanUser(const std::string& username) {
if (auto room = room_network->GetRoomMember().lock()) {
room->SendModerationRequest(Network::RoomMessageTypes::IdModUnban, username);
}
}
std::vector<std::string> NetPlayRoomInfo() {
std::vector<std::string> info_list;
if (auto room = room_network->GetRoomMember().lock()) {
auto members = room->GetMemberInformation();
if (!members.empty()) {
// name and max players
auto room_info = room->GetRoomInformation();
info_list.push_back(room_info.name + "|" + std::to_string(room_info.member_slots));
// all members
for (const auto& member : members) {
info_list.push_back(member.nickname);
}
}
}
return info_list;
}
bool NetPlayIsJoined() {
auto member = room_network->GetRoomMember().lock();
if (!member) {
return false;
}
return (member->GetState() == Network::RoomMember::State::Joined ||
member->GetState() == Network::RoomMember::State::Moderator);
}
bool NetPlayIsHostedRoom() {
if (auto room = room_network->GetRoom().lock()) {
return room->GetState() == Network::Room::State::Open;
}
return false;
}
void NetPlayLeaveRoom() {
if (auto room = room_network->GetRoom().lock()) {
// if you are in a room, leave it
if (auto member = room_network->GetRoomMember().lock()) {
member->Leave();
}
ClearChat();
// if you are hosting a room, also stop hosting
if (room->GetState() == Network::Room::State::Open) {
room->Destroy();
}
}
}
void NetworkShutdown() {
room_network->Shutdown();
}
bool NetPlayIsModerator() {
auto member = room_network->GetRoomMember().lock();
if (!member) {
return false;
}
return member->GetState() == Network::RoomMember::State::Moderator;
}
std::vector<std::string> NetPlayGetBanList() {
std::vector<std::string> ban_list;
if (auto room = room_network->GetRoom().lock()) {
auto [username_bans, ip_bans] = room->GetBanList();
// Add username bans
for (const auto& username : username_bans) {
ban_list.push_back(username);
}
// Add IP bans
for (const auto& ip : ip_bans) {
ban_list.push_back(ip);
}
}
return ban_list;
}

View file

@ -0,0 +1,65 @@
// Copyright 2024 Mandarine Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#pragma once
#include <string>
#include <vector>
#include <common/common_types.h>
#include <network/network.h>
enum class NetPlayStatus : s32 {
NO_ERROR,
NETWORK_ERROR,
LOST_CONNECTION,
NAME_COLLISION,
MAC_COLLISION,
CONSOLE_ID_COLLISION,
WRONG_VERSION,
WRONG_PASSWORD,
COULD_NOT_CONNECT,
ROOM_IS_FULL,
HOST_BANNED,
PERMISSION_DENIED,
NO_SUCH_USER,
ALREADY_IN_ROOM,
CREATE_ROOM_ERROR,
HOST_KICKED,
UNKNOWN_ERROR,
ROOM_UNINITIALIZED,
ROOM_IDLE,
ROOM_JOINING,
ROOM_JOINED,
ROOM_MODERATOR,
MEMBER_JOIN,
MEMBER_LEAVE,
MEMBER_KICKED,
MEMBER_BANNED,
ADDRESS_UNBANNED,
CHAT_MESSAGE,
};
bool NetworkInit(Network::RoomNetwork* room_network);
NetPlayStatus NetPlayCreateRoom(const std::string& ipaddress, int port,
const std::string& username, const std::string& password,
const std::string& room_name, int max_players);
NetPlayStatus NetPlayJoinRoom(const std::string& ipaddress, int port,
const std::string& username, const std::string& password);
std::vector<std::string> NetPlayRoomInfo();
bool NetPlayIsJoined();
bool NetPlayIsHostedRoom();
bool NetPlayIsModerator();
void NetPlaySendMessage(const std::string& msg);
void NetPlayKickUser(const std::string& username);
void NetPlayBanUser(const std::string& username);
void NetPlayLeaveRoom();
std::string NetPlayGetConsoleId();
void NetworkShutdown();
std::vector<std::string> NetPlayGetBanList();
void NetPlayUnbanUser(const std::string& username);

View file

@ -35,7 +35,6 @@ struct RoomInformation {
u16 port; ///< The port of this room u16 port; ///< The port of this room
GameInfo preferred_game; ///< Game to advertise that you want to play GameInfo preferred_game; ///< Game to advertise that you want to play
std::string host_username; ///< Forum username of the host std::string host_username; ///< Forum username of the host
bool enable_citron_mods; ///< Allow citron Moderators to moderate on this room
}; };
struct Room { struct Room {

View file

@ -200,7 +200,6 @@ int main(int argc, char** argv) {
u64 preferred_game_id = 0; u64 preferred_game_id = 0;
u32 port = Network::DefaultRoomPort; u32 port = Network::DefaultRoomPort;
u32 max_members = 16; u32 max_members = 16;
bool enable_citron_mods = false;
static struct option long_options[] = { static struct option long_options[] = {
{"room-name", required_argument, 0, 'n'}, {"room-name", required_argument, 0, 'n'},
@ -268,9 +267,6 @@ int main(int argc, char** argv) {
case 'l': case 'l':
log_file.assign(optarg); log_file.assign(optarg);
break; break;
case 'e':
enable_citron_mods = true;
break;
case 'h': case 'h':
PrintHelp(argv[0]); PrintHelp(argv[0]);
return 0; return 0;
@ -338,10 +334,6 @@ int main(int argc, char** argv) {
Settings::values.citron_token = token; 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 // Load the ban list
Network::Room::BanList ban_list; Network::Room::BanList ban_list;
@ -370,7 +362,7 @@ int main(int argc, char** argv) {
.id = preferred_game_id}; .id = preferred_game_id};
if (!room->Create(room_name, room_description, bind_address, static_cast<u16>(port), if (!room->Create(room_name, room_description, bind_address, static_cast<u16>(port),
password, max_members, username, preferred_game_info, 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: "); LOG_INFO(Network, "Failed to create room: ");
return -1; return -1;
} }

View file

@ -355,7 +355,14 @@ void Room::RoomImpl::HandleJoinRequest(const ENetEvent* event) {
std::lock_guard lock(verify_uid_mutex); std::lock_guard lock(verify_uid_mutex);
uid = verify_uid; 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; std::string ip;
{ {
@ -574,8 +581,7 @@ bool Room::RoomImpl::HasModPermission(const ENetPeer* client) const {
if (sending_member == members.end()) { if (sending_member == members.end()) {
return false; return false;
} }
if (room_information.enable_citron_mods && if (sending_member->user_data.moderator) { // Community moderator
sending_member->user_data.moderator) { // Community moderator
return true; 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 u32 max_connections, const std::string& host_username,
const GameInfo preferred_game, const GameInfo preferred_game,
std::unique_ptr<VerifyUser::Backend> verify_backend, std::unique_ptr<VerifyUser::Backend> verify_backend,
const Room::BanList& ban_list, bool enable_citron_mods) { const Room::BanList& ban_list) {
ENetAddress address; ENetAddress address;
address.host = ENET_HOST_ANY; address.host = ENET_HOST_ANY;
if (!server_address.empty()) { 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.port = server_port;
room_impl->room_information.preferred_game = preferred_game; room_impl->room_information.preferred_game = preferred_game;
room_impl->room_information.host_username = host_username; room_impl->room_information.host_username = host_username;
room_impl->room_information.enable_citron_mods = enable_citron_mods;
room_impl->password = password; room_impl->password = password;
room_impl->verify_backend = std::move(verify_backend); room_impl->verify_backend = std::move(verify_backend);
room_impl->username_ban_list = ban_list.first; room_impl->username_ban_list = ban_list.first;

View file

@ -123,7 +123,7 @@ public:
const u32 max_connections = MaxConcurrentConnections, const u32 max_connections = MaxConcurrentConnections,
const std::string& host_username = "", const GameInfo = {}, const std::string& host_username = "", const GameInfo = {},
std::unique_ptr<VerifyUser::Backend> verify_backend = nullptr, std::unique_ptr<VerifyUser::Backend> verify_backend = nullptr,
const BanList& ban_list = {}, bool enable_citron_mods = false); const BanList& ban_list = {});
/** /**
* Sets the verification GUID of the room. * Sets the verification GUID of the room.