Addition of Controller Configuration UI

This commit adds in the UI for Controller Configuration to Settings, in addition to introducing the storage and loading of aforementioned configurations to a file that can be saved/loaded at runtime. This commit also fixes updating of individual fields in Settings when changed from an external activity.
This commit is contained in:
◱ PixelyIon 2020-05-28 19:27:25 +00:00 committed by ◱ PixelyIon
parent 8e1f8ae7e9
commit 75d485a9a7
38 changed files with 2614 additions and 177 deletions

View File

@ -349,6 +349,7 @@
<inspection_tool class="AndroidLintUsableSpace" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="AndroidLintUsableSpace" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AndroidLintUseAlpha2" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="AndroidLintUseAlpha2" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AndroidLintUseCheckPermission" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="AndroidLintUseCheckPermission" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AndroidLintUseCompatLoadingForDrawables" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="AndroidLintUseCompoundDrawables" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="AndroidLintUseCompoundDrawables" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AndroidLintUseOfBundledGooglePlayServices" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="AndroidLintUseOfBundledGooglePlayServices" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="AndroidLintUseRequireInsteadOfGet" enabled="false" level="ERROR" enabled_by_default="false" /> <inspection_tool class="AndroidLintUseRequireInsteadOfGet" enabled="false" level="ERROR" enabled_by_default="false" />
@ -2479,7 +2480,7 @@
<inspection_tool class="UnusedReturnValue" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="UnusedReturnValue" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UnusedSymbol" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="UnusedSymbol" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UnusedUnaryOperator" enabled="false" level="WARNING" enabled_by_default="false" /> <inspection_tool class="UnusedUnaryOperator" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="UnusedValue" enabled="true" level="WEAK WARNING" enabled_by_default="true" /> <inspection_tool class="UnusedValue" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="UpperCaseFieldNameNotConstant" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="UpperCaseFieldNameNotConstant" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UseBulkOperation" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="UseBulkOperation" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="UseCompareMethod" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="UseCompareMethod" enabled="true" level="WARNING" enabled_by_default="true" />

View File

@ -0,0 +1,53 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Controller Configuration" type="AndroidRunConfigurationType" factoryName="Android App" activateToolWindowBeforeRun="false">
<module name="app" />
<option name="DEPLOY" value="true" />
<option name="DEPLOY_APK_FROM_BUNDLE" value="false" />
<option name="DEPLOY_AS_INSTANT" value="false" />
<option name="ARTIFACT_NAME" value="" />
<option name="PM_INSTALL_OPTIONS" value="" />
<option name="DYNAMIC_FEATURES_DISABLED_LIST" value="" />
<option name="ACTIVITY_EXTRA_FLAGS" value="" />
<option name="MODE" value="specific_activity" />
<option name="CLEAR_LOGCAT" value="false" />
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
<option name="SKIP_NOOP_APK_INSTALLATIONS" value="true" />
<option name="FORCE_STOP_RUNNING_APP" value="true" />
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
<option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="-1" />
<option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="" />
<option name="DEBUGGER_TYPE" value="Java" />
<Auto>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Auto>
<Hybrid>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Hybrid>
<Java />
<Native>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Native>
<Profilers>
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Sample Java Methods" />
</Profilers>
<option name="DEEP_LINK" value="" />
<option name="ACTIVITY_CLASS" value="emu.skyline.input.ControllerActivity" />
<method v="2">
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>

53
.idea/runConfigurations/Setting.xml generated Normal file
View File

@ -0,0 +1,53 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Setting" type="AndroidRunConfigurationType" factoryName="Android App" activateToolWindowBeforeRun="false">
<module name="app" />
<option name="DEPLOY" value="true" />
<option name="DEPLOY_APK_FROM_BUNDLE" value="false" />
<option name="DEPLOY_AS_INSTANT" value="false" />
<option name="ARTIFACT_NAME" value="" />
<option name="PM_INSTALL_OPTIONS" value="" />
<option name="DYNAMIC_FEATURES_DISABLED_LIST" value="" />
<option name="ACTIVITY_EXTRA_FLAGS" value="" />
<option name="MODE" value="specific_activity" />
<option name="CLEAR_LOGCAT" value="false" />
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
<option name="SKIP_NOOP_APK_INSTALLATIONS" value="true" />
<option name="FORCE_STOP_RUNNING_APP" value="true" />
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
<option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="-1" />
<option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="" />
<option name="DEBUGGER_TYPE" value="Java" />
<Auto>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Auto>
<Hybrid>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Hybrid>
<Java />
<Native>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Native>
<Profilers>
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Sample Java Methods" />
</Profilers>
<option name="DEEP_LINK" value="" />
<option name="ACTIVITY_CLASS" value="emu.skyline.SettingsActivity" />
<method v="2">
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>

3
.idea/scopes/SkylineXml.xml generated Normal file
View File

