Renamed Android module
This commit is contained in:
parent
8dbf42bb1c
commit
13e79aa43f
|
@ -0,0 +1,24 @@
|
|||
# SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
# To get usable stack traces
|
||||
-dontobfuscate
|
||||
|
||||
# Prevents crashing when using Wini
|
||||
-keep class org.ini4j.spi.IniParser
|
||||
-keep class org.ini4j.spi.IniBuilder
|
||||
-keep class org.ini4j.spi.IniFormatter
|
||||
|
||||
# Suppress warnings for R8
|
||||
-dontwarn org.bouncycastle.jsse.BCSSLParameters
|
||||
-dontwarn org.bouncycastle.jsse.BCSSLSocket
|
||||
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
|
||||
-dontwarn org.conscrypt.Conscrypt$Version
|
||||
-dontwarn org.conscrypt.Conscrypt
|
||||
-dontwarn org.conscrypt.ConscryptHostnameVerifier
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
|
||||
-dontwarn org.openjsse.net.ssl.OpenJSSE
|
||||
-dontwarn java.beans.Introspector
|
||||
-dontwarn java.beans.VetoableChangeListener
|
||||
-dontwarn java.beans.VetoableChangeSupport
|
|
@ -0,0 +1,22 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="200dp"
|
||||
android:height="200dp"
|
||||
android:viewportWidth="500"
|
||||
android:viewportHeight="500">
|
||||
<path
|
||||
android:fillColor="#C6C6C6"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M262.66,175.11L262.66,375.05C318.54,375.05 363.85,330.29 363.85,275.08C363.85,219.87 318.54,175.11 262.66,175.11M282.43,197.01C318.67,206 344.09,238.19 344.09,275.11C344.09,312.03 318.67,344.22 282.43,353.2L282.43,197.01"
|
||||
android:strokeWidth="1.46"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeLineCap="butt"
|
||||
android:strokeLineJoin="miter" />
|
||||
<path
|
||||
android:fillColor="#FFDC00"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M237.31,125.11C181.43,125.11 136.12,169.87 136.12,225.08C136.12,280.29 181.43,325.05 237.31,325.05ZM217.57,147.01L217.57,303.2C189.11,296.16 166.67,274.54 158.84,246.6C151.01,218.65 159,188.71 179.75,168.21C190.16,157.86 203.24,150.53 217.57,147.01"
|
||||
android:strokeWidth="1.46"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeLineCap="butt"
|
||||
android:strokeLineJoin="miter" />
|
||||
</vector>
|
|
@ -0,0 +1,12 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="155.3dp"
|
||||
android:height="172.55dp"
|
||||
android:viewportWidth="155.3"
|
||||
android:viewportHeight="172.55">
|
||||
<path
|
||||
android:fillColor="#C6C6C6"
|
||||
android:pathData="M86.28,34.51v138a69,69 0,0 0,0 -138M99.76,49.63a55.57,55.57 0,0 1,0 107.8V49.63" />
|
||||
<path
|
||||
android:fillColor="#FFDC00"
|
||||
android:pathData="M69,0a69,69 0,0 0,0 138ZM55.54,15.12v107.8A55.55,55.55 0,0 1,29.75 29.75,55.1 55.1,0 0,1 55.54,15.12" />
|
||||
</vector>
|
|
@ -0,0 +1,24 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="340.97dp"
|
||||
android:height="389.85dp"
|
||||
android:viewportWidth="340.97"
|
||||
android:viewportHeight="389.85">
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurface"
|
||||
android:pathData="M341,268.68v73c0,14.5 -2.24,25.24 -6.83,32.82 -5.92,10.15 -16.21,15.32 -30.54,15.32S279,384.61 273,374.27c-4.56,-7.64 -6.8,-18.42 -6.8,-32.92V268.68a4.52,4.52 0,0 1,4.51 -4.51H273a4.5,4.5 0,0 1,4.5 4.51v72.5c0,33.53 14.88,37.4 26.07,37.4 12.14,0 26.08,-4.17 26.08,-36.71V268.68a4.52,4.52 0,0 1,4.52 -4.51h2.27A4.5,4.5 0,0 1,341 268.68Z" />
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurface"
|
||||
android:pathData="M246.49,389.85H178.6c-2.35,0 -4.72,-1.88 -4.72,-6.08a8.28,8.28 0,0 1,1.33 -4.48l60.33,-104.47H186a4.51,4.51 0,0 1,-4.51 -4.51v-1.58a4.51,4.51 0,0 1,4.48 -4.51h0.8c58.69,-0.11 59.12,0 59.67,0.07a5.19,5.19 0,0 1,4 5.8,8.69 8.69,0 0,1 -1.33,3.76l-60.6,104.77h58a4.51,4.51 0,0 1,4.51 4.51v2.21A4.51,4.51 0,0 1,246.49 389.85Z" />
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurface"
|
||||
android:pathData="M73.6,268.68v82.06c0,26 -11.8,38.44 -37.12,39.09h-0.12a4.51,4.51 0,0 1,-4.51 -4.51V383a4.51,4.51 0,0 1,4.48 -4.5c18.49,-0.15 26,-8.23 26,-27.9v-2.37A32.34,32.34 0,0 1,59 351.46c-6.39,5.5 -14.5,8.29 -24.07,8.29C12.09,359.75 0,347.34 0,323.86V268.68a4.52,4.52 0,0 1,4.51 -4.51H6.73a4.52,4.52 0,0 1,4.5 4.51v55c0,7.6 1.82,14.22 5,18.18 3.57,4.56 9.17,6.49 18.75,6.49 10.13,0 17.32,-3.76 22,-11.5 3.61,-5.92 5.43,-13.66 5.43,-23V268.68a4.52,4.52 0,0 1,4.51 -4.51h2.22A4.52,4.52 0,0 1,73.6 268.68Z" />
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurface"
|
||||
android:pathData="M163.27,268.68v73c0,14.5 -2.24,25.24 -6.84,32.82 -5.92,10.15 -16.2,15.32 -30.53,15.32s-24.62,-5.23 -30.58,-15.57c-4.56,-7.64 -6.79,-18.42 -6.79,-32.92V268.68A4.51,4.51 0,0 1,93 264.17h2.28a4.51,4.51 0,0 1,4.51 4.51v72.5c0,33.53 14.88,37.4 26.07,37.4 12.14,0 26.08,-4.17 26.08,-36.71V268.68a4.51,4.51 0,0 1,4.51 -4.51h2.27A4.51,4.51 0,0 1,163.27 268.68Z" />
|
||||
<path
|
||||
android:fillColor="#C6C6C6"
|
||||
android:pathData="M181.2,42.83V214.17a85.67,85.67 0,0 0,0 -171.34M197.93,61.6a69,69 0,0 1,0 133.8V61.6" />
|
||||
<path
|
||||
android:fillColor="#FFDC00"
|
||||
android:pathData="M159.78,0a85.67,85.67 0,1 0,0 171.33ZM143.05,18.77v133.8A69,69 0,0 1,111 36.92a68.47,68.47 0,0 1,32 -18.15" />
|
||||
</vector>
|
|
@ -0,0 +1,95 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<application
|
||||
android:name="org.sudachi.sudachi_emu.SudachiApplication"
|
||||
android:label="@string/app_name_suffixed"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:allowBackup="true"
|
||||
android:hasFragileUserData="false"
|
||||
android:supportsRtl="true"
|
||||
android:isGame="true"
|
||||
android:appCategory="game"
|
||||
android:banner="@drawable/tv_banner"
|
||||
android:fullBackupContent="@xml/data_extraction_rules"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules_api_31"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
|
||||
<meta-data android:name="android.game_mode_config"
|
||||
android:resource="@xml/game_mode_config" />
|
||||
|
||||
<activity
|
||||
android:name="org.sudachi.sudachi_emu.ui.main.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Sudachi.Splash.Main">
|
||||
|
||||
<!-- This intentfilter marks this Activity as the one that gets launched from Home screen. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name="org.sudachi.sudachi_emu.features.settings.ui.SettingsActivity"
|
||||
android:theme="@style/Theme.Sudachi.Main"
|
||||
android:label="@string/preferences_settings"/>
|
||||
|
||||
<activity
|
||||
android:name="org.sudachi.sudachi_emu.activities.EmulationActivity"
|
||||
android:theme="@style/Theme.Sudachi.Main"
|
||||
android:launchMode="singleTop"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.nfc.action.TECH_DISCOVERED" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="application/octet-stream" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data
|
||||
android:mimeType="application/octet-stream"
|
||||
android:scheme="content"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.nfc.action.TECH_DISCOVERED"
|
||||
android:resource="@xml/nfc_tech_filter" />
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name=".features.DocumentProvider"
|
||||
android:authorities="${applicationId}.user"
|
||||
android:grantUriPermissions="true"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
|
@ -0,0 +1,55 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import org.sudachi.sudachi_emu.features.input.NativeInput
|
||||
import java.io.File
|
||||
import org.sudachi.sudachi_emu.utils.DirectoryInitialization
|
||||
import org.sudachi.sudachi_emu.utils.DocumentsTree
|
||||
import org.sudachi.sudachi_emu.utils.GpuDriverHelper
|
||||
import org.sudachi.sudachi_emu.utils.Log
|
||||
|
||||
fun Context.getPublicFilesDir(): File = getExternalFilesDir(null) ?: filesDir
|
||||
|
||||
class SudachiApplication : Application() {
|
||||
private fun createNotificationChannels() {
|
||||
val noticeChannel = NotificationChannel(
|
||||
getString(R.string.notice_notification_channel_id),
|
||||
getString(R.string.notice_notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
)
|
||||
noticeChannel.description = getString(R.string.notice_notification_channel_description)
|
||||
noticeChannel.setSound(null, null)
|
||||
|
||||
// Register the channel with the system; you can't change the importance
|
||||
// or other notification behaviors after this
|
||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||
notificationManager.createNotificationChannel(noticeChannel)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
application = this
|
||||
documentsTree = DocumentsTree()
|
||||
DirectoryInitialization.start()
|
||||
GpuDriverHelper.initializeDriverParameters()
|
||||
NativeInput.reloadInputDevices()
|
||||
NativeLibrary.logDeviceInfo()
|
||||
Log.logDeviceInfo()
|
||||
|
||||
createNotificationChannels()
|
||||
}
|
||||
|
||||
companion object {
|
||||
var documentsTree: DocumentsTree? = null
|
||||
lateinit var application: SudachiApplication
|
||||
|
||||
val appContext: Context
|
||||
get() = application.applicationContext
|
||||
}
|
||||
}
|
|
@ -0,0 +1,509 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.activities
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Icon
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Rational
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.Surface
|
||||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.preference.PreferenceManager
|
||||
import org.sudachi.sudachi_emu.NativeLibrary
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.SudachiApplication
|
||||
import org.sudachi.sudachi_emu.databinding.ActivityEmulationBinding
|
||||
import org.sudachi.sudachi_emu.features.input.NativeInput
|
||||
import org.sudachi.sudachi_emu.features.settings.model.BooleanSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.model.IntSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.model.Settings
|
||||
import org.sudachi.sudachi_emu.model.EmulationViewModel
|
||||
import org.sudachi.sudachi_emu.model.Game
|
||||
import org.sudachi.sudachi_emu.utils.InputHandler
|
||||
import org.sudachi.sudachi_emu.utils.Log
|
||||
import org.sudachi.sudachi_emu.utils.MemoryUtil
|
||||
import org.sudachi.sudachi_emu.utils.NativeConfig
|
||||
import org.sudachi.sudachi_emu.utils.NfcReader
|
||||
import org.sudachi.sudachi_emu.utils.ParamPackage
|
||||
import org.sudachi.sudachi_emu.utils.ThemeHelper
|
||||
import java.text.NumberFormat
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class EmulationActivity : AppCompatActivity(), SensorEventListener {
|
||||
private lateinit var binding: ActivityEmulationBinding
|
||||
|
||||
var isActivityRecreated = false
|
||||
private lateinit var nfcReader: NfcReader
|
||||
|
||||
private val gyro = FloatArray(3)
|
||||
private val accel = FloatArray(3)
|
||||
private var motionTimestamp: Long = 0
|
||||
private var flipMotionOrientation: Boolean = false
|
||||
|
||||
private val actionPause = "ACTION_EMULATOR_PAUSE"
|
||||
private val actionPlay = "ACTION_EMULATOR_PLAY"
|
||||
private val actionMute = "ACTION_EMULATOR_MUTE"
|
||||
private val actionUnmute = "ACTION_EMULATOR_UNMUTE"
|
||||
|
||||
private val emulationViewModel: EmulationViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Log.gameLaunched = true
|
||||
ThemeHelper.setTheme(this)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
InputHandler.updateControllerData()
|
||||
val players = NativeConfig.getInputSettings(true)
|
||||
var hasConfiguredControllers = false
|
||||
players.forEach {
|
||||
if (it.hasMapping()) {
|
||||
hasConfiguredControllers = true
|
||||
}
|
||||
}
|
||||
if (!hasConfiguredControllers && InputHandler.androidControllers.isNotEmpty()) {
|
||||
var params: ParamPackage? = null
|
||||
for (controller in InputHandler.registeredControllers) {
|
||||
if (controller.get("port", -1) == 0) {
|
||||
params = controller
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (params != null) {
|
||||
NativeInput.updateMappingsWithDefault(
|
||||
0,
|
||||
params,
|
||||
params.get("display", getString(R.string.unknown))
|
||||
)
|
||||
NativeConfig.saveGlobalConfig()
|
||||
}
|
||||
}
|
||||
|
||||
binding = ActivityEmulationBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
val navHostFragment =
|
||||
supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment
|
||||
navHostFragment.navController.setGraph(R.navigation.emulation_navigation, intent.extras)
|
||||
|
||||
isActivityRecreated = savedInstanceState != null
|
||||
|
||||
// Set these options now so that the SurfaceView the game renders into is the right size.
|
||||
enableFullscreenImmersive()
|
||||
|
||||
window.decorView.setBackgroundColor(getColor(android.R.color.black))
|
||||
|
||||
nfcReader = NfcReader(this)
|
||||
nfcReader.initialize()
|
||||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(SudachiApplication.appContext)
|
||||
if (!preferences.getBoolean(Settings.PREF_MEMORY_WARNING_SHOWN, false)) {
|
||||
if (MemoryUtil.isLessThan(MemoryUtil.REQUIRED_MEMORY, MemoryUtil.totalMemory)) {
|
||||
Toast.makeText(
|
||||
this,
|
||||
getString(
|
||||
R.string.device_memory_inadequate,
|
||||
MemoryUtil.getDeviceRAM(),
|
||||
getString(
|
||||
R.string.memory_formatted,
|
||||
NumberFormat.getInstance().format(MemoryUtil.REQUIRED_MEMORY),
|
||||
getString(R.string.memory_gigabyte)
|
||||
)
|
||||
),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
preferences.edit()
|
||||
.putBoolean(Settings.PREF_MEMORY_WARNING_SHOWN, true)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
if (keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||
// Special case, we do not support multiline input, dismiss the keyboard.
|
||||
val overlayView: View =
|
||||
this.findViewById(R.id.surface_input_overlay)
|
||||
val im =
|
||||
overlayView.context.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
im.hideSoftInputFromWindow(overlayView.windowToken, 0)
|
||||
} else {
|
||||
val textChar = event.unicodeChar
|
||||
if (textChar == 0) {
|
||||
// No text, button input.
|
||||
NativeLibrary.submitInlineKeyboardInput(keyCode)
|
||||
} else {
|
||||
// Text submitted.
|
||||
NativeLibrary.submitInlineKeyboardText(textChar.toChar().toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
nfcReader.startScanning()
|
||||
startMotionSensorListener()
|
||||
InputHandler.updateControllerData()
|
||||
|
||||
buildPictureInPictureParams()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
nfcReader.stopScanning()
|
||||
stopMotionSensorListener()
|
||||
}
|
||||
|
||||
override fun onUserLeaveHint() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
if (BooleanSetting.PICTURE_IN_PICTURE.getBoolean() && !isInPictureInPictureMode) {
|
||||
val pictureInPictureParamsBuilder = PictureInPictureParams.Builder()
|
||||
.getPictureInPictureActionsBuilder().getPictureInPictureAspectBuilder()
|
||||
enterPictureInPictureMode(pictureInPictureParamsBuilder.build())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
nfcReader.onNewIntent(intent)
|
||||
InputHandler.updateControllerData()
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK &&
|
||||
event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD
|
||||
) {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
if (emulationViewModel.drawerOpen.value) {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
return InputHandler.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK &&
|
||||
event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD
|
||||
) {
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
if (emulationViewModel.drawerOpen.value) {
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
// Don't attempt to do anything if we are disconnecting a device.
|
||||
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
||||
return true
|
||||
}
|
||||
|
||||
return InputHandler.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
val rotation = this.display?.rotation
|
||||
if (rotation == Surface.ROTATION_90) {
|
||||
flipMotionOrientation = true
|
||||
}
|
||||
if (rotation == Surface.ROTATION_270) {
|
||||
flipMotionOrientation = false
|
||||
}
|
||||
|
||||
if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) {
|
||||
if (flipMotionOrientation) {
|
||||
accel[0] = event.values[1] / SensorManager.GRAVITY_EARTH
|
||||
accel[1] = -event.values[0] / SensorManager.GRAVITY_EARTH
|
||||
} else {
|
||||
accel[0] = -event.values[1] / SensorManager.GRAVITY_EARTH
|
||||
accel[1] = event.values[0] / SensorManager.GRAVITY_EARTH
|
||||
}
|
||||
accel[2] = -event.values[2] / SensorManager.GRAVITY_EARTH
|
||||
}
|
||||
if (event.sensor.type == Sensor.TYPE_GYROSCOPE) {
|
||||
// Investigate why sensor value is off by 6x
|
||||
if (flipMotionOrientation) {
|
||||
gyro[0] = -event.values[1] / 6.0f
|
||||
gyro[1] = event.values[0] / 6.0f
|
||||
} else {
|
||||
gyro[0] = event.values[1] / 6.0f
|
||||
gyro[1] = -event.values[0] / 6.0f
|
||||
}
|
||||
gyro[2] = event.values[2] / 6.0f
|
||||
}
|
||||
|
||||
// Only update state on accelerometer data
|
||||
if (event.sensor.type != Sensor.TYPE_ACCELEROMETER) {
|
||||
return
|
||||
}
|
||||
val deltaTimestamp = (event.timestamp - motionTimestamp) / 1000
|
||||
motionTimestamp = event.timestamp
|
||||
NativeInput.onDeviceMotionEvent(
|
||||
NativeInput.Player1Device,
|
||||
deltaTimestamp,
|
||||
gyro[0],
|
||||
gyro[1],
|
||||
gyro[2],
|
||||
accel[0],
|
||||
accel[1],
|
||||
accel[2]
|
||||
)
|
||||
NativeInput.onDeviceMotionEvent(
|
||||
NativeInput.ConsoleDevice,
|
||||
deltaTimestamp,
|
||||
gyro[0],
|
||||
gyro[1],
|
||||
gyro[2],
|
||||
accel[0],
|
||||
accel[1],
|
||||
accel[2]
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor, i: Int) {}
|
||||
|
||||
private fun enableFullscreenImmersive() {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
WindowInsetsControllerCompat(window, window.decorView).let { controller ->
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
controller.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
}
|
||||
|
||||
private fun PictureInPictureParams.Builder.getPictureInPictureAspectBuilder():
|
||||
PictureInPictureParams.Builder {
|
||||
val aspectRatio = when (IntSetting.RENDERER_ASPECT_RATIO.getInt()) {
|
||||
0 -> Rational(16, 9)
|
||||
1 -> Rational(4, 3)
|
||||
2 -> Rational(21, 9)
|
||||
3 -> Rational(16, 10)
|
||||
else -> null // Best fit
|
||||
}
|
||||
return this.apply { aspectRatio?.let { setAspectRatio(it) } }
|
||||
}
|
||||
|
||||
private fun PictureInPictureParams.Builder.getPictureInPictureActionsBuilder():
|
||||
PictureInPictureParams.Builder {
|
||||
val pictureInPictureActions: MutableList<RemoteAction> = mutableListOf()
|
||||
val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
|
||||
if (NativeLibrary.isPaused()) {
|
||||
val playIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_play)
|
||||
val playPendingIntent = PendingIntent.getBroadcast(
|
||||
this@EmulationActivity,
|
||||
R.drawable.ic_pip_play,
|
||||
Intent(actionPlay),
|
||||
pendingFlags
|
||||
)
|
||||
val playRemoteAction = RemoteAction(
|
||||
playIcon,
|
||||
getString(R.string.play),
|
||||
getString(R.string.play),
|
||||
playPendingIntent
|
||||
)
|
||||
pictureInPictureActions.add(playRemoteAction)
|
||||
} else {
|
||||
val pauseIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_pause)
|
||||
val pausePendingIntent = PendingIntent.getBroadcast(
|
||||
this@EmulationActivity,
|
||||
R.drawable.ic_pip_pause,
|
||||
Intent(actionPause),
|
||||
pendingFlags
|
||||
)
|
||||
val pauseRemoteAction = RemoteAction(
|
||||
pauseIcon,
|
||||
getString(R.string.pause),
|
||||
getString(R.string.pause),
|
||||
pausePendingIntent
|
||||
)
|
||||
pictureInPictureActions.add(pauseRemoteAction)
|
||||
}
|
||||
|
||||
if (BooleanSetting.AUDIO_MUTED.getBoolean()) {
|
||||
val unmuteIcon = Icon.createWithResource(
|
||||
this@EmulationActivity,
|
||||
R.drawable.ic_pip_unmute
|
||||
)
|
||||
val unmutePendingIntent = PendingIntent.getBroadcast(
|
||||
this@EmulationActivity,
|
||||
R.drawable.ic_pip_unmute,
|
||||
Intent(actionUnmute),
|
||||
pendingFlags
|
||||
)
|
||||
val unmuteRemoteAction = RemoteAction(
|
||||
unmuteIcon,
|
||||
getString(R.string.unmute),
|
||||
getString(R.string.unmute),
|
||||
unmutePendingIntent
|
||||
)
|
||||
pictureInPictureActions.add(unmuteRemoteAction)
|
||||
} else {
|
||||
val muteIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_mute)
|
||||
val mutePendingIntent = PendingIntent.getBroadcast(
|
||||
this@EmulationActivity,
|
||||
R.drawable.ic_pip_mute,
|
||||
Intent(actionMute),
|
||||
pendingFlags
|
||||
)
|
||||
val muteRemoteAction = RemoteAction(
|
||||
muteIcon,
|
||||
getString(R.string.mute),
|
||||
getString(R.string.mute),
|
||||
mutePendingIntent
|
||||
)
|
||||
pictureInPictureActions.add(muteRemoteAction)
|
||||
}
|
||||
|
||||
return this.apply { setActions(pictureInPictureActions) }
|
||||
}
|
||||
|
||||
fun buildPictureInPictureParams() {
|
||||
val pictureInPictureParamsBuilder = PictureInPictureParams.Builder()
|
||||
.getPictureInPictureActionsBuilder().getPictureInPictureAspectBuilder()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val isEmulationActive = emulationViewModel.emulationStarted.value &&
|
||||
!emulationViewModel.isEmulationStopping.value
|
||||
pictureInPictureParamsBuilder.setAutoEnterEnabled(
|
||||
BooleanSetting.PICTURE_IN_PICTURE.getBoolean() && isEmulationActive
|
||||
)
|
||||
}
|
||||
setPictureInPictureParams(pictureInPictureParamsBuilder.build())
|
||||
}
|
||||
|
||||
private var pictureInPictureReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent) {
|
||||
if (intent.action == actionPlay) {
|
||||
if (NativeLibrary.isPaused()) NativeLibrary.unpauseEmulation()
|
||||
} else if (intent.action == actionPause) {
|
||||
if (!NativeLibrary.isPaused()) NativeLibrary.pauseEmulation()
|
||||
}
|
||||
if (intent.action == actionUnmute) {
|
||||
if (BooleanSetting.AUDIO_MUTED.getBoolean()) {
|
||||
BooleanSetting.AUDIO_MUTED.setBoolean(false)
|
||||
}
|
||||
} else if (intent.action == actionMute) {
|
||||
if (!BooleanSetting.AUDIO_MUTED.getBoolean()) {
|
||||
BooleanSetting.AUDIO_MUTED.setBoolean(true)
|
||||
}
|
||||
}
|
||||
buildPictureInPictureParams()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
override fun onPictureInPictureModeChanged(
|
||||
isInPictureInPictureMode: Boolean,
|
||||
newConfig: Configuration
|
||||
) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
if (isInPictureInPictureMode) {
|
||||
IntentFilter().apply {
|
||||
addAction(actionPause)
|
||||
addAction(actionPlay)
|
||||
addAction(actionMute)
|
||||
addAction(actionUnmute)
|
||||
}.also {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(pictureInPictureReceiver, it, RECEIVER_EXPORTED)
|
||||
} else {
|
||||
registerReceiver(pictureInPictureReceiver, it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
unregisterReceiver(pictureInPictureReceiver)
|
||||
} catch (ignored: Exception) {
|
||||
}
|
||||
// Always resume audio, since there is no UI button
|
||||
if (BooleanSetting.AUDIO_MUTED.getBoolean()) {
|
||||
BooleanSetting.AUDIO_MUTED.setBoolean(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onEmulationStarted() {
|
||||
emulationViewModel.setEmulationStarted(true)
|
||||
}
|
||||
|
||||
fun onEmulationStopped(status: Int) {
|
||||
if (status == 0 && emulationViewModel.programChanged.value == -1) {
|
||||
finish()
|
||||
}
|
||||
emulationViewModel.setEmulationStopped(true)
|
||||
}
|
||||
|
||||
fun onProgramChanged(programIndex: Int) {
|
||||
emulationViewModel.setProgramChanged(programIndex)
|
||||
}
|
||||
|
||||
private fun startMotionSensorListener() {
|
||||
val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
|
||||
val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
||||
sensorManager.registerListener(this, gyroSensor, SensorManager.SENSOR_DELAY_GAME)
|
||||
sensorManager.registerListener(this, accelSensor, SensorManager.SENSOR_DELAY_GAME)
|
||||
}
|
||||
|
||||
private fun stopMotionSensorListener() {
|
||||
val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
|
||||
val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
||||
|
||||
sensorManager.unregisterListener(this, gyroSensor)
|
||||
sensorManager.unregisterListener(this, accelSensor)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_SELECTED_GAME = "SelectedGame"
|
||||
|
||||
fun launch(activity: AppCompatActivity, game: Game) {
|
||||
val launcher = Intent(activity, EmulationActivity::class.java)
|
||||
launcher.putExtra(EXTRA_SELECTED_GAME, game)
|
||||
activity.startActivity(launcher)
|
||||
}
|
||||
|
||||
private fun areCoordinatesOutside(view: View?, x: Float, y: Float): Boolean {
|
||||
if (view == null) {
|
||||
return true
|
||||
}
|
||||
val viewBounds = Rect()
|
||||
view.getGlobalVisibleRect(viewBounds)
|
||||
return !viewBounds.contains(x.roundToInt(), y.roundToInt())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.adapters
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.recyclerview.widget.AsyncDifferConfig
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
/**
|
||||
* Generic adapter that implements an [AsyncDifferConfig] and covers some of the basic boilerplate
|
||||
* code used in every [RecyclerView].
|
||||
* Type assigned to [Model] must inherit from [Object] in order to be compared properly.
|
||||
* @param exact Decides whether each item will be compared by reference or by their contents
|
||||
*/
|
||||
abstract class AbstractDiffAdapter<Model : Any, Holder : AbstractViewHolder<Model>>(
|
||||
exact: Boolean = true
|
||||
) : ListAdapter<Model, Holder>(AsyncDifferConfig.Builder(DiffCallback<Model>(exact)).build()) {
|
||||
override fun onBindViewHolder(holder: Holder, position: Int) =
|
||||
holder.bind(currentList[position])
|
||||
|
||||
private class DiffCallback<Model>(val exact: Boolean) : DiffUtil.ItemCallback<Model>() {
|
||||
override fun areItemsTheSame(oldItem: Model & Any, newItem: Model & Any): Boolean {
|
||||
if (exact) {
|
||||
return oldItem === newItem
|
||||
}
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
@SuppressLint("DiffUtilEquals")
|
||||
override fun areContentsTheSame(oldItem: Model & Any, newItem: Model & Any): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.adapters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import org.sudachi.sudachi_emu.databinding.ListItemAddonBinding
|
||||
import org.sudachi.sudachi_emu.model.Patch
|
||||
import org.sudachi.sudachi_emu.model.AddonViewModel
|
||||
import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder
|
||||
|
||||
class AddonAdapter(val addonViewModel: AddonViewModel) :
|
||||
AbstractDiffAdapter<Patch, AddonAdapter.AddonViewHolder>() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddonViewHolder {
|
||||
ListItemAddonBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
.also { return AddonViewHolder(it) }
|
||||
}
|
||||
|
||||
inner class AddonViewHolder(val binding: ListItemAddonBinding) :
|
||||
AbstractViewHolder<Patch>(binding) {
|
||||
override fun bind(model: Patch) {
|
||||
binding.root.setOnClickListener {
|
||||
binding.addonCheckbox.isChecked = !binding.addonCheckbox.isChecked
|
||||
}
|
||||
binding.title.text = model.name
|
||||
binding.version.text = model.version
|
||||
binding.addonCheckbox.setOnCheckedChangeListener { _, checked ->
|
||||
model.enabled = checked
|
||||
}
|
||||
binding.addonCheckbox.isChecked = model.enabled
|
||||
binding.buttonDelete.setOnClickListener {
|
||||
addonViewModel.setAddonToDelete(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.adapters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.navigation.findNavController
|
||||
import org.sudachi.sudachi_emu.HomeNavigationDirections
|
||||
import org.sudachi.sudachi_emu.NativeLibrary
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.SudachiApplication
|
||||
import org.sudachi.sudachi_emu.databinding.CardSimpleOutlinedBinding
|
||||
import org.sudachi.sudachi_emu.model.Applet
|
||||
import org.sudachi.sudachi_emu.model.AppletInfo
|
||||
import org.sudachi.sudachi_emu.model.Game
|
||||
import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder
|
||||
|
||||
class AppletAdapter(val activity: FragmentActivity, applets: List<Applet>) :
|
||||
AbstractListAdapter<Applet, AppletAdapter.AppletViewHolder>(applets) {
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): AppletAdapter.AppletViewHolder {
|
||||
CardSimpleOutlinedBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
.also { return AppletViewHolder(it) }
|
||||
}
|
||||
|
||||
inner class AppletViewHolder(val binding: CardSimpleOutlinedBinding) :
|
||||
AbstractViewHolder<Applet>(binding) {
|
||||
override fun bind(model: Applet) {
|
||||
binding.title.setText(model.titleId)
|
||||
binding.description.setText(model.descriptionId)
|
||||
binding.icon.setImageDrawable(
|
||||
ResourcesCompat.getDrawable(
|
||||
binding.icon.context.resources,
|
||||
model.iconId,
|
||||
binding.icon.context.theme
|
||||
)
|
||||
)
|
||||
|
||||
binding.root.setOnClickListener { onClick(model) }
|
||||
}
|
||||
|
||||
fun onClick(applet: Applet) {
|
||||
val appletPath = NativeLibrary.getAppletLaunchPath(applet.appletInfo.entryId)
|
||||
if (appletPath.isEmpty()) {
|
||||
Toast.makeText(
|
||||
binding.root.context,
|
||||
R.string.applets_error_applet,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
return
|
||||
}
|
||||
|
||||
if (applet.appletInfo == AppletInfo.Cabinet) {
|
||||
binding.root.findNavController()
|
||||
.navigate(R.id.action_appletLauncherFragment_to_cabinetLauncherDialogFragment)
|
||||
return
|
||||
}
|
||||
|
||||
NativeLibrary.setCurrentAppletId(applet.appletInfo.appletId)
|
||||
val appletGame = Game(
|
||||
title = SudachiApplication.appContext.getString(applet.titleId),
|
||||
path = appletPath
|
||||
)
|
||||
val action = HomeNavigationDirections.actionGlobalEmulationActivity(appletGame)
|
||||
binding.root.findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.adapters
|
||||
|
||||
import android.net.Uri
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import org.sudachi.sudachi_emu.databinding.CardFolderBinding
|
||||
import org.sudachi.sudachi_emu.fragments.GameFolderPropertiesDialogFragment
|
||||
import org.sudachi.sudachi_emu.model.GameDir
|
||||
import org.sudachi.sudachi_emu.model.GamesViewModel
|
||||
import org.sudachi.sudachi_emu.utils.ViewUtils.marquee
|
||||
import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder
|
||||
|
||||
class FolderAdapter(val activity: FragmentActivity, val gamesViewModel: GamesViewModel) :
|
||||
AbstractDiffAdapter<GameDir, FolderAdapter.FolderViewHolder>() {
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): FolderAdapter.FolderViewHolder {
|
||||
CardFolderBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
.also { return FolderViewHolder(it) }
|
||||
}
|
||||
|
||||
inner class FolderViewHolder(val binding: CardFolderBinding) :
|
||||
AbstractViewHolder<GameDir>(binding) {
|
||||
override fun bind(model: GameDir) {
|
||||
binding.apply {
|
||||
path.text = Uri.parse(model.uriString).path
|
||||
path.marquee()
|
||||
|
||||
buttonEdit.setOnClickListener {
|
||||
GameFolderPropertiesDialogFragment.newInstance(model)
|
||||
.show(
|
||||
activity.supportFragmentManager,
|
||||
GameFolderPropertiesDialogFragment.TAG
|
||||
)
|
||||
}
|
||||
|
||||
buttonDelete.setOnClickListener {
|
||||
gamesViewModel.removeFolder(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.adapters
|
||||
|
||||
import android.net.Uri
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.sudachi.sudachi_emu.HomeNavigationDirections
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.SudachiApplication
|
||||
import org.sudachi.sudachi_emu.databinding.CardGameBinding
|
||||
import org.sudachi.sudachi_emu.model.Game
|
||||
import org.sudachi.sudachi_emu.model.GamesViewModel
|
||||
import org.sudachi.sudachi_emu.utils.GameIconUtils
|
||||
import org.sudachi.sudachi_emu.utils.ViewUtils.marquee
|
||||
import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder
|
||||
|
||||
class GameAdapter(private val activity: AppCompatActivity) :
|
||||
AbstractDiffAdapter<Game, GameAdapter.GameViewHolder>(exact = false) {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
|
||||
CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
.also { return GameViewHolder(it) }
|
||||
}
|
||||
|
||||
inner class GameViewHolder(val binding: CardGameBinding) :
|
||||
AbstractViewHolder<Game>(binding) {
|
||||
override fun bind(model: Game) {
|
||||
binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
GameIconUtils.loadGameIcon(model, binding.imageGameScreen)
|
||||
|
||||
binding.textGameTitle.text = model.title.replace("[\\t\\n\\r]+".toRegex(), " ")
|
||||
|
||||
binding.textGameTitle.marquee()
|
||||
binding.cardGame.setOnClickListener { onClick(model) }
|
||||
binding.cardGame.setOnLongClickListener { onLongClick(model) }
|
||||
}
|
||||
|
||||
fun onClick(game: Game) {
|
||||
val gameExists = DocumentFile.fromSingleUri(
|
||||
SudachiApplication.appContext,
|
||||
Uri.parse(game.path)
|
||||
)?.exists() == true
|
||||
if (!gameExists) {
|
||||
Toast.makeText(
|
||||
SudachiApplication.appContext,
|
||||
R.string.loader_error_file_not_found,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
|
||||
ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true)
|
||||
return
|
||||
}
|
||||
|
||||
val preferences =
|
||||
PreferenceManager.getDefaultSharedPreferences(SudachiApplication.appContext)
|
||||
preferences.edit()
|
||||
.putLong(
|
||||
game.keyLastPlayedTime,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
.apply()
|
||||
|
||||
activity.lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val shortcut =
|
||||
ShortcutInfoCompat.Builder(SudachiApplication.appContext, game.path)
|
||||
.setShortLabel(game.title)
|
||||
.setIcon(GameIconUtils.getShortcutIcon(activity, game))
|
||||
.setIntent(game.launchIntent)
|
||||
.build()
|
||||
ShortcutManagerCompat.pushDynamicShortcut(SudachiApplication.appContext, shortcut)
|
||||
}
|
||||
}
|
||||
|
||||
val action = HomeNavigationDirections.actionGlobalEmulationActivity(game, true)
|
||||
binding.root.findNavController().navigate(action)
|
||||
}
|
||||
|
||||
fun onLongClick(game: Game): Boolean {
|
||||
val action = HomeNavigationDirections.actionGlobalPerGamePropertiesFragment(game)
|
||||
binding.root.findNavController().navigate(action)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.adapters
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import org.sudachi.sudachi_emu.databinding.ListItemSettingBinding
|
||||
import org.sudachi.sudachi_emu.fragments.LicenseBottomSheetDialogFragment
|
||||
import org.sudachi.sudachi_emu.model.License
|
||||
import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible
|
||||
import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder
|
||||
|
||||
class LicenseAdapter(private val activity: AppCompatActivity, licenses: List<License>) :
|
||||
AbstractListAdapter<License, LicenseAdapter.LicenseViewHolder>(licenses) {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LicenseViewHolder {
|
||||
ListItemSettingBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
.also { return LicenseViewHolder(it) }
|
||||
}
|
||||
|
||||
inner class LicenseViewHolder(val binding: ListItemSettingBinding) :
|
||||
AbstractViewHolder<License>(binding) {
|
||||
override fun bind(model: License) {
|
||||
binding.apply {
|
||||
textSettingName.text = root.context.getString(model.titleId)
|
||||
textSettingDescription.text = root.context.getString(model.descriptionId)
|
||||
textSettingValue.setVisible(false)
|
||||
|
||||
root.setOnClickListener { onClick(model) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun onClick(license: License) {
|
||||
LicenseBottomSheetDialogFragment.newInstance(license)
|
||||
.show(activity.supportFragmentManager, LicenseBottomSheetDialogFragment.TAG)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.adapters
|
||||
|
||||
import android.text.Html
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.sudachi.sudachi_emu.databinding.PageSetupBinding
|
||||
import org.sudachi.sudachi_emu.model.HomeViewModel
|
||||
import org.sudachi.sudachi_emu.model.SetupCallback
|
||||
import org.sudachi.sudachi_emu.model.SetupPage
|
||||
import org.sudachi.sudachi_emu.model.StepState
|
||||
import org.sudachi.sudachi_emu.utils.ViewUtils
|
||||
import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible
|
||||
import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder
|
||||
|
||||
class SetupAdapter(val activity: AppCompatActivity, pages: List<SetupPage>) :
|
||||
AbstractListAdapter<SetupPage, SetupAdapter.SetupPageViewHolder>(pages) {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SetupPageViewHolder {
|
||||
PageSetupBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
.also { return SetupPageViewHolder(it) }
|
||||
}
|
||||
|
||||
inner class SetupPageViewHolder(val binding: PageSetupBinding) :
|
||||
AbstractViewHolder<SetupPage>(binding), SetupCallback {
|
||||
override fun bind(model: SetupPage) {
|
||||
if (model.stepCompleted.invoke() == StepState.COMPLETE) {
|
||||
binding.buttonAction.setVisible(visible = false, gone = false)
|
||||
binding.textConfirmation.setVisible(true)
|
||||
}
|
||||
|
||||
binding.icon.setImageDrawable(
|
||||
ResourcesCompat.getDrawable(
|
||||
activity.resources,
|
||||
model.iconId,
|
||||
activity.theme
|
||||
)
|
||||
)
|
||||
binding.textTitle.text = activity.resources.getString(model.titleId)
|
||||
binding.textDescription.text =
|
||||
Html.fromHtml(activity.resources.getString(model.descriptionId), 0)
|
||||
|
||||
binding.buttonAction.apply {
|
||||
text = activity.resources.getString(model.buttonTextId)
|
||||
if (model.buttonIconId != 0) {
|
||||
icon = ResourcesCompat.getDrawable(
|
||||
activity.resources,
|
||||
model.buttonIconId,
|
||||
activity.theme
|
||||
)
|
||||
}
|
||||
iconGravity =
|
||||
if (model.leftAlignedIcon) {
|
||||
MaterialButton.ICON_GRAVITY_START
|
||||
} else {
|
||||
MaterialButton.ICON_GRAVITY_END
|
||||
}
|
||||
setOnClickListener {
|
||||
model.buttonAction.invoke(this@SetupPageViewHolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStepCompleted() {
|
||||
ViewUtils.hideView(binding.buttonAction, 200)
|
||||
ViewUtils.showView(binding.textConfirmation, 200)
|
||||
ViewModelProvider(activity)[HomeViewModel::class.java].setShouldPageForward(true)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.disk_shader_cache
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.sudachi.sudachi_emu.NativeLibrary
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.activities.EmulationActivity
|
||||
import org.sudachi.sudachi_emu.model.EmulationViewModel
|
||||
import org.sudachi.sudachi_emu.utils.Log
|
||||
|
||||
@Keep
|
||||
object DiskShaderCacheProgress {
|
||||
private lateinit var emulationViewModel: EmulationViewModel
|
||||
|
||||
private fun prepareViewModel() {
|
||||
emulationViewModel =
|
||||
ViewModelProvider(
|
||||
NativeLibrary.sEmulationActivity.get() as EmulationActivity
|
||||
)[EmulationViewModel::class.java]
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun loadProgress(stage: Int, progress: Int, max: Int) {
|
||||
val emulationActivity = NativeLibrary.sEmulationActivity.get()
|
||||
if (emulationActivity == null) {
|
||||
Log.error("[DiskShaderCacheProgress] EmulationActivity not present")
|
||||
return
|
||||
}
|
||||
|
||||
emulationActivity.runOnUiThread {
|
||||
when (LoadCallbackStage.values()[stage]) {
|
||||
LoadCallbackStage.Prepare -> prepareViewModel()
|
||||
LoadCallbackStage.Build -> emulationViewModel.updateProgress(
|
||||
emulationActivity.getString(R.string.building_shaders),
|
||||
progress,
|
||||
max
|
||||
)
|
||||
|
||||
LoadCallbackStage.Complete -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Equivalent to VideoCore::LoadCallbackStage
|
||||
enum class LoadCallbackStage {
|
||||
Prepare, Build, Complete
|
||||
}
|
||||
}
|
|
@ -0,0 +1,416 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.input
|
||||
|
||||
import org.sudachi.sudachi_emu.features.input.model.NativeButton
|
||||
import org.sudachi.sudachi_emu.features.input.model.NativeAnalog
|
||||
import org.sudachi.sudachi_emu.features.input.model.InputType
|
||||
import org.sudachi.sudachi_emu.features.input.model.ButtonName
|
||||
import org.sudachi.sudachi_emu.features.input.model.NpadStyleIndex
|
||||
import org.sudachi.sudachi_emu.utils.NativeConfig
|
||||
import org.sudachi.sudachi_emu.utils.ParamPackage
|
||||
import android.view.InputDevice
|
||||
|
||||
object NativeInput {
|
||||
/**
|
||||
* Default controller id for each device
|
||||
*/
|
||||
const val Player1Device = 0
|
||||
const val Player2Device = 1
|
||||
const val Player3Device = 2
|
||||
const val Player4Device = 3
|
||||
const val Player5Device = 4
|
||||
const val Player6Device = 5
|
||||
const val Player7Device = 6
|
||||
const val Player8Device = 7
|
||||
const val ConsoleDevice = 8
|
||||
|
||||
/**
|
||||
* Button states
|
||||
*/
|
||||
object ButtonState {
|
||||
const val RELEASED = 0
|
||||
const val PRESSED = 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if pro controller isn't available and handheld is.
|
||||
* Intended to check where the input overlay should direct its inputs.
|
||||
*/
|
||||
external fun isHandheldOnly(): Boolean
|
||||
|
||||
/**
|
||||
* Handles button press events for a gamepad.
|
||||
* @param guid 32 character hexadecimal string consisting of the controller's PID+VID.
|
||||
* @param port Port determined by controller connection order.
|
||||
* @param buttonId The Android Keycode corresponding to this event.
|
||||
* @param action Mask identifying which action is happening (button pressed down, or button released).
|
||||
*/
|
||||
external fun onGamePadButtonEvent(
|
||||
guid: String,
|
||||
port: Int,
|
||||
buttonId: Int,
|
||||
action: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles axis movement events.
|
||||
* @param guid 32 character hexadecimal string consisting of the controller's PID+VID.
|
||||
* @param port Port determined by controller connection order.
|
||||
* @param axis The axis ID.
|
||||
* @param value Value along the given axis.
|
||||
*/
|
||||
external fun onGamePadAxisEvent(guid: String, port: Int, axis: Int, value: Float)
|
||||
|
||||
/**
|
||||
* Handles motion events.
|
||||
* @param guid 32 character hexadecimal string consisting of the controller's PID+VID.
|
||||
* @param port Port determined by controller connection order.
|
||||
* @param deltaTimestamp The finger id corresponding to this event.
|
||||
* @param xGyro The value of the x-axis for the gyroscope.
|
||||
* @param yGyro The value of the y-axis for the gyroscope.
|
||||
* @param zGyro The value of the z-axis for the gyroscope.
|
||||
* @param xAccel The value of the x-axis for the accelerometer.
|
||||
* @param yAccel The value of the y-axis for the accelerometer.
|
||||
* @param zAccel The value of the z-axis for the accelerometer.
|
||||
*/
|
||||
external fun onGamePadMotionEvent(
|
||||
guid: String,
|
||||
port: Int,
|
||||
deltaTimestamp: Long,
|
||||
xGyro: Float,
|
||||
yGyro: Float,
|
||||
zGyro: Float,
|
||||
xAccel: Float,
|
||||
yAccel: Float,
|
||||
zAccel: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* Signals and load a nfc tag
|
||||
* @param data Byte array containing all the data from a nfc tag.
|
||||
*/
|
||||
external fun onReadNfcTag(data: ByteArray?)
|
||||
|
||||
/**
|
||||
* Removes current loaded nfc tag.
|
||||
*/
|
||||
external fun onRemoveNfcTag()
|
||||
|
||||
/**
|
||||
* Handles touch press events.
|
||||
* @param fingerId The finger id corresponding to this event.
|
||||
* @param xAxis The value of the x-axis on the touchscreen.
|
||||
* @param yAxis The value of the y-axis on the touchscreen.
|
||||
*/
|
||||
external fun onTouchPressed(fingerId: Int, xAxis: Float, yAxis: Float)
|
||||
|
||||
/**
|
||||
* Handles touch movement.
|
||||
* @param fingerId The finger id corresponding to this event.
|
||||
* @param xAxis The value of the x-axis on the touchscreen.
|
||||
* @param yAxis The value of the y-axis on the touchscreen.
|
||||
*/
|
||||
external fun onTouchMoved(fingerId: Int, xAxis: Float, yAxis: Float)
|
||||
|
||||
/**
|
||||
* Handles touch release events.
|
||||
* @param fingerId The finger id corresponding to this event
|
||||
*/
|
||||
external fun onTouchReleased(fingerId: Int)
|
||||
|
||||
/**
|
||||
* Sends a button input to the global virtual controllers.
|
||||
* @param port Port determined by controller connection order.
|
||||
* @param button The [NativeButton] corresponding to this event.
|
||||
* @param action Mask identifying which action is happening (button pressed down, or button released).
|
||||
*/
|
||||
fun onOverlayButtonEvent(port: Int, button: NativeButton, action: Int) =
|
||||
onOverlayButtonEventImpl(port, button.int, action)
|
||||
|
||||
private external fun onOverlayButtonEventImpl(port: Int, buttonId: Int, action: Int)
|
||||
|
||||
/**
|
||||
* Sends a joystick input to the global virtual controllers.
|
||||
* @param port Port determined by controller connection order.
|
||||
* @param stick The [NativeAnalog] corresponding to this event.
|
||||
* @param xAxis Value along the X axis.
|
||||
* @param yAxis Value along the Y axis.
|
||||
*/
|
||||
fun onOverlayJoystickEvent(port: Int, stick: NativeAnalog, xAxis: Float, yAxis: Float) =
|
||||
onOverlayJoystickEventImpl(port, stick.int, xAxis, yAxis)
|
||||
|
||||
private external fun onOverlayJoystickEventImpl(
|
||||
port: Int,
|
||||
stickId: Int,
|
||||
xAxis: Float,
|
||||
yAxis: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles motion events for the global virtual controllers.
|
||||
* @param port Port determined by controller connection order
|
||||
* @param deltaTimestamp The finger id corresponding to this event.
|
||||
* @param xGyro The value of the x-axis for the gyroscope.
|
||||
* @param yGyro The value of the y-axis for the gyroscope.
|
||||
* @param zGyro The value of the z-axis for the gyroscope.
|
||||
* @param xAccel The value of the x-axis for the accelerometer.
|
||||
* @param yAccel The value of the y-axis for the accelerometer.
|
||||
* @param zAccel The value of the z-axis for the accelerometer.
|
||||
*/
|
||||
external fun onDeviceMotionEvent(
|
||||
port: Int,
|
||||
deltaTimestamp: Long,
|
||||
xGyro: Float,
|
||||
yGyro: Float,
|
||||
zGyro: Float,
|
||||
xAccel: Float,
|
||||
yAccel: Float,
|
||||
zAccel: Float
|
||||
)
|
||||
|
||||
/**
|
||||
* Reloads all input devices from the currently loaded Settings::values.players into HID Core
|
||||
*/
|
||||
external fun reloadInputDevices()
|
||||
|
||||
/**
|
||||
* Registers a controller to be used with mapping
|
||||
* @param device An [InputDevice] or the input overlay wrapped with [SudachiInputDevice]
|
||||
*/
|
||||
external fun registerController(device: SudachiInputDevice)
|
||||
|
||||
/**
|
||||
* Gets the names of input devices that have been registered with the input subsystem via [registerController]
|
||||
*/
|
||||
external fun getInputDevices(): Array<String>
|
||||
|
||||
/**
|
||||
* Reads all input profiles from disk. Must be called before creating a profile picker.
|
||||
*/
|
||||
external fun loadInputProfiles()
|
||||
|
||||
/**
|
||||
* Gets the names of each available input profile.
|
||||
*/
|
||||
external fun getInputProfileNames(): Array<String>
|
||||
|
||||
/**
|
||||
* Checks if the user-provided name for an input profile is valid.
|
||||
* @param name User-provided name for an input profile.
|
||||
* @return Whether [name] is valid or not.
|
||||
*/
|
||||
external fun isProfileNameValid(name: String): Boolean
|
||||
|
||||
/**
|
||||
* Creates a new input profile.
|
||||
* @param name The new profile's name.
|
||||
* @param playerIndex Index of the player that's currently being edited. Used to write the profile
|
||||
* name to this player's config.
|
||||
* @return Whether creating the profile was successful or not.
|
||||
*/
|
||||
external fun createProfile(name: String, playerIndex: Int): Boolean
|
||||
|
||||
/**
|
||||
* Deletes an input profile.
|
||||
* @param name Name of the profile to delete.
|
||||
* @param playerIndex Index of the player that's currently being edited. Used to remove the profile
|
||||
* name from this player's config if they have it loaded.
|
||||
* @return Whether deleting this profile was successful or not.
|
||||
*/
|
||||
external fun deleteProfile(name: String, playerIndex: Int): Boolean
|
||||
|
||||
/**
|
||||
* Loads an input profile.
|
||||
* @param name Name of the input profile to load.
|
||||
* @param playerIndex Index of the player that will have this profile loaded.
|
||||
* @return Whether loading this profile was successful or not.
|
||||
*/
|
||||
external fun loadProfile(name: String, playerIndex: Int): Boolean
|
||||
|
||||
/**
|
||||
* Saves an input profile.
|
||||
* @param name Name of the profile to save.
|
||||
* @param playerIndex Index of the player that's currently being edited. Used to write the profile
|
||||
* name to this player's config.
|
||||
* @return Whether saving the profile was successful or not.
|
||||
*/
|
||||
external fun saveProfile(name: String, playerIndex: Int): Boolean
|
||||
|
||||
/**
|
||||
* Intended to be used immediately before a call to [NativeConfig.saveControlPlayerValues]
|
||||
* Must be used while per-game config is loaded.
|
||||
*/
|
||||
external fun loadPerGameConfiguration(
|
||||
playerIndex: Int,
|
||||
selectedIndex: Int,
|
||||
selectedProfileName: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Tells the input subsystem to start listening for inputs to map.
|
||||
* @param type Type of input to map as shown by the int property in each [InputType].
|
||||
*/
|
||||
external fun beginMapping(type: Int)
|
||||
|
||||
/**
|
||||
* Gets an input's [ParamPackage] as a serialized string. Used for input verification before mapping.
|
||||
* Must be run after [beginMapping] and before [stopMapping].
|
||||
*/
|
||||
external fun getNextInput(): String
|
||||
|
||||
/**
|
||||
* Tells the input subsystem to stop listening for inputs to map.
|
||||
*/
|
||||
external fun stopMapping()
|
||||
|
||||
/**
|
||||
* Updates a controller's mappings with auto-mapping params.
|
||||
* @param playerIndex Index of the player to auto-map.
|
||||
* @param deviceParams [ParamPackage] representing the device to auto-map as received
|
||||
* from [getInputDevices].
|
||||
* @param displayName Name of the device to auto-map as received from the "display" param in [deviceParams].
|
||||
* Intended to be a way to provide a default name for a controller if the "display" param is empty.
|
||||
*/
|
||||
fun updateMappingsWithDefault(
|
||||
playerIndex: Int,
|
||||
deviceParams: ParamPackage,
|
||||
displayName: String
|
||||
) = updateMappingsWithDefaultImpl(playerIndex, deviceParams.serialize(), displayName)
|
||||
|
||||
private external fun updateMappingsWithDefaultImpl(
|
||||
playerIndex: Int,
|
||||
deviceParams: String,
|
||||
displayName: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets the params for a specific button.
|
||||
* @param playerIndex Index of the player to get params from.
|
||||
* @param button The [NativeButton] to get params for.
|
||||
* @return A [ParamPackage] representing a player's specific button.
|
||||
*/
|
||||
fun getButtonParam(playerIndex: Int, button: NativeButton): ParamPackage =
|
||||
ParamPackage(getButtonParamImpl(playerIndex, button.int))
|
||||
|
||||
private external fun getButtonParamImpl(playerIndex: Int, buttonId: Int): String
|
||||
|
||||
/**
|
||||
* Sets the params for a specific button.
|
||||
* @param playerIndex Index of the player to set params for.
|
||||
* @param button The [NativeButton] to set params for.
|
||||
* @param param A [ParamPackage] to set.
|
||||
*/
|
||||
fun setButtonParam(playerIndex: Int, button: NativeButton, param: ParamPackage) =
|
||||
setButtonParamImpl(playerIndex, button.int, param.serialize())
|
||||
|
||||
private external fun setButtonParamImpl(playerIndex: Int, buttonId: Int, param: String)
|
||||
|
||||
/**
|
||||
* Gets the params for a specific stick.
|
||||
* @param playerIndex Index of the player to get params from.
|
||||
* @param stick The [NativeAnalog] to get params for.
|
||||
* @return A [ParamPackage] representing a player's specific stick.
|
||||
*/
|
||||
fun getStickParam(playerIndex: Int, stick: NativeAnalog): ParamPackage =
|
||||
ParamPackage(getStickParamImpl(playerIndex, stick.int))
|
||||
|
||||
private external fun getStickParamImpl(playerIndex: Int, stickId: Int): String
|
||||
|
||||
/**
|
||||
* Sets the params for a specific stick.
|
||||
* @param playerIndex Index of the player to set params for.
|
||||
* @param stick The [NativeAnalog] to set params for.
|
||||
* @param param A [ParamPackage] to set.
|
||||
*/
|
||||
fun setStickParam(playerIndex: Int, stick: NativeAnalog, param: ParamPackage) =
|
||||
setStickParamImpl(playerIndex, stick.int, param.serialize())
|
||||
|
||||
private external fun setStickParamImpl(playerIndex: Int, stickId: Int, param: String)
|
||||
|
||||
/**
|
||||
* Gets the int representation of a [ButtonName]. Tells you what to show as the mapped input for
|
||||
* a button/analog/other.
|
||||
* @param param A [ParamPackage] that represents a specific button's params.
|
||||
* @return The [ButtonName] for [param].
|
||||
*/
|
||||
fun getButtonName(param: ParamPackage): ButtonName =
|
||||
ButtonName.from(getButtonNameImpl(param.serialize()))
|
||||
|
||||
private external fun getButtonNameImpl(param: String): Int
|
||||
|
||||
/**
|
||||
* Gets each supported [NpadStyleIndex] for a given player.
|
||||
* @param playerIndex Index of the player to get supported indexes for.
|
||||
* @return List of each supported [NpadStyleIndex].
|
||||
*/
|
||||
fun getSupportedStyleTags(playerIndex: Int): List<NpadStyleIndex> =
|
||||
getSupportedStyleTagsImpl(playerIndex).map { NpadStyleIndex.from(it) }
|
||||
|
||||
private external fun getSupportedStyleTagsImpl(playerIndex: Int): IntArray
|
||||
|
||||
/**
|
||||
* Gets the [NpadStyleIndex] for a given player.
|
||||
* @param playerIndex Index of the player to get an [NpadStyleIndex] from.
|
||||
* @return The [NpadStyleIndex] for a given player.
|
||||
*/
|
||||
fun getStyleIndex(playerIndex: Int): NpadStyleIndex =
|
||||
NpadStyleIndex.from(getStyleIndexImpl(playerIndex))
|
||||
|
||||
private external fun getStyleIndexImpl(playerIndex: Int): Int
|
||||
|
||||
/**
|
||||
* Sets the [NpadStyleIndex] for a given player.
|
||||
* @param playerIndex Index of the player to change.
|
||||
* @param style The new style to set.
|
||||
*/
|
||||
fun setStyleIndex(playerIndex: Int, style: NpadStyleIndex) =
|
||||
setStyleIndexImpl(playerIndex, style.int)
|
||||
|
||||
private external fun setStyleIndexImpl(playerIndex: Int, styleIndex: Int)
|
||||
|
||||
/**
|
||||
* Checks if a device is a controller.
|
||||
* @param params [ParamPackage] for an input device retrieved from [getInputDevices]
|
||||
* @return Whether the device is a controller or not.
|
||||
*/
|
||||
fun isController(params: ParamPackage): Boolean = isControllerImpl(params.serialize())
|
||||
|
||||
private external fun isControllerImpl(params: String): Boolean
|
||||
|
||||
/**
|
||||
* Checks if a controller is connected
|
||||
* @param playerIndex Index of the player to check.
|
||||
* @return Whether the player is connected or not.
|
||||
*/
|
||||
external fun getIsConnected(playerIndex: Int): Boolean
|
||||
|
||||
/**
|
||||
* Connects/disconnects a controller and ensures that connection order stays in-tact.
|
||||
* @param playerIndex Index of the player to connect/disconnect.
|
||||
* @param connected Whether to connect or disconnect this controller.
|
||||
*/
|
||||
fun connectControllers(playerIndex: Int, connected: Boolean = true) {
|
||||
val connectedControllers = mutableListOf<Boolean>().apply {
|
||||
if (connected) {
|
||||
for (i in 0 until 8) {
|
||||
add(i <= playerIndex)
|
||||
}
|
||||
} else {
|
||||
for (i in 0 until 8) {
|
||||
add(i < playerIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
connectControllersImpl(connectedControllers.toBooleanArray())
|
||||
}
|
||||
|
||||
private external fun connectControllersImpl(connected: BooleanArray)
|
||||
|
||||
/**
|
||||
* Resets all of the button and analog mappings for a player.
|
||||
* @param playerIndex Index of the player that will have its mappings reset.
|
||||
*/
|
||||
external fun resetControllerMappings(playerIndex: Int)
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.input
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.view.InputDevice
|
||||
import androidx.annotation.Keep
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.sudachi.sudachi_emu.SudachiApplication
|
||||
|
||||
@Keep
|
||||
@Suppress("DEPRECATION")
|
||||
interface SudachiVibrator {
|
||||
fun supportsVibration(): Boolean
|
||||
|
||||
fun vibrate(intensity: Float)
|
||||
|
||||
companion object {
|
||||
fun getControllerVibrator(device: InputDevice): SudachiVibrator =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
SudachiVibratorManager(device.vibratorManager)
|
||||
} else {
|
||||
SudachiVibratorManagerCompat(device.vibrator)
|
||||
}
|
||||
|
||||
fun getSystemVibrator(): SudachiVibrator =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val vibratorManager = SudachiApplication.appContext
|
||||
.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||
SudachiVibratorManager(vibratorManager)
|
||||
} else {
|
||||
val vibrator = SudachiApplication.appContext
|
||||
.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||
SudachiVibratorManagerCompat(vibrator)
|
||||
}
|
||||
|
||||
fun getVibrationEffect(intensity: Float): VibrationEffect? {
|
||||
if (intensity > 0f) {
|
||||
return VibrationEffect.createOneShot(
|
||||
50,
|
||||
(255.0 * intensity).toInt().coerceIn(1, 255)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
class SudachiVibratorManager(private val vibratorManager: VibratorManager) : SudachiVibrator {
|
||||
override fun supportsVibration(): Boolean {
|
||||
return vibratorManager.vibratorIds.isNotEmpty()
|
||||
}
|
||||
|
||||
override fun vibrate(intensity: Float) {
|
||||
val vibration = SudachiVibrator.getVibrationEffect(intensity) ?: return
|
||||
vibratorManager.vibrate(CombinedVibration.createParallel(vibration))
|
||||
}
|
||||
}
|
||||
|
||||
class SudachiVibratorManagerCompat(private val vibrator: Vibrator) : SudachiVibrator {
|
||||
override fun supportsVibration(): Boolean {
|
||||
return vibrator.hasVibrator()
|
||||
}
|
||||
|
||||
override fun vibrate(intensity: Float) {
|
||||
val vibration = SudachiVibrator.getVibrationEffect(intensity) ?: return
|
||||
vibrator.vibrate(vibration)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.input.model
|
||||
|
||||
enum class AnalogDirection(val int: Int, val param: String) {
|
||||
Up(0, "up"),
|
||||
Down(1, "down"),
|
||||
Left(2, "left"),
|
||||
Right(3, "right")
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.input.model
|
||||
|
||||
// Loosely matches the enum in common/input.h
|
||||
enum class ButtonName(val int: Int) {
|
||||
Invalid(1),
|
||||
|
||||
// This will display the engine name instead of the button name
|
||||
Engine(2),
|
||||
|
||||
// This will display the button by value instead of the button name
|
||||
Value(3);
|
||||
|
||||
companion object {
|
||||
fun from(int: Int): ButtonName = entries.firstOrNull { it.int == int } ?: Invalid
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.input.model
|
||||
|
||||
// Must match enum in src/common/settings_input.h
|
||||
enum class NativeAnalog(val int: Int) {
|
||||
LStick(0),
|
||||
RStick(1);
|
||||
|
||||
companion object {
|
||||
fun from(int: Int): NativeAnalog = entries.firstOrNull { it.int == int } ?: LStick
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.input.model
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.sudachi.sudachi_emu.R
|
||||
|
||||
// Must match enum in src/core/hid/hid_types.h
|
||||
enum class NpadStyleIndex(val int: Int, @StringRes val nameId: Int = 0) {
|
||||
None(0),
|
||||
Fullkey(3, R.string.pro_controller),
|
||||
Handheld(4, R.string.handheld),
|
||||
HandheldNES(4),
|
||||
JoyconDual(5, R.string.dual_joycons),
|
||||
JoyconLeft(6, R.string.left_joycon),
|
||||
JoyconRight(7, R.string.right_joycon),
|
||||
GameCube(8, R.string.gamecube_controller),
|
||||
Pokeball(9),
|
||||
NES(10),
|
||||
SNES(12),
|
||||
N64(13),
|
||||
SegaGenesis(14),
|
||||
SystemExt(32),
|
||||
System(33);
|
||||
|
||||
companion object {
|
||||
fun from(int: Int): NpadStyleIndex = entries.firstOrNull { it.int == int } ?: None
|
||||
}
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.input.model
|
||||
|
||||
import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
data class PlayerInput(
|
||||
var connected: Boolean,
|
||||
var buttons: Array<String>,
|
||||
var analogs: Array<String>,
|
||||
var motions: Array<String>,
|
||||
|
||||
var vibrationEnabled: Boolean,
|
||||
var vibrationStrength: Int,
|
||||
|
||||
var bodyColorLeft: Long,
|
||||
var bodyColorRight: Long,
|
||||
var buttonColorLeft: Long,
|
||||
var buttonColorRight: Long,
|
||||
var profileName: String,
|
||||
|
||||
var useSystemVibrator: Boolean
|
||||
) {
|
||||
// It's recommended to use the generated equals() and hashCode() methods
|
||||
// when using arrays in a data class
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as PlayerInput
|
||||
|
||||
if (connected != other.connected) return false
|
||||
if (!buttons.contentEquals(other.buttons)) return false
|
||||
if (!analogs.contentEquals(other.analogs)) return false
|
||||
if (!motions.contentEquals(other.motions)) return false
|
||||
if (vibrationEnabled != other.vibrationEnabled) return false
|
||||
if (vibrationStrength != other.vibrationStrength) return false
|
||||
if (bodyColorLeft != other.bodyColorLeft) return false
|
||||
if (bodyColorRight != other.bodyColorRight) return false
|
||||
if (buttonColorLeft != other.buttonColorLeft) return false
|
||||
if (buttonColorRight != other.buttonColorRight) return false
|
||||
if (profileName != other.profileName) return false
|
||||
return useSystemVibrator == other.useSystemVibrator
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = connected.hashCode()
|
||||
result = 31 * result + buttons.contentHashCode()
|
||||
result = 31 * result + analogs.contentHashCode()
|
||||
result = 31 * result + motions.contentHashCode()
|
||||
result = 31 * result + vibrationEnabled.hashCode()
|
||||
result = 31 * result + vibrationStrength
|
||||
result = 31 * result + bodyColorLeft.hashCode()
|
||||
result = 31 * result + bodyColorRight.hashCode()
|
||||
result = 31 * result + buttonColorLeft.hashCode()
|
||||
result = 31 * result + buttonColorRight.hashCode()
|
||||
result = 31 * result + profileName.hashCode()
|
||||
result = 31 * result + useSystemVibrator.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
fun hasMapping(): Boolean {
|
||||
var hasMapping = false
|
||||
buttons.forEach {
|
||||
if (it != "[empty]" && it.isNotEmpty()) {
|
||||
hasMapping = true
|
||||
}
|
||||
}
|
||||
analogs.forEach {
|
||||
if (it != "[empty]" && it.isNotEmpty()) {
|
||||
hasMapping = true
|
||||
}
|
||||
}
|
||||
motions.forEach {
|
||||
if (it != "[empty]" && it.isNotEmpty()) {
|
||||
hasMapping = true
|
||||
}
|
||||
}
|
||||
return hasMapping
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.model
|
||||
|
||||
interface AbstractFloatSetting : AbstractSetting {
|
||||
fun getFloat(needsGlobal: Boolean = false): Float
|
||||
fun setFloat(value: Float)
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.model
|
||||
|
||||
interface AbstractIntSetting : AbstractSetting {
|
||||
fun getInt(needsGlobal: Boolean = false): Int
|
||||
fun setInt(value: Int)
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.model
|
||||
|
||||
interface AbstractStringSetting : AbstractSetting {
|
||||
fun getString(needsGlobal: Boolean = false): String
|
||||
fun setString(value: String)
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.model
|
||||
|
||||
import org.sudachi.sudachi_emu.utils.NativeConfig
|
||||
|
||||
enum class ByteSetting(override val key: String) : AbstractByteSetting {
|
||||
AUDIO_VOLUME("volume");
|
||||
|
||||
override fun getByte(needsGlobal: Boolean): Byte = NativeConfig.getByte(key, needsGlobal)
|
||||
|
||||
override fun setByte(value: Byte) {
|
||||
if (NativeConfig.isPerGameConfigLoaded()) {
|
||||
global = false
|
||||
}
|
||||
NativeConfig.setByte(key, value)
|
||||
}
|
||||
|
||||
override val defaultValue: Byte by lazy { NativeConfig.getDefaultToString(key).toByte() }
|
||||
|
||||
override fun getValueAsString(needsGlobal: Boolean): String = getByte(needsGlobal).toString()
|
||||
|
||||
override fun reset() = NativeConfig.setByte(key, defaultValue)
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.model
|
||||
|
||||
import org.sudachi.sudachi_emu.utils.NativeConfig
|
||||
|
||||
enum class FloatSetting(override val key: String) : AbstractFloatSetting {
|
||||
// No float settings currently exist
|
||||
EMPTY_SETTING("");
|
||||
|
||||
override fun getFloat(needsGlobal: Boolean): Float = NativeConfig.getFloat(key, false)
|
||||
|
||||
override fun setFloat(value: Float) {
|
||||
if (NativeConfig.isPerGameConfigLoaded()) {
|
||||
global = false
|
||||
}
|
||||
NativeConfig.setFloat(key, value)
|
||||
}
|
||||
|
||||
override val defaultValue: Float by lazy { NativeConfig.getDefaultToString(key).toFloat() }
|
||||
|
||||
override fun getValueAsString(needsGlobal: Boolean): String = getFloat(needsGlobal).toString()
|
||||
|
||||
override fun reset() = NativeConfig.setFloat(key, defaultValue)
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.model
|
||||
|
||||
import org.sudachi.sudachi_emu.utils.NativeConfig
|
||||
|
||||
enum class LongSetting(override val key: String) : AbstractLongSetting {
|
||||
CUSTOM_RTC("custom_rtc");
|
||||
|
||||
override fun getLong(needsGlobal: Boolean): Long = NativeConfig.getLong(key, needsGlobal)
|
||||
|
||||
override fun setLong(value: Long) {
|
||||
if (NativeConfig.isPerGameConfigLoaded()) {
|
||||
global = false
|
||||
}
|
||||
NativeConfig.setLong(key, value)
|
||||
}
|
||||
|
||||
override val defaultValue: Long by lazy { NativeConfig.getDefaultToString(key).toLong() }
|
||||
|
||||
override fun getValueAsString(needsGlobal: Boolean): String = getLong(needsGlobal).toString()
|
||||
|
||||
override fun reset() = NativeConfig.setLong(key, defaultValue)
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.model
|
||||
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.SudachiApplication
|
||||
|
||||
object Settings {
|
||||
enum class MenuTag(val titleId: Int = 0) {
|
||||
SECTION_ROOT(R.string.advanced_settings),
|
||||
SECTION_SYSTEM(R.string.preferences_system),
|
||||
SECTION_RENDERER(R.string.preferences_graphics),
|
||||
SECTION_AUDIO(R.string.preferences_audio),
|
||||
SECTION_INPUT(R.string.preferences_controls),
|
||||
SECTION_INPUT_PLAYER_ONE,
|
||||
SECTION_INPUT_PLAYER_TWO,
|
||||
SECTION_INPUT_PLAYER_THREE,
|
||||
SECTION_INPUT_PLAYER_FOUR,
|
||||
SECTION_INPUT_PLAYER_FIVE,
|
||||
SECTION_INPUT_PLAYER_SIX,
|
||||
SECTION_INPUT_PLAYER_SEVEN,
|
||||
SECTION_INPUT_PLAYER_EIGHT,
|
||||
SECTION_THEME(R.string.preferences_theme),
|
||||
SECTION_DEBUG(R.string.preferences_debug);
|
||||
}
|
||||
|
||||
fun getPlayerString(player: Int): String =
|
||||
SudachiApplication.appContext.getString(R.string.preferences_player, player)
|
||||
|
||||
const val PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch"
|
||||
const val PREF_MEMORY_WARNING_SHOWN = "MemoryWarningShown"
|
||||
|
||||
// Deprecated input overlay preference keys
|
||||
const val PREF_CONTROL_SCALE = "controlScale"
|
||||
const val PREF_CONTROL_OPACITY = "controlOpacity"
|
||||
const val PREF_TOUCH_ENABLED = "isTouchEnabled"
|
||||
const val PREF_BUTTON_A = "buttonToggle0"
|
||||
const val PREF_BUTTON_B = "buttonToggle1"
|
||||
const val PREF_BUTTON_X = "buttonToggle2"
|
||||
const val PREF_BUTTON_Y = "buttonToggle3"
|
||||
const val PREF_BUTTON_L = "buttonToggle4"
|
||||
const val PREF_BUTTON_R = "buttonToggle5"
|
||||
const val PREF_BUTTON_ZL = "buttonToggle6"
|
||||
const val PREF_BUTTON_ZR = "buttonToggle7"
|
||||
const val PREF_BUTTON_PLUS = "buttonToggle8"
|
||||
const val PREF_BUTTON_MINUS = "buttonToggle9"
|
||||
const val PREF_BUTTON_DPAD = "buttonToggle10"
|
||||
const val PREF_STICK_L = "buttonToggle11"
|
||||
const val PREF_STICK_R = "buttonToggle12"
|
||||
const val PREF_BUTTON_STICK_L = "buttonToggle13"
|
||||
const val PREF_BUTTON_STICK_R = "buttonToggle14"
|
||||
const val PREF_BUTTON_HOME = "buttonToggle15"
|
||||
const val PREF_BUTTON_SCREENSHOT = "buttonToggle16"
|
||||
const val PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER = "EmulationMenuSettings_JoystickRelCenter"
|
||||
const val PREF_MENU_SETTINGS_DPAD_SLIDE = "EmulationMenuSettings_DpadSlideEnable"
|
||||
const val PREF_MENU_SETTINGS_HAPTICS = "EmulationMenuSettings_Haptics"
|
||||
const val PREF_MENU_SETTINGS_SHOW_FPS = "EmulationMenuSettings_ShowFps"
|
||||
const val PREF_MENU_SETTINGS_SHOW_OVERLAY = "EmulationMenuSettings_ShowOverlay"
|
||||
val overlayPreferences = listOf(
|
||||
PREF_BUTTON_A,
|
||||
PREF_BUTTON_B,
|
||||
PREF_BUTTON_X,
|
||||
PREF_BUTTON_Y,
|
||||
PREF_BUTTON_L,
|
||||
PREF_BUTTON_R,
|
||||
PREF_BUTTON_ZL,
|
||||
PREF_BUTTON_ZR,
|
||||
PREF_BUTTON_PLUS,
|
||||
PREF_BUTTON_MINUS,
|
||||
PREF_BUTTON_DPAD,
|
||||
PREF_STICK_L,
|
||||
PREF_STICK_R,
|
||||
PREF_BUTTON_HOME,
|
||||
PREF_BUTTON_SCREENSHOT,
|
||||
PREF_BUTTON_STICK_L,
|
||||
PREF_BUTTON_STICK_R
|
||||
)
|
||||
|
||||
// Deprecated layout preference keys
|
||||
const val PREF_LANDSCAPE_SUFFIX = "_Landscape"
|
||||
const val PREF_PORTRAIT_SUFFIX = "_Portrait"
|
||||
const val PREF_FOLDABLE_SUFFIX = "_Foldable"
|
||||
val overlayLayoutSuffixes = listOf(
|
||||
PREF_LANDSCAPE_SUFFIX,
|
||||
PREF_PORTRAIT_SUFFIX,
|
||||
PREF_FOLDABLE_SUFFIX
|
||||
)
|
||||
|
||||
// Deprecated theme preference keys
|
||||
const val PREF_THEME = "Theme"
|
||||
const val PREF_THEME_MODE = "ThemeMode"
|
||||
const val PREF_BLACK_BACKGROUNDS = "BlackBackgrounds"
|
||||
|
||||
enum class EmulationOrientation(val int: Int) {
|
||||
Unspecified(0),
|
||||
SensorLandscape(5),
|
||||
Landscape(1),
|
||||
ReverseLandscape(2),
|
||||
SensorPortrait(6),
|
||||
Portrait(4),
|
||||
ReversePortrait(3);
|
||||
|
||||
companion object {
|
||||
fun from(int: Int): EmulationOrientation =
|
||||
entries.firstOrNull { it.int == int } ?: Unspecified
|
||||
}
|
||||
}
|
||||
|
||||
enum class EmulationVerticalAlignment(val int: Int) {
|
||||
Top(1),
|
||||
Center(0),
|
||||
Bottom(2);
|
||||
|
||||
companion object {
|
||||
fun from(int: Int): EmulationVerticalAlignment =
|
||||
entries.firstOrNull { it.int == int } ?: Center
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.model
|
||||
|
||||
import org.sudachi.sudachi_emu.utils.NativeConfig
|
||||
|
||||
enum class StringSetting(override val key: String) : AbstractStringSetting {
|
||||
DRIVER_PATH("driver_path"),
|
||||
DEVICE_NAME("device_name");
|
||||
|
||||
override fun getString(needsGlobal: Boolean): String = NativeConfig.getString(key, needsGlobal)
|
||||
|
||||
override fun setString(value: String) {
|
||||
if (NativeConfig.isPerGameConfigLoaded()) {
|
||||
global = false
|
||||
}
|
||||
NativeConfig.setString(key, value)
|
||||
}
|
||||
|
||||
override val defaultValue: String by lazy { NativeConfig.getDefaultToString(key) }
|
||||
|
||||
override fun getValueAsString(needsGlobal: Boolean): String = getString(needsGlobal)
|
||||
|
||||
override fun reset() = NativeConfig.setString(key, defaultValue)
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.model.view
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.sudachi.sudachi_emu.features.input.NativeInput
|
||||
import org.sudachi.sudachi_emu.features.input.model.AnalogDirection
|
||||
import org.sudachi.sudachi_emu.features.input.model.InputType
|
||||
import org.sudachi.sudachi_emu.features.input.model.NativeAnalog
|
||||
import org.sudachi.sudachi_emu.utils.ParamPackage
|
||||
|
||||
class AnalogInputSetting(
|
||||
override val playerIndex: Int,
|
||||
val nativeAnalog: NativeAnalog,
|
||||
val analogDirection: AnalogDirection,
|
||||
@StringRes titleId: Int = 0,
|
||||
titleString: String = ""
|
||||
) : InputSetting(titleId, titleString) {
|
||||
override val type = TYPE_INPUT
|
||||
override val inputType = InputType.Stick
|
||||
|
||||
override fun getSelectedValue(): String {
|
||||
val params = NativeInput.getStickParam(playerIndex, nativeAnalog)
|
||||
val analog = analogToText(params, analogDirection.param)
|
||||
return getDisplayString(params, analog)
|
||||
}
|
||||
|
||||
override fun setSelectedValue(param: ParamPackage) =
|
||||
NativeInput.setStickParam(playerIndex, nativeAnalog, param)
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.model.view
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.sudachi.sudachi_emu.features.settings.model.AbstractLongSetting
|
||||
|
||||
class DateTimeSetting(
|
||||
private val longSetting: AbstractLongSetting,
|
||||
@StringRes titleId: Int = 0,
|
||||
titleString: String = "",
|
||||
@StringRes descriptionId: Int = 0,
|
||||
descriptionString: String = ""
|
||||
) : SettingsItem(longSetting, titleId, titleString, descriptionId, descriptionString) {
|
||||
override val type = TYPE_DATETIME_SETTING
|
||||
|
||||
fun getValue(needsGlobal: Boolean = false): Long = longSetting.getLong(needsGlobal)
|
||||
fun setValue(value: Long) = (setting as AbstractLongSetting).setLong(value)
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.model.view
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.SudachiApplication
|
||||
import org.sudachi.sudachi_emu.features.input.NativeInput
|
||||
import org.sudachi.sudachi_emu.features.input.model.ButtonName
|
||||
import org.sudachi.sudachi_emu.features.input.model.InputType
|
||||
import org.sudachi.sudachi_emu.utils.ParamPackage
|
||||
|
||||
sealed class InputSetting(
|
||||
@StringRes titleId: Int,
|
||||
titleString: String
|
||||
) : SettingsItem(emptySetting, titleId, titleString, 0, "") {
|
||||
override val type = TYPE_INPUT
|
||||
abstract val inputType: InputType
|
||||
abstract val playerIndex: Int
|
||||
|
||||
protected val context get() = SudachiApplication.appContext
|
||||
|
||||
abstract fun getSelectedValue(): String
|
||||
|
||||
abstract fun setSelectedValue(param: ParamPackage)
|
||||
|
||||
protected fun getDisplayString(params: ParamPackage, control: String): String {
|
||||
val deviceName = params.get("display", "")
|
||||
deviceName.ifEmpty {
|
||||
return context.getString(R.string.not_set)
|
||||
}
|
||||
return "$deviceName: $control"
|
||||
}
|
||||
|
||||
private fun getDirectionName(direction: String): String =
|
||||
when (direction) {
|
||||
"up" -> context.getString(R.string.up)
|
||||
"down" -> context.getString(R.string.down)
|
||||
"left" -> context.getString(R.string.left)
|
||||
"right" -> context.getString(R.string.right)
|
||||
else -> direction
|
||||
}
|
||||
|
||||
protected fun buttonToText(param: ParamPackage): String {
|
||||
if (!param.has("engine")) {
|
||||
return context.getString(R.string.not_set)
|
||||
}
|
||||
|
||||
val toggle = if (param.get("toggle", false)) "~" else ""
|
||||
val inverted = if (param.get("inverted", false)) "!" else ""
|
||||
val invert = if (param.get("invert", "+") == "-") "-" else ""
|
||||
val turbo = if (param.get("turbo", false)) "$" else ""
|
||||
val commonButtonName = NativeInput.getButtonName(param)
|
||||
|
||||
if (commonButtonName == ButtonName.Invalid) {
|
||||
return context.getString(R.string.invalid)
|
||||
}
|
||||
|
||||
if (commonButtonName == ButtonName.Engine) {
|
||||
return param.get("engine", "")
|
||||
}
|
||||
|
||||
if (commonButtonName == ButtonName.Value) {
|
||||
if (param.has("hat")) {
|
||||
val hat = getDirectionName(param.get("direction", ""))
|
||||
return context.getString(R.string.qualified_hat, turbo, toggle, inverted, hat)
|
||||
}
|
||||
if (param.has("axis")) {
|
||||
val axis = param.get("axis", "")
|
||||
return context.getString(
|
||||
R.string.qualified_button_stick_axis,
|
||||
toggle,
|
||||
inverted,
|
||||
invert,
|
||||
axis
|
||||
)
|
||||
}
|
||||
if (param.has("button")) {
|
||||
val button = param.get("button", "")
|
||||
return context.getString(R.string.qualified_button, turbo, toggle, inverted, button)
|
||||
}
|
||||
}
|
||||
|
||||
return context.getString(R.string.unknown)
|
||||
}
|
||||
|
||||
protected fun analogToText(param: ParamPackage, direction: String): String {
|
||||
if (!param.has("engine")) {
|
||||
return context.getString(R.string.not_set)
|
||||
}
|
||||
|
||||
if (param.get("engine", "") == "analog_from_button") {
|
||||
return buttonToText(ParamPackage(param.get(direction, "")))
|
||||
}
|
||||
|
||||
if (!param.has("axis_x") || !param.has("axis_y")) {
|
||||
return context.getString(R.string.unknown)
|
||||
}
|
||||
|
||||
val xAxis = param.get("axis_x", "")
|
||||
val yAxis = param.get("axis_y", "")
|
||||
val xInvert = param.get("invert_x", "+") == "-"
|
||||
val yInvert = param.get("invert_y", "+") == "-"
|
||||
|
||||
if (direction == "modifier") {
|
||||
return context.getString(R.string.unused)
|
||||
}
|
||||
|
||||
when (direction) {
|
||||
"up" -> {
|
||||
val yInvertString = if (yInvert) "+" else "-"
|
||||
return context.getString(R.string.qualified_axis, yAxis, yInvertString)
|
||||
}
|
||||
|
||||
"down" -> {
|
||||
val yInvertString = if (yInvert) "-" else "+"
|
||||
return context.getString(R.string.qualified_axis, yAxis, yInvertString)
|
||||
}
|
||||
|
||||
"left" -> {
|
||||
val xInvertString = if (xInvert) "+" else "-"
|
||||
return context.getString(R.string.qualified_axis, xAxis, xInvertString)
|
||||
}
|
||||
|
||||
"right" -> {
|
||||
val xInvertString = if (xInvert) "-" else "+"
|
||||
return context.getString(R.string.qualified_axis, xAxis, xInvertString)
|
||||
}
|
||||
}
|
||||
|
||||
return context.getString(R.string.unknown)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.model.view
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.sudachi.sudachi_emu.features.input.NativeInput
|
||||
import org.sudachi.sudachi_emu.features.input.model.InputType
|
||||
import org.sudachi.sudachi_emu.features.input.model.NativeAnalog
|
||||
import org.sudachi.sudachi_emu.utils.ParamPackage
|
||||
|
||||
class ModifierInputSetting(
|
||||
override val playerIndex: Int,
|
||||
val nativeAnalog: NativeAnalog,
|
||||
@StringRes titleId: Int = 0,
|
||||
titleString: String = ""
|
||||
) : InputSetting(titleId, titleString) {
|
||||
override val inputType = InputType.Button
|
||||
|
||||
override fun getSelectedValue(): String {
|
||||
val analogParam = NativeInput.getStickParam(playerIndex, nativeAnalog)
|
||||
val modifierParam = ParamPackage(analogParam.get("modifier", ""))
|
||||
return buttonToText(modifierParam)
|
||||
}
|
||||
|
||||
override fun setSelectedValue(param: ParamPackage) {
|
||||
val newParam = NativeInput.getStickParam(playerIndex, nativeAnalog)
|
||||
newParam.set("modifier", param.serialize())
|
||||
NativeInput.setStickParam(playerIndex, nativeAnalog, newParam)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.model.view
|
||||
|
||||
import androidx.annotation.ArrayRes
|
||||
import androidx.annotation.StringRes
|
||||
import org.sudachi.sudachi_emu.features.settings.model.AbstractIntSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.model.AbstractSetting
|
||||
|
||||
class SingleChoiceSetting(
|
||||
setting: AbstractSetting,
|
||||
@StringRes titleId: Int = 0,
|
||||
titleString: String = "",
|
||||
@StringRes descriptionId: Int = 0,
|
||||
descriptionString: String = "",
|
||||
@ArrayRes val choicesId: Int,
|
||||
@ArrayRes val valuesId: Int
|
||||
) : SettingsItem(setting, titleId, titleString, descriptionId, descriptionString) {
|
||||
override val type = TYPE_SINGLE_CHOICE
|
||||
|
||||
fun getSelectedValue(needsGlobal: Boolean = false) =
|
||||
when (setting) {
|
||||
is AbstractIntSetting -> setting.getInt(needsGlobal)
|
||||
else -> -1
|
||||
}
|
||||
|
||||
fun setSelectedValue(value: Int) = (setting as AbstractIntSetting).setInt(value)
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.model.view
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.sudachi.sudachi_emu.features.settings.model.AbstractStringSetting
|
||||
|
||||
class StringInputSetting(
|
||||
setting: AbstractStringSetting,
|
||||
@StringRes titleId: Int = 0,
|
||||
titleString: String = "",
|
||||
@StringRes descriptionId: Int = 0,
|
||||
descriptionString: String = ""
|
||||
) : SettingsItem(setting, titleId, titleString, descriptionId, descriptionString) {
|
||||
override val type = TYPE_STRING_INPUT
|
||||
|
||||
fun getSelectedValue(needsGlobal: Boolean = false) = setting.getValueAsString(needsGlobal)
|
||||
|
||||
fun setSelectedValue(selection: String) =
|
||||
(setting as AbstractStringSetting).setString(selection)
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.model.view
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.sudachi.sudachi_emu.features.settings.model.AbstractBooleanSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.model.AbstractIntSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.model.AbstractSetting
|
||||
|
||||
class SwitchSetting(
|
||||
setting: AbstractSetting,
|
||||
@StringRes titleId: Int = 0,
|
||||
titleString: String = "",
|
||||
@StringRes descriptionId: Int = 0,
|
||||
descriptionString: String = ""
|
||||
) : SettingsItem(setting, titleId, titleString, descriptionId, descriptionString) {
|
||||
override val type = TYPE_SWITCH
|
||||
|
||||
fun getIsChecked(needsGlobal: Boolean = false): Boolean {
|
||||
return when (setting) {
|
||||
is AbstractIntSetting -> setting.getInt(needsGlobal) == 1
|
||||
is AbstractBooleanSetting -> setting.getBoolean(needsGlobal)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun setChecked(value: Boolean) {
|
||||
when (setting) {
|
||||
is AbstractIntSetting -> setting.setInt(if (value) 1 else 0)
|
||||
is AbstractBooleanSetting -> setting.setBoolean(value)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,300 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.ui
|
||||
|
||||
import android.app.Dialog
|
||||
import android.graphics.drawable.Animatable2
|
||||
import android.graphics.drawable.AnimatedVectorDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.databinding.DialogMappingBinding
|
||||
import org.sudachi.sudachi_emu.features.input.NativeInput
|
||||
import org.sudachi.sudachi_emu.features.input.model.NativeAnalog
|
||||
import org.sudachi.sudachi_emu.features.input.model.NativeButton
|
||||
import org.sudachi.sudachi_emu.features.settings.model.view.AnalogInputSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.model.view.ButtonInputSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.model.view.InputSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.model.view.ModifierInputSetting
|
||||
import org.sudachi.sudachi_emu.utils.InputHandler
|
||||
import org.sudachi.sudachi_emu.utils.ParamPackage
|
||||
|
||||
class InputDialogFragment : DialogFragment() {
|
||||
private var inputAccepted = false
|
||||
|
||||
private var position: Int = 0
|
||||
|
||||
private lateinit var inputSetting: InputSetting
|
||||
|
||||
private lateinit var binding: DialogMappingBinding
|
||||
|
||||
private val settingsViewModel: SettingsViewModel by activityViewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (settingsViewModel.clickedItem == null) dismiss()
|
||||
|
||||
position = requireArguments().getInt(POSITION)
|
||||
|
||||
InputHandler.updateControllerData()
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
inputSetting = settingsViewModel.clickedItem as InputSetting
|
||||
binding = DialogMappingBinding.inflate(layoutInflater)
|
||||
|
||||
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||
.setPositiveButton(android.R.string.cancel) { _, _ ->
|
||||
NativeInput.stopMapping()
|
||||
dismiss()
|
||||
}
|
||||
.setView(binding.root)
|
||||
|
||||
val playButtonMapAnimation = { twoDirections: Boolean ->
|
||||
val stickAnimation: AnimatedVectorDrawable
|
||||
val buttonAnimation: AnimatedVectorDrawable
|
||||
binding.imageStickAnimation.apply {
|
||||
val anim = if (twoDirections) {
|
||||
R.drawable.stick_two_direction_anim
|
||||
} else {
|
||||
R.drawable.stick_one_direction_anim
|
||||
}
|
||||
setBackgroundResource(anim)
|
||||
stickAnimation = background as AnimatedVectorDrawable
|
||||
}
|
||||
binding.imageButtonAnimation.apply {
|
||||
setBackgroundResource(R.drawable.button_anim)
|
||||
buttonAnimation = background as AnimatedVectorDrawable
|
||||
}
|
||||
stickAnimation.registerAnimationCallback(object : Animatable2.AnimationCallback() {
|
||||
override fun onAnimationEnd(drawable: Drawable?) {
|
||||
buttonAnimation.start()
|
||||
}
|
||||
})
|
||||
buttonAnimation.registerAnimationCallback(object : Animatable2.AnimationCallback() {
|
||||
override fun onAnimationEnd(drawable: Drawable?) {
|
||||
stickAnimation.start()
|
||||
}
|
||||
})
|
||||
stickAnimation.start()
|
||||
}
|
||||
|
||||
when (val setting = inputSetting) {
|
||||
is AnalogInputSetting -> {
|
||||
when (setting.nativeAnalog) {
|
||||
NativeAnalog.LStick -> builder.setTitle(
|
||||
getString(R.string.map_control, getString(R.string.left_stick))
|
||||
)
|
||||
|
||||
NativeAnalog.RStick -> builder.setTitle(
|
||||
getString(R.string.map_control, getString(R.string.right_stick))
|
||||
)
|
||||
}
|
||||
|
||||
builder.setMessage(R.string.stick_map_description)
|
||||
|
||||
playButtonMapAnimation.invoke(true)
|
||||
}
|
||||
|
||||
is ModifierInputSetting -> {
|
||||
builder.setTitle(getString(R.string.map_control, setting.title))
|
||||
.setMessage(R.string.button_map_description)
|
||||
playButtonMapAnimation.invoke(false)
|
||||
}
|
||||
|
||||
is ButtonInputSetting -> {
|
||||
if (setting.nativeButton == NativeButton.DUp ||
|
||||
setting.nativeButton == NativeButton.DDown ||
|
||||
setting.nativeButton == NativeButton.DLeft ||
|
||||
setting.nativeButton == NativeButton.DRight
|
||||
) {
|
||||
builder.setTitle(getString(R.string.map_dpad_direction, setting.title))
|
||||
} else {
|
||||
builder.setTitle(getString(R.string.map_control, setting.title))
|
||||
}
|
||||
builder.setMessage(R.string.button_map_description)
|
||||
playButtonMapAnimation.invoke(false)
|
||||
}
|
||||
}
|
||||
|
||||
return builder.create()
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
view.requestFocus()
|
||||
view.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus) v.requestFocus() }
|
||||
dialog?.setOnKeyListener { _, _, keyEvent -> onKeyEvent(keyEvent) }
|
||||
binding.root.setOnGenericMotionListener { _, motionEvent -> onMotionEvent(motionEvent) }
|
||||
NativeInput.beginMapping(inputSetting.inputType.int)
|
||||
}
|
||||
|
||||
private fun onKeyEvent(event: KeyEvent): Boolean {
|
||||
if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK &&
|
||||
event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
val action = when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> NativeInput.ButtonState.PRESSED
|
||||
KeyEvent.ACTION_UP -> NativeInput.ButtonState.RELEASED
|
||||
else -> return false
|
||||
}
|
||||
val controllerData =
|
||||
InputHandler.androidControllers[event.device.controllerNumber] ?: return false
|
||||
NativeInput.onGamePadButtonEvent(
|
||||
controllerData.getGUID(),
|
||||
controllerData.getPort(),
|
||||
event.keyCode,
|
||||
action
|
||||
)
|
||||
onInputReceived(event.device)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onMotionEvent(event: MotionEvent): Boolean {
|
||||
if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK &&
|
||||
event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Temp workaround for DPads that give both axis and button input. The input system can't
|
||||
// take in a specific axis direction for a binding so you lose half of the directions for a DPad.
|
||||
|
||||
val controllerData =
|
||||
InputHandler.androidControllers[event.device.controllerNumber] ?: return false
|
||||
event.device.motionRanges.forEach {
|
||||
NativeInput.onGamePadAxisEvent(
|
||||
controllerData.getGUID(),
|
||||
controllerData.getPort(),
|
||||
it.axis,
|
||||
event.getAxisValue(it.axis)
|
||||
)
|
||||
onInputReceived(event.device)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onInputReceived(device: InputDevice) {
|
||||
val params = ParamPackage(NativeInput.getNextInput())
|
||||
if (params.has("engine") && isInputAcceptable(params) && !inputAccepted) {
|
||||
inputAccepted = true
|
||||
setResult(params, device)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setResult(params: ParamPackage, device: InputDevice) {
|
||||
NativeInput.stopMapping()
|
||||
params.set("display", "${device.name} ${params.get("port", 0)}")
|
||||
when (val item = settingsViewModel.clickedItem as InputSetting) {
|
||||
is ModifierInputSetting,
|
||||
is ButtonInputSetting -> {
|
||||
// Invert DPad up and left bindings by default
|
||||
val tempSetting = inputSetting as? ButtonInputSetting
|
||||
if (tempSetting != null) {
|
||||
if (tempSetting.nativeButton == NativeButton.DUp ||
|
||||
tempSetting.nativeButton == NativeButton.DLeft &&
|
||||
params.has("axis")
|
||||
) {
|
||||
params.set("invert", "-")
|
||||
}
|
||||
}
|
||||
|
||||
item.setSelectedValue(params)
|
||||
settingsViewModel.setAdapterItemChanged(position)
|
||||
}
|
||||
|
||||
is AnalogInputSetting -> {
|
||||
var analogParam = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog)
|
||||
analogParam = adjustAnalogParam(params, analogParam, item.analogDirection.param)
|
||||
|
||||
// Invert Y-Axis by default
|
||||
analogParam.set("invert_y", "-")
|
||||
|
||||
item.setSelectedValue(analogParam)
|
||||
settingsViewModel.setReloadListAndNotifyDataset(true)
|
||||
}
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private fun adjustAnalogParam(
|
||||
inputParam: ParamPackage,
|
||||
analogParam: ParamPackage,
|
||||
buttonName: String
|
||||
): ParamPackage {
|
||||
// The poller returned a complete axis, so set all the buttons
|
||||
if (inputParam.has("axis_x") && inputParam.has("axis_y")) {
|
||||
return inputParam
|
||||
}
|
||||
|
||||
// Check if the current configuration has either no engine or an axis binding.
|
||||
// Clears out the old binding and adds one with analog_from_button.
|
||||
if (!analogParam.has("engine") || analogParam.has("axis_x") || analogParam.has("axis_y")) {
|
||||
analogParam.clear()
|
||||
analogParam.set("engine", "analog_from_button")
|
||||
}
|
||||
analogParam.set(buttonName, inputParam.serialize())
|
||||
return analogParam
|
||||
}
|
||||
|
||||
private fun isInputAcceptable(params: ParamPackage): Boolean {
|
||||
if (InputHandler.registeredControllers.size == 1) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (params.has("motion")) {
|
||||
return true
|
||||
}
|
||||
|
||||
val currentDevice = settingsViewModel.getCurrentDeviceParams(params)
|
||||
if (currentDevice.get("engine", "any") == "any") {
|
||||
return true
|
||||
}
|
||||
|
||||
val guidMatch = params.get("guid", "") == currentDevice.get("guid", "") ||
|
||||
params.get("guid", "") == currentDevice.get("guid2", "")
|
||||
return params.get("engine", "") == currentDevice.get("engine", "") &&
|
||||
guidMatch &&
|
||||
params.get("port", 0) == currentDevice.get("port", 0)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "InputDialogFragment"
|
||||
|
||||
const val POSITION = "Position"
|
||||
|
||||
fun newInstance(
|
||||
inputMappingViewModel: SettingsViewModel,
|
||||
setting: InputSetting,
|
||||
position: Int
|
||||
): InputDialogFragment {
|
||||
inputMappingViewModel.clickedItem = setting
|
||||
val args = Bundle()
|
||||
args.putInt(POSITION, position)
|
||||
val fragment = InputDialogFragment()
|
||||
fragment.arguments = args
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,171 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup.MarginLayoutParams
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.navArgs
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import org.sudachi.sudachi_emu.NativeLibrary
|
||||
import java.io.IOException
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.databinding.ActivitySettingsBinding
|
||||
import org.sudachi.sudachi_emu.features.input.NativeInput
|
||||
import org.sudachi.sudachi_emu.features.settings.utils.SettingsFile
|
||||
import org.sudachi.sudachi_emu.fragments.ResetSettingsDialogFragment
|
||||
import org.sudachi.sudachi_emu.utils.*
|
||||
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
private lateinit var binding: ActivitySettingsBinding
|
||||
|
||||
private val args by navArgs<SettingsActivityArgs>()
|
||||
|
||||
private val settingsViewModel: SettingsViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
ThemeHelper.setTheme(this)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
binding = ActivitySettingsBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
if (!NativeConfig.isPerGameConfigLoaded() && args.game != null) {
|
||||
SettingsFile.loadCustomConfig(args.game!!)
|
||||
}
|
||||
settingsViewModel.game = args.game
|
||||
|
||||
val navHostFragment =
|
||||
supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment
|
||||
navHostFragment.navController.setGraph(R.navigation.settings_navigation, intent.extras)
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
if (InsetsHelper.getSystemGestureType(applicationContext) !=
|
||||
InsetsHelper.GESTURE_NAVIGATION
|
||||
) {
|
||||
binding.navigationBarShade.setBackgroundColor(
|
||||
ThemeHelper.getColorWithOpacity(
|
||||
MaterialColors.getColor(
|
||||
binding.navigationBarShade,
|
||||
com.google.android.material.R.attr.colorSurface
|
||||
),
|
||||
ThemeHelper.SYSTEM_BAR_ALPHA
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
settingsViewModel.shouldRecreate.collect(
|
||||
this,
|
||||
resetState = { settingsViewModel.setShouldRecreate(false) }
|
||||
) { if (it) recreate() }
|
||||
settingsViewModel.shouldNavigateBack.collect(
|
||||
this,
|
||||
resetState = { settingsViewModel.setShouldNavigateBack(false) }
|
||||
) { if (it) navigateBack() }
|
||||
settingsViewModel.shouldShowResetSettingsDialog.collect(
|
||||
this,
|
||||
resetState = { settingsViewModel.setShouldShowResetSettingsDialog(false) }
|
||||
) {
|
||||
if (it) {
|
||||
ResetSettingsDialogFragment().show(
|
||||
supportFragmentManager,
|
||||
ResetSettingsDialogFragment.TAG
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onBackPressedDispatcher.addCallback(
|
||||
this,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() = navigateBack()
|
||||
}
|
||||
)
|
||||
|
||||
setInsets()
|
||||
}
|
||||
|
||||
fun navigateBack() {
|
||||
val navHostFragment =
|
||||
supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment
|
||||
if (navHostFragment.childFragmentManager.backStackEntryCount > 0) {
|
||||
navHostFragment.navController.popBackStack()
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (!DirectoryInitialization.areDirectoriesReady) {
|
||||
DirectoryInitialization.start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
Log.info("[SettingsActivity] Settings activity stopping. Saving settings to INI...")
|
||||
if (isFinishing) {
|
||||
NativeInput.reloadInputDevices()
|
||||
NativeLibrary.applySettings()
|
||||
if (args.game == null) {
|
||||
NativeConfig.saveGlobalConfig()
|
||||
} else if (NativeConfig.isPerGameConfigLoaded()) {
|
||||
NativeLibrary.logSettings()
|
||||
NativeConfig.savePerGameConfig()
|
||||
NativeConfig.unloadPerGameConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSettingsReset() {
|
||||
// Delete settings file because the user may have changed values that do not exist in the UI
|
||||
if (args.game == null) {
|
||||
NativeConfig.unloadGlobalConfig()
|
||||
val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG)
|
||||
if (!settingsFile.delete()) {
|
||||
throw IOException("Failed to delete $settingsFile")
|
||||
}
|
||||
NativeConfig.initializeGlobalConfig()
|
||||
} else {
|
||||
NativeConfig.unloadPerGameConfig()
|
||||
val settingsFile = SettingsFile.getCustomSettingsFile(args.game!!)
|
||||
if (!settingsFile.delete()) {
|
||||
throw IOException("Failed to delete $settingsFile")
|
||||
}
|
||||
}
|
||||
|
||||
Toast.makeText(
|
||||
applicationContext,
|
||||
getString(R.string.settings_reset),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun setInsets() {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(
|
||||
binding.navigationBarShade
|
||||
) { _: View, windowInsets: WindowInsetsCompat ->
|
||||
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
|
||||
// The only situation where we care to have a nav bar shade is when it's at the bottom
|
||||
// of the screen where scrolling list elements can go behind it.
|
||||
val mlpNavShade = binding.navigationBarShade.layoutParams as MarginLayoutParams
|
||||
mlpNavShade.height = barInsets.bottom
|
||||
binding.navigationBarShade.layoutParams = mlpNavShade
|
||||
|
||||
windowInsets
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,975 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import org.sudachi.sudachi_emu.NativeLibrary
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.SudachiApplication
|
||||
import org.sudachi.sudachi_emu.features.input.NativeInput
|
||||
import org.sudachi.sudachi_emu.features.input.model.AnalogDirection
|
||||
import org.sudachi.sudachi_emu.features.input.model.NativeAnalog
|
||||
import org.sudachi.sudachi_emu.features.input.model.NativeButton
|
||||
import org.sudachi.sudachi_emu.features.input.model.NpadStyleIndex
|
||||
import org.sudachi.sudachi_emu.features.settings.model.AbstractBooleanSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.model.AbstractIntSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.model.BooleanSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.model.ByteSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.model.IntSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.model.LongSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.model.Settings
|
||||
import org.sudachi.sudachi_emu.features.settings.model.Settings.MenuTag
|
||||
import org.sudachi.sudachi_emu.features.settings.model.ShortSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.model.StringSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.model.view.*
|
||||
import org.sudachi.sudachi_emu.utils.InputHandler
|
||||
import org.sudachi.sudachi_emu.utils.NativeConfig
|
||||
|
||||
class SettingsFragmentPresenter(
|
||||
private val settingsViewModel: SettingsViewModel,
|
||||
private val adapter: SettingsAdapter,
|
||||
private var menuTag: MenuTag
|
||||
) {
|
||||
private var settingsList = ArrayList<SettingsItem>()
|
||||
|
||||
private val context get() = SudachiApplication.appContext
|
||||
|
||||
// Extension for altering settings list based on each setting's properties
|
||||
fun ArrayList<SettingsItem>.add(key: String) {
|
||||
val item = SettingsItem.settingsItems[key]!!
|
||||
if (settingsViewModel.game != null && !item.setting.isSwitchable) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!NativeConfig.isPerGameConfigLoaded() && !NativeLibrary.isRunning()) {
|
||||
item.setting.global = true
|
||||
}
|
||||
|
||||
val pairedSettingKey = item.setting.pairedSettingKey
|
||||
if (pairedSettingKey.isNotEmpty()) {
|
||||
val pairedSettingValue = NativeConfig.getBoolean(
|
||||
pairedSettingKey,
|
||||
if (NativeLibrary.isRunning() && !NativeConfig.isPerGameConfigLoaded()) {
|
||||
!NativeConfig.usingGlobal(pairedSettingKey)
|
||||
} else {
|
||||
NativeConfig.usingGlobal(pairedSettingKey)
|
||||
}
|
||||
)
|
||||
if (!pairedSettingValue) return
|
||||
}
|
||||
add(item)
|
||||
}
|
||||
|
||||
// Allows you to show/hide abstract settings based on the paired setting key
|
||||
fun ArrayList<SettingsItem>.addAbstract(item: SettingsItem) {
|
||||
val pairedSettingKey = item.setting.pairedSettingKey
|
||||
if (pairedSettingKey.isNotEmpty()) {
|
||||
val pairedSettingsItem =
|
||||
this.firstOrNull { it.setting.key == pairedSettingKey } ?: return
|
||||
val pairedSetting = pairedSettingsItem.setting as AbstractBooleanSetting
|
||||
if (!pairedSetting.getBoolean(!NativeConfig.isPerGameConfigLoaded())) return
|
||||
}
|
||||
add(item)
|
||||
}
|
||||
|
||||
fun onViewCreated() {
|
||||
loadSettingsList()
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun loadSettingsList(notifyDataSetChanged: Boolean = false) {
|
||||
val sl = ArrayList<SettingsItem>()
|
||||
when (menuTag) {
|
||||
MenuTag.SECTION_ROOT -> addConfigSettings(sl)
|
||||
MenuTag.SECTION_SYSTEM -> addSystemSettings(sl)
|
||||
MenuTag.SECTION_RENDERER -> addGraphicsSettings(sl)
|
||||
MenuTag.SECTION_AUDIO -> addAudioSettings(sl)
|
||||
MenuTag.SECTION_INPUT -> addInputSettings(sl)
|
||||
MenuTag.SECTION_INPUT_PLAYER_ONE -> addInputPlayer(sl, 0)
|
||||
MenuTag.SECTION_INPUT_PLAYER_TWO -> addInputPlayer(sl, 1)
|
||||
MenuTag.SECTION_INPUT_PLAYER_THREE -> addInputPlayer(sl, 2)
|
||||
MenuTag.SECTION_INPUT_PLAYER_FOUR -> addInputPlayer(sl, 3)
|
||||
MenuTag.SECTION_INPUT_PLAYER_FIVE -> addInputPlayer(sl, 4)
|
||||
MenuTag.SECTION_INPUT_PLAYER_SIX -> addInputPlayer(sl, 5)
|
||||
MenuTag.SECTION_INPUT_PLAYER_SEVEN -> addInputPlayer(sl, 6)
|
||||
MenuTag.SECTION_INPUT_PLAYER_EIGHT -> addInputPlayer(sl, 7)
|
||||
MenuTag.SECTION_THEME -> addThemeSettings(sl)
|
||||
MenuTag.SECTION_DEBUG -> addDebugSettings(sl)
|
||||
}
|
||||
settingsList = sl
|
||||
adapter.submitList(settingsList) {
|
||||
if (notifyDataSetChanged) {
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addConfigSettings(sl: ArrayList<SettingsItem>) {
|
||||
sl.apply {
|
||||
add(
|
||||
SubmenuSetting(
|
||||
titleId = R.string.preferences_system,
|
||||
descriptionId = R.string.preferences_system_description,
|
||||
iconId = R.drawable.ic_system_settings,
|
||||
menuKey = MenuTag.SECTION_SYSTEM
|
||||
)
|
||||
)
|
||||
add(
|
||||
SubmenuSetting(
|
||||
titleId = R.string.preferences_graphics,
|
||||
descriptionId = R.string.preferences_graphics_description,
|
||||
iconId = R.drawable.ic_graphics,
|
||||
menuKey = MenuTag.SECTION_RENDERER
|
||||
)
|
||||
)
|
||||
add(
|
||||
SubmenuSetting(
|
||||
titleId = R.string.preferences_audio,
|
||||
descriptionId = R.string.preferences_audio_description,
|
||||
iconId = R.drawable.ic_audio,
|
||||
menuKey = MenuTag.SECTION_AUDIO
|
||||
)
|
||||
)
|
||||
add(
|
||||
SubmenuSetting(
|
||||
titleId = R.string.preferences_debug,
|
||||
descriptionId = R.string.preferences_debug_description,
|
||||
iconId = R.drawable.ic_code,
|
||||
menuKey = MenuTag.SECTION_DEBUG
|
||||
)
|
||||
)
|
||||
add(
|
||||
RunnableSetting(
|
||||
titleId = R.string.reset_to_default,
|
||||
descriptionId = R.string.reset_to_default_description,
|
||||
isRunnable = !NativeLibrary.isRunning(),
|
||||
iconId = R.drawable.ic_restore
|
||||
) { settingsViewModel.setShouldShowResetSettingsDialog(true) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addSystemSettings(sl: ArrayList<SettingsItem>) {
|
||||
sl.apply {
|
||||
add(StringSetting.DEVICE_NAME.key)
|
||||
add(BooleanSetting.RENDERER_USE_SPEED_LIMIT.key)
|
||||
add(ShortSetting.RENDERER_SPEED_LIMIT.key)
|
||||
add(BooleanSetting.USE_DOCKED_MODE.key)
|
||||
add(IntSetting.REGION_INDEX.key)
|
||||
add(IntSetting.LANGUAGE_INDEX.key)
|
||||
add(BooleanSetting.USE_CUSTOM_RTC.key)
|
||||
add(LongSetting.CUSTOM_RTC.key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addGraphicsSettings(sl: ArrayList<SettingsItem>) {
|
||||
sl.apply {
|
||||
add(IntSetting.RENDERER_ACCURACY.key)
|
||||
add(IntSetting.RENDERER_RESOLUTION.key)
|
||||
add(IntSetting.RENDERER_VSYNC.key)
|
||||
add(IntSetting.RENDERER_SCALING_FILTER.key)
|
||||
add(IntSetting.FSR_SHARPENING_SLIDER.key)
|
||||
add(IntSetting.RENDERER_ANTI_ALIASING.key)
|
||||
add(IntSetting.MAX_ANISOTROPY.key)
|
||||
add(IntSetting.RENDERER_SCREEN_LAYOUT.key)
|
||||
add(IntSetting.RENDERER_ASPECT_RATIO.key)
|
||||
add(IntSetting.VERTICAL_ALIGNMENT.key)
|
||||
add(BooleanSetting.PICTURE_IN_PICTURE.key)
|
||||
add(BooleanSetting.RENDERER_USE_DISK_SHADER_CACHE.key)
|
||||
add(BooleanSetting.RENDERER_FORCE_MAX_CLOCK.key)
|
||||
add(BooleanSetting.RENDERER_ASYNCHRONOUS_SHADERS.key)
|
||||
add(BooleanSetting.RENDERER_REACTIVE_FLUSHING.key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addAudioSettings(sl: ArrayList<SettingsItem>) {
|
||||
sl.apply {
|
||||
add(IntSetting.AUDIO_OUTPUT_ENGINE.key)
|
||||
add(ByteSetting.AUDIO_VOLUME.key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addInputSettings(sl: ArrayList<SettingsItem>) {
|
||||
settingsViewModel.currentDevice = 0
|
||||
|
||||
if (NativeConfig.isPerGameConfigLoaded()) {
|
||||
NativeInput.loadInputProfiles()
|
||||
val profiles = NativeInput.getInputProfileNames().toMutableList()
|
||||
profiles.add(0, "")
|
||||
val prettyProfiles = profiles.toTypedArray()
|
||||
prettyProfiles[0] =
|
||||
context.getString(R.string.use_global_input_configuration)
|
||||
sl.apply {
|
||||
for (i in 0 until 8) {
|
||||
add(
|
||||
IntSingleChoiceSetting(
|
||||
getPerGameProfileSetting(profiles, i),
|
||||
titleString = getPlayerProfileString(i + 1),
|
||||
choices = prettyProfiles,
|
||||
values = IntArray(profiles.size) { it }.toTypedArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val getConnectedIcon: (Int) -> Int = { playerIndex: Int ->
|
||||
if (NativeInput.getIsConnected(playerIndex)) {
|
||||
R.drawable.ic_controller
|
||||
} else {
|
||||
R.drawable.ic_controller_disconnected
|
||||
}
|
||||
}
|
||||
|
||||
val inputSettings = NativeConfig.getInputSettings(true)
|
||||
sl.apply {
|
||||
add(
|
||||
SubmenuSetting(
|
||||
titleString = Settings.getPlayerString(1),
|
||||
descriptionString = inputSettings[0].profileName,
|
||||
menuKey = MenuTag.SECTION_INPUT_PLAYER_ONE,
|
||||
iconId = getConnectedIcon(0)
|
||||
)
|
||||
)
|
||||
add(
|
||||
SubmenuSetting(
|
||||
titleString = Settings.getPlayerString(2),
|
||||
descriptionString = inputSettings[1].profileName,
|
||||
menuKey = MenuTag.SECTION_INPUT_PLAYER_TWO,
|
||||
iconId = getConnectedIcon(1)
|
||||
)
|
||||
)
|
||||
add(
|
||||
SubmenuSetting(
|
||||
titleString = Settings.getPlayerString(3),
|
||||
descriptionString = inputSettings[2].profileName,
|
||||
menuKey = MenuTag.SECTION_INPUT_PLAYER_THREE,
|
||||
iconId = getConnectedIcon(2)
|
||||
)
|
||||
)
|
||||
add(
|
||||
SubmenuSetting(
|
||||
titleString = Settings.getPlayerString(4),
|
||||
descriptionString = inputSettings[3].profileName,
|
||||
menuKey = MenuTag.SECTION_INPUT_PLAYER_FOUR,
|
||||
iconId = getConnectedIcon(3)
|
||||
)
|
||||
)
|
||||
add(
|
||||
SubmenuSetting(
|
||||
titleString = Settings.getPlayerString(5),
|
||||
descriptionString = inputSettings[4].profileName,
|
||||
menuKey = MenuTag.SECTION_INPUT_PLAYER_FIVE,
|
||||
iconId = getConnectedIcon(4)
|
||||
)
|
||||
)
|
||||
add(
|
||||
SubmenuSetting(
|
||||
titleString = Settings.getPlayerString(6),
|
||||
descriptionString = inputSettings[5].profileName,
|
||||
menuKey = MenuTag.SECTION_INPUT_PLAYER_SIX,
|
||||
iconId = getConnectedIcon(5)
|
||||
)
|
||||
)
|
||||
add(
|
||||
SubmenuSetting(
|
||||
titleString = Settings.getPlayerString(7),
|
||||
descriptionString = inputSettings[6].profileName,
|
||||
menuKey = MenuTag.SECTION_INPUT_PLAYER_SEVEN,
|
||||
iconId = getConnectedIcon(6)
|
||||
)
|
||||
)
|
||||
add(
|
||||
SubmenuSetting(
|
||||
titleString = Settings.getPlayerString(8),
|
||||
descriptionString = inputSettings[7].profileName,
|
||||
menuKey = MenuTag.SECTION_INPUT_PLAYER_EIGHT,
|
||||
iconId = getConnectedIcon(7)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPlayerProfileString(player: Int): String =
|
||||
context.getString(R.string.player_num_profile, player)
|
||||
|
||||
private fun getPerGameProfileSetting(
|
||||
profiles: List<String>,
|
||||
playerIndex: Int
|
||||
): AbstractIntSetting {
|
||||
return object : AbstractIntSetting {
|
||||
private val players
|
||||
get() = NativeConfig.getInputSettings(false)
|
||||
|
||||
override val key = ""
|
||||
|
||||
override fun getInt(needsGlobal: Boolean): Int {
|
||||
val currentProfile = players[playerIndex].profileName
|
||||
profiles.forEachIndexed { i, profile ->
|
||||
if (profile == currentProfile) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun setInt(value: Int) {
|
||||
NativeInput.loadPerGameConfiguration(playerIndex, value, profiles[value])
|
||||
NativeInput.connectControllers(playerIndex)
|
||||
NativeConfig.saveControlPlayerValues()
|
||||
}
|
||||
|
||||
override val defaultValue = 0
|
||||
|
||||
override fun getValueAsString(needsGlobal: Boolean): String = getInt().toString()
|
||||
|
||||
override fun reset() = setInt(defaultValue)
|
||||
|
||||
override var global = true
|
||||
|
||||
override val isRuntimeModifiable = true
|
||||
|
||||
override val isSaveable = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun addInputPlayer(sl: ArrayList<SettingsItem>, playerIndex: Int) {
|
||||
sl.apply {
|
||||
val connectedSetting = object : AbstractBooleanSetting {
|
||||
override val key = "connected"
|
||||
|
||||
override fun getBoolean(needsGlobal: Boolean): Boolean =
|
||||
NativeInput.getIsConnected(playerIndex)
|
||||
|
||||
override fun setBoolean(value: Boolean) =
|
||||
NativeInput.connectControllers(playerIndex, value)
|
||||
|
||||
override val defaultValue = playerIndex == 0
|
||||
|
||||
override fun getValueAsString(needsGlobal: Boolean): String =
|
||||
getBoolean(needsGlobal).toString()
|
||||
|
||||
override fun reset() = setBoolean(defaultValue)
|
||||
}
|
||||
add(SwitchSetting(connectedSetting, R.string.connected))
|
||||
|
||||
val styleTags = NativeInput.getSupportedStyleTags(playerIndex)
|
||||
val npadType = object : AbstractIntSetting {
|
||||
override val key = "npad_type"
|
||||
override fun getInt(needsGlobal: Boolean): Int {
|
||||
val styleIndex = NativeInput.getStyleIndex(playerIndex)
|
||||
return styleTags.indexOfFirst { it == styleIndex }
|
||||
}
|
||||
|
||||
override fun setInt(value: Int) {
|
||||
NativeInput.setStyleIndex(playerIndex, styleTags[value])
|
||||
settingsViewModel.setReloadListAndNotifyDataset(true)
|
||||
}
|
||||
|
||||
override val defaultValue = NpadStyleIndex.Fullkey.int
|
||||
override fun getValueAsString(needsGlobal: Boolean): String = getInt().toString()
|
||||
override fun reset() = setInt(defaultValue)
|
||||
override val pairedSettingKey: String = "connected"
|
||||
}
|
||||
addAbstract(
|
||||
IntSingleChoiceSetting(
|
||||
npadType,
|
||||
titleId = R.string.controller_type,
|
||||
choices = styleTags.map { context.getString(it.nameId) }
|
||||
.toTypedArray(),
|
||||
values = IntArray(styleTags.size) { it }.toTypedArray()
|
||||
)
|
||||
)
|
||||
|
||||
InputHandler.updateControllerData()
|
||||
|
||||
val autoMappingSetting = object : AbstractIntSetting {
|
||||
override val key = "auto_mapping_device"
|
||||
|
||||
override fun getInt(needsGlobal: Boolean): Int = -1
|
||||
|
||||
override fun setInt(value: Int) {
|
||||
val registeredController = InputHandler.registeredControllers[value + 1]
|
||||
val displayName = registeredController.get(
|
||||
"display",
|
||||
context.getString(R.string.unknown)
|
||||
)
|
||||
NativeInput.updateMappingsWithDefault(
|
||||
playerIndex,
|
||||
registeredController,
|
||||
displayName
|
||||
)
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.attempted_auto_map, displayName),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
settingsViewModel.setReloadListAndNotifyDataset(true)
|
||||
}
|
||||
|
||||
override val defaultValue = -1
|
||||
|
||||
override fun getValueAsString(needsGlobal: Boolean) = getInt().toString()
|
||||
|
||||
override fun reset() = setInt(defaultValue)
|
||||
|
||||
override val isRuntimeModifiable: Boolean = true
|
||||
}
|
||||
|
||||
val unknownString = context.getString(R.string.unknown)
|
||||
val prettyAutoMappingControllerList = InputHandler.registeredControllers.mapNotNull {
|
||||
val port = it.get("port", -1)
|
||||
return@mapNotNull if (port == 100 || port == -1) {
|
||||
null
|
||||
} else {
|
||||
it.get("display", unknownString)
|
||||
}
|
||||
}.toTypedArray()
|
||||
add(
|
||||
IntSingleChoiceSetting(
|
||||
autoMappingSetting,
|
||||
titleId = R.string.auto_map,
|
||||
descriptionId = R.string.auto_map_description,
|
||||
choices = prettyAutoMappingControllerList,
|
||||
values = IntArray(prettyAutoMappingControllerList.size) { it }.toTypedArray()
|
||||
)
|
||||
)
|
||||
|
||||
val mappingFilterSetting = object : AbstractIntSetting {
|
||||
override val key = "mapping_filter"
|
||||
|
||||
override fun getInt(needsGlobal: Boolean): Int = settingsViewModel.currentDevice
|
||||
|
||||
override fun setInt(value: Int) {
|
||||
settingsViewModel.currentDevice = value
|
||||
}
|
||||
|
||||
override val defaultValue = 0
|
||||
|
||||
override fun getValueAsString(needsGlobal: Boolean) = getInt().toString()
|
||||
|
||||
override fun reset() = setInt(defaultValue)
|
||||
|
||||
override val isRuntimeModifiable: Boolean = true
|
||||
}
|
||||
|
||||
val prettyControllerList = InputHandler.registeredControllers.mapNotNull {
|
||||
return@mapNotNull if (it.get("port", 0) == 100) {
|
||||
null
|
||||
} else {
|
||||
it.get("display", unknownString)
|
||||
}
|
||||
}.toTypedArray()
|
||||
add(
|
||||
IntSingleChoiceSetting(
|
||||
mappingFilterSetting,
|
||||
titleId = R.string.input_mapping_filter,
|
||||
descriptionId = R.string.input_mapping_filter_description,
|
||||
choices = prettyControllerList,
|
||||
values = IntArray(prettyControllerList.size) { it }.toTypedArray()
|
||||
)
|
||||
)
|
||||
|
||||
add(InputProfileSetting(playerIndex))
|
||||
add(
|
||||
RunnableSetting(titleId = R.string.reset_to_default, isRunnable = true) {
|
||||
settingsViewModel.setShouldShowResetInputDialog(true)
|
||||
}
|
||||
)
|
||||
|
||||
val styleIndex = NativeInput.getStyleIndex(playerIndex)
|
||||
|
||||
// Buttons
|
||||
when (styleIndex) {
|
||||
NpadStyleIndex.Fullkey,
|
||||
NpadStyleIndex.Handheld,
|
||||
NpadStyleIndex.JoyconDual -> {
|
||||
add(HeaderSetting(R.string.buttons))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.A, R.string.button_a))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.B, R.string.button_b))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.X, R.string.button_x))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.Y, R.string.button_y))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.Plus, R.string.button_plus))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.Minus, R.string.button_minus))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.Home, R.string.button_home))
|
||||
add(
|
||||
ButtonInputSetting(
|
||||
playerIndex,
|
||||
NativeButton.Capture,
|
||||
R.string.button_capture
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
NpadStyleIndex.JoyconLeft -> {
|
||||
add(HeaderSetting(R.string.buttons))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.Minus, R.string.button_minus))
|
||||
add(
|
||||
ButtonInputSetting(
|
||||
playerIndex,
|
||||
NativeButton.Capture,
|
||||
R.string.button_capture
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
NpadStyleIndex.JoyconRight -> {
|
||||
add(HeaderSetting(R.string.buttons))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.A, R.string.button_a))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.B, R.string.button_b))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.X, R.string.button_x))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.Y, R.string.button_y))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.Plus, R.string.button_plus))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.Home, R.string.button_home))
|
||||
}
|
||||
|
||||
NpadStyleIndex.GameCube -> {
|
||||
add(HeaderSetting(R.string.buttons))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.A, R.string.button_a))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.B, R.string.button_b))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.X, R.string.button_x))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.Y, R.string.button_y))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.Plus, R.string.start_pause))
|
||||
}
|
||||
|
||||
else -> {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
||||
when (styleIndex) {
|
||||
NpadStyleIndex.Fullkey,
|
||||
NpadStyleIndex.Handheld,
|
||||
NpadStyleIndex.JoyconDual,
|
||||
NpadStyleIndex.JoyconLeft -> {
|
||||
add(HeaderSetting(R.string.dpad))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.DUp, R.string.up))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.DDown, R.string.down))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.DLeft, R.string.left))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.DRight, R.string.right))
|
||||
}
|
||||
|
||||
else -> {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
||||
// Left stick
|
||||
when (styleIndex) {
|
||||
NpadStyleIndex.Fullkey,
|
||||
NpadStyleIndex.Handheld,
|
||||
NpadStyleIndex.JoyconDual,
|
||||
NpadStyleIndex.JoyconLeft -> {
|
||||
add(HeaderSetting(R.string.left_stick))
|
||||
addAll(getStickDirections(playerIndex, NativeAnalog.LStick))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.LStick, R.string.pressed))
|
||||
addAll(getExtraStickSettings(playerIndex, NativeAnalog.LStick))
|
||||
}
|
||||
|
||||
NpadStyleIndex.GameCube -> {
|
||||
add(HeaderSetting(R.string.control_stick))
|
||||
addAll(getStickDirections(playerIndex, NativeAnalog.LStick))
|
||||
addAll(getExtraStickSettings(playerIndex, NativeAnalog.LStick))
|
||||
}
|
||||
|
||||
else -> {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
||||
// Right stick
|
||||
when (styleIndex) {
|
||||
NpadStyleIndex.Fullkey,
|
||||
NpadStyleIndex.Handheld,
|
||||
NpadStyleIndex.JoyconDual,
|
||||
NpadStyleIndex.JoyconRight -> {
|
||||
add(HeaderSetting(R.string.right_stick))
|
||||
addAll(getStickDirections(playerIndex, NativeAnalog.RStick))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.RStick, R.string.pressed))
|
||||
addAll(getExtraStickSettings(playerIndex, NativeAnalog.RStick))
|
||||
}
|
||||
|
||||
NpadStyleIndex.GameCube -> {
|
||||
add(HeaderSetting(R.string.c_stick))
|
||||
addAll(getStickDirections(playerIndex, NativeAnalog.RStick))
|
||||
addAll(getExtraStickSettings(playerIndex, NativeAnalog.RStick))
|
||||
}
|
||||
|
||||
else -> {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
||||
// L/R, ZL/ZR, and SL/SR
|
||||
when (styleIndex) {
|
||||
NpadStyleIndex.Fullkey,
|
||||
NpadStyleIndex.Handheld -> {
|
||||
add(HeaderSetting(R.string.triggers))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.L, R.string.button_l))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_r))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_zl))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_zr))
|
||||
}
|
||||
|
||||
NpadStyleIndex.JoyconDual -> {
|
||||
add(HeaderSetting(R.string.triggers))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.L, R.string.button_l))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_r))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_zl))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_zr))
|
||||
add(
|
||||
ButtonInputSetting(
|
||||
playerIndex,
|
||||
NativeButton.SLLeft,
|
||||
R.string.button_sl_left
|
||||
)
|
||||
)
|
||||
add(
|
||||
ButtonInputSetting(
|
||||
playerIndex,
|
||||
NativeButton.SRLeft,
|
||||
R.string.button_sr_left
|
||||
)
|
||||
)
|
||||
add(
|
||||
ButtonInputSetting(
|
||||
playerIndex,
|
||||
NativeButton.SLRight,
|
||||
R.string.button_sl_right
|
||||
)
|
||||
)
|
||||
add(
|
||||
ButtonInputSetting(
|
||||
playerIndex,
|
||||
NativeButton.SRRight,
|
||||
R.string.button_sr_right
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
NpadStyleIndex.JoyconLeft -> {
|
||||
add(HeaderSetting(R.string.triggers))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.L, R.string.button_l))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_zl))
|
||||
add(
|
||||
ButtonInputSetting(
|
||||
playerIndex,
|
||||
NativeButton.SLLeft,
|
||||
R.string.button_sl_left
|
||||
)
|
||||
)
|
||||
add(
|
||||
ButtonInputSetting(
|
||||
playerIndex,
|
||||
NativeButton.SRLeft,
|
||||
R.string.button_sr_left
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
NpadStyleIndex.JoyconRight -> {
|
||||
add(HeaderSetting(R.string.triggers))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_r))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_zr))
|
||||
add(
|
||||
ButtonInputSetting(
|
||||
playerIndex,
|
||||
NativeButton.SLRight,
|
||||
R.string.button_sl_right
|
||||
)
|
||||
)
|
||||
add(
|
||||
ButtonInputSetting(
|
||||
playerIndex,
|
||||
NativeButton.SRRight,
|
||||
R.string.button_sr_right
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
NpadStyleIndex.GameCube -> {
|
||||
add(HeaderSetting(R.string.triggers))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_z))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_l))
|
||||
add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_r))
|
||||
}
|
||||
|
||||
else -> {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
||||
add(HeaderSetting(R.string.vibration))
|
||||
val vibrationEnabledSetting = object : AbstractBooleanSetting {
|
||||
override val key = "vibration"
|
||||
|
||||
override fun getBoolean(needsGlobal: Boolean): Boolean =
|
||||
NativeConfig.getInputSettings(true)[playerIndex].vibrationEnabled
|
||||
|
||||
override fun setBoolean(value: Boolean) {
|
||||
val settings = NativeConfig.getInputSettings(true)
|
||||
settings[playerIndex].vibrationEnabled = value
|
||||
NativeConfig.setInputSettings(settings, true)
|
||||
}
|
||||
|
||||
override val defaultValue = true
|
||||
|
||||
override fun getValueAsString(needsGlobal: Boolean): String =
|
||||
getBoolean(needsGlobal).toString()
|
||||
|
||||
override fun reset() = setBoolean(defaultValue)
|
||||
}
|
||||
add(SwitchSetting(vibrationEnabledSetting, R.string.vibration))
|
||||
|
||||
val useSystemVibratorSetting = object : AbstractBooleanSetting {
|
||||
override val key = ""
|
||||
|
||||
override fun getBoolean(needsGlobal: Boolean): Boolean =
|
||||
NativeConfig.getInputSettings(true)[playerIndex].useSystemVibrator
|
||||
|
||||
override fun setBoolean(value: Boolean) {
|
||||
val settings = NativeConfig.getInputSettings(true)
|
||||
settings[playerIndex].useSystemVibrator = value
|
||||
NativeConfig.setInputSettings(settings, true)
|
||||
}
|
||||
|
||||
override val defaultValue = playerIndex == 0
|
||||
|
||||
override fun getValueAsString(needsGlobal: Boolean): String =
|
||||
getBoolean(needsGlobal).toString()
|
||||
|
||||
override fun reset() = setBoolean(defaultValue)
|
||||
|
||||
override val pairedSettingKey: String = "vibration"
|
||||
}
|
||||
addAbstract(SwitchSetting(useSystemVibratorSetting, R.string.use_system_vibrator))
|
||||
|
||||
val vibrationStrengthSetting = object : AbstractIntSetting {
|
||||
override val key = ""
|
||||
|
||||
override fun getInt(needsGlobal: Boolean): Int =
|
||||
NativeConfig.getInputSettings(true)[playerIndex].vibrationStrength
|
||||
|
||||
override fun setInt(value: Int) {
|
||||
val settings = NativeConfig.getInputSettings(true)
|
||||
settings[playerIndex].vibrationStrength = value
|
||||
NativeConfig.setInputSettings(settings, true)
|
||||
}
|
||||
|
||||
override val defaultValue = 100
|
||||
|
||||
override fun getValueAsString(needsGlobal: Boolean): String =
|
||||
getInt(needsGlobal).toString()
|
||||
|
||||
override fun reset() = setInt(defaultValue)
|
||||
|
||||
override val pairedSettingKey: String = "vibration"
|
||||
}
|
||||
addAbstract(
|
||||
SliderSetting(vibrationStrengthSetting, R.string.vibration_strength, units = "%")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience function for creating AbstractIntSettings for modifier range/stick range/stick deadzones
|
||||
private fun getStickIntSettingFromParam(
|
||||
playerIndex: Int,
|
||||
paramName: String,
|
||||
stick: NativeAnalog,
|
||||
defaultValue: Float
|
||||
): AbstractIntSetting =
|
||||
object : AbstractIntSetting {
|
||||
val params get() = NativeInput.getStickParam(playerIndex, stick)
|
||||
|
||||
override val key = ""
|
||||
|
||||
override fun getInt(needsGlobal: Boolean): Int =
|
||||
(params.get(paramName, defaultValue) * 100).toInt()
|
||||
|
||||
override fun setInt(value: Int) {
|
||||
val tempParams = params
|
||||
tempParams.set(paramName, value.toFloat() / 100)
|
||||
NativeInput.setStickParam(playerIndex, stick, tempParams)
|
||||
}
|
||||
|
||||
override val defaultValue = (defaultValue * 100).toInt()
|
||||
|
||||
override fun getValueAsString(needsGlobal: Boolean): String =
|
||||
getInt(needsGlobal).toString()
|
||||
|
||||
override fun reset() = setInt(this.defaultValue)
|
||||
}
|
||||
|
||||
private fun getExtraStickSettings(
|
||||
playerIndex: Int,
|
||||
nativeAnalog: NativeAnalog
|
||||
): List<SettingsItem> {
|
||||
val stickIsController =
|
||||
NativeInput.isController(NativeInput.getStickParam(playerIndex, nativeAnalog))
|
||||
val modifierRangeSetting =
|
||||
getStickIntSettingFromParam(playerIndex, "modifier_scale", nativeAnalog, 0.5f)
|
||||
val stickRangeSetting =
|
||||
getStickIntSettingFromParam(playerIndex, "range", nativeAnalog, 0.95f)
|
||||
val stickDeadzoneSetting =
|
||||
getStickIntSettingFromParam(playerIndex, "deadzone", nativeAnalog, 0.15f)
|
||||
|
||||
val out = mutableListOf<SettingsItem>().apply {
|
||||
if (stickIsController) {
|
||||
add(SliderSetting(stickRangeSetting, titleId = R.string.range, min = 25, max = 150))
|
||||
add(SliderSetting(stickDeadzoneSetting, R.string.deadzone))
|
||||
} else {
|
||||
add(ModifierInputSetting(playerIndex, NativeAnalog.LStick, R.string.modifier))
|
||||
add(SliderSetting(modifierRangeSetting, R.string.modifier_range))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun getStickDirections(player: Int, stick: NativeAnalog): List<AnalogInputSetting> =
|
||||
listOf(
|
||||
AnalogInputSetting(
|
||||
player,
|
||||
stick,
|
||||
AnalogDirection.Up,
|
||||
R.string.up
|
||||
),
|
||||
AnalogInputSetting(
|
||||
player,
|
||||
stick,
|
||||
AnalogDirection.Down,
|
||||
R.string.down
|
||||
),
|
||||
AnalogInputSetting(
|
||||
player,
|
||||
stick,
|
||||
AnalogDirection.Left,
|
||||
R.string.left
|
||||
),
|
||||
AnalogInputSetting(
|
||||
player,
|
||||
stick,
|
||||
AnalogDirection.Right,
|
||||
R.string.right
|
||||
)
|
||||
)
|
||||
|
||||
private fun addThemeSettings(sl: ArrayList<SettingsItem>) {
|
||||
sl.apply {
|
||||
val theme: AbstractIntSetting = object : AbstractIntSetting {
|
||||
override fun getInt(needsGlobal: Boolean): Int = IntSetting.THEME.getInt()
|
||||
override fun setInt(value: Int) {
|
||||
IntSetting.THEME.setInt(value)
|
||||
settingsViewModel.setShouldRecreate(true)
|
||||
}
|
||||
|
||||
override val key: String = IntSetting.THEME.key
|
||||
override val isRuntimeModifiable: Boolean = IntSetting.THEME.isRuntimeModifiable
|
||||
override fun getValueAsString(needsGlobal: Boolean): String =
|
||||
IntSetting.THEME.getValueAsString()
|
||||
|
||||
override val defaultValue: Int = IntSetting.THEME.defaultValue
|
||||
override fun reset() = IntSetting.THEME.setInt(defaultValue)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
add(
|
||||
SingleChoiceSetting(
|
||||
theme,
|
||||
titleId = R.string.change_app_theme,
|
||||
choicesId = R.array.themeEntriesA12,
|
||||
valuesId = R.array.themeValuesA12
|
||||
)
|
||||
)
|
||||
} else {
|
||||
add(
|
||||
SingleChoiceSetting(
|
||||
theme,
|
||||
titleId = R.string.change_app_theme,
|
||||
choicesId = R.array.themeEntries,
|
||||
valuesId = R.array.themeValues
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val themeMode: AbstractIntSetting = object : AbstractIntSetting {
|
||||
override fun getInt(needsGlobal: Boolean): Int = IntSetting.THEME_MODE.getInt()
|
||||
override fun setInt(value: Int) {
|
||||
IntSetting.THEME_MODE.setInt(value)
|
||||
settingsViewModel.setShouldRecreate(true)
|
||||
}
|
||||
|
||||
override val key: String = IntSetting.THEME_MODE.key
|
||||
override val isRuntimeModifiable: Boolean =
|
||||
IntSetting.THEME_MODE.isRuntimeModifiable
|
||||
|
||||
override fun getValueAsString(needsGlobal: Boolean): String =
|
||||
IntSetting.THEME_MODE.getValueAsString()
|
||||
|
||||
override val defaultValue: Int = IntSetting.THEME_MODE.defaultValue
|
||||
override fun reset() {
|
||||
IntSetting.THEME_MODE.setInt(defaultValue)
|
||||
settingsViewModel.setShouldRecreate(true)
|
||||
}
|
||||
}
|
||||
|
||||
add(
|
||||
SingleChoiceSetting(
|
||||
themeMode,
|
||||
titleId = R.string.change_theme_mode,
|
||||
choicesId = R.array.themeModeEntries,
|
||||
valuesId = R.array.themeModeValues
|
||||
)
|
||||
)
|
||||
|
||||
val blackBackgrounds: AbstractBooleanSetting = object : AbstractBooleanSetting {
|
||||
override fun getBoolean(needsGlobal: Boolean): Boolean =
|
||||
BooleanSetting.BLACK_BACKGROUNDS.getBoolean()
|
||||
|
||||
override fun setBoolean(value: Boolean) {
|
||||
BooleanSetting.BLACK_BACKGROUNDS.setBoolean(value)
|
||||
settingsViewModel.setShouldRecreate(true)
|
||||
}
|
||||
|
||||
override val key: String = BooleanSetting.BLACK_BACKGROUNDS.key
|
||||
override val isRuntimeModifiable: Boolean =
|
||||
BooleanSetting.BLACK_BACKGROUNDS.isRuntimeModifiable
|
||||
|
||||
override fun getValueAsString(needsGlobal: Boolean): String =
|
||||
BooleanSetting.BLACK_BACKGROUNDS.getValueAsString()
|
||||
|
||||
override val defaultValue: Boolean = BooleanSetting.BLACK_BACKGROUNDS.defaultValue
|
||||
override fun reset() {
|
||||
BooleanSetting.BLACK_BACKGROUNDS
|
||||
.setBoolean(BooleanSetting.BLACK_BACKGROUNDS.defaultValue)
|
||||
settingsViewModel.setShouldRecreate(true)
|
||||
}
|
||||
}
|
||||
|
||||
add(
|
||||
SwitchSetting(
|
||||
blackBackgrounds,
|
||||
titleId = R.string.use_black_backgrounds,
|
||||
descriptionId = R.string.use_black_backgrounds_description
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addDebugSettings(sl: ArrayList<SettingsItem>) {
|
||||
sl.apply {
|
||||
add(HeaderSetting(R.string.gpu))
|
||||
add(IntSetting.RENDERER_BACKEND.key)
|
||||
add(BooleanSetting.RENDERER_DEBUG.key)
|
||||
|
||||
add(HeaderSetting(R.string.cpu))
|
||||
add(IntSetting.CPU_BACKEND.key)
|
||||
add(IntSetting.CPU_ACCURACY.key)
|
||||
add(BooleanSetting.CPU_DEBUG_MODE.key)
|
||||
add(SettingsItem.FASTMEM_COMBINED)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.SudachiApplication
|
||||
import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem
|
||||
import org.sudachi.sudachi_emu.model.Game
|
||||
import org.sudachi.sudachi_emu.utils.InputHandler
|
||||
import org.sudachi.sudachi_emu.utils.ParamPackage
|
||||
|
||||
class SettingsViewModel : ViewModel() {
|
||||
var game: Game? = null
|
||||
|
||||
var clickedItem: SettingsItem? = null
|
||||
|
||||
var currentDevice = 0
|
||||
|
||||
val shouldRecreate: StateFlow<Boolean> get() = _shouldRecreate
|
||||
private val _shouldRecreate = MutableStateFlow(false)
|
||||
|
||||
val shouldNavigateBack: StateFlow<Boolean> get() = _shouldNavigateBack
|
||||
private val _shouldNavigateBack = MutableStateFlow(false)
|
||||
|
||||
val shouldShowResetSettingsDialog: StateFlow<Boolean> get() = _shouldShowResetSettingsDialog
|
||||
private val _shouldShowResetSettingsDialog = MutableStateFlow(false)
|
||||
|
||||
val shouldReloadSettingsList: StateFlow<Boolean> get() = _shouldReloadSettingsList
|
||||
private val _shouldReloadSettingsList = MutableStateFlow(false)
|
||||
|
||||
val sliderProgress: StateFlow<Int> get() = _sliderProgress
|
||||
private val _sliderProgress = MutableStateFlow(-1)
|
||||
|
||||
val sliderTextValue: StateFlow<String> get() = _sliderTextValue
|
||||
private val _sliderTextValue = MutableStateFlow("")
|
||||
|
||||
val adapterItemChanged: StateFlow<Int> get() = _adapterItemChanged
|
||||
private val _adapterItemChanged = MutableStateFlow(-1)
|
||||
|
||||
private val _datasetChanged = MutableStateFlow(false)
|
||||
val datasetChanged = _datasetChanged.asStateFlow()
|
||||
|
||||
private val _reloadListAndNotifyDataset = MutableStateFlow(false)
|
||||
val reloadListAndNotifyDataset = _reloadListAndNotifyDataset.asStateFlow()
|
||||
|
||||
private val _shouldShowDeleteProfileDialog = MutableStateFlow("")
|
||||
val shouldShowDeleteProfileDialog = _shouldShowDeleteProfileDialog.asStateFlow()
|
||||
|
||||
private val _shouldShowResetInputDialog = MutableStateFlow(false)
|
||||
val shouldShowResetInputDialog = _shouldShowResetInputDialog.asStateFlow()
|
||||
|
||||
fun setShouldRecreate(value: Boolean) {
|
||||
_shouldRecreate.value = value
|
||||
}
|
||||
|
||||
fun setShouldNavigateBack(value: Boolean) {
|
||||
_shouldNavigateBack.value = value
|
||||
}
|
||||
|
||||
fun setShouldShowResetSettingsDialog(value: Boolean) {
|
||||
_shouldShowResetSettingsDialog.value = value
|
||||
}
|
||||
|
||||
fun setShouldReloadSettingsList(value: Boolean) {
|
||||
_shouldReloadSettingsList.value = value
|
||||
}
|
||||
|
||||
fun setSliderTextValue(value: Float, units: String) {
|
||||
_sliderProgress.value = value.toInt()
|
||||
_sliderTextValue.value = String.format(
|
||||
SudachiApplication.appContext.getString(R.string.value_with_units),
|
||||
value.toInt().toString(),
|
||||
units
|
||||
)
|
||||
}
|
||||
|
||||
fun setSliderProgress(value: Float) {
|
||||
_sliderProgress.value = value.toInt()
|
||||
}
|
||||
|
||||
fun setAdapterItemChanged(value: Int) {
|
||||
_adapterItemChanged.value = value
|
||||
}
|
||||
|
||||
fun setDatasetChanged(value: Boolean) {
|
||||
_datasetChanged.value = value
|
||||
}
|
||||
|
||||
fun setReloadListAndNotifyDataset(value: Boolean) {
|
||||
_reloadListAndNotifyDataset.value = value
|
||||
}
|
||||
|
||||
fun setShouldShowDeleteProfileDialog(profile: String) {
|
||||
_shouldShowDeleteProfileDialog.value = profile
|
||||
}
|
||||
|
||||
fun setShouldShowResetInputDialog(value: Boolean) {
|
||||
_shouldShowResetInputDialog.value = value
|
||||
}
|
||||
|
||||
fun getCurrentDeviceParams(defaultParams: ParamPackage): ParamPackage =
|
||||
try {
|
||||
InputHandler.registeredControllers[currentDevice]
|
||||
} catch (e: IndexOutOfBoundsException) {
|
||||
defaultParams
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.ui.viewholder
|
||||
|
||||
import android.view.View
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import org.sudachi.sudachi_emu.databinding.ListItemSettingBinding
|
||||
import org.sudachi.sudachi_emu.features.settings.model.view.DateTimeSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem
|
||||
import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter
|
||||
import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible
|
||||
|
||||
class DateTimeViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
|
||||
SettingViewHolder(binding.root, adapter) {
|
||||
private lateinit var setting: DateTimeSetting
|
||||
|
||||
override fun bind(item: SettingsItem) {
|
||||
setting = item as DateTimeSetting
|
||||
binding.textSettingName.text = item.title
|
||||
binding.textSettingDescription.setVisible(item.description.isNotEmpty())
|
||||
binding.textSettingDescription.text = item.description
|
||||
binding.textSettingValue.setVisible(true)
|
||||
val epochTime = setting.getValue()
|
||||
val instant = Instant.ofEpochMilli(epochTime * 1000)
|
||||
val zonedTime = ZonedDateTime.ofInstant(instant, ZoneId.of("UTC"))
|
||||
val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
|
||||
binding.textSettingValue.text = dateFormatter.format(zonedTime)
|
||||
|
||||
binding.buttonClear.setVisible(setting.clearable)
|
||||
binding.buttonClear.setOnClickListener {
|
||||
adapter.onClearClick(setting, bindingAdapterPosition)
|
||||
}
|
||||
|
||||
setStyle(setting.isEditable, binding)
|
||||
}
|
||||
|
||||
override fun onClick(clicked: View) {
|
||||
if (setting.isEditable) {
|
||||
adapter.onDateTimeClick(setting, bindingAdapterPosition)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLongClick(clicked: View): Boolean {
|
||||
if (setting.isEditable) {
|
||||
return adapter.onLongClick(setting, bindingAdapterPosition)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.ui.viewholder
|
||||
|
||||
import android.view.View
|
||||
import org.sudachi.sudachi_emu.databinding.ListItemSettingsHeaderBinding
|
||||
import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem
|
||||
import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter
|
||||
|
||||
class HeaderViewHolder(val binding: ListItemSettingsHeaderBinding, adapter: SettingsAdapter) :
|
||||
SettingViewHolder(binding.root, adapter) {
|
||||
|
||||
init {
|
||||
itemView.setOnClickListener(null)
|
||||
}
|
||||
|
||||
override fun bind(item: SettingsItem) {
|
||||
binding.textHeaderName.text = item.title
|
||||
}
|
||||
|
||||
override fun onClick(clicked: View) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
override fun onLongClick(clicked: View): Boolean {
|
||||
// no-op
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.ui.viewholder
|
||||
|
||||
import android.view.View
|
||||
import org.sudachi.sudachi_emu.databinding.ListItemSettingBinding
|
||||
import org.sudachi.sudachi_emu.features.settings.model.view.InputProfileSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem
|
||||
import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible
|
||||
|
||||
class InputProfileViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
|
||||
SettingViewHolder(binding.root, adapter) {
|
||||
private lateinit var setting: InputProfileSetting
|
||||
|
||||
override fun bind(item: SettingsItem) {
|
||||
setting = item as InputProfileSetting
|
||||
binding.textSettingName.text = setting.title
|
||||
binding.textSettingValue.text =
|
||||
setting.getCurrentProfile().ifEmpty { binding.root.context.getString(R.string.not_set) }
|
||||
|
||||
binding.textSettingDescription.setVisible(false)
|
||||
binding.buttonClear.setVisible(false)
|
||||
binding.icon.setVisible(false)
|
||||
binding.buttonClear.setVisible(false)
|
||||
}
|
||||
|
||||
override fun onClick(clicked: View) =
|
||||
adapter.onInputProfileClick(setting, bindingAdapterPosition)
|
||||
|
||||
override fun onLongClick(clicked: View): Boolean = false
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.ui.viewholder
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import org.sudachi.sudachi_emu.databinding.ListItemSettingBinding
|
||||
import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem
|
||||
import org.sudachi.sudachi_emu.features.settings.model.view.SubmenuSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter
|
||||
import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible
|
||||
|
||||
class SubmenuViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
|
||||
SettingViewHolder(binding.root, adapter) {
|
||||
private lateinit var setting: SubmenuSetting
|
||||
|
||||
override fun bind(item: SettingsItem) {
|
||||
setting = item as SubmenuSetting
|
||||
binding.icon.setVisible(setting.iconId != 0)
|
||||
if (setting.iconId != 0) {
|
||||
binding.icon.setImageDrawable(
|
||||
ResourcesCompat.getDrawable(
|
||||
binding.icon.resources,
|
||||
setting.iconId,
|
||||
binding.icon.context.theme
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
binding.textSettingName.text = setting.title
|
||||
binding.textSettingDescription.setVisible(setting.description.isNotEmpty())
|
||||
binding.textSettingDescription.text = setting.description
|
||||
binding.textSettingValue.setVisible(false)
|
||||
binding.buttonClear.setVisible(false)
|
||||
}
|
||||
|
||||
override fun onClick(clicked: View) {
|
||||
adapter.onSubmenuClick(setting)
|
||||
}
|
||||
|
||||
override fun onLongClick(clicked: View): Boolean {
|
||||
// no-op
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.ui.viewholder
|
||||
|
||||
import android.view.View
|
||||
import android.widget.CompoundButton
|
||||
import org.sudachi.sudachi_emu.databinding.ListItemSettingSwitchBinding
|
||||
import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem
|
||||
import org.sudachi.sudachi_emu.features.settings.model.view.SwitchSetting
|
||||
import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter
|
||||
import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible
|
||||
|
||||
class SwitchSettingViewHolder(val binding: ListItemSettingSwitchBinding, adapter: SettingsAdapter) :
|
||||
SettingViewHolder(binding.root, adapter) {
|
||||
|
||||
private lateinit var setting: SwitchSetting
|
||||
|
||||
override fun bind(item: SettingsItem) {
|
||||
setting = item as SwitchSetting
|
||||
binding.textSettingName.text = setting.title
|
||||
binding.textSettingDescription.setVisible(setting.description.isNotEmpty())
|
||||
binding.textSettingDescription.text = setting.description
|
||||
|
||||
binding.switchWidget.setOnCheckedChangeListener(null)
|
||||
binding.switchWidget.isChecked = setting.getIsChecked(setting.needsRuntimeGlobal)
|
||||
binding.switchWidget.setOnCheckedChangeListener { _: CompoundButton, _: Boolean ->
|
||||
adapter.onBooleanClick(setting, binding.switchWidget.isChecked, bindingAdapterPosition)
|
||||
}
|
||||
|
||||
binding.buttonClear.setVisible(setting.clearable)
|
||||
binding.buttonClear.setOnClickListener {
|
||||
adapter.onClearClick(setting, bindingAdapterPosition)
|
||||
}
|
||||
|
||||
setStyle(setting.isEditable, binding)
|
||||
}
|
||||
|
||||
override fun onClick(clicked: View) {
|
||||
if (setting.isEditable) {
|
||||
binding.switchWidget.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLongClick(clicked: View): Boolean {
|
||||
if (setting.isEditable) {
|
||||
return adapter.onLongClick(setting, bindingAdapterPosition)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.features.settings.utils
|
||||
|
||||
import android.net.Uri
|
||||
import org.sudachi.sudachi_emu.model.Game
|
||||
import java.io.*
|
||||
import org.sudachi.sudachi_emu.utils.DirectoryInitialization
|
||||
import org.sudachi.sudachi_emu.utils.FileUtil
|
||||
import org.sudachi.sudachi_emu.utils.NativeConfig
|
||||
|
||||
/**
|
||||
* Contains static methods for interacting with .ini files in which settings are stored.
|
||||
*/
|
||||
object SettingsFile {
|
||||
const val FILE_NAME_CONFIG = "config.ini"
|
||||
|
||||
fun getSettingsFile(fileName: String): File =
|
||||
File(DirectoryInitialization.userDirectory + "/config/" + fileName)
|
||||
|
||||
fun getCustomSettingsFile(game: Game): File =
|
||||
File(DirectoryInitialization.userDirectory + "/config/custom/" + game.settingsName + ".ini")
|
||||
|
||||
fun loadCustomConfig(game: Game) {
|
||||
val fileName = FileUtil.getFilename(Uri.parse(game.path))
|
||||
NativeConfig.initializePerGameConfig(game.programId, fileName)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.fragments
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.databinding.DialogAddFolderBinding
|
||||
import org.sudachi.sudachi_emu.model.GameDir
|
||||
import org.sudachi.sudachi_emu.model.GamesViewModel
|
||||
import org.sudachi.sudachi_emu.model.HomeViewModel
|
||||
|
||||
class AddGameFolderDialogFragment : DialogFragment() {
|
||||
private val homeViewModel: HomeViewModel by activityViewModels()
|
||||
private val gamesViewModel: GamesViewModel by activityViewModels()
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val binding = DialogAddFolderBinding.inflate(layoutInflater)
|
||||
val folderUriString = requireArguments().getString(FOLDER_URI_STRING)
|
||||
if (folderUriString == null) {
|
||||
dismiss()
|
||||
}
|
||||
binding.path.text = Uri.parse(folderUriString).path
|
||||
|
||||
return MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.add_game_folder)
|
||||
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
|
||||
val newGameDir = GameDir(folderUriString!!, binding.deepScanSwitch.isChecked)
|
||||
homeViewModel.setGamesDirSelected(true)
|
||||
gamesViewModel.addFolder(newGameDir)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setView(binding.root)
|
||||
.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "AddGameFolderDialogFragment"
|
||||
|
||||
private const val FOLDER_URI_STRING = "FolderUriString"
|
||||
|
||||
fun newInstance(folderUriString: String): AddGameFolderDialogFragment {
|
||||
val args = Bundle()
|
||||
args.putString(FOLDER_URI_STRING, folderUriString)
|
||||
val fragment = AddGameFolderDialogFragment()
|
||||
fragment.arguments = args
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,205 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.fragments
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import kotlinx.coroutines.launch
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.adapters.AddonAdapter
|
||||
import org.sudachi.sudachi_emu.databinding.FragmentAddonsBinding
|
||||
import org.sudachi.sudachi_emu.model.AddonViewModel
|
||||
import org.sudachi.sudachi_emu.model.HomeViewModel
|
||||
import org.sudachi.sudachi_emu.utils.AddonUtil
|
||||
import org.sudachi.sudachi_emu.utils.FileUtil.copyFilesTo
|
||||
import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins
|
||||
import org.sudachi.sudachi_emu.utils.collect
|
||||
import java.io.File
|
||||
|
||||
class AddonsFragment : Fragment() {
|
||||
private var _binding: FragmentAddonsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val homeViewModel: HomeViewModel by activityViewModels()
|
||||
private val addonViewModel: AddonViewModel by activityViewModels()
|
||||
|
||||
private val args by navArgs<AddonsFragmentArgs>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
addonViewModel.onOpenAddons(args.game)
|
||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentAddonsBinding.inflate(inflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
homeViewModel.setNavigationVisibility(visible = false, animated = false)
|
||||
homeViewModel.setStatusBarShadeVisibility(false)
|
||||
|
||||
binding.toolbarAddons.setNavigationOnClickListener {
|
||||
binding.root.findNavController().popBackStack()
|
||||
}
|
||||
|
||||
binding.toolbarAddons.title = getString(R.string.addons_game, args.game.title)
|
||||
|
||||
binding.listAddons.apply {
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
adapter = AddonAdapter(addonViewModel)
|
||||
}
|
||||
|
||||
addonViewModel.addonList.collect(viewLifecycleOwner) {
|
||||
(binding.listAddons.adapter as AddonAdapter).submitList(it)
|
||||
}
|
||||
addonViewModel.showModInstallPicker.collect(
|
||||
viewLifecycleOwner,
|
||||
resetState = { addonViewModel.showModInstallPicker(false) }
|
||||
) { if (it) installAddon.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) }
|
||||
addonViewModel.showModNoticeDialog.collect(
|
||||
viewLifecycleOwner,
|
||||
resetState = { addonViewModel.showModNoticeDialog(false) }
|
||||
) {
|
||||
if (it) {
|
||||
MessageDialogFragment.newInstance(
|
||||
requireActivity(),
|
||||
titleId = R.string.addon_notice,
|
||||
descriptionId = R.string.addon_notice_description,
|
||||
dismissible = false,
|
||||
positiveAction = { addonViewModel.showModInstallPicker(true) },
|
||||
negativeAction = {},
|
||||
negativeButtonTitleId = R.string.close
|
||||
).show(parentFragmentManager, MessageDialogFragment.TAG)
|
||||
}
|
||||
}
|
||||
addonViewModel.addonToDelete.collect(
|
||||
viewLifecycleOwner,
|
||||
resetState = { addonViewModel.setAddonToDelete(null) }
|
||||
) {
|
||||
if (it != null) {
|
||||
MessageDialogFragment.newInstance(
|
||||
requireActivity(),
|
||||
titleId = R.string.confirm_uninstall,
|
||||
descriptionId = R.string.confirm_uninstall_description,
|
||||
positiveAction = { addonViewModel.onDeleteAddon(it) },
|
||||
negativeAction = {}
|
||||
).show(parentFragmentManager, MessageDialogFragment.TAG)
|
||||
}
|
||||
}
|
||||
|
||||
binding.buttonInstall.setOnClickListener {
|
||||
ContentTypeSelectionDialogFragment().show(
|
||||
parentFragmentManager,
|
||||
ContentTypeSelectionDialogFragment.TAG
|
||||
)
|
||||
}
|
||||
|
||||
setInsets()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
addonViewModel.refreshAddons()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
addonViewModel.onCloseAddons()
|
||||
}
|
||||
|
||||
val installAddon =
|
||||
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result ->
|
||||
if (result == null) {
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
val externalAddonDirectory = DocumentFile.fromTreeUri(requireContext(), result)
|
||||
if (externalAddonDirectory == null) {
|
||||
MessageDialogFragment.newInstance(
|
||||
requireActivity(),
|
||||
titleId = R.string.invalid_directory,
|
||||
descriptionId = R.string.invalid_directory_description
|
||||
).show(parentFragmentManager, MessageDialogFragment.TAG)
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
val isValid = externalAddonDirectory.listFiles()
|
||||
.any { AddonUtil.validAddonDirectories.contains(it.name?.lowercase()) }
|
||||
val errorMessage = MessageDialogFragment.newInstance(
|
||||
requireActivity(),
|
||||
titleId = R.string.invalid_directory,
|
||||
descriptionId = R.string.invalid_directory_description
|
||||
)
|
||||
if (isValid) {
|
||||
ProgressDialogFragment.newInstance(
|
||||
requireActivity(),
|
||||
R.string.installing_game_content,
|
||||
false
|
||||
) { progressCallback, _ ->
|
||||
val parentDirectoryName = externalAddonDirectory.name
|
||||
val internalAddonDirectory =
|
||||
File(args.game.addonDir + parentDirectoryName)
|
||||
try {
|
||||
externalAddonDirectory.copyFilesTo(internalAddonDirectory, progressCallback)
|
||||
} catch (_: Exception) {
|
||||
return@newInstance errorMessage
|
||||
}
|
||||
addonViewModel.refreshAddons()
|
||||
return@newInstance getString(R.string.addon_installed_successfully)
|
||||
}.show(parentFragmentManager, ProgressDialogFragment.TAG)
|
||||
} else {
|
||||
errorMessage.show(parentFragmentManager, MessageDialogFragment.TAG)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setInsets() =
|
||||
ViewCompat.setOnApplyWindowInsetsListener(
|
||||
binding.root
|
||||
) { _: View, windowInsets: WindowInsetsCompat ->
|
||||
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
|
||||
|
||||
val leftInsets = barInsets.left + cutoutInsets.left
|
||||
val rightInsets = barInsets.right + cutoutInsets.right
|
||||
|
||||
binding.toolbarAddons.updateMargins(left = leftInsets, right = rightInsets)
|
||||
binding.listAddons.updateMargins(left = leftInsets, right = rightInsets)
|
||||
binding.listAddons.updatePadding(
|
||||
bottom = barInsets.bottom +
|
||||
resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab)
|
||||
)
|
||||
|
||||
val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab)
|
||||
binding.buttonInstall.updateMargins(
|
||||
left = leftInsets + fabSpacing,
|
||||
right = rightInsets + fabSpacing,
|
||||
bottom = barInsets.bottom + fabSpacing
|
||||
)
|
||||
|
||||
windowInsets
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.fragments
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.SudachiApplication
|
||||
import org.sudachi.sudachi_emu.model.AddonViewModel
|
||||
import org.sudachi.sudachi_emu.ui.main.MainActivity
|
||||
|
||||
class ContentTypeSelectionDialogFragment : DialogFragment() {
|
||||
private val addonViewModel: AddonViewModel by activityViewModels()
|
||||
|
||||
private val preferences get() =
|
||||
PreferenceManager.getDefaultSharedPreferences(SudachiApplication.appContext)
|
||||
|
||||
private var selectedItem = 0
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val launchOptions =
|
||||
arrayOf(getString(R.string.updates_and_dlc), getString(R.string.mods_and_cheats))
|
||||
|
||||
if (savedInstanceState != null) {
|
||||
selectedItem = savedInstanceState.getInt(SELECTED_ITEM)
|
||||
}
|
||||
|
||||
val mainActivity = requireActivity() as MainActivity
|
||||
return MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.select_content_type)
|
||||
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
|
||||
when (selectedItem) {
|
||||
0 -> mainActivity.installGameUpdate.launch(arrayOf("*/*"))
|
||||
else -> {
|
||||
if (!preferences.getBoolean(MOD_NOTICE_SEEN, false)) {
|
||||
preferences.edit().putBoolean(MOD_NOTICE_SEEN, true).apply()
|
||||
addonViewModel.showModNoticeDialog(true)
|
||||
return@setPositiveButton
|
||||
}
|
||||
addonViewModel.showModInstallPicker(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.setSingleChoiceItems(launchOptions, 0) { _: DialogInterface, i: Int ->
|
||||
selectedItem = i
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putInt(SELECTED_ITEM, selectedItem)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "ContentTypeSelectionDialogFragment"
|
||||
|
||||
private const val SELECTED_ITEM = "SelectedItem"
|
||||
private const val MOD_NOTICE_SEEN = "ModNoticeSeen"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.fragments
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.sudachi.sudachi_emu.NativeLibrary
|
||||
import org.sudachi.sudachi_emu.R
|
||||
|
||||
class CoreErrorDialogFragment : DialogFragment() {
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
|
||||
MaterialAlertDialogBuilder(requireActivity())
|
||||
.setTitle(requireArguments().getString(TITLE))
|
||||
.setMessage(requireArguments().getString(MESSAGE))
|
||||
.setPositiveButton(R.string.continue_button, null)
|
||||
.setNegativeButton(R.string.abort_button) { _: DialogInterface?, _: Int ->
|
||||
NativeLibrary.coreErrorAlertResult = false
|
||||
synchronized(NativeLibrary.coreErrorAlertLock) {
|
||||
NativeLibrary.coreErrorAlertLock.notify()
|
||||
}
|
||||
}
|
||||
.create()
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
super.onDismiss(dialog)
|
||||
NativeLibrary.coreErrorAlertResult = true
|
||||
synchronized(NativeLibrary.coreErrorAlertLock) { NativeLibrary.coreErrorAlertLock.notify() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TITLE = "Title"
|
||||
const val MESSAGE = "Message"
|
||||
|
||||
fun newInstance(title: String, message: String): CoreErrorDialogFragment {
|
||||
val frag = CoreErrorDialogFragment()
|
||||
val args = Bundle()
|
||||
args.putString(TITLE, title)
|
||||
args.putString(MESSAGE, message)
|
||||
frag.arguments = args
|
||||
return frag
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.fragments
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.databinding.DialogProgressBarBinding
|
||||
import org.sudachi.sudachi_emu.model.DriverViewModel
|
||||
import org.sudachi.sudachi_emu.utils.collect
|
||||
|
||||
class DriversLoadingDialogFragment : DialogFragment() {
|
||||
private val driverViewModel: DriverViewModel by activityViewModels()
|
||||
|
||||
private lateinit var binding: DialogProgressBarBinding
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
binding = DialogProgressBarBinding.inflate(layoutInflater)
|
||||
binding.progressBar.isIndeterminate = true
|
||||
|
||||
isCancelable = false
|
||||
|
||||
return MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.loading)
|
||||
.setView(binding.root)
|
||||
.create()
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View = binding.root
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
driverViewModel.isInteractionAllowed.collect(viewLifecycleOwner) { if (it) dismiss() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "DriversLoadingDialogFragment"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.fragments
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.databinding.FragmentEarlyAccessBinding
|
||||
import org.sudachi.sudachi_emu.model.HomeViewModel
|
||||
import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins
|
||||
|
||||
class EarlyAccessFragment : Fragment() {
|
||||
private var _binding: FragmentEarlyAccessBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val homeViewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentEarlyAccessBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
homeViewModel.setNavigationVisibility(visible = false, animated = true)
|
||||
homeViewModel.setStatusBarShadeVisibility(visible = false)
|
||||
|
||||
binding.toolbarAbout.setNavigationOnClickListener {
|
||||
parentFragmentManager.primaryNavigationFragment?.findNavController()?.popBackStack()
|
||||
}
|
||||
|
||||
binding.getEarlyAccessButton.setOnClickListener {
|
||||
openLink(
|
||||
getString(R.string.play_store_link)
|
||||
)
|
||||
}
|
||||
|
||||
setInsets()
|
||||
}
|
||||
|
||||
private fun openLink(link: String) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun setInsets() =
|
||||
ViewCompat.setOnApplyWindowInsetsListener(
|
||||
binding.root
|
||||
) { _: View, windowInsets: WindowInsetsCompat ->
|
||||
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
|
||||
|
||||
val leftInsets = barInsets.left + cutoutInsets.left
|
||||
val rightInsets = barInsets.right + cutoutInsets.right
|
||||
|
||||
binding.appbarEa.updateMargins(left = leftInsets, right = rightInsets)
|
||||
|
||||
binding.scrollEa.updatePadding(
|
||||
left = leftInsets,
|
||||
right = rightInsets,
|
||||
bottom = barInsets.bottom
|
||||
)
|
||||
|
||||
windowInsets
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,78 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.fragments
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.databinding.DialogFolderPropertiesBinding
|
||||
import org.sudachi.sudachi_emu.model.GameDir
|
||||
import org.sudachi.sudachi_emu.model.GamesViewModel
|
||||
import org.sudachi.sudachi_emu.utils.NativeConfig
|
||||
import org.sudachi.sudachi_emu.utils.SerializableHelper.parcelable
|
||||
|
||||
class GameFolderPropertiesDialogFragment : DialogFragment() {
|
||||
private val gamesViewModel: GamesViewModel by activityViewModels()
|
||||
|
||||
private var deepScan = false
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val binding = DialogFolderPropertiesBinding.inflate(layoutInflater)
|
||||
val gameDir = requireArguments().parcelable<GameDir>(GAME_DIR)!!
|
||||
|
||||
// Restore checkbox state
|
||||
binding.deepScanSwitch.isChecked =
|
||||
savedInstanceState?.getBoolean(DEEP_SCAN) ?: gameDir.deepScan
|
||||
|
||||
// Ensure that we can get the checkbox state even if the view is destroyed
|
||||
deepScan = binding.deepScanSwitch.isChecked
|
||||
binding.deepScanSwitch.setOnClickListener {
|
||||
deepScan = binding.deepScanSwitch.isChecked
|
||||
}
|
||||
|
||||
return MaterialAlertDialogBuilder(requireContext())
|
||||
.setView(binding.root)
|
||||
.setTitle(R.string.game_folder_properties)
|
||||
.setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int ->
|
||||
val folderIndex = gamesViewModel.folders.value.indexOf(gameDir)
|
||||
if (folderIndex != -1) {
|
||||
gamesViewModel.folders.value[folderIndex].deepScan =
|
||||
binding.deepScanSwitch.isChecked
|
||||
gamesViewModel.updateGameDirs()
|
||||
}
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
NativeConfig.saveGlobalConfig()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putBoolean(DEEP_SCAN, deepScan)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "GameFolderPropertiesDialogFragment"
|
||||
|
||||
private const val GAME_DIR = "GameDir"
|
||||
|
||||
private const val DEEP_SCAN = "DeepScan"
|
||||
|
||||
fun newInstance(gameDir: GameDir): GameFolderPropertiesDialogFragment {
|
||||
val args = Bundle()
|
||||
args.putParcelable(GAME_DIR, gameDir)
|
||||
val fragment = GameFolderPropertiesDialogFragment()
|
||||
fragment.arguments = args
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,179 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.fragments
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import org.sudachi.sudachi_emu.NativeLibrary
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.databinding.FragmentGameInfoBinding
|
||||
import org.sudachi.sudachi_emu.model.GameVerificationResult
|
||||
import org.sudachi.sudachi_emu.model.HomeViewModel
|
||||
import org.sudachi.sudachi_emu.utils.GameMetadata
|
||||
import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible
|
||||
import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins
|
||||
|
||||
class GameInfoFragment : Fragment() {
|
||||
private var _binding: FragmentGameInfoBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val homeViewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private val args by navArgs<GameInfoFragmentArgs>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
|
||||
// Check for an up-to-date version string
|
||||
args.game.version = GameMetadata.getVersion(args.game.path, true)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentGameInfoBinding.inflate(inflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
homeViewModel.setNavigationVisibility(visible = false, animated = false)
|
||||
homeViewModel.setStatusBarShadeVisibility(false)
|
||||
|
||||
binding.apply {
|
||||
toolbarInfo.title = args.game.title
|
||||
toolbarInfo.setNavigationOnClickListener {
|
||||
view.findNavController().popBackStack()
|
||||
}
|
||||
|
||||
val pathString = Uri.parse(args.game.path).path ?: ""
|
||||
path.setHint(R.string.path)
|
||||
pathField.setText(pathString)
|
||||
pathField.setOnClickListener { copyToClipboard(getString(R.string.path), pathString) }
|
||||
|
||||
programId.setHint(R.string.program_id)
|
||||
programIdField.setText(args.game.programIdHex)
|
||||
programIdField.setOnClickListener {
|
||||
copyToClipboard(getString(R.string.program_id), args.game.programIdHex)
|
||||
}
|
||||
|
||||
if (args.game.developer.isNotEmpty()) {
|
||||
developer.setHint(R.string.developer)
|
||||
developerField.setText(args.game.developer)
|
||||
developerField.setOnClickListener {
|
||||
copyToClipboard(getString(R.string.developer), args.game.developer)
|
||||
}
|
||||
} else {
|
||||
developer.setVisible(false)
|
||||
}
|
||||
|
||||
version.setHint(R.string.version)
|
||||
versionField.setText(args.game.version)
|
||||
versionField.setOnClickListener {
|
||||
copyToClipboard(getString(R.string.version), args.game.version)
|
||||
}
|
||||
|
||||
buttonCopy.setOnClickListener {
|
||||
val details = """
|
||||
${args.game.title}
|
||||
${getString(R.string.path)} - $pathString
|
||||
${getString(R.string.program_id)} - ${args.game.programIdHex}
|
||||
${getString(R.string.developer)} - ${args.game.developer}
|
||||
${getString(R.string.version)} - ${args.game.version}
|
||||
""".trimIndent()
|
||||
copyToClipboard(args.game.title, details)
|
||||
}
|
||||
|
||||
buttonVerifyIntegrity.setOnClickListener {
|
||||
ProgressDialogFragment.newInstance(
|
||||
requireActivity(),
|
||||
R.string.verifying,
|
||||
true
|
||||
) { progressCallback, _ ->
|
||||
val result = GameVerificationResult.from(
|
||||
NativeLibrary.verifyGameContents(
|
||||
args.game.path,
|
||||
progressCallback
|
||||
)
|
||||
)
|
||||
return@newInstance when (result) {
|
||||
GameVerificationResult.Success ->
|
||||
MessageDialogFragment.newInstance(
|
||||
titleId = R.string.verify_success,
|
||||
descriptionId = R.string.operation_completed_successfully
|
||||
)
|
||||
|
||||
GameVerificationResult.Failed ->
|
||||
MessageDialogFragment.newInstance(
|
||||
titleId = R.string.verify_failure,
|
||||
descriptionId = R.string.verify_failure_description
|
||||
)
|
||||
|
||||
GameVerificationResult.NotImplemented ->
|
||||
MessageDialogFragment.newInstance(
|
||||
titleId = R.string.verify_no_result,
|
||||
descriptionId = R.string.verify_no_result_description
|
||||
)
|
||||
}
|
||||
}.show(parentFragmentManager, ProgressDialogFragment.TAG)
|
||||
}
|
||||
}
|
||||
|
||||
setInsets()
|
||||
}
|
||||
|
||||
private fun copyToClipboard(label: String, body: String) {
|
||||
val clipBoard =
|
||||
requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText(label, body)
|
||||
clipBoard.setPrimaryClip(clip)
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
R.string.copied_to_clipboard,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setInsets() =
|
||||
ViewCompat.setOnApplyWindowInsetsListener(
|
||||
binding.root
|
||||
) { _: View, windowInsets: WindowInsetsCompat ->
|
||||
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
|
||||
|
||||
val leftInsets = barInsets.left + cutoutInsets.left
|
||||
val rightInsets = barInsets.right + cutoutInsets.right
|
||||
|
||||
binding.toolbarInfo.updateMargins(left = leftInsets, right = rightInsets)
|
||||
binding.scrollInfo.updateMargins(left = leftInsets, right = rightInsets)
|
||||
|
||||
binding.contentInfo.updatePadding(bottom = barInsets.bottom)
|
||||
|
||||
windowInsets
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import org.sudachi.sudachi_emu.databinding.DialogLicenseBinding
|
||||
import org.sudachi.sudachi_emu.model.License
|
||||
import org.sudachi.sudachi_emu.utils.SerializableHelper.parcelable
|
||||
|
||||
class LicenseBottomSheetDialogFragment : BottomSheetDialogFragment() {
|
||||
private var _binding: DialogLicenseBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = DialogLicenseBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
BottomSheetBehavior.from<View>(view.parent as View).state =
|
||||
BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
|
||||
val license = requireArguments().parcelable<License>(LICENSE)!!
|
||||
|
||||
binding.apply {
|
||||
textTitle.setText(license.titleId)
|
||||
textLink.setText(license.linkId)
|
||||
textCopyright.setText(license.copyrightId)
|
||||
textLicense.setText(license.licenseId)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "LicenseBottomSheetDialogFragment"
|
||||
|
||||
const val LICENSE = "License"
|
||||
|
||||
fun newInstance(
|
||||
license: License
|
||||
): LicenseBottomSheetDialogFragment {
|
||||
val dialog = LicenseBottomSheetDialogFragment()
|
||||
val bundle = Bundle()
|
||||
bundle.putParcelable(LICENSE, license)
|
||||
dialog.arguments = bundle
|
||||
return dialog
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.adapters.LicenseAdapter
|
||||
import org.sudachi.sudachi_emu.databinding.FragmentLicensesBinding
|
||||
import org.sudachi.sudachi_emu.model.HomeViewModel
|
||||
import org.sudachi.sudachi_emu.model.License
|
||||
import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins
|
||||
|
||||
class LicensesFragment : Fragment() {
|
||||
private var _binding: FragmentLicensesBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val homeViewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentLicensesBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
homeViewModel.setNavigationVisibility(visible = false, animated = true)
|
||||
homeViewModel.setStatusBarShadeVisibility(visible = false)
|
||||
|
||||
binding.toolbarLicenses.setNavigationOnClickListener {
|
||||
binding.root.findNavController().popBackStack()
|
||||
}
|
||||
|
||||
val licenses = listOf(
|
||||
License(
|
||||
R.string.license_fidelityfx_fsr,
|
||||
R.string.license_fidelityfx_fsr_description,
|
||||
R.string.license_fidelityfx_fsr_link,
|
||||
R.string.license_fidelityfx_fsr_copyright,
|
||||
R.string.license_fidelityfx_fsr_text
|
||||
),
|
||||
License(
|
||||
R.string.license_cubeb,
|
||||
R.string.license_cubeb_description,
|
||||
R.string.license_cubeb_link,
|
||||
R.string.license_cubeb_copyright,
|
||||
R.string.license_cubeb_text
|
||||
),
|
||||
License(
|
||||
R.string.license_dynarmic,
|
||||
R.string.license_dynarmic_description,
|
||||
R.string.license_dynarmic_link,
|
||||
R.string.license_dynarmic_copyright,
|
||||
R.string.license_dynarmic_text
|
||||
),
|
||||
License(
|
||||
R.string.license_ffmpeg,
|
||||
R.string.license_ffmpeg_description,
|
||||
R.string.license_ffmpeg_link,
|
||||
R.string.license_ffmpeg_copyright,
|
||||
R.string.license_ffmpeg_text
|
||||
),
|
||||
License(
|
||||
R.string.license_opus,
|
||||
R.string.license_opus_description,
|
||||
R.string.license_opus_link,
|
||||
R.string.license_opus_copyright,
|
||||
R.string.license_opus_text
|
||||
),
|
||||
License(
|
||||
R.string.license_sirit,
|
||||
R.string.license_sirit_description,
|
||||
R.string.license_sirit_link,
|
||||
R.string.license_sirit_copyright,
|
||||
R.string.license_sirit_text
|
||||
),
|
||||
License(
|
||||
R.string.license_adreno_tools,
|
||||
R.string.license_adreno_tools_description,
|
||||
R.string.license_adreno_tools_link,
|
||||
R.string.license_adreno_tools_copyright,
|
||||
R.string.license_adreno_tools_text
|
||||
)
|
||||
)
|
||||
|
||||
binding.listLicenses.apply {
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
adapter = LicenseAdapter(requireActivity() as AppCompatActivity, licenses)
|
||||
}
|
||||
|
||||
setInsets()
|
||||
}
|
||||
|
||||
private fun setInsets() =
|
||||
ViewCompat.setOnApplyWindowInsetsListener(
|
||||
binding.root
|
||||
) { _: View, windowInsets: WindowInsetsCompat ->
|
||||
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
|
||||
|
||||
val leftInsets = barInsets.left + cutoutInsets.left
|
||||
val rightInsets = barInsets.right + cutoutInsets.right
|
||||
|
||||
binding.appbarLicenses.updateMargins(left = leftInsets, right = rightInsets)
|
||||
binding.listLicenses.updateMargins(left = leftInsets, right = rightInsets)
|
||||
|
||||
binding.listLicenses.updatePadding(bottom = barInsets.bottom)
|
||||
|
||||
windowInsets
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.fragments
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.sudachi.sudachi_emu.R
|
||||
|
||||
class PermissionDeniedDialogFragment : DialogFragment() {
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
return MaterialAlertDialogBuilder(requireContext())
|
||||
.setPositiveButton(R.string.home_settings) { _: DialogInterface?, _: Int ->
|
||||
openSettings()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setTitle(R.string.permission_denied)
|
||||
.setMessage(R.string.permission_denied_description)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun openSettings() {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
val uri = Uri.fromParts("package", requireActivity().packageName, null)
|
||||
intent.data = uri
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "PermissionDeniedDialogFragment"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.fragments
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.features.settings.ui.SettingsActivity
|
||||
|
||||
class ResetSettingsDialogFragment : DialogFragment() {
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val settingsActivity = requireActivity() as SettingsActivity
|
||||
|
||||
return MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.reset_all_settings)
|
||||
.setMessage(R.string.reset_all_settings_description)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
settingsActivity.onSettingsReset()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "ResetSettingsDialogFragment"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,218 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.fragments
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.preference.PreferenceManager
|
||||
import info.debatty.java.stringsimilarity.Jaccard
|
||||
import info.debatty.java.stringsimilarity.JaroWinkler
|
||||
import java.util.Locale
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.SudachiApplication
|
||||
import org.sudachi.sudachi_emu.adapters.GameAdapter
|
||||
import org.sudachi.sudachi_emu.databinding.FragmentSearchBinding
|
||||
import org.sudachi.sudachi_emu.layout.AutofitGridLayoutManager
|
||||
import org.sudachi.sudachi_emu.model.Game
|
||||
import org.sudachi.sudachi_emu.model.GamesViewModel
|
||||
import org.sudachi.sudachi_emu.model.HomeViewModel
|
||||
import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible
|
||||
import org.sudachi.sudachi_emu.utils.collect
|
||||
|
||||
class SearchFragment : Fragment() {
|
||||
private var _binding: FragmentSearchBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val gamesViewModel: GamesViewModel by activityViewModels()
|
||||
private val homeViewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private lateinit var preferences: SharedPreferences
|
||||
|
||||
companion object {
|
||||
private const val SEARCH_TEXT = "SearchText"
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentSearchBinding.inflate(layoutInflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
homeViewModel.setNavigationVisibility(visible = true, animated = true)
|
||||
homeViewModel.setStatusBarShadeVisibility(true)
|
||||
preferences = PreferenceManager.getDefaultSharedPreferences(SudachiApplication.appContext)
|
||||
|
||||
if (savedInstanceState != null) {
|
||||
binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT))
|
||||
}
|
||||
|
||||
binding.gridGamesSearch.apply {
|
||||
layoutManager = AutofitGridLayoutManager(
|
||||
requireContext(),
|
||||
requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
|
||||
)
|
||||
adapter = GameAdapter(requireActivity() as AppCompatActivity)
|
||||
}
|
||||
|
||||
binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> filterAndSearch() }
|
||||
|
||||
binding.searchText.doOnTextChanged { text: CharSequence?, _: Int, _: Int, _: Int ->
|
||||
binding.clearButton.setVisible(text.toString().isNotEmpty())
|
||||
filterAndSearch()
|
||||
}
|
||||
|
||||
gamesViewModel.searchFocused.collect(
|
||||
viewLifecycleOwner,
|
||||
resetState = { gamesViewModel.setSearchFocused(false) }
|
||||
) { if (it) focusSearch() }
|
||||
gamesViewModel.games.collect(viewLifecycleOwner) { filterAndSearch() }
|
||||
gamesViewModel.searchedGames.collect(viewLifecycleOwner) {
|
||||
(binding.gridGamesSearch.adapter as GameAdapter).submitList(it)
|
||||
binding.noResultsView.setVisible(it.isNotEmpty())
|
||||
}
|
||||
|
||||
binding.clearButton.setOnClickListener { binding.searchText.setText("") }
|
||||
|
||||
binding.searchBackground.setOnClickListener { focusSearch() }
|
||||
|
||||
setInsets()
|
||||
filterAndSearch()
|
||||
}
|
||||
|
||||
private inner class ScoredGame(val score: Double, val item: Game)
|
||||
|
||||
private fun filterAndSearch() {
|
||||
val baseList = gamesViewModel.games.value
|
||||
val filteredList: List<Game> = when (binding.chipGroup.checkedChipId) {
|
||||
R.id.chip_recently_played -> {
|
||||
baseList.filter {
|
||||
val lastPlayedTime = preferences.getLong(it.keyLastPlayedTime, 0L)
|
||||
lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
|
||||
}.sortedByDescending { preferences.getLong(it.keyLastPlayedTime, 0L) }
|
||||
}
|
||||
|
||||
R.id.chip_recently_added -> {
|
||||
baseList.filter {
|
||||
val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L)
|
||||
addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
|
||||
}.sortedByDescending { preferences.getLong(it.keyAddedToLibraryTime, 0L) }
|
||||
}
|
||||
|
||||
R.id.chip_homebrew -> baseList.filter { it.isHomebrew }
|
||||
|
||||
R.id.chip_retail -> baseList.filter { !it.isHomebrew }
|
||||
|
||||
else -> baseList
|
||||
}
|
||||
|
||||
if (binding.searchText.text.toString().isEmpty() &&
|
||||
binding.chipGroup.checkedChipId != View.NO_ID
|
||||
) {
|
||||
gamesViewModel.setSearchedGames(filteredList)
|
||||
return
|
||||
}
|
||||
|
||||
val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault())
|
||||
val searchAlgorithm = if (searchTerm.length > 1) Jaccard(2) else JaroWinkler()
|
||||
val sortedList: List<Game> = filteredList.mapNotNull { game ->
|
||||
val title = game.title.lowercase(Locale.getDefault())
|
||||
val score = searchAlgorithm.similarity(searchTerm, title)
|
||||
if (score > 0.03) {
|
||||
ScoredGame(score, game)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.sortedByDescending { it.score }.map { it.item }
|
||||
gamesViewModel.setSearchedGames(sortedList)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
if (_binding != null) {
|
||||
outState.putString(SEARCH_TEXT, binding.searchText.text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun focusSearch() {
|
||||
if (_binding != null) {
|
||||
binding.searchText.requestFocus()
|
||||
val imm = requireActivity()
|
||||
.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?
|
||||
imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setInsets() =
|
||||
ViewCompat.setOnApplyWindowInsetsListener(
|
||||
binding.root
|
||||
) { view: View, windowInsets: WindowInsetsCompat ->
|
||||
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
|
||||
val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med)
|
||||
val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
|
||||
val spacingNavigationRail =
|
||||
resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
|
||||
val chipSpacing = resources.getDimensionPixelSize(R.dimen.spacing_chip)
|
||||
|
||||
binding.constraintSearch.updatePadding(
|
||||
left = barInsets.left + cutoutInsets.left,
|
||||
top = barInsets.top,
|
||||
right = barInsets.right + cutoutInsets.right
|
||||
)
|
||||
|
||||
binding.gridGamesSearch.updatePadding(
|
||||
top = extraListSpacing,
|
||||
bottom = barInsets.bottom + spacingNavigation + extraListSpacing
|
||||
)
|
||||
binding.noResultsView.updatePadding(bottom = spacingNavigation + barInsets.bottom)
|
||||
|
||||
val mlpDivider = binding.divider.layoutParams as ViewGroup.MarginLayoutParams
|
||||
if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
|
||||
binding.frameSearch.updatePadding(left = spacingNavigationRail)
|
||||
binding.gridGamesSearch.updatePadding(left = spacingNavigationRail)
|
||||
binding.noResultsView.updatePadding(left = spacingNavigationRail)
|
||||
binding.chipGroup.updatePadding(
|
||||
left = chipSpacing + spacingNavigationRail,
|
||||
right = chipSpacing
|
||||
)
|
||||
mlpDivider.leftMargin = chipSpacing + spacingNavigationRail
|
||||
mlpDivider.rightMargin = chipSpacing
|
||||
} else {
|
||||
binding.frameSearch.updatePadding(right = spacingNavigationRail)
|
||||
binding.gridGamesSearch.updatePadding(right = spacingNavigationRail)
|
||||
binding.noResultsView.updatePadding(right = spacingNavigationRail)
|
||||
binding.chipGroup.updatePadding(
|
||||
left = chipSpacing,
|
||||
right = chipSpacing + spacingNavigationRail
|
||||
)
|
||||
mlpDivider.leftMargin = chipSpacing
|
||||
mlpDivider.rightMargin = chipSpacing + spacingNavigationRail
|
||||
}
|
||||
binding.divider.layoutParams = mlpDivider
|
||||
|
||||
windowInsets
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.layout
|
||||
|
||||
import android.content.Context
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.Recycler
|
||||
import org.sudachi.sudachi_emu.R
|
||||
|
||||
/**
|
||||
* Cut down version of the solution provided here
|
||||
* https://stackoverflow.com/questions/26666143/recyclerview-gridlayoutmanager-how-to-auto-detect-span-count
|
||||
*/
|
||||
class AutofitGridLayoutManager(
|
||||
context: Context,
|
||||
columnWidth: Int
|
||||
) : GridLayoutManager(context, 1) {
|
||||
private var columnWidth = 0
|
||||
private var isColumnWidthChanged = true
|
||||
private var lastWidth = 0
|
||||
private var lastHeight = 0
|
||||
|
||||
init {
|
||||
setColumnWidth(checkedColumnWidth(context, columnWidth))
|
||||
}
|
||||
|
||||
private fun checkedColumnWidth(context: Context, columnWidth: Int): Int {
|
||||
var newColumnWidth = columnWidth
|
||||
if (newColumnWidth <= 0) {
|
||||
newColumnWidth = context.resources.getDimensionPixelSize(R.dimen.spacing_xtralarge)
|
||||
}
|
||||
return newColumnWidth
|
||||
}
|
||||
|
||||
private fun setColumnWidth(newColumnWidth: Int) {
|
||||
if (newColumnWidth > 0 && newColumnWidth != columnWidth) {
|
||||
columnWidth = newColumnWidth
|
||||
isColumnWidthChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLayoutChildren(recycler: Recycler, state: RecyclerView.State) {
|
||||
val width = width
|
||||
val height = height
|
||||
if (columnWidth > 0 && width > 0 && height > 0 &&
|
||||
(isColumnWidthChanged || lastWidth != width || lastHeight != height)
|
||||
) {
|
||||
val totalSpace: Int = if (orientation == VERTICAL) {
|
||||
width - paddingRight - paddingLeft
|
||||
} else {
|
||||
height - paddingTop - paddingBottom
|
||||
}
|
||||
val spanCount = 1.coerceAtLeast(totalSpace / columnWidth)
|
||||
setSpanCount(spanCount)
|
||||
isColumnWidthChanged = false
|
||||
}
|
||||
lastWidth = width
|
||||
lastHeight = height
|
||||
super.onLayoutChildren(recycler, state)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.model
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.sudachi.sudachi_emu.NativeLibrary
|
||||
import org.sudachi.sudachi_emu.utils.NativeConfig
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class AddonViewModel : ViewModel() {
|
||||
private val _patchList = MutableStateFlow(mutableListOf<Patch>())
|
||||
val addonList get() = _patchList.asStateFlow()
|
||||
|
||||
private val _showModInstallPicker = MutableStateFlow(false)
|
||||
val showModInstallPicker get() = _showModInstallPicker.asStateFlow()
|
||||
|
||||
private val _showModNoticeDialog = MutableStateFlow(false)
|
||||
val showModNoticeDialog get() = _showModNoticeDialog.asStateFlow()
|
||||
|
||||
private val _addonToDelete = MutableStateFlow<Patch?>(null)
|
||||
val addonToDelete = _addonToDelete.asStateFlow()
|
||||
|
||||
var game: Game? = null
|
||||
|
||||
private val isRefreshing = AtomicBoolean(false)
|
||||
|
||||
fun onOpenAddons(game: Game) {
|
||||
this.game = game
|
||||
refreshAddons()
|
||||
}
|
||||
|
||||
fun refreshAddons() {
|
||||
if (isRefreshing.get() || game == null) {
|
||||
return
|
||||
}
|
||||
isRefreshing.set(true)
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val patchList = (
|
||||
NativeLibrary.getPatchesForFile(game!!.path, game!!.programId)
|
||||
?: emptyArray()
|
||||
).toMutableList()
|
||||
patchList.sortBy { it.name }
|
||||
_patchList.value = patchList
|
||||
isRefreshing.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAddonToDelete(patch: Patch?) {
|
||||
_addonToDelete.value = patch
|
||||
}
|
||||
|
||||
fun onDeleteAddon(patch: Patch) {
|
||||
when (PatchType.from(patch.type)) {
|
||||
PatchType.Update -> NativeLibrary.removeUpdate(patch.programId)
|
||||
PatchType.DLC -> NativeLibrary.removeDLC(patch.programId)
|
||||
PatchType.Mod -> NativeLibrary.removeMod(patch.programId, patch.name)
|
||||
}
|
||||
refreshAddons()
|
||||
}
|
||||
|
||||
fun onCloseAddons() {
|
||||
if (_patchList.value.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
NativeConfig.setDisabledAddons(
|
||||
game!!.programId,
|
||||
_patchList.value.mapNotNull {
|
||||
if (it.enabled) {
|
||||
null
|
||||
} else {
|
||||
it.name
|
||||
}
|
||||
}.toTypedArray()
|
||||
)
|
||||
NativeConfig.saveGlobalConfig()
|
||||
_patchList.value.clear()
|
||||
game = null
|
||||
}
|
||||
|
||||
fun showModInstallPicker(install: Boolean) {
|
||||
_showModInstallPicker.value = install
|
||||
}
|
||||
|
||||
fun showModNoticeDialog(show: Boolean) {
|
||||
_showModNoticeDialog.value = show
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.model
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import org.sudachi.sudachi_emu.R
|
||||
|
||||
data class Applet(
|
||||
@StringRes val titleId: Int,
|
||||
@StringRes val descriptionId: Int,
|
||||
@DrawableRes val iconId: Int,
|
||||
val appletInfo: AppletInfo,
|
||||
val cabinetMode: CabinetMode = CabinetMode.None
|
||||
)
|
||||
|
||||
// Combination of Common::AM::Applets::AppletId enum and the entry id
|
||||
enum class AppletInfo(val appletId: Int, val entryId: Long = 0) {
|
||||
None(0x00),
|
||||
Application(0x01),
|
||||
OverlayDisplay(0x02),
|
||||
QLaunch(0x03),
|
||||
Starter(0x04),
|
||||
Auth(0x0A),
|
||||
Cabinet(0x0B, 0x0100000000001002),
|
||||
Controller(0x0C),
|
||||
DataErase(0x0D),
|
||||
Error(0x0E),
|
||||
NetConnect(0x0F),
|
||||
ProfileSelect(0x10),
|
||||
SoftwareKeyboard(0x11),
|
||||
MiiEdit(0x12, 0x0100000000001009),
|
||||
Web(0x13),
|
||||
Shop(0x14),
|
||||
PhotoViewer(0x015, 0x010000000000100D),
|
||||
Settings(0x16),
|
||||
OfflineWeb(0x17),
|
||||
LoginShare(0x18),
|
||||
WebAuth(0x19),
|
||||
MyPage(0x1A)
|
||||
}
|
||||
|
||||
// Matches enum in Service::NFP::CabinetMode with extra metadata
|
||||
enum class CabinetMode(
|
||||
val id: Int,
|
||||
@StringRes val titleId: Int = 0,
|
||||
@DrawableRes val iconId: Int = 0
|
||||
) {
|
||||
None(-1),
|
||||
StartNicknameAndOwnerSettings(0, R.string.cabinet_nickname_and_owner, R.drawable.ic_edit),
|
||||
StartGameDataEraser(1, R.string.cabinet_game_data_eraser, R.drawable.ic_refresh),
|
||||
StartRestorer(2, R.string.cabinet_restorer, R.drawable.ic_restore),
|
||||
StartFormatter(3, R.string.cabinet_formatter, R.drawable.ic_clear)
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.model
|
||||
|
||||
import org.sudachi.sudachi_emu.utils.GpuDriverMetadata
|
||||
|
||||
data class Driver(
|
||||
override var selected: Boolean,
|
||||
val title: String,
|
||||
val version: String = "",
|
||||
val description: String = ""
|
||||
) : SelectableItem {
|
||||
override fun onSelectionStateChanged(selected: Boolean) {
|
||||
this.selected = selected
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun GpuDriverMetadata.toDriver(selected: Boolean = false): Driver =
|
||||
Driver(
|
||||
selected,
|
||||
this.name ?: "",
|
||||
this.version ?: "",
|
||||
this.description ?: ""
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,186 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.model
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.sudachi.sudachi_emu.NativeLibrary
|
||||
import org.sudachi.sudachi_emu.SudachiApplication
|
||||
import org.sudachi.sudachi_emu.utils.GameHelper
|
||||
import org.sudachi.sudachi_emu.utils.NativeConfig
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class GamesViewModel : ViewModel() {
|
||||
val games: StateFlow<List<Game>> get() = _games
|
||||
private val _games = MutableStateFlow(emptyList<Game>())
|
||||
|
||||
val searchedGames: StateFlow<List<Game>> get() = _searchedGames
|
||||
private val _searchedGames = MutableStateFlow(emptyList<Game>())
|
||||
|
||||
val isReloading: StateFlow<Boolean> get() = _isReloading
|
||||
private val _isReloading = MutableStateFlow(false)
|
||||
|
||||
private val reloading = AtomicBoolean(false)
|
||||
|
||||
val shouldSwapData: StateFlow<Boolean> get() = _shouldSwapData
|
||||
private val _shouldSwapData = MutableStateFlow(false)
|
||||
|
||||
val shouldScrollToTop: StateFlow<Boolean> get() = _shouldScrollToTop
|
||||
private val _shouldScrollToTop = MutableStateFlow(false)
|
||||
|
||||
val searchFocused: StateFlow<Boolean> get() = _searchFocused
|
||||
private val _searchFocused = MutableStateFlow(false)
|
||||
|
||||
private val _folders = MutableStateFlow(mutableListOf<GameDir>())
|
||||
val folders = _folders.asStateFlow()
|
||||
|
||||
init {
|
||||
// Ensure keys are loaded so that ROM metadata can be decrypted.
|
||||
NativeLibrary.reloadKeys()
|
||||
|
||||
getGameDirs()
|
||||
reloadGames(directoriesChanged = false, firstStartup = true)
|
||||
}
|
||||
|
||||
fun setGames(games: List<Game>) {
|
||||
val sortedList = games.sortedWith(
|
||||
compareBy(
|
||||
{ it.title.lowercase(Locale.getDefault()) },
|
||||
{ it.path }
|
||||
)
|
||||
)
|
||||
|
||||
_games.value = sortedList
|
||||
}
|
||||
|
||||
fun setSearchedGames(games: List<Game>) {
|
||||
_searchedGames.value = games
|
||||
}
|
||||
|
||||
fun setShouldSwapData(shouldSwap: Boolean) {
|
||||
_shouldSwapData.value = shouldSwap
|
||||
}
|
||||
|
||||
fun setShouldScrollToTop(shouldScroll: Boolean) {
|
||||
_shouldScrollToTop.value = shouldScroll
|
||||
}
|
||||
|
||||
fun setSearchFocused(searchFocused: Boolean) {
|
||||
_searchFocused.value = searchFocused
|
||||
}
|
||||
|
||||
fun reloadGames(directoriesChanged: Boolean, firstStartup: Boolean = false) {
|
||||
if (reloading.get()) {
|
||||
return
|
||||
}
|
||||
reloading.set(true)
|
||||
_isReloading.value = true
|
||||
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (firstStartup) {
|
||||
// Retrieve list of cached games
|
||||
val storedGames =
|
||||
PreferenceManager.getDefaultSharedPreferences(SudachiApplication.appContext)
|
||||
.getStringSet(GameHelper.KEY_GAMES, emptySet())
|
||||
if (storedGames!!.isNotEmpty()) {
|
||||
val deserializedGames = mutableSetOf<Game>()
|
||||
storedGames.forEach {
|
||||
val game: Game
|
||||
try {
|
||||
game = Json.decodeFromString(it)
|
||||
} catch (e: Exception) {
|
||||
// We don't care about any errors related to parsing the game cache
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val gameExists =
|
||||
DocumentFile.fromSingleUri(
|
||||
SudachiApplication.appContext,
|
||||
Uri.parse(game.path)
|
||||
)?.exists()
|
||||
if (gameExists == true) {
|
||||
deserializedGames.add(game)
|
||||
}
|
||||
}
|
||||
setGames(deserializedGames.toList())
|
||||
}
|
||||
}
|
||||
|
||||
setGames(GameHelper.getGames())
|
||||
reloading.set(false)
|
||||
_isReloading.value = false
|
||||
|
||||
if (directoriesChanged) {
|
||||
setShouldSwapData(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addFolder(gameDir: GameDir) =
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
NativeConfig.addGameDir(gameDir)
|
||||
getGameDirs(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFolder(gameDir: GameDir) =
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val gameDirs = _folders.value.toMutableList()
|
||||
val removedDirIndex = gameDirs.indexOf(gameDir)
|
||||
if (removedDirIndex != -1) {
|
||||
gameDirs.removeAt(removedDirIndex)
|
||||
NativeConfig.setGameDirs(gameDirs.toTypedArray())
|
||||
getGameDirs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateGameDirs() =
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
NativeConfig.setGameDirs(_folders.value.toTypedArray())
|
||||
getGameDirs()
|
||||
}
|
||||
}
|
||||
|
||||
fun onOpenGameFoldersFragment() =
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
getGameDirs()
|
||||
}
|
||||
}
|
||||
|
||||
fun onCloseGameFoldersFragment() {
|
||||
NativeConfig.saveGlobalConfig()
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
getGameDirs(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getGameDirs(reloadList: Boolean = false) {
|
||||
val gameDirs = NativeConfig.getGameDirs()
|
||||
_folders.value = gameDirs.toMutableList()
|
||||
if (reloadList) {
|
||||
reloadGames(true)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.model
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
data class HomeSetting(
|
||||
val titleId: Int,
|
||||
val descriptionId: Int,
|
||||
val iconId: Int,
|
||||
val onClick: () -> Unit,
|
||||
val isEnabled: () -> Boolean = { true },
|
||||
val disabledTitleId: Int = 0,
|
||||
val disabledMessageId: Int = 0,
|
||||
val details: StateFlow<String> = MutableStateFlow("")
|
||||
)
|
|
@ -0,0 +1,76 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.model
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
val navigationVisible: StateFlow<Pair<Boolean, Boolean>> get() = _navigationVisible
|
||||
private val _navigationVisible = MutableStateFlow(Pair(false, false))
|
||||
|
||||
val statusBarShadeVisible: StateFlow<Boolean> get() = _statusBarShadeVisible
|
||||
private val _statusBarShadeVisible = MutableStateFlow(true)
|
||||
|
||||
val shouldPageForward: StateFlow<Boolean> get() = _shouldPageForward
|
||||
private val _shouldPageForward = MutableStateFlow(false)
|
||||
|
||||
private val _gamesDirSelected = MutableStateFlow(false)
|
||||
val gamesDirSelected get() = _gamesDirSelected.asStateFlow()
|
||||
|
||||
private val _openImportSaves = MutableStateFlow(false)
|
||||
val openImportSaves get() = _openImportSaves.asStateFlow()
|
||||
|
||||
private val _contentToInstall = MutableStateFlow<List<Uri>?>(null)
|
||||
val contentToInstall get() = _contentToInstall.asStateFlow()
|
||||
|
||||
private val _reloadPropertiesList = MutableStateFlow(false)
|
||||
val reloadPropertiesList get() = _reloadPropertiesList.asStateFlow()
|
||||
|
||||
private val _checkKeys = MutableStateFlow(false)
|
||||
val checkKeys = _checkKeys.asStateFlow()
|
||||
|
||||
var navigatedToSetup = false
|
||||
|
||||
fun setNavigationVisibility(visible: Boolean, animated: Boolean) {
|
||||
if (navigationVisible.value.first == visible) {
|
||||
return
|
||||
}
|
||||
_navigationVisible.value = Pair(visible, animated)
|
||||
}
|
||||
|
||||
fun setStatusBarShadeVisibility(visible: Boolean) {
|
||||
if (statusBarShadeVisible.value == visible) {
|
||||
return
|
||||
}
|
||||
_statusBarShadeVisible.value = visible
|
||||
}
|
||||
|
||||
fun setShouldPageForward(pageForward: Boolean) {
|
||||
_shouldPageForward.value = pageForward
|
||||
}
|
||||
|
||||
fun setGamesDirSelected(selected: Boolean) {
|
||||
_gamesDirSelected.value = selected
|
||||
}
|
||||
|
||||
fun setOpenImportSaves(import: Boolean) {
|
||||
_openImportSaves.value = import
|
||||
}
|
||||
|
||||
fun setContentToInstall(documents: List<Uri>?) {
|
||||
_contentToInstall.value = documents
|
||||
}
|
||||
|
||||
fun reloadPropertiesList(reload: Boolean) {
|
||||
_reloadPropertiesList.value = reload
|
||||
}
|
||||
|
||||
fun setCheckKeys(value: Boolean) {
|
||||
_checkKeys.value = value
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.model
|
||||
|
||||
enum class InstallResult(val int: Int) {
|
||||
Success(0),
|
||||
Overwrite(1),
|
||||
Failure(2),
|
||||
BaseInstallAttempted(3);
|
||||
|
||||
companion object {
|
||||
fun from(int: Int): InstallResult = entries.firstOrNull { it.int == int } ?: Success
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class License(
|
||||
val titleId: Int,
|
||||
val descriptionId: Int,
|
||||
val linkId: Int,
|
||||
val copyrightId: Int,
|
||||
val licenseId: Int
|
||||
) : Parcelable
|
|
@ -0,0 +1,16 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.model
|
||||
|
||||
import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
data class Patch(
|
||||
var enabled: Boolean,
|
||||
val name: String,
|
||||
val version: String,
|
||||
val type: Int,
|
||||
val programId: String,
|
||||
val titleId: String
|
||||
)
|
|
@ -0,0 +1,14 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.model
|
||||
|
||||
enum class PatchType(val int: Int) {
|
||||
Update(0),
|
||||
DLC(1),
|
||||
Mod(2);
|
||||
|
||||
companion object {
|
||||
fun from(int: Int): PatchType = entries.firstOrNull { it.int == int } ?: Update
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.model
|
||||
|
||||
interface SelectableItem {
|
||||
var selected: Boolean
|
||||
fun onSelectionStateChanged(selected: Boolean)
|
||||
}
|
|
@ -0,0 +1,151 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.overlay
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.view.MotionEvent
|
||||
import org.sudachi.sudachi_emu.features.input.NativeInput.ButtonState
|
||||
import org.sudachi.sudachi_emu.features.input.model.NativeButton
|
||||
import org.sudachi.sudachi_emu.overlay.model.OverlayControlData
|
||||
|
||||
/**
|
||||
* Custom [BitmapDrawable] that is capable
|
||||
* of storing it's own ID.
|
||||
*
|
||||
* @param res [Resources] instance.
|
||||
* @param defaultStateBitmap [Bitmap] to use with the default state Drawable.
|
||||
* @param pressedStateBitmap [Bitmap] to use with the pressed state Drawable.
|
||||
* @param button [NativeButton] for this type of button.
|
||||
*/
|
||||
class InputOverlayDrawableButton(
|
||||
res: Resources,
|
||||
defaultStateBitmap: Bitmap,
|
||||
pressedStateBitmap: Bitmap,
|
||||
val button: NativeButton,
|
||||
val overlayControlData: OverlayControlData
|
||||
) {
|
||||
// The ID value what motion event is tracking
|
||||
var trackId: Int
|
||||
|
||||
// The drawable position on the screen
|
||||
private var buttonPositionX = 0
|
||||
private var buttonPositionY = 0
|
||||
|
||||
val width: Int
|
||||
val height: Int
|
||||
|
||||
private val defaultStateBitmap: BitmapDrawable
|
||||
private val pressedStateBitmap: BitmapDrawable
|
||||
private var pressedState = false
|
||||
|
||||
private var previousTouchX = 0
|
||||
private var previousTouchY = 0
|
||||
var controlPositionX = 0
|
||||
var controlPositionY = 0
|
||||
|
||||
init {
|
||||
this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap)
|
||||
this.pressedStateBitmap = BitmapDrawable(res, pressedStateBitmap)
|
||||
trackId = -1
|
||||
width = this.defaultStateBitmap.intrinsicWidth
|
||||
height = this.defaultStateBitmap.intrinsicHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates button status based on the motion event.
|
||||
*
|
||||
* @return true if value was changed
|
||||
*/
|
||||
fun updateStatus(event: MotionEvent): Boolean {
|
||||
val pointerIndex = event.actionIndex
|
||||
val xPosition = event.getX(pointerIndex).toInt()
|
||||
val yPosition = event.getY(pointerIndex).toInt()
|
||||
val pointerId = event.getPointerId(pointerIndex)
|
||||
val motionEvent = event.action and MotionEvent.ACTION_MASK
|
||||
val isActionDown =
|
||||
motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN
|
||||
val isActionUp =
|
||||
motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP
|
||||
|
||||
if (isActionDown) {
|
||||
if (!bounds.contains(xPosition, yPosition)) {
|
||||
return false
|
||||
}
|
||||
pressedState = true
|
||||
trackId = pointerId
|
||||
return true
|
||||
}
|
||||
|
||||
if (isActionUp) {
|
||||
if (trackId != pointerId) {
|
||||
return false
|
||||
}
|
||||
pressedState = false
|
||||
trackId = -1
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
fun setPosition(x: Int, y: Int) {
|
||||
buttonPositionX = x
|
||||
buttonPositionY = y
|
||||
}
|
||||
|
||||
fun draw(canvas: Canvas?) {
|
||||
currentStateBitmapDrawable.draw(canvas!!)
|
||||
}
|
||||
|
||||
private val currentStateBitmapDrawable: BitmapDrawable
|
||||
get() = if (pressedState) pressedStateBitmap else defaultStateBitmap
|
||||
|
||||
fun onConfigureTouch(event: MotionEvent): Boolean {
|
||||
val pointerIndex = event.actionIndex
|
||||
val fingerPositionX = event.getX(pointerIndex).toInt()
|
||||
val fingerPositionY = event.getY(pointerIndex).toInt()
|
||||
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
previousTouchX = fingerPositionX
|
||||
previousTouchY = fingerPositionY
|
||||
controlPositionX = fingerPositionX - (width / 2)
|
||||
controlPositionY = fingerPositionY - (height / 2)
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
controlPositionX += fingerPositionX - previousTouchX
|
||||
controlPositionY += fingerPositionY - previousTouchY
|
||||
setBounds(
|
||||
controlPositionX,
|
||||
controlPositionY,
|
||||
width + controlPositionX,
|
||||
height + controlPositionY
|
||||
)
|
||||
previousTouchX = fingerPositionX
|
||||
previousTouchY = fingerPositionY
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun setBounds(left: Int, top: Int, right: Int, bottom: Int) {
|
||||
defaultStateBitmap.setBounds(left, top, right, bottom)
|
||||
pressedStateBitmap.setBounds(left, top, right, bottom)
|
||||
}
|
||||
|
||||
fun setOpacity(value: Int) {
|
||||
defaultStateBitmap.alpha = value
|
||||
pressedStateBitmap.alpha = value
|
||||
}
|
||||
|
||||
val status: Int
|
||||
get() = if (pressedState) ButtonState.PRESSED else ButtonState.RELEASED
|
||||
val bounds: Rect
|
||||
get() = defaultStateBitmap.bounds
|
||||
}
|
|
@ -0,0 +1,266 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.overlay
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.view.MotionEvent
|
||||
import org.sudachi.sudachi_emu.features.input.NativeInput.ButtonState
|
||||
import org.sudachi.sudachi_emu.features.input.model.NativeButton
|
||||
|
||||
/**
|
||||
* Custom [BitmapDrawable] that is capable
|
||||
* of storing it's own ID.
|
||||
*
|
||||
* @param res [Resources] instance.
|
||||
* @param defaultStateBitmap [Bitmap] of the default state.
|
||||
* @param pressedOneDirectionStateBitmap [Bitmap] of the pressed state in one direction.
|
||||
* @param pressedTwoDirectionsStateBitmap [Bitmap] of the pressed state in two direction.
|
||||
*/
|
||||
class InputOverlayDrawableDpad(
|
||||
res: Resources,
|
||||
defaultStateBitmap: Bitmap,
|
||||
pressedOneDirectionStateBitmap: Bitmap,
|
||||
pressedTwoDirectionsStateBitmap: Bitmap
|
||||
) {
|
||||
/**
|
||||
* Gets one of the InputOverlayDrawableDpad's button IDs.
|
||||
*
|
||||
* @return the requested InputOverlayDrawableDpad's button ID.
|
||||
*/
|
||||
// The ID identifying what type of button this Drawable represents.
|
||||
val up = NativeButton.DUp
|
||||
val down = NativeButton.DDown
|
||||
val left = NativeButton.DLeft
|
||||
val right = NativeButton.DRight
|
||||
var trackId: Int
|
||||
|
||||
val width: Int
|
||||
val height: Int
|
||||
|
||||
private val defaultStateBitmap: BitmapDrawable
|
||||
private val pressedOneDirectionStateBitmap: BitmapDrawable
|
||||
private val pressedTwoDirectionsStateBitmap: BitmapDrawable
|
||||
|
||||
private var previousTouchX = 0
|
||||
private var previousTouchY = 0
|
||||
private var controlPositionX = 0
|
||||
private var controlPositionY = 0
|
||||
|
||||
private var upButtonState = false
|
||||
private var downButtonState = false
|
||||
private var leftButtonState = false
|
||||
private var rightButtonState = false
|
||||
|
||||
init {
|
||||
this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap)
|
||||
this.pressedOneDirectionStateBitmap = BitmapDrawable(res, pressedOneDirectionStateBitmap)
|
||||
this.pressedTwoDirectionsStateBitmap = BitmapDrawable(res, pressedTwoDirectionsStateBitmap)
|
||||
width = this.defaultStateBitmap.intrinsicWidth
|
||||
height = this.defaultStateBitmap.intrinsicHeight
|
||||
trackId = -1
|
||||
}
|
||||
|
||||
fun updateStatus(event: MotionEvent, dpad_slide: Boolean): Boolean {
|
||||
val pointerIndex = event.actionIndex
|
||||
val xPosition = event.getX(pointerIndex).toInt()
|
||||
val yPosition = event.getY(pointerIndex).toInt()
|
||||
val pointerId = event.getPointerId(pointerIndex)
|
||||
val motionEvent = event.action and MotionEvent.ACTION_MASK
|
||||
val isActionDown =
|
||||
motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN
|
||||
val isActionUp =
|
||||
motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP
|
||||
if (isActionDown) {
|
||||
if (!bounds.contains(xPosition, yPosition)) {
|
||||
return false
|
||||
}
|
||||
trackId = pointerId
|
||||
}
|
||||
if (isActionUp) {
|
||||
if (trackId != pointerId) {
|
||||
return false
|
||||
}
|
||||
trackId = -1
|
||||
upButtonState = false
|
||||
downButtonState = false
|
||||
leftButtonState = false
|
||||
rightButtonState = false
|
||||
return true
|
||||
}
|
||||
if (trackId == -1) {
|
||||
return false
|
||||
}
|
||||
if (!dpad_slide && !isActionDown) {
|
||||
return false
|
||||
}
|
||||
for (i in 0 until event.pointerCount) {
|
||||
if (trackId != event.getPointerId(i)) {
|
||||
continue
|
||||
}
|
||||
|
||||
var touchX = event.getX(i)
|
||||
var touchY = event.getY(i)
|
||||
var maxY = bounds.bottom.toFloat()
|
||||
var maxX = bounds.right.toFloat()
|
||||
touchX -= bounds.centerX().toFloat()
|
||||
maxX -= bounds.centerX().toFloat()
|
||||
touchY -= bounds.centerY().toFloat()
|
||||
maxY -= bounds.centerY().toFloat()
|
||||
val axisX = touchX / maxX
|
||||
val axisY = touchY / maxY
|
||||
val oldUpState = upButtonState
|
||||
val oldDownState = downButtonState
|
||||
val oldLeftState = leftButtonState
|
||||
val oldRightState = rightButtonState
|
||||
|
||||
upButtonState = axisY < -VIRT_AXIS_DEADZONE
|
||||
downButtonState = axisY > VIRT_AXIS_DEADZONE
|
||||
leftButtonState = axisX < -VIRT_AXIS_DEADZONE
|
||||
rightButtonState = axisX > VIRT_AXIS_DEADZONE
|
||||
return oldUpState != upButtonState ||
|
||||
oldDownState != downButtonState ||
|
||||
oldLeftState != leftButtonState ||
|
||||
oldRightState != rightButtonState
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun draw(canvas: Canvas) {
|
||||
val px = controlPositionX + width / 2
|
||||
val py = controlPositionY + height / 2
|
||||
|
||||
// Pressed up
|
||||
if (upButtonState && !leftButtonState && !rightButtonState) {
|
||||
pressedOneDirectionStateBitmap.draw(canvas)
|
||||
return
|
||||
}
|
||||
|
||||
// Pressed down
|
||||
if (downButtonState && !leftButtonState && !rightButtonState) {
|
||||
canvas.save()
|
||||
canvas.rotate(180f, px.toFloat(), py.toFloat())
|
||||
pressedOneDirectionStateBitmap.draw(canvas)
|
||||
canvas.restore()
|
||||
return
|
||||
}
|
||||
|
||||
// Pressed left
|
||||
if (leftButtonState && !upButtonState && !downButtonState) {
|
||||
canvas.save()
|
||||
canvas.rotate(270f, px.toFloat(), py.toFloat())
|
||||
pressedOneDirectionStateBitmap.draw(canvas)
|
||||
canvas.restore()
|
||||
return
|
||||
}
|
||||
|
||||
// Pressed right
|
||||
if (rightButtonState && !upButtonState && !downButtonState) {
|
||||
canvas.save()
|
||||
canvas.rotate(90f, px.toFloat(), py.toFloat())
|
||||
pressedOneDirectionStateBitmap.draw(canvas)
|
||||
canvas.restore()
|
||||
return
|
||||
}
|
||||
|
||||
// Pressed up left
|
||||
if (upButtonState && leftButtonState && !rightButtonState) {
|
||||
pressedTwoDirectionsStateBitmap.draw(canvas)
|
||||
return
|
||||
}
|
||||
|
||||
// Pressed up right
|
||||
if (upButtonState && !leftButtonState && rightButtonState) {
|
||||
canvas.save()
|
||||
canvas.rotate(90f, px.toFloat(), py.toFloat())
|
||||
pressedTwoDirectionsStateBitmap.draw(canvas)
|
||||
canvas.restore()
|
||||
return
|
||||
}
|
||||
|
||||
// Pressed down right
|
||||
if (downButtonState && !leftButtonState && rightButtonState) {
|
||||
canvas.save()
|
||||
canvas.rotate(180f, px.toFloat(), py.toFloat())
|
||||
pressedTwoDirectionsStateBitmap.draw(canvas)
|
||||
canvas.restore()
|
||||
return
|
||||
}
|
||||
|
||||
// Pressed down left
|
||||
if (downButtonState && leftButtonState && !rightButtonState) {
|
||||
canvas.save()
|
||||
canvas.rotate(270f, px.toFloat(), py.toFloat())
|
||||
pressedTwoDirectionsStateBitmap.draw(canvas)
|
||||
canvas.restore()
|
||||
return
|
||||
}
|
||||
|
||||
// Not pressed
|
||||
defaultStateBitmap.draw(canvas)
|
||||
}
|
||||
|
||||
val upStatus: Int
|
||||
get() = if (upButtonState) ButtonState.PRESSED else ButtonState.RELEASED
|
||||
val downStatus: Int
|
||||
get() = if (downButtonState) ButtonState.PRESSED else ButtonState.RELEASED
|
||||
val leftStatus: Int
|
||||
get() = if (leftButtonState) ButtonState.PRESSED else ButtonState.RELEASED
|
||||
val rightStatus: Int
|
||||
get() = if (rightButtonState) ButtonState.PRESSED else ButtonState.RELEASED
|
||||
|
||||
fun onConfigureTouch(event: MotionEvent): Boolean {
|
||||
val pointerIndex = event.actionIndex
|
||||
val fingerPositionX = event.getX(pointerIndex).toInt()
|
||||
val fingerPositionY = event.getY(pointerIndex).toInt()
|
||||
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
previousTouchX = fingerPositionX
|
||||
previousTouchY = fingerPositionY
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
controlPositionX += fingerPositionX - previousTouchX
|
||||
controlPositionY += fingerPositionY - previousTouchY
|
||||
setBounds(
|
||||
controlPositionX,
|
||||
controlPositionY,
|
||||
width + controlPositionX,
|
||||
height + controlPositionY
|
||||
)
|
||||
previousTouchX = fingerPositionX
|
||||
previousTouchY = fingerPositionY
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun setPosition(x: Int, y: Int) {
|
||||
controlPositionX = x
|
||||
controlPositionY = y
|
||||
}
|
||||
|
||||
fun setBounds(left: Int, top: Int, right: Int, bottom: Int) {
|
||||
defaultStateBitmap.setBounds(left, top, right, bottom)
|
||||
pressedOneDirectionStateBitmap.setBounds(left, top, right, bottom)
|
||||
pressedTwoDirectionsStateBitmap.setBounds(left, top, right, bottom)
|
||||
}
|
||||
|
||||
fun setOpacity(value: Int) {
|
||||
defaultStateBitmap.alpha = value
|
||||
pressedOneDirectionStateBitmap.alpha = value
|
||||
pressedTwoDirectionsStateBitmap.alpha = value
|
||||
}
|
||||
|
||||
val bounds: Rect
|
||||
get() = defaultStateBitmap.bounds
|
||||
|
||||
companion object {
|
||||
const val VIRT_AXIS_DEADZONE = 0.5f
|
||||
}
|
||||
}
|
|
@ -0,0 +1,188 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.overlay.model
|
||||
|
||||
import androidx.annotation.IntegerRes
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.SudachiApplication
|
||||
|
||||
enum class OverlayControl(
|
||||
val id: String,
|
||||
val defaultVisibility: Boolean,
|
||||
@IntegerRes val defaultLandscapePositionResources: Pair<Int, Int>,
|
||||
@IntegerRes val defaultPortraitPositionResources: Pair<Int, Int>,
|
||||
@IntegerRes val defaultFoldablePositionResources: Pair<Int, Int>
|
||||
) {
|
||||
BUTTON_A(
|
||||
"button_a",
|
||||
true,
|
||||
Pair(R.integer.BUTTON_A_X, R.integer.BUTTON_A_Y),
|
||||
Pair(R.integer.BUTTON_A_X_PORTRAIT, R.integer.BUTTON_A_Y_PORTRAIT),
|
||||
Pair(R.integer.BUTTON_A_X_FOLDABLE, R.integer.BUTTON_A_Y_FOLDABLE)
|
||||
),
|
||||
BUTTON_B(
|
||||
"button_b",
|
||||
true,
|
||||
Pair(R.integer.BUTTON_B_X, R.integer.BUTTON_B_Y),
|
||||
Pair(R.integer.BUTTON_B_X_PORTRAIT, R.integer.BUTTON_B_Y_PORTRAIT),
|
||||
Pair(R.integer.BUTTON_B_X_FOLDABLE, R.integer.BUTTON_B_Y_FOLDABLE)
|
||||
),
|
||||
BUTTON_X(
|
||||
"button_x",
|
||||
true,
|
||||
Pair(R.integer.BUTTON_X_X, R.integer.BUTTON_X_Y),
|
||||
Pair(R.integer.BUTTON_X_X_PORTRAIT, R.integer.BUTTON_X_Y_PORTRAIT),
|
||||
Pair(R.integer.BUTTON_X_X_FOLDABLE, R.integer.BUTTON_X_Y_FOLDABLE)
|
||||
),
|
||||
BUTTON_Y(
|
||||
"button_y",
|
||||
true,
|
||||
Pair(R.integer.BUTTON_Y_X, R.integer.BUTTON_Y_Y),
|
||||
Pair(R.integer.BUTTON_Y_X_PORTRAIT, R.integer.BUTTON_Y_Y_PORTRAIT),
|
||||
Pair(R.integer.BUTTON_Y_X_FOLDABLE, R.integer.BUTTON_Y_Y_FOLDABLE)
|
||||
),
|
||||
BUTTON_PLUS(
|
||||
"button_plus",
|
||||
true,
|
||||
Pair(R.integer.BUTTON_PLUS_X, R.integer.BUTTON_PLUS_Y),
|
||||
Pair(R.integer.BUTTON_PLUS_X_PORTRAIT, R.integer.BUTTON_PLUS_Y_PORTRAIT),
|
||||
Pair(R.integer.BUTTON_PLUS_X_FOLDABLE, R.integer.BUTTON_PLUS_Y_FOLDABLE)
|
||||
),
|
||||
BUTTON_MINUS(
|
||||
"button_minus",
|
||||
true,
|
||||
Pair(R.integer.BUTTON_MINUS_X, R.integer.BUTTON_MINUS_Y),
|
||||
Pair(R.integer.BUTTON_MINUS_X_PORTRAIT, R.integer.BUTTON_MINUS_Y_PORTRAIT),
|
||||
Pair(R.integer.BUTTON_MINUS_X_FOLDABLE, R.integer.BUTTON_MINUS_Y_FOLDABLE)
|
||||
),
|
||||
BUTTON_HOME(
|
||||
"button_home",
|
||||
false,
|
||||
Pair(R.integer.BUTTON_HOME_X, R.integer.BUTTON_HOME_Y),
|
||||
Pair(R.integer.BUTTON_HOME_X_PORTRAIT, R.integer.BUTTON_HOME_Y_PORTRAIT),
|
||||
Pair(R.integer.BUTTON_HOME_X_FOLDABLE, R.integer.BUTTON_HOME_Y_FOLDABLE)
|
||||
),
|
||||
BUTTON_CAPTURE(
|
||||
"button_capture",
|
||||
false,
|
||||
Pair(R.integer.BUTTON_CAPTURE_X, R.integer.BUTTON_CAPTURE_Y),
|
||||
Pair(R.integer.BUTTON_CAPTURE_X_PORTRAIT, R.integer.BUTTON_CAPTURE_Y_PORTRAIT),
|
||||
Pair(R.integer.BUTTON_CAPTURE_X_FOLDABLE, R.integer.BUTTON_CAPTURE_Y_FOLDABLE)
|
||||
),
|
||||
BUTTON_L(
|
||||
"button_l",
|
||||
true,
|
||||
Pair(R.integer.BUTTON_L_X, R.integer.BUTTON_L_Y),
|
||||
Pair(R.integer.BUTTON_L_X_PORTRAIT, R.integer.BUTTON_L_Y_PORTRAIT),
|
||||
Pair(R.integer.BUTTON_L_X_FOLDABLE, R.integer.BUTTON_L_Y_FOLDABLE)
|
||||
),
|
||||
BUTTON_R(
|
||||
"button_r",
|
||||
true,
|
||||
Pair(R.integer.BUTTON_R_X, R.integer.BUTTON_R_Y),
|
||||
Pair(R.integer.BUTTON_R_X_PORTRAIT, R.integer.BUTTON_R_Y_PORTRAIT),
|
||||
Pair(R.integer.BUTTON_R_X_FOLDABLE, R.integer.BUTTON_R_Y_FOLDABLE)
|
||||
),
|
||||
BUTTON_ZL(
|
||||
"button_zl",
|
||||
true,
|
||||
Pair(R.integer.BUTTON_ZL_X, R.integer.BUTTON_ZL_Y),
|
||||
Pair(R.integer.BUTTON_ZL_X_PORTRAIT, R.integer.BUTTON_ZL_Y_PORTRAIT),
|
||||
Pair(R.integer.BUTTON_ZL_X_FOLDABLE, R.integer.BUTTON_ZL_Y_FOLDABLE)
|
||||
),
|
||||
BUTTON_ZR(
|
||||
"button_zr",
|
||||
true,
|
||||
Pair(R.integer.BUTTON_ZR_X, R.integer.BUTTON_ZR_Y),
|
||||
Pair(R.integer.BUTTON_ZR_X_PORTRAIT, R.integer.BUTTON_ZR_Y_PORTRAIT),
|
||||
Pair(R.integer.BUTTON_ZR_X_FOLDABLE, R.integer.BUTTON_ZR_Y_FOLDABLE)
|
||||
),
|
||||
BUTTON_STICK_L(
|
||||
"button_stick_l",
|
||||
true,
|
||||
Pair(R.integer.BUTTON_STICK_L_X, R.integer.BUTTON_STICK_L_Y),
|
||||
Pair(R.integer.BUTTON_STICK_L_X_PORTRAIT, R.integer.BUTTON_STICK_L_Y_PORTRAIT),
|
||||
Pair(R.integer.BUTTON_STICK_L_X_FOLDABLE, R.integer.BUTTON_STICK_L_Y_FOLDABLE)
|
||||
),
|
||||
BUTTON_STICK_R(
|
||||
"button_stick_r",
|
||||
true,
|
||||
Pair(R.integer.BUTTON_STICK_R_X, R.integer.BUTTON_STICK_R_Y),
|
||||
Pair(R.integer.BUTTON_STICK_R_X_PORTRAIT, R.integer.BUTTON_STICK_R_Y_PORTRAIT),
|
||||
Pair(R.integer.BUTTON_STICK_R_X_FOLDABLE, R.integer.BUTTON_STICK_R_Y_FOLDABLE)
|
||||
),
|
||||
STICK_L(
|
||||
"stick_l",
|
||||
true,
|
||||
Pair(R.integer.STICK_L_X, R.integer.STICK_L_Y),
|
||||
Pair(R.integer.STICK_L_X_PORTRAIT, R.integer.STICK_L_Y_PORTRAIT),
|
||||
Pair(R.integer.STICK_L_X_FOLDABLE, R.integer.STICK_L_Y_FOLDABLE)
|
||||
),
|
||||
STICK_R(
|
||||
"stick_r",
|
||||
true,
|
||||
Pair(R.integer.STICK_R_X, R.integer.STICK_R_Y),
|
||||
Pair(R.integer.STICK_R_X_PORTRAIT, R.integer.STICK_R_Y_PORTRAIT),
|
||||
Pair(R.integer.STICK_R_X_FOLDABLE, R.integer.STICK_R_Y_FOLDABLE)
|
||||
),
|
||||
COMBINED_DPAD(
|
||||
"combined_dpad",
|
||||
true,
|
||||
Pair(R.integer.COMBINED_DPAD_X, R.integer.COMBINED_DPAD_Y),
|
||||
Pair(R.integer.COMBINED_DPAD_X_PORTRAIT, R.integer.COMBINED_DPAD_Y_PORTRAIT),
|
||||
Pair(R.integer.COMBINED_DPAD_X_FOLDABLE, R.integer.COMBINED_DPAD_Y_FOLDABLE)
|
||||
);
|
||||
|
||||
fun getDefaultPositionForLayout(layout: OverlayLayout): Pair<Double, Double> {
|
||||
val rawResourcePair: Pair<Int, Int>
|
||||
SudachiApplication.appContext.resources.apply {
|
||||
rawResourcePair = when (layout) {
|
||||
OverlayLayout.Landscape -> {
|
||||
Pair(
|
||||
getInteger(this@OverlayControl.defaultLandscapePositionResources.first),
|
||||
getInteger(this@OverlayControl.defaultLandscapePositionResources.second)
|
||||
)
|
||||
}
|
||||
|
||||
OverlayLayout.Portrait -> {
|
||||
Pair(
|
||||
getInteger(this@OverlayControl.defaultPortraitPositionResources.first),
|
||||
getInteger(this@OverlayControl.defaultPortraitPositionResources.second)
|
||||
)
|
||||
}
|
||||
|
||||
OverlayLayout.Foldable -> {
|
||||
Pair(
|
||||
getInteger(this@OverlayControl.defaultFoldablePositionResources.first),
|
||||
getInteger(this@OverlayControl.defaultFoldablePositionResources.second)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Pair(
|
||||
rawResourcePair.first.toDouble() / 1000,
|
||||
rawResourcePair.second.toDouble() / 1000
|
||||
)
|
||||
}
|
||||
|
||||
fun toOverlayControlData(): OverlayControlData =
|
||||
OverlayControlData(
|
||||
id,
|
||||
defaultVisibility,
|
||||
getDefaultPositionForLayout(OverlayLayout.Landscape),
|
||||
getDefaultPositionForLayout(OverlayLayout.Portrait),
|
||||
getDefaultPositionForLayout(OverlayLayout.Foldable)
|
||||
)
|
||||
|
||||
companion object {
|
||||
val map: HashMap<String, OverlayControl> by lazy {
|
||||
val hashMap = hashMapOf<String, OverlayControl>()
|
||||
entries.forEach { hashMap[it.id] = it }
|
||||
hashMap
|
||||
}
|
||||
|
||||
fun from(id: String): OverlayControl? = map[id]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.overlay.model
|
||||
|
||||
data class OverlayControlData(
|
||||
val id: String,
|
||||
var enabled: Boolean,
|
||||
var landscapePosition: Pair<Double, Double>,
|
||||
var portraitPosition: Pair<Double, Double>,
|
||||
var foldablePosition: Pair<Double, Double>
|
||||
) {
|
||||
fun positionFromLayout(layout: OverlayLayout): Pair<Double, Double> =
|
||||
when (layout) {
|
||||
OverlayLayout.Landscape -> landscapePosition
|
||||
OverlayLayout.Portrait -> portraitPosition
|
||||
OverlayLayout.Foldable -> foldablePosition
|
||||
}
|
||||
}
|
|
@ -0,0 +1,692 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.ui.main
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup.MarginLayoutParams
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.PathInterpolator
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.navigation.NavigationBarView
|
||||
import java.io.File
|
||||
import java.io.FilenameFilter
|
||||
import org.sudachi.sudachi_emu.HomeNavigationDirections
|
||||
import org.sudachi.sudachi_emu.NativeLibrary
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.databinding.ActivityMainBinding
|
||||
import org.sudachi.sudachi_emu.features.settings.model.Settings
|
||||
import org.sudachi.sudachi_emu.fragments.AddGameFolderDialogFragment
|
||||
import org.sudachi.sudachi_emu.fragments.ProgressDialogFragment
|
||||
import org.sudachi.sudachi_emu.fragments.MessageDialogFragment
|
||||
import org.sudachi.sudachi_emu.model.AddonViewModel
|
||||
import org.sudachi.sudachi_emu.model.DriverViewModel
|
||||
import org.sudachi.sudachi_emu.model.GamesViewModel
|
||||
import org.sudachi.sudachi_emu.model.HomeViewModel
|
||||
import org.sudachi.sudachi_emu.model.InstallResult
|
||||
import org.sudachi.sudachi_emu.model.TaskState
|
||||
import org.sudachi.sudachi_emu.model.TaskViewModel
|
||||
import org.sudachi.sudachi_emu.utils.*
|
||||
import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
class MainActivity : AppCompatActivity(), ThemeProvider {
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
|
||||
private val homeViewModel: HomeViewModel by viewModels()
|
||||
private val gamesViewModel: GamesViewModel by viewModels()
|
||||
private val taskViewModel: TaskViewModel by viewModels()
|
||||
private val addonViewModel: AddonViewModel by viewModels()
|
||||
private val driverViewModel: DriverViewModel by viewModels()
|
||||
|
||||
override var themeId: Int = 0
|
||||
|
||||
private val CHECKED_DECRYPTION = "CheckedDecryption"
|
||||
private var checkedDecryption = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val splashScreen = installSplashScreen()
|
||||
splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady }
|
||||
|
||||
ThemeHelper.setTheme(this)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
if (savedInstanceState != null) {
|
||||
checkedDecryption = savedInstanceState.getBoolean(CHECKED_DECRYPTION)
|
||||
}
|
||||
if (!checkedDecryption) {
|
||||
val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||
.getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true)
|
||||
if (!firstTimeSetup) {
|
||||
checkKeys()
|
||||
}
|
||||
checkedDecryption = true
|
||||
}
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
|
||||
window.statusBarColor =
|
||||
ContextCompat.getColor(applicationContext, android.R.color.transparent)
|
||||
window.navigationBarColor =
|
||||
ContextCompat.getColor(applicationContext, android.R.color.transparent)
|
||||
|
||||
binding.statusBarShade.setBackgroundColor(
|
||||
ThemeHelper.getColorWithOpacity(
|
||||
MaterialColors.getColor(
|
||||
binding.root,
|
||||
com.google.android.material.R.attr.colorSurface
|
||||
),
|
||||
ThemeHelper.SYSTEM_BAR_ALPHA
|
||||
)
|
||||
)
|
||||
if (InsetsHelper.getSystemGestureType(applicationContext) !=
|
||||
InsetsHelper.GESTURE_NAVIGATION
|
||||
) {
|
||||
binding.navigationBarShade.setBackgroundColor(
|
||||
ThemeHelper.getColorWithOpacity(
|
||||
MaterialColors.getColor(
|
||||
binding.root,
|
||||
com.google.android.material.R.attr.colorSurface
|
||||
),
|
||||
ThemeHelper.SYSTEM_BAR_ALPHA
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val navHostFragment =
|
||||
supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment
|
||||
setUpNavigation(navHostFragment.navController)
|
||||
(binding.navigationView as NavigationBarView).setOnItemReselectedListener {
|
||||
when (it.itemId) {
|
||||
R.id.gamesFragment -> gamesViewModel.setShouldScrollToTop(true)
|
||||
R.id.searchFragment -> gamesViewModel.setSearchFocused(true)
|
||||
R.id.homeSettingsFragment -> {
|
||||
val action = HomeNavigationDirections.actionGlobalSettingsActivity(
|
||||
null,
|
||||
Settings.MenuTag.SECTION_ROOT
|
||||
)
|
||||
navHostFragment.navController.navigate(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prevents navigation from being drawn for a short time on recreation if set to hidden
|
||||
if (!homeViewModel.navigationVisible.value.first) {
|
||||
binding.navigationView.setVisible(visible = false, gone = false)
|
||||
binding.statusBarShade.setVisible(visible = false, gone = false)
|
||||
}
|
||||
|
||||
homeViewModel.navigationVisible.collect(this) { showNavigation(it.first, it.second) }
|
||||
homeViewModel.statusBarShadeVisible.collect(this) { showStatusBarShade(it) }
|
||||
homeViewModel.contentToInstall.collect(
|
||||
this,
|
||||
resetState = { homeViewModel.setContentToInstall(null) }
|
||||
) {
|
||||
if (it != null) {
|
||||
installContent(it)
|
||||
}
|
||||
}
|
||||
homeViewModel.checkKeys.collect(this, resetState = { homeViewModel.setCheckKeys(false) }) {
|
||||
if (it) checkKeys()
|
||||
}
|
||||
|
||||
setInsets()
|
||||
}
|
||||
|
||||
private fun checkKeys() {
|
||||
if (!NativeLibrary.areKeysPresent()) {
|
||||
MessageDialogFragment.newInstance(
|
||||
titleId = R.string.keys_missing,
|
||||
descriptionId = R.string.keys_missing_description,
|
||||
helpLinkId = R.string.keys_missing_help
|
||||
).show(supportFragmentManager, MessageDialogFragment.TAG)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putBoolean(CHECKED_DECRYPTION, checkedDecryption)
|
||||
}
|
||||
|
||||
fun finishSetup(navController: NavController) {
|
||||
navController.navigate(R.id.action_firstTimeSetupFragment_to_gamesFragment)
|
||||
(binding.navigationView as NavigationBarView).setupWithNavController(navController)
|
||||
showNavigation(visible = true, animated = true)
|
||||
}
|
||||
|
||||
private fun setUpNavigation(navController: NavController) {
|
||||
val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||
.getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true)
|
||||
|
||||
if (firstTimeSetup && !homeViewModel.navigatedToSetup) {
|
||||
navController.navigate(R.id.firstTimeSetupFragment)
|
||||
homeViewModel.navigatedToSetup = true
|
||||
} else {
|
||||
(binding.navigationView as NavigationBarView).setupWithNavController(navController)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showNavigation(visible: Boolean, animated: Boolean) {
|
||||
if (!animated) {
|
||||
binding.navigationView.setVisible(visible)
|
||||
return
|
||||
}
|
||||
|
||||
val smallLayout = resources.getBoolean(R.bool.small_layout)
|
||||
binding.navigationView.animate().apply {
|
||||
if (visible) {
|
||||
binding.navigationView.setVisible(true)
|
||||
duration = 300
|
||||
interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f)
|
||||
|
||||
if (smallLayout) {
|
||||
binding.navigationView.translationY =
|
||||
binding.navigationView.height.toFloat() * 2
|
||||
translationY(0f)
|
||||
} else {
|
||||
if (ViewCompat.getLayoutDirection(binding.navigationView) ==
|
||||
ViewCompat.LAYOUT_DIRECTION_LTR
|
||||
) {
|
||||
binding.navigationView.translationX =
|
||||
binding.navigationView.width.toFloat() * -2
|
||||
translationX(0f)
|
||||
} else {
|
||||
binding.navigationView.translationX =
|
||||
binding.navigationView.width.toFloat() * 2
|
||||
translationX(0f)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
duration = 300
|
||||
interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f)
|
||||
|
||||
if (smallLayout) {
|
||||
translationY(binding.navigationView.height.toFloat() * 2)
|
||||
} else {
|
||||
if (ViewCompat.getLayoutDirection(binding.navigationView) ==
|
||||
ViewCompat.LAYOUT_DIRECTION_LTR
|
||||
) {
|
||||
translationX(binding.navigationView.width.toFloat() * -2)
|
||||
} else {
|
||||
translationX(binding.navigationView.width.toFloat() * 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.withEndAction {
|
||||
if (!visible) {
|
||||
binding.navigationView.setVisible(visible = false, gone = false)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun showStatusBarShade(visible: Boolean) {
|
||||
binding.statusBarShade.animate().apply {
|
||||
if (visible) {
|
||||
binding.statusBarShade.setVisible(true)
|
||||
binding.statusBarShade.translationY = binding.statusBarShade.height.toFloat() * -2
|
||||
duration = 300
|
||||
translationY(0f)
|
||||
interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f)
|
||||
} else {
|
||||
duration = 300
|
||||
translationY(binding.navigationView.height.toFloat() * -2)
|
||||
interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f)
|
||||
}
|
||||
}.withEndAction {
|
||||
if (!visible) {
|
||||
binding.statusBarShade.setVisible(visible = false, gone = false)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
ThemeHelper.setCorrectTheme(this)
|
||||
super.onResume()
|
||||
}
|
||||
|
||||
private fun setInsets() =
|
||||
ViewCompat.setOnApplyWindowInsetsListener(
|
||||
binding.root
|
||||
) { _: View, windowInsets: WindowInsetsCompat ->
|
||||
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val mlpStatusShade = binding.statusBarShade.layoutParams as MarginLayoutParams
|
||||
mlpStatusShade.height = insets.top
|
||||
binding.statusBarShade.layoutParams = mlpStatusShade
|
||||
|
||||
// The only situation where we care to have a nav bar shade is when it's at the bottom
|
||||
// of the screen where scrolling list elements can go behind it.
|
||||
val mlpNavShade = binding.navigationBarShade.layoutParams as MarginLayoutParams
|
||||
mlpNavShade.height = insets.bottom
|
||||
binding.navigationBarShade.layoutParams = mlpNavShade
|
||||
|
||||
windowInsets
|
||||
}
|
||||
|
||||
override fun setTheme(resId: Int) {
|
||||
super.setTheme(resId)
|
||||
themeId = resId
|
||||
}
|
||||
|
||||
val getGamesDirectory =
|
||||
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result ->
|
||||
if (result != null) {
|
||||
processGamesDir(result)
|
||||
}
|
||||
}
|
||||
|
||||
fun processGamesDir(result: Uri) {
|
||||
contentResolver.takePersistableUriPermission(
|
||||
result,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
|
||||
val uriString = result.toString()
|
||||
val folder = gamesViewModel.folders.value.firstOrNull { it.uriString == uriString }
|
||||
if (folder != null) {
|
||||
Toast.makeText(
|
||||
applicationContext,
|
||||
R.string.folder_already_added,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
return
|
||||
}
|
||||
|
||||
AddGameFolderDialogFragment.newInstance(uriString)
|
||||
.show(supportFragmentManager, AddGameFolderDialogFragment.TAG)
|
||||
}
|
||||
|
||||
val getProdKey =
|
||||
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
|
||||
if (result != null) {
|
||||
processKey(result)
|
||||
}
|
||||
}
|
||||
|
||||
fun processKey(result: Uri): Boolean {
|
||||
if (FileUtil.getExtension(result) != "keys") {
|
||||
MessageDialogFragment.newInstance(
|
||||
this,
|
||||
titleId = R.string.reading_keys_failure,
|
||||
descriptionId = R.string.install_prod_keys_failure_extension_description
|
||||
).show(supportFragmentManager, MessageDialogFragment.TAG)
|
||||
return false
|
||||
}
|
||||
|
||||
contentResolver.takePersistableUriPermission(
|
||||
result,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
|
||||
val dstPath = DirectoryInitialization.userDirectory + "/keys/"
|
||||
if (FileUtil.copyUriToInternalStorage(
|
||||
result,
|
||||
dstPath,
|
||||
"prod.keys"
|
||||
) != null
|
||||
) {
|
||||
if (NativeLibrary.reloadKeys()) {
|
||||
Toast.makeText(
|
||||
applicationContext,
|
||||
R.string.install_keys_success,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
homeViewModel.setCheckKeys(true)
|
||||
gamesViewModel.reloadGames(true)
|
||||
return true
|
||||
} else {
|
||||
MessageDialogFragment.newInstance(
|
||||
this,
|
||||
titleId = R.string.invalid_keys_error,
|
||||
descriptionId = R.string.install_keys_failure_description,
|
||||
helpLinkId = R.string.dumping_keys_quickstart_link
|
||||
).show(supportFragmentManager, MessageDialogFragment.TAG)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
val getFirmware =
|
||||
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
|
||||
if (result == null) {
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
val filterNCA = FilenameFilter { _, dirName -> dirName.endsWith(".nca") }
|
||||
|
||||
val firmwarePath =
|
||||
File(DirectoryInitialization.userDirectory + "/nand/system/Contents/registered/")
|
||||
val cacheFirmwareDir = File("${cacheDir.path}/registered/")
|
||||
|
||||
ProgressDialogFragment.newInstance(
|
||||
this,
|
||||
R.string.firmware_installing
|
||||
) { progressCallback, _ ->
|
||||
var messageToShow: Any
|
||||
try {
|
||||
FileUtil.unzipToInternalStorage(
|
||||
result.toString(),
|
||||
cacheFirmwareDir,
|
||||
progressCallback
|
||||
)
|
||||
val unfilteredNumOfFiles = cacheFirmwareDir.list()?.size ?: -1
|
||||
val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2
|
||||
messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) {
|
||||
MessageDialogFragment.newInstance(
|
||||
this,
|
||||
titleId = R.string.firmware_installed_failure,
|
||||
descriptionId = R.string.firmware_installed_failure_description
|
||||
)
|
||||
} else {
|
||||
firmwarePath.deleteRecursively()
|
||||
cacheFirmwareDir.copyRecursively(firmwarePath, true)
|
||||
NativeLibrary.initializeSystem(true)
|
||||
homeViewModel.setCheckKeys(true)
|
||||
getString(R.string.save_file_imported_success)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.error("[MainActivity] Firmware install failed - ${e.message}")
|
||||
messageToShow = getString(R.string.fatal_error)
|
||||
} finally {
|
||||
cacheFirmwareDir.deleteRecursively()
|
||||
}
|
||||
messageToShow
|
||||
}.show(supportFragmentManager, ProgressDialogFragment.TAG)
|
||||
}
|
||||
|
||||
val getAmiiboKey =
|
||||
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
|
||||
if (result == null) {
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
if (FileUtil.getExtension(result) != "bin") {
|
||||
MessageDialogFragment.newInstance(
|
||||
this,
|
||||
titleId = R.string.reading_keys_failure,
|
||||
descriptionId = R.string.install_amiibo_keys_failure_extension_description
|
||||
).show(supportFragmentManager, MessageDialogFragment.TAG)
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
contentResolver.takePersistableUriPermission(
|
||||
result,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
|
||||
val dstPath = DirectoryInitialization.userDirectory + "/keys/"
|
||||
if (FileUtil.copyUriToInternalStorage(
|
||||
result,
|
||||
dstPath,
|
||||
"key_retail.bin"
|
||||
) != null
|
||||
) {
|
||||
if (NativeLibrary.reloadKeys()) {
|
||||
Toast.makeText(
|
||||
applicationContext,
|
||||
R.string.install_keys_success,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} else {
|
||||
MessageDialogFragment.newInstance(
|
||||
this,
|
||||
titleId = R.string.invalid_keys_error,
|
||||
descriptionId = R.string.install_keys_failure_description,
|
||||
helpLinkId = R.string.dumping_keys_quickstart_link
|
||||
).show(supportFragmentManager, MessageDialogFragment.TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val installGameUpdate = registerForActivityResult(
|
||||
ActivityResultContracts.OpenMultipleDocuments()
|
||||
) { documents: List<Uri> ->
|
||||
if (documents.isEmpty()) {
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
if (addonViewModel.game == null) {
|
||||
installContent(documents)
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
ProgressDialogFragment.newInstance(
|
||||
this@MainActivity,
|
||||
R.string.verifying_content,
|
||||
false
|
||||
) { _, _ ->
|
||||
var updatesMatchProgram = true
|
||||
for (document in documents) {
|
||||
val valid = NativeLibrary.doesUpdateMatchProgram(
|
||||
addonViewModel.game!!.programId,
|
||||
document.toString()
|
||||
)
|
||||
if (!valid) {
|
||||
updatesMatchProgram = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (updatesMatchProgram) {
|
||||
homeViewModel.setContentToInstall(documents)
|
||||
} else {
|
||||
MessageDialogFragment.newInstance(
|
||||
this@MainActivity,
|
||||
titleId = R.string.content_install_notice,
|
||||
descriptionId = R.string.content_install_notice_description,
|
||||
positiveAction = { homeViewModel.setContentToInstall(documents) },
|
||||
negativeAction = {}
|
||||
)
|
||||
}
|
||||
}.show(supportFragmentManager, ProgressDialogFragment.TAG)
|
||||
}
|
||||
|
||||
private fun installContent(documents: List<Uri>) {
|
||||
ProgressDialogFragment.newInstance(
|
||||
this@MainActivity,
|
||||
R.string.installing_game_content
|
||||
) { progressCallback, messageCallback ->
|
||||
var installSuccess = 0
|
||||
var installOverwrite = 0
|
||||
var errorBaseGame = 0
|
||||
var error = 0
|
||||
documents.forEach {
|
||||
messageCallback.invoke(FileUtil.getFilename(it))
|
||||
when (
|
||||
InstallResult.from(
|
||||
NativeLibrary.installFileToNand(
|
||||
it.toString(),
|
||||
progressCallback
|
||||
)
|
||||
)
|
||||
) {
|
||||
InstallResult.Success -> {
|
||||
installSuccess += 1
|
||||
}
|
||||
|
||||
InstallResult.Overwrite -> {
|
||||
installOverwrite += 1
|
||||
}
|
||||
|
||||
InstallResult.BaseInstallAttempted -> {
|
||||
errorBaseGame += 1
|
||||
}
|
||||
|
||||
InstallResult.Failure -> {
|
||||
error += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addonViewModel.refreshAddons()
|
||||
|
||||
val separator = System.getProperty("line.separator") ?: "\n"
|
||||
val installResult = StringBuilder()
|
||||
if (installSuccess > 0) {
|
||||
installResult.append(
|
||||
getString(
|
||||
R.string.install_game_content_success_install,
|
||||
installSuccess
|
||||
)
|
||||
)
|
||||
installResult.append(separator)
|
||||
}
|
||||
if (installOverwrite > 0) {
|
||||
installResult.append(
|
||||
getString(
|
||||
R.string.install_game_content_success_overwrite,
|
||||
installOverwrite
|
||||
)
|
||||
)
|
||||
installResult.append(separator)
|
||||
}
|
||||
val errorTotal: Int = errorBaseGame + error
|
||||
if (errorTotal > 0) {
|
||||
installResult.append(separator)
|
||||
installResult.append(
|
||||
getString(
|
||||
R.string.install_game_content_failed_count,
|
||||
errorTotal
|
||||
)
|
||||
)
|
||||
installResult.append(separator)
|
||||
if (errorBaseGame > 0) {
|
||||
installResult.append(separator)
|
||||
installResult.append(
|
||||
getString(R.string.install_game_content_failure_base)
|
||||
)
|
||||
installResult.append(separator)
|
||||
}
|
||||
if (error > 0) {
|
||||
installResult.append(
|
||||
getString(R.string.install_game_content_failure_description)
|
||||
)
|
||||
installResult.append(separator)
|
||||
}
|
||||
return@newInstance MessageDialogFragment.newInstance(
|
||||
this,
|
||||
titleId = R.string.install_game_content_failure,
|
||||
descriptionString = installResult.toString().trim(),
|
||||
helpLinkId = R.string.install_game_content_help_link
|
||||
)
|
||||
} else {
|
||||
return@newInstance MessageDialogFragment.newInstance(
|
||||
this,
|
||||
titleId = R.string.install_game_content_success,
|
||||
descriptionString = installResult.toString().trim()
|
||||
)
|
||||
}
|
||||
}.show(supportFragmentManager, ProgressDialogFragment.TAG)
|
||||
}
|
||||
|
||||
val exportUserData = registerForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/zip")
|
||||
) { result ->
|
||||
if (result == null) {
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
ProgressDialogFragment.newInstance(
|
||||
this,
|
||||
R.string.exporting_user_data,
|
||||
true
|
||||
) { progressCallback, _ ->
|
||||
val zipResult = FileUtil.zipFromInternalStorage(
|
||||
File(DirectoryInitialization.userDirectory!!),
|
||||
DirectoryInitialization.userDirectory!!,
|
||||
BufferedOutputStream(contentResolver.openOutputStream(result)),
|
||||
progressCallback,
|
||||
compression = false
|
||||
)
|
||||
return@newInstance when (zipResult) {
|
||||
TaskState.Completed -> getString(R.string.user_data_export_success)
|
||||
TaskState.Failed -> R.string.export_failed
|
||||
TaskState.Cancelled -> R.string.user_data_export_cancelled
|
||||
}
|
||||
}.show(supportFragmentManager, ProgressDialogFragment.TAG)
|
||||
}
|
||||
|
||||
val importUserData =
|
||||
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
|
||||
if (result == null) {
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
ProgressDialogFragment.newInstance(
|
||||
this,
|
||||
R.string.importing_user_data
|
||||
) { progressCallback, _ ->
|
||||
val checkStream =
|
||||
ZipInputStream(BufferedInputStream(contentResolver.openInputStream(result)))
|
||||
var isSudachiBackup = false
|
||||
checkStream.use { stream ->
|
||||
var ze: ZipEntry? = null
|
||||
while (stream.nextEntry?.also { ze = it } != null) {
|
||||
val itemName = ze!!.name.trim()
|
||||
if (itemName == "/config/config.ini" || itemName == "config/config.ini") {
|
||||
isSudachiBackup = true
|
||||
return@use
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!isSudachiBackup) {
|
||||
return@newInstance MessageDialogFragment.newInstance(
|
||||
this,
|
||||
titleId = R.string.invalid_sudachi_backup,
|
||||
descriptionId = R.string.user_data_import_failed_description
|
||||
)
|
||||
}
|
||||
|
||||
// Clear existing user data
|
||||
NativeConfig.unloadGlobalConfig()
|
||||
File(DirectoryInitialization.userDirectory!!).deleteRecursively()
|
||||
|
||||
// Copy archive to internal storage
|
||||
try {
|
||||
FileUtil.unzipToInternalStorage(
|
||||
result.toString(),
|
||||
File(DirectoryInitialization.userDirectory!!),
|
||||
progressCallback
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
return@newInstance MessageDialogFragment.newInstance(
|
||||
this,
|
||||
titleId = R.string.import_failed,
|
||||
descriptionId = R.string.user_data_import_failed_description
|
||||
)
|
||||
}
|
||||
|
||||
// Reinitialize relevant data
|
||||
NativeLibrary.initializeSystem(true)
|
||||
NativeConfig.initializeGlobalConfig()
|
||||
gamesViewModel.reloadGames(false)
|
||||
driverViewModel.reloadDriverData()
|
||||
|
||||
return@newInstance getString(R.string.user_data_import_success)
|
||||
}.show(supportFragmentManager, ProgressDialogFragment.TAG)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.ui.main
|
||||
|
||||
interface ThemeProvider {
|
||||
/**
|
||||
* Provides theme ID by overriding an activity's 'setTheme' method and returning that result
|
||||
*/
|
||||
var themeId: Int
|
||||
}
|
|
@ -0,0 +1,503 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.utils
|
||||
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.URLDecoder
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import org.sudachi.sudachi_emu.SudachiApplication
|
||||
import org.sudachi.sudachi_emu.model.MinimalDocumentFile
|
||||
import org.sudachi.sudachi_emu.model.TaskState
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.lang.NullPointerException
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.ZipOutputStream
|
||||
import kotlin.IllegalStateException
|
||||
|
||||
object FileUtil {
|
||||
const val PATH_TREE = "tree"
|
||||
const val DECODE_METHOD = "UTF-8"
|
||||
const val APPLICATION_OCTET_STREAM = "application/octet-stream"
|
||||
const val TEXT_PLAIN = "text/plain"
|
||||
|
||||
private val context get() = SudachiApplication.appContext
|
||||
|
||||
/**
|
||||
* Create a file from directory with filename.
|
||||
* @param context Application context
|
||||
* @param directory parent path for file.
|
||||
* @param filename file display name.
|
||||
* @return boolean
|
||||
*/
|
||||
fun createFile(directory: String?, filename: String): DocumentFile? {
|
||||
var decodedFilename = filename
|
||||
try {
|
||||
val directoryUri = Uri.parse(directory)
|
||||
val parent = DocumentFile.fromTreeUri(context, directoryUri) ?: return null
|
||||
decodedFilename = URLDecoder.decode(decodedFilename, DECODE_METHOD)
|
||||
var mimeType = APPLICATION_OCTET_STREAM
|
||||
if (decodedFilename.endsWith(".txt")) {
|
||||
mimeType = TEXT_PLAIN
|
||||
}
|
||||
val exists = parent.findFile(decodedFilename)
|
||||
return exists ?: parent.createFile(mimeType, decodedFilename)
|
||||
} catch (e: Exception) {
|
||||
Log.error("[FileUtil]: Cannot create file, error: " + e.message)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a directory from directory with filename.
|
||||
* @param directory parent path for directory.
|
||||
* @param directoryName directory display name.
|
||||
* @return boolean
|
||||
*/
|
||||
fun createDir(directory: String?, directoryName: String?): DocumentFile? {
|
||||
var decodedDirectoryName = directoryName
|
||||
try {
|
||||
val directoryUri = Uri.parse(directory)
|
||||
val parent = DocumentFile.fromTreeUri(context, directoryUri) ?: return null
|
||||
decodedDirectoryName = URLDecoder.decode(decodedDirectoryName, DECODE_METHOD)
|
||||
val isExist = parent.findFile(decodedDirectoryName)
|
||||
return isExist ?: parent.createDirectory(decodedDirectoryName)
|
||||
} catch (e: Exception) {
|
||||
Log.error("[FileUtil]: Cannot create file, error: " + e.message)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Open content uri and return file descriptor to JNI.
|
||||
* @param path Native content uri path
|
||||
* @param openMode will be one of "r", "r", "rw", "wa", "rwa"
|
||||
* @return file descriptor
|
||||
*/
|
||||
@JvmStatic
|
||||
fun openContentUri(path: String, openMode: String?): Int {
|
||||
try {
|
||||
val uri = Uri.parse(path)
|
||||
val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, openMode!!)
|
||||
if (parcelFileDescriptor == null) {
|
||||
Log.error("[FileUtil]: Cannot get the file descriptor from uri: $path")
|
||||
return -1
|
||||
}
|
||||
val fileDescriptor = parcelFileDescriptor.detachFd()
|
||||
parcelFileDescriptor.close()
|
||||
return fileDescriptor
|
||||
} catch (e: Exception) {
|
||||
Log.error("[FileUtil]: Cannot open content uri, error: " + e.message)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference: https://stackoverflow.com/questions/42186820/documentfile-is-very-slow
|
||||
* This function will be faster than DocumentFile.listFiles
|
||||
* @param uri Directory uri.
|
||||
* @return CheapDocument lists.
|
||||
*/
|
||||
fun listFiles(uri: Uri): Array<MinimalDocumentFile> {
|
||||
val resolver = context.contentResolver
|
||||
val columns = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE
|
||||
)
|
||||
var c: Cursor? = null
|
||||
val results: MutableList<MinimalDocumentFile> = ArrayList()
|
||||
try {
|
||||
val docId: String = if (isRootTreeUri(uri)) {
|
||||
DocumentsContract.getTreeDocumentId(uri)
|
||||
} else {
|
||||
DocumentsContract.getDocumentId(uri)
|
||||
}
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId)
|
||||
c = resolver.query(childrenUri, columns, null, null, null)
|
||||
while (c!!.moveToNext()) {
|
||||
val documentId = c.getString(0)
|
||||
val documentName = c.getString(1)
|
||||
val documentMimeType = c.getString(2)
|
||||
val documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId)
|
||||
val document = MinimalDocumentFile(documentName, documentMimeType, documentUri)
|
||||
results.add(document)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.error("[FileUtil]: Cannot list file error: " + e.message)
|
||||
} finally {
|
||||
closeQuietly(c)
|
||||
}
|
||||
return results.toTypedArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether given path exists.
|
||||
* @param path Native content uri path
|
||||
* @return bool
|
||||
*/
|
||||
fun exists(path: String?, suppressLog: Boolean = false): Boolean {
|
||||
var c: Cursor? = null
|
||||
try {
|
||||
val mUri = Uri.parse(path)
|
||||
val columns = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
|
||||
c = context.contentResolver.query(mUri, columns, null, null, null)
|
||||
return c!!.count > 0
|
||||
} catch (e: Exception) {
|
||||
if (!suppressLog) {
|
||||
Log.info("[FileUtil] Cannot find file from given path, error: " + e.message)
|
||||
}
|
||||
} finally {
|
||||
closeQuietly(c)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether given path is a directory
|
||||
* @param path content uri path
|
||||
* @return bool
|
||||
*/
|
||||
fun isDirectory(path: String): Boolean {
|
||||
val resolver = context.contentResolver
|
||||
val columns = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE
|
||||
)
|
||||
var isDirectory = false
|
||||
var c: Cursor? = null
|
||||
try {
|
||||
val mUri = Uri.parse(path)
|
||||
c = resolver.query(mUri, columns, null, null, null)
|
||||
c!!.moveToNext()
|
||||
val mimeType = c.getString(0)
|
||||
isDirectory = mimeType == DocumentsContract.Document.MIME_TYPE_DIR
|
||||
} catch (e: Exception) {
|
||||
Log.error("[FileUtil]: Cannot list files, error: " + e.message)
|
||||
} finally {
|
||||
closeQuietly(c)
|
||||
}
|
||||
return isDirectory
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file display name from given path
|
||||
* @param uri content uri
|
||||
* @return String display name
|
||||
*/
|
||||
fun getFilename(uri: Uri): String {
|
||||
val resolver = SudachiApplication.appContext.contentResolver
|
||||
val columns = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME
|
||||
)
|
||||
var filename = ""
|
||||
var c: Cursor? = null
|
||||
try {
|
||||
c = resolver.query(uri, columns, null, null, null)
|
||||
c!!.moveToNext()
|
||||
filename = c.getString(0)
|
||||
} catch (e: Exception) {
|
||||
Log.error("[FileUtil]: Cannot get file size, error: " + e.message)
|
||||
} finally {
|
||||
closeQuietly(c)
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
fun getFilesName(path: String): Array<String> {
|
||||
val uri = Uri.parse(path)
|
||||
val files: MutableList<String> = ArrayList()
|
||||
for (file in listFiles(uri)) {
|
||||
files.add(file.filename)
|
||||
}
|
||||
return files.toTypedArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file size from given path.
|
||||
* @param path content uri path
|
||||
* @return long file size
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getFileSize(path: String): Long {
|
||||
val resolver = context.contentResolver
|
||||
val columns = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_SIZE
|
||||
)
|
||||
var size: Long = 0
|
||||
var c: Cursor? = null
|
||||
try {
|
||||
val mUri = Uri.parse(path)
|
||||
c = resolver.query(mUri, columns, null, null, null)
|
||||
c!!.moveToNext()
|
||||
size = c.getLong(0)
|
||||
} catch (e: Exception) {
|
||||
Log.error("[FileUtil]: Cannot get file size, error: " + e.message)
|
||||
} finally {
|
||||
closeQuietly(c)
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an input stream with a given [Uri] and copies its data to the given path. This will
|
||||
* overwrite any pre-existing files.
|
||||
*
|
||||
* @param sourceUri The [Uri] to copy data from
|
||||
* @param destinationParentPath Destination directory
|
||||
* @param destinationFilename Optionally renames the file once copied
|
||||
*/
|
||||
fun copyUriToInternalStorage(
|
||||
sourceUri: Uri,
|
||||
destinationParentPath: String,
|
||||
destinationFilename: String = ""
|
||||
): File? =
|
||||
try {
|
||||
val fileName =
|
||||
if (destinationFilename == "") getFilename(sourceUri) else "/$destinationFilename"
|
||||
val inputStream = context.contentResolver.openInputStream(sourceUri)!!
|
||||
|
||||
val destinationFile = File("$destinationParentPath$fileName")
|
||||
if (destinationFile.exists()) {
|
||||
destinationFile.delete()
|
||||
}
|
||||
|
||||
destinationFile.outputStream().use { fos ->
|
||||
inputStream.use { it.copyTo(fos) }
|
||||
}
|
||||
destinationFile
|
||||
} catch (e: IOException) {
|
||||
null
|
||||
} catch (e: NullPointerException) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the given zip file into the given directory.
|
||||
* @param path String representation of a [Uri] or a typical path delimited by '/'
|
||||
* @param destDir Location to unzip the contents of [path] into
|
||||
* @param progressCallback Lambda that is called with the total number of files and the current
|
||||
* progress through the process. Stops execution as soon as possible if this returns true.
|
||||
*/
|
||||
@Throws(SecurityException::class)
|
||||
fun unzipToInternalStorage(
|
||||
path: String,
|
||||
destDir: File,
|
||||
progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false }
|
||||
) {
|
||||
var totalEntries = 0L
|
||||
ZipInputStream(getInputStream(path)).use { zis ->
|
||||
var tempEntry = zis.nextEntry
|
||||
while (tempEntry != null) {
|
||||
tempEntry = zis.nextEntry
|
||||
totalEntries++
|
||||
}
|
||||
}
|
||||
|
||||
var progress = 0L
|
||||
ZipInputStream(getInputStream(path)).use { zis ->
|
||||
var entry: ZipEntry? = zis.nextEntry
|
||||
while (entry != null) {
|
||||
if (progressCallback.invoke(totalEntries, progress)) {
|
||||
return@use
|
||||
}
|
||||
|
||||
val newFile = File(destDir, entry.name)
|
||||
val destinationDirectory = if (entry.isDirectory) newFile else newFile.parentFile
|
||||
|
||||
if (!newFile.canonicalPath.startsWith(destDir.canonicalPath + File.separator)) {
|
||||
throw SecurityException("Zip file attempted path traversal! ${entry.name}")
|
||||
}
|
||||
|
||||
if (!destinationDirectory.isDirectory && !destinationDirectory.mkdirs()) {
|
||||
throw IOException("Failed to create directory $destinationDirectory")
|
||||
}
|
||||
|
||||
if (!entry.isDirectory) {
|
||||
newFile.outputStream().use { fos -> zis.copyTo(fos) }
|
||||
}
|
||||
entry = zis.nextEntry
|
||||
progress++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a zip file from a directory within internal storage
|
||||
* @param inputFile File representation of the item that will be zipped
|
||||
* @param rootDir Directory containing the inputFile
|
||||
* @param outputStream Stream where the zip file will be output
|
||||
* @param progressCallback Lambda that is called with the total number of files and the current
|
||||
* progress through the process. Stops execution as soon as possible if this returns true.
|
||||
* @param compression Disables compression if true
|
||||
*/
|
||||
fun zipFromInternalStorage(
|
||||
inputFile: File,
|
||||
rootDir: String,
|
||||
outputStream: BufferedOutputStream,
|
||||
progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false },
|
||||
compression: Boolean = true
|
||||
): TaskState {
|
||||
try {
|
||||
ZipOutputStream(outputStream).use { zos ->
|
||||
if (!compression) {
|
||||
zos.setMethod(ZipOutputStream.DEFLATED)
|
||||
zos.setLevel(Deflater.NO_COMPRESSION)
|
||||
}
|
||||
|
||||
var count = 0L
|
||||
val totalFiles = inputFile.walkTopDown().count().toLong()
|
||||
inputFile.walkTopDown().forEach { file ->
|
||||
if (progressCallback.invoke(totalFiles, count)) {
|
||||
return TaskState.Cancelled
|
||||
}
|
||||
|
||||
if (!file.isDirectory) {
|
||||
val entryName =
|
||||
file.absolutePath.removePrefix(rootDir).removePrefix("/")
|
||||
val entry = ZipEntry(entryName)
|
||||
zos.putNextEntry(entry)
|
||||
if (file.isFile) {
|
||||
file.inputStream().use { fis -> fis.copyTo(zos) }
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.error("[FileUtil] Failed creating zip file - ${e.message}")
|
||||
return TaskState.Failed
|
||||
}
|
||||
return TaskState.Completed
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function that copies the contents of a DocumentFile folder into a [File]
|
||||
* @param file [File] representation of the folder to copy into
|
||||
* @param progressCallback Lambda that is called with the total number of files and the current
|
||||
* progress through the process. Stops execution as soon as possible if this returns true.
|
||||
* @throws IllegalStateException Fails when trying to copy a folder into a file and vice versa
|
||||
*/
|
||||
fun DocumentFile.copyFilesTo(
|
||||
file: File,
|
||||
progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false }
|
||||
) {
|
||||
file.mkdirs()
|
||||
if (!this.isDirectory || !file.isDirectory) {
|
||||
throw IllegalStateException(
|
||||
"[FileUtil] Tried to copy a folder into a file or vice versa"
|
||||
)
|
||||
}
|
||||
|
||||
var count = 0L
|
||||
val totalFiles = this.listFiles().size.toLong()
|
||||
this.listFiles().forEach {
|
||||
if (progressCallback.invoke(totalFiles, count)) {
|
||||
return
|
||||
}
|
||||
|
||||
val newFile = File(file, it.name!!)
|
||||
if (it.isDirectory) {
|
||||
newFile.mkdirs()
|
||||
DocumentFile.fromTreeUri(SudachiApplication.appContext, it.uri)?.copyFilesTo(newFile)
|
||||
} else {
|
||||
val inputStream =
|
||||
SudachiApplication.appContext.contentResolver.openInputStream(it.uri)
|
||||
BufferedInputStream(inputStream).use { bos ->
|
||||
if (!newFile.exists()) {
|
||||
newFile.createNewFile()
|
||||
}
|
||||
newFile.outputStream().use { os -> bos.copyTo(os) }
|
||||
}
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
fun isRootTreeUri(uri: Uri): Boolean {
|
||||
val paths = uri.pathSegments
|
||||
return paths.size == 2 && PATH_TREE == paths[0]
|
||||
}
|
||||
|
||||
fun closeQuietly(closeable: AutoCloseable?) {
|
||||
if (closeable != null) {
|
||||
try {
|
||||
closeable.close()
|
||||
} catch (rethrown: RuntimeException) {
|
||||
throw rethrown
|
||||
} catch (ignored: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getExtension(uri: Uri): String {
|
||||
val fileName = getFilename(uri)
|
||||
return fileName.substring(fileName.lastIndexOf(".") + 1)
|
||||
.lowercase()
|
||||
}
|
||||
|
||||
fun isTreeUriValid(uri: Uri): Boolean {
|
||||
val resolver = context.contentResolver
|
||||
val columns = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE
|
||||
)
|
||||
return try {
|
||||
val docId: String = if (isRootTreeUri(uri)) {
|
||||
DocumentsContract.getTreeDocumentId(uri)
|
||||
} else {
|
||||
DocumentsContract.getDocumentId(uri)
|
||||
}
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId)
|
||||
resolver.query(childrenUri, columns, null, null, null)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun getInputStream(path: String) = if (path.contains("content://")) {
|
||||
Uri.parse(path).inputStream()
|
||||
} else {
|
||||
File(path).inputStream()
|
||||
}
|
||||
|
||||
fun getOutputStream(path: String) = if (path.contains("content://")) {
|
||||
Uri.parse(path).outputStream()
|
||||
} else {
|
||||
File(path).outputStream()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun getStringFromFile(file: File): String =
|
||||
String(file.readBytes(), StandardCharsets.UTF_8)
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun getStringFromInputStream(stream: InputStream): String =
|
||||
String(stream.readBytes(), StandardCharsets.UTF_8)
|
||||
|
||||
fun DocumentFile.inputStream(): InputStream =
|
||||
SudachiApplication.appContext.contentResolver.openInputStream(uri)!!
|
||||
|
||||
fun DocumentFile.outputStream(): OutputStream =
|
||||
SudachiApplication.appContext.contentResolver.openOutputStream(uri)!!
|
||||
|
||||
fun Uri.inputStream(): InputStream =
|
||||
SudachiApplication.appContext.contentResolver.openInputStream(this)!!
|
||||
|
||||
fun Uri.outputStream(): OutputStream =
|
||||
SudachiApplication.appContext.contentResolver.openOutputStream(this)!!
|
||||
|
||||
fun Uri.asDocumentFile(): DocumentFile? =
|
||||
DocumentFile.fromSingleUri(SudachiApplication.appContext, this)
|
||||
}
|
|
@ -0,0 +1,152 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.utils
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.sudachi.sudachi_emu.NativeLibrary
|
||||
import org.sudachi.sudachi_emu.SudachiApplication
|
||||
import org.sudachi.sudachi_emu.model.Game
|
||||
import org.sudachi.sudachi_emu.model.GameDir
|
||||
import org.sudachi.sudachi_emu.model.MinimalDocumentFile
|
||||
|
||||
object GameHelper {
|
||||
private const val KEY_OLD_GAME_PATH = "game_path"
|
||||
const val KEY_GAMES = "Games"
|
||||
|
||||
private lateinit var preferences: SharedPreferences
|
||||
|
||||
fun getGames(): List<Game> {
|
||||
val games = mutableListOf<Game>()
|
||||
val context = SudachiApplication.appContext
|
||||
preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
val gameDirs = mutableListOf<GameDir>()
|
||||
val oldGamesDir = preferences.getString(KEY_OLD_GAME_PATH, "") ?: ""
|
||||
if (oldGamesDir.isNotEmpty()) {
|
||||
gameDirs.add(GameDir(oldGamesDir, true))
|
||||
preferences.edit().remove(KEY_OLD_GAME_PATH).apply()
|
||||
}
|
||||
gameDirs.addAll(NativeConfig.getGameDirs())
|
||||
|
||||
// Ensure keys are loaded so that ROM metadata can be decrypted.
|
||||
NativeLibrary.reloadKeys()
|
||||
|
||||
// Reset metadata so we don't use stale information
|
||||
GameMetadata.resetMetadata()
|
||||
|
||||
// Remove previous filesystem provider information so we can get up to date version info
|
||||
NativeLibrary.clearFilesystemProvider()
|
||||
|
||||
val badDirs = mutableListOf<Int>()
|
||||
gameDirs.forEachIndexed { index: Int, gameDir: GameDir ->
|
||||
val gameDirUri = Uri.parse(gameDir.uriString)
|
||||
val isValid = FileUtil.isTreeUriValid(gameDirUri)
|
||||
if (isValid) {
|
||||
addGamesRecursive(
|
||||
games,
|
||||
FileUtil.listFiles(gameDirUri),
|
||||
if (gameDir.deepScan) 3 else 1
|
||||
)
|
||||
} else {
|
||||
badDirs.add(index)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all game dirs with insufficient permissions from config
|
||||
if (badDirs.isNotEmpty()) {
|
||||
var offset = 0
|
||||
badDirs.forEach {
|
||||
gameDirs.removeAt(it - offset)
|
||||
offset++
|
||||
}
|
||||
}
|
||||
NativeConfig.setGameDirs(gameDirs.toTypedArray())
|
||||
|
||||
// Cache list of games found on disk
|
||||
val serializedGames = mutableSetOf<String>()
|
||||
games.forEach {
|
||||
serializedGames.add(Json.encodeToString(it))
|
||||
}
|
||||
preferences.edit()
|
||||
.remove(KEY_GAMES)
|
||||
.putStringSet(KEY_GAMES, serializedGames)
|
||||
.apply()
|
||||
|
||||
return games.toList()
|
||||
}
|
||||
|
||||
private fun addGamesRecursive(
|
||||
games: MutableList<Game>,
|
||||
files: Array<MinimalDocumentFile>,
|
||||
depth: Int
|
||||
) {
|
||||
if (depth <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
files.forEach {
|
||||
if (it.isDirectory) {
|
||||
addGamesRecursive(
|
||||
games,
|
||||
FileUtil.listFiles(it.uri),
|
||||
depth - 1
|
||||
)
|
||||
} else {
|
||||
if (Game.extensions.contains(FileUtil.getExtension(it.uri))) {
|
||||
val game = getGame(it.uri, true)
|
||||
if (game != null) {
|
||||
games.add(game)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getGame(uri: Uri, addedToLibrary: Boolean): Game? {
|
||||
val filePath = uri.toString()
|
||||
if (!GameMetadata.getIsValid(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Needed to update installed content information
|
||||
NativeLibrary.addFileToFilesystemProvider(filePath)
|
||||
|
||||
var name = GameMetadata.getTitle(filePath)
|
||||
|
||||
// If the game's title field is empty, use the filename.
|
||||
if (name.isEmpty()) {
|
||||
name = FileUtil.getFilename(uri)
|
||||
}
|
||||
var programId = GameMetadata.getProgramId(filePath)
|
||||
|
||||
// If the game's ID field is empty, use the filename without extension.
|
||||
if (programId.isEmpty()) {
|
||||
programId = name.substring(0, name.lastIndexOf("."))
|
||||
}
|
||||
|
||||
val newGame = Game(
|
||||
name,
|
||||
filePath,
|
||||
programId,
|
||||
GameMetadata.getDeveloper(filePath),
|
||||
GameMetadata.getVersion(filePath, false),
|
||||
GameMetadata.getIsHomebrew(filePath)
|
||||
)
|
||||
|
||||
if (addedToLibrary) {
|
||||
val addedTime = preferences.getLong(newGame.keyAddedToLibraryTime, 0L)
|
||||
if (addedTime == 0L) {
|
||||
preferences.edit()
|
||||
.putLong(newGame.keyAddedToLibraryTime, System.currentTimeMillis())
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
return newGame
|
||||
}
|
||||
}
|
|
@ -0,0 +1,229 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.utils
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.view.Surface
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import org.sudachi.sudachi_emu.NativeLibrary
|
||||
import org.sudachi.sudachi_emu.SudachiApplication
|
||||
import org.sudachi.sudachi_emu.features.settings.model.StringSetting
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.zip.ZipException
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
object GpuDriverHelper {
|
||||
private const val META_JSON_FILENAME = "meta.json"
|
||||
private var fileRedirectionPath: String? = null
|
||||
var driverInstallationPath: String? = null
|
||||
private var hookLibPath: String? = null
|
||||
|
||||
val driverStoragePath get() = DirectoryInitialization.userDirectory!! + "/gpu_drivers/"
|
||||
|
||||
fun initializeDriverParameters() {
|
||||
try {
|
||||
// Initialize the file redirection directory.
|
||||
fileRedirectionPath = SudachiApplication.appContext
|
||||
.getExternalFilesDir(null)!!.canonicalPath + "/gpu/vk_file_redirect/"
|
||||
|
||||
// Initialize the driver installation directory.
|
||||
driverInstallationPath = SudachiApplication.appContext
|
||||
.filesDir.canonicalPath + "/gpu_driver/"
|
||||
} catch (e: IOException) {
|
||||
throw RuntimeException(e)
|
||||
}
|
||||
|
||||
// Initialize directories.
|
||||
initializeDirectories()
|
||||
|
||||
// Initialize hook libraries directory.
|
||||
hookLibPath = SudachiApplication.appContext.applicationInfo.nativeLibraryDir + "/"
|
||||
|
||||
// Initialize GPU driver.
|
||||
NativeLibrary.initializeGpuDriver(
|
||||
hookLibPath,
|
||||
driverInstallationPath,
|
||||
installedCustomDriverData.libraryName,
|
||||
fileRedirectionPath
|
||||
)
|
||||
}
|
||||
|
||||
fun getDrivers(): MutableList<Pair<String, GpuDriverMetadata>> {
|
||||
val driverZips = File(driverStoragePath).listFiles()
|
||||
val drivers: MutableList<Pair<String, GpuDriverMetadata>> =
|
||||
driverZips
|
||||
?.mapNotNull {
|
||||
val metadata = getMetadataFromZip(it)
|
||||
metadata.name?.let { _ -> Pair(it.path, metadata) }
|
||||
}
|
||||
?.sortedByDescending { it: Pair<String, GpuDriverMetadata> -> it.second.name }
|
||||
?.distinct()
|
||||
?.toMutableList() ?: mutableListOf()
|
||||
return drivers
|
||||
}
|
||||
|
||||
fun installDefaultDriver() {
|
||||
// Removing the installed driver will result in the backend using the default system driver.
|
||||
File(driverInstallationPath!!).deleteRecursively()
|
||||
initializeDriverParameters()
|
||||
}
|
||||
|
||||
fun copyDriverToInternalStorage(driverUri: Uri): Boolean {
|
||||
// Ensure we have directories.
|
||||
initializeDirectories()
|
||||
|
||||
// Copy the zip file URI to user data
|
||||
val copiedFile =
|
||||
FileUtil.copyUriToInternalStorage(driverUri, driverStoragePath) ?: return false
|
||||
|
||||
// Validate driver
|
||||
val metadata = getMetadataFromZip(copiedFile)
|
||||
if (metadata.name == null) {
|
||||
copiedFile.delete()
|
||||
return false
|
||||
}
|
||||
|
||||
if (metadata.minApi > Build.VERSION.SDK_INT) {
|
||||
copiedFile.delete()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies driver zip into user data directory so that it can be exported along with
|
||||
* other user data and also unzipped into the installation directory
|
||||
*/
|
||||
fun installCustomDriver(driverUri: Uri): Boolean {
|
||||
// Revert to system default in the event the specified driver is bad.
|
||||
installDefaultDriver()
|
||||
|
||||
// Ensure we have directories.
|
||||
initializeDirectories()
|
||||
|
||||
// Copy the zip file URI to user data
|
||||
val copiedFile =
|
||||
FileUtil.copyUriToInternalStorage(driverUri, driverStoragePath) ?: return false
|
||||
|
||||
// Validate driver
|
||||
val metadata = getMetadataFromZip(copiedFile)
|
||||
if (metadata.name == null) {
|
||||
copiedFile.delete()
|
||||
return false
|
||||
}
|
||||
|
||||
if (metadata.minApi > Build.VERSION.SDK_INT) {
|
||||
copiedFile.delete()
|
||||
return false
|
||||
}
|
||||
|
||||
// Unzip the driver.
|
||||
try {
|
||||
FileUtil.unzipToInternalStorage(
|
||||
copiedFile.path,
|
||||
File(driverInstallationPath!!)
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Initialize the driver parameters.
|
||||
initializeDriverParameters()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Unzips driver into installation directory
|
||||
*/
|
||||
fun installCustomDriver(driver: File): Boolean {
|
||||
// Revert to system default in the event the specified driver is bad.
|
||||
installDefaultDriver()
|
||||
|
||||
// Ensure we have directories.
|
||||
initializeDirectories()
|
||||
|
||||
// Validate driver
|
||||
val metadata = getMetadataFromZip(driver)
|
||||
if (metadata.name == null) {
|
||||
driver.delete()
|
||||
return false
|
||||
}
|
||||
|
||||
// Unzip the driver to the private installation directory
|
||||
try {
|
||||
FileUtil.unzipToInternalStorage(
|
||||
driver.path,
|
||||
File(driverInstallationPath!!)
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Initialize the driver parameters.
|
||||
initializeDriverParameters()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes in a zip file and reads the meta.json file for presentation to the UI
|
||||
*
|
||||
* @param driver Zip containing driver and meta.json file
|
||||
* @return A non-null [GpuDriverMetadata] instance that may have null members
|
||||
*/
|
||||
fun getMetadataFromZip(driver: File): GpuDriverMetadata {
|
||||
try {
|
||||
ZipFile(driver).use { zf ->
|
||||
val entries = zf.entries()
|
||||
while (entries.hasMoreElements()) {
|
||||
val entry = entries.nextElement()
|
||||
if (!entry.isDirectory && entry.name.lowercase().contains(".json")) {
|
||||
zf.getInputStream(entry).use {
|
||||
return GpuDriverMetadata(it, entry.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: ZipException) {
|
||||
} catch (_: FileNotFoundException) {
|
||||
}
|
||||
return GpuDriverMetadata()
|
||||
}
|
||||
|
||||
external fun supportsCustomDriverLoading(): Boolean
|
||||
|
||||
external fun getSystemDriverInfo(
|
||||
surface: Surface = Surface(SurfaceTexture(true)),
|
||||
hookLibPath: String = GpuDriverHelper.hookLibPath!!
|
||||
): Array<String>?
|
||||
|
||||
// Parse the custom driver metadata to retrieve the name.
|
||||
val installedCustomDriverData: GpuDriverMetadata
|
||||
get() = GpuDriverMetadata(File(driverInstallationPath + META_JSON_FILENAME))
|
||||
|
||||
val customDriverSettingData: GpuDriverMetadata
|
||||
get() = getMetadataFromZip(File(StringSetting.DRIVER_PATH.getString()))
|
||||
|
||||
fun initializeDirectories() {
|
||||
// Ensure the file redirection directory exists.
|
||||
val fileRedirectionDir = File(fileRedirectionPath!!)
|
||||
if (!fileRedirectionDir.exists()) {
|
||||
fileRedirectionDir.mkdirs()
|
||||
}
|
||||
// Ensure the driver installation directory exists.
|
||||
val driverInstallationDir = File(driverInstallationPath!!)
|
||||
if (!driverInstallationDir.exists()) {
|
||||
driverInstallationDir.mkdirs()
|
||||
}
|
||||
// Ensure the driver storage directory exists
|
||||
val driverStorageDirectory = File(driverStoragePath)
|
||||
if (!driverStorageDirectory.exists()) {
|
||||
driverStorageDirectory.mkdirs()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.utils
|
||||
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import org.sudachi.sudachi_emu.features.input.NativeInput
|
||||
import org.sudachi.sudachi_emu.features.input.SudachiInputOverlayDevice
|
||||
import org.sudachi.sudachi_emu.features.input.SudachiPhysicalDevice
|
||||
|
||||
object InputHandler {
|
||||
var androidControllers = mapOf<Int, SudachiPhysicalDevice>()
|
||||
var registeredControllers = mutableListOf<ParamPackage>()
|
||||
|
||||
fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
val action = when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> NativeInput.ButtonState.PRESSED
|
||||
KeyEvent.ACTION_UP -> NativeInput.ButtonState.RELEASED
|
||||
else -> return false
|
||||
}
|
||||
|
||||
var controllerData = androidControllers[event.device.controllerNumber]
|
||||
if (controllerData == null) {
|
||||
updateControllerData()
|
||||
controllerData = androidControllers[event.device.controllerNumber] ?: return false
|
||||
}
|
||||
|
||||
NativeInput.onGamePadButtonEvent(
|
||||
controllerData.getGUID(),
|
||||
controllerData.getPort(),
|
||||
event.keyCode,
|
||||
action
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
val controllerData =
|
||||
androidControllers[event.device.controllerNumber] ?: return false
|
||||
event.device.motionRanges.forEach {
|
||||
NativeInput.onGamePadAxisEvent(
|
||||
controllerData.getGUID(),
|
||||
controllerData.getPort(),
|
||||
it.axis,
|
||||
event.getAxisValue(it.axis)
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun getDevices(): Map<Int, SudachiPhysicalDevice> {
|
||||
val gameControllerDeviceIds = mutableMapOf<Int, SudachiPhysicalDevice>()
|
||||
val deviceIds = InputDevice.getDeviceIds()
|
||||
var port = 0
|
||||
val inputSettings = NativeConfig.getInputSettings(true)
|
||||
deviceIds.forEach { deviceId ->
|
||||
InputDevice.getDevice(deviceId)?.apply {
|
||||
// Verify that the device has gamepad buttons, control sticks, or both.
|
||||
if (sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
||||
sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
) {
|
||||
if (!gameControllerDeviceIds.contains(controllerNumber)) {
|
||||
gameControllerDeviceIds[controllerNumber] = SudachiPhysicalDevice(
|
||||
this,
|
||||
port,
|
||||
inputSettings[port].useSystemVibrator
|
||||
)
|
||||
}
|
||||
port++
|
||||
}
|
||||
}
|
||||
}
|
||||
return gameControllerDeviceIds
|
||||
}
|
||||
|
||||
fun updateControllerData() {
|
||||
androidControllers = getDevices()
|
||||
androidControllers.forEach {
|
||||
NativeInput.registerController(it.value)
|
||||
}
|
||||
|
||||
// Register the input overlay on a dedicated port for all player 1 vibrations
|
||||
NativeInput.registerController(SudachiInputOverlayDevice(androidControllers.isEmpty(), 100))
|
||||
registeredControllers.clear()
|
||||
NativeInput.getInputDevices().forEach {
|
||||
registeredControllers.add(ParamPackage(it))
|
||||
}
|
||||
registeredControllers.sortBy { it.get("port", 0) }
|
||||
}
|
||||
|
||||
fun InputDevice.getGUID(): String = String.format("%016x%016x", productId, vendorId)
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.utils
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Collects this [Flow] with a given [LifecycleOwner].
|
||||
* @param scope [LifecycleOwner] that this [Flow] will be collected with.
|
||||
* @param repeatState When to repeat collection on this [Flow].
|
||||
* @param resetState Optional lambda to reset state of an underlying [MutableStateFlow] after
|
||||
* [stateCollector] has been run.
|
||||
* @param stateCollector Lambda that receives new state.
|
||||
*/
|
||||
inline fun <reified T> Flow<T>.collect(
|
||||
scope: LifecycleOwner,
|
||||
repeatState: Lifecycle.State = Lifecycle.State.CREATED,
|
||||
crossinline resetState: () -> Unit = {},
|
||||
crossinline stateCollector: (state: T) -> Unit
|
||||
) {
|
||||
scope.apply {
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(repeatState) {
|
||||
this@collect.collect {
|
||||
stateCollector(it)
|
||||
resetState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.utils
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import org.sudachi.sudachi_emu.R
|
||||
import org.sudachi.sudachi_emu.SudachiApplication
|
||||
import java.util.Locale
|
||||
import kotlin.math.ceil
|
||||
|
||||
object MemoryUtil {
|
||||
private val context get() = SudachiApplication.appContext
|
||||
|
||||
private val Float.hundredths: String
|
||||
get() = String.format(Locale.ROOT, "%.2f", this)
|
||||
|
||||
// Required total system memory
|
||||
const val REQUIRED_MEMORY = 8
|
||||
|
||||
const val Kb: Float = 1024F
|
||||
const val Mb = Kb * 1024
|
||||
const val Gb = Mb * 1024
|
||||
const val Tb = Gb * 1024
|
||||
const val Pb = Tb * 1024
|
||||
const val Eb = Pb * 1024
|
||||
|
||||
fun bytesToSizeUnit(size: Float, roundUp: Boolean = false): String =
|
||||
when {
|
||||
size < Kb -> {
|
||||
context.getString(
|
||||
R.string.memory_formatted,
|
||||
size.hundredths,
|
||||
context.getString(R.string.memory_byte_shorthand)
|
||||
)
|
||||
}
|
||||
size < Mb -> {
|
||||
context.getString(
|
||||
R.string.memory_formatted,
|
||||
if (roundUp) ceil(size / Kb) else (size / Kb).hundredths,
|
||||
context.getString(R.string.memory_kilobyte)
|
||||
)
|
||||
}
|
||||
size < Gb -> {
|
||||
context.getString(
|
||||
R.string.memory_formatted,
|
||||
if (roundUp) ceil(size / Mb) else (size / Mb).hundredths,
|
||||
context.getString(R.string.memory_megabyte)
|
||||
)
|
||||
}
|
||||
size < Tb -> {
|
||||
context.getString(
|
||||
R.string.memory_formatted,
|
||||
if (roundUp) ceil(size / Gb) else (size / Gb).hundredths,
|
||||
context.getString(R.string.memory_gigabyte)
|
||||
)
|
||||
}
|
||||
size < Pb -> {
|
||||
context.getString(
|
||||
R.string.memory_formatted,
|
||||
if (roundUp) ceil(size / Tb) else (size / Tb).hundredths,
|
||||
context.getString(R.string.memory_terabyte)
|
||||
)
|
||||
}
|
||||
size < Eb -> {
|
||||
context.getString(
|
||||
R.string.memory_formatted,
|
||||
if (roundUp) ceil(size / Pb) else (size / Pb).hundredths,
|
||||
context.getString(R.string.memory_petabyte)
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
context.getString(
|
||||
R.string.memory_formatted,
|
||||
if (roundUp) ceil(size / Eb) else (size / Eb).hundredths,
|
||||
context.getString(R.string.memory_exabyte)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val totalMemory: Float
|
||||
get() {
|
||||
val memInfo = ActivityManager.MemoryInfo()
|
||||
with(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager) {
|
||||
getMemoryInfo(memInfo)
|
||||
}
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
memInfo.advertisedMem.toFloat()
|
||||
} else {
|
||||
memInfo.totalMem.toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
fun isLessThan(minimum: Int, size: Float): Boolean =
|
||||
when (size) {
|
||||
Kb -> totalMemory < Mb && totalMemory < minimum
|
||||
Mb -> totalMemory < Gb && (totalMemory / Mb) < minimum
|
||||
Gb -> totalMemory < Tb && (totalMemory / Gb) < minimum
|
||||
Tb -> totalMemory < Pb && (totalMemory / Tb) < minimum
|
||||
Pb -> totalMemory < Eb && (totalMemory / Pb) < minimum
|
||||
Eb -> totalMemory / Eb < minimum
|
||||
else -> totalMemory < Kb && totalMemory < minimum
|
||||
}
|
||||
|
||||
// Devices are unlikely to have 0.5GB increments of memory so we'll just round up to account for
|
||||
// the potential error created by memInfo.totalMem
|
||||
fun getDeviceRAM(): String = bytesToSizeUnit(totalMemory, true)
|
||||
}
|
|
@ -0,0 +1,186 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.utils
|
||||
|
||||
import org.sudachi.sudachi_emu.model.GameDir
|
||||
import org.sudachi.sudachi_emu.overlay.model.OverlayControlData
|
||||
|
||||
import org.sudachi.sudachi_emu.features.input.model.PlayerInput
|
||||
|
||||
object NativeConfig {
|
||||
/**
|
||||
* Loads global config.
|
||||
*/
|
||||
@Synchronized
|
||||
external fun initializeGlobalConfig()
|
||||
|
||||
/**
|
||||
* Destroys the stored global config object. This does not save the existing config.
|
||||
*/
|
||||
@Synchronized
|
||||
external fun unloadGlobalConfig()
|
||||
|
||||
/**
|
||||
* Reads values in the global config file and saves them.
|
||||
*/
|
||||
@Synchronized
|
||||
external fun reloadGlobalConfig()
|
||||
|
||||
/**
|
||||
* Saves global settings values in memory to disk.
|
||||
*/
|
||||
@Synchronized
|
||||
external fun saveGlobalConfig()
|
||||
|
||||
/**
|
||||
* Creates per-game config for the specified parameters. Must be unloaded once per-game config
|
||||
* is closed with [unloadPerGameConfig]. All switchable values that [NativeConfig] gets/sets
|
||||
* will follow the per-game config until the global config is reloaded.
|
||||
*
|
||||
* @param programId String representation of the u64 programId
|
||||
* @param fileName Filename of the game, including its extension
|
||||
*/
|
||||
@Synchronized
|
||||
external fun initializePerGameConfig(programId: String, fileName: String)
|
||||
|
||||
@Synchronized
|
||||
external fun isPerGameConfigLoaded(): Boolean
|
||||
|
||||
/**
|
||||
* Saves per-game settings values in memory to disk.
|
||||
*/
|
||||
@Synchronized
|
||||
external fun savePerGameConfig()
|
||||
|
||||
/**
|
||||
* Destroys the stored per-game config object. This does not save the config.
|
||||
*/
|
||||
@Synchronized
|
||||
external fun unloadPerGameConfig()
|
||||
|
||||
@Synchronized
|
||||
external fun getBoolean(key: String, needsGlobal: Boolean): Boolean
|
||||
|
||||
@Synchronized
|
||||
external fun setBoolean(key: String, value: Boolean)
|
||||
|
||||
@Synchronized
|
||||
external fun getByte(key: String, needsGlobal: Boolean): Byte
|
||||
|
||||
@Synchronized
|
||||
external fun setByte(key: String, value: Byte)
|
||||
|
||||
@Synchronized
|
||||
external fun getShort(key: String, needsGlobal: Boolean): Short
|
||||
|
||||
@Synchronized
|
||||
external fun setShort(key: String, value: Short)
|
||||
|
||||
@Synchronized
|
||||
external fun getInt(key: String, needsGlobal: Boolean): Int
|
||||
|
||||
@Synchronized
|
||||
external fun setInt(key: String, value: Int)
|
||||
|
||||
@Synchronized
|
||||
external fun getFloat(key: String, needsGlobal: Boolean): Float
|
||||
|
||||
@Synchronized
|
||||
external fun setFloat(key: String, value: Float)
|
||||
|
||||
@Synchronized
|
||||
external fun getLong(key: String, needsGlobal: Boolean): Long
|
||||
|
||||
@Synchronized
|
||||
external fun setLong(key: String, value: Long)
|
||||
|
||||
@Synchronized
|
||||
external fun getString(key: String, needsGlobal: Boolean): String
|
||||
|
||||
@Synchronized
|
||||
external fun setString(key: String, value: String)
|
||||
|
||||
external fun getIsRuntimeModifiable(key: String): Boolean
|
||||
|
||||
external fun getPairedSettingKey(key: String): String
|
||||
|
||||
external fun getIsSwitchable(key: String): Boolean
|
||||
|
||||
@Synchronized
|
||||
external fun usingGlobal(key: String): Boolean
|
||||
|
||||
@Synchronized
|
||||
external fun setGlobal(key: String, global: Boolean)
|
||||
|
||||
external fun getIsSaveable(key: String): Boolean
|
||||
|
||||
external fun getDefaultToString(key: String): String
|
||||
|
||||
/**
|
||||
* Gets every [GameDir] in AndroidSettings::values.game_dirs
|
||||
*/
|
||||
@Synchronized
|
||||
external fun getGameDirs(): Array<GameDir>
|
||||
|
||||
/**
|
||||
* Clears the AndroidSettings::values.game_dirs array and replaces them with the provided array
|
||||
*/
|
||||
@Synchronized
|
||||
external fun setGameDirs(dirs: Array<GameDir>)
|
||||
|
||||
/**
|
||||
* Adds a single [GameDir] to the AndroidSettings::values.game_dirs array
|
||||
*/
|
||||
@Synchronized
|
||||
external fun addGameDir(dir: GameDir)
|
||||
|
||||
/**
|
||||
* Gets an array of the addons that are disabled for a given game
|
||||
*
|
||||
* @param programId String representation of a game's program ID
|
||||
* @return An array of disabled addons
|
||||
*/
|
||||
@Synchronized
|
||||
external fun getDisabledAddons(programId: String): Array<String>
|
||||
|
||||
/**
|
||||
* Clears the disabled addons array corresponding to [programId] and replaces them
|
||||
* with [disabledAddons]
|
||||
*
|
||||
* @param programId String representation of a game's program ID
|
||||
* @param disabledAddons Replacement array of disabled addons
|
||||
*/
|
||||
@Synchronized
|
||||
external fun setDisabledAddons(programId: String, disabledAddons: Array<String>)
|
||||
|
||||
/**
|
||||
* Gets an array of [OverlayControlData] from settings
|
||||
*
|
||||
* @return An array of [OverlayControlData]
|
||||
*/
|
||||
@Synchronized
|
||||
external fun getOverlayControlData(): Array<OverlayControlData>
|
||||
|
||||
/**
|
||||
* Clears the AndroidSettings::values.overlay_control_data array and replaces its values
|
||||
* with [overlayControlData]
|
||||
*
|
||||
* @param overlayControlData Replacement array of [OverlayControlData]
|
||||
*/
|
||||
@Synchronized
|
||||
external fun setOverlayControlData(overlayControlData: Array<OverlayControlData>)
|
||||
|
||||
@Synchronized
|
||||
external fun getInputSettings(global: Boolean): Array<PlayerInput>
|
||||
|
||||
@Synchronized
|
||||
external fun setInputSettings(value: Array<PlayerInput>, global: Boolean)
|
||||
|
||||
/**
|
||||
* Saves control values for a specific player
|
||||
* Must be used when per game config is loaded
|
||||
*/
|
||||
@Synchronized
|
||||
external fun saveControlPlayerValues()
|
||||
}
|
|
@ -0,0 +1,141 @@
|
|||
// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.utils
|
||||
|
||||
// Kotlin version of src/common/param_package.h
|
||||
class ParamPackage(serialized: String = "") {
|
||||
private val KEY_VALUE_SEPARATOR = ":"
|
||||
private val PARAM_SEPARATOR = ","
|
||||
|
||||
private val ESCAPE_CHARACTER = "$"
|
||||
private val KEY_VALUE_SEPARATOR_ESCAPE = "$0"
|
||||
private val PARAM_SEPARATOR_ESCAPE = "$1"
|
||||
private val ESCAPE_CHARACTER_ESCAPE = "$2"
|
||||
|
||||
private val EMPTY_PLACEHOLDER = "[empty]"
|
||||
|
||||
val data = mutableMapOf<String, String>()
|
||||
|
||||
init {
|
||||
val pairs = serialized.split(PARAM_SEPARATOR)
|
||||
for (pair in pairs) {
|
||||
val keyValue = pair.split(KEY_VALUE_SEPARATOR).toMutableList()
|
||||
if (keyValue.size != 2) {
|
||||
Log.error("[ParamPackage] Invalid key pair $keyValue")
|
||||
continue
|
||||
}
|
||||
|
||||
keyValue.forEachIndexed { i: Int, _: String ->
|
||||
keyValue[i] = keyValue[i].replace(KEY_VALUE_SEPARATOR_ESCAPE, KEY_VALUE_SEPARATOR)
|
||||
keyValue[i] = keyValue[i].replace(PARAM_SEPARATOR_ESCAPE, PARAM_SEPARATOR)
|
||||
keyValue[i] = keyValue[i].replace(ESCAPE_CHARACTER_ESCAPE, ESCAPE_CHARACTER)
|
||||
}
|
||||
|
||||
set(keyValue[0], keyValue[1])
|
||||
}
|
||||
}
|
||||
|
||||
constructor(params: List<Pair<String, String>>) : this() {
|
||||
params.forEach {
|
||||
data[it.first] = it.second
|
||||
}
|
||||
}
|
||||
|
||||
fun serialize(): String {
|
||||
if (data.isEmpty()) {
|
||||
return EMPTY_PLACEHOLDER
|
||||
}
|
||||
|
||||
val result = StringBuilder()
|
||||
data.forEach {
|
||||
val keyValue = mutableListOf(it.key, it.value)
|
||||
keyValue.forEachIndexed { i, _ ->
|
||||
keyValue[i] = keyValue[i].replace(ESCAPE_CHARACTER, ESCAPE_CHARACTER_ESCAPE)
|
||||
keyValue[i] = keyValue[i].replace(PARAM_SEPARATOR, PARAM_SEPARATOR_ESCAPE)
|
||||
keyValue[i] = keyValue[i].replace(KEY_VALUE_SEPARATOR, KEY_VALUE_SEPARATOR_ESCAPE)
|
||||
}
|
||||
result.append("${keyValue[0]}$KEY_VALUE_SEPARATOR${keyValue[1]}$PARAM_SEPARATOR")
|
||||
}
|
||||
return result.removeSuffix(PARAM_SEPARATOR).toString()
|
||||
}
|
||||
|
||||
fun get(key: String, defaultValue: String): String =
|
||||
if (has(key)) {
|
||||
data[key]!!
|
||||
} else {
|
||||
Log.debug("[ParamPackage] key $key not found")
|
||||
defaultValue
|
||||
}
|
||||
|
||||
fun get(key: String, defaultValue: Int): Int =
|
||||
if (has(key)) {
|
||||
try {
|
||||
data[key]!!.toInt()
|
||||
} catch (e: NumberFormatException) {
|
||||
Log.debug("[ParamPackage] failed to convert ${data[key]!!} to int")
|
||||
defaultValue
|
||||
}
|
||||
} else {
|
||||
Log.debug("[ParamPackage] key $key not found")
|
||||
defaultValue
|
||||
}
|
||||
|
||||
private fun Int.toBoolean(): Boolean =
|
||||
if (this == 1) {
|
||||
true
|
||||
} else if (this == 0) {
|
||||
false
|
||||
} else {
|
||||
throw Exception("Tried to convert a value to a boolean that was not 0 or 1!")
|
||||
}
|
||||
|
||||
fun get(key: String, defaultValue: Boolean): Boolean =
|
||||
if (has(key)) {
|
||||
try {
|
||||
get(key, if (defaultValue) 1 else 0).toBoolean()
|
||||
} catch (e: Exception) {
|
||||
Log.debug("[ParamPackage] failed to convert ${data[key]!!} to boolean")
|
||||
defaultValue
|
||||
}
|
||||
} else {
|
||||
Log.debug("[ParamPackage] key $key not found")
|
||||
defaultValue
|
||||
}
|
||||
|
||||
fun get(key: String, defaultValue: Float): Float =
|
||||
if (has(key)) {
|
||||
try {
|
||||
data[key]!!.toFloat()
|
||||
} catch (e: NumberFormatException) {
|
||||
Log.debug("[ParamPackage] failed to convert ${data[key]!!} to float")
|
||||
defaultValue
|
||||
}
|
||||
} else {
|
||||
Log.debug("[ParamPackage] key $key not found")
|
||||
defaultValue
|
||||
}
|
||||
|
||||
fun set(key: String, value: String) {
|
||||
data[key] = value
|
||||
}
|
||||
|
||||
fun set(key: String, value: Int) {
|
||||
data[key] = value.toString()
|
||||
}
|
||||
|
||||
fun Boolean.toInt(): Int = if (this) 1 else 0
|
||||
fun set(key: String, value: Boolean) {
|
||||
data[key] = value.toInt().toString()
|
||||
}
|
||||
|
||||
fun set(key: String, value: Float) {
|
||||
data[key] = value.toString()
|
||||
}
|
||||
|
||||
fun has(key: String): Boolean = data.containsKey(key)
|
||||
|
||||
fun erase(key: String) = data.remove(key)
|
||||
|
||||
fun clear() = data.clear()
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.utils
|
||||
|
||||
import android.content.SharedPreferences
|
||||
|
||||
object PreferenceUtil {
|
||||
/**
|
||||
* Retrieves a shared preference value and then deletes the value in storage.
|
||||
* @param key Associated key for the value in this preferences instance
|
||||
* @return Typed value associated with [key]. Null if no such key exists.
|
||||
*/
|
||||
inline fun <reified T> SharedPreferences.migratePreference(key: String): T? {
|
||||
if (!this.contains(key)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val value: Any = when (T::class) {
|
||||
String::class -> this.getString(key, "")!!
|
||||
|
||||
Boolean::class -> this.getBoolean(key, false)
|
||||
|
||||
Int::class -> this.getInt(key, 0)
|
||||
|
||||
Float::class -> this.getFloat(key, 0f)
|
||||
|
||||
Long::class -> this.getLong(key, 0)
|
||||
|
||||
else -> throw IllegalStateException("Tried to migrate preference with invalid type!")
|
||||
}
|
||||
deletePreference(key)
|
||||
return value as T
|
||||
}
|
||||
|
||||
fun SharedPreferences.deletePreference(key: String) = this.edit().remove(key).apply()
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.utils
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import java.io.Serializable
|
||||
|
||||
object SerializableHelper {
|
||||
inline fun <reified T : Serializable> Bundle.serializable(key: String): T? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getSerializable(key, T::class.java)
|
||||
} else {
|
||||
getSerializable(key) as? T
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : Serializable> Intent.serializable(key: String): T? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getSerializableExtra(key, T::class.java)
|
||||
} else {
|
||||
getSerializableExtra(key) as? T
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : Parcelable> Bundle.parcelable(key: String): T? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getParcelable(key, T::class.java)
|
||||
} else {
|
||||
getParcelable(key) as? T
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : Parcelable> Intent.parcelable(key: String): T? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
getParcelableExtra(key, T::class.java)
|
||||
} else {
|
||||
getParcelableExtra(key) as? T
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.utils
|
||||
|
||||
import android.text.TextUtils
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
|
||||
object ViewUtils {
|
||||
fun showView(view: View, length: Long = 300) {
|
||||
view.apply {
|
||||
alpha = 0f
|
||||
visibility = View.VISIBLE
|
||||
isClickable = true
|
||||
}.animate().apply {
|
||||
duration = length
|
||||
alpha(1f)
|
||||
}.start()
|
||||
}
|
||||
|
||||
fun hideView(view: View, length: Long = 300) {
|
||||
if (view.visibility == View.INVISIBLE) {
|
||||
return
|
||||
}
|
||||
|
||||
view.apply {
|
||||
alpha = 1f
|
||||
isClickable = false
|
||||
}.animate().apply {
|
||||
duration = length
|
||||
alpha(0f)
|
||||
}.withEndAction {
|
||||
view.visibility = View.INVISIBLE
|
||||
}.start()
|
||||
}
|
||||
|
||||
fun View.updateMargins(
|
||||
left: Int = -1,
|
||||
top: Int = -1,
|
||||
right: Int = -1,
|
||||
bottom: Int = -1
|
||||
) {
|
||||
val layoutParams = this.layoutParams as ViewGroup.MarginLayoutParams
|
||||
layoutParams.apply {
|
||||
if (left != -1) {
|
||||
leftMargin = left
|
||||
}
|
||||
if (top != -1) {
|
||||
topMargin = top
|
||||
}
|
||||
if (right != -1) {
|
||||
rightMargin = right
|
||||
}
|
||||
if (bottom != -1) {
|
||||
bottomMargin = bottom
|
||||
}
|
||||
}
|
||||
this.layoutParams = layoutParams
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows or hides a view.
|
||||
* @param visible Whether a view will be made View.VISIBLE or View.INVISIBLE/GONE.
|
||||
* @param gone Optional parameter for hiding a view. Uses View.GONE if true and View.INVISIBLE otherwise.
|
||||
*/
|
||||
fun View.setVisible(visible: Boolean, gone: Boolean = true) {
|
||||
visibility = if (visible) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
if (gone) {
|
||||
View.GONE
|
||||
} else {
|
||||
View.INVISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a marquee on some text.
|
||||
* @param delay Optional parameter for changing the start delay. 3 seconds of delay by default.
|
||||
*/
|
||||
fun TextView.marquee(delay: Long = 3000) {
|
||||
ellipsize = null
|
||||
marqueeRepeatLimit = -1
|
||||
isSingleLine = true
|
||||
postDelayed({
|
||||
ellipsize = TextUtils.TruncateAt.MARQUEE
|
||||
isSelected = true
|
||||
}, delay)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
package org.sudachi.sudachi_emu.views
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.util.Rational
|
||||
import android.view.SurfaceView
|
||||
|
||||
class FixedRatioSurfaceView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : SurfaceView(context, attrs, defStyleAttr) {
|
||||
private var aspectRatio: Float = 0f // (width / height), 0f is a special value for stretch
|
||||
|
||||
/**
|
||||
* Sets the desired aspect ratio for this view
|
||||
* @param ratio the ratio to force the view to, or null to stretch to fit
|
||||
*/
|
||||
fun setAspectRatio(ratio: Rational?) {
|
||||
aspectRatio = ratio?.toFloat() ?: 0f
|
||||
requestLayout()
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val displayWidth: Float = MeasureSpec.getSize(widthMeasureSpec).toFloat()
|
||||
val displayHeight: Float = MeasureSpec.getSize(heightMeasureSpec).toFloat()
|
||||
if (aspectRatio != 0f) {
|
||||
val displayAspect = displayWidth / displayHeight
|
||||
if (displayAspect < aspectRatio) {
|
||||
// Max out width
|
||||
val halfHeight = displayHeight / 2
|
||||
val surfaceHeight = displayWidth / aspectRatio
|
||||
val newTop: Float = halfHeight - (surfaceHeight / 2)
|
||||
val newBottom: Float = halfHeight + (surfaceHeight / 2)
|
||||
super.onMeasure(
|
||||
widthMeasureSpec,
|
||||
MeasureSpec.makeMeasureSpec(
|
||||
newBottom.toInt() - newTop.toInt(),
|
||||
MeasureSpec.EXACTLY
|
||||
)
|
||||
)
|
||||
return
|
||||
} else {
|
||||
// Max out height
|
||||
val halfWidth = displayWidth / 2
|
||||
val surfaceWidth = displayHeight * aspectRatio
|
||||
val newLeft: Float = halfWidth - (surfaceWidth / 2)
|
||||
val newRight: Float = halfWidth + (surfaceWidth / 2)
|
||||
super.onMeasure(
|
||||
MeasureSpec.makeMeasureSpec(
|
||||
newRight.toInt() - newLeft.toInt(),
|
||||
MeasureSpec.EXACTLY
|
||||
),
|
||||
heightMeasureSpec
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
# SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
add_library(sudachi-android SHARED
|
||||
emu_window/emu_window.cpp
|
||||
emu_window/emu_window.h
|
||||
native.cpp
|
||||
native.h
|
||||
native_config.cpp
|
||||
android_settings.cpp
|
||||
game_metadata.cpp
|
||||
native_log.cpp
|
||||
android_config.cpp
|
||||
android_config.h
|
||||
native_input.cpp
|
||||
)
|
||||
|
||||
set_property(TARGET sudachi-android PROPERTY IMPORTED_LOCATION ${FFmpeg_LIBRARY_DIR})
|
||||
|
||||
target_link_libraries(sudachi-android PRIVATE audio_core common core input_common frontend_common Vulkan::Headers)
|
||||
target_link_libraries(sudachi-android PRIVATE android camera2ndk EGL glad jnigraphics log)
|
||||
if (ARCHITECTURE_arm64)
|
||||
target_link_libraries(sudachi-android PRIVATE adrenotools)
|
||||
endif()
|
||||
|
||||
set(CPACK_PACKAGE_EXECUTABLES ${CPACK_PACKAGE_EXECUTABLES} sudachi-android)
|
|
@ -0,0 +1,60 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include "jni/android_common/android_common.h"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include "common/string_util.h"
|
||||
#include "jni/id_cache.h"
|
||||
|
||||
std::string GetJString(JNIEnv* env, jstring jstr) {
|
||||
if (!jstr) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const jchar* jchars = env->GetStringChars(jstr, nullptr);
|
||||
const jsize length = env->GetStringLength(jstr);
|
||||
const std::u16string_view string_view(reinterpret_cast<const char16_t*>(jchars), length);
|
||||
const std::string converted_string = Common::UTF16ToUTF8(string_view);
|
||||
env->ReleaseStringChars(jstr, jchars);
|
||||
|
||||
return converted_string;
|
||||
}
|
||||
|
||||
jstring ToJString(JNIEnv* env, std::string_view str) {
|
||||
const std::u16string converted_string = Common::UTF8ToUTF16(str);
|
||||
return env->NewString(reinterpret_cast<const jchar*>(converted_string.data()),
|
||||
static_cast<jint>(converted_string.size()));
|
||||
}
|
||||
|
||||
jstring ToJString(JNIEnv* env, std::u16string_view str) {
|
||||
return ToJString(env, Common::UTF16ToUTF8(str));
|
||||
}
|
||||
|
||||
double GetJDouble(JNIEnv* env, jobject jdouble) {
|
||||
return env->GetDoubleField(jdouble, IDCache::GetDoubleValueField());
|
||||
}
|
||||
|
||||
jobject ToJDouble(JNIEnv* env, double value) {
|
||||
return env->NewObject(IDCache::GetDoubleClass(), IDCache::GetDoubleConstructor(), value);
|
||||
}
|
||||
|
||||
s32 GetJInteger(JNIEnv* env, jobject jinteger) {
|
||||
return env->GetIntField(jinteger, IDCache::GetIntegerValueField());
|
||||
}
|
||||
|
||||
jobject ToJInteger(JNIEnv* env, s32 value) {
|
||||
return env->NewObject(IDCache::GetIntegerClass(), IDCache::GetIntegerConstructor(), value);
|
||||
}
|
||||
|
||||
bool GetJBoolean(JNIEnv* env, jobject jboolean) {
|
||||
return env->GetBooleanField(jboolean, IDCache::GetBooleanValueField());
|
||||
}
|
||||
|
||||
jobject ToJBoolean(JNIEnv* env, bool value) {
|
||||
return env->NewObject(IDCache::GetBooleanClass(), IDCache::GetBooleanConstructor(), value);
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
// SPDX-FileCopyrightText: Copyright 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include <jni.h>
|
||||
#include "common/common_types.h"
|
||||
|
||||
std::string GetJString(JNIEnv* env, jstring jstr);
|
||||
jstring ToJString(JNIEnv* env, std::string_view str);
|
||||
jstring ToJString(JNIEnv* env, std::u16string_view str);
|
||||
|
||||
double GetJDouble(JNIEnv* env, jobject jdouble);
|
||||
jobject ToJDouble(JNIEnv* env, double value);
|
||||
|
||||
s32 GetJInteger(JNIEnv* env, jobject jinteger);
|
||||
jobject ToJInteger(JNIEnv* env, s32 value);
|
||||
|
||||
bool GetJBoolean(JNIEnv* env, jobject jboolean);
|
||||
jobject ToJBoolean(JNIEnv* env, bool value);
|
|
@ -0,0 +1,337 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <common/logging/log.h>
|
||||
#include <input_common/main.h>
|
||||
#include "android_config.h"
|
||||
#include "android_settings.h"
|
||||
#include "common/settings_setting.h"
|
||||
|
||||
AndroidConfig::AndroidConfig(const std::string& config_name, ConfigType config_type)
|
||||
: Config(config_type) {
|
||||
Initialize(config_name);
|
||||
if (config_type != ConfigType::InputProfile) {
|
||||
ReadAndroidValues();
|
||||
SaveAndroidValues();
|
||||
}
|
||||
}
|
||||
|
||||
void AndroidConfig::ReloadAllValues() {
|
||||
Reload();
|
||||
ReadAndroidValues();
|
||||
SaveAndroidValues();
|
||||
}
|
||||
|
||||
void AndroidConfig::SaveAllValues() {
|
||||
SaveValues();
|
||||
SaveAndroidValues();
|
||||
}
|
||||
|
||||
void AndroidConfig::ReadAndroidValues() {
|
||||
if (global) {
|
||||
ReadAndroidUIValues();
|
||||
ReadUIValues();
|
||||
ReadOverlayValues();
|
||||
}
|
||||
ReadDriverValues();
|
||||
ReadAndroidControlValues();
|
||||
}
|
||||
|
||||
void AndroidConfig::ReadAndroidUIValues() {
|
||||
BeginGroup(Settings::TranslateCategory(Settings::Category::Android));
|
||||
|
||||
ReadCategory(Settings::Category::Android);
|
||||
|
||||
EndGroup();
|
||||
}
|
||||
|
||||
void AndroidConfig::ReadUIValues() {
|
||||
BeginGroup(Settings::TranslateCategory(Settings::Category::Ui));
|
||||
|
||||
ReadPathValues();
|
||||
|
||||
EndGroup();
|
||||
}
|
||||
|
||||
void AndroidConfig::ReadPathValues() {
|
||||
BeginGroup(Settings::TranslateCategory(Settings::Category::Paths));
|
||||
|
||||
AndroidSettings::values.game_dirs.clear();
|
||||
const int gamedirs_size = BeginArray(std::string("gamedirs"));
|
||||
for (int i = 0; i < gamedirs_size; ++i) {
|
||||
SetArrayIndex(i);
|
||||
AndroidSettings::GameDir game_dir;
|
||||
game_dir.path = ReadStringSetting(std::string("path"));
|
||||
game_dir.deep_scan =
|
||||
ReadBooleanSetting(std::string("deep_scan"), std::make_optional(false));
|
||||
AndroidSettings::values.game_dirs.push_back(game_dir);
|
||||
}
|
||||
EndArray();
|
||||
|
||||
EndGroup();
|
||||
}
|
||||
|
||||
void AndroidConfig::ReadDriverValues() {
|
||||
BeginGroup(Settings::TranslateCategory(Settings::Category::GpuDriver));
|
||||
|
||||
ReadCategory(Settings::Category::GpuDriver);
|
||||
|
||||
EndGroup();
|
||||
}
|
||||
|
||||
void AndroidConfig::ReadOverlayValues() {
|
||||
BeginGroup(Settings::TranslateCategory(Settings::Category::Overlay));
|
||||
|
||||
ReadCategory(Settings::Category::Overlay);
|
||||
|
||||
AndroidSettings::values.overlay_control_data.clear();
|
||||
const int control_data_size = BeginArray("control_data");
|
||||
for (int i = 0; i < control_data_size; ++i) {
|
||||
SetArrayIndex(i);
|
||||
AndroidSettings::OverlayControlData control_data;
|
||||
control_data.id = ReadStringSetting(std::string("id"));
|
||||
control_data.enabled = ReadBooleanSetting(std::string("enabled"));
|
||||
control_data.landscape_position.first =
|
||||
ReadDoubleSetting(std::string("landscape\\x_position"));
|
||||
control_data.landscape_position.second =
|
||||
ReadDoubleSetting(std::string("landscape\\y_position"));
|
||||
control_data.portrait_position.first =
|
||||
ReadDoubleSetting(std::string("portrait\\x_position"));
|
||||
control_data.portrait_position.second =
|
||||
ReadDoubleSetting(std::string("portrait\\y_position"));
|
||||
control_data.foldable_position.first =
|
||||
ReadDoubleSetting(std::string("foldable\\x_position"));
|
||||
control_data.foldable_position.second =
|
||||
ReadDoubleSetting(std::string("foldable\\y_position"));
|
||||
AndroidSettings::values.overlay_control_data.push_back(control_data);
|
||||
}
|
||||
EndArray();
|
||||
|
||||
EndGroup();
|
||||
}
|
||||
|
||||
void AndroidConfig::ReadAndroidPlayerValues(std::size_t player_index) {
|
||||
std::string player_prefix;
|
||||
if (type != ConfigType::InputProfile) {
|
||||
player_prefix.append("player_").append(ToString(player_index)).append("_");
|
||||
}
|
||||
|
||||
auto& player = Settings::values.players.GetValue()[player_index];
|
||||
if (IsCustomConfig()) {
|
||||
const auto profile_name =
|
||||
ReadStringSetting(std::string(player_prefix).append("profile_name"));
|
||||
if (profile_name.empty()) {
|
||||
// Use the global input config
|
||||
player = Settings::values.players.GetValue(true)[player_index];
|
||||
player.profile_name = "";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Android doesn't have default options for controllers. We have the input overlay for that.
|
||||
for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) {
|
||||
const std::string default_param;
|
||||
auto& player_buttons = player.buttons[i];
|
||||
|
||||
player_buttons = ReadStringSetting(
|
||||
std::string(player_prefix).append(Settings::NativeButton::mapping[i]), default_param);
|
||||
if (player_buttons.empty()) {
|
||||
player_buttons = default_param;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) {
|
||||
const std::string default_param;
|
||||
auto& player_analogs = player.analogs[i];
|
||||
|
||||
player_analogs = ReadStringSetting(
|
||||
std::string(player_prefix).append(Settings::NativeAnalog::mapping[i]), default_param);
|
||||
if (player_analogs.empty()) {
|
||||
player_analogs = default_param;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < Settings::NativeMotion::NumMotions; ++i) {
|
||||
const std::string default_param;
|
||||
auto& player_motions = player.motions[i];
|
||||
|
||||
player_motions = ReadStringSetting(
|
||||
std::string(player_prefix).append(Settings::NativeMotion::mapping[i]), default_param);
|
||||
if (player_motions.empty()) {
|
||||
player_motions = default_param;
|
||||
}
|
||||
}
|
||||
player.use_system_vibrator = ReadBooleanSetting(
|
||||
std::string(player_prefix).append("use_system_vibrator"), player_index == 0);
|
||||
}
|
||||
|
||||
void AndroidConfig::ReadAndroidControlValues() {
|
||||
BeginGroup(Settings::TranslateCategory(Settings::Category::Controls));
|
||||
|
||||
Settings::values.players.SetGlobal(!IsCustomConfig());
|
||||
for (std::size_t p = 0; p < Settings::values.players.GetValue().size(); ++p) {
|
||||
ReadAndroidPlayerValues(p);
|
||||
}
|
||||
if (IsCustomConfig()) {
|
||||
EndGroup();
|
||||
return;
|
||||
}
|
||||
// ReadDebugControlValues();
|
||||
// ReadHidbusValues();
|
||||
|
||||
EndGroup();
|
||||
}
|
||||
|
||||
void AndroidConfig::SaveAndroidValues() {
|
||||
if (global) {
|
||||
SaveAndroidUIValues();
|
||||
SaveUIValues();
|
||||
SaveOverlayValues();
|
||||
}
|
||||
SaveDriverValues();
|
||||
SaveAndroidControlValues();
|
||||
|
||||
WriteToIni();
|
||||
}
|
||||
|
||||
void AndroidConfig::SaveAndroidUIValues() {
|
||||
BeginGroup(Settings::TranslateCategory(Settings::Category::Android));
|
||||
|
||||
WriteCategory(Settings::Category::Android);
|
||||
|
||||
EndGroup();
|
||||
}
|
||||
|
||||
void AndroidConfig::SaveUIValues() {
|
||||
BeginGroup(Settings::TranslateCategory(Settings::Category::Ui));
|
||||
|
||||
SavePathValues();
|
||||
|
||||
EndGroup();
|
||||
}
|
||||
|
||||
void AndroidConfig::SavePathValues() {
|
||||
BeginGroup(Settings::TranslateCategory(Settings::Category::Paths));
|
||||
|
||||
BeginArray(std::string("gamedirs"));
|
||||
for (size_t i = 0; i < AndroidSettings::values.game_dirs.size(); ++i) {
|
||||
SetArrayIndex(i);
|
||||
const auto& game_dir = AndroidSettings::values.game_dirs[i];
|
||||
WriteStringSetting(std::string("path"), game_dir.path);
|
||||
WriteBooleanSetting(std::string("deep_scan"), game_dir.deep_scan,
|
||||
std::make_optional(false));
|
||||
}
|
||||
EndArray();
|
||||
|
||||
EndGroup();
|
||||
}
|
||||
|
||||
void AndroidConfig::SaveDriverValues() {
|
||||
BeginGroup(Settings::TranslateCategory(Settings::Category::GpuDriver));
|
||||
|
||||
WriteCategory(Settings::Category::GpuDriver);
|
||||
|
||||
EndGroup();
|
||||
}
|
||||
|
||||
void AndroidConfig::SaveOverlayValues() {
|
||||
BeginGroup(Settings::TranslateCategory(Settings::Category::Overlay));
|
||||
|
||||
WriteCategory(Settings::Category::Overlay);
|
||||
|
||||
BeginArray("control_data");
|
||||
for (size_t i = 0; i < AndroidSettings::values.overlay_control_data.size(); ++i) {
|
||||
SetArrayIndex(i);
|
||||
const auto& control_data = AndroidSettings::values.overlay_control_data[i];
|
||||
WriteStringSetting(std::string("id"), control_data.id);
|
||||
WriteBooleanSetting(std::string("enabled"), control_data.enabled);
|
||||
WriteDoubleSetting(std::string("landscape\\x_position"),
|
||||
control_data.landscape_position.first);
|
||||
WriteDoubleSetting(std::string("landscape\\y_position"),
|
||||
control_data.landscape_position.second);
|
||||
WriteDoubleSetting(std::string("portrait\\x_position"),
|
||||
control_data.portrait_position.first);
|
||||
WriteDoubleSetting(std::string("portrait\\y_position"),
|
||||
control_data.portrait_position.second);
|
||||
WriteDoubleSetting(std::string("foldable\\x_position"),
|
||||
control_data.foldable_position.first);
|
||||
WriteDoubleSetting(std::string("foldable\\y_position"),
|
||||
control_data.foldable_position.second);
|
||||
}
|
||||
EndArray();
|
||||
|
||||
EndGroup();
|
||||
}
|
||||
|
||||
void AndroidConfig::SaveAndroidPlayerValues(std::size_t player_index) {
|
||||
std::string player_prefix;
|
||||
if (type != ConfigType::InputProfile) {
|
||||
player_prefix = std::string("player_").append(ToString(player_index)).append("_");
|
||||
}
|
||||
|
||||
const auto& player = Settings::values.players.GetValue()[player_index];
|
||||
if (IsCustomConfig() && player.profile_name.empty()) {
|
||||
// No custom profile selected
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string default_param;
|
||||
for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) {
|
||||
WriteStringSetting(std::string(player_prefix).append(Settings::NativeButton::mapping[i]),
|
||||
player.buttons[i], std::make_optional(default_param));
|
||||
}
|
||||
for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) {
|
||||
WriteStringSetting(std::string(player_prefix).append(Settings::NativeAnalog::mapping[i]),
|
||||
player.analogs[i], std::make_optional(default_param));
|
||||
}
|
||||
for (int i = 0; i < Settings::NativeMotion::NumMotions; ++i) {
|
||||
WriteStringSetting(std::string(player_prefix).append(Settings::NativeMotion::mapping[i]),
|
||||
player.motions[i], std::make_optional(default_param));
|
||||
}
|
||||
WriteBooleanSetting(std::string(player_prefix).append("use_system_vibrator"),
|
||||
player.use_system_vibrator, std::make_optional(player_index == 0));
|
||||
}
|
||||
|
||||
void AndroidConfig::SaveAndroidControlValues() {
|
||||
BeginGroup(Settings::TranslateCategory(Settings::Category::Controls));
|
||||
|
||||
Settings::values.players.SetGlobal(!IsCustomConfig());
|
||||
for (std::size_t p = 0; p < Settings::values.players.GetValue().size(); ++p) {
|
||||
SaveAndroidPlayerValues(p);
|
||||
}
|
||||
if (IsCustomConfig()) {
|
||||
EndGroup();
|
||||
return;
|
||||
}
|
||||
// SaveDebugControlValues();
|
||||
// SaveHidbusValues();
|
||||
|
||||
EndGroup();
|
||||
}
|
||||
|
||||
std::vector<Settings::BasicSetting*>& AndroidConfig::FindRelevantList(Settings::Category category) {
|
||||
auto& map = Settings::values.linkage.by_category;
|
||||
if (map.contains(category)) {
|
||||
return Settings::values.linkage.by_category[category];
|
||||
}
|
||||
return AndroidSettings::values.linkage.by_category[category];
|
||||
}
|
||||
|
||||
void AndroidConfig::ReadAndroidControlPlayerValues(std::size_t player_index) {
|
||||
BeginGroup(Settings::TranslateCategory(Settings::Category::Controls));
|
||||
|
||||
ReadPlayerValues(player_index);
|
||||
ReadAndroidPlayerValues(player_index);
|
||||
|
||||
EndGroup();
|
||||
}
|
||||
|
||||
void AndroidConfig::SaveAndroidControlPlayerValues(std::size_t player_index) {
|
||||
BeginGroup(Settings::TranslateCategory(Settings::Category::Controls));
|
||||
|
||||
LOG_DEBUG(Config, "Saving players control configuration values");
|
||||
SavePlayerValues(player_index);
|
||||
SaveAndroidPlayerValues(player_index);
|
||||
|
||||
EndGroup();
|
||||
|
||||
WriteToIni();
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "frontend_common/config.h"
|
||||
|
||||
class AndroidConfig final : public Config {
|
||||
public:
|
||||
explicit AndroidConfig(const std::string& config_name = "config",
|
||||
ConfigType config_type = ConfigType::GlobalConfig);
|
||||
|
||||
void ReloadAllValues() override;
|
||||
void SaveAllValues() override;
|
||||
|
||||
void ReadAndroidControlPlayerValues(std::size_t player_index);
|
||||
void SaveAndroidControlPlayerValues(std::size_t player_index);
|
||||
|
||||
protected:
|
||||
void ReadAndroidPlayerValues(std::size_t player_index);
|
||||
void ReadAndroidControlValues();
|
||||
void ReadAndroidValues();
|
||||
void ReadAndroidUIValues();
|
||||
void ReadDriverValues();
|
||||
void ReadOverlayValues();
|
||||
void ReadHidbusValues() override {}
|
||||
void ReadDebugControlValues() override {}
|
||||
void ReadPathValues() override;
|
||||
void ReadShortcutValues() override {}
|
||||
void ReadUIValues() override;
|
||||
void ReadUIGamelistValues() override {}
|
||||
void ReadUILayoutValues() override {}
|
||||
void ReadMultiplayerValues() override {}
|
||||
|
||||
void SaveAndroidPlayerValues(std::size_t player_index);
|
||||
void SaveAndroidControlValues();
|
||||
void SaveAndroidValues();
|
||||
void SaveAndroidUIValues();
|
||||
void SaveDriverValues();
|
||||
void SaveOverlayValues();
|
||||
void SaveHidbusValues() override {}
|
||||
void SaveDebugControlValues() override {}
|
||||
void SavePathValues() override;
|
||||
void SaveShortcutValues() override {}
|
||||
void SaveUIValues() override;
|
||||
void SaveUIGamelistValues() override {}
|
||||
void SaveUILayoutValues() override {}
|
||||
void SaveMultiplayerValues() override {}
|
||||
|
||||
std::vector<Settings::BasicSetting*>& FindRelevantList(Settings::Category category) override;
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include "android_settings.h"
|
||||
|
||||
namespace AndroidSettings {
|
||||
|
||||
Values values;
|
||||
|
||||
} // namespace AndroidSettings
|
|
@ -0,0 +1,330 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
|
||||
#include <INIReader.h>
|
||||
#include "common/fs/file.h"
|
||||
#include "common/fs/fs.h"
|
||||
#include "common/fs/path_util.h"
|
||||
#include "common/logging/log.h"
|
||||
#include "common/settings.h"
|
||||
#include "common/settings_enums.h"
|
||||
#include "core/hle/service/acc/profile_manager.h"
|
||||
#include "input_common/main.h"
|
||||
#include "jni/config.h"
|
||||
#include "jni/default_ini.h"
|
||||
#include "uisettings.h"
|
||||
|
||||
namespace FS = Common::FS;
|
||||
|
||||
Config::Config(const std::string& config_name, ConfigType config_type)
|
||||
: type(config_type), global{config_type == ConfigType::GlobalConfig} {
|
||||
Initialize(config_name);
|
||||
}
|
||||
|
||||
Config::~Config() = default;
|
||||
|
||||
bool Config::LoadINI(const std::string& default_contents, bool retry) {
|
||||
void(FS::CreateParentDir(config_loc));
|
||||
config = std::make_unique<INIReader>(FS::PathToUTF8String(config_loc));
|
||||
const auto config_loc_str = FS::PathToUTF8String(config_loc);
|
||||
if (config->ParseError() < 0) {
|
||||
if (retry) {
|
||||
LOG_WARNING(Config, "Failed to load {}. Creating file from defaults...",
|
||||
config_loc_str);
|
||||
|
||||
void(FS::CreateParentDir(config_loc));
|
||||
void(FS::WriteStringToFile(config_loc, FS::FileType::TextFile, default_contents));
|
||||
|
||||
config = std::make_unique<INIReader>(config_loc_str);
|
||||
|
||||
return LoadINI(default_contents, false);
|
||||
}
|
||||
LOG_ERROR(Config, "Failed.");
|
||||
return false;
|
||||
}
|
||||
LOG_INFO(Config, "Successfully loaded {}", config_loc_str);
|
||||
return true;
|
||||
}
|
||||
|
||||
template <>
|
||||
void Config::ReadSetting(const std::string& group, Settings::Setting<std::string>& setting) {
|
||||
std::string setting_value = config->Get(group, setting.GetLabel(), setting.GetDefault());
|
||||
if (setting_value.empty()) {
|
||||
setting_value = setting.GetDefault();
|
||||
}
|
||||
setting = std::move(setting_value);
|
||||
}
|
||||
|
||||
template <>
|
||||
void Config::ReadSetting(const std::string& group, Settings::Setting<bool>& setting) {
|
||||
setting = config->GetBoolean(group, setting.GetLabel(), setting.GetDefault());
|
||||
}
|
||||
|
||||
template <typename Type, bool ranged>
|
||||
void Config::ReadSetting(const std::string& group, Settings::Setting<Type, ranged>& setting) {
|
||||
setting = static_cast<Type>(
|
||||
config->GetInteger(group, setting.GetLabel(), static_cast<long>(setting.GetDefault())));
|
||||
}
|
||||
|
||||
void Config::ReadValues() {
|
||||
ReadSetting("ControlsGeneral", Settings::values.mouse_enabled);
|
||||
ReadSetting("ControlsGeneral", Settings::values.touch_device);
|
||||
ReadSetting("ControlsGeneral", Settings::values.keyboard_enabled);
|
||||
ReadSetting("ControlsGeneral", Settings::values.debug_pad_enabled);
|
||||
ReadSetting("ControlsGeneral", Settings::values.vibration_enabled);
|
||||
ReadSetting("ControlsGeneral", Settings::values.enable_accurate_vibrations);
|
||||
ReadSetting("ControlsGeneral", Settings::values.motion_enabled);
|
||||
Settings::values.touchscreen.enabled =
|
||||
config->GetBoolean("ControlsGeneral", "touch_enabled", true);
|
||||
Settings::values.touchscreen.rotation_angle =
|
||||
config->GetInteger("ControlsGeneral", "touch_angle", 0);
|
||||
Settings::values.touchscreen.diameter_x =
|
||||
config->GetInteger("ControlsGeneral", "touch_diameter_x", 15);
|
||||
Settings::values.touchscreen.diameter_y =
|
||||
config->GetInteger("ControlsGeneral", "touch_diameter_y", 15);
|
||||
|
||||
int num_touch_from_button_maps =
|
||||
config->GetInteger("ControlsGeneral", "touch_from_button_map", 0);
|
||||
if (num_touch_from_button_maps > 0) {
|
||||
for (int i = 0; i < num_touch_from_button_maps; ++i) {
|
||||
Settings::TouchFromButtonMap map;
|
||||
map.name = config->Get("ControlsGeneral",
|
||||
std::string("touch_from_button_maps_") + std::to_string(i) +
|
||||
std::string("_name"),
|
||||
"default");
|
||||
const int num_touch_maps = config->GetInteger(
|
||||
"ControlsGeneral",
|
||||
std::string("touch_from_button_maps_") + std::to_string(i) + std::string("_count"),
|
||||
0);
|
||||
map.buttons.reserve(num_touch_maps);
|
||||
|
||||
for (int j = 0; j < num_touch_maps; ++j) {
|
||||
std::string touch_mapping =
|
||||
config->Get("ControlsGeneral",
|
||||
std::string("touch_from_button_maps_") + std::to_string(i) +
|
||||
std::string("_bind_") + std::to_string(j),
|
||||
"");
|
||||
map.buttons.emplace_back(std::move(touch_mapping));
|
||||
}
|
||||
|
||||
Settings::values.touch_from_button_maps.emplace_back(std::move(map));
|
||||
}
|
||||
} else {
|
||||
Settings::values.touch_from_button_maps.emplace_back(
|
||||
Settings::TouchFromButtonMap{"default", {}});
|
||||
num_touch_from_button_maps = 1;
|
||||
}
|
||||
Settings::values.touch_from_button_map_index = std::clamp(
|
||||
Settings::values.touch_from_button_map_index.GetValue(), 0, num_touch_from_button_maps - 1);
|
||||
|
||||
ReadSetting("ControlsGeneral", Settings::values.udp_input_servers);
|
||||
|
||||
// Data Storage
|
||||
ReadSetting("Data Storage", Settings::values.use_virtual_sd);
|
||||
FS::SetSudachiPath(FS::SudachiPath::NANDDir,
|
||||
config->Get("Data Storage", "nand_directory",
|
||||
FS::GetSudachiPathString(FS::SudachiPath::NANDDir)));
|
||||
FS::SetSudachiPath(FS::SudachiPath::SDMCDir,
|
||||
config->Get("Data Storage", "sdmc_directory",
|
||||
FS::GetSudachiPathString(FS::SudachiPath::SDMCDir)));
|
||||
FS::SetSudachiPath(FS::SudachiPath::LoadDir,
|
||||
config->Get("Data Storage", "load_directory",
|
||||
FS::GetSudachiPathString(FS::SudachiPath::LoadDir)));
|
||||
FS::SetSudachiPath(FS::SudachiPath::DumpDir,
|
||||
config->Get("Data Storage", "dump_directory",
|
||||
FS::GetSudachiPathString(FS::SudachiPath::DumpDir)));
|
||||
ReadSetting("Data Storage", Settings::values.gamecard_inserted);
|
||||
ReadSetting("Data Storage", Settings::values.gamecard_current_game);
|
||||
ReadSetting("Data Storage", Settings::values.gamecard_path);
|
||||
|
||||
// System
|
||||
ReadSetting("System", Settings::values.current_user);
|
||||
Settings::values.current_user = std::clamp<int>(Settings::values.current_user.GetValue(), 0,
|
||||
Service::Account::MAX_USERS - 1);
|
||||
|
||||
// Disable docked mode by default on Android
|
||||
Settings::values.use_docked_mode.SetValue(config->GetBoolean("System", "use_docked_mode", false)
|
||||
? Settings::ConsoleMode::Docked
|
||||
: Settings::ConsoleMode::Handheld);
|
||||
|
||||
const auto rng_seed_enabled = config->GetBoolean("System", "rng_seed_enabled", false);
|
||||
if (rng_seed_enabled) {
|
||||
Settings::values.rng_seed.SetValue(config->GetInteger("System", "rng_seed", 0));
|
||||
} else {
|
||||
Settings::values.rng_seed.SetValue(0);
|
||||
}
|
||||
Settings::values.rng_seed_enabled.SetValue(rng_seed_enabled);
|
||||
|
||||
const auto custom_rtc_enabled = config->GetBoolean("System", "custom_rtc_enabled", false);
|
||||
if (custom_rtc_enabled) {
|
||||
Settings::values.custom_rtc = config->GetInteger("System", "custom_rtc", 0);
|
||||
} else {
|
||||
Settings::values.custom_rtc = 0;
|
||||
}
|
||||
Settings::values.custom_rtc_enabled = custom_rtc_enabled;
|
||||
|
||||
ReadSetting("System", Settings::values.language_index);
|
||||
ReadSetting("System", Settings::values.region_index);
|
||||
ReadSetting("System", Settings::values.time_zone_index);
|
||||
ReadSetting("System", Settings::values.sound_index);
|
||||
|
||||
// Core
|
||||
ReadSetting("Core", Settings::values.use_multi_core);
|
||||
ReadSetting("Core", Settings::values.memory_layout_mode);
|
||||
|
||||
// Cpu
|
||||
ReadSetting("Cpu", Settings::values.cpu_accuracy);
|
||||
ReadSetting("Cpu", Settings::values.cpu_debug_mode);
|
||||
ReadSetting("Cpu", Settings::values.cpuopt_page_tables);
|
||||
ReadSetting("Cpu", Settings::values.cpuopt_block_linking);
|
||||
ReadSetting("Cpu", Settings::values.cpuopt_return_stack_buffer);
|
||||
ReadSetting("Cpu", Settings::values.cpuopt_fast_dispatcher);
|
||||
ReadSetting("Cpu", Settings::values.cpuopt_context_elimination);
|
||||
ReadSetting("Cpu", Settings::values.cpuopt_const_prop);
|
||||
ReadSetting("Cpu", Settings::values.cpuopt_misc_ir);
|
||||
ReadSetting("Cpu", Settings::values.cpuopt_reduce_misalign_checks);
|
||||
ReadSetting("Cpu", Settings::values.cpuopt_fastmem);
|
||||
ReadSetting("Cpu", Settings::values.cpuopt_fastmem_exclusives);
|
||||
ReadSetting("Cpu", Settings::values.cpuopt_recompile_exclusives);
|
||||
ReadSetting("Cpu", Settings::values.cpuopt_ignore_memory_aborts);
|
||||
ReadSetting("Cpu", Settings::values.cpuopt_unsafe_unfuse_fma);
|
||||
ReadSetting("Cpu", Settings::values.cpuopt_unsafe_reduce_fp_error);
|
||||
ReadSetting("Cpu", Settings::values.cpuopt_unsafe_ignore_standard_fpcr);
|
||||
ReadSetting("Cpu", Settings::values.cpuopt_unsafe_inaccurate_nan);
|
||||
ReadSetting("Cpu", Settings::values.cpuopt_unsafe_fastmem_check);
|
||||
ReadSetting("Cpu", Settings::values.cpuopt_unsafe_ignore_global_monitor);
|
||||
|
||||
// Renderer
|
||||
ReadSetting("Renderer", Settings::values.renderer_backend);
|
||||
ReadSetting("Renderer", Settings::values.renderer_debug);
|
||||
ReadSetting("Renderer", Settings::values.renderer_shader_feedback);
|
||||
ReadSetting("Renderer", Settings::values.enable_nsight_aftermath);
|
||||
ReadSetting("Renderer", Settings::values.disable_shader_loop_safety_checks);
|
||||
ReadSetting("Renderer", Settings::values.vulkan_device);
|
||||
|
||||
ReadSetting("Renderer", Settings::values.resolution_setup);
|
||||
ReadSetting("Renderer", Settings::values.scaling_filter);
|
||||
ReadSetting("Renderer", Settings::values.fsr_sharpening_slider);
|
||||
ReadSetting("Renderer", Settings::values.anti_aliasing);
|
||||
ReadSetting("Renderer", Settings::values.fullscreen_mode);
|
||||
ReadSetting("Renderer", Settings::values.aspect_ratio);
|
||||
ReadSetting("Renderer", Settings::values.max_anisotropy);
|
||||
ReadSetting("Renderer", Settings::values.use_speed_limit);
|
||||
ReadSetting("Renderer", Settings::values.speed_limit);
|
||||
ReadSetting("Renderer", Settings::values.use_disk_shader_cache);
|
||||
ReadSetting("Renderer", Settings::values.use_asynchronous_gpu_emulation);
|
||||
ReadSetting("Renderer", Settings::values.vsync_mode);
|
||||
ReadSetting("Renderer", Settings::values.shader_backend);
|
||||
ReadSetting("Renderer", Settings::values.use_asynchronous_shaders);
|
||||
ReadSetting("Renderer", Settings::values.nvdec_emulation);
|
||||
ReadSetting("Renderer", Settings::values.use_fast_gpu_time);
|
||||
ReadSetting("Renderer", Settings::values.use_vulkan_driver_pipeline_cache);
|
||||
|
||||
ReadSetting("Renderer", Settings::values.bg_red);
|
||||
ReadSetting("Renderer", Settings::values.bg_green);
|
||||
ReadSetting("Renderer", Settings::values.bg_blue);
|
||||
|
||||
// Use GPU accuracy normal by default on Android
|
||||
Settings::values.gpu_accuracy = static_cast<Settings::GpuAccuracy>(config->GetInteger(
|
||||
"Renderer", "gpu_accuracy", static_cast<u32>(Settings::GpuAccuracy::Normal)));
|
||||
|
||||
// Use GPU default anisotropic filtering on Android
|
||||
Settings::values.max_anisotropy =
|
||||
static_cast<Settings::AnisotropyMode>(config->GetInteger("Renderer", "max_anisotropy", 1));
|
||||
|
||||
// Disable ASTC compute by default on Android
|
||||
Settings::values.accelerate_astc.SetValue(
|
||||
config->GetBoolean("Renderer", "accelerate_astc", false) ? Settings::AstcDecodeMode::Gpu
|
||||
: Settings::AstcDecodeMode::Cpu);
|
||||
|
||||
// Enable asynchronous presentation by default on Android
|
||||
Settings::values.async_presentation =
|
||||
config->GetBoolean("Renderer", "async_presentation", true);
|
||||
|
||||
// Disable force_max_clock by default on Android
|
||||
Settings::values.renderer_force_max_clock =
|
||||
config->GetBoolean("Renderer", "force_max_clock", false);
|
||||
|
||||
// Disable use_reactive_flushing by default on Android
|
||||
Settings::values.use_reactive_flushing =
|
||||
config->GetBoolean("Renderer", "use_reactive_flushing", false);
|
||||
|
||||
// Audio
|
||||
ReadSetting("Audio", Settings::values.sink_id);
|
||||
ReadSetting("Audio", Settings::values.audio_output_device_id);
|
||||
ReadSetting("Audio", Settings::values.volume);
|
||||
|
||||
// Miscellaneous
|
||||
// log_filter has a different default here than from common
|
||||
Settings::values.log_filter = "*:Info";
|
||||
ReadSetting("Miscellaneous", Settings::values.use_dev_keys);
|
||||
|
||||
// Debugging
|
||||
Settings::values.record_frame_times =
|
||||
config->GetBoolean("Debugging", "record_frame_times", false);
|
||||
ReadSetting("Debugging", Settings::values.dump_exefs);
|
||||
ReadSetting("Debugging", Settings::values.dump_nso);
|
||||
ReadSetting("Debugging", Settings::values.enable_fs_access_log);
|
||||
ReadSetting("Debugging", Settings::values.reporting_services);
|
||||
ReadSetting("Debugging", Settings::values.quest_flag);
|
||||
ReadSetting("Debugging", Settings::values.use_debug_asserts);
|
||||
ReadSetting("Debugging", Settings::values.use_auto_stub);
|
||||
ReadSetting("Debugging", Settings::values.disable_macro_jit);
|
||||
ReadSetting("Debugging", Settings::values.disable_macro_hle);
|
||||
ReadSetting("Debugging", Settings::values.use_gdbstub);
|
||||
ReadSetting("Debugging", Settings::values.gdbstub_port);
|
||||
|
||||
const auto title_list = config->Get("AddOns", "title_ids", "");
|
||||
std::stringstream ss(title_list);
|
||||
std::string line;
|
||||
while (std::getline(ss, line, '|')) {
|
||||
const auto title_id = std::strtoul(line.c_str(), nullptr, 16);
|
||||
const auto disabled_list = config->Get("AddOns", "disabled_" + line, "");
|
||||
|
||||
std::stringstream inner_ss(disabled_list);
|
||||
std::string inner_line;
|
||||
std::vector<std::string> out;
|
||||
while (std::getline(inner_ss, inner_line, '|')) {
|
||||
out.push_back(inner_line);
|
||||
}
|
||||
|
||||
Settings::values.disabled_addons.insert_or_assign(title_id, out);
|
||||
}
|
||||
|
||||
// Web Service
|
||||
ReadSetting("WebService", Settings::values.enable_telemetry);
|
||||
ReadSetting("WebService", Settings::values.web_api_url);
|
||||
ReadSetting("WebService", Settings::values.sudachi_username);
|
||||
ReadSetting("WebService", Settings::values.sudachi_token);
|
||||
|
||||
// Network
|
||||
ReadSetting("Network", Settings::values.network_interface);
|
||||
|
||||
// Android
|
||||
ReadSetting("Android", AndroidSettings::values.picture_in_picture);
|
||||
ReadSetting("Android", AndroidSettings::values.screen_layout);
|
||||
}
|
||||
|
||||
void Config::Initialize(const std::string& config_name) {
|
||||
const auto fs_config_loc = FS::GetSudachiPath(FS::SudachiPath::ConfigDir);
|
||||
const auto config_file = fmt::format("{}.ini", config_name);
|
||||
|
||||
switch (type) {
|
||||
case ConfigType::GlobalConfig:
|
||||
config_loc = FS::PathToUTF8String(fs_config_loc / config_file);
|
||||
break;
|
||||
case ConfigType::PerGameConfig:
|
||||
config_loc = FS::PathToUTF8String(fs_config_loc / "custom" / FS::ToU8String(config_file));
|
||||
break;
|
||||
case ConfigType::InputProfile:
|
||||
config_loc = FS::PathToUTF8String(fs_config_loc / "input" / config_file);
|
||||
LoadINI(DefaultINI::android_config_file);
|
||||
return;
|
||||
}
|
||||
LoadINI(DefaultINI::android_config_file);
|
||||
ReadValues();
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "common/settings.h"
|
||||
|
||||
class INIReader;
|
||||
|
||||
class Config {
|
||||
bool LoadINI(const std::string& default_contents = "", bool retry = true);
|
||||
|
||||
public:
|
||||
enum class ConfigType {
|
||||
GlobalConfig,
|
||||
PerGameConfig,
|
||||
InputProfile,
|
||||
};
|
||||
|
||||
explicit Config(const std::string& config_name = "config",
|
||||
ConfigType config_type = ConfigType::GlobalConfig);
|
||||
~Config();
|
||||
|
||||
void Initialize(const std::string& config_name);
|
||||
|
||||
private:
|
||||
/**
|
||||
* Applies a value read from the config to a Setting.
|
||||
*
|
||||
* @param group The name of the INI group
|
||||
* @param setting The sudachi setting to modify
|
||||
*/
|
||||
template <typename Type, bool ranged>
|
||||
void ReadSetting(const std::string& group, Settings::Setting<Type, ranged>& setting);
|
||||
|
||||
void ReadValues();
|
||||
|
||||
const ConfigType type;
|
||||
std::unique_ptr<INIReader> config;
|
||||
std::string config_loc;
|
||||
const bool global;
|
||||
};
|
|
@ -0,0 +1,511 @@
|
|||
// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace DefaultINI {
|
||||
|
||||
const char* android_config_file = R"(
|
||||
|
||||
[ControlsP0]
|
||||
# The input devices and parameters for each Switch native input
|
||||
# The config section determines the player number where the config will be applied on. For example "ControlsP0", "ControlsP1", ...
|
||||
# It should be in the format of "engine:[engine_name],[param1]:[value1],[param2]:[value2]..."
|
||||
# Escape characters $0 (for ':'), $1 (for ',') and $2 (for '$') can be used in values
|
||||
|
||||
# Indicates if this player should be connected at boot
|
||||
connected=
|
||||
|
||||
# for button input, the following devices are available:
|
||||
# - "keyboard" (default) for keyboard input. Required parameters:
|
||||
# - "code": the code of the key to bind
|
||||
# - "sdl" for joystick input using SDL. Required parameters:
|
||||
# - "guid": SDL identification GUID of the joystick
|
||||
# - "port": the index of the joystick to bind
|
||||
# - "button"(optional): the index of the button to bind
|
||||
# - "hat"(optional): the index of the hat to bind as direction buttons
|
||||
# - "axis"(optional): the index of the axis to bind
|
||||
# - "direction"(only used for hat): the direction name of the hat to bind. Can be "up", "down", "left" or "right"
|
||||
# - "threshold"(only used for axis): a float value in (-1.0, 1.0) which the button is
|
||||
# triggered if the axis value crosses
|
||||
# - "direction"(only used for axis): "+" means the button is triggered when the axis value
|
||||
# is greater than the threshold; "-" means the button is triggered when the axis value
|
||||
# is smaller than the threshold
|
||||
button_a=
|
||||
button_b=
|
||||
button_x=
|
||||
button_y=
|
||||
button_lstick=
|
||||
button_rstick=
|
||||
button_l=
|
||||
button_r=
|
||||
button_zl=
|
||||
button_zr=
|
||||
button_plus=
|
||||
button_minus=
|
||||
button_dleft=
|
||||
button_dup=
|
||||
button_dright=
|
||||
button_ddown=
|
||||
button_lstick_left=
|
||||
button_lstick_up=
|
||||
button_lstick_right=
|
||||
button_lstick_down=
|
||||
button_sl=
|
||||
button_sr=
|
||||
button_home=
|
||||
button_screenshot=
|
||||
|
||||
# for analog input, the following devices are available:
|
||||
# - "analog_from_button" (default) for emulating analog input from direction buttons. Required parameters:
|
||||
# - "up", "down", "left", "right": sub-devices for each direction.
|
||||
# Should be in the format as a button input devices using escape characters, for example, "engine$0keyboard$1code$00"
|
||||
# - "modifier": sub-devices as a modifier.
|
||||
# - "modifier_scale": a float number representing the applied modifier scale to the analog input.
|
||||
# Must be in range of 0.0-1.0. Defaults to 0.5
|
||||
# - "sdl" for joystick input using SDL. Required parameters:
|
||||
# - "guid": SDL identification GUID of the joystick
|
||||
# - "port": the index of the joystick to bind
|
||||
# - "axis_x": the index of the axis to bind as x-axis (default to 0)
|
||||
# - "axis_y": the index of the axis to bind as y-axis (default to 1)
|
||||
lstick=
|
||||
rstick=
|
||||
|
||||
# for motion input, the following devices are available:
|
||||
# - "keyboard" (default) for emulating random motion input from buttons. Required parameters:
|
||||
# - "code": the code of the key to bind
|
||||
# - "sdl" for motion input using SDL. Required parameters:
|
||||
# - "guid": SDL identification GUID of the joystick
|
||||
# - "port": the index of the joystick to bind
|
||||
# - "motion": the index of the motion sensor to bind
|
||||
# - "cemuhookudp" for motion input using Cemu Hook protocol. Required parameters:
|
||||
# - "guid": the IP address of the cemu hook server encoded to a hex string. for example 192.168.0.1 = "c0a80001"
|
||||
# - "port": the port of the cemu hook server
|
||||
# - "pad": the index of the joystick
|
||||
# - "motion": the index of the motion sensor of the joystick to bind
|
||||
motionleft=
|
||||
motionright=
|
||||
|
||||
[ControlsGeneral]
|
||||
# To use the debug_pad, prepend `debug_pad_` before each button setting above.
|
||||
# i.e. debug_pad_button_a=
|
||||
|
||||
# Enable debug pad inputs to the guest
|
||||
# 0 (default): Disabled, 1: Enabled
|
||||
debug_pad_enabled =
|
||||
|
||||
# Whether to enable or disable vibration
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
vibration_enabled=
|
||||
|
||||
# Whether to enable or disable accurate vibrations
|
||||
# 0 (default): Disabled, 1: Enabled
|
||||
enable_accurate_vibrations=
|
||||
|
||||
# Enables controller motion inputs
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
motion_enabled =
|
||||
|
||||
# Defines the udp device's touch screen coordinate system for cemuhookudp devices
|
||||
# - "min_x", "min_y", "max_x", "max_y"
|
||||
touch_device=
|
||||
|
||||
# for mapping buttons to touch inputs.
|
||||
#touch_from_button_map=1
|
||||
#touch_from_button_maps_0_name=default
|
||||
#touch_from_button_maps_0_count=2
|
||||
#touch_from_button_maps_0_bind_0=foo
|
||||
#touch_from_button_maps_0_bind_1=bar
|
||||
# etc.
|
||||
|
||||
# List of Cemuhook UDP servers, delimited by ','.
|
||||
# Default: 127.0.0.1:26760
|
||||
# Example: 127.0.0.1:26760,123.4.5.67:26761
|
||||
udp_input_servers =
|
||||
|
||||
# Enable controlling an axis via a mouse input.
|
||||
# 0 (default): Off, 1: On
|
||||
mouse_panning =
|
||||
|
||||
# Set mouse sensitivity.
|
||||
# Default: 1.0
|
||||
mouse_panning_sensitivity =
|
||||
|
||||
# Emulate an analog control stick from keyboard inputs.
|
||||
# 0 (default): Disabled, 1: Enabled
|
||||
emulate_analog_keyboard =
|
||||
|
||||
# Enable mouse inputs to the guest
|
||||
# 0 (default): Disabled, 1: Enabled
|
||||
mouse_enabled =
|
||||
|
||||
# Enable keyboard inputs to the guest
|
||||
# 0 (default): Disabled, 1: Enabled
|
||||
keyboard_enabled =
|
||||
|
||||
[Core]
|
||||
# Whether to use multi-core for CPU emulation
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
use_multi_core =
|
||||
|
||||
# Enable unsafe extended guest system memory layout (8GB DRAM)
|
||||
# 0 (default): Disabled, 1: Enabled
|
||||
use_unsafe_extended_memory_layout =
|
||||
|
||||
[Cpu]
|
||||
# Adjusts various optimizations.
|
||||
# Auto-select mode enables choice unsafe optimizations.
|
||||
# Accurate enables only safe optimizations.
|
||||
# Unsafe allows any unsafe optimizations.
|
||||
# 0 (default): Auto-select, 1: Accurate, 2: Enable unsafe optimizations
|
||||
cpu_accuracy =
|
||||
|
||||
# Allow disabling safe optimizations.
|
||||
# 0 (default): Disabled, 1: Enabled
|
||||
cpu_debug_mode =
|
||||
|
||||
# Enable inline page tables optimization (faster guest memory access)
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
cpuopt_page_tables =
|
||||
|
||||
# Enable block linking CPU optimization (reduce block dispatcher use during predictable jumps)
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
cpuopt_block_linking =
|
||||
|
||||
# Enable return stack buffer CPU optimization (reduce block dispatcher use during predictable returns)
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
cpuopt_return_stack_buffer =
|
||||
|
||||
# Enable fast dispatcher CPU optimization (use a two-tiered dispatcher architecture)
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
cpuopt_fast_dispatcher =
|
||||
|
||||
# Enable context elimination CPU Optimization (reduce host memory use for guest context)
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
cpuopt_context_elimination =
|
||||
|
||||
# Enable constant propagation CPU optimization (basic IR optimization)
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
cpuopt_const_prop =
|
||||
|
||||
# Enable miscellaneous CPU optimizations (basic IR optimization)
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
cpuopt_misc_ir =
|
||||
|
||||
# Enable reduction of memory misalignment checks (reduce memory fallbacks for misaligned access)
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
cpuopt_reduce_misalign_checks =
|
||||
|
||||
# Enable Host MMU Emulation (faster guest memory access)
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
cpuopt_fastmem =
|
||||
|
||||
# Enable Host MMU Emulation for exclusive memory instructions (faster guest memory access)
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
cpuopt_fastmem_exclusives =
|
||||
|
||||
# Enable fallback on failure of fastmem of exclusive memory instructions (faster guest memory access)
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
cpuopt_recompile_exclusives =
|
||||
|
||||
# Enable optimization to ignore invalid memory accesses (faster guest memory access)
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
cpuopt_ignore_memory_aborts =
|
||||
|
||||
# Enable unfuse FMA (improve performance on CPUs without FMA)
|
||||
# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select.
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
cpuopt_unsafe_unfuse_fma =
|
||||
|
||||
# Enable faster FRSQRTE and FRECPE
|
||||
# Only enabled if cpu_accuracy is set to Unsafe.
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
cpuopt_unsafe_reduce_fp_error =
|
||||
|
||||
# Enable faster ASIMD instructions (32 bits only)
|
||||
# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select.
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
cpuopt_unsafe_ignore_standard_fpcr =
|
||||
|
||||
# Enable inaccurate NaN handling
|
||||
# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select.
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
cpuopt_unsafe_inaccurate_nan =
|
||||
|
||||
# Disable address space checks (64 bits only)
|
||||
# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select.
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
cpuopt_unsafe_fastmem_check =
|
||||
|
||||
# Enable faster exclusive instructions
|
||||
# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select.
|
||||
# 0: Disabled, 1 (default): Enabled
|
||||
cpuopt_unsafe_ignore_global_monitor =
|
||||
|
||||
[Renderer]
|
||||
# Which backend API to use.
|
||||
# 0: OpenGL (unsupported), 1 (default): Vulkan, 2: Null
|
||||
backend =
|
||||
|
||||
# Whether to enable asynchronous presentation (Vulkan only)
|
||||
# 0: Off, 1 (default): On
|
||||
async_presentation =
|
||||
|
||||
# Forces the GPU to run at the maximum possible clocks (thermal constraints will still be applied).
|
||||
# 0 (default): Disabled, 1: Enabled
|
||||
force_max_clock =
|
||||
|
||||
# Enable graphics API debugging mode.
|
||||
# 0 (default): Disabled, 1: Enabled
|
||||
debug =
|
||||
|
||||
# Enable shader feedback.
|
||||
# 0 (default): Disabled, 1: Enabled
|
||||
renderer_shader_feedback =
|
||||
|
||||
# Enable Nsight Aftermath crash dumps
|
||||
# 0 (default): Disabled, 1: Enabled
|
||||
nsight_aftermath =
|
||||
|
||||
# Disable shader loop safety checks, executing the shader without loop logic changes
|
||||
# 0 (default): Disabled, 1: Enabled
|
||||
disable_shader_loop_safety_checks =
|
||||
|
||||
# Which Vulkan physical device to use (defaults to 0)
|
||||
vulkan_device =
|
||||
|
||||
# 0: 0.5x (360p/540p) [EXPERIMENTAL]
|
||||
# 1: 0.75x (540p/810p) [EXPERIMENTAL]
|
||||
# 2 (default): 1x (720p/1080p)
|
||||
# 3: 2x (1440p/2160p)
|
||||
# 4: 3x (2160p/3240p)
|
||||
# 5: 4x (2880p/4320p)
|
||||
# 6: 5x (3600p/5400p)
|
||||
# 7: 6x (4320p/6480p)
|
||||
resolution_setup =
|
||||
|
||||
# Pixel filter to use when up- or down-sampling rendered frames.
|
||||
# 0: Nearest Neighbor
|
||||
# 1 (default): Bilinear
|
||||
# 2: Bicubic
|
||||
# 3: Gaussian
|
||||
# 4: ScaleForce
|
||||
# 5: AMD FidelityFX™️ Super Resolution [Vulkan Only]
|
||||
scaling_filter =
|
||||
|
||||
# Anti-Aliasing (AA)
|
||||
# 0 (default): None, 1: FXAA
|
||||
anti_aliasing =
|
||||
|
||||
# Whether to use fullscreen or borderless window mode
|
||||
# 0 (Windows default): Borderless window, 1 (All other default): Exclusive fullscreen
|
||||
fullscreen_mode =
|
||||
|
||||
# Aspect ratio
|
||||
# 0: Default (16:9), 1: Force 4:3, 2: Force 21:9, 3: Force 16:10, 4: Stretch to Window
|
||||
aspect_ratio =
|
||||
|
||||
# Anisotropic filtering
|
||||
# 0: Default, 1: 2x, 2: 4x, 3: 8x, 4: 16x
|
||||
max_anisotropy =
|
||||
|
||||
# Whether to enable VSync or not.
|
||||
# OpenGL: Values other than 0 enable VSync
|
||||
# Vulkan: FIFO is selected if the requested mode is not supported by the driver.
|
||||
# FIFO (VSync) does not drop frames or exhibit tearing but is limited by the screen refresh rate.
|
||||
# FIFO Relaxed is similar to FIFO but allows tearing as it recovers from a slow down.
|
||||
# Mailbox can have lower latency than FIFO and does not tear but may drop frames.
|
||||
# Immediate (no synchronization) just presents whatever is available and can exhibit tearing.
|
||||
# 0: Immediate (Off), 1 (Default): Mailbox (On), 2: FIFO, 3: FIFO Relaxed
|
||||
use_vsync =
|
||||
|
||||
# Selects the OpenGL shader backend. NV_gpu_program5 is required for GLASM. If NV_gpu_program5 is
|
||||
# not available and GLASM is selected, GLSL will be used.
|
||||
# 0: GLSL, 1 (default): GLASM, 2: SPIR-V
|
||||
shader_backend =
|
||||
|
||||
# Whether to allow asynchronous shader building.
|
||||
# 0 (default): Off, 1: On
|
||||
use_asynchronous_shaders =
|
||||
|
||||
# Uses reactive flushing instead of predictive flushing. Allowing a more accurate syncing of memory.
|
||||
# 0 (default): Off, 1: On
|
||||
use_reactive_flushing =
|
||||
|
||||
# NVDEC emulation.
|
||||
# 0: Disabled, 1: CPU Decoding, 2 (default): GPU Decoding
|
||||
nvdec_emulation =
|
||||
|
||||
# Accelerate ASTC texture decoding.
|
||||
# 0 (default): Off, 1: On
|
||||
accelerate_astc =
|
||||
|
||||
# Turns on the speed limiter, which will limit the emulation speed to the desired speed limit value
|
||||
# 0: Off, 1: On (default)
|
||||
use_speed_limit =
|
||||
|
||||
# Limits the speed of the game to run no faster than this value as a percentage of target speed
|
||||
# 1 - 9999: Speed limit as a percentage of target game speed. 100 (default)
|
||||
speed_limit =
|
||||
|
||||
# Whether to use disk based shader cache
|
||||
# 0: Off, 1 (default): On
|
||||
use_disk_shader_cache =
|
||||
|
||||
# Which gpu accuracy level to use
|
||||
# 0 (default): Normal, 1: High, 2: Extreme (Very slow)
|
||||
gpu_accuracy =
|
||||
|
||||
# Whether to use asynchronous GPU emulation
|
||||
# 0 : Off (slow), 1 (default): On (fast)
|
||||
use_asynchronous_gpu_emulation =
|
||||
|
||||
# Inform the guest that GPU operations completed more quickly than they did.
|
||||
# 0: Off, 1 (default): On
|
||||
use_fast_gpu_time =
|
||||
|
||||
# Force unmodified buffers to be flushed, which can cost performance.
|
||||
# 0: Off (default), 1: On
|
||||
use_pessimistic_flushes =
|
||||
|
||||
# Whether to use garbage collection or not for GPU caches.
|
||||
# 0 (default): Off, 1: On
|
||||
use_caches_gc =
|
||||
|
||||
# The clear color for the renderer. What shows up on the sides of the bottom screen.
|
||||
# Must be in range of 0-255. Defaults to 0 for all.
|
||||
bg_red =
|
||||
bg_blue =
|
||||
bg_green =
|
||||
|
||||
[Audio]
|
||||
# Which audio output engine to use.
|
||||
# auto (default): Auto-select
|
||||
# cubeb: Cubeb audio engine (if available)
|
||||
# sdl2: SDL2 audio engine (if available)
|
||||
# null: No audio output
|
||||
output_engine =
|
||||
|
||||
# Which audio device to use.
|
||||
# auto (default): Auto-select
|
||||
output_device =
|
||||
|
||||
# Output volume.
|
||||
# 100 (default): 100%, 0; mute
|
||||
volume =
|
||||
|
||||
[Data Storage]
|
||||
# Whether to create a virtual SD card.
|
||||
# 1: Yes, 0 (default): No
|
||||
use_virtual_sd =
|
||||
|
||||
# Whether or not to enable gamecard emulation
|
||||
# 1: Yes, 0 (default): No
|
||||
gamecard_inserted =
|
||||
|
||||
# Whether or not the gamecard should be emulated as the current game
|
||||
# If 'gamecard_inserted' is 0 this setting is irrelevant
|
||||
# 1: Yes, 0 (default): No
|
||||
gamecard_current_game =
|
||||
|
||||
# Path to an XCI file to use as the gamecard
|
||||
# If 'gamecard_inserted' is 0 this setting is irrelevant
|
||||
# If 'gamecard_current_game' is 1 this setting is irrelevant
|
||||
gamecard_path =
|
||||
|
||||
[System]
|
||||
# Whether the system is docked
|
||||
# 1 (default): Yes, 0: No
|
||||
use_docked_mode =
|
||||
|
||||
# Sets the seed for the RNG generator built into the switch
|
||||
# rng_seed will be ignored and randomly generated if rng_seed_enabled is false
|
||||
rng_seed_enabled =
|
||||
rng_seed =
|
||||
|
||||
# Sets the current time (in seconds since 12:00 AM Jan 1, 1970) that will be used by the time service
|
||||
# This will auto-increment, with the time set being the time the game is started
|
||||
# This override will only occur if custom_rtc_enabled is true, otherwise the current time is used
|
||||
custom_rtc_enabled =
|
||||
custom_rtc =
|
||||
|
||||
# Sets the systems language index
|
||||
# 0: Japanese, 1: English (default), 2: French, 3: German, 4: Italian, 5: Spanish, 6: Chinese,
|
||||
# 7: Korean, 8: Dutch, 9: Portuguese, 10: Russian, 11: Taiwanese, 12: British English, 13: Canadian French,
|
||||
# 14: Latin American Spanish, 15: Simplified Chinese, 16: Traditional Chinese, 17: Brazilian Portuguese
|
||||
language_index =
|
||||
|
||||
# The system region that sudachi will use during emulation
|
||||
# -1: Auto-select (default), 0: Japan, 1: USA, 2: Europe, 3: Australia, 4: China, 5: Korea, 6: Taiwan
|
||||
region_index =
|
||||
|
||||
# The system time zone that sudachi will use during emulation
|
||||
# 0: Auto-select (default), 1: Default (system archive value), Others: Index for specified time zone
|
||||
time_zone_index =
|
||||
|
||||
# Sets the sound output mode.
|
||||
# 0: Mono, 1 (default): Stereo, 2: Surround
|
||||
sound_index =
|
||||
|
||||
[Miscellaneous]
|
||||
# A filter which removes logs below a certain logging level.
|
||||
# Examples: *:Debug Kernel.SVC:Trace Service.*:Critical
|
||||
log_filter = *:Trace
|
||||
|
||||
# Use developer keys
|
||||
# 0 (default): Disabled, 1: Enabled
|
||||
use_dev_keys =
|
||||
|
||||
[Debugging]
|
||||
# Record frame time data, can be found in the log directory. Boolean value
|
||||
record_frame_times =
|
||||
# Determines whether or not sudachi will dump the ExeFS of all games it attempts to load while loading them
|
||||
dump_exefs=false
|
||||
# Determines whether or not sudachi will dump all NSOs it attempts to load while loading them
|
||||
dump_nso=false
|
||||
# Determines whether or not sudachi will save the filesystem access log.
|
||||
enable_fs_access_log=false
|
||||
# Enables verbose reporting services
|
||||
reporting_services =
|
||||
# Determines whether or not sudachi will report to the game that the emulated console is in Kiosk Mode
|
||||
# false: Retail/Normal Mode (default), true: Kiosk Mode
|
||||
quest_flag =
|
||||
# Determines whether debug asserts should be enabled, which will throw an exception on asserts.
|
||||
# false: Disabled (default), true: Enabled
|
||||
use_debug_asserts =
|
||||
# Determines whether unimplemented HLE service calls should be automatically stubbed.
|
||||
# false: Disabled (default), true: Enabled
|
||||
use_auto_stub =
|
||||
# Enables/Disables the macro JIT compiler
|
||||
disable_macro_jit=false
|
||||
# Determines whether to enable the GDB stub and wait for the debugger to attach before running.
|
||||
# false: Disabled (default), true: Enabled
|
||||
use_gdbstub=false
|
||||
# The port to use for the GDB server, if it is enabled.
|
||||
gdbstub_port=6543
|
||||
|
||||
[WebService]
|
||||
# Whether or not to enable telemetry
|
||||
# 0: No, 1 (default): Yes
|
||||
enable_telemetry =
|
||||
# URL for Web API
|
||||
web_api_url = https://api.sudachi-emu.org
|
||||
# Username and token for sudachi Web Service
|
||||
# See https://profile.sudachi-emu.org/ for more info
|
||||
sudachi_username =
|
||||
sudachi_token =
|
||||
|
||||
[Network]
|
||||
# Name of the network interface device to use with sudachi LAN play.
|
||||
# e.g. On *nix: 'enp7s0', 'wlp6s0u1u3u3', 'lo'
|
||||
# e.g. On Windows: 'Ethernet', 'Wi-Fi'
|
||||
network_interface =
|
||||
|
||||
[AddOns]
|
||||
# Used to disable add-ons
|
||||
# List of title IDs of games that will have add-ons disabled (separated by '|'):
|
||||
title_ids =
|
||||
# For each title ID, have a key/value pair called `disabled_<title_id>` equal to the names of the add-ons to disable (sep. by '|')
|
||||
# e.x. disabled_0100000000010000 = Update|DLC <- disables Updates and DLC on Super Mario Odyssey
|
||||
)";
|
||||
} // namespace DefaultINI
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue