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