@ -0,0 +1,3 @@
<component name="DependencyValidationManager">
<scope name="SkylineXml" pattern="file[app]:src/main/res/layout/*||file[app]:src/main/res/menu/*||file[app]:src/main/res/values/*||file[app]:src/main/res/values-night/*||file[app]:src/main/res/xml/*" />
</component>

View File

@ -2,11 +2,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
package="emu.skyline"> package="emu.skyline">
<uses-permission android:name="android.permission.READ_INTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_INTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-feature <uses-feature
android:glEsVersion="0x00030001" android:glEsVersion="0x00030001"
android:required="true" /> android:required="true" />
<uses-permission android:name="android.permission.VIBRATE" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:extractNativeLibs="true" android:extractNativeLibs="true"
@ -32,6 +35,7 @@
</activity> </activity>
<activity <activity
android:name="emu.skyline.SettingsActivity" android:name="emu.skyline.SettingsActivity"
android:exported="true"
android:label="@string/settings" android:label="@string/settings"
android:parentActivityName="emu.skyline.MainActivity"> android:parentActivityName="emu.skyline.MainActivity">
<meta-data <meta-data
@ -43,19 +47,30 @@
android:name="android.support.PARENT_ACTIVITY" android:name="android.support.PARENT_ACTIVITY"
android:value="emu.skyline.SettingsActivity" /> android:value="emu.skyline.SettingsActivity" />
</activity> </activity>
<activity
android:name="emu.skyline.input.ControllerActivity"
android:exported="true">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="emu.skyline.SettingsActivity" />
</activity>
<activity <activity
android:name="emu.skyline.EmulationActivity" android:name="emu.skyline.EmulationActivity"
android:configChanges="orientation|screenSize" android:configChanges="orientation|screenSize"
android:launchMode="singleInstance" android:launchMode="singleInstance"
android:screenOrientation="landscape" android:screenOrientation="landscape"
android:theme="@style/EmuTheme"
tools:ignore="LockedOrientationActivity"> tools:ignore="LockedOrientationActivity">
<meta-data <meta-data
android:name="android.support.PARENT_ACTIVITY" android:name="android.support.PARENT_ACTIVITY"
android:value="emu.skyline.MainActivity" /> android:value="emu.skyline.MainActivity" />
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data <data
android:mimeType="application/nro" android:mimeType="application/nro"
android:pathPattern=".*\\.nro" android:pathPattern=".*\\.nro"
@ -65,6 +80,7 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data <data
android:mimeType="text/plain" android:mimeType="text/plain"
android:pathPattern=".*\\.nro" android:pathPattern=".*\\.nro"

View File

@ -170,7 +170,7 @@ namespace skyline::service {
} }
session->serviceStatus = type::KSession::ServiceStatus::Closed; session->serviceStatus = type::KSession::ServiceStatus::Closed;
} }
}; }
void ServiceManager::SyncRequestHandler(KHandle handle) { void ServiceManager::SyncRequestHandler(KHandle handle) {
auto session = state.process->GetHandle<type::KSession>(handle); auto session = state.process->GetHandle<type::KSession>(handle);

View File

@ -15,9 +15,9 @@ import android.util.Log
import android.view.* import android.view.*
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import emu.skyline.input.AxisId
import emu.skyline.input.ButtonId
import emu.skyline.input.ButtonState import emu.skyline.input.ButtonState
import emu.skyline.input.NpadAxis
import emu.skyline.input.NpadButton
import emu.skyline.loader.getRomFormat import emu.skyline.loader.getRomFormat
import kotlinx.android.synthetic.main.emu_activity.* import kotlinx.android.synthetic.main.emu_activity.*
import java.io.File import java.io.File
@ -231,26 +231,26 @@ class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback {
else -> return false else -> return false
} }
val buttonMap : Map<Int, NpadButton> = mapOf( val buttonMap : Map<Int, ButtonId> = mapOf(
KeyEvent.KEYCODE_BUTTON_A to NpadButton.A, KeyEvent.KEYCODE_BUTTON_A to ButtonId.A,
KeyEvent.KEYCODE_BUTTON_B to NpadButton.B, KeyEvent.KEYCODE_BUTTON_B to ButtonId.B,
KeyEvent.KEYCODE_BUTTON_X to NpadButton.X, KeyEvent.KEYCODE_BUTTON_X to ButtonId.X,
KeyEvent.KEYCODE_BUTTON_Y to NpadButton.Y, KeyEvent.KEYCODE_BUTTON_Y to ButtonId.Y,
KeyEvent.KEYCODE_BUTTON_THUMBL to NpadButton.LeftStick, KeyEvent.KEYCODE_BUTTON_THUMBL to ButtonId.LeftStick,
KeyEvent.KEYCODE_BUTTON_THUMBR to NpadButton.RightStick, KeyEvent.KEYCODE_BUTTON_THUMBR to ButtonId.RightStick,
KeyEvent.KEYCODE_BUTTON_L1 to NpadButton.L, KeyEvent.KEYCODE_BUTTON_L1 to ButtonId.L,
KeyEvent.KEYCODE_BUTTON_R1 to NpadButton.R, KeyEvent.KEYCODE_BUTTON_R1 to ButtonId.R,
KeyEvent.KEYCODE_BUTTON_L2 to NpadButton.ZL, KeyEvent.KEYCODE_BUTTON_L2 to ButtonId.ZL,
KeyEvent.KEYCODE_BUTTON_R2 to NpadButton.ZR, KeyEvent.KEYCODE_BUTTON_R2 to ButtonId.ZR,
KeyEvent.KEYCODE_BUTTON_START to NpadButton.Plus, KeyEvent.KEYCODE_BUTTON_START to ButtonId.Plus,
KeyEvent.KEYCODE_BUTTON_SELECT to NpadButton.Minus, KeyEvent.KEYCODE_BUTTON_SELECT to ButtonId.Minus,
KeyEvent.KEYCODE_DPAD_DOWN to NpadButton.DpadDown, KeyEvent.KEYCODE_DPAD_DOWN to ButtonId.DpadDown,
KeyEvent.KEYCODE_DPAD_UP to NpadButton.DpadUp, KeyEvent.KEYCODE_DPAD_UP to ButtonId.DpadUp,
KeyEvent.KEYCODE_DPAD_LEFT to NpadButton.DpadLeft, KeyEvent.KEYCODE_DPAD_LEFT to ButtonId.DpadLeft,
KeyEvent.KEYCODE_DPAD_RIGHT to NpadButton.DpadRight) KeyEvent.KEYCODE_DPAD_RIGHT to ButtonId.DpadRight)
return try { return try {
setButtonState(buttonMap.getValue(event.keyCode).value(), action.ordinal); setButtonState(buttonMap.getValue(event.keyCode).value(), action.ordinal)
true true
} catch (ignored : NoSuchElementException) { } catch (ignored : NoSuchElementException) {
super.dispatchKeyEvent(event) super.dispatchKeyEvent(event)
@ -273,13 +273,13 @@ class EmulationActivity : AppCompatActivity(), SurfaceHolder.Callback {
override fun dispatchGenericMotionEvent(event : MotionEvent) : Boolean { override fun dispatchGenericMotionEvent(event : MotionEvent) : Boolean {
if ((event.source and InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD || if ((event.source and InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD ||
(event.source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK) { (event.source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK) {
val hatXMap : Map<Float, NpadButton> = mapOf( val hatXMap : Map<Float, ButtonId> = mapOf(
-1.0f to NpadButton.DpadLeft, -1.0f to ButtonId.DpadLeft,
+1.0f to NpadButton.DpadRight) +1.0f to ButtonId.DpadRight)
val hatYMap : Map<Float, NpadButton> = mapOf( val hatYMap : Map<Float, ButtonId> = mapOf(
-1.0f to NpadButton.DpadUp, -1.0f to ButtonId.DpadUp,
+1.0f to NpadButton.DpadDown) +1.0f to ButtonId.DpadDown)
if (controllerHatX != event.getAxisValue(MotionEvent.AXIS_HAT_X)) { if (controllerHatX != event.getAxisValue(MotionEvent.AXIS_HAT_X)) {
if (event.getAxisValue(MotionEvent.AXIS_HAT_X) == 0.0f) 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) { if ((event.source and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK && event.action == MotionEvent.ACTION_MOVE) {
val axisMap : Map<Int, NpadAxis> = mapOf( val axisMap : Map<Int, AxisId> = mapOf(
MotionEvent.AXIS_X to NpadAxis.LX, MotionEvent.AXIS_X to AxisId.LX,
MotionEvent.AXIS_Y to NpadAxis.LY, MotionEvent.AXIS_Y to AxisId.LY,
MotionEvent.AXIS_Z to NpadAxis.RX, MotionEvent.AXIS_Z to AxisId.RX,
MotionEvent.AXIS_RZ to NpadAxis.RY) MotionEvent.AXIS_RZ to AxisId.RY)
//TODO: Digital inputs based off of analog sticks //TODO: Digital inputs based off of analog sticks
event.device.motionRanges.forEach { event.device.motionRanges.forEach {

View File

@ -9,7 +9,10 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import emu.skyline.input.InputManager
import emu.skyline.preference.ControllerPreference
import kotlinx.android.synthetic.main.titlebar.* import kotlinx.android.synthetic.main.titlebar.*
class SettingsActivity : AppCompatActivity() { class SettingsActivity : AppCompatActivity() {
@ -19,11 +22,23 @@ class SettingsActivity : AppCompatActivity() {
private val preferenceFragment : PreferenceFragment = PreferenceFragment() 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?) { override fun onCreate(savedInstanceState : Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
inputManager = InputManager(this)
setContentView(R.layout.settings_activity) setContentView(R.layout.settings_activity)
setSupportActionBar(toolbar) 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?) { public override fun onActivityResult(requestCode : Int, resultCode : Int, data : Intent?) {
super.onActivityResult(requestCode, resultCode, data) 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() { 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() { fun refreshPreference(key : String) {
preferenceScreen = null val preference = preferenceManager.findPreference<Preference>(key)!!
addPreferencesFromResource(R.xml.preferences) preference.isSelectable = !preference.isSelectable
preference.isSelectable = !preference.isSelectable
} }
/** /**

View File

@ -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) val item = getItem(position)
if (item is AppItem) { if (item is AppItem && holder is ItemViewHolder) {
val holder = viewHolder as ItemViewHolder
holder.title.text = item.title holder.title.text = item.title
holder.subtitle.text = item.subTitle ?: missingString holder.subtitle.text = item.subTitle ?: missingString
@ -122,9 +120,7 @@ internal class AppAdapter(val layoutType : LayoutType, private val gridSpan : In
setOnClickListener { onClick.invoke(item) } setOnClickListener { onClick.invoke(item) }
setOnLongClickListener { true.also { onLongClick.invoke(item) } } setOnLongClickListener { true.also { onLongClick.invoke(item) } }
} }
} else if (item is BaseHeader) { } else if (item is BaseHeader && holder is HeaderViewHolder) {
val holder = viewHolder as HeaderViewHolder
holder.header!!.text = item.title holder.header!!.text = item.title
} }
} }

View File

@ -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<ControllerItem?, BaseHeader, RecyclerView.ViewHolder>() {
/**
* 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
}
}
}

View File

@ -17,13 +17,12 @@ import java.io.*
import java.util.* import java.util.*
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
/** /**
* An enumeration of the type of elements in this adapter * An enumeration of the type of elements in this adapter
*/ */
enum class ElementType(val type : Int) { enum class ElementType {
Header(0x0), Header,
Item(0x1) Item,
} }
/** /**
@ -132,7 +131,7 @@ abstract class HeaderAdapter<ItemType : BaseItem?, HeaderType : BaseHeader?, Vie
* @param position The position of the element * @param position The position of the element
*/ */
override fun getItemViewType(position : Int) : Int { override fun getItemViewType(position : Int) : Int {
return elementArray[visibleArray[position]]!!.elementType.type return elementArray[visibleArray[position]]!!.elementType.ordinal
} }
/** /**
@ -194,7 +193,7 @@ abstract class HeaderAdapter<ItemType : BaseItem?, HeaderType : BaseHeader?, Vie
for (index in elementArray.indices) { for (index in elementArray.indices) {
val item = elementArray[index]!! val item = elementArray[index]!!
if (item is BaseItem) { if (item is BaseItem && item.key() != null) {
keyIndex.append(keyArray.size, index) keyIndex.append(keyArray.size, index)
keyArray.add(item.key()!!.toLowerCase(Locale.getDefault())) keyArray.add(item.key()!!.toLowerCase(Locale.getDefault()))
} }

View File

@ -100,14 +100,12 @@ internal class LogAdapter internal constructor(val context : Context, val compac
} }
/** /**
* 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) val item = getItem(position)
if (item is LogItem) { if (item is LogItem && holder is ItemViewHolder) {
val holder = viewHolder as ItemViewHolder
holder.title.text = item.message holder.title.text = item.message
holder.subtitle?.text = item.level holder.subtitle?.text = item.level
@ -115,9 +113,7 @@ internal class LogAdapter internal constructor(val context : Context, val compac
clipboard.setPrimaryClip(ClipData.newPlainText("Log Message", item.message + " (" + item.level + ")")) clipboard.setPrimaryClip(ClipData.newPlainText("Log Message", item.message + " (" + item.level + ")"))
Toast.makeText(holder.itemView.context, "Copied to clipboard", Toast.LENGTH_LONG).show() Toast.makeText(holder.itemView.context, "Copied to clipboard", Toast.LENGTH_LONG).show()
} }
} else if (item is BaseHeader) { } else if (item is BaseHeader && holder is HeaderViewHolder) {
val holder = viewHolder as HeaderViewHolder
holder.header.text = item.title holder.header.text = item.title
} }
} }

View File

@ -15,5 +15,5 @@ abstract class BaseItem : BaseElement(ElementType.Item) {
/** /**
* This function returns a string used for searching * This function returns a string used for searching
*/ */
abstract fun key() : String? open fun key() : String? = null
} }

View File

@ -1,21 +0,0 @@
/*
* SPDX-License-Identifier: MPL-2.0
* Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
*/
package emu.skyline.input
/**
* This is a generic interface for all Button classes to implement
*/
interface ButtonId {
/**
* This should return the value of the Button according to what libskyline expects
*/
fun value() : Long
}
enum class ButtonState(val state : Boolean) {
Released(false),
Pressed(true),
}

View File

@ -0,0 +1,72 @@
/*
* SPDX-License-Identifier: MPL-2.0
* Copyright © 2020 Skyline Team and Contributors (https://github.com/skyline-emu/)
*/
package emu.skyline.input
import emu.skyline.R
import java.io.Serializable
/**
* This enumerates the types of Controller that can be emulated
*
* @param stringRes The string resource of the controller's name
* @param firstController If the type only applies to the first controller
*/
enum class ControllerType(val stringRes : Int, val firstController : Boolean, val sticks : Array<StickId> = arrayOf(), val buttons : Array<ButtonId> = 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<ControllerType>? = 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<String, String>? = 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)

View File

@ -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<ButtonId, ControllerItem>()
/**
* This is a map between an axis and it's corresponding [ControllerStickItem] in [adapter]
*/
val axisMap = mutableMapOf<AxisId, ControllerStickItem>()
/**
* 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<View>(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)
}
}

View File

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

View File

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

View File

@ -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<Int, Controller>
/**
* A [HashMap] between all [HostEvent]s and their corresponding [GuestEvent]s
*/
lateinit var eventMap : HashMap<HostEvent?, GuestEvent?>
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<Int, Controller>
@Suppress("UNCHECKED_CAST")
eventMap = objectInput.readObject() as HashMap<HostEvent?, GuestEvent?>
}
/**
* 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()
}
}

View File

@ -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,
}

View File

@ -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<Float>(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()
}
}
}

View File

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

View File

@ -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<Int>() // 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<Float>(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()
}
}
}

View File

@ -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<ControllerPreference> { _ -> 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)
}
}

View File

@ -11,7 +11,9 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.util.AttributeSet import android.util.AttributeSet
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.Preference.SummaryProvider
import androidx.preference.R import androidx.preference.R
import emu.skyline.SettingsActivity
/** /**
* This preference shows the decoded URI of it's preference and launches [FolderActivity] * 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 private var mDirectory : String? = null
constructor(context : Context?, attrs : AttributeSet?, defStyleAttr : Int) : super(context, attrs, defStyleAttr) { constructor(context : Context?, attrs : AttributeSet?, defStyleAttr : Int) : super(context, attrs, defStyleAttr) {
summaryProvider = SimpleSummaryProvider() summaryProvider = SummaryProvider<FolderPreference> { preference ->
preference.onSetInitialValue(null)
Uri.decode(preference.mDirectory) ?: ""
}
} }
constructor(context : Context?, attrs : AttributeSet?) : this(context, attrs, R.attr.preferenceStyle) 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 * This launches [FolderActivity] on click to change the directory
*/ */
override fun onClick() { override fun onClick() {
if (context is SettingsActivity)
(context as SettingsActivity).refreshKey = key
val intent = Intent(context, FolderActivity::class.java) val intent = Intent(context, FolderActivity::class.java)
(context as Activity).startActivityForResult(intent, 0) (context as Activity).startActivityForResult(intent, 0)
} }
@ -44,16 +52,4 @@ class FolderPreference : Preference {
override fun onSetInitialValue(defaultValue : Any?) { override fun onSetInitialValue(defaultValue : Any?) {
mDirectory = getPersistedString(defaultValue as String?) mDirectory = getPersistedString(defaultValue as String?)
} }
/**
* This [Preference.SummaryProvider] is used to set the summary for URI values
*/
private class SimpleSummaryProvider : SummaryProvider<FolderPreference> {
/**
* This returns the decoded URI of the directory as the summary
*/
override fun provideSummary(preference : FolderPreference) : CharSequence {
return Uri.decode(preference.mDirectory) ?: ""
}
}
} }

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.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M15,7.5V2H9v5.5l3,3 3,-3zM7.5,9H2v6h5.5l3,-3 -3,-3zM9,16.5V22h6v-5.5l-3,-3 -3,3zM16.5,9l-3,3 3,3H22V9h-5.5z" />
</vector>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#A0000000" />
<stroke
android:width="2.5dp"
android:color="@android:color/black" />
</shape>

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.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M15,7.5V2H9v5.5l3,3 3,-3zM7.5,9H2v6h5.5l3,-3 -3,-3zM9,16.5V22h6v-5.5l-3,-3 -3,3zM16.5,9l-3,3 3,3H22V9h-5.5z" />
</vector>

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<solid android:color="#A0000000" />
<stroke
android:width="2dp"
android:color="@android:color/black" />
<size
android:width="25dp"
android:height="25dp" />
<padding
android:bottom="10dp"
android:left="10dp"
android:right="10dp"
android:top="10dp" />
</shape>
</item>
<item>
<shape android:shape="oval">
<!--
<stroke
android:width="2dp"
android:color="#B0FFFFFF" />
-->
<solid android:color="#50000000" />
<size
android:width="30dp"
android:height="30dp" />
</shape>
</item>
</layer-list>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#A0000000" />
<stroke
android:width="2.5dp"
android:color="@android:color/black" />
</shape>

View File

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/button_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:animateLayoutChanges="true"
android:defaultFocusHighlightEnabled="false"
android:focusable="true"
android:focusableInTouchMode="true"
android:focusedByDefault="true"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/button_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:animateLayoutChanges="true"
android:text="@string/use_button_axis"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.AppCompat.Display1"
android:textSize="16sp" />
<RelativeLayout
android:id="@+id/button_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:animateLayoutChanges="true"
android:gravity="center">
<ImageView
android:id="@+id/button_icon"
android:layout_width="50dp"
android:layout_height="50dp"
android:alpha="0.15"
android:contentDescription="@string/buttons"
android:outlineProvider="bounds"
android:src="@drawable/ic_button" />
<TextView
android:id="@+id/button_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignStart="@id/button_icon"
android:layout_alignTop="@id/button_icon"
android:layout_alignEnd="@id/button_icon"
android:layout_alignBottom="@id/button_icon"
android:alpha="0.25"
android:fontFamily="monospace"
android:gravity="center"
android:includeFontPadding="false"
android:textSize="27sp" />
</RelativeLayout>
<SeekBar
android:id="@+id/button_seekbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:max="100"
android:progress="25"
android:secondaryProgressTint="@color/colorPrimaryDark"
android:secondaryProgressTintMode="screen"
android:visibility="gone" />
<Button
android:id="@+id/button_reset"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginEnd="10dp"
android:text="@string/reset" />
</LinearLayout>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<include layout="@layout/titlebar" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/controller_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/controller_item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:orientation="vertical"
android:padding="15dp">
<TextView
android:id="@+id/text_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_alignParentTop="false"
android:textAppearance="?android:attr/textAppearanceListItem"
tools:ignore="RelativeOverlap" />
<TextView
android:id="@+id/text_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/text_title"
android:layout_alignStart="@id/text_title"
android:textAppearance="?android:attr/textAppearanceListItemSecondary"
android:textColor="@android:color/tertiary_text_light" />
</RelativeLayout>

View File

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/rumble_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:animateLayoutChanges="true"
android:defaultFocusHighlightEnabled="false"
android:focusable="true"
android:focusableInTouchMode="true"
android:focusedByDefault="true"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/rumble_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:animateLayoutChanges="true"
android:text="@string/press_any_button"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.AppCompat.Display1"
android:textSize="16sp" />
<RelativeLayout
android:id="@+id/rumble_controller"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:animateLayoutChanges="true"
android:gravity="center_horizontal">
<ImageView
android:id="@+id/rumble_controller_icon"
android:layout_width="50dp"
android:layout_height="50dp"
android:alpha="0.25"
android:contentDescription="@string/controller"
android:src="@drawable/ic_controller" />
<TextView
android:id="@+id/rumble_controller_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignTop="@id/rumble_controller_icon"
android:layout_marginStart="10dp"
android:layout_marginTop="4dp"
android:layout_toEndOf="@id/rumble_controller_icon"
android:textAppearance="?android:attr/textAppearanceListItem"
android:textSize="15sp" />
<TextView
android:id="@+id/rumble_controller_supported"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/rumble_controller_name"
android:layout_alignStart="@id/rumble_controller_name"
android:textAppearance="?android:attr/textAppearanceListItemSecondary"
android:textColor="@android:color/tertiary_text_light"
android:textSize="13sp" />
</RelativeLayout>
<Button
android:id="@+id/rumble_reset"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginEnd="10dp"
android:text="@string/reset" />
</LinearLayout>

View File

@ -0,0 +1,125 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/stick_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:animateLayoutChanges="true"
android:defaultFocusHighlightEnabled="false"
android:focusable="true"
android:focusableInTouchMode="true"
android:focusedByDefault="true"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/stick_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layout_marginBottom="10dp"
android:animateLayoutChanges="true"
android:text="@string/stick_button"
android:textAppearance="@style/TextAppearance.AppCompat.Display1"
android:textColor="@color/colorPrimary"
android:textSize="16sp" />
<TextView
android:id="@+id/stick_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:animateLayoutChanges="true"
android:text="@string/use_button_axis"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.AppCompat.Display1"
android:textSize="16sp" />
<RelativeLayout
android:id="@+id/stick_circle_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginBottom="10dp"
android:gravity="center">
<ImageView
android:id="@+id/stick_circle_icon"
android:layout_width="80dp"
android:layout_height="80dp"
android:alpha="0.4"
android:contentDescription="@string/buttons"
android:outlineProvider="bounds"
android:src="@drawable/ic_stick_circle" />
<RelativeLayout
android:id="@+id/stick_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignStart="@id/stick_circle_icon"
android:layout_alignTop="@id/stick_circle_icon"
android:layout_alignEnd="@id/stick_circle_icon"
android:layout_alignBottom="@id/stick_circle_icon">
<ImageView
android:id="@+id/stick_icon"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_margin="15dp"
android:alpha="0.4"
android:contentDescription="@string/buttons"
android:outlineProvider="bounds"
android:src="@drawable/ic_stick" />
<TextView
android:id="@+id/stick_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignStart="@id/stick_icon"
android:layout_alignTop="@id/stick_icon"
android:layout_alignEnd="@id/stick_icon"
android:layout_alignBottom="@id/stick_icon"
android:alpha="0.55"
android:fontFamily="monospace"
android:gravity="center"
android:includeFontPadding="false"
android:textColor="@android:color/white"
android:textSize="27sp" />
</RelativeLayout>
</RelativeLayout>
<SeekBar
android:id="@+id/stick_seekbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:max="100"
android:progress="25"
android:secondaryProgressTint="@color/colorPrimaryDark"
android:secondaryProgressTintMode="screen"
android:visibility="gone" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end">
<Button
android:id="@+id/stick_reset"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:text="@string/reset" />
<Button
android:id="@+id/stick_next"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/next" />
</LinearLayout>
</LinearLayout>

View File

@ -1,4 +1,4 @@
<resources> <resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name">Skyline</string> <string name="app_name">Skyline</string>
<!-- Common --> <!-- Common -->
<string name="search">Search</string> <string name="search">Search</string>
@ -24,10 +24,14 @@
<!-- Settings --> <!-- Settings -->
<string name="emulator">Emulator</string> <string name="emulator">Emulator</string>
<string name="search_location">Search Location</string> <string name="search_location">Search Location</string>
<string name="theme">Theme</string>
<string name="layout_type">Game Display Layout</string> <string name="layout_type">Game Display Layout</string>
<string name="select_action">Always Show Game Information</string> <string name="select_action">Always Show Game Information</string>
<string name="select_action_desc_on">Game information will be shown on clicking a game</string> <string name="select_action_desc_on">Game information will be shown on clicking a game</string>
<string name="select_action_desc_off">Game information will only be shown on long-clicking a game</string> <string name="select_action_desc_off">Game information will only be shown on long-clicking a game</string>
<string name="perf_stats">Show Performance Statistics</string>
<string name="perf_stats_desc_off">Performance Statistics will not be shown</string>
<string name="perf_stats_desc_on">Performance Statistics will be shown in the top-left corner</string>
<string name="log_level">Log Level</string> <string name="log_level">Log Level</string>
<string name="log_compact">Compact Logs</string> <string name="log_compact">Compact Logs</string>
<string name="log_compact_desc_on">Logs will be displayed in a compact form factor</string> <string name="log_compact_desc_on">Logs will be displayed in a compact form factor</string>
@ -36,9 +40,64 @@
<string name="use_docked">Use Docked Mode</string> <string name="use_docked">Use Docked Mode</string>
<string name="handheld_enabled">The system will emulate being in handheld mode</string> <string name="handheld_enabled">The system will emulate being in handheld mode</string>
<string name="docked_enabled">The system will emulate being in docked mode</string> <string name="docked_enabled">The system will emulate being in docked mode</string>
<string name="theme">Theme</string>
<string name="username">Username</string> <string name="username">Username</string>
<string name="username_default">@string/app_name</string> <string name="username_default">@string/app_name</string>
<!-- Input -->
<string name="input">Input</string>
<string name="show_osc">Show On-Screen Controls</string>
<string name="osc_not_shown">On-Screen Controls won\'t be shown</string>
<string name="osc_shown">On-Screen Controls will be shown</string>
<string name="controller">Controller</string>
<string name="config_controller">Configure Controller</string>
<string name="controller_type">Controller Type</string>
<string name="none">None</string>
<string name="handheld_procon">Handheld + Pro Controller</string>
<string name="procon">Pro Controller</string>
<string name="ljoycon">Left JoyCon</string>
<string name="rjoycon">Right JoyCon</string>
<string name="general">General</string>
<string name="partner_joycon">Partner Joy-Con</string>
<string name="rumble_device">Rumble Device</string>
<string name="supported">Supported</string>
<string name="not_supported">Not Supported</string>
<string name="press_any_button">Press any button on a controller</string>
<string name="confirm_button_again">Confirm choice by pressing a button again</string>
<string name="reset">Reset</string>
<string name="buttons">Buttons</string>
<string name="use_button_axis">Use any button or axis on a controller</string>
<string name="release_confirm">Release to confirm selection</string>
<string name="hold_confirm">Hold to confirm selection</string>
<string name="sticks">Sticks</string>
<string name="stick_button">Stick Button</string>
<string name="next">Next</string>
<string name="x_plus">Stick X+ Axis (Right)</string>
<string name="y_plus">Stick Y+ Axis (Up)</string>
<string name="x_minus">Stick X- Axis (Left)</string>
<string name="y_minus">Stick Y- Axis (Down)</string>
<string name="a_button">A</string>
<string name="b_button">B</string>
<string name="x_button">X</string>
<string name="y_button">Y</string>
<string name="left_shoulder">Left Shoulder</string>
<string name="right_shoulder">Right Shoulder</string>
<string name="left_trigger">Left Trigger</string>
<string name="right_trigger">Right Trigger</string>
<string name="plus_button">Plus</string>
<string name="minus_button">Minus</string>
<string name="emu_menu_button">Emulator Menu</string>
<string name="stick_preview">Stick Preview</string>
<string name="done">Done</string>
<string name="use_non_stick">Use any unmapped button to finish</string>
<string name="button">Button</string>
<string name="up">Up</string>
<string name="down">Down</string>
<string name="left">Left</string>
<string name="right">Right</string>
<string name="dpad">D-pad</string>
<string name="face_buttons">Face Buttons</string>
<string name="shoulder_trigger"><![CDATA[Shoulder & Trigger Buttons]]></string>
<string name="shoulder_rail">Shoulder Buttons on Joy-Con Rail</string>
<string name="misc_buttons">Miscellaneous Buttons</string>
<!-- Licenses --> <!-- Licenses -->
<string name="licenses">Licenses</string> <string name="licenses">Licenses</string>
<string name="skyline_license_description">The license of Skyline (MPL 2.0)</string> <string name="skyline_license_description">The license of Skyline (MPL 2.0)</string>
@ -66,7 +125,7 @@
<string name="roboto_description">We use Roboto as our FOSS shared font replacement for Nintendo\'s extended character set (Apache License 2.0)</string> <string name="roboto_description">We use Roboto as our FOSS shared font replacement for Nintendo\'s extended character set (Apache License 2.0)</string>
<string name="source_sans_pro">Source Sans Pro</string> <string name="source_sans_pro">Source Sans Pro</string>
<string name="source_sans_pro_description">We use Source Sans Pro as our FOSS shared font replacement for Extended Chinese (SIL Open Font License 1.1)</string> <string name="source_sans_pro_description">We use Source Sans Pro as our FOSS shared font replacement for Extended Chinese (SIL Open Font License 1.1)</string>
<string name="perf_stats">Show Performance Statistics</string> <!-- Misc -->
<string name="perf_stats_desc_off">Performance Statistics will not be shown</string> <!--suppress AndroidLintUnusedResources -->
<string name="perf_stats_desc_on">Performance Statistics will be shown in the top-left corner</string> <string name="expand_button_title" tools:override="true">Expand</string>
</resources> </resources>

View File

@ -9,6 +9,11 @@
<item name="colorOnSecondary">@color/colorOnSecondary</item> <item name="colorOnSecondary">@color/colorOnSecondary</item>
</style> </style>
<style name="EmuTheme" parent="AppTheme">
<item name="android:windowTranslucentStatus">true</item>
<item name="android:windowTranslucentNavigation">true</item>
</style>
<style name="AppTheme.ActionBar" parent=""> <style name="AppTheme.ActionBar" parent="">
<item name="android:textColorPrimary">@color/colorOnPrimary</item> <item name="android:textColorPrimary">@color/colorOnPrimary</item>
<item name="android:textColorSecondary">@color/colorOnPrimary</item> <item name="android:textColorSecondary">@color/colorOnPrimary</item>

View File

@ -1,19 +1,3 @@
<!--
~ Copyright 2018 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" <androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory <PreferenceCategory
@ -21,8 +5,7 @@
android:title="@string/emulator"> android:title="@string/emulator">
<emu.skyline.preference.FolderPreference <emu.skyline.preference.FolderPreference
app:key="search_location" app:key="search_location"
app:title="@string/search_location" app:title="@string/search_location" />
app:useSimpleSummaryProvider="true" />
<emu.skyline.preference.ThemePreference <emu.skyline.preference.ThemePreference
android:defaultValue="2" android:defaultValue="2"
android:entries="@array/app_theme" android:entries="@array/app_theme"
@ -78,6 +61,25 @@
app:key="operation_mode" app:key="operation_mode"
app:title="@string/use_docked" /> app:title="@string/use_docked" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory
android:key="category_input"
android:title="@string/input"
app:initialExpandedChildrenCount="4">
<CheckBoxPreference
android:defaultValue="true"
android:summaryOff="@string/osc_not_shown"
android:summaryOn="@string/osc_shown"
app:key="show_osc"
app:title="@string/show_osc" />
<emu.skyline.preference.ControllerPreference index="0" />
<emu.skyline.preference.ControllerPreference index="1" />
<emu.skyline.preference.ControllerPreference index="2" />
<emu.skyline.preference.ControllerPreference index="3" />
<emu.skyline.preference.ControllerPreference index="4" />
<emu.skyline.preference.ControllerPreference index="5" />
<emu.skyline.preference.ControllerPreference index="6" />
<emu.skyline.preference.ControllerPreference index="7" />
</PreferenceCategory>
<PreferenceCategory <PreferenceCategory
android:key="category_licenses" android:key="category_licenses"
android:title="@string/licenses" android:title="@string/licenses"