diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index eccdeea2..dde74879 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -349,6 +349,7 @@
+
@@ -2479,7 +2480,7 @@
-
+
diff --git a/.idea/runConfigurations/Controller_Configuration.xml b/.idea/runConfigurations/Controller_Configuration.xml
new file mode 100644
index 00000000..2770de74
--- /dev/null
+++ b/.idea/runConfigurations/Controller_Configuration.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/Setting.xml b/.idea/runConfigurations/Setting.xml
new file mode 100644
index 00000000..8d90e67a
--- /dev/null
+++ b/.idea/runConfigurations/Setting.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/scopes/SkylineXml.xml b/.idea/scopes/SkylineXml.xml
new file mode 100644
index 00000000..b4d317e8
--- /dev/null
+++ b/.idea/scopes/SkylineXml.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a29a4b8f..a1380a27 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,11 +2,14 @@
+
+
+
+
+
+
+
+
+
+
serviceStatus = type::KSession::ServiceStatus::Closed;
}
- };
+ }
void ServiceManager::SyncRequestHandler(KHandle handle) {
auto session = state.process->GetHandle(handle);
diff --git a/app/src/main/java/emu/skyline/EmulationActivity.kt b/app/src/main/java/emu/skyline/EmulationActivity.kt
index a6abfd10..81714724 100644
--- a/app/src/main/java/emu/skyline/EmulationActivity.kt
+++ b/app/src/main/java/emu/skyline/EmulationActivity.kt
@@ -15,9 +15,9 @@ import android.util.Log
import android.view.*
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.PreferenceManager
+import emu.skyline.input.AxisId
+import emu.skyline.input.ButtonId
import emu.skyline.input.ButtonState
-import emu.skyline.input.NpadAxis
-import emu.skyline.input.NpadButton
import emu.skyline.loader.getRomFormat
import kotlinx.android.synthetic.main.emu_activity.*
import java.io.File
@@ -231,26 +231,26 @@ class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback {
else -> return false
}
- val buttonMap : Map = mapOf(
- KeyEvent.KEYCODE_BUTTON_A to NpadButton.A,
- KeyEvent.KEYCODE_BUTTON_B to NpadButton.B,
- KeyEvent.KEYCODE_BUTTON_X to NpadButton.X,
- KeyEvent.KEYCODE_BUTTON_Y to NpadButton.Y,
- KeyEvent.KEYCODE_BUTTON_THUMBL to NpadButton.LeftStick,
- KeyEvent.KEYCODE_BUTTON_THUMBR to NpadButton.RightStick,
- KeyEvent.KEYCODE_BUTTON_L1 to NpadButton.L,
- KeyEvent.KEYCODE_BUTTON_R1 to NpadButton.R,
- KeyEvent.KEYCODE_BUTTON_L2 to NpadButton.ZL,
- KeyEvent.KEYCODE_BUTTON_R2 to NpadButton.ZR,
- KeyEvent.KEYCODE_BUTTON_START to NpadButton.Plus,
- KeyEvent.KEYCODE_BUTTON_SELECT to NpadButton.Minus,
- KeyEvent.KEYCODE_DPAD_DOWN to NpadButton.DpadDown,
- KeyEvent.KEYCODE_DPAD_UP to NpadButton.DpadUp,
- KeyEvent.KEYCODE_DPAD_LEFT to NpadButton.DpadLeft,
- KeyEvent.KEYCODE_DPAD_RIGHT to NpadButton.DpadRight)
+ val buttonMap : Map = mapOf(
+ KeyEvent.KEYCODE_BUTTON_A to ButtonId.A,
+ KeyEvent.KEYCODE_BUTTON_B to ButtonId.B,
+ KeyEvent.KEYCODE_BUTTON_X to ButtonId.X,
+ KeyEvent.KEYCODE_BUTTON_Y to ButtonId.Y,
+ KeyEvent.KEYCODE_BUTTON_THUMBL to ButtonId.LeftStick,
+ KeyEvent.KEYCODE_BUTTON_THUMBR to ButtonId.RightStick,
+ KeyEvent.KEYCODE_BUTTON_L1 to ButtonId.L,
+ KeyEvent.KEYCODE_BUTTON_R1 to ButtonId.R,
+ KeyEvent.KEYCODE_BUTTON_L2 to ButtonId.ZL,
+ KeyEvent.KEYCODE_BUTTON_R2 to ButtonId.ZR,
+ KeyEvent.KEYCODE_BUTTON_START to ButtonId.Plus,
+ KeyEvent.KEYCODE_BUTTON_SELECT to ButtonId.Minus,
+ KeyEvent.KEYCODE_DPAD_DOWN to ButtonId.DpadDown,
+ KeyEvent.KEYCODE_DPAD_UP to ButtonId.DpadUp,
+ KeyEvent.KEYCODE_DPAD_LEFT to ButtonId.DpadLeft,
+ KeyEvent.KEYCODE_DPAD_RIGHT to ButtonId.DpadRight)
return try {
- setButtonState(buttonMap.getValue(event.keyCode).value(), action.ordinal);
+ setButtonState(buttonMap.getValue(event.keyCode).value(), action.ordinal)
true
} catch (ignored : NoSuchElementException) {
super.dispatchKeyEvent(event)
@@ -273,13 +273,13 @@ class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback {
override fun dispatchGenericMotionEvent(event : MotionEvent) : Boolean {
if ((event.source and InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD ||
(event.source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK) {
- val hatXMap : Map = mapOf(
- -1.0f to NpadButton.DpadLeft,
- +1.0f to NpadButton.DpadRight)
+ val hatXMap : Map = mapOf(
+ -1.0f to ButtonId.DpadLeft,
+ +1.0f to ButtonId.DpadRight)
- val hatYMap : Map = mapOf(
- -1.0f to NpadButton.DpadUp,
- +1.0f to NpadButton.DpadDown)
+ val hatYMap : Map = mapOf(
+ -1.0f to ButtonId.DpadUp,
+ +1.0f to ButtonId.DpadDown)
if (controllerHatX != event.getAxisValue(MotionEvent.AXIS_HAT_X)) {
if (event.getAxisValue(MotionEvent.AXIS_HAT_X) == 0.0f)
@@ -305,11 +305,11 @@ class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback {
}
if ((event.source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK && event.action == MotionEvent.ACTION_MOVE) {
- val axisMap : Map = mapOf(
- MotionEvent.AXIS_X to NpadAxis.LX,
- MotionEvent.AXIS_Y to NpadAxis.LY,
- MotionEvent.AXIS_Z to NpadAxis.RX,
- MotionEvent.AXIS_RZ to NpadAxis.RY)
+ val axisMap : Map = mapOf(
+ MotionEvent.AXIS_X to AxisId.LX,
+ MotionEvent.AXIS_Y to AxisId.LY,
+ MotionEvent.AXIS_Z to AxisId.RX,
+ MotionEvent.AXIS_RZ to AxisId.RY)
//TODO: Digital inputs based off of analog sticks
event.device.motionRanges.forEach {
diff --git a/app/src/main/java/emu/skyline/SettingsActivity.kt b/app/src/main/java/emu/skyline/SettingsActivity.kt
index a74f1081..327a8413 100644
--- a/app/src/main/java/emu/skyline/SettingsActivity.kt
+++ b/app/src/main/java/emu/skyline/SettingsActivity.kt
@@ -9,7 +9,10 @@ import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import androidx.appcompat.app.AppCompatActivity
+import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
+import emu.skyline.input.InputManager
+import emu.skyline.preference.ControllerPreference
import kotlinx.android.synthetic.main.titlebar.*
class SettingsActivity : AppCompatActivity() {
@@ -19,11 +22,23 @@ class SettingsActivity : AppCompatActivity() {
private val preferenceFragment : PreferenceFragment = PreferenceFragment()
/**
- * This initializes [toolbar] and [R.id.settings]
+ * This is an instance of [InputManager] used by [ControllerPreference]
+ */
+ lateinit var inputManager : InputManager
+
+ /**
+ * The key of the element to force a refresh when [onActivityResult] is called
+ */
+ var refreshKey : String? = null
+
+ /**
+ * This initializes all of the elements in the activity and displays the settings fragment
*/
override fun onCreate(savedInstanceState : Bundle?) {
super.onCreate(savedInstanceState)
+ inputManager = InputManager(this)
+
setContentView(R.layout.settings_activity)
setSupportActionBar(toolbar)
@@ -36,11 +51,17 @@ class SettingsActivity : AppCompatActivity() {
}
/**
- * This is used to refresh the preferences after [emu.skyline.preference.FolderActivity] has returned
+ * This is used to refresh the preferences after [emu.skyline.preference.FolderActivity] or [emu.skyline.input.ControllerActivity] has returned
*/
public override fun onActivityResult(requestCode : Int, resultCode : Int, data : Intent?) {
super.onActivityResult(requestCode, resultCode, data)
- preferenceFragment.refreshPreferences()
+
+ if (refreshKey != null) {
+ inputManager.syncObjects()
+ preferenceFragment.refreshPreference(refreshKey!!)
+
+ refreshKey = null
+ }
}
/**
@@ -48,11 +69,12 @@ class SettingsActivity : AppCompatActivity() {
*/
class PreferenceFragment : PreferenceFragmentCompat() {
/**
- * This clears the preference screen and reloads all preferences
+ * This forces refreshing a certain preference by indirectly calling [Preference.notifyChanged]
*/
- fun refreshPreferences() {
- preferenceScreen = null
- addPreferencesFromResource(R.xml.preferences)
+ fun refreshPreference(key : String) {
+ val preference = preferenceManager.findPreference(key)!!
+ preference.isSelectable = !preference.isSelectable
+ preference.isSelectable = !preference.isSelectable
}
/**
diff --git a/app/src/main/java/emu/skyline/adapter/AppAdapter.kt b/app/src/main/java/emu/skyline/adapter/AppAdapter.kt
index 025dc007..35480f24 100644
--- a/app/src/main/java/emu/skyline/adapter/AppAdapter.kt
+++ b/app/src/main/java/emu/skyline/adapter/AppAdapter.kt
@@ -98,14 +98,12 @@ internal class AppAdapter(val layoutType : LayoutType, private val gridSpan : In
}
/**
- * This function binds the item at [position] to the supplied [viewHolder]
+ * This function binds the item at [position] to the supplied [holder]
*/
- override fun onBindViewHolder(viewHolder : RecyclerView.ViewHolder, position : Int) {
+ override fun onBindViewHolder(holder : RecyclerView.ViewHolder, position : Int) {
val item = getItem(position)
- if (item is AppItem) {
- val holder = viewHolder as ItemViewHolder
-
+ if (item is AppItem && holder is ItemViewHolder) {
holder.title.text = item.title
holder.subtitle.text = item.subTitle ?: missingString
@@ -122,9 +120,7 @@ internal class AppAdapter(val layoutType : LayoutType, private val gridSpan : In
setOnClickListener { onClick.invoke(item) }
setOnLongClickListener { true.also { onLongClick.invoke(item) } }
}
- } else if (item is BaseHeader) {
- val holder = viewHolder as HeaderViewHolder
-
+ } else if (item is BaseHeader && holder is HeaderViewHolder) {
holder.header!!.text = item.title
}
}
diff --git a/app/src/main/java/emu/skyline/adapter/ControllerAdapter.kt b/app/src/main/java/emu/skyline/adapter/ControllerAdapter.kt
new file mode 100644
index 00000000..1ba242ee
--- /dev/null
+++ b/app/src/main/java/emu/skyline/adapter/ControllerAdapter.kt
@@ -0,0 +1,228 @@
+/*
+ * SPDX-License-Identifier: MPL-2.0
+ * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
+ */
+
+package emu.skyline.adapter
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import emu.skyline.R
+import emu.skyline.data.BaseItem
+import emu.skyline.input.*
+
+/**
+ * This is a class that holds everything relevant to a single item in the controller configuration list
+ *
+ * @param content The main line of text describing what the item is
+ * @param subContent The secondary line of text to show data more specific data about the item
+ */
+abstract class ControllerItem(var content : String, var subContent : String) : BaseItem() {
+ /**
+ * The underlying adapter this item is contained within
+ */
+ var adapter : ControllerAdapter? = null
+
+ /**
+ * The position of this item in the adapter
+ */
+ var position : Int? = null
+
+ /**
+ * This function updates the visible contents of the item
+ */
+ fun update(content : String?, subContent : String?) {
+ if (content != null)
+ this.content = content
+
+ if (subContent != null)
+ this.subContent = subContent
+
+ position?.let { adapter?.notifyItemChanged(it) }
+ }
+
+ /**
+ * This is used as a generic function to update the contents of the item
+ */
+ abstract fun update()
+}
+
+/**
+ * This item is used to display the [type] of the currently active controller
+ */
+class ControllerTypeItem(val context : Context, val type : ControllerType) : ControllerItem(context.getString(R.string.controller_type), context.getString(type.stringRes)) {
+ /**
+ * This function just updates [subContent] based on [type]
+ */
+ override fun update() = update(null, context.getString(type.stringRes))
+}
+
+/**
+ * This item is used to display general settings items regarding controller
+ *
+ * @param type The type of controller setting this item is displaying
+ */
+class ControllerGeneralItem(val context : ControllerActivity, val type : GeneralType) : ControllerItem(context.getString(type.stringRes), getSummary(context, type)) {
+ companion object {
+ /**
+ * This returns the summary for [type] by using data encapsulated within [Controller]
+ */
+ fun getSummary(context : ControllerActivity, type : GeneralType) : String {
+ val controller = context.manager.controllers[context.id]!!
+
+ return when (type) {
+ GeneralType.PartnerJoyCon -> {
+ val partner = (controller as JoyConLeftController).partnerId
+
+ if (partner != null)
+ "${context.getString(R.string.controller)} #${partner + 1}"
+ else
+ context.getString(R.string.none)
+ }
+ GeneralType.RumbleDevice -> controller.rumbleDevice?.second ?: context.getString(R.string.none)
+ }
+ }
+ }
+
+ /**
+ * This function just updates [subContent] based on [getSummary]
+ */
+ override fun update() = update(null, getSummary(context, type))
+}
+
+/**
+ * This item is used to display a particular [button] mapping for the controller
+ */
+class ControllerButtonItem(val context : ControllerActivity, val button : ButtonId) : ControllerItem(button.long?.let { context.getString(it) } ?: button.toString(), getSummary(context, button)) {
+ companion object {
+ /**
+ * This returns the summary for [button] by doing a reverse-lookup in [InputManager.eventMap]
+ */
+ fun getSummary(context : ControllerActivity, button : ButtonId) : String {
+ val guestEvent = ButtonGuestEvent(context.id, button)
+ return context.manager.eventMap.filter { it.value is ButtonGuestEvent && it.value == guestEvent }.keys.firstOrNull()?.toString() ?: context.getString(R.string.none)
+ }
+ }
+
+ /**
+ * This function just updates [subContent] based on [getSummary]
+ */
+ override fun update() = update(null, getSummary(context, button))
+}
+
+/**
+ * This item is used to display all information regarding a [stick] and it's mappings for the controller
+ */
+class ControllerStickItem(val context : ControllerActivity, val stick : StickId) : ControllerItem(stick.toString(), getSummary(context, stick)) {
+ companion object {
+ /**
+ * This returns the summary for [stick] by doing reverse-lookups in [InputManager.eventMap]
+ */
+ fun getSummary(context : ControllerActivity, stick : StickId) : String {
+ val buttonGuestEvent = ButtonGuestEvent(context.id, stick.button)
+ val button = context.manager.eventMap.filter { it.value is ButtonGuestEvent && it.value == buttonGuestEvent }.keys.firstOrNull()?.toString() ?: context.getString(R.string.none)
+
+ var axisGuestEvent = AxisGuestEvent(context.id, stick.yAxis, true)
+ val yAxisPlus = context.manager.eventMap.filter { it.value is AxisGuestEvent && it.value == axisGuestEvent }.keys.firstOrNull()?.toString() ?: context.getString(R.string.none)
+
+ axisGuestEvent = AxisGuestEvent(context.id, stick.yAxis, false)
+ val yAxisMinus = context.manager.eventMap.filter { it.value is AxisGuestEvent && it.value == axisGuestEvent }.keys.firstOrNull()?.toString() ?: context.getString(R.string.none)
+
+ axisGuestEvent = AxisGuestEvent(context.id, stick.xAxis, true)
+ val xAxisPlus = context.manager.eventMap.filter { it.value is AxisGuestEvent && it.value == axisGuestEvent }.keys.firstOrNull()?.toString() ?: context.getString(R.string.none)
+
+ axisGuestEvent = AxisGuestEvent(context.id, stick.xAxis, false)
+ val xAxisMinus = context.manager.eventMap.filter { it.value is AxisGuestEvent && it.value == axisGuestEvent }.keys.firstOrNull()?.toString() ?: context.getString(R.string.none)
+
+ return "${context.getString(R.string.button)}: $button\n${context.getString(R.string.up)}: $yAxisPlus\n${context.getString(R.string.down)}: $yAxisMinus\n${context.getString(R.string.left)}: $xAxisMinus\n${context.getString(R.string.right)}: $xAxisPlus"
+ }
+ }
+
+ /**
+ * This function just updates [subContent] based on [getSummary]
+ */
+ override fun update() = update(null, getSummary(context, stick))
+}
+
+/**
+ * This adapter is used to create a list which handles having a simple view
+ */
+class ControllerAdapter(val context : Context) : HeaderAdapter() {
+ /**
+ * This adds a header to the view with the contents of [string]
+ */
+ fun addHeader(string : String) {
+ super.addHeader(BaseHeader(string))
+ }
+
+ /**
+ * This functions sets [ControllerItem.adapter] and delegates the call to [HeaderAdapter.addItem]
+ */
+ fun addItem(item : ControllerItem) {
+ item.adapter = this
+ super.addItem(item)
+ }
+
+ /**
+ * The ViewHolder used by items is used to hold the views associated with an item
+ *
+ * @param parent The parent view that contains all the others
+ * @param title The TextView associated with the title
+ * @param subtitle The TextView associated with the subtitle
+ * @param item The View containing the two other views
+ */
+ class ItemViewHolder(val parent : View, var title : TextView, var subtitle : TextView, var item : View) : RecyclerView.ViewHolder(parent)
+
+ /**
+ * The ViewHolder used by headers is used to hold the views associated with an headers
+ *
+ * @param parent The parent view that contains all the others
+ * @param header The TextView associated with the header
+ */
+ private class HeaderViewHolder(val parent : View, var header : TextView? = null) : RecyclerView.ViewHolder(parent)
+
+ /**
+ * This function creates the view-holder of type [viewType] with the layout parent as [parent]
+ */
+ override fun onCreateViewHolder(parent : ViewGroup, viewType : Int) : RecyclerView.ViewHolder {
+ val inflater = LayoutInflater.from(context)
+ var holder : RecyclerView.ViewHolder? = null
+
+ if (viewType == ElementType.Item.ordinal) {
+ val view = inflater.inflate(R.layout.controller_item, parent, false)
+ holder = ItemViewHolder(view, view.findViewById(R.id.text_title), view.findViewById(R.id.text_subtitle), view.findViewById(R.id.controller_item))
+
+ if (context is View.OnClickListener)
+ holder.item.setOnClickListener(context as View.OnClickListener)
+ } else if (viewType == ElementType.Header.ordinal) {
+ val view = inflater.inflate(R.layout.section_item, parent, false)
+ holder = HeaderViewHolder(view)
+
+ holder.header = view.findViewById(R.id.text_title)
+ }
+
+ return holder!!
+ }
+
+ /**
+ * This function binds the item at [position] to the supplied [holder]
+ */
+ override fun onBindViewHolder(holder : RecyclerView.ViewHolder, position : Int) {
+ val item = getItem(position)
+
+ if (item is ControllerItem && holder is ItemViewHolder) {
+ item.position = position
+
+ holder.title.text = item.content
+ holder.subtitle.text = item.subContent
+
+ holder.parent.tag = item
+ } else if (item is BaseHeader && holder is HeaderViewHolder) {
+ holder.header?.text = item.title
+ }
+ }
+}
diff --git a/app/src/main/java/emu/skyline/adapter/HeaderAdapter.kt b/app/src/main/java/emu/skyline/adapter/HeaderAdapter.kt
index 1d3b9600..5698fd21 100644
--- a/app/src/main/java/emu/skyline/adapter/HeaderAdapter.kt
+++ b/app/src/main/java/emu/skyline/adapter/HeaderAdapter.kt
@@ -17,13 +17,12 @@ import java.io.*
import java.util.*
import kotlin.collections.ArrayList
-
/**
* An enumeration of the type of elements in this adapter
*/
-enum class ElementType(val type : Int) {
- Header(0x0),
- Item(0x1)
+enum class ElementType {
+ Header,
+ Item,
}
/**
@@ -132,7 +131,7 @@ abstract class HeaderAdapter = arrayOf(), val buttons : Array = arrayOf()) {
+ None(R.string.none, false),
+ HandheldProController(R.string.handheld_procon, true, arrayOf(StickId.Left, StickId.Right), arrayOf(ButtonId.A, ButtonId.B, ButtonId.X, ButtonId.Y, ButtonId.DpadUp, ButtonId.DpadDown, ButtonId.DpadLeft, ButtonId.DpadRight, ButtonId.L, ButtonId.R, ButtonId.ZL, ButtonId.ZR, ButtonId.Plus, ButtonId.Minus)),
+ ProController(R.string.procon, false, arrayOf(StickId.Left, StickId.Right), arrayOf(ButtonId.A, ButtonId.B, ButtonId.X, ButtonId.Y, ButtonId.DpadUp, ButtonId.DpadDown, ButtonId.DpadLeft, ButtonId.DpadRight, ButtonId.L, ButtonId.R, ButtonId.ZL, ButtonId.ZR, ButtonId.Plus, ButtonId.Minus)),
+ JoyConLeft(R.string.ljoycon, false, arrayOf(StickId.Left), arrayOf(ButtonId.DpadUp, ButtonId.DpadDown, ButtonId.DpadLeft, ButtonId.DpadRight, ButtonId.L, ButtonId.ZL, ButtonId.Minus, ButtonId.LeftSL, ButtonId.LeftSR)),
+ JoyConRight(R.string.rjoycon, false, arrayOf(StickId.Right), arrayOf(ButtonId.A, ButtonId.B, ButtonId.X, ButtonId.Y, ButtonId.R, ButtonId.ZR, ButtonId.Plus, ButtonId.RightSL, ButtonId.RightSR)),
+}
+
+/**
+ * The enumerates the type of general settings for a Controller
+ *
+ * @param stringRes The string resource for the setting
+ * @param compatibleControllers An array of the types of compatible controllers
+ */
+enum class GeneralType(val stringRes : Int, val compatibleControllers : Array? = null) {
+ PartnerJoyCon(R.string.partner_joycon, arrayOf(ControllerType.JoyConLeft)),
+ RumbleDevice(R.string.rumble_device),
+}
+
+/**
+ * This is the base class for all controllers, when controllers require to store more variables it'll be stored here
+ *
+ * @param id The ID of the controller
+ * @param type The type of the controller
+ * @param rumbleDevice The device descriptor and the name of the device rumble/force-feedback will be passed onto
+ */
+open class Controller(val id : Int, var type : ControllerType, var rumbleDevice : Pair? = null) : Serializable {
+ /**
+ * The current version of this class so that different versions won't be deserialized mistakenly
+ */
+ private val serialVersionUID = 0L
+}
+
+/**
+ * This Controller class is for the Handheld-ProCon controller that change based on the operation mode
+ */
+class HandheldController(id : Int) : Controller(id, ControllerType.HandheldProController)
+
+/**
+ * This Controller class is for the Pro Controller (ProCon)
+ */
+class ProController(id : Int) : Controller(id, ControllerType.ProController)
+
+/**
+ * This Controller class is for the left Joy-Con controller
+ *
+ * @param partnerId The ID of the corresponding right Joy-Con if this is a pair
+ */
+class JoyConLeftController(id : Int, var partnerId : Int? = null) : Controller(id, ControllerType.JoyConLeft)
+
+/**
+ * This Controller class is for the right Joy-Con controller
+ *
+ * @param partnerId The ID of the corresponding left Joy-Con if this is a pair
+ */
+class JoyConRightController(id : Int, var partnerId : Int? = null) : Controller(id, ControllerType.JoyConRight)
diff --git a/app/src/main/java/emu/skyline/input/ControllerActivity.kt b/app/src/main/java/emu/skyline/input/ControllerActivity.kt
new file mode 100644
index 00000000..45bcdb10
--- /dev/null
+++ b/app/src/main/java/emu/skyline/input/ControllerActivity.kt
@@ -0,0 +1,256 @@
+/*
+ * SPDX-License-Identifier: MPL-2.0
+ * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
+ */
+
+package emu.skyline.input
+
+import android.os.Bundle
+import android.view.KeyEvent
+import android.view.View
+import androidx.appcompat.app.AppCompatActivity
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import emu.skyline.R
+import emu.skyline.adapter.*
+import emu.skyline.input.dialog.ButtonDialog
+import emu.skyline.input.dialog.RumbleDialog
+import emu.skyline.input.dialog.StickDialog
+import kotlinx.android.synthetic.main.controller_activity.*
+import kotlinx.android.synthetic.main.titlebar.*
+
+/**
+ * This activity is used to change the settings for a specific controller
+ */
+class ControllerActivity : AppCompatActivity(), View.OnClickListener {
+ /**
+ * The index of the controller this activity manages
+ */
+ var id : Int = -1
+
+ /**
+ * The adapter used by [controller_list] to hold all the items
+ */
+ val adapter = ControllerAdapter(this)
+
+ /**
+ * The [InputManager] class handles loading/saving the input data
+ */
+ lateinit var manager : InputManager
+
+ /**
+ * This is a map between a button and it's corresponding [ControllerItem] in [adapter]
+ */
+ val buttonMap = mutableMapOf()
+
+ /**
+ * This is a map between an axis and it's corresponding [ControllerStickItem] in [adapter]
+ */
+ val axisMap = mutableMapOf()
+
+ /**
+ * This function updates the [adapter] based on information from [manager]
+ */
+ private fun update() {
+ adapter.clear()
+
+ val controller = manager.controllers[id]!!
+
+ adapter.addItem(ControllerTypeItem(this, controller.type))
+
+ if (controller.type == ControllerType.None)
+ return
+
+ var wroteTitle = false
+
+ for (item in GeneralType.values()) {
+ if (item.compatibleControllers == null || item.compatibleControllers.contains(controller.type)) {
+ if (!wroteTitle) {
+ adapter.addHeader(getString(R.string.general))
+ wroteTitle = true
+ }
+
+ adapter.addItem(ControllerGeneralItem(this, item))
+ }
+ }
+
+ wroteTitle = false
+
+ for (stick in controller.type.sticks) {
+ if (!wroteTitle) {
+ adapter.addHeader(getString(R.string.sticks))
+ wroteTitle = true
+ }
+
+ val stickItem = ControllerStickItem(this, stick)
+
+ adapter.addItem(stickItem)
+ buttonMap[stick.button] = stickItem
+ axisMap[stick.xAxis] = stickItem
+ axisMap[stick.yAxis] = stickItem
+ }
+
+ val dpadButtons = Pair(R.string.dpad, arrayOf(ButtonId.DpadUp, ButtonId.DpadDown, ButtonId.DpadLeft, ButtonId.DpadRight))
+ val faceButtons = Pair(R.string.face_buttons, arrayOf(ButtonId.A, ButtonId.B, ButtonId.X, ButtonId.Y))
+ val shoulderTriggerButtons = Pair(R.string.shoulder_trigger, arrayOf(ButtonId.L, ButtonId.R, ButtonId.ZL, ButtonId.ZR))
+ val shoulderRailButtons = Pair(R.string.shoulder_rail, arrayOf(ButtonId.LeftSL, ButtonId.LeftSR, ButtonId.RightSL, ButtonId.RightSR))
+
+ val buttonArrays = arrayOf(dpadButtons, faceButtons, shoulderTriggerButtons, shoulderRailButtons)
+
+ for (buttonArray in buttonArrays) {
+ wroteTitle = false
+
+ for (button in controller.type.buttons.filter { it in buttonArray.second }) {
+ if (!wroteTitle) {
+ adapter.addHeader(getString(buttonArray.first))
+ wroteTitle = true
+ }
+
+ val buttonItem = ControllerButtonItem(this, button)
+
+ adapter.addItem(buttonItem)
+ buttonMap[button] = buttonItem
+ }
+ }
+
+ wroteTitle = false
+
+ for (button in controller.type.buttons.filterNot { item -> buttonArrays.any { item in it.second } }.plus(ButtonId.Menu)) {
+ if (!wroteTitle) {
+ adapter.addHeader(getString(R.string.misc_buttons))
+ wroteTitle = true
+ }
+
+ val buttonItem = ControllerButtonItem(this, button)
+
+ adapter.addItem(buttonItem)
+ buttonMap[button] = buttonItem
+ }
+ }
+
+ /**
+ * This initializes all of the elements in the activity
+ */
+ override fun onCreate(state : Bundle?) {
+ super.onCreate(state)
+
+ manager = InputManager(this)
+
+ id = intent.getIntExtra("index", 0)
+
+ if (id < 0 || id > 7)
+ throw IllegalArgumentException()
+
+ title = "${getString(R.string.config_controller)} #${id + 1}"
+
+ setContentView(R.layout.controller_activity)
+
+ setSupportActionBar(toolbar)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+
+ window.decorView.findViewById(android.R.id.content).viewTreeObserver.addOnTouchModeChangeListener {
+ if (!it)
+ toolbar_layout.setExpanded(false)
+ }
+
+ controller_list.layoutManager = LinearLayoutManager(this)
+ controller_list.adapter = adapter
+
+ update()
+ }
+
+ /**
+ * This causes the input file to be synced when the activity has been paused
+ */
+ override fun onPause() {
+ manager.syncFile()
+ super.onPause()
+ }
+
+ /**
+ * This handles the onClick events for the items in the activity
+ */
+ override fun onClick(v : View?) {
+ when (val tag = v!!.tag) {
+ is ControllerTypeItem -> {
+ val type = manager.controllers[id]!!.type
+
+ val types = ControllerType.values().filter { !it.firstController || id == 0 }
+ val typeNames = types.map { getString(it.stringRes) }.toTypedArray()
+
+ MaterialAlertDialogBuilder(this)
+ .setTitle(tag.content)
+ .setSingleChoiceItems(typeNames, types.indexOf(type)) { dialog, typeIndex ->
+ manager.controllers[id] = when (types[typeIndex]) {
+ ControllerType.None -> Controller(id, ControllerType.None)
+ ControllerType.HandheldProController -> HandheldController(id)
+ ControllerType.ProController -> ProController(id)
+ ControllerType.JoyConLeft -> JoyConLeftController(id)
+ ControllerType.JoyConRight -> JoyConRightController(id)
+ }
+
+ update()
+
+ dialog.dismiss()
+ }
+ .show()
+ }
+
+ is ControllerGeneralItem -> {
+ when (tag.type) {
+ GeneralType.PartnerJoyCon -> {
+ val controller = manager.controllers[id] as JoyConLeftController
+
+ val rJoyCons = manager.controllers.values.filter { it.type == ControllerType.JoyConRight }
+ val rJoyConNames = (listOf(getString(R.string.none)) + rJoyCons.map { "${getString(R.string.controller)} #${it.id + 1}" }).toTypedArray()
+
+ val partnerNameIndex = if (controller.partnerId == null) 0 else rJoyCons.withIndex().single { it.value.id == controller.partnerId }.index + 1
+
+ MaterialAlertDialogBuilder(this)
+ .setTitle(tag.content)
+ .setSingleChoiceItems(rJoyConNames, partnerNameIndex) { dialog, index ->
+ (manager.controllers[controller.partnerId ?: -1] as JoyConRightController?)?.partnerId = null
+
+ controller.partnerId = if (index == 0) null else rJoyCons[index - 1].id
+
+ if (controller.partnerId != null)
+ (manager.controllers[controller.partnerId ?: -1] as JoyConRightController?)?.partnerId = controller.id
+
+ tag.update()
+
+ dialog.dismiss()
+ }
+ .show()
+ }
+
+ GeneralType.RumbleDevice -> {
+ val dialog = RumbleDialog(tag)
+ dialog.show(supportFragmentManager, null)
+ }
+ }
+ }
+
+ is ControllerButtonItem -> {
+ val dialog = ButtonDialog(tag)
+ dialog.show(supportFragmentManager, null)
+ }
+
+ is ControllerStickItem -> {
+ val dialog = StickDialog(tag)
+ dialog.show(supportFragmentManager, null)
+ }
+ }
+ }
+
+ /**
+ * This handles on calling [onBackPressed] when [KeyEvent.KEYCODE_BUTTON_B] is lifted
+ */
+ override fun onKeyUp(keyCode : Int, event : KeyEvent?) : Boolean {
+ if (keyCode == KeyEvent.KEYCODE_BUTTON_B) {
+ onBackPressed()
+ return true
+ }
+
+ return super.onKeyUp(keyCode, event)
+ }
+}
diff --git a/app/src/main/java/emu/skyline/input/GuestEvent.kt b/app/src/main/java/emu/skyline/input/GuestEvent.kt
new file mode 100644
index 00000000..0bbfa3b6
--- /dev/null
+++ b/app/src/main/java/emu/skyline/input/GuestEvent.kt
@@ -0,0 +1,156 @@
+/*
+ * SPDX-License-Identifier: MPL-2.0
+ * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
+ */
+
+package emu.skyline.input
+
+import java.io.Serializable
+import java.util.*
+import kotlin.math.abs
+
+/**
+ * This enumerates all of the buttons that the emulator recognizes
+ */
+enum class ButtonId(val short : String? = null, val long : Int? = null) {
+ A("A", emu.skyline.R.string.a_button),
+ B("B", emu.skyline.R.string.b_button),
+ X("X", emu.skyline.R.string.x_button),
+ Y("Y", emu.skyline.R.string.y_button),
+ LeftStick("L"),
+ RightStick("R"),
+ L("L", emu.skyline.R.string.left_shoulder),
+ R("R", emu.skyline.R.string.right_shoulder),
+ ZL("ZL", emu.skyline.R.string.left_trigger),
+ ZR("ZR", emu.skyline.R.string.right_trigger),
+ Plus("+", emu.skyline.R.string.plus_button),
+ Minus("-", emu.skyline.R.string.minus_button),
+ DpadLeft("◀", emu.skyline.R.string.left),
+ DpadUp("▲", emu.skyline.R.string.up),
+ DpadRight("▶", emu.skyline.R.string.right),
+ DpadDown("▼", emu.skyline.R.string.down),
+ LeftStickLeft,
+ LeftStickUp,
+ LeftStickRight,
+ LeftStickDown,
+ RightStickLeft,
+ RightStickUp,
+ RightStickRight,
+ RightStickDown,
+ LeftSL("SL", emu.skyline.R.string.left_shoulder),
+ LeftSR("SR", emu.skyline.R.string.right_shoulder),
+ RightSL("SL", emu.skyline.R.string.left_shoulder),
+ RightSR("SR", emu.skyline.R.string.right_shoulder),
+ Menu("⌂", emu.skyline.R.string.emu_menu_button);
+
+ /**
+ * This returns the value as setting the [ordinal]-th bit in a [Long]
+ */
+ fun value() : Long {
+ return (1.toLong()) shl ordinal
+ }
+}
+
+/**
+ * This enumerates the states of a button and denotes their Boolean values in [state]
+ */
+enum class ButtonState(val state : Boolean) {
+ Released(false),
+ Pressed(true),
+}
+
+/**
+ * This enumerates all of the axes on a controller that the emulator recognizes
+ */
+enum class AxisId {
+ RX,
+ RY,
+ LX,
+ LY,
+}
+
+/**
+ * This enumerates all the sticks on a controller with all their components
+ *
+ * @param xAxis The [AxisId] corresponding to movement on the X-axis for the stick
+ * @param yAxis The [AxisId] corresponding to movement on the Y-axis for the stick
+ * @param button The [ButtonId] of the button activated when the stick is pressed
+ */
+enum class StickId(val xAxis : AxisId, val yAxis : AxisId, val button : ButtonId) {
+ Left(AxisId.LX, AxisId.LY, ButtonId.LeftStick),
+ Right(AxisId.RX, AxisId.RY, ButtonId.RightStick);
+
+ override fun toString() = "$name Stick"
+}
+
+/**
+ * This an abstract class for all guest events that is inherited by all other event classes
+ *
+ * @param id The ID of the guest controller this event corresponds to
+ */
+abstract class GuestEvent(val id : Int) : Serializable {
+ /**
+ * The equality function is abstract so that equality checking will be for the derived classes rather than this abstract class
+ */
+ abstract override fun equals(other : Any?) : Boolean
+
+ /**
+ * The hash function is abstract so that hashes will be generated for the derived classes rather than this abstract class
+ */
+ abstract override fun hashCode() : Int
+}
+
+/**
+ * This class is used for all guest events that correspond to a button
+ *
+ * @param button The ID of the button that this represents
+ * @param threshold The threshold of a corresponding [MotionHostEvent]'s axis value for this to be "pressed"
+ */
+class ButtonGuestEvent(id : Int, val button : ButtonId, val threshold : Float = 0f) : GuestEvent(id) {
+ /**
+ * This does some basic equality checking for the type of [other] and all members in the class except [threshold] as that is irrelevant for a lookup
+ */
+ override fun equals(other : Any?) : Boolean = if (other is ButtonGuestEvent) this.id == other.id && this.button == other.button else false
+
+ /**
+ * This computes the hash for all members of the class except [threshold] as that is irrelevant for a lookup
+ */
+ override fun hashCode() : Int = Objects.hash(id, button)
+}
+
+/**
+ * This class is used for all guest events that correspond to a specific pole of an axis
+ *
+ * @param axis The ID of the axis that this represents
+ * @param polarity The polarity of the axis this represents
+ * @param max The maximum recorded value of the corresponding [MotionHostEvent] to scale the axis value
+ */
+class AxisGuestEvent(id : Int, val axis : AxisId, val polarity : Boolean, var max : Float = 1f) : GuestEvent(id) {
+ /**
+ * This does some basic equality checking for the type of [other] and all members in the class except [max] as that is irrelevant for a lookup
+ */
+ override fun equals(other : Any?) : Boolean = if (other is AxisGuestEvent) this.id == other.id && this.axis == other.axis && this.polarity == other.polarity else false
+
+ /**
+ * This computes the hash for all members of the class except [max] as that is irrelevant for a lookup
+ */
+ override fun hashCode() : Int = Objects.hash(id, axis, polarity)
+
+ /**
+ * This is used to retrieve the scaled value/update the maximum value of this axis
+ *
+ * @param axis The unscaled value of the axis to scale
+ * @return The scaled value of this axis
+ */
+ fun value(axis : Float) : Float {
+ if (max == 1f) return axis
+
+ val axisAbs = abs(axis)
+ if (axisAbs >= max) {
+ max = axisAbs
+ return 1f
+ }
+
+ return axis + (axis * (1f - max))
+ }
+}
diff --git a/app/src/main/java/emu/skyline/input/HostEvent.kt b/app/src/main/java/emu/skyline/input/HostEvent.kt
new file mode 100644
index 00000000..a77d81fd
--- /dev/null
+++ b/app/src/main/java/emu/skyline/input/HostEvent.kt
@@ -0,0 +1,69 @@
+/*
+ * SPDX-License-Identifier: MPL-2.0
+ * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
+ */
+
+package emu.skyline.input
+
+import android.view.KeyEvent
+import android.view.MotionEvent
+import java.io.Serializable
+import java.util.*
+
+/**
+ * This an abstract class for all host events that is inherited by all other event classes
+ *
+ * @param descriptor The device descriptor of the device this event originates from
+ */
+abstract class HostEvent(val descriptor : String = "") : Serializable {
+ /**
+ * The [toString] function is abstract so that the derived classes can return a proper string
+ */
+ abstract override fun toString() : String
+
+ /**
+ * The equality function is abstract so that equality checking will be for the derived classes rather than this abstract class
+ */
+ abstract override fun equals(other : Any?) : Boolean
+
+ /**
+ * The hash function is abstract so that hashes will be generated for the derived classes rather than this abstract class
+ */
+ abstract override fun hashCode() : Int
+}
+
+class KeyHostEvent(descriptor : String = "", val keyCode : Int) : HostEvent(descriptor) {
+ /**
+ * This returns the string representation of [keyCode]
+ */
+ override fun toString() : String = KeyEvent.keyCodeToString(keyCode)
+
+ /**
+ * This does some basic equality checking for the type of [other] and all members in the class
+ */
+ override fun equals(other : Any?) : Boolean = if (other is KeyHostEvent) this.descriptor == other.descriptor && this.keyCode == other.keyCode else false
+
+ /**
+ * This computes the hash for all members of the class
+ */
+ override fun hashCode() : Int = Objects.hash(descriptor, keyCode)
+}
+
+class MotionHostEvent(descriptor : String = "", val axis : Int, val polarity : Boolean) : HostEvent(descriptor) {
+ /**
+ * This returns the string representation of [axis] combined with [polarity]
+ */
+ override fun toString() : String = MotionEvent.axisToString(axis) + if (polarity) "+" else "-"
+
+ /**
+ * This does some basic equality checking for the type of [other] and all members in the class
+ */
+ override fun equals(other : Any?) : Boolean {
+ return if (other is MotionHostEvent) this.descriptor == other.descriptor && this.axis == other.axis && this.polarity == other.polarity else false
+ }
+
+ /**
+ * This computes the hash for all members of the class
+ */
+ override fun hashCode() : Int = Objects.hash(descriptor, axis, polarity)
+}
diff --git a/app/src/main/java/emu/skyline/input/InputManager.kt b/app/src/main/java/emu/skyline/input/InputManager.kt
new file mode 100644
index 00000000..aef530e1
--- /dev/null
+++ b/app/src/main/java/emu/skyline/input/InputManager.kt
@@ -0,0 +1,108 @@
+/*
+ * SPDX-License-Identifier: MPL-2.0
+ * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
+ */
+
+package emu.skyline.input
+
+import android.content.Context
+import android.util.Log
+import java.io.*
+
+/**
+ * This class is used to manage all transactions with storing/retrieving data in relation to input
+ */
+class InputManager constructor(val context : Context) {
+ /**
+ * The underlying [File] object with the input data
+ */
+ private val file = File("${context.applicationInfo.dataDir}/input.bin")
+
+ /**
+ * A [HashMap] of all the controllers that contains their metadata
+ */
+ lateinit var controllers : HashMap
+
+ /**
+ * A [HashMap] between all [HostEvent]s and their corresponding [GuestEvent]s
+ */
+ lateinit var eventMap : HashMap
+
+ init {
+ var readFile = false
+
+ try {
+ if (file.exists() && file.length() != 0L) {
+ syncObjects()
+ readFile = true
+ }
+ } catch (e : Exception) {
+ Log.e(this.toString(), e.localizedMessage ?: "InputManager cannot read \"${file.absolutePath}\"")
+ }
+
+ if (!readFile) {
+ controllers = hashMapOf(
+ 0 to Controller(0, ControllerType.None),
+ 1 to Controller(1, ControllerType.None),
+ 2 to Controller(2, ControllerType.None),
+ 3 to Controller(3, ControllerType.None),
+ 4 to Controller(4, ControllerType.None),
+ 5 to Controller(5, ControllerType.None),
+ 6 to Controller(6, ControllerType.None),
+ 7 to Controller(7, ControllerType.None))
+
+ eventMap = hashMapOf()
+
+ syncFile()
+ }
+ }
+
+ /**
+ * This function syncs the class with data from [file]
+ */
+ fun syncObjects() {
+ val fileInput = FileInputStream(file)
+ val objectInput = ObjectInputStream(fileInput)
+
+ @Suppress("UNCHECKED_CAST")
+ controllers = objectInput.readObject() as HashMap
+
+ @Suppress("UNCHECKED_CAST")
+ eventMap = objectInput.readObject() as HashMap
+ }
+
+ /**
+ * This function syncs [file] with data from the class and eliminates unused value from the map
+ */
+ fun syncFile() {
+ val fileOutput = FileOutputStream(file)
+ val objectOutput = ObjectOutputStream(fileOutput)
+
+ for (controller in controllers.values) {
+ for (button in ButtonId.values()) {
+ if (button != ButtonId.Menu && !(controller.type.buttons.contains(button) || controller.type.sticks.any { it.button == button })) {
+ val guestEvent = ButtonGuestEvent(controller.id, button)
+
+ eventMap.filterValues { it is ButtonGuestEvent && it == guestEvent }.keys.forEach { eventMap.remove(it) }
+ }
+ }
+
+ for (stick in StickId.values()) {
+ if (!controller.type.sticks.contains(stick)) {
+ for (axis in arrayOf(stick.xAxis, stick.yAxis)) {
+ for (polarity in booleanArrayOf(true, false)) {
+ val guestEvent = AxisGuestEvent(controller.id, axis, polarity)
+
+ eventMap.filterValues { it is AxisGuestEvent && it == guestEvent }.keys.forEach { eventMap.remove(it) }
+ }
+ }
+ }
+ }
+ }
+
+ objectOutput.writeObject(controllers)
+ objectOutput.writeObject(eventMap)
+
+ objectOutput.flush()
+ }
+}
diff --git a/app/src/main/java/emu/skyline/input/Npad.kt b/app/src/main/java/emu/skyline/input/Npad.kt
deleted file mode 100644
index dbed09ed..00000000
--- a/app/src/main/java/emu/skyline/input/Npad.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * SPDX-License-Identifier: MPL-2.0
- * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
- */
-
-package emu.skyline.input
-
-/**
- * This enumerates all buttons on an NPad controller
- */
-enum class NpadButton : ButtonId {
- A,
- B,
- X,
- Y,
- LeftStick,
- RightStick,
- L,
- R,
- ZL,
- ZR,
- Plus,
- Minus,
- DpadLeft,
- DpadUp,
- DpadRight,
- DpadDown,
- LeftStickLeft,
- LeftStickUp,
- LeftStickRight,
- LeftStickDown,
- RightStickLeft,
- RightStickUp,
- RightStickRight,
- RightStickDown,
- LeftSL,
- LeftSR,
- RightSL,
- RightSR;
-
- /**
- * This just returns the value as setting the [ordinal]-th bit in a [Long]
- */
- override fun value() : Long {
- return (1.toLong()) shl ordinal
- }
-}
-
-/**
- * This enumerates all the axis on an NPad controller
- */
-enum class NpadAxis {
- RX,
- RY,
- LX,
- LY,
-}
diff --git a/app/src/main/java/emu/skyline/input/dialog/ButtonDialog.kt b/app/src/main/java/emu/skyline/input/dialog/ButtonDialog.kt
new file mode 100644
index 00000000..f08de67e
--- /dev/null
+++ b/app/src/main/java/emu/skyline/input/dialog/ButtonDialog.kt
@@ -0,0 +1,240 @@
+/*
+ * SPDX-License-Identifier: MPL-2.0
+ * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
+ */
+
+package emu.skyline.input.dialog
+
+import android.animation.LayoutTransition
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.view.*
+import android.view.animation.LinearInterpolator
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import emu.skyline.R
+import emu.skyline.adapter.ControllerButtonItem
+import emu.skyline.input.*
+import kotlinx.android.synthetic.main.button_dialog.*
+import kotlin.math.abs
+
+/**
+ * This dialog is used to set a device to map any buttons
+ *
+ * @param item This is used to hold the [ControllerButtonItem] between instances
+ */
+class ButtonDialog(val item : ControllerButtonItem) : BottomSheetDialogFragment() {
+ /**
+ * This inflates the layout of the dialog after initial view creation
+ */
+ override fun onCreateView(inflater : LayoutInflater, container : ViewGroup?, savedInstanceState : Bundle?) : View? = inflater.inflate(R.layout.button_dialog, container)
+
+ /**
+ * This expands the bottom sheet so that it's fully visible
+ */
+ override fun onStart() {
+ super.onStart()
+
+ val behavior = BottomSheetBehavior.from(requireView().parent as View)
+ behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ }
+
+ /**
+ * This sets up all user interaction with this dialog
+ */
+ override fun onActivityCreated(savedInstanceState : Bundle?) {
+ super.onActivityCreated(savedInstanceState)
+
+ if (context is ControllerActivity) {
+ val context = requireContext() as ControllerActivity
+ val controller = context.manager.controllers[context.id]!!
+
+ // View focus handling so all input is always directed to this view
+ view?.requestFocus()
+ view?.onFocusChangeListener = View.OnFocusChangeListener { v, hasFocus -> if (!hasFocus) v.requestFocus() }
+
+ // Write the text for the button's icon
+ button_text.text = item.button.short ?: item.button.toString()
+
+ // Set up the reset button to clear out all entries corresponding to this button from [InputManager.eventMap]
+ button_reset.setOnClickListener {
+ val guestEvent = ButtonGuestEvent(context.id, item.button)
+
+ context.manager.eventMap.filterValues { it is ButtonGuestEvent && it == guestEvent }.keys.forEach { context.manager.eventMap.remove(it) }
+
+ item.update()
+
+ dismiss()
+ }
+
+ // Ensure that layout animations are proper
+ button_layout.layoutTransition.setAnimateParentHierarchy(false)
+ button_layout.layoutTransition.enableTransitionType(LayoutTransition.CHANGING)
+
+ // We want the secondary progress bar to be visible through the first one
+ button_seekbar.progressDrawable.alpha = 128
+
+ var deviceId : Int? = null // The ID of the currently selected device
+ var inputId : Int? = null // The key code/axis ID of the currently selected event
+
+ var axisPolarity = false // The polarity of the axis for the currently selected event
+ var axisRunnable : Runnable? = null // The Runnable that is used for counting down till an axis is selected
+ val axisHandler = Handler(Looper.getMainLooper()) // The handler responsible for handling posting [axisRunnable]
+
+ // The last values of the HAT axes so that they can be ignored in [View.OnGenericMotionListener] so they are passed onto [DialogInterface.OnKeyListener] as [KeyEvent]s
+ var oldDpadX = 0.0f
+ var oldDpadY = 0.0f
+
+ dialog?.setOnKeyListener { _, _, event ->
+ // We want all input events from Joysticks and Buttons except for [KeyEvent.KEYCODE_BACK] as that will should be processed elsewhere
+ if (((event.isFromSource(InputDevice.SOURCE_CLASS_BUTTON) && event.keyCode != KeyEvent.KEYCODE_BACK) || event.isFromSource(InputDevice.SOURCE_CLASS_JOYSTICK)) && event.repeatCount == 0) {
+ if ((deviceId != event.deviceId || inputId != event.keyCode) && event.action == KeyEvent.ACTION_DOWN) {
+ // We set [deviceId] and [inputId] on [KeyEvent.ACTION_DOWN] alongside updating the views to match the action
+ deviceId = event.deviceId
+ inputId = event.keyCode
+
+ if (axisRunnable != null) {
+ axisHandler.removeCallbacks(axisRunnable!!)
+ axisRunnable = null
+ }
+
+ button_icon.animate().alpha(0.75f).setDuration(50).start()
+ button_text.animate().alpha(0.9f).setDuration(50).start()
+
+ button_title.text = getString(R.string.release_confirm)
+ button_seekbar.visibility = View.GONE
+ } else if (deviceId == event.deviceId && inputId == event.keyCode && event.action == KeyEvent.ACTION_UP) {
+ // We serialize the current [deviceId] and [inputId] into a [KeyHostEvent] and map it to a corresponding [GuestEvent] on [KeyEvent.ACTION_UP]
+ val hostEvent = KeyHostEvent(event.device.descriptor, event.keyCode)
+
+ var guestEvent = context.manager.eventMap[hostEvent]
+
+ if (guestEvent is GuestEvent) {
+ context.manager.eventMap.remove(hostEvent)
+
+ if (guestEvent is ButtonGuestEvent)
+ context.buttonMap[guestEvent.button]?.update()
+ else if (guestEvent is AxisGuestEvent)
+ context.axisMap[guestEvent.axis]?.update()
+ }
+
+ guestEvent = ButtonGuestEvent(context.id, item.button)
+
+ context.manager.eventMap.filterValues { it == guestEvent }.keys.forEach { context.manager.eventMap.remove(it) }
+
+ context.manager.eventMap[hostEvent] = guestEvent
+
+ item.update()
+
+ dismiss()
+ }
+
+ true
+ } else {
+ false
+ }
+ }
+
+ val axes = arrayOf(MotionEvent.AXIS_X, MotionEvent.AXIS_Y, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ, MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_THROTTLE, MotionEvent.AXIS_RUDDER, MotionEvent.AXIS_WHEEL, MotionEvent.AXIS_GAS, MotionEvent.AXIS_BRAKE).plus(IntRange(MotionEvent.AXIS_GENERIC_1, MotionEvent.AXIS_GENERIC_16).toList())
+
+ val axesHistory = arrayOfNulls(axes.size) // The last recorded value of an axis, this is used to eliminate any stagnant axes
+
+ view?.setOnGenericMotionListener { _, event ->
+ // We retrieve the value of the HAT axes so that we can check for change and ignore any input from them so it'll be passed onto the [KeyEvent] handler
+ val dpadX = event.getAxisValue(MotionEvent.AXIS_HAT_X)
+ val dpadY = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
+
+ // We want all input events from Joysticks and Buttons that are [MotionEvent.ACTION_MOVE] and not from the D-pad
+ if ((event.isFromSource(InputDevice.SOURCE_CLASS_JOYSTICK) || event.isFromSource(InputDevice.SOURCE_CLASS_BUTTON)) && event.action == MotionEvent.ACTION_MOVE && dpadX == oldDpadX && dpadY == oldDpadY) {
+ // We iterate over every axis to check if any of them pass the selection threshold and if they do then select them by setting [deviceId], [inputId] and [axisPolarity]
+ for (axisItem in axes.withIndex()) {
+ val axis = axisItem.value
+ val value = event.getAxisValue(axis)
+
+ // This checks the history of the axis so it we can ignore any stagnant axis
+ if ((event.historySize == 0 || value == event.getHistoricalAxisValue(axis, 0)) && (axesHistory[axisItem.index]?.let { it == value } != false)) {
+ axesHistory[axisItem.index] = value
+ continue
+ }
+
+ axesHistory[axisItem.index] = value
+
+ if (abs(value) >= 0.5 && (deviceId != event.deviceId || inputId != axis || axisPolarity != (value >= 0)) && !(axes.contains(inputId) && value == event.getAxisValue(inputId!!))) {
+ deviceId = event.deviceId
+ inputId = axis
+ axisPolarity = value >= 0
+
+ button_title.text = getString(R.string.hold_confirm)
+ button_seekbar.visibility = View.VISIBLE
+
+ break
+ }
+ }
+
+ // If the currently active input is a valid axis
+ if (axes.contains(inputId)) {
+ val value = event.getAxisValue(inputId!!)
+ val threshold = button_seekbar.progress / 100f
+
+ // Update the secondary progress bar in [button_seekbar] based on the axis's value
+ button_seekbar.secondaryProgress = (abs(value) * 100).toInt()
+
+ // If the axis value crosses the threshold then post [axisRunnable] with a delay and animate the views accordingly
+ if (abs(value) >= threshold) {
+ if (axisRunnable == null) {
+ axisRunnable = Runnable {
+ val hostEvent = MotionHostEvent(event.device.descriptor, inputId!!, axisPolarity)
+
+ var guestEvent = context.manager.eventMap[hostEvent]
+
+ if (guestEvent is GuestEvent) {
+ context.manager.eventMap.remove(hostEvent)
+
+ if (guestEvent is ButtonGuestEvent)
+ context.buttonMap[(guestEvent as ButtonGuestEvent).button]?.update()
+ else if (guestEvent is AxisGuestEvent)
+ context.axisMap[(guestEvent as AxisGuestEvent).axis]?.update()
+ }
+
+ guestEvent = ButtonGuestEvent(controller.id, item.button)
+
+ context.manager.eventMap.filterValues { it == guestEvent }.keys.forEach { context.manager.eventMap.remove(it) }
+
+ context.manager.eventMap[hostEvent] = guestEvent
+
+ item.update()
+
+ dismiss()
+ }
+
+ axisHandler.postDelayed(axisRunnable!!, 1000)
+ }
+
+ button_icon.animate().alpha(0.85f).setInterpolator(LinearInterpolator(context, null)).setDuration(1000).start()
+ button_text.animate().alpha(0.95f).setInterpolator(LinearInterpolator(context, null)).setDuration(1000).start()
+ } else {
+ // If the axis value is below the threshold, remove [axisRunnable] from it being posted and animate the views accordingly
+ if (axisRunnable != null) {
+ axisHandler.removeCallbacks(axisRunnable!!)
+ axisRunnable = null
+ }
+
+ button_icon.animate().alpha(0.25f).setDuration(50).start()
+ button_text.animate().alpha(0.35f).setDuration(50).start()
+ }
+ }
+
+ true
+ } else {
+ oldDpadX = dpadX
+ oldDpadY = dpadY
+
+ false
+ }
+ }
+ } else {
+ dismiss()
+ }
+ }
+}
diff --git a/app/src/main/java/emu/skyline/input/dialog/RumbleDialog.kt b/app/src/main/java/emu/skyline/input/dialog/RumbleDialog.kt
new file mode 100644
index 00000000..020d5236
--- /dev/null
+++ b/app/src/main/java/emu/skyline/input/dialog/RumbleDialog.kt
@@ -0,0 +1,123 @@
+/*
+ * SPDX-License-Identifier: MPL-2.0
+ * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
+ */
+
+package emu.skyline.input.dialog
+
+import android.animation.LayoutTransition
+import android.os.Bundle
+import android.os.VibrationEffect
+import android.view.*
+import android.view.animation.LinearInterpolator
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import emu.skyline.R
+import emu.skyline.adapter.ControllerGeneralItem
+import emu.skyline.input.ControllerActivity
+import kotlinx.android.synthetic.main.rumble_dialog.*
+
+/**
+ * This dialog is used to set a device to pass on any rumble/force feedback data onto
+ *
+ * @param item This is used to hold the [ControllerGeneralItem] between instances
+ */
+class RumbleDialog(val item : ControllerGeneralItem) : BottomSheetDialogFragment() {
+ /**
+ * This inflates the layout of the dialog after initial view creation
+ */
+ override fun onCreateView(inflater : LayoutInflater, container : ViewGroup?, savedInstanceState : Bundle?) : View? = inflater.inflate(R.layout.rumble_dialog, container)
+
+ /**
+ * This expands the bottom sheet so that it's fully visible
+ */
+ override fun onStart() {
+ super.onStart()
+
+ val behavior = BottomSheetBehavior.from(requireView().parent as View)
+ behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ }
+
+ /**
+ * This sets up all user interaction with this dialog
+ */
+ override fun onActivityCreated(savedInstanceState : Bundle?) {
+ super.onActivityCreated(savedInstanceState)
+
+ if (context is ControllerActivity) {
+ val context = requireContext() as ControllerActivity
+ val controller = context.manager.controllers[context.id]!!
+
+ // Set up the reset button to clear out [Controller.rumbleDevice] when pressed
+ rumble_reset.setOnClickListener {
+ controller.rumbleDevice = null
+ item.update()
+
+ dismiss()
+ }
+
+ // Ensure that layout animations are proper
+ rumble_layout.layoutTransition.enableTransitionType(LayoutTransition.CHANGING)
+ rumble_controller.layoutTransition.enableTransitionType(LayoutTransition.CHANGING)
+
+ var deviceId : Int? = null // The ID of the currently selected device
+
+ dialog?.setOnKeyListener { _, _, event ->
+ // We want all input events from Joysticks and game pads
+ if (event.isFromSource(InputDevice.SOURCE_GAMEPAD) || event.isFromSource(InputDevice.SOURCE_JOYSTICK)) {
+ if (event.repeatCount == 0 && event.action == KeyEvent.ACTION_DOWN) {
+ val vibrator = event.device.vibrator
+
+ when {
+ // If the device doesn't match the currently selected device then update the UI accordingly and set [deviceId] to the current device
+ deviceId != event.deviceId -> {
+ rumble_controller_name.text = event.device.name
+
+ if (vibrator.hasVibrator()) {
+ rumble_controller_supported.text = getString(R.string.supported)
+ rumble_title.text = getString(R.string.confirm_button_again)
+
+ vibrator.vibrate(VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE))
+ } else {
+ rumble_controller_supported.text = getString(R.string.not_supported)
+ rumble_title.text = getString(R.string.press_any_button)
+ }
+
+ rumble_controller_icon.animate().apply {
+ interpolator = LinearInterpolator()
+ duration = 100
+ alpha(if (vibrator.hasVibrator()) 0.75f else 0.5f)
+ start()
+ }
+
+ deviceId = event.deviceId
+ }
+
+ // If the currently selected device has a vibrator then go ahead and select it
+ vibrator.hasVibrator() -> {
+ vibrator.vibrate(VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE))
+
+ controller.rumbleDevice = Pair(event.device.descriptor, event.device.name)
+
+ item.update()
+
+ dismiss()
+ }
+
+ // If the currently selected device doesn't have a vibrator then dismiss the dialog entirely
+ else -> {
+ dismiss()
+ }
+ }
+ }
+
+ true
+ } else {
+ false
+ }
+ }
+ } else {
+ dismiss()
+ }
+ }
+}
diff --git a/app/src/main/java/emu/skyline/input/dialog/StickDialog.kt b/app/src/main/java/emu/skyline/input/dialog/StickDialog.kt
new file mode 100644
index 00000000..4757c9a2
--- /dev/null
+++ b/app/src/main/java/emu/skyline/input/dialog/StickDialog.kt
@@ -0,0 +1,605 @@
+/*
+ * SPDX-License-Identifier: MPL-2.0
+ * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
+ */
+
+package emu.skyline.input.dialog
+
+import android.animation.LayoutTransition
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.util.TypedValue
+import android.view.*
+import android.view.animation.LinearInterpolator
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import emu.skyline.R
+import emu.skyline.adapter.ControllerStickItem
+import emu.skyline.input.*
+import kotlinx.android.synthetic.main.stick_dialog.*
+import java.util.*
+import kotlin.math.abs
+import kotlin.math.max
+
+/**
+ * This dialog is used to set a device to map any sticks
+ *
+ * @param item This is used to hold the [ControllerStickItem] between instances
+ */
+class StickDialog(val item : ControllerStickItem) : BottomSheetDialogFragment() {
+ /**
+ * This enumerates all of the stages this dialog can be in
+ */
+ private enum class DialogStage(val string : Int) {
+ Button(R.string.stick_button),
+ YPlus(R.string.y_plus),
+ YMinus(R.string.y_minus),
+ XMinus(R.string.x_minus),
+ XPlus(R.string.x_plus),
+ Stick(R.string.stick_preview);
+ }
+
+ /**
+ * This is the current stage of the dialog
+ */
+ private var stage = DialogStage.Button
+
+ /**
+ * This is the handler of all [Runnable]s posted by the dialog
+ */
+ private val handler = Handler(Looper.getMainLooper())
+
+ /**
+ * This is the [Runnable] that is used for running the current stage's animation
+ */
+ private var stageAnimation : Runnable? = null
+
+ /**
+ * This is a flag that causes any running animation to immediately halt
+ */
+ private var animationStop = false
+
+ /**
+ * This inflates the layout of the dialog after initial view creation
+ */
+ override fun onCreateView(inflater : LayoutInflater, container : ViewGroup?, savedInstanceState : Bundle?) : View? {
+ return requireActivity().layoutInflater.inflate(R.layout.stick_dialog, container)
+ }
+
+ /**
+ * This expands the bottom sheet so that it's fully visible
+ */
+ override fun onStart() {
+ super.onStart()
+
+ val behavior = BottomSheetBehavior.from(requireView().parent as View)
+ behavior.state = BottomSheetBehavior.STATE_EXPANDED
+ }
+
+ /**
+ * This function converts [dip] (Density Independent Pixels) to normal pixels
+ */
+ private fun dipToPixels(dip : Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dip, resources.displayMetrics)
+
+ /**
+ * This function updates the animation based on the current stage and stops the currently running animation if it hasn't already
+ */
+ @Suppress("LABEL_NAME_CLASH")
+ fun updateAnimation() {
+ animationStop = false
+ stageAnimation?.let { handler.removeCallbacks(it) }
+
+ stick_container?.animate()?.scaleX(1f)?.scaleY(1f)?.alpha(1f)?.translationY(0f)?.translationX(0f)?.rotationX(0f)?.rotationY(0f)?.start()
+
+ when (stage) {
+ DialogStage.Button -> {
+ stageAnimation = Runnable {
+ if (stage != DialogStage.Button || animationStop)
+ return@Runnable
+
+ stick_container?.animate()?.scaleX(0.85f)?.scaleY(0.85f)?.alpha(1f)?.withEndAction {
+ if (stage != DialogStage.Button || animationStop)
+ return@withEndAction
+
+ val runnable = Runnable {
+ if (stage != DialogStage.Button || animationStop)
+ return@Runnable
+
+ stick_container?.animate()?.scaleX(1f)?.scaleY(1f)?.alpha(0.85f)?.withEndAction {
+ if (stage != DialogStage.Button || animationStop)
+ return@withEndAction
+
+ stageAnimation?.let {
+ handler.postDelayed(it, 750)
+ }
+ }?.start()
+ }
+
+ handler.postDelayed(runnable, 300)
+ }?.start()
+ }
+ }
+
+ DialogStage.YPlus, DialogStage.YMinus -> {
+ val polarity = if (stage == DialogStage.YMinus) 1 else -1
+
+ stageAnimation = Runnable {
+ if ((stage != DialogStage.YPlus && stage != DialogStage.YMinus) || animationStop)
+ return@Runnable
+
+ stick_container?.animate()?.setDuration(300)?.translationY(dipToPixels(15f) * polarity)?.rotationX(27f * polarity)?.alpha(1f)?.withEndAction {
+ if ((stage != DialogStage.YPlus && stage != DialogStage.YMinus) || animationStop)
+ return@withEndAction
+
+ val runnable = Runnable {
+ if ((stage != DialogStage.YPlus && stage != DialogStage.YMinus) || animationStop)
+ return@Runnable
+
+ stick_container?.animate()?.setDuration(250)?.translationY(0f)?.rotationX(0f)?.alpha(0.85f)?.withEndAction {
+ if ((stage != DialogStage.YPlus && stage != DialogStage.YMinus) || animationStop)
+ return@withEndAction
+
+ stageAnimation?.let {
+ handler.postDelayed(it, 750)
+ }
+ }?.start()
+ }
+
+ handler.postDelayed(runnable, 300)
+ }?.start()
+ }
+ }
+
+ DialogStage.XPlus, DialogStage.XMinus -> {
+ val polarity = if (stage == DialogStage.XPlus) 1 else -1
+
+ stageAnimation = Runnable {
+ if ((stage != DialogStage.XPlus && stage != DialogStage.XMinus) || animationStop)
+ return@Runnable
+
+ stick_container?.animate()?.setDuration(300)?.translationX(dipToPixels(16.5f) * polarity)?.rotationY(27f * polarity)?.alpha(1f)?.withEndAction {
+ if ((stage != DialogStage.XPlus && stage != DialogStage.XMinus) || animationStop)
+ return@withEndAction
+
+ val runnable = Runnable {
+ if ((stage != DialogStage.XPlus && stage != DialogStage.XMinus) || animationStop)
+ return@Runnable
+
+ stick_container?.animate()?.setDuration(250)?.translationX(0f)?.rotationY(0f)?.alpha(0.85f)?.withEndAction {
+ if ((stage != DialogStage.XPlus && stage != DialogStage.XMinus) || animationStop)
+ return@withEndAction
+
+ stageAnimation?.let {
+ handler.postDelayed(it, 750)
+ }
+ }?.start()
+ }
+
+ handler.postDelayed(runnable, 300)
+ }?.start()
+ }
+ }
+
+ else -> {
+ }
+ }
+
+ stageAnimation?.let { handler.postDelayed(it, 750) }
+ }
+
+ /**
+ * This function goes to a particular stage based on the offset from the current stage
+ */
+ private fun gotoStage(offset : Int = 1) {
+ val ordinal = stage.ordinal + offset
+ val size = DialogStage.values().size
+
+ if (ordinal in 0 until size) {
+ stage = DialogStage.values()[ordinal]
+
+ stick_title.text = getString(stage.string)
+ stick_subtitle.text = if (stage != DialogStage.Stick) getString(R.string.use_button_axis) else getString(R.string.use_non_stick)
+ stick_icon.animate().alpha(0.25f).setDuration(50).start()
+ stick_name.animate().alpha(0.35f).setDuration(50).start()
+ stick_seekbar.visibility = View.GONE
+
+ stick_next.text = if (ordinal + 1 == size) getString(R.string.done) else getString(R.string.next)
+
+ updateAnimation()
+ } else {
+ dismiss()
+ }
+ }
+
+ /**
+ * This sets up all user interaction with this dialog
+ */
+ override fun onActivityCreated(savedInstanceState : Bundle?) {
+ super.onActivityCreated(savedInstanceState)
+
+ if (context is ControllerActivity) {
+ val context = requireContext() as ControllerActivity
+ val controller = context.manager.controllers[context.id]!!
+
+ // View focus handling so all input is always directed to this view
+ view?.requestFocus()
+ view?.onFocusChangeListener = View.OnFocusChangeListener { v, hasFocus -> if (!hasFocus) v.requestFocus() }
+
+ // Write the text for the stick's icon
+ stick_name.text = item.stick.button.short ?: item.stick.button.toString()
+
+ // Set up the reset button to clear out all entries corresponding to this stick from [InputManager.eventMap]
+ stick_reset.setOnClickListener {
+ for (axis in arrayOf(item.stick.xAxis, item.stick.yAxis)) {
+ for (polarity in booleanArrayOf(true, false)) {
+ val guestEvent = AxisGuestEvent(context.id, axis, polarity)
+
+ context.manager.eventMap.filterValues { it == guestEvent }.keys.forEach { context.manager.eventMap.remove(it) }
+ }
+ }
+
+ val guestEvent = ButtonGuestEvent(context.id, item.stick.button)
+
+ context.manager.eventMap.filterValues { it == guestEvent }.keys.forEach { context.manager.eventMap.remove(it) }
+
+ item.update()
+
+ dismiss()
+ }
+
+ // Ensure that layout animations are proper
+ stick_layout.layoutTransition.setAnimateParentHierarchy(false)
+ stick_layout.layoutTransition.enableTransitionType(LayoutTransition.CHANGING)
+
+ // We want the secondary progress bar to be visible through the first one
+ stick_seekbar.progressDrawable.alpha = 128
+
+ updateAnimation()
+
+ var deviceId : Int? = null // The ID of the currently selected device
+ var inputId : Int? = null // The key code/axis ID of the currently selected event
+
+ val ignoredEvents = mutableListOf() // The hashes of events that are to be ignored due to being already mapped to some component of the stick
+
+ var axisPolarity = false // The polarity of the axis for the currently selected event
+ var axisRunnable : Runnable? = null // The Runnable that is used for counting down till an axis is selected
+
+ // The last values of the HAT axes so that they can be ignored in [View.OnGenericMotionListener] so they are passed onto [DialogInterface.OnKeyListener] as [KeyEvent]s
+ var oldDpadX = 0.0f
+ var oldDpadY = 0.0f
+
+ stick_next.setOnClickListener {
+ gotoStage(1)
+
+ deviceId = null
+ inputId = null
+
+ axisRunnable?.let { handler.removeCallbacks(it) }
+ }
+
+ view?.setOnKeyListener { _, _, event ->
+ when {
+ // We want all input events from Joysticks and Buttons except for [KeyEvent.KEYCODE_BACK] as that will should be processed elsewhere
+ ((event.isFromSource(InputDevice.SOURCE_CLASS_BUTTON) && event.keyCode != KeyEvent.KEYCODE_BACK) || event.isFromSource(InputDevice.SOURCE_CLASS_JOYSTICK)) && event.repeatCount == 0 -> {
+ if (stage == DialogStage.Stick) {
+ // When the stick is being previewed after everything is mapped we do a lookup into [InputManager.eventMap] to find a corresponding [GuestEvent] and animate the stick correspondingly
+ when (val guestEvent = context.manager.eventMap[KeyHostEvent(event.device.descriptor, event.keyCode)]) {
+ is ButtonGuestEvent -> {
+ if (guestEvent.button == item.stick.button) {
+ if (event.action == KeyEvent.ACTION_DOWN) {
+ stick_container?.animate()?.setStartDelay(0)?.setDuration(50)?.scaleX(0.85f)?.scaleY(0.85f)?.start()
+
+ stick_icon.animate().alpha(0.85f).setDuration(50).start()
+ stick_name.animate().alpha(0.95f).setDuration(50).start()
+ } else {
+ stick_container?.animate()?.setStartDelay(0)?.setDuration(25)?.scaleX(1f)?.scaleY(1f)?.start()
+
+ stick_icon.animate().alpha(0.25f).setDuration(25).start()
+ stick_name.animate().alpha(0.35f).setDuration(25).start()
+ }
+ } else if (event.action == KeyEvent.ACTION_UP) {
+ stick_next?.callOnClick()
+ }
+ }
+
+ is AxisGuestEvent -> {
+ val coefficient = if (event.action == KeyEvent.ACTION_DOWN) {
+ if (guestEvent.polarity) 1 else -1
+ } else {
+ 0
+ }
+
+ if (guestEvent.axis == item.stick.xAxis) {
+ stick_container?.translationX = dipToPixels(16.5f) * coefficient
+ stick_container?.rotationY = 27f * coefficient
+ } else if (guestEvent.axis == item.stick.yAxis) {
+ stick_container?.translationY = dipToPixels(16.5f) * -coefficient
+ stick_container?.rotationX = 27f * coefficient
+ }
+ }
+
+ null -> if (event.action == KeyEvent.ACTION_UP) stick_next?.callOnClick()
+ }
+ } else if (stage != DialogStage.Stick) {
+ if ((deviceId != event.deviceId || inputId != event.keyCode) && event.action == KeyEvent.ACTION_DOWN && !ignoredEvents.any { it == Objects.hash(event.deviceId, event.keyCode) }) {
+ // We set [deviceId] and [inputId] on [KeyEvent.ACTION_DOWN] alongside updating the views to match the action while ignoring any events in [ignoredEvents]
+ deviceId = event.deviceId
+ inputId = event.keyCode
+
+ if (axisRunnable != null) {
+ handler.removeCallbacks(axisRunnable!!)
+ axisRunnable = null
+ }
+
+ animationStop = true
+
+ val coefficient = if (stage == DialogStage.YMinus || stage == DialogStage.XPlus) 1 else -1
+
+ when (stage) {
+ DialogStage.Button -> stick_container?.animate()?.setStartDelay(0)?.setDuration(25)?.scaleX(0.85f)?.scaleY(0.85f)?.alpha(1f)?.start()
+ DialogStage.YPlus, DialogStage.YMinus -> stick_container?.animate()?.setStartDelay(0)?.setDuration(75)?.translationY(dipToPixels(16.5f) * coefficient)?.rotationX(27f * coefficient)?.alpha(1f)?.start()
+ DialogStage.XPlus, DialogStage.XMinus -> stick_container?.animate()?.setStartDelay(0)?.setDuration(75)?.translationX(dipToPixels(16.5f) * coefficient)?.rotationY(27f * coefficient)?.alpha(1f)?.start()
+ else -> {
+ }
+ }
+
+ stick_icon.animate().alpha(0.85f).setDuration(50).start()
+ stick_name.animate().alpha(0.95f).setDuration(50).start()
+
+ stick_subtitle.text = getString(R.string.release_confirm)
+ stick_seekbar.visibility = View.GONE
+ } else if (deviceId == event.deviceId && inputId == event.keyCode && event.action == KeyEvent.ACTION_UP) {
+ // We serialize the current [deviceId] and [inputId] into a [KeyHostEvent] and map it to a corresponding [GuestEvent] and add it to [ignoredEvents] on [KeyEvent.ACTION_UP]
+ val hostEvent = KeyHostEvent(event.device.descriptor, event.keyCode)
+
+ var guestEvent = context.manager.eventMap[hostEvent]
+
+ if (guestEvent is GuestEvent) {
+ context.manager.eventMap.remove(hostEvent)
+
+ if (guestEvent is ButtonGuestEvent)
+ context.buttonMap[guestEvent.button]?.update()
+ else if (guestEvent is AxisGuestEvent)
+ context.axisMap[guestEvent.axis]?.update()
+ }
+
+ guestEvent = when (stage) {
+ DialogStage.Button -> ButtonGuestEvent(controller.id, item.stick.button)
+ DialogStage.YPlus, DialogStage.YMinus -> AxisGuestEvent(controller.id, item.stick.yAxis, stage == DialogStage.YPlus)
+ DialogStage.XPlus, DialogStage.XMinus -> AxisGuestEvent(controller.id, item.stick.xAxis, stage == DialogStage.XPlus)
+ else -> null
+ }
+
+ context.manager.eventMap.filterValues { it == guestEvent }.keys.forEach { context.manager.eventMap.remove(it) }
+
+ context.manager.eventMap[hostEvent] = guestEvent
+
+ ignoredEvents.add(Objects.hash(deviceId!!, inputId!!))
+
+ item.update()
+
+ stick_next?.callOnClick()
+ }
+ }
+
+ true
+ }
+
+ event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP -> {
+ // We handle [KeyEvent.KEYCODE_BACK] by trying to go to the last stage using [gotoStage]
+ gotoStage(-1)
+
+ deviceId = null
+ inputId = null
+
+ true
+ }
+
+ else -> {
+ false
+ }
+ }
+ }
+
+ val axes = arrayOf(MotionEvent.AXIS_X, MotionEvent.AXIS_Y, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_THROTTLE, MotionEvent.AXIS_RUDDER, MotionEvent.AXIS_WHEEL, MotionEvent.AXIS_GAS, MotionEvent.AXIS_BRAKE).plus(IntRange(MotionEvent.AXIS_GENERIC_1, MotionEvent.AXIS_GENERIC_16).toList())
+
+ val axesHistory = arrayOfNulls(axes.size) // The last recorded value of an axis, this is used to eliminate any stagnant axes
+ val axesMax = Array(axes.size) { 0f } // The maximum recorded value of the axis, this is to scale the axis to a stick accordingly (The value is also checked at runtime, so it's fine if this isn't the true maximum)
+
+ view?.setOnGenericMotionListener { _, event ->
+ // We retrieve the value of the HAT axes so that we can check for change and ignore any input from them so it'll be passed onto the [KeyEvent] handler
+ val dpadX = event.getAxisValue(MotionEvent.AXIS_HAT_X)
+ val dpadY = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
+
+ // We want all input events from Joysticks and Buttons that are [MotionEvent.ACTION_MOVE] and not from the D-pad
+ if ((event.isFromSource(InputDevice.SOURCE_CLASS_JOYSTICK) || event.isFromSource(InputDevice.SOURCE_CLASS_BUTTON)) && event.action == MotionEvent.ACTION_MOVE && dpadX == oldDpadX && dpadY == oldDpadY) {
+ if (stage == DialogStage.Stick) {
+ // When the stick is being previewed after everything is mapped we do a lookup into [InputManager.eventMap] to find a corresponding [GuestEvent] and animate the stick correspondingly
+ for (axisItem in axes.withIndex()) {
+ val axis = axisItem.value
+ var value = event.getAxisValue(axis)
+
+ if ((event.historySize == 0 || value == event.getHistoricalAxisValue(axis, 0)) && (axesHistory[axisItem.index]?.let { it == value } != false)) {
+ axesHistory[axisItem.index] = value
+ continue
+ }
+
+ axesHistory[axisItem.index] = value
+
+ var polarity = value >= 0
+ val guestEvent = context.manager.eventMap[MotionHostEvent(event.device.descriptor, axis, polarity)] ?: if (value == 0f) {
+ polarity = false
+ context.manager.eventMap[MotionHostEvent(event.device.descriptor, axis, polarity)]
+ } else {
+ null
+ }
+
+ when (guestEvent) {
+ is ButtonGuestEvent -> {
+ if (guestEvent.button == item.stick.button) {
+ if (abs(value) >= guestEvent.threshold) {
+ stick_container?.animate()?.setStartDelay(0)?.setDuration(50)?.scaleX(0.85f)?.scaleY(0.85f)?.start()
+ stick_icon.animate().alpha(0.85f).setDuration(50).start()
+ stick_name.animate().alpha(0.95f).setDuration(50).start()
+ } else {
+ stick_container?.animate()?.setStartDelay(0)?.setDuration(25)?.scaleX(1f)?.scaleY(1f)?.start()
+ stick_icon.animate().alpha(0.25f).setDuration(25).start()
+ stick_name.animate().alpha(0.35f).setDuration(25).start()
+ }
+ }
+ }
+
+ is AxisGuestEvent -> {
+ value = guestEvent.value(value)
+
+ val coefficient = if (polarity) abs(value) else -abs(value)
+
+ if (guestEvent.axis == item.stick.xAxis) {
+ stick_container?.translationX = dipToPixels(16.5f) * coefficient
+ stick_container?.rotationY = 27f * coefficient
+ } else if (guestEvent.axis == item.stick.yAxis) {
+ stick_container?.translationY = dipToPixels(16.5f) * coefficient
+ stick_container?.rotationX = 27f * -coefficient
+ }
+ }
+ }
+ }
+ } else {
+ // We iterate over every axis to check if any of them pass the selection threshold and if they do then select them by setting [deviceId], [inputId] and [axisPolarity]
+ for (axisItem in axes.withIndex()) {
+ val axis = axisItem.value
+ val value = event.getAxisValue(axis)
+
+ axesMax[axisItem.index] = max(abs(value), axesMax[axisItem.index])
+
+ // This checks the history of the axis so it we can ignore any stagnant axis
+ if ((event.historySize == 0 || value == event.getHistoricalAxisValue(axis, 0)) && (axesHistory[axisItem.index]?.let { it == value } != false)) {
+ axesHistory[axisItem.index] = value
+ continue
+ }
+
+ axesHistory[axisItem.index] = value
+
+ if (abs(value) >= 0.5 && (deviceId != event.deviceId || inputId != axis || axisPolarity != (value >= 0)) && !ignoredEvents.any { it == Objects.hash(event.deviceId, axis, value >= 0) } && !(axes.contains(inputId) && value == event.getAxisValue(inputId!!))) {
+ deviceId = event.deviceId
+ inputId = axis
+ axisPolarity = value >= 0
+
+ stick_subtitle.text = getString(R.string.hold_confirm)
+
+ if (stage == DialogStage.Button)
+ stick_seekbar.visibility = View.VISIBLE
+
+ animationStop = true
+
+ break
+ }
+ }
+
+ // If the currently active input is a valid axis
+ if (axes.contains(inputId)) {
+ val value = event.getAxisValue(inputId!!)
+ val threshold = if (stage == DialogStage.Button) stick_seekbar.progress / 100f else 0.5f
+
+ when (stage) {
+ // Update the secondary progress bar in [button_seekbar] based on the axis's value
+ DialogStage.Button -> {
+ stick_container?.animate()?.setStartDelay(0)?.setDuration(25)?.scaleX(0.85f)?.scaleY(0.85f)?.alpha(1f)?.start()
+ stick_seekbar.secondaryProgress = (abs(value) * 100).toInt()
+ }
+
+
+ // Update the the position of the stick in the Y-axis based on the axis's value
+ DialogStage.YPlus, DialogStage.YMinus -> {
+ val coefficient = if (stage == DialogStage.YMinus) abs(value) else -abs(value)
+
+ stick_container?.translationY = dipToPixels(16.5f) * coefficient
+ stick_container?.rotationX = 27f * -coefficient
+ }
+
+ // Update the the position of the stick in the X-axis based on the axis's value
+ DialogStage.XPlus, DialogStage.XMinus -> {
+ val coefficient = if (stage == DialogStage.XPlus) abs(value) else -abs(value)
+
+ stick_container?.translationX = dipToPixels(16.5f) * coefficient
+ stick_container?.rotationY = 27f * coefficient
+ }
+
+ else -> {
+ }
+ }
+
+ // If the axis value crosses the threshold then post [axisRunnable] with a delay and animate the views accordingly
+ if (abs(value) >= threshold) {
+ if (axisRunnable == null) {
+ axisRunnable = Runnable {
+ val hostEvent = MotionHostEvent(event.device.descriptor, inputId!!, axisPolarity)
+
+ var guestEvent = context.manager.eventMap[hostEvent]
+
+ if (guestEvent is GuestEvent) {
+ context.manager.eventMap.remove(hostEvent)
+
+ if (guestEvent is ButtonGuestEvent)
+ context.buttonMap[(guestEvent as ButtonGuestEvent).button]?.update()
+ else if (guestEvent is AxisGuestEvent)
+ context.axisMap[(guestEvent as AxisGuestEvent).axis]?.update()
+ }
+
+ val max = axesMax[axes.indexOf(inputId!!)]
+
+ guestEvent = when (stage) {
+ DialogStage.Button -> ButtonGuestEvent(controller.id, item.stick.button, threshold)
+ DialogStage.YPlus, DialogStage.YMinus -> AxisGuestEvent(controller.id, item.stick.yAxis, stage == DialogStage.YPlus, max)
+ DialogStage.XPlus, DialogStage.XMinus -> AxisGuestEvent(controller.id, item.stick.xAxis, stage == DialogStage.XPlus, max)
+ else -> null
+ }
+
+ context.manager.eventMap.filterValues { it == guestEvent }.keys.forEach { context.manager.eventMap.remove(it) }
+
+ context.manager.eventMap[hostEvent] = guestEvent
+
+ ignoredEvents.add(Objects.hash(deviceId!!, inputId!!, axisPolarity))
+
+ axisRunnable = null
+
+ item.update()
+
+ stick_next?.callOnClick()
+ }
+
+ handler.postDelayed(axisRunnable!!, 1000)
+ }
+
+ stick_icon.animate().alpha(0.85f).setInterpolator(LinearInterpolator(context, null)).setDuration(1000).start()
+ stick_name.animate().alpha(0.95f).setInterpolator(LinearInterpolator(context, null)).setDuration(1000).start()
+ } else {
+ // If the axis value is below the threshold, remove [axisRunnable] from it being posted and animate the views accordingly
+ if (axisRunnable != null) {
+ handler.removeCallbacks(axisRunnable!!)
+ axisRunnable = null
+ }
+
+ if (stage == DialogStage.Button)
+ stick_container?.animate()?.setStartDelay(0)?.setDuration(10)?.scaleX(1f)?.scaleY(1f)?.alpha(1f)?.start()
+
+ stick_icon.animate().alpha(0.25f).setDuration(50).start()
+ stick_name.animate().alpha(0.35f).setDuration(50).start()
+ }
+ }
+ }
+
+ true
+ } else {
+ oldDpadX = dpadX
+ oldDpadY = dpadY
+
+ false
+ }
+ }
+ } else {
+ dismiss()
+ }
+ }
+}
diff --git a/app/src/main/java/emu/skyline/preference/ControllerPreference.kt b/app/src/main/java/emu/skyline/preference/ControllerPreference.kt
new file mode 100644
index 00000000..caa43762
--- /dev/null
+++ b/app/src/main/java/emu/skyline/preference/ControllerPreference.kt
@@ -0,0 +1,64 @@
+/*
+ * SPDX-License-Identifier: MPL-2.0
+ * Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
+ */
+
+package emu.skyline.preference
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.util.AttributeSet
+import androidx.preference.Preference
+import androidx.preference.Preference.SummaryProvider
+import emu.skyline.R
+import emu.skyline.SettingsActivity
+import emu.skyline.input.ControllerActivity
+
+/**
+ * This preference is used to launch [ControllerActivity] using a preference
+ */
+class ControllerPreference : Preference {
+ /**
+ * The index of the controller this preference manages
+ */
+ private var index : Int = -1
+
+ constructor(context : Context?, attrs : AttributeSet?, defStyleAttr : Int) : super(context, attrs, defStyleAttr) {
+ for (i in 0 until attrs!!.attributeCount) {
+ val attr = attrs.getAttributeName(i)
+
+ if (attr.equals("index", ignoreCase = true)) {
+ index = attrs.getAttributeValue(i).toInt()
+ break
+ }
+ }
+
+ if (index == -1)
+ throw IllegalArgumentException()
+
+ if (key == null)
+ key = "controller_$index"
+
+ title = "${context?.getString(R.string.config_controller)} #${index + 1}"
+
+ if (context is SettingsActivity)
+ summaryProvider = SummaryProvider { _ -> context.inputManager.controllers[index]?.type?.stringRes?.let { context.getString(it) } }
+ }
+
+ constructor(context : Context?, attrs : AttributeSet?) : this(context, attrs, R.attr.preferenceStyle)
+
+ constructor(context : Context?) : this(context, null)
+
+ /**
+ * This launches [ControllerActivity] on click to configure the controller
+ */
+ override fun onClick() {
+ if (context is SettingsActivity)
+ (context as SettingsActivity).refreshKey = key
+
+ val intent = Intent(context, ControllerActivity::class.java)
+ intent.putExtra("index", index)
+ (context as Activity).startActivityForResult(intent, 0)
+ }
+}
diff --git a/app/src/main/java/emu/skyline/preference/FolderPreference.kt b/app/src/main/java/emu/skyline/preference/FolderPreference.kt
index c99d9ffa..3cca893f 100644
--- a/app/src/main/java/emu/skyline/preference/FolderPreference.kt
+++ b/app/src/main/java/emu/skyline/preference/FolderPreference.kt
@@ -11,7 +11,9 @@ import android.content.Intent
import android.net.Uri
import android.util.AttributeSet
import androidx.preference.Preference
+import androidx.preference.Preference.SummaryProvider
import androidx.preference.R
+import emu.skyline.SettingsActivity
/**
* This preference shows the decoded URI of it's preference and launches [FolderActivity]
@@ -23,7 +25,10 @@ class FolderPreference : Preference {
private var mDirectory : String? = null
constructor(context : Context?, attrs : AttributeSet?, defStyleAttr : Int) : super(context, attrs, defStyleAttr) {
- summaryProvider = SimpleSummaryProvider()
+ summaryProvider = SummaryProvider { preference ->
+ preference.onSetInitialValue(null)
+ Uri.decode(preference.mDirectory) ?: ""
+ }
}
constructor(context : Context?, attrs : AttributeSet?) : this(context, attrs, R.attr.preferenceStyle)
@@ -34,6 +39,9 @@ class FolderPreference : Preference {
* This launches [FolderActivity] on click to change the directory
*/
override fun onClick() {
+ if (context is SettingsActivity)
+ (context as SettingsActivity).refreshKey = key
+
val intent = Intent(context, FolderActivity::class.java)
(context as Activity).startActivityForResult(intent, 0)
}
@@ -44,16 +52,4 @@ class FolderPreference : Preference {
override fun onSetInitialValue(defaultValue : Any?) {
mDirectory = getPersistedString(defaultValue as String?)
}
-
- /**
- * This [Preference.SummaryProvider] is used to set the summary for URI values
- */
- private class SimpleSummaryProvider : SummaryProvider {
- /**
- * This returns the decoded URI of the directory as the summary
- */
- override fun provideSummary(preference : FolderPreference) : CharSequence {
- return Uri.decode(preference.mDirectory) ?: ""
- }
- }
}
diff --git a/app/src/main/res/drawable-night/ic_controller.xml b/app/src/main/res/drawable-night/ic_controller.xml
new file mode 100644
index 00000000..4dcc1fa7
--- /dev/null
+++ b/app/src/main/res/drawable-night/ic_controller.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_button.xml b/app/src/main/res/drawable/ic_button.xml
new file mode 100644
index 00000000..ba8ea434
--- /dev/null
+++ b/app/src/main/res/drawable/ic_button.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_controller.xml b/app/src/main/res/drawable/ic_controller.xml
new file mode 100644
index 00000000..1ff4b0ae
--- /dev/null
+++ b/app/src/main/res/drawable/ic_controller.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_stick.xml b/app/src/main/res/drawable/ic_stick.xml
new file mode 100644
index 00000000..45db0273
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stick.xml
@@ -0,0 +1,32 @@
+
+
+ -
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_stick_circle.xml b/app/src/main/res/drawable/ic_stick_circle.xml
new file mode 100644
index 00000000..ba8ea434
--- /dev/null
+++ b/app/src/main/res/drawable/ic_stick_circle.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/button_dialog.xml b/app/src/main/res/layout/button_dialog.xml
new file mode 100644
index 00000000..b62ac3dc
--- /dev/null
+++ b/app/src/main/res/layout/button_dialog.xml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/controller_activity.xml b/app/src/main/res/layout/controller_activity.xml
new file mode 100644
index 00000000..7147a7bc
--- /dev/null
+++ b/app/src/main/res/layout/controller_activity.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/controller_item.xml b/app/src/main/res/layout/controller_item.xml
new file mode 100644
index 00000000..1abb8dc6
--- /dev/null
+++ b/app/src/main/res/layout/controller_item.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/rumble_dialog.xml b/app/src/main/res/layout/rumble_dialog.xml
new file mode 100644
index 00000000..80f13cf1
--- /dev/null
+++ b/app/src/main/res/layout/rumble_dialog.xml
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/stick_dialog.xml b/app/src/main/res/layout/stick_dialog.xml
new file mode 100644
index 00000000..0a4bcea2
--- /dev/null
+++ b/app/src/main/res/layout/stick_dialog.xml
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2fb389ea..6663827f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,4 +1,4 @@
-
+
Skyline
Search
@@ -24,10 +24,14 @@
Emulator
Search Location
+ Theme
Game Display Layout
Always Show Game Information
Game information will be shown on clicking a game
Game information will only be shown on long-clicking a game
+ Show Performance Statistics
+ Performance Statistics will not be shown
+ Performance Statistics will be shown in the top-left corner
Log Level
Compact Logs
Logs will be displayed in a compact form factor
@@ -36,9 +40,64 @@
Use Docked Mode
The system will emulate being in handheld mode
The system will emulate being in docked mode
- Theme
Username
@string/app_name
+
+ Input
+ Show On-Screen Controls
+ On-Screen Controls won\'t be shown
+ On-Screen Controls will be shown
+ Controller
+ Configure Controller
+ Controller Type
+ None
+ Handheld + Pro Controller
+ Pro Controller
+ Left JoyCon
+ Right JoyCon
+ General
+ Partner Joy-Con
+ Rumble Device
+ Supported
+ Not Supported
+ Press any button on a controller
+ Confirm choice by pressing a button again
+ Reset
+ Buttons
+ Use any button or axis on a controller
+ Release to confirm selection
+ Hold to confirm selection
+ Sticks
+ Stick Button
+ Next
+ Stick X+ Axis (Right)
+ Stick Y+ Axis (Up)
+ Stick X- Axis (Left)
+ Stick Y- Axis (Down)
+ A
+ B
+ X
+ Y
+ Left Shoulder
+ Right Shoulder
+ Left Trigger
+ Right Trigger
+ Plus
+ Minus
+ Emulator Menu
+ Stick Preview
+ Done
+ Use any unmapped button to finish
+ Button
+ Up
+ Down
+ Left
+ Right
+ D-pad
+ Face Buttons
+
+ Shoulder Buttons on Joy-Con Rail
+ Miscellaneous Buttons
Licenses
The license of Skyline (MPL 2.0)
@@ -66,7 +125,7 @@
We use Roboto as our FOSS shared font replacement for Nintendo\'s extended character set (Apache License 2.0)
Source Sans Pro
We use Source Sans Pro as our FOSS shared font replacement for Extended Chinese (SIL Open Font License 1.1)
- Show Performance Statistics
- Performance Statistics will not be shown
- Performance Statistics will be shown in the top-left corner
+
+
+ Expand
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 1f783e2c..2b7f6f71 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -9,6 +9,11 @@
- @color/colorOnSecondary
+
+