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.NFC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.VIBRATE" />
<application

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.Patch
import org.citron.citron_emu.model.GameVerificationResult
import org.citron.citron_emu.network.NetPlayManager
import java.net.NetworkInterface
/**
@ -243,6 +244,27 @@ object NativeLibrary {
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
@JvmStatic
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.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
import org.citron.citron_emu.features.settings.model.Settings
import org.citron.citron_emu.model.EmulationViewModel
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.Log
import org.citron.citron_emu.utils.MemoryUtil
@ -423,6 +425,16 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener {
setPictureInPictureParams(pictureInPictureParamsBuilder.build())
}
fun displayMultiplayerDialog() {
val dialog = NetPlayDialog(this)
dialog.show()
}
fun addNetPlayMessages(type: Int, msg: String) {
NetPlayManager.addNetPlayMessage(type, msg)
}
private var pictureInPictureReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent) {
if (intent.action == actionPlay) {

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
}
R.id.menu_multiplayer -> {
emulationActivity?.displayMultiplayerDialog()
true
}
R.id.menu_controls -> {
val action = HomeNavigationDirections.actionGlobalSettingsActivity(
null,

View file

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

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

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 <jni.h>
#include "common/android/multiplayer/multiplayer.h"
#include "common/android/android_common.h"
#include "common/android/id_cache.h"
#include "common/detached_tasks.h"
@ -870,4 +871,83 @@ jboolean Java_org_citron_citron_1emu_NativeLibrary_areKeysPresent(JNIEnv* env, j
return ContentManager::AreKeysPresent();
}
JNIEXPORT jint JNICALL Java_org_citron_citron_1emu_network_NetPlayManager_netPlayCreateRoom(
JNIEnv* env, [[maybe_unused]] jobject obj, jstring ipaddress, jint port,
jstring username, jstring password, jstring room_name, jint max_players) {
return static_cast<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"

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"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<menu xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
tools:ignore="ExtraText">
<item
android:id="@+id/menu_pause_emulation"
@ -21,6 +23,12 @@
android:icon="@drawable/ic_controller"
android:title="@string/preferences_controls" />
<item
android:id="@+id/menu_multiplayer"
android:icon="@drawable/ic_multiplayer"
android:title="@string/multiplayer" />
<item
android:id="@+id/menu_overlay_controls"
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_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 -->
<string name="info">Info</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
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
</string>
<string name="emulation_multiplayer">Multiplayer</string>
<string name="cancel">Cancel</string>
<string name="ok">Ok</string>
</resources>