diff --git a/src/android/sudachi/proguard-rules.pro b/src/android/sudachi/proguard-rules.pro new file mode 100644 index 0000000..3c6e455 --- /dev/null +++ b/src/android/sudachi/proguard-rules.pro @@ -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 diff --git a/src/android/sudachi/src/ea/res/drawable/ic_sudachi.xml b/src/android/sudachi/src/ea/res/drawable/ic_sudachi.xml new file mode 100644 index 0000000..deb8ba5 --- /dev/null +++ b/src/android/sudachi/src/ea/res/drawable/ic_sudachi.xml @@ -0,0 +1,22 @@ + + + + diff --git a/src/android/sudachi/src/ea/res/drawable/ic_sudachi_full.xml b/src/android/sudachi/src/ea/res/drawable/ic_sudachi_full.xml new file mode 100644 index 0000000..4ef4728 --- /dev/null +++ b/src/android/sudachi/src/ea/res/drawable/ic_sudachi_full.xml @@ -0,0 +1,12 @@ + + + + diff --git a/src/android/sudachi/src/ea/res/drawable/ic_sudachi_title.xml b/src/android/sudachi/src/ea/res/drawable/ic_sudachi_title.xml new file mode 100644 index 0000000..29d0cfc --- /dev/null +++ b/src/android/sudachi/src/ea/res/drawable/ic_sudachi_title.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/src/android/sudachi/src/main/AndroidManifest.xml b/src/android/sudachi/src/main/AndroidManifest.xml new file mode 100644 index 0000000..73638c0 --- /dev/null +++ b/src/android/sudachi/src/main/AndroidManifest.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/SudachiApplication.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/SudachiApplication.kt new file mode 100644 index 0000000..858b6ef --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/SudachiApplication.kt @@ -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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/activities/EmulationActivity.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/activities/EmulationActivity.kt new file mode 100644 index 0000000..59a14ea --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/activities/EmulationActivity.kt @@ -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 = 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()) + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractDiffAdapter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractDiffAdapter.kt new file mode 100644 index 0000000..b145080 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractDiffAdapter.kt @@ -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>( + exact: Boolean = true +) : ListAdapter(AsyncDifferConfig.Builder(DiffCallback(exact)).build()) { + override fun onBindViewHolder(holder: Holder, position: Int) = + holder.bind(currentList[position]) + + private class DiffCallback(val exact: Boolean) : DiffUtil.ItemCallback() { + 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 + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AddonAdapter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AddonAdapter.kt new file mode 100644 index 0000000..92824b2 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AddonAdapter.kt @@ -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() { + 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(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) + } + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AppletAdapter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AppletAdapter.kt new file mode 100644 index 0000000..10db1f7 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AppletAdapter.kt @@ -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) : + AbstractListAdapter(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(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) + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/FolderAdapter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/FolderAdapter.kt new file mode 100644 index 0000000..703280d --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/FolderAdapter.kt @@ -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() { + 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(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) + } + } + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/GameAdapter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/GameAdapter.kt new file mode 100644 index 0000000..b396326 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/GameAdapter.kt @@ -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(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(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 + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/LicenseAdapter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/LicenseAdapter.kt new file mode 100644 index 0000000..dd06d2e --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/LicenseAdapter.kt @@ -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) : + AbstractListAdapter(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(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) + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/SetupAdapter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/SetupAdapter.kt new file mode 100644 index 0000000..313ae21 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/SetupAdapter.kt @@ -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) : + AbstractListAdapter(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(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) + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/disk_shader_cache/DiskShaderCacheProgress.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/disk_shader_cache/DiskShaderCacheProgress.kt new file mode 100644 index 0000000..580014d --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/disk_shader_cache/DiskShaderCacheProgress.kt @@ -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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/NativeInput.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/NativeInput.kt new file mode 100644 index 0000000..52288d9 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/NativeInput.kt @@ -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 + + /** + * 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 + + /** + * 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 = + 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().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) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/SudachiVibrator.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/SudachiVibrator.kt new file mode 100644 index 0000000..4f50bb0 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/SudachiVibrator.kt @@ -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) + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/AnalogDirection.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/AnalogDirection.kt new file mode 100644 index 0000000..45d92d1 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/AnalogDirection.kt @@ -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") +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/ButtonName.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/ButtonName.kt new file mode 100644 index 0000000..2d8ff80 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/ButtonName.kt @@ -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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeAnalog.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeAnalog.kt new file mode 100644 index 0000000..88ef792 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeAnalog.kt @@ -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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/NpadStyleIndex.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/NpadStyleIndex.kt new file mode 100644 index 0000000..4d938fa --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/NpadStyleIndex.kt @@ -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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/PlayerInput.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/PlayerInput.kt new file mode 100644 index 0000000..bd1328c --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/PlayerInput.kt @@ -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, + var analogs: Array, + var motions: Array, + + 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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractFloatSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractFloatSetting.kt new file mode 100644 index 0000000..7dd7c0f --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractFloatSetting.kt @@ -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) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractIntSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractIntSetting.kt new file mode 100644 index 0000000..bc01b75 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractIntSetting.kt @@ -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) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractStringSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractStringSetting.kt new file mode 100644 index 0000000..88213f7 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractStringSetting.kt @@ -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) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/ByteSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/ByteSetting.kt new file mode 100644 index 0000000..49b9459 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/ByteSetting.kt @@ -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) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/FloatSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/FloatSetting.kt new file mode 100644 index 0000000..e7e1c29 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/FloatSetting.kt @@ -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) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/LongSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/LongSetting.kt new file mode 100644 index 0000000..677d0e9 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/LongSetting.kt @@ -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) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/Settings.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/Settings.kt new file mode 100644 index 0000000..4965f73 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/Settings.kt @@ -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 + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/StringSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/StringSetting.kt new file mode 100644 index 0000000..29aea99 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/StringSetting.kt @@ -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) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/AnalogInputSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/AnalogInputSetting.kt new file mode 100644 index 0000000..1e5b91d --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/AnalogInputSetting.kt @@ -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) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/DateTimeSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/DateTimeSetting.kt new file mode 100644 index 0000000..5a53371 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/DateTimeSetting.kt @@ -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) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/InputSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/InputSetting.kt new file mode 100644 index 0000000..094a00d --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/InputSetting.kt @@ -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) + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/ModifierInputSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/ModifierInputSetting.kt new file mode 100644 index 0000000..3b77c6a --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/ModifierInputSetting.kt @@ -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) + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SingleChoiceSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SingleChoiceSetting.kt new file mode 100644 index 0000000..946f683 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SingleChoiceSetting.kt @@ -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) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/StringInputSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/StringInputSetting.kt new file mode 100644 index 0000000..f488290 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/StringInputSetting.kt @@ -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) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SwitchSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SwitchSetting.kt new file mode 100644 index 0000000..897935e --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SwitchSetting.kt @@ -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) + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputDialogFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputDialogFragment.kt new file mode 100644 index 0000000..91fc566 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputDialogFragment.kt @@ -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 + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsActivity.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsActivity.kt new file mode 100644 index 0000000..84f6bd0 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsActivity.kt @@ -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() + + 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 + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsFragmentPresenter.kt new file mode 100644 index 0000000..725fe74 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -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() + + private val context get() = SudachiApplication.appContext + + // Extension for altering settings list based on each setting's properties + fun ArrayList.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.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() + 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) { + 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) { + 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) { + 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) { + sl.apply { + add(IntSetting.AUDIO_OUTPUT_ENGINE.key) + add(ByteSetting.AUDIO_VOLUME.key) + } + } + + private fun addInputSettings(sl: ArrayList) { + 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, + 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, 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 { + 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().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 = + 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) { + 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) { + 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) + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsViewModel.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsViewModel.kt new file mode 100644 index 0000000..9f03729 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsViewModel.kt @@ -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 get() = _shouldRecreate + private val _shouldRecreate = MutableStateFlow(false) + + val shouldNavigateBack: StateFlow get() = _shouldNavigateBack + private val _shouldNavigateBack = MutableStateFlow(false) + + val shouldShowResetSettingsDialog: StateFlow get() = _shouldShowResetSettingsDialog + private val _shouldShowResetSettingsDialog = MutableStateFlow(false) + + val shouldReloadSettingsList: StateFlow get() = _shouldReloadSettingsList + private val _shouldReloadSettingsList = MutableStateFlow(false) + + val sliderProgress: StateFlow get() = _sliderProgress + private val _sliderProgress = MutableStateFlow(-1) + + val sliderTextValue: StateFlow get() = _sliderTextValue + private val _sliderTextValue = MutableStateFlow("") + + val adapterItemChanged: StateFlow 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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt new file mode 100644 index 0000000..6305e70 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt @@ -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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/HeaderViewHolder.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/HeaderViewHolder.kt new file mode 100644 index 0000000..b8a4629 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/HeaderViewHolder.kt @@ -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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt new file mode 100644 index 0000000..693b8d5 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt @@ -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 +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt new file mode 100644 index 0000000..4e60b9e --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt @@ -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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt new file mode 100644 index 0000000..f2002ce --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt @@ -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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/utils/SettingsFile.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/utils/SettingsFile.kt new file mode 100644 index 0000000..a9b9d84 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/utils/SettingsFile.kt @@ -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) + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/AddGameFolderDialogFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/AddGameFolderDialogFragment.kt new file mode 100644 index 0000000..e39bdd5 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/AddGameFolderDialogFragment.kt @@ -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 + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/AddonsFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/AddonsFragment.kt new file mode 100644 index 0000000..07d5882 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/AddonsFragment.kt @@ -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() + + 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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/ContentTypeSelectionDialogFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/ContentTypeSelectionDialogFragment.kt new file mode 100644 index 0000000..384daf0 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/ContentTypeSelectionDialogFragment.kt @@ -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" + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/CoreErrorDialogFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/CoreErrorDialogFragment.kt new file mode 100644 index 0000000..c750e9e --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/CoreErrorDialogFragment.kt @@ -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 + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/DriversLoadingDialogFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/DriversLoadingDialogFragment.kt new file mode 100644 index 0000000..bb34bd3 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/DriversLoadingDialogFragment.kt @@ -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" + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/EarlyAccessFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/EarlyAccessFragment.kt new file mode 100644 index 0000000..0712521 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/EarlyAccessFragment.kt @@ -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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/EmulationFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/EmulationFragment.kt new file mode 100644 index 0000000..d5ca95d --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/EmulationFragment.kt @@ -0,0 +1,1048 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Context +import android.content.DialogInterface +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.PowerManager +import android.os.SystemClock +import android.util.Rational +import android.view.* +import android.widget.FrameLayout +import android.widget.TextView +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.widget.PopupMenu +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.drawerlayout.widget.DrawerLayout +import androidx.drawerlayout.widget.DrawerLayout.DrawerListener +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowInfoTracker +import androidx.window.layout.WindowLayoutInfo +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.slider.Slider +import org.sudachi.sudachi_emu.HomeNavigationDirections +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.activities.EmulationActivity +import org.sudachi.sudachi_emu.databinding.DialogOverlayAdjustBinding +import org.sudachi.sudachi_emu.databinding.FragmentEmulationBinding +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.features.settings.model.Settings.EmulationOrientation +import org.sudachi.sudachi_emu.features.settings.model.Settings.EmulationVerticalAlignment +import org.sudachi.sudachi_emu.features.settings.utils.SettingsFile +import org.sudachi.sudachi_emu.model.DriverViewModel +import org.sudachi.sudachi_emu.model.Game +import org.sudachi.sudachi_emu.model.EmulationViewModel +import org.sudachi.sudachi_emu.overlay.model.OverlayControl +import org.sudachi.sudachi_emu.overlay.model.OverlayLayout +import org.sudachi.sudachi_emu.utils.* +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import java.lang.NullPointerException + +class EmulationFragment : Fragment(), SurfaceHolder.Callback { + private lateinit var emulationState: EmulationState + private var emulationActivity: EmulationActivity? = null + private var perfStatsUpdater: (() -> Unit)? = null + private var thermalStatsUpdater: (() -> Unit)? = null + + private var _binding: FragmentEmulationBinding? = null + private val binding get() = _binding!! + + private val args by navArgs() + + private lateinit var game: Game + + private val emulationViewModel: EmulationViewModel by activityViewModels() + private val driverViewModel: DriverViewModel by activityViewModels() + + private var isInFoldableLayout = false + + private lateinit var powerManager: PowerManager + + override fun onAttach(context: Context) { + super.onAttach(context) + if (context is EmulationActivity) { + emulationActivity = context + NativeLibrary.setEmulationActivity(context) + } else { + throw IllegalStateException("EmulationFragment must have EmulationActivity parent") + } + } + + /** + * Initialize anything that doesn't depend on the layout / views in here. + */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + updateOrientation() + + powerManager = requireContext().getSystemService(Context.POWER_SERVICE) as PowerManager + + val intentUri: Uri? = requireActivity().intent.data + var intentGame: Game? = null + if (intentUri != null) { + intentGame = if (Game.extensions.contains(FileUtil.getExtension(intentUri))) { + GameHelper.getGame(requireActivity().intent.data!!, false) + } else { + null + } + } + + try { + game = if (args.game != null) { + args.game!! + } else { + intentGame!! + } + } catch (e: NullPointerException) { + Toast.makeText( + requireContext(), + R.string.no_game_present, + Toast.LENGTH_SHORT + ).show() + requireActivity().finish() + return + } + + // Always load custom settings when launching a game from an intent + if (args.custom || intentGame != null) { + SettingsFile.loadCustomConfig(game) + NativeConfig.unloadPerGameConfig() + } else { + NativeConfig.reloadGlobalConfig() + } + + // Install the selected driver asynchronously as the game starts + driverViewModel.onLaunchGame() + + // So this fragment doesn't restart on configuration changes; i.e. rotation. + retainInstance = true + emulationState = EmulationState(game.path) { + return@EmulationState driverViewModel.isInteractionAllowed.value + } + } + + /** + * Initialize the UI and start emulation in here. + */ + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentEmulationBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (requireActivity().isFinishing) { + return + } + + binding.surfaceEmulation.holder.addCallback(this) + binding.doneControlConfig.setOnClickListener { stopConfiguringControls() } + + binding.drawerLayout.addDrawerListener(object : DrawerListener { + override fun onDrawerSlide(drawerView: View, slideOffset: Float) { + binding.surfaceInputOverlay.dispatchTouchEvent( + MotionEvent.obtain( + SystemClock.uptimeMillis(), + SystemClock.uptimeMillis() + 100, + MotionEvent.ACTION_UP, + 0f, + 0f, + 0 + ) + ) + } + + override fun onDrawerOpened(drawerView: View) { + binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED) + binding.inGameMenu.requestFocus() + emulationViewModel.setDrawerOpen(true) + } + + override fun onDrawerClosed(drawerView: View) { + binding.drawerLayout.setDrawerLockMode(IntSetting.LOCK_DRAWER.getInt()) + emulationViewModel.setDrawerOpen(false) + } + + override fun onDrawerStateChanged(newState: Int) { + // No op + } + }) + binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + binding.inGameMenu.getHeaderView(0).findViewById(R.id.text_game_title).text = + game.title + + binding.inGameMenu.menu.findItem(R.id.menu_lock_drawer).apply { + val lockMode = IntSetting.LOCK_DRAWER.getInt() + val titleId = if (lockMode == DrawerLayout.LOCK_MODE_LOCKED_CLOSED) { + R.string.unlock_drawer + } else { + R.string.lock_drawer + } + val iconId = if (lockMode == DrawerLayout.LOCK_MODE_UNLOCKED) { + R.drawable.ic_unlock + } else { + R.drawable.ic_lock + } + + title = getString(titleId) + icon = ResourcesCompat.getDrawable( + resources, + iconId, + requireContext().theme + ) + } + + binding.inGameMenu.setNavigationItemSelectedListener { + when (it.itemId) { + R.id.menu_pause_emulation -> { + if (emulationState.isPaused) { + emulationState.run(false) + it.title = resources.getString(R.string.emulation_pause) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_pause, + requireContext().theme + ) + } else { + emulationState.pause() + it.title = resources.getString(R.string.emulation_unpause) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_play, + requireContext().theme + ) + } + binding.inGameMenu.requestFocus() + true + } + + R.id.menu_settings -> { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_ROOT + ) + binding.inGameMenu.requestFocus() + binding.root.findNavController().navigate(action) + true + } + + R.id.menu_settings_per_game -> { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + args.game, + Settings.MenuTag.SECTION_ROOT + ) + binding.inGameMenu.requestFocus() + binding.root.findNavController().navigate(action) + true + } + + R.id.menu_controls -> { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_INPUT + ) + binding.root.findNavController().navigate(action) + true + } + + R.id.menu_overlay_controls -> { + showOverlayOptions() + true + } + + R.id.menu_lock_drawer -> { + when (IntSetting.LOCK_DRAWER.getInt()) { + DrawerLayout.LOCK_MODE_UNLOCKED -> { + IntSetting.LOCK_DRAWER.setInt(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + it.title = resources.getString(R.string.unlock_drawer) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_lock, + requireContext().theme + ) + } + + DrawerLayout.LOCK_MODE_LOCKED_CLOSED -> { + IntSetting.LOCK_DRAWER.setInt(DrawerLayout.LOCK_MODE_UNLOCKED) + it.title = resources.getString(R.string.lock_drawer) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_unlock, + requireContext().theme + ) + } + } + binding.inGameMenu.requestFocus() + NativeConfig.saveGlobalConfig() + true + } + + R.id.menu_exit -> { + emulationState.stop() + NativeConfig.reloadGlobalConfig() + emulationViewModel.setIsEmulationStopping(true) + binding.drawerLayout.close() + binding.inGameMenu.requestFocus() + true + } + + else -> true + } + } + + setInsets() + + requireActivity().onBackPressedDispatcher.addCallback( + requireActivity(), + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (!NativeLibrary.isRunning()) { + return + } + emulationViewModel.setDrawerOpen(!binding.drawerLayout.isOpen) + } + } + ) + + GameIconUtils.loadGameIcon(game, binding.loadingImage) + binding.loadingTitle.text = game.title + binding.loadingTitle.isSelected = true + binding.loadingText.isSelected = true + + WindowInfoTracker.getOrCreate(requireContext()) + .windowLayoutInfo(requireActivity()).collect(viewLifecycleOwner) { + updateFoldableLayout(requireActivity() as EmulationActivity, it) + } + emulationViewModel.shaderProgress.collect(viewLifecycleOwner) { + if (it > 0 && it != emulationViewModel.totalShaders.value) { + binding.loadingProgressIndicator.isIndeterminate = false + + if (it < binding.loadingProgressIndicator.max) { + binding.loadingProgressIndicator.progress = it + } + } + + if (it == emulationViewModel.totalShaders.value) { + binding.loadingText.setText(R.string.loading) + binding.loadingProgressIndicator.isIndeterminate = true + } + } + emulationViewModel.totalShaders.collect(viewLifecycleOwner) { + binding.loadingProgressIndicator.max = it + } + emulationViewModel.shaderMessage.collect(viewLifecycleOwner) { + if (it.isNotEmpty()) { + binding.loadingText.text = it + } + } + + emulationViewModel.emulationStarted.collect(viewLifecycleOwner) { + if (it) { + binding.drawerLayout.setDrawerLockMode(IntSetting.LOCK_DRAWER.getInt()) + ViewUtils.showView(binding.surfaceInputOverlay) + ViewUtils.hideView(binding.loadingIndicator) + + emulationState.updateSurface() + + // Setup overlays + updateShowFpsOverlay() + updateThermalOverlay() + } + } + emulationViewModel.isEmulationStopping.collect(viewLifecycleOwner) { + if (it) { + binding.loadingText.setText(R.string.shutting_down) + ViewUtils.showView(binding.loadingIndicator) + ViewUtils.hideView(binding.inputContainer) + ViewUtils.hideView(binding.showFpsText) + } + } + emulationViewModel.drawerOpen.collect(viewLifecycleOwner) { + if (it) { + binding.drawerLayout.open() + binding.inGameMenu.requestFocus() + } else { + binding.drawerLayout.close() + } + } + emulationViewModel.programChanged.collect(viewLifecycleOwner) { + if (it != 0) { + emulationViewModel.setEmulationStarted(false) + binding.drawerLayout.close() + binding.drawerLayout + .setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + ViewUtils.hideView(binding.surfaceInputOverlay) + ViewUtils.showView(binding.loadingIndicator) + } + } + emulationViewModel.emulationStopped.collect(viewLifecycleOwner) { + if (it && emulationViewModel.programChanged.value != -1) { + if (perfStatsUpdater != null) { + perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!) + } + emulationState.changeProgram(emulationViewModel.programChanged.value) + emulationViewModel.setProgramChanged(-1) + emulationViewModel.setEmulationStopped(false) + } + } + + driverViewModel.isInteractionAllowed.collect(viewLifecycleOwner) { + if (it) startEmulation() + } + } + + private fun startEmulation(programIndex: Int = 0) { + if (!NativeLibrary.isRunning() && !NativeLibrary.isPaused()) { + if (!DirectoryInitialization.areDirectoriesReady) { + DirectoryInitialization.start() + } + + updateScreenLayout() + + emulationState.run(emulationActivity!!.isActivityRecreated, programIndex) + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + if (_binding == null) { + return + } + + updateScreenLayout() + val showInputOverlay = BooleanSetting.SHOW_INPUT_OVERLAY.getBoolean() + if (emulationActivity?.isInPictureInPictureMode == true) { + if (binding.drawerLayout.isOpen) { + binding.drawerLayout.close() + } + if (showInputOverlay) { + binding.surfaceInputOverlay.setVisible(visible = false, gone = false) + } + } else { + binding.surfaceInputOverlay.setVisible( + showInputOverlay && emulationViewModel.emulationStarted.value + ) + if (!isInFoldableLayout) { + if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) { + binding.surfaceInputOverlay.layout = OverlayLayout.Portrait + } else { + binding.surfaceInputOverlay.layout = OverlayLayout.Landscape + } + } + } + } + + override fun onPause() { + if (emulationState.isRunning && emulationActivity?.isInPictureInPictureMode != true) { + emulationState.pause() + } + super.onPause() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onDetach() { + NativeLibrary.clearEmulationActivity() + super.onDetach() + } + + private fun resetInputOverlay() { + IntSetting.OVERLAY_SCALE.reset() + IntSetting.OVERLAY_OPACITY.reset() + binding.surfaceInputOverlay.post { + binding.surfaceInputOverlay.resetLayoutVisibilityAndPlacement() + } + } + + private fun updateShowFpsOverlay() { + val showOverlay = BooleanSetting.SHOW_PERFORMANCE_OVERLAY.getBoolean() + binding.showFpsText.setVisible(showOverlay) + if (showOverlay) { + val SYSTEM_FPS = 0 + val FPS = 1 + val FRAMETIME = 2 + val SPEED = 3 + perfStatsUpdater = { + if (emulationViewModel.emulationStarted.value && + !emulationViewModel.isEmulationStopping.value + ) { + val perfStats = NativeLibrary.getPerfStats() + val cpuBackend = NativeLibrary.getCpuBackend() + val gpuDriver = NativeLibrary.getGpuDriver() + if (_binding != null) { + binding.showFpsText.text = + String.format("FPS: %.1f\n%s/%s", perfStats[FPS], cpuBackend, gpuDriver) + } + perfStatsUpdateHandler.postDelayed(perfStatsUpdater!!, 800) + } + } + perfStatsUpdateHandler.post(perfStatsUpdater!!) + } else { + if (perfStatsUpdater != null) { + perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!) + } + } + } + + private fun updateThermalOverlay() { + val showOverlay = BooleanSetting.SHOW_THERMAL_OVERLAY.getBoolean() + binding.showThermalsText.setVisible(showOverlay) + if (showOverlay) { + thermalStatsUpdater = { + if (emulationViewModel.emulationStarted.value && + !emulationViewModel.isEmulationStopping.value + ) { + val thermalStatus = when (powerManager.currentThermalStatus) { + PowerManager.THERMAL_STATUS_LIGHT -> "😥" + PowerManager.THERMAL_STATUS_MODERATE -> "🥵" + PowerManager.THERMAL_STATUS_SEVERE -> "🔥" + PowerManager.THERMAL_STATUS_CRITICAL, + PowerManager.THERMAL_STATUS_EMERGENCY, + PowerManager.THERMAL_STATUS_SHUTDOWN -> "☢️" + + else -> "🙂" + } + if (_binding != null) { + binding.showThermalsText.text = thermalStatus + } + thermalStatsUpdateHandler.postDelayed(thermalStatsUpdater!!, 1000) + } + } + thermalStatsUpdateHandler.post(thermalStatsUpdater!!) + } else { + if (thermalStatsUpdater != null) { + thermalStatsUpdateHandler.removeCallbacks(thermalStatsUpdater!!) + } + } + } + + @SuppressLint("SourceLockedOrientationActivity") + private fun updateOrientation() { + emulationActivity?.let { + val orientationSetting = + EmulationOrientation.from(IntSetting.RENDERER_SCREEN_LAYOUT.getInt()) + it.requestedOrientation = when (orientationSetting) { + EmulationOrientation.Unspecified -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + EmulationOrientation.SensorLandscape -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + + EmulationOrientation.Landscape -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + EmulationOrientation.ReverseLandscape -> + ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + + EmulationOrientation.SensorPortrait -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + + EmulationOrientation.Portrait -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + EmulationOrientation.ReversePortrait -> + ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT + } + } + } + + private fun updateScreenLayout() { + val verticalAlignment = + EmulationVerticalAlignment.from(IntSetting.VERTICAL_ALIGNMENT.getInt()) + 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 + } + when (verticalAlignment) { + EmulationVerticalAlignment.Top -> { + binding.surfaceEmulation.setAspectRatio(aspectRatio) + val params = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + params.gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL + binding.surfaceEmulation.layoutParams = params + } + + EmulationVerticalAlignment.Center -> { + binding.surfaceEmulation.setAspectRatio(null) + binding.surfaceEmulation.updateLayoutParams { + width = ViewGroup.LayoutParams.MATCH_PARENT + height = ViewGroup.LayoutParams.MATCH_PARENT + } + } + + EmulationVerticalAlignment.Bottom -> { + binding.surfaceEmulation.setAspectRatio(aspectRatio) + val params = + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + params.gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL + binding.surfaceEmulation.layoutParams = params + } + } + emulationState.updateSurface() + emulationActivity?.buildPictureInPictureParams() + updateOrientation() + } + + private fun updateFoldableLayout( + emulationActivity: EmulationActivity, + newLayoutInfo: WindowLayoutInfo + ) { + val isFolding = + (newLayoutInfo.displayFeatures.find { it is FoldingFeature } as? FoldingFeature)?.let { + if (it.isSeparating) { + emulationActivity.requestedOrientation = + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + if (it.orientation == FoldingFeature.Orientation.HORIZONTAL) { + // Restrict emulation and overlays to the top of the screen + binding.emulationContainer.layoutParams.height = it.bounds.top + // Restrict input and menu drawer to the bottom of the screen + binding.inputContainer.layoutParams.height = it.bounds.bottom + binding.inGameMenu.layoutParams.height = it.bounds.bottom + + isInFoldableLayout = true + binding.surfaceInputOverlay.layout = OverlayLayout.Foldable + } + } + it.isSeparating + } ?: false + if (!isFolding) { + binding.emulationContainer.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + binding.inputContainer.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + binding.inGameMenu.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + isInFoldableLayout = false + updateOrientation() + onConfigurationChanged(resources.configuration) + } + binding.emulationContainer.requestLayout() + binding.inputContainer.requestLayout() + binding.inGameMenu.requestLayout() + } + + override fun surfaceCreated(holder: SurfaceHolder) { + // We purposely don't do anything here. + // All work is done in surfaceChanged, which we are guaranteed to get even for surface creation. + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + Log.debug("[EmulationFragment] Surface changed. Resolution: " + width + "x" + height) + emulationState.newSurface(holder.surface) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + emulationState.clearSurface() + } + + private fun showOverlayOptions() { + val anchor = binding.inGameMenu.findViewById(R.id.menu_overlay_controls) + val popup = PopupMenu(requireContext(), anchor) + + popup.menuInflater.inflate(R.menu.menu_overlay_options, popup.menu) + + popup.menu.apply { + findItem(R.id.menu_toggle_fps).isChecked = + BooleanSetting.SHOW_PERFORMANCE_OVERLAY.getBoolean() + findItem(R.id.thermal_indicator).isChecked = + BooleanSetting.SHOW_THERMAL_OVERLAY.getBoolean() + findItem(R.id.menu_rel_stick_center).isChecked = + BooleanSetting.JOYSTICK_REL_CENTER.getBoolean() + findItem(R.id.menu_dpad_slide).isChecked = BooleanSetting.DPAD_SLIDE.getBoolean() + findItem(R.id.menu_show_overlay).isChecked = + BooleanSetting.SHOW_INPUT_OVERLAY.getBoolean() + findItem(R.id.menu_haptics).isChecked = BooleanSetting.HAPTIC_FEEDBACK.getBoolean() + findItem(R.id.menu_touchscreen).isChecked = BooleanSetting.TOUCHSCREEN.getBoolean() + } + + popup.setOnDismissListener { NativeConfig.saveGlobalConfig() } + popup.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_toggle_fps -> { + it.isChecked = !it.isChecked + BooleanSetting.SHOW_PERFORMANCE_OVERLAY.setBoolean(it.isChecked) + updateShowFpsOverlay() + true + } + + R.id.thermal_indicator -> { + it.isChecked = !it.isChecked + BooleanSetting.SHOW_THERMAL_OVERLAY.setBoolean(it.isChecked) + updateThermalOverlay() + true + } + + R.id.menu_edit_overlay -> { + binding.drawerLayout.close() + binding.surfaceInputOverlay.requestFocus() + startConfiguringControls() + true + } + + R.id.menu_adjust_overlay -> { + adjustOverlay() + true + } + + R.id.menu_toggle_controls -> { + val overlayControlData = NativeConfig.getOverlayControlData() + val optionsArray = BooleanArray(overlayControlData.size) + overlayControlData.forEachIndexed { i, _ -> + optionsArray[i] = overlayControlData.firstOrNull { data -> + OverlayControl.entries[i].id == data.id + }?.enabled == true + } + + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.emulation_toggle_controls) + .setMultiChoiceItems( + R.array.gamepadButtons, + optionsArray + ) { _, indexSelected, isChecked -> + overlayControlData.firstOrNull { data -> + OverlayControl.entries[indexSelected].id == data.id + }?.enabled = isChecked + } + .setPositiveButton(android.R.string.ok) { _, _ -> + NativeConfig.setOverlayControlData(overlayControlData) + NativeConfig.saveGlobalConfig() + binding.surfaceInputOverlay.refreshControls() + } + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(R.string.emulation_toggle_all) { _, _ -> } + .show() + + // Override normal behaviour so the dialog doesn't close + dialog.getButton(AlertDialog.BUTTON_NEUTRAL) + .setOnClickListener { + val isChecked = !optionsArray[0] + overlayControlData.forEachIndexed { i, _ -> + optionsArray[i] = isChecked + dialog.listView.setItemChecked(i, isChecked) + overlayControlData[i].enabled = isChecked + } + } + true + } + + R.id.menu_show_overlay -> { + it.isChecked = !it.isChecked + BooleanSetting.SHOW_INPUT_OVERLAY.setBoolean(it.isChecked) + binding.surfaceInputOverlay.refreshControls() + true + } + + R.id.menu_rel_stick_center -> { + it.isChecked = !it.isChecked + BooleanSetting.JOYSTICK_REL_CENTER.setBoolean(it.isChecked) + true + } + + R.id.menu_dpad_slide -> { + it.isChecked = !it.isChecked + BooleanSetting.DPAD_SLIDE.setBoolean(it.isChecked) + true + } + + R.id.menu_haptics -> { + it.isChecked = !it.isChecked + BooleanSetting.HAPTIC_FEEDBACK.setBoolean(it.isChecked) + true + } + + R.id.menu_touchscreen -> { + it.isChecked = !it.isChecked + BooleanSetting.TOUCHSCREEN.setBoolean(it.isChecked) + true + } + + R.id.menu_reset_overlay -> { + binding.drawerLayout.close() + resetInputOverlay() + true + } + + else -> true + } + } + + popup.show() + } + + @SuppressLint("SourceLockedOrientationActivity") + private fun startConfiguringControls() { + // Lock the current orientation to prevent editing inconsistencies + if (IntSetting.RENDERER_SCREEN_LAYOUT.getInt() == EmulationOrientation.Unspecified.int) { + emulationActivity?.let { + it.requestedOrientation = + if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT + } else { + ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE + } + } + } + binding.doneControlConfig.setVisible(true) + binding.surfaceInputOverlay.setIsInEditMode(true) + } + + private fun stopConfiguringControls() { + binding.doneControlConfig.setVisible(false) + binding.surfaceInputOverlay.setIsInEditMode(false) + // Unlock the orientation if it was locked for editing + if (IntSetting.RENDERER_SCREEN_LAYOUT.getInt() == EmulationOrientation.Unspecified.int) { + emulationActivity?.let { + it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + NativeConfig.saveGlobalConfig() + } + + @SuppressLint("SetTextI18n") + private fun adjustOverlay() { + val adjustBinding = DialogOverlayAdjustBinding.inflate(layoutInflater) + adjustBinding.apply { + inputScaleSlider.apply { + valueTo = 150F + value = IntSetting.OVERLAY_SCALE.getInt().toFloat() + addOnChangeListener( + Slider.OnChangeListener { _, value, _ -> + inputScaleValue.text = "${value.toInt()}%" + setControlScale(value.toInt()) + } + ) + } + inputOpacitySlider.apply { + valueTo = 100F + value = IntSetting.OVERLAY_OPACITY.getInt().toFloat() + addOnChangeListener( + Slider.OnChangeListener { _, value, _ -> + inputOpacityValue.text = "${value.toInt()}%" + setControlOpacity(value.toInt()) + } + ) + } + inputScaleValue.text = "${inputScaleSlider.value.toInt()}%" + inputOpacityValue.text = "${inputOpacitySlider.value.toInt()}%" + } + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.emulation_control_adjust) + .setView(adjustBinding.root) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + NativeConfig.saveGlobalConfig() + } + .setNeutralButton(R.string.slider_default) { _: DialogInterface?, _: Int -> + setControlScale(50) + setControlOpacity(100) + } + .show() + } + + private fun setControlScale(scale: Int) { + IntSetting.OVERLAY_SCALE.setInt(scale) + binding.surfaceInputOverlay.refreshControls() + } + + private fun setControlOpacity(opacity: Int) { + IntSetting.OVERLAY_OPACITY.setInt(opacity) + binding.surfaceInputOverlay.refreshControls() + } + + private fun setInsets() { + ViewCompat.setOnApplyWindowInsetsListener( + binding.inGameMenu + ) { v: View, windowInsets: WindowInsetsCompat -> + val cutInsets: Insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + var left = 0 + var right = 0 + if (ViewCompat.getLayoutDirection(v) == ViewCompat.LAYOUT_DIRECTION_LTR) { + left = cutInsets.left + } else { + right = cutInsets.right + } + + v.updatePadding(left = left, top = cutInsets.top, right = right) + windowInsets + } + } + + private class EmulationState( + private val gamePath: String, + private val emulationCanStart: () -> Boolean + ) { + private var state: State + private var surface: Surface? = null + lateinit var emulationThread: Thread + + init { + // Starting state is stopped. + state = State.STOPPED + } + + @get:Synchronized + val isStopped: Boolean + get() = state == State.STOPPED + + // Getters for the current state + @get:Synchronized + val isPaused: Boolean + get() = state == State.PAUSED + + @get:Synchronized + val isRunning: Boolean + get() = state == State.RUNNING + + @Synchronized + fun stop() { + if (state != State.STOPPED) { + Log.debug("[EmulationFragment] Stopping emulation.") + NativeLibrary.stopEmulation() + state = State.STOPPED + } else { + Log.warning("[EmulationFragment] Stop called while already stopped.") + } + } + + // State changing methods + @Synchronized + fun pause() { + if (state != State.PAUSED) { + Log.debug("[EmulationFragment] Pausing emulation.") + + NativeLibrary.pauseEmulation() + + state = State.PAUSED + } else { + Log.warning("[EmulationFragment] Pause called while already paused.") + } + } + + @Synchronized + fun run(isActivityRecreated: Boolean, programIndex: Int = 0) { + if (isActivityRecreated) { + if (NativeLibrary.isRunning()) { + state = State.PAUSED + } + } else { + Log.debug("[EmulationFragment] activity resumed or fresh start") + } + + // If the surface is set, run now. Otherwise, wait for it to get set. + if (surface != null) { + runWithValidSurface(programIndex) + } + } + + @Synchronized + fun changeProgram(programIndex: Int) { + emulationThread.join() + emulationThread = Thread({ + Log.debug("[EmulationFragment] Starting emulation thread.") + NativeLibrary.run(gamePath, programIndex, false) + }, "NativeEmulation") + emulationThread.start() + } + + // Surface callbacks + @Synchronized + fun newSurface(surface: Surface?) { + this.surface = surface + if (this.surface != null) { + runWithValidSurface() + } + } + + @Synchronized + fun updateSurface() { + if (surface != null) { + NativeLibrary.surfaceChanged(surface) + } + } + + @Synchronized + fun clearSurface() { + if (surface == null) { + Log.warning("[EmulationFragment] clearSurface called, but surface already null.") + } else { + surface = null + Log.debug("[EmulationFragment] Surface destroyed.") + when (state) { + State.RUNNING -> { + state = State.PAUSED + } + + State.PAUSED -> Log.warning( + "[EmulationFragment] Surface cleared while emulation paused." + ) + + else -> Log.warning( + "[EmulationFragment] Surface cleared while emulation stopped." + ) + } + } + } + + private fun runWithValidSurface(programIndex: Int = 0) { + NativeLibrary.surfaceChanged(surface) + if (!emulationCanStart.invoke()) { + return + } + + when (state) { + State.STOPPED -> { + emulationThread = Thread({ + Log.debug("[EmulationFragment] Starting emulation thread.") + NativeLibrary.run(gamePath, programIndex, true) + }, "NativeEmulation") + emulationThread.start() + } + + State.PAUSED -> { + Log.debug("[EmulationFragment] Resuming emulation.") + NativeLibrary.unpauseEmulation() + } + + else -> Log.debug("[EmulationFragment] Bug, run called while already running.") + } + state = State.RUNNING + } + + private enum class State { + STOPPED, RUNNING, PAUSED + } + } + + companion object { + private val perfStatsUpdateHandler = Handler(Looper.myLooper()!!) + private val thermalStatsUpdateHandler = Handler(Looper.myLooper()!!) + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/GameFolderPropertiesDialogFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/GameFolderPropertiesDialogFragment.kt new file mode 100644 index 0000000..b75bd53 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/GameFolderPropertiesDialogFragment.kt @@ -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(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 + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/GameInfoFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/GameInfoFragment.kt new file mode 100644 index 0000000..9c13afe --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/GameInfoFragment.kt @@ -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() + + 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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/LicenseBottomSheetDialogFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/LicenseBottomSheetDialogFragment.kt new file mode 100644 index 0000000..a525bd3 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/LicenseBottomSheetDialogFragment.kt @@ -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.parent as View).state = + BottomSheetBehavior.STATE_HALF_EXPANDED + + val license = requireArguments().parcelable(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 + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/LicensesFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/LicensesFragment.kt new file mode 100644 index 0000000..76dec47 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/LicensesFragment.kt @@ -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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/PermissionDeniedDialogFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/PermissionDeniedDialogFragment.kt new file mode 100644 index 0000000..33ccdfb --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/PermissionDeniedDialogFragment.kt @@ -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" + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/ResetSettingsDialogFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/ResetSettingsDialogFragment.kt new file mode 100644 index 0000000..8a82475 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/ResetSettingsDialogFragment.kt @@ -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" + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/SearchFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/SearchFragment.kt new file mode 100644 index 0000000..803e00c --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/SearchFragment.kt @@ -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 = 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 = 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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/layout/AutofitGridLayoutManager.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/layout/AutofitGridLayoutManager.kt new file mode 100644 index 0000000..aedda0e --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/layout/AutofitGridLayoutManager.kt @@ -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) + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/AddonViewModel.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/AddonViewModel.kt new file mode 100644 index 0000000..b83ad2f --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/AddonViewModel.kt @@ -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()) + 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(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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Applet.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Applet.kt new file mode 100644 index 0000000..a7c9aef --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Applet.kt @@ -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) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Driver.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Driver.kt new file mode 100644 index 0000000..756fa25 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Driver.kt @@ -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 ?: "" + ) + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/GamesViewModel.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/GamesViewModel.kt new file mode 100644 index 0000000..eb3c532 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/GamesViewModel.kt @@ -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> get() = _games + private val _games = MutableStateFlow(emptyList()) + + val searchedGames: StateFlow> get() = _searchedGames + private val _searchedGames = MutableStateFlow(emptyList()) + + val isReloading: StateFlow get() = _isReloading + private val _isReloading = MutableStateFlow(false) + + private val reloading = AtomicBoolean(false) + + val shouldSwapData: StateFlow get() = _shouldSwapData + private val _shouldSwapData = MutableStateFlow(false) + + val shouldScrollToTop: StateFlow get() = _shouldScrollToTop + private val _shouldScrollToTop = MutableStateFlow(false) + + val searchFocused: StateFlow get() = _searchFocused + private val _searchFocused = MutableStateFlow(false) + + private val _folders = MutableStateFlow(mutableListOf()) + 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) { + val sortedList = games.sortedWith( + compareBy( + { it.title.lowercase(Locale.getDefault()) }, + { it.path } + ) + ) + + _games.value = sortedList + } + + fun setSearchedGames(games: List) { + _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() + 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) + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/HomeSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/HomeSetting.kt new file mode 100644 index 0000000..dc9aa5e --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/HomeSetting.kt @@ -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 = MutableStateFlow("") +) diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/HomeViewModel.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/HomeViewModel.kt new file mode 100644 index 0000000..d003e36 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/HomeViewModel.kt @@ -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> get() = _navigationVisible + private val _navigationVisible = MutableStateFlow(Pair(false, false)) + + val statusBarShadeVisible: StateFlow get() = _statusBarShadeVisible + private val _statusBarShadeVisible = MutableStateFlow(true) + + val shouldPageForward: StateFlow 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?>(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?) { + _contentToInstall.value = documents + } + + fun reloadPropertiesList(reload: Boolean) { + _reloadPropertiesList.value = reload + } + + fun setCheckKeys(value: Boolean) { + _checkKeys.value = value + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/InstallResult.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/InstallResult.kt new file mode 100644 index 0000000..c736305 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/InstallResult.kt @@ -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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/License.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/License.kt new file mode 100644 index 0000000..24f2800 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/License.kt @@ -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 diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Patch.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Patch.kt new file mode 100644 index 0000000..b556efb --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Patch.kt @@ -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 +) diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/PatchType.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/PatchType.kt new file mode 100644 index 0000000..305d75d --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/PatchType.kt @@ -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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/SelectableItem.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/SelectableItem.kt new file mode 100644 index 0000000..c24819a --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/SelectableItem.kt @@ -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) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableButton.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableButton.kt new file mode 100644 index 0000000..4c5bc1a --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableButton.kt @@ -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 +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableDpad.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableDpad.kt new file mode 100644 index 0000000..82ba281 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableDpad.kt @@ -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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControl.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControl.kt new file mode 100644 index 0000000..d0d1630 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControl.kt @@ -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, + @IntegerRes val defaultPortraitPositionResources: Pair, + @IntegerRes val defaultFoldablePositionResources: Pair +) { + 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 { + val rawResourcePair: Pair + 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 by lazy { + val hashMap = hashMapOf() + entries.forEach { hashMap[it.id] = it } + hashMap + } + + fun from(id: String): OverlayControl? = map[id] + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControlData.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControlData.kt new file mode 100644 index 0000000..901187c --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControlData.kt @@ -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, + var portraitPosition: Pair, + var foldablePosition: Pair +) { + fun positionFromLayout(layout: OverlayLayout): Pair = + when (layout) { + OverlayLayout.Landscape -> landscapePosition + OverlayLayout.Portrait -> portraitPosition + OverlayLayout.Foldable -> foldablePosition + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/ui/main/MainActivity.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/ui/main/MainActivity.kt new file mode 100644 index 0000000..1a84992 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/ui/main/MainActivity.kt @@ -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 -> + 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) { + 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) + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/ui/main/ThemeProvider.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/ui/main/ThemeProvider.kt new file mode 100644 index 0000000..baf1994 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/ui/main/ThemeProvider.kt @@ -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 +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/FileUtil.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/FileUtil.kt new file mode 100644 index 0000000..0c56d8d --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/FileUtil.kt @@ -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 { + 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 = 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 { + val uri = Uri.parse(path) + val files: MutableList = 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) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/GameHelper.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/GameHelper.kt new file mode 100644 index 0000000..9dce357 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/GameHelper.kt @@ -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 { + val games = mutableListOf() + val context = SudachiApplication.appContext + preferences = PreferenceManager.getDefaultSharedPreferences(context) + + val gameDirs = mutableListOf() + 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() + 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() + 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, + files: Array, + 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 + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/GpuDriverHelper.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/GpuDriverHelper.kt new file mode 100644 index 0000000..94ef649 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/GpuDriverHelper.kt @@ -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> { + val driverZips = File(driverStoragePath).listFiles() + val drivers: MutableList> = + driverZips + ?.mapNotNull { + val metadata = getMetadataFromZip(it) + metadata.name?.let { _ -> Pair(it.path, metadata) } + } + ?.sortedByDescending { it: Pair -> 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? + + // 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() + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/InputHandler.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/InputHandler.kt new file mode 100644 index 0000000..3aa83be --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/InputHandler.kt @@ -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() + var registeredControllers = mutableListOf() + + 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 { + val gameControllerDeviceIds = mutableMapOf() + 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) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/LifecycleUtils.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/LifecycleUtils.kt new file mode 100644 index 0000000..2158a49 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/LifecycleUtils.kt @@ -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 Flow.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() + } + } + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/MemoryUtil.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/MemoryUtil.kt new file mode 100644 index 0000000..884ab83 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/MemoryUtil.kt @@ -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) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/NativeConfig.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/NativeConfig.kt new file mode 100644 index 0000000..a8d3694 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/NativeConfig.kt @@ -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 + + /** + * Clears the AndroidSettings::values.game_dirs array and replaces them with the provided array + */ + @Synchronized + external fun setGameDirs(dirs: Array) + + /** + * 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 + + /** + * 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) + + /** + * Gets an array of [OverlayControlData] from settings + * + * @return An array of [OverlayControlData] + */ + @Synchronized + external fun getOverlayControlData(): Array + + /** + * 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) + + @Synchronized + external fun getInputSettings(global: Boolean): Array + + @Synchronized + external fun setInputSettings(value: Array, global: Boolean) + + /** + * Saves control values for a specific player + * Must be used when per game config is loaded + */ + @Synchronized + external fun saveControlPlayerValues() +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/ParamPackage.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/ParamPackage.kt new file mode 100644 index 0000000..8d9ccc4 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/ParamPackage.kt @@ -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() + + 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>) : 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() +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/PreferenceUtil.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/PreferenceUtil.kt new file mode 100644 index 0000000..a3daa27 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/PreferenceUtil.kt @@ -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 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() +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/SerializableHelper.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/SerializableHelper.kt new file mode 100644 index 0000000..a8182ab --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/SerializableHelper.kt @@ -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 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 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 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 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 + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/ViewUtils.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/ViewUtils.kt new file mode 100644 index 0000000..4509cdc --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/ViewUtils.kt @@ -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) + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/views/FixedRatioSurfaceView.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/views/FixedRatioSurfaceView.kt new file mode 100644 index 0000000..1a2874c --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/views/FixedRatioSurfaceView.kt @@ -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) + } +} diff --git a/src/android/sudachi/src/main/jni/CMakeLists.txt b/src/android/sudachi/src/main/jni/CMakeLists.txt new file mode 100644 index 0000000..b0a750c --- /dev/null +++ b/src/android/sudachi/src/main/jni/CMakeLists.txt @@ -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) diff --git a/src/android/sudachi/src/main/jni/android_common/android_common.cpp b/src/android/sudachi/src/main/jni/android_common/android_common.cpp new file mode 100644 index 0000000..b06d73c --- /dev/null +++ b/src/android/sudachi/src/main/jni/android_common/android_common.cpp @@ -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 +#include + +#include + +#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(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(converted_string.data()), + static_cast(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); +} diff --git a/src/android/sudachi/src/main/jni/android_common/android_common.h b/src/android/sudachi/src/main/jni/android_common/android_common.h new file mode 100644 index 0000000..5fd1c05 --- /dev/null +++ b/src/android/sudachi/src/main/jni/android_common/android_common.h @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +#include +#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); diff --git a/src/android/sudachi/src/main/jni/android_config.cpp b/src/android/sudachi/src/main/jni/android_config.cpp new file mode 100644 index 0000000..ef8a850 --- /dev/null +++ b/src/android/sudachi/src/main/jni/android_config.cpp @@ -0,0 +1,337 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#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& 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(); +} diff --git a/src/android/sudachi/src/main/jni/android_config.h b/src/android/sudachi/src/main/jni/android_config.h new file mode 100644 index 0000000..246c4c4 --- /dev/null +++ b/src/android/sudachi/src/main/jni/android_config.h @@ -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& FindRelevantList(Settings::Category category) override; +}; diff --git a/src/android/sudachi/src/main/jni/android_settings.cpp b/src/android/sudachi/src/main/jni/android_settings.cpp new file mode 100644 index 0000000..ee99827 --- /dev/null +++ b/src/android/sudachi/src/main/jni/android_settings.cpp @@ -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 diff --git a/src/android/sudachi/src/main/jni/config.cpp b/src/android/sudachi/src/main/jni/config.cpp new file mode 100644 index 0000000..74827a0 --- /dev/null +++ b/src/android/sudachi/src/main/jni/config.cpp @@ -0,0 +1,330 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include + +#include +#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(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(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& 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& setting) { + setting = config->GetBoolean(group, setting.GetLabel(), setting.GetDefault()); +} + +template +void Config::ReadSetting(const std::string& group, Settings::Setting& setting) { + setting = static_cast( + config->GetInteger(group, setting.GetLabel(), static_cast(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(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(config->GetInteger( + "Renderer", "gpu_accuracy", static_cast(Settings::GpuAccuracy::Normal))); + + // Use GPU default anisotropic filtering on Android + Settings::values.max_anisotropy = + static_cast(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 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(); +} diff --git a/src/android/sudachi/src/main/jni/config.h b/src/android/sudachi/src/main/jni/config.h new file mode 100644 index 0000000..6c13cd0 --- /dev/null +++ b/src/android/sudachi/src/main/jni/config.h @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include + +#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 + void ReadSetting(const std::string& group, Settings::Setting& setting); + + void ReadValues(); + + const ConfigType type; + std::unique_ptr config; + std::string config_loc; + const bool global; +}; diff --git a/src/android/sudachi/src/main/jni/default_ini.h b/src/android/sudachi/src/main/jni/default_ini.h new file mode 100644 index 0000000..23f6436 --- /dev/null +++ b/src/android/sudachi/src/main/jni/default_ini.h @@ -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_` 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 diff --git a/src/android/sudachi/src/main/jni/emu_window/emu_window.cpp b/src/android/sudachi/src/main/jni/emu_window/emu_window.cpp new file mode 100644 index 0000000..dae83bf --- /dev/null +++ b/src/android/sudachi/src/main/jni/emu_window/emu_window.cpp @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include + +#include "common/android/id_cache.h" +#include "common/logging/log.h" +#include "input_common/drivers/android.h" +#include "input_common/drivers/touch_screen.h" +#include "input_common/drivers/virtual_amiibo.h" +#include "input_common/drivers/virtual_gamepad.h" +#include "input_common/main.h" +#include "jni/emu_window/emu_window.h" +#include "jni/native.h" + +void EmuWindow_Android::OnSurfaceChanged(ANativeWindow* surface) { + m_window_width = ANativeWindow_getWidth(surface); + m_window_height = ANativeWindow_getHeight(surface); + + // Ensures that we emulate with the correct aspect ratio. + UpdateCurrentFramebufferLayout(m_window_width, m_window_height); + + window_info.render_surface = reinterpret_cast(surface); +} + +void EmuWindow_Android::OnTouchPressed(int id, float x, float y) { + const auto [touch_x, touch_y] = MapToTouchScreen(x, y); + EmulationSession::GetInstance().GetInputSubsystem().GetTouchScreen()->TouchPressed(touch_x, + touch_y, id); +} + +void EmuWindow_Android::OnTouchMoved(int id, float x, float y) { + const auto [touch_x, touch_y] = MapToTouchScreen(x, y); + EmulationSession::GetInstance().GetInputSubsystem().GetTouchScreen()->TouchMoved(touch_x, + touch_y, id); +} + +void EmuWindow_Android::OnTouchReleased(int id) { + EmulationSession::GetInstance().GetInputSubsystem().GetTouchScreen()->TouchReleased(id); +} + +void EmuWindow_Android::OnFrameDisplayed() { + if (!m_first_frame) { + Common::Android::RunJNIOnFiber( + [&](JNIEnv* env) { EmulationSession::GetInstance().OnEmulationStarted(); }); + m_first_frame = true; + } +} + +EmuWindow_Android::EmuWindow_Android(ANativeWindow* surface, + std::shared_ptr driver_library) + : m_driver_library{driver_library} { + LOG_INFO(Frontend, "initializing"); + + if (!surface) { + LOG_CRITICAL(Frontend, "surface is nullptr"); + return; + } + + OnSurfaceChanged(surface); + window_info.type = Core::Frontend::WindowSystemType::Android; +} diff --git a/src/android/sudachi/src/main/jni/id_cache.cpp b/src/android/sudachi/src/main/jni/id_cache.cpp new file mode 100644 index 0000000..f703fa8 --- /dev/null +++ b/src/android/sudachi/src/main/jni/id_cache.cpp @@ -0,0 +1,428 @@ +// SPDX-FileCopyrightText: Copyright 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include + +#include "common/assert.h" +#include "common/fs/fs_android.h" +#include "jni/applets/software_keyboard.h" +#include "jni/id_cache.h" +#include "video_core/rasterizer_interface.h" + +static JavaVM* s_java_vm; +static jclass s_native_library_class; +static jclass s_disk_cache_progress_class; +static jclass s_load_callback_stage_class; +static jclass s_game_dir_class; +static jmethodID s_game_dir_constructor; +static jmethodID s_exit_emulation_activity; +static jmethodID s_disk_cache_load_progress; +static jmethodID s_on_emulation_started; +static jmethodID s_on_emulation_stopped; +static jmethodID s_on_program_changed; + +static jclass s_game_class; +static jmethodID s_game_constructor; +static jfieldID s_game_title_field; +static jfieldID s_game_path_field; +static jfieldID s_game_program_id_field; +static jfieldID s_game_developer_field; +static jfieldID s_game_version_field; +static jfieldID s_game_is_homebrew_field; + +static jclass s_string_class; +static jclass s_pair_class; +static jmethodID s_pair_constructor; +static jfieldID s_pair_first_field; +static jfieldID s_pair_second_field; + +static jclass s_overlay_control_data_class; +static jmethodID s_overlay_control_data_constructor; +static jfieldID s_overlay_control_data_id_field; +static jfieldID s_overlay_control_data_enabled_field; +static jfieldID s_overlay_control_data_landscape_position_field; +static jfieldID s_overlay_control_data_portrait_position_field; +static jfieldID s_overlay_control_data_foldable_position_field; + +static jclass s_patch_class; +static jmethodID s_patch_constructor; +static jfieldID s_patch_enabled_field; +static jfieldID s_patch_name_field; +static jfieldID s_patch_version_field; +static jfieldID s_patch_type_field; +static jfieldID s_patch_program_id_field; +static jfieldID s_patch_title_id_field; + +static jclass s_double_class; +static jmethodID s_double_constructor; +static jfieldID s_double_value_field; + +static jclass s_integer_class; +static jmethodID s_integer_constructor; +static jfieldID s_integer_value_field; + +static jclass s_boolean_class; +static jmethodID s_boolean_constructor; +static jfieldID s_boolean_value_field; + +static constexpr jint JNI_VERSION = JNI_VERSION_1_6; + +namespace IDCache { + +JNIEnv* GetEnvForThread() { + thread_local static struct OwnedEnv { + OwnedEnv() { + status = s_java_vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6); + if (status == JNI_EDETACHED) + s_java_vm->AttachCurrentThread(&env, nullptr); + } + + ~OwnedEnv() { + if (status == JNI_EDETACHED) + s_java_vm->DetachCurrentThread(); + } + + int status; + JNIEnv* env = nullptr; + } owned; + return owned.env; +} + +jclass GetNativeLibraryClass() { + return s_native_library_class; +} + +jclass GetDiskCacheProgressClass() { + return s_disk_cache_progress_class; +} + +jclass GetDiskCacheLoadCallbackStageClass() { + return s_load_callback_stage_class; +} + +jclass GetGameDirClass() { + return s_game_dir_class; +} + +jmethodID GetGameDirConstructor() { + return s_game_dir_constructor; +} + +jmethodID GetExitEmulationActivity() { + return s_exit_emulation_activity; +} + +jmethodID GetDiskCacheLoadProgress() { + return s_disk_cache_load_progress; +} + +jmethodID GetOnEmulationStarted() { + return s_on_emulation_started; +} + +jmethodID GetOnEmulationStopped() { + return s_on_emulation_stopped; +} + +jmethodID GetOnProgramChanged() { + return s_on_program_changed; +} + +jclass GetGameClass() { + return s_game_class; +} + +jmethodID GetGameConstructor() { + return s_game_constructor; +} + +jfieldID GetGameTitleField() { + return s_game_title_field; +} + +jfieldID GetGamePathField() { + return s_game_path_field; +} + +jfieldID GetGameProgramIdField() { + return s_game_program_id_field; +} + +jfieldID GetGameDeveloperField() { + return s_game_developer_field; +} + +jfieldID GetGameVersionField() { + return s_game_version_field; +} + +jfieldID GetGameIsHomebrewField() { + return s_game_is_homebrew_field; +} + +jclass GetStringClass() { + return s_string_class; +} + +jclass GetPairClass() { + return s_pair_class; +} + +jmethodID GetPairConstructor() { + return s_pair_constructor; +} + +jfieldID GetPairFirstField() { + return s_pair_first_field; +} + +jfieldID GetPairSecondField() { + return s_pair_second_field; +} + +jclass GetOverlayControlDataClass() { + return s_overlay_control_data_class; +} + +jmethodID GetOverlayControlDataConstructor() { + return s_overlay_control_data_constructor; +} + +jfieldID GetOverlayControlDataIdField() { + return s_overlay_control_data_id_field; +} + +jfieldID GetOverlayControlDataEnabledField() { + return s_overlay_control_data_enabled_field; +} + +jfieldID GetOverlayControlDataLandscapePositionField() { + return s_overlay_control_data_landscape_position_field; +} + +jfieldID GetOverlayControlDataPortraitPositionField() { + return s_overlay_control_data_portrait_position_field; +} + +jfieldID GetOverlayControlDataFoldablePositionField() { + return s_overlay_control_data_foldable_position_field; +} + +jclass GetPatchClass() { + return s_patch_class; +} + +jmethodID GetPatchConstructor() { + return s_patch_constructor; +} + +jfieldID GetPatchEnabledField() { + return s_patch_enabled_field; +} + +jfieldID GetPatchNameField() { + return s_patch_name_field; +} + +jfieldID GetPatchVersionField() { + return s_patch_version_field; +} + +jfieldID GetPatchTypeField() { + return s_patch_type_field; +} + +jfieldID GetPatchProgramIdField() { + return s_patch_program_id_field; +} + +jfieldID GetPatchTitleIdField() { + return s_patch_title_id_field; +} + +jclass GetDoubleClass() { + return s_double_class; +} + +jmethodID GetDoubleConstructor() { + return s_double_constructor; +} + +jfieldID GetDoubleValueField() { + return s_double_value_field; +} + +jclass GetIntegerClass() { + return s_integer_class; +} + +jmethodID GetIntegerConstructor() { + return s_integer_constructor; +} + +jfieldID GetIntegerValueField() { + return s_integer_value_field; +} + +jclass GetBooleanClass() { + return s_boolean_class; +} + +jmethodID GetBooleanConstructor() { + return s_boolean_constructor; +} + +jfieldID GetBooleanValueField() { + return s_boolean_value_field; +} + +} // namespace IDCache + +#ifdef __cplusplus +extern "C" { +#endif + +jint JNI_OnLoad(JavaVM* vm, void* reserved) { + s_java_vm = vm; + + JNIEnv* env; + if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION) != JNI_OK) + return JNI_ERR; + + // Initialize Java classes + const jclass native_library_class = env->FindClass("org/sudachi/sudachi_emu/NativeLibrary"); + s_native_library_class = reinterpret_cast(env->NewGlobalRef(native_library_class)); + s_disk_cache_progress_class = reinterpret_cast(env->NewGlobalRef( + env->FindClass("org/sudachi/sudachi_emu/disk_shader_cache/DiskShaderCacheProgress"))); + s_load_callback_stage_class = reinterpret_cast(env->NewGlobalRef(env->FindClass( + "org/sudachi/sudachi_emu/disk_shader_cache/DiskShaderCacheProgress$LoadCallbackStage"))); + + const jclass game_dir_class = env->FindClass("org/sudachi/sudachi_emu/model/GameDir"); + s_game_dir_class = reinterpret_cast(env->NewGlobalRef(game_dir_class)); + s_game_dir_constructor = env->GetMethodID(game_dir_class, "", "(Ljava/lang/String;Z)V"); + env->DeleteLocalRef(game_dir_class); + + // Initialize methods + s_exit_emulation_activity = + env->GetStaticMethodID(s_native_library_class, "exitEmulationActivity", "(I)V"); + s_disk_cache_load_progress = + env->GetStaticMethodID(s_disk_cache_progress_class, "loadProgress", "(III)V"); + s_on_emulation_started = + env->GetStaticMethodID(s_native_library_class, "onEmulationStarted", "()V"); + s_on_emulation_stopped = + env->GetStaticMethodID(s_native_library_class, "onEmulationStopped", "(I)V"); + s_on_program_changed = + env->GetStaticMethodID(s_native_library_class, "onProgramChanged", "(I)V"); + + const jclass game_class = env->FindClass("org/sudachi/sudachi_emu/model/Game"); + s_game_class = reinterpret_cast(env->NewGlobalRef(game_class)); + s_game_constructor = env->GetMethodID(game_class, "", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/" + "String;Ljava/lang/String;Ljava/lang/String;Z)V"); + s_game_title_field = env->GetFieldID(game_class, "title", "Ljava/lang/String;"); + s_game_path_field = env->GetFieldID(game_class, "path", "Ljava/lang/String;"); + s_game_program_id_field = env->GetFieldID(game_class, "programId", "Ljava/lang/String;"); + s_game_developer_field = env->GetFieldID(game_class, "developer", "Ljava/lang/String;"); + s_game_version_field = env->GetFieldID(game_class, "version", "Ljava/lang/String;"); + s_game_is_homebrew_field = env->GetFieldID(game_class, "isHomebrew", "Z"); + env->DeleteLocalRef(game_class); + + const jclass string_class = env->FindClass("java/lang/String"); + s_string_class = reinterpret_cast(env->NewGlobalRef(string_class)); + env->DeleteLocalRef(string_class); + + const jclass pair_class = env->FindClass("kotlin/Pair"); + s_pair_class = reinterpret_cast(env->NewGlobalRef(pair_class)); + s_pair_constructor = + env->GetMethodID(pair_class, "", "(Ljava/lang/Object;Ljava/lang/Object;)V"); + s_pair_first_field = env->GetFieldID(pair_class, "first", "Ljava/lang/Object;"); + s_pair_second_field = env->GetFieldID(pair_class, "second", "Ljava/lang/Object;"); + env->DeleteLocalRef(pair_class); + + const jclass overlay_control_data_class = + env->FindClass("org/sudachi/sudachi_emu/overlay/model/OverlayControlData"); + s_overlay_control_data_class = + reinterpret_cast(env->NewGlobalRef(overlay_control_data_class)); + s_overlay_control_data_constructor = + env->GetMethodID(overlay_control_data_class, "", + "(Ljava/lang/String;ZLkotlin/Pair;Lkotlin/Pair;Lkotlin/Pair;)V"); + s_overlay_control_data_id_field = + env->GetFieldID(overlay_control_data_class, "id", "Ljava/lang/String;"); + s_overlay_control_data_enabled_field = + env->GetFieldID(overlay_control_data_class, "enabled", "Z"); + s_overlay_control_data_landscape_position_field = + env->GetFieldID(overlay_control_data_class, "landscapePosition", "Lkotlin/Pair;"); + s_overlay_control_data_portrait_position_field = + env->GetFieldID(overlay_control_data_class, "portraitPosition", "Lkotlin/Pair;"); + s_overlay_control_data_foldable_position_field = + env->GetFieldID(overlay_control_data_class, "foldablePosition", "Lkotlin/Pair;"); + env->DeleteLocalRef(overlay_control_data_class); + + const jclass patch_class = env->FindClass("org/sudachi/sudachi_emu/model/Patch"); + s_patch_class = reinterpret_cast(env->NewGlobalRef(patch_class)); + s_patch_constructor = env->GetMethodID( + patch_class, "", + "(ZLjava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;)V"); + s_patch_enabled_field = env->GetFieldID(patch_class, "enabled", "Z"); + s_patch_name_field = env->GetFieldID(patch_class, "name", "Ljava/lang/String;"); + s_patch_version_field = env->GetFieldID(patch_class, "version", "Ljava/lang/String;"); + s_patch_type_field = env->GetFieldID(patch_class, "type", "I"); + s_patch_program_id_field = env->GetFieldID(patch_class, "programId", "Ljava/lang/String;"); + s_patch_title_id_field = env->GetFieldID(patch_class, "titleId", "Ljava/lang/String;"); + env->DeleteLocalRef(patch_class); + + const jclass double_class = env->FindClass("java/lang/Double"); + s_double_class = reinterpret_cast(env->NewGlobalRef(double_class)); + s_double_constructor = env->GetMethodID(double_class, "", "(D)V"); + s_double_value_field = env->GetFieldID(double_class, "value", "D"); + env->DeleteLocalRef(double_class); + + const jclass int_class = env->FindClass("java/lang/Integer"); + s_integer_class = reinterpret_cast(env->NewGlobalRef(int_class)); + s_integer_constructor = env->GetMethodID(int_class, "", "(I)V"); + s_integer_value_field = env->GetFieldID(int_class, "value", "I"); + env->DeleteLocalRef(int_class); + + const jclass boolean_class = env->FindClass("java/lang/Boolean"); + s_boolean_class = reinterpret_cast(env->NewGlobalRef(boolean_class)); + s_boolean_constructor = env->GetMethodID(boolean_class, "", "(Z)V"); + s_boolean_value_field = env->GetFieldID(boolean_class, "value", "Z"); + env->DeleteLocalRef(boolean_class); + + // Initialize Android Storage + Common::FS::Android::RegisterCallbacks(env, s_native_library_class); + + // Initialize applets + SoftwareKeyboard::InitJNI(env); + + return JNI_VERSION; +} + +void JNI_OnUnload(JavaVM* vm, void* reserved) { + JNIEnv* env; + if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION) != JNI_OK) { + return; + } + + // UnInitialize Android Storage + Common::FS::Android::UnRegisterCallbacks(); + env->DeleteGlobalRef(s_native_library_class); + env->DeleteGlobalRef(s_disk_cache_progress_class); + env->DeleteGlobalRef(s_load_callback_stage_class); + env->DeleteGlobalRef(s_game_dir_class); + env->DeleteGlobalRef(s_game_class); + env->DeleteGlobalRef(s_string_class); + env->DeleteGlobalRef(s_pair_class); + env->DeleteGlobalRef(s_overlay_control_data_class); + env->DeleteGlobalRef(s_patch_class); + env->DeleteGlobalRef(s_double_class); + env->DeleteGlobalRef(s_integer_class); + env->DeleteGlobalRef(s_boolean_class); + + // UnInitialize applets + SoftwareKeyboard::CleanupJNI(env); +} + +#ifdef __cplusplus +} +#endif diff --git a/src/android/sudachi/src/main/jni/native_config.cpp b/src/android/sudachi/src/main/jni/native_config.cpp new file mode 100644 index 0000000..30f8dab --- /dev/null +++ b/src/android/sudachi/src/main/jni/native_config.cpp @@ -0,0 +1,543 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include + +#include + +#include "android_config.h" +#include "android_settings.h" +#include "common/android/android_common.h" +#include "common/android/id_cache.h" +#include "common/logging/log.h" +#include "common/settings.h" +#include "frontend_common/config.h" +#include "native.h" + +std::unique_ptr global_config; +std::unique_ptr per_game_config; + +template +Settings::Setting* getSetting(JNIEnv* env, jstring jkey) { + auto key = Common::Android::GetJString(env, jkey); + auto basic_setting = Settings::values.linkage.by_key[key]; + if (basic_setting != 0) { + return static_cast*>(basic_setting); + } + auto basic_android_setting = AndroidSettings::values.linkage.by_key[key]; + if (basic_android_setting != 0) { + return static_cast*>(basic_android_setting); + } + LOG_ERROR(Frontend, "[Android Native] Could not find setting - {}", key); + return nullptr; +} + +extern "C" { + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_initializeGlobalConfig(JNIEnv* env, jobject obj) { + global_config = std::make_unique(); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_unloadGlobalConfig(JNIEnv* env, jobject obj) { + global_config.reset(); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_reloadGlobalConfig(JNIEnv* env, jobject obj) { + global_config->AndroidConfig::ReloadAllValues(); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_saveGlobalConfig(JNIEnv* env, jobject obj) { + global_config->AndroidConfig::SaveAllValues(); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_initializePerGameConfig(JNIEnv* env, jobject obj, + jstring jprogramId, + jstring jfileName) { + auto program_id = EmulationSession::GetProgramId(env, jprogramId); + auto file_name = Common::Android::GetJString(env, jfileName); + const auto config_file_name = program_id == 0 ? file_name : fmt::format("{:016X}", program_id); + per_game_config = + std::make_unique(config_file_name, Config::ConfigType::PerGameConfig); +} + +jboolean Java_org_sudachi_sudachi_1emu_utils_NativeConfig_isPerGameConfigLoaded(JNIEnv* env, + jobject obj) { + return per_game_config != nullptr; +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_savePerGameConfig(JNIEnv* env, jobject obj) { + per_game_config->AndroidConfig::SaveAllValues(); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_unloadPerGameConfig(JNIEnv* env, jobject obj) { + per_game_config.reset(); +} + +jboolean Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getBoolean(JNIEnv* env, jobject obj, + jstring jkey, jboolean needGlobal) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return false; + } + return setting->GetValue(static_cast(needGlobal)); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setBoolean(JNIEnv* env, jobject obj, jstring jkey, + jboolean value) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return; + } + setting->SetValue(static_cast(value)); +} + +jbyte Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getByte(JNIEnv* env, jobject obj, jstring jkey, + jboolean needGlobal) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return -1; + } + return setting->GetValue(static_cast(needGlobal)); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setByte(JNIEnv* env, jobject obj, jstring jkey, + jbyte value) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return; + } + setting->SetValue(value); +} + +jshort Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getShort(JNIEnv* env, jobject obj, jstring jkey, + jboolean needGlobal) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return -1; + } + return setting->GetValue(static_cast(needGlobal)); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setShort(JNIEnv* env, jobject obj, jstring jkey, + jshort value) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return; + } + setting->SetValue(value); +} + +jint Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getInt(JNIEnv* env, jobject obj, jstring jkey, + jboolean needGlobal) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return -1; + } + return setting->GetValue(needGlobal); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setInt(JNIEnv* env, jobject obj, jstring jkey, + jint value) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return; + } + setting->SetValue(value); +} + +jfloat Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getFloat(JNIEnv* env, jobject obj, jstring jkey, + jboolean needGlobal) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return -1; + } + return setting->GetValue(static_cast(needGlobal)); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setFloat(JNIEnv* env, jobject obj, jstring jkey, + jfloat value) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return; + } + setting->SetValue(value); +} + +jlong Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getLong(JNIEnv* env, jobject obj, jstring jkey, + jboolean needGlobal) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return -1; + } + return setting->GetValue(static_cast(needGlobal)); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setLong(JNIEnv* env, jobject obj, jstring jkey, + jlong value) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return; + } + setting->SetValue(value); +} + +jstring Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getString(JNIEnv* env, jobject obj, jstring jkey, + jboolean needGlobal) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return Common::Android::ToJString(env, ""); + } + return Common::Android::ToJString(env, setting->GetValue(static_cast(needGlobal))); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setString(JNIEnv* env, jobject obj, jstring jkey, + jstring value) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return; + } + + setting->SetValue(Common::Android::GetJString(env, value)); +} + +jboolean Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getIsRuntimeModifiable(JNIEnv* env, jobject obj, + jstring jkey) { + auto setting = getSetting(env, jkey); + if (setting != nullptr) { + return setting->RuntimeModifiable(); + } + return true; +} + +jstring Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getPairedSettingKey(JNIEnv* env, jobject obj, + jstring jkey) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return Common::Android::ToJString(env, ""); + } + if (setting->PairedSetting() == nullptr) { + return Common::Android::ToJString(env, ""); + } + + return Common::Android::ToJString(env, setting->PairedSetting()->GetLabel()); +} + +jboolean Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getIsSwitchable(JNIEnv* env, jobject obj, + jstring jkey) { + auto setting = getSetting(env, jkey); + if (setting != nullptr) { + return setting->Switchable(); + } + return false; +} + +jboolean Java_org_sudachi_sudachi_1emu_utils_NativeConfig_usingGlobal(JNIEnv* env, jobject obj, + jstring jkey) { + auto setting = getSetting(env, jkey); + if (setting != nullptr) { + return setting->UsingGlobal(); + } + return true; +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setGlobal(JNIEnv* env, jobject obj, jstring jkey, + jboolean global) { + auto setting = getSetting(env, jkey); + if (setting != nullptr) { + setting->SetGlobal(static_cast(global)); + } +} + +jboolean Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getIsSaveable(JNIEnv* env, jobject obj, + jstring jkey) { + auto setting = getSetting(env, jkey); + if (setting != nullptr) { + return setting->Save(); + } + return false; +} + +jstring Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getDefaultToString(JNIEnv* env, jobject obj, + jstring jkey) { + auto setting = getSetting(env, jkey); + if (setting != nullptr) { + return Common::Android::ToJString(env, setting->DefaultToString()); + } + return Common::Android::ToJString(env, ""); +} + +jobjectArray Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getGameDirs(JNIEnv* env, jobject obj) { + jclass gameDirClass = Common::Android::GetGameDirClass(); + jmethodID gameDirConstructor = Common::Android::GetGameDirConstructor(); + jobjectArray jgameDirArray = + env->NewObjectArray(AndroidSettings::values.game_dirs.size(), gameDirClass, nullptr); + for (size_t i = 0; i < AndroidSettings::values.game_dirs.size(); ++i) { + jobject jgameDir = env->NewObject( + gameDirClass, gameDirConstructor, + Common::Android::ToJString(env, AndroidSettings::values.game_dirs[i].path), + static_cast(AndroidSettings::values.game_dirs[i].deep_scan)); + env->SetObjectArrayElement(jgameDirArray, i, jgameDir); + } + return jgameDirArray; +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setGameDirs(JNIEnv* env, jobject obj, + jobjectArray gameDirs) { + AndroidSettings::values.game_dirs.clear(); + int size = env->GetArrayLength(gameDirs); + + if (size == 0) { + return; + } + + jobject dir = env->GetObjectArrayElement(gameDirs, 0); + jclass gameDirClass = Common::Android::GetGameDirClass(); + jfieldID uriStringField = env->GetFieldID(gameDirClass, "uriString", "Ljava/lang/String;"); + jfieldID deepScanBooleanField = env->GetFieldID(gameDirClass, "deepScan", "Z"); + for (int i = 0; i < size; ++i) { + dir = env->GetObjectArrayElement(gameDirs, i); + jstring juriString = static_cast(env->GetObjectField(dir, uriStringField)); + jboolean jdeepScanBoolean = env->GetBooleanField(dir, deepScanBooleanField); + std::string uriString = Common::Android::GetJString(env, juriString); + AndroidSettings::values.game_dirs.push_back( + AndroidSettings::GameDir{uriString, static_cast(jdeepScanBoolean)}); + } +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_addGameDir(JNIEnv* env, jobject obj, + jobject gameDir) { + jclass gameDirClass = Common::Android::GetGameDirClass(); + jfieldID uriStringField = env->GetFieldID(gameDirClass, "uriString", "Ljava/lang/String;"); + jfieldID deepScanBooleanField = env->GetFieldID(gameDirClass, "deepScan", "Z"); + + jstring juriString = static_cast(env->GetObjectField(gameDir, uriStringField)); + jboolean jdeepScanBoolean = env->GetBooleanField(gameDir, deepScanBooleanField); + std::string uriString = Common::Android::GetJString(env, juriString); + AndroidSettings::values.game_dirs.push_back( + AndroidSettings::GameDir{uriString, static_cast(jdeepScanBoolean)}); +} + +jobjectArray Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getDisabledAddons(JNIEnv* env, jobject obj, + jstring jprogramId) { + auto program_id = EmulationSession::GetProgramId(env, jprogramId); + auto& disabledAddons = Settings::values.disabled_addons[program_id]; + jobjectArray jdisabledAddonsArray = + env->NewObjectArray(disabledAddons.size(), Common::Android::GetStringClass(), + Common::Android::ToJString(env, "")); + for (size_t i = 0; i < disabledAddons.size(); ++i) { + env->SetObjectArrayElement(jdisabledAddonsArray, i, + Common::Android::ToJString(env, disabledAddons[i])); + } + return jdisabledAddonsArray; +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setDisabledAddons(JNIEnv* env, jobject obj, + jstring jprogramId, + jobjectArray jdisabledAddons) { + auto program_id = EmulationSession::GetProgramId(env, jprogramId); + Settings::values.disabled_addons[program_id].clear(); + std::vector disabled_addons; + const int size = env->GetArrayLength(jdisabledAddons); + for (int i = 0; i < size; ++i) { + auto jaddon = static_cast(env->GetObjectArrayElement(jdisabledAddons, i)); + disabled_addons.push_back(Common::Android::GetJString(env, jaddon)); + } + Settings::values.disabled_addons[program_id] = disabled_addons; +} + +jobjectArray Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getOverlayControlData(JNIEnv* env, + jobject obj) { + jobjectArray joverlayControlDataArray = + env->NewObjectArray(AndroidSettings::values.overlay_control_data.size(), + Common::Android::GetOverlayControlDataClass(), nullptr); + for (size_t i = 0; i < AndroidSettings::values.overlay_control_data.size(); ++i) { + const auto& control_data = AndroidSettings::values.overlay_control_data[i]; + jobject jlandscapePosition = + env->NewObject(Common::Android::GetPairClass(), Common::Android::GetPairConstructor(), + Common::Android::ToJDouble(env, control_data.landscape_position.first), + Common::Android::ToJDouble(env, control_data.landscape_position.second)); + jobject jportraitPosition = + env->NewObject(Common::Android::GetPairClass(), Common::Android::GetPairConstructor(), + Common::Android::ToJDouble(env, control_data.portrait_position.first), + Common::Android::ToJDouble(env, control_data.portrait_position.second)); + jobject jfoldablePosition = + env->NewObject(Common::Android::GetPairClass(), Common::Android::GetPairConstructor(), + Common::Android::ToJDouble(env, control_data.foldable_position.first), + Common::Android::ToJDouble(env, control_data.foldable_position.second)); + + jobject jcontrolData = + env->NewObject(Common::Android::GetOverlayControlDataClass(), + Common::Android::GetOverlayControlDataConstructor(), + Common::Android::ToJString(env, control_data.id), control_data.enabled, + jlandscapePosition, jportraitPosition, jfoldablePosition); + env->SetObjectArrayElement(joverlayControlDataArray, i, jcontrolData); + } + return joverlayControlDataArray; +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setOverlayControlData( + JNIEnv* env, jobject obj, jobjectArray joverlayControlDataArray) { + AndroidSettings::values.overlay_control_data.clear(); + int size = env->GetArrayLength(joverlayControlDataArray); + + if (size == 0) { + return; + } + + for (int i = 0; i < size; ++i) { + jobject joverlayControlData = env->GetObjectArrayElement(joverlayControlDataArray, i); + jstring jidString = static_cast(env->GetObjectField( + joverlayControlData, Common::Android::GetOverlayControlDataIdField())); + bool enabled = static_cast(env->GetBooleanField( + joverlayControlData, Common::Android::GetOverlayControlDataEnabledField())); + + jobject jlandscapePosition = env->GetObjectField( + joverlayControlData, Common::Android::GetOverlayControlDataLandscapePositionField()); + std::pair landscape_position = std::make_pair( + Common::Android::GetJDouble( + env, env->GetObjectField(jlandscapePosition, Common::Android::GetPairFirstField())), + Common::Android::GetJDouble( + env, + env->GetObjectField(jlandscapePosition, Common::Android::GetPairSecondField()))); + + jobject jportraitPosition = env->GetObjectField( + joverlayControlData, Common::Android::GetOverlayControlDataPortraitPositionField()); + std::pair portrait_position = std::make_pair( + Common::Android::GetJDouble( + env, env->GetObjectField(jportraitPosition, Common::Android::GetPairFirstField())), + Common::Android::GetJDouble( + env, + env->GetObjectField(jportraitPosition, Common::Android::GetPairSecondField()))); + + jobject jfoldablePosition = env->GetObjectField( + joverlayControlData, Common::Android::GetOverlayControlDataFoldablePositionField()); + std::pair foldable_position = std::make_pair( + Common::Android::GetJDouble( + env, env->GetObjectField(jfoldablePosition, Common::Android::GetPairFirstField())), + Common::Android::GetJDouble( + env, + env->GetObjectField(jfoldablePosition, Common::Android::GetPairSecondField()))); + + AndroidSettings::values.overlay_control_data.push_back(AndroidSettings::OverlayControlData{ + Common::Android::GetJString(env, jidString), enabled, landscape_position, + portrait_position, foldable_position}); + } +} + +jobjectArray Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getInputSettings(JNIEnv* env, jobject obj, + jboolean j_global) { + Settings::values.players.SetGlobal(static_cast(j_global)); + auto& players = Settings::values.players.GetValue(); + jobjectArray j_input_settings = + env->NewObjectArray(players.size(), Common::Android::GetPlayerInputClass(), nullptr); + for (size_t i = 0; i < players.size(); ++i) { + auto j_connected = static_cast(players[i].connected); + + jobjectArray j_buttons = env->NewObjectArray( + players[i].buttons.size(), Common::Android::GetStringClass(), env->NewStringUTF("")); + for (size_t j = 0; j < players[i].buttons.size(); ++j) { + env->SetObjectArrayElement(j_buttons, j, + Common::Android::ToJString(env, players[i].buttons[j])); + } + jobjectArray j_analogs = env->NewObjectArray( + players[i].analogs.size(), Common::Android::GetStringClass(), env->NewStringUTF("")); + for (size_t j = 0; j < players[i].analogs.size(); ++j) { + env->SetObjectArrayElement(j_analogs, j, + Common::Android::ToJString(env, players[i].analogs[j])); + } + jobjectArray j_motions = env->NewObjectArray( + players[i].motions.size(), Common::Android::GetStringClass(), env->NewStringUTF("")); + for (size_t j = 0; j < players[i].motions.size(); ++j) { + env->SetObjectArrayElement(j_motions, j, + Common::Android::ToJString(env, players[i].motions[j])); + } + + auto j_vibration_enabled = static_cast(players[i].vibration_enabled); + auto j_vibration_strength = static_cast(players[i].vibration_strength); + + auto j_body_color_left = static_cast(players[i].body_color_left); + auto j_body_color_right = static_cast(players[i].body_color_right); + auto j_button_color_left = static_cast(players[i].button_color_left); + auto j_button_color_right = static_cast(players[i].button_color_right); + + auto j_profile_name = Common::Android::ToJString(env, players[i].profile_name); + + auto j_use_system_vibrator = players[i].use_system_vibrator; + + jobject playerInput = env->NewObject( + Common::Android::GetPlayerInputClass(), Common::Android::GetPlayerInputConstructor(), + j_connected, j_buttons, j_analogs, j_motions, j_vibration_enabled, j_vibration_strength, + j_body_color_left, j_body_color_right, j_button_color_left, j_button_color_right, + j_profile_name, j_use_system_vibrator); + env->SetObjectArrayElement(j_input_settings, i, playerInput); + } + return j_input_settings; +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setInputSettings(JNIEnv* env, jobject obj, + jobjectArray j_value, + jboolean j_global) { + auto& players = Settings::values.players.GetValue(static_cast(j_global)); + int playersSize = env->GetArrayLength(j_value); + for (int i = 0; i < playersSize; ++i) { + jobject jplayer = env->GetObjectArrayElement(j_value, i); + + players[i].connected = static_cast( + env->GetBooleanField(jplayer, Common::Android::GetPlayerInputConnectedField())); + + auto j_buttons_array = static_cast( + env->GetObjectField(jplayer, Common::Android::GetPlayerInputButtonsField())); + int buttons_size = env->GetArrayLength(j_buttons_array); + for (int j = 0; j < buttons_size; ++j) { + auto button = static_cast(env->GetObjectArrayElement(j_buttons_array, j)); + players[i].buttons[j] = Common::Android::GetJString(env, button); + } + auto j_analogs_array = static_cast( + env->GetObjectField(jplayer, Common::Android::GetPlayerInputAnalogsField())); + int analogs_size = env->GetArrayLength(j_analogs_array); + for (int j = 0; j < analogs_size; ++j) { + auto analog = static_cast(env->GetObjectArrayElement(j_analogs_array, j)); + players[i].analogs[j] = Common::Android::GetJString(env, analog); + } + auto j_motions_array = static_cast( + env->GetObjectField(jplayer, Common::Android::GetPlayerInputMotionsField())); + int motions_size = env->GetArrayLength(j_motions_array); + for (int j = 0; j < motions_size; ++j) { + auto motion = static_cast(env->GetObjectArrayElement(j_motions_array, j)); + players[i].motions[j] = Common::Android::GetJString(env, motion); + } + + players[i].vibration_enabled = static_cast( + env->GetBooleanField(jplayer, Common::Android::GetPlayerInputVibrationEnabledField())); + players[i].vibration_strength = static_cast( + env->GetIntField(jplayer, Common::Android::GetPlayerInputVibrationStrengthField())); + + players[i].body_color_left = static_cast( + env->GetLongField(jplayer, Common::Android::GetPlayerInputBodyColorLeftField())); + players[i].body_color_right = static_cast( + env->GetLongField(jplayer, Common::Android::GetPlayerInputBodyColorRightField())); + players[i].button_color_left = static_cast( + env->GetLongField(jplayer, Common::Android::GetPlayerInputButtonColorLeftField())); + players[i].button_color_right = static_cast( + env->GetLongField(jplayer, Common::Android::GetPlayerInputButtonColorRightField())); + + auto profileName = static_cast( + env->GetObjectField(jplayer, Common::Android::GetPlayerInputProfileNameField())); + players[i].profile_name = Common::Android::GetJString(env, profileName); + + players[i].use_system_vibrator = + env->GetBooleanField(jplayer, Common::Android::GetPlayerInputUseSystemVibratorField()); + } +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_saveControlPlayerValues(JNIEnv* env, jobject obj) { + Settings::values.players.SetGlobal(false); + + // Clear all controls from the config in case the user reverted back to globals + per_game_config->ClearControlPlayerValues(); + for (size_t index = 0; index < Settings::values.players.GetValue().size(); ++index) { + per_game_config->SaveAndroidControlPlayerValues(index); + } +} + +} // extern "C" diff --git a/src/android/sudachi/src/main/jni/native_input.cpp b/src/android/sudachi/src/main/jni/native_input.cpp new file mode 100644 index 0000000..0ff784c --- /dev/null +++ b/src/android/sudachi/src/main/jni/native_input.cpp @@ -0,0 +1,638 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include + +#include "android_config.h" +#include "common/android/android_common.h" +#include "common/android/id_cache.h" +#include "hid_core/frontend/emulated_controller.h" +#include "hid_core/hid_core.h" +#include "input_common/drivers/android.h" +#include "input_common/drivers/touch_screen.h" +#include "input_common/drivers/virtual_amiibo.h" +#include "input_common/drivers/virtual_gamepad.h" +#include "native.h" + +std::unordered_map> map_profiles; + +bool IsHandheldOnly() { + const auto npad_style_set = + EmulationSession::GetInstance().System().HIDCore().GetSupportedStyleTag(); + + if (npad_style_set.fullkey == 1) { + return false; + } + + if (npad_style_set.handheld == 0) { + return false; + } + + return !Settings::IsDockedMode(); +} + +std::filesystem::path GetNameWithoutExtension(std::filesystem::path filename) { + return filename.replace_extension(); +} + +bool IsProfileNameValid(std::string_view profile_name) { + return profile_name.find_first_of("<>:;\"/\\|,.!?*") == std::string::npos; +} + +bool ProfileExistsInFilesystem(std::string_view profile_name) { + return Common::FS::Exists(Common::FS::GetSudachiPath(Common::FS::SudachiPath::ConfigDir) / + "input" / fmt::format("{}.ini", profile_name)); +} + +bool ProfileExistsInMap(const std::string& profile_name) { + return map_profiles.find(profile_name) != map_profiles.end(); +} + +bool SaveProfile(const std::string& profile_name, std::size_t player_index) { + if (!ProfileExistsInMap(profile_name)) { + return false; + } + + Settings::values.players.GetValue()[player_index].profile_name = profile_name; + map_profiles[profile_name]->SaveAndroidControlPlayerValues(player_index); + return true; +} + +bool LoadProfile(std::string& profile_name, std::size_t player_index) { + if (!ProfileExistsInMap(profile_name)) { + return false; + } + + if (!ProfileExistsInFilesystem(profile_name)) { + map_profiles.erase(profile_name); + return false; + } + + LOG_INFO(Config, "Loading input profile `{}`", profile_name); + + Settings::values.players.GetValue()[player_index].profile_name = profile_name; + map_profiles[profile_name]->ReadAndroidControlPlayerValues(player_index); + return true; +} + +void ApplyControllerConfig(size_t player_index, + const std::function& apply) { + auto& hid_core = EmulationSession::GetInstance().System().HIDCore(); + if (player_index == 0) { + auto* handheld = hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + auto* player_one = hid_core.GetEmulatedController(Core::HID::NpadIdType::Player1); + handheld->EnableConfiguration(); + player_one->EnableConfiguration(); + apply(handheld); + apply(player_one); + handheld->DisableConfiguration(); + player_one->DisableConfiguration(); + handheld->SaveCurrentConfig(); + player_one->SaveCurrentConfig(); + } else { + auto* controller = hid_core.GetEmulatedControllerByIndex(player_index); + controller->EnableConfiguration(); + apply(controller); + controller->DisableConfiguration(); + controller->SaveCurrentConfig(); + } +} + +std::vector GetSupportedStyles(int player_index) { + auto& hid_core = EmulationSession::GetInstance().System().HIDCore(); + const auto npad_style_set = hid_core.GetSupportedStyleTag(); + std::vector supported_indexes; + if (npad_style_set.fullkey == 1) { + supported_indexes.push_back(static_cast(Core::HID::NpadStyleIndex::Fullkey)); + } + + if (npad_style_set.joycon_dual == 1) { + supported_indexes.push_back(static_cast(Core::HID::NpadStyleIndex::JoyconDual)); + } + + if (npad_style_set.joycon_left == 1) { + supported_indexes.push_back(static_cast(Core::HID::NpadStyleIndex::JoyconLeft)); + } + + if (npad_style_set.joycon_right == 1) { + supported_indexes.push_back(static_cast(Core::HID::NpadStyleIndex::JoyconRight)); + } + + if (player_index == 0 && npad_style_set.handheld == 1) { + supported_indexes.push_back(static_cast(Core::HID::NpadStyleIndex::Handheld)); + } + + if (npad_style_set.gamecube == 1) { + supported_indexes.push_back(static_cast(Core::HID::NpadStyleIndex::GameCube)); + } + + return supported_indexes; +} + +void ConnectController(size_t player_index, bool connected) { + auto& hid_core = EmulationSession::GetInstance().System().HIDCore(); + ApplyControllerConfig(player_index, [&](Core::HID::EmulatedController* controller) { + auto supported_styles = GetSupportedStyles(player_index); + auto controller_style = controller->GetNpadStyleIndex(true); + auto style = std::find(supported_styles.begin(), supported_styles.end(), + static_cast(controller_style)); + if (style == supported_styles.end() && !supported_styles.empty()) { + controller->SetNpadStyleIndex( + static_cast(supported_styles[0])); + } + }); + + if (player_index == 0) { + auto* handheld = hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + auto* player_one = hid_core.GetEmulatedController(Core::HID::NpadIdType::Player1); + handheld->EnableConfiguration(); + player_one->EnableConfiguration(); + if (player_one->GetNpadStyleIndex(true) == Core::HID::NpadStyleIndex::Handheld) { + if (connected) { + handheld->Connect(); + } else { + handheld->Disconnect(); + } + player_one->Disconnect(); + } else { + if (connected) { + player_one->Connect(); + } else { + player_one->Disconnect(); + } + handheld->Disconnect(); + } + handheld->DisableConfiguration(); + player_one->DisableConfiguration(); + handheld->SaveCurrentConfig(); + player_one->SaveCurrentConfig(); + } else { + auto* controller = hid_core.GetEmulatedControllerByIndex(player_index); + controller->EnableConfiguration(); + if (connected) { + controller->Connect(); + } else { + controller->Disconnect(); + } + controller->DisableConfiguration(); + controller->SaveCurrentConfig(); + } +} + +extern "C" { + +jboolean Java_org_sudachi_sudachi_1emu_features_input_NativeInput_isHandheldOnly(JNIEnv* env, + jobject j_obj) { + return IsHandheldOnly(); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onGamePadButtonEvent( + JNIEnv* env, jobject j_obj, jstring j_guid, jint j_port, jint j_button_id, jint j_action) { + EmulationSession::GetInstance().GetInputSubsystem().GetAndroid()->SetButtonState( + Common::Android::GetJString(env, j_guid), j_port, j_button_id, j_action != 0); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onGamePadAxisEvent( + JNIEnv* env, jobject j_obj, jstring j_guid, jint j_port, jint j_stick_id, jfloat j_value) { + EmulationSession::GetInstance().GetInputSubsystem().GetAndroid()->SetAxisPosition( + Common::Android::GetJString(env, j_guid), j_port, j_stick_id, j_value); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onGamePadMotionEvent( + JNIEnv* env, jobject j_obj, jstring j_guid, jint j_port, jlong j_delta_timestamp, + jfloat j_x_gyro, jfloat j_y_gyro, jfloat j_z_gyro, jfloat j_x_accel, jfloat j_y_accel, + jfloat j_z_accel) { + EmulationSession::GetInstance().GetInputSubsystem().GetAndroid()->SetMotionState( + Common::Android::GetJString(env, j_guid), j_port, j_delta_timestamp, j_x_gyro, j_y_gyro, + j_z_gyro, j_x_accel, j_y_accel, j_z_accel); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onReadNfcTag(JNIEnv* env, + jobject j_obj, + jbyteArray j_data) { + jboolean isCopy{false}; + std::span data(reinterpret_cast(env->GetByteArrayElements(j_data, &isCopy)), + static_cast(env->GetArrayLength(j_data))); + + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().GetInputSubsystem().GetVirtualAmiibo()->LoadAmiibo(data); + } +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onRemoveNfcTag(JNIEnv* env, + jobject j_obj) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().GetInputSubsystem().GetVirtualAmiibo()->CloseAmiibo(); + } +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onTouchPressed( + JNIEnv* env, jobject j_obj, jint j_id, jfloat j_x_axis, jfloat j_y_axis) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().Window().OnTouchPressed(j_id, j_x_axis, j_y_axis); + } +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onTouchMoved(JNIEnv* env, + jobject j_obj, jint j_id, + jfloat j_x_axis, + jfloat j_y_axis) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().Window().OnTouchMoved(j_id, j_x_axis, j_y_axis); + } +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onTouchReleased(JNIEnv* env, + jobject j_obj, + jint j_id) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().Window().OnTouchReleased(j_id); + } +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onOverlayButtonEventImpl( + JNIEnv* env, jobject j_obj, jint j_port, jint j_button_id, jint j_action) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().GetInputSubsystem().GetVirtualGamepad()->SetButtonState( + j_port, j_button_id, j_action == 1); + } +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onOverlayJoystickEventImpl( + JNIEnv* env, jobject j_obj, jint j_port, jint j_stick_id, jfloat j_x_axis, jfloat j_y_axis) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().GetInputSubsystem().GetVirtualGamepad()->SetStickPosition( + j_port, j_stick_id, j_x_axis, j_y_axis); + } +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onDeviceMotionEvent( + JNIEnv* env, jobject j_obj, jint j_port, jlong j_delta_timestamp, jfloat j_x_gyro, + jfloat j_y_gyro, jfloat j_z_gyro, jfloat j_x_accel, jfloat j_y_accel, jfloat j_z_accel) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().GetInputSubsystem().GetVirtualGamepad()->SetMotionState( + j_port, j_delta_timestamp, j_x_gyro, j_y_gyro, j_z_gyro, j_x_accel, j_y_accel, + j_z_accel); + } +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_reloadInputDevices(JNIEnv* env, + jobject j_obj) { + EmulationSession::GetInstance().System().HIDCore().ReloadInputDevices(); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_registerController(JNIEnv* env, + jobject j_obj, + jobject j_device) { + EmulationSession::GetInstance().GetInputSubsystem().GetAndroid()->RegisterController(j_device); +} + +jobjectArray Java_org_sudachi_sudachi_1emu_features_input_NativeInput_getInputDevices( + JNIEnv* env, jobject j_obj) { + auto devices = EmulationSession::GetInstance().GetInputSubsystem().GetInputDevices(); + jobjectArray jdevices = env->NewObjectArray(devices.size(), Common::Android::GetStringClass(), + Common::Android::ToJString(env, "")); + for (size_t i = 0; i < devices.size(); ++i) { + env->SetObjectArrayElement(jdevices, i, + Common::Android::ToJString(env, devices[i].Serialize())); + } + return jdevices; +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_loadInputProfiles(JNIEnv* env, + jobject j_obj) { + map_profiles.clear(); + const auto input_profile_loc = + Common::FS::GetSudachiPath(Common::FS::SudachiPath::ConfigDir) / "input"; + + if (Common::FS::IsDir(input_profile_loc)) { + Common::FS::IterateDirEntries( + input_profile_loc, + [&](const std::filesystem::path& full_path) { + const auto filename = full_path.filename(); + const auto name_without_ext = + Common::FS::PathToUTF8String(GetNameWithoutExtension(filename)); + + if (filename.extension() == ".ini" && IsProfileNameValid(name_without_ext)) { + map_profiles.insert_or_assign( + name_without_ext, std::make_unique( + name_without_ext, Config::ConfigType::InputProfile)); + } + + return true; + }, + Common::FS::DirEntryFilter::File); + } +} + +jobjectArray Java_org_sudachi_sudachi_1emu_features_input_NativeInput_getInputProfileNames( + JNIEnv* env, jobject j_obj) { + std::vector profile_names; + profile_names.reserve(map_profiles.size()); + + auto it = map_profiles.cbegin(); + while (it != map_profiles.cend()) { + const auto& [profile_name, config] = *it; + if (!ProfileExistsInFilesystem(profile_name)) { + it = map_profiles.erase(it); + continue; + } + + profile_names.push_back(profile_name); + ++it; + } + + std::stable_sort(profile_names.begin(), profile_names.end()); + + jobjectArray j_profile_names = + env->NewObjectArray(profile_names.size(), Common::Android::GetStringClass(), + Common::Android::ToJString(env, "")); + for (size_t i = 0; i < profile_names.size(); ++i) { + env->SetObjectArrayElement(j_profile_names, i, + Common::Android::ToJString(env, profile_names[i])); + } + + return j_profile_names; +} + +jboolean Java_org_sudachi_sudachi_1emu_features_input_NativeInput_isProfileNameValid( + JNIEnv* env, jobject j_obj, jstring j_name) { + return Common::Android::GetJString(env, j_name).find_first_of("<>:;\"/\\|,.!?*") == + std::string::npos; +} + +jboolean Java_org_sudachi_sudachi_1emu_features_input_NativeInput_createProfile( + JNIEnv* env, jobject j_obj, jstring j_name, jint j_player_index) { + auto profile_name = Common::Android::GetJString(env, j_name); + if (ProfileExistsInMap(profile_name)) { + return false; + } + + map_profiles.insert_or_assign( + profile_name, + std::make_unique(profile_name, Config::ConfigType::InputProfile)); + + return SaveProfile(profile_name, j_player_index); +} + +jboolean Java_org_sudachi_sudachi_1emu_features_input_NativeInput_deleteProfile( + JNIEnv* env, jobject j_obj, jstring j_name, jint j_player_index) { + auto profile_name = Common::Android::GetJString(env, j_name); + if (!ProfileExistsInMap(profile_name)) { + return false; + } + + if (!ProfileExistsInFilesystem(profile_name) || + Common::FS::RemoveFile(map_profiles[profile_name]->GetConfigFilePath())) { + map_profiles.erase(profile_name); + } + + Settings::values.players.GetValue()[j_player_index].profile_name = ""; + return !ProfileExistsInMap(profile_name) && !ProfileExistsInFilesystem(profile_name); +} + +jboolean Java_org_sudachi_sudachi_1emu_features_input_NativeInput_loadProfile(JNIEnv* env, + jobject j_obj, + jstring j_name, + jint j_player_index) { + auto profile_name = Common::Android::GetJString(env, j_name); + return LoadProfile(profile_name, j_player_index); +} + +jboolean Java_org_sudachi_sudachi_1emu_features_input_NativeInput_saveProfile(JNIEnv* env, + jobject j_obj, + jstring j_name, + jint j_player_index) { + auto profile_name = Common::Android::GetJString(env, j_name); + return SaveProfile(profile_name, j_player_index); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_loadPerGameConfiguration( + JNIEnv* env, jobject j_obj, jint j_player_index, jint j_selected_index, + jstring j_selected_profile_name) { + static constexpr size_t HANDHELD_INDEX = 8; + + auto& hid_core = EmulationSession::GetInstance().System().HIDCore(); + Settings::values.players.SetGlobal(false); + + auto profile_name = Common::Android::GetJString(env, j_selected_profile_name); + auto* emulated_controller = hid_core.GetEmulatedControllerByIndex(j_player_index); + + if (j_selected_index == 0) { + Settings::values.players.GetValue()[j_player_index].profile_name = ""; + if (j_player_index == 0) { + Settings::values.players.GetValue()[HANDHELD_INDEX] = {}; + } + Settings::values.players.SetGlobal(true); + emulated_controller->ReloadFromSettings(); + return; + } + if (profile_name.empty()) { + return; + } + auto& player = Settings::values.players.GetValue()[j_player_index]; + auto& global_player = Settings::values.players.GetValue(true)[j_player_index]; + player.profile_name = profile_name; + global_player.profile_name = profile_name; + // Read from the profile into the custom player settings + LoadProfile(profile_name, j_player_index); + // Make sure the controller is connected + player.connected = true; + + emulated_controller->ReloadFromSettings(); + + if (j_player_index > 0) { + return; + } + // Handle Handheld cases + auto& handheld_player = Settings::values.players.GetValue()[HANDHELD_INDEX]; + auto* handheld_controller = hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + if (player.controller_type == Settings::ControllerType::Handheld) { + handheld_player = player; + } else { + handheld_player = {}; + } + handheld_controller->ReloadFromSettings(); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_beginMapping(JNIEnv* env, + jobject j_obj, + jint jtype) { + EmulationSession::GetInstance().GetInputSubsystem().BeginMapping( + static_cast(jtype)); +} + +jstring Java_org_sudachi_sudachi_1emu_features_input_NativeInput_getNextInput(JNIEnv* env, + jobject j_obj) { + return Common::Android::ToJString( + env, EmulationSession::GetInstance().GetInputSubsystem().GetNextInput().Serialize()); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_stopMapping(JNIEnv* env, + jobject j_obj) { + EmulationSession::GetInstance().GetInputSubsystem().StopMapping(); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_updateMappingsWithDefaultImpl( + JNIEnv* env, jobject j_obj, jint j_player_index, jstring j_device_params, + jstring j_display_name) { + auto& input_subsystem = EmulationSession::GetInstance().GetInputSubsystem(); + + // Clear all previous mappings + for (int button_id = 0; button_id < Settings::NativeButton::NumButtons; ++button_id) { + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetButtonParam(button_id, {}); + }); + } + for (int analog_id = 0; analog_id < Settings::NativeAnalog::NumAnalogs; ++analog_id) { + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetStickParam(analog_id, {}); + }); + } + + // Apply new mappings + auto device = Common::ParamPackage(Common::Android::GetJString(env, j_device_params)); + auto button_mappings = input_subsystem.GetButtonMappingForDevice(device); + auto analog_mappings = input_subsystem.GetAnalogMappingForDevice(device); + auto display_name = Common::Android::GetJString(env, j_display_name); + for (const auto& button_mapping : button_mappings) { + const std::size_t index = button_mapping.first; + auto named_mapping = button_mapping.second; + named_mapping.Set("display", display_name); + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetButtonParam(index, named_mapping); + }); + } + for (const auto& analog_mapping : analog_mappings) { + const std::size_t index = analog_mapping.first; + auto named_mapping = analog_mapping.second; + named_mapping.Set("display", display_name); + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetStickParam(index, named_mapping); + }); + } +} + +jstring Java_org_sudachi_sudachi_1emu_features_input_NativeInput_getButtonParamImpl( + JNIEnv* env, jobject j_obj, jint j_player_index, jint j_button) { + return Common::Android::ToJString(env, EmulationSession::GetInstance() + .System() + .HIDCore() + .GetEmulatedControllerByIndex(j_player_index) + ->GetButtonParam(j_button) + .Serialize()); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_setButtonParamImpl( + JNIEnv* env, jobject j_obj, jint j_player_index, jint j_button_id, jstring j_param) { + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetButtonParam(j_button_id, + Common::ParamPackage(Common::Android::GetJString(env, j_param))); + }); +} + +jstring Java_org_sudachi_sudachi_1emu_features_input_NativeInput_getStickParamImpl( + JNIEnv* env, jobject j_obj, jint j_player_index, jint j_stick) { + return Common::Android::ToJString(env, EmulationSession::GetInstance() + .System() + .HIDCore() + .GetEmulatedControllerByIndex(j_player_index) + ->GetStickParam(j_stick) + .Serialize()); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_setStickParamImpl( + JNIEnv* env, jobject j_obj, jint j_player_index, jint j_stick_id, jstring j_param) { + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetStickParam(j_stick_id, + Common::ParamPackage(Common::Android::GetJString(env, j_param))); + }); +} + +jint Java_org_sudachi_sudachi_1emu_features_input_NativeInput_getButtonNameImpl(JNIEnv* env, + jobject j_obj, + jstring j_param) { + return static_cast(EmulationSession::GetInstance().GetInputSubsystem().GetButtonName( + Common::ParamPackage(Common::Android::GetJString(env, j_param)))); +} + +jintArray Java_org_sudachi_sudachi_1emu_features_input_NativeInput_getSupportedStyleTagsImpl( + JNIEnv* env, jobject j_obj, jint j_player_index) { + auto supported_styles = GetSupportedStyles(j_player_index); + jintArray j_supported_indexes = env->NewIntArray(supported_styles.size()); + env->SetIntArrayRegion(j_supported_indexes, 0, supported_styles.size(), + supported_styles.data()); + return j_supported_indexes; +} + +jint Java_org_sudachi_sudachi_1emu_features_input_NativeInput_getStyleIndexImpl( + JNIEnv* env, jobject j_obj, jint j_player_index) { + return static_cast(EmulationSession::GetInstance() + .System() + .HIDCore() + .GetEmulatedControllerByIndex(j_player_index) + ->GetNpadStyleIndex(true)); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_setStyleIndexImpl( + JNIEnv* env, jobject j_obj, jint j_player_index, jint j_style_index) { + auto& hid_core = EmulationSession::GetInstance().System().HIDCore(); + auto type = static_cast(j_style_index); + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetNpadStyleIndex(type); + }); + if (j_player_index == 0) { + auto* handheld = hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + auto* player_one = hid_core.GetEmulatedController(Core::HID::NpadIdType::Player1); + ConnectController(j_player_index, + player_one->IsConnected(true) || handheld->IsConnected(true)); + } +} + +jboolean Java_org_sudachi_sudachi_1emu_features_input_NativeInput_isControllerImpl( + JNIEnv* env, jobject j_obj, jstring jparams) { + return static_cast(EmulationSession::GetInstance().GetInputSubsystem().IsController( + Common::ParamPackage(Common::Android::GetJString(env, jparams)))); +} + +jboolean Java_org_sudachi_sudachi_1emu_features_input_NativeInput_getIsConnected( + JNIEnv* env, jobject j_obj, jint j_player_index) { + auto& hid_core = EmulationSession::GetInstance().System().HIDCore(); + auto* controller = hid_core.GetEmulatedControllerByIndex(static_cast(j_player_index)); + if (j_player_index == 0 && + controller->GetNpadStyleIndex(true) == Core::HID::NpadStyleIndex::Handheld) { + return hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld)->IsConnected(true); + } + return controller->IsConnected(true); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_connectControllersImpl( + JNIEnv* env, jobject j_obj, jbooleanArray j_connected) { + jboolean isCopy = false; + auto j_connected_array_size = env->GetArrayLength(j_connected); + jboolean* j_connected_array = env->GetBooleanArrayElements(j_connected, &isCopy); + for (int i = 0; i < j_connected_array_size; ++i) { + ConnectController(i, j_connected_array[i]); + } +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_resetControllerMappings( + JNIEnv* env, jobject j_obj, jint j_player_index) { + // Clear all previous mappings + for (int button_id = 0; button_id < Settings::NativeButton::NumButtons; ++button_id) { + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetButtonParam(button_id, {}); + }); + } + for (int analog_id = 0; analog_id < Settings::NativeAnalog::NumAnalogs; ++analog_id) { + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetStickParam(analog_id, {}); + }); + } +} + +} // extern "C" diff --git a/src/android/sudachi/src/main/res/drawable-hdpi/ic_stat_notification_logo.png b/src/android/sudachi/src/main/res/drawable-hdpi/ic_stat_notification_logo.png new file mode 100644 index 0000000..66ebfa8 Binary files /dev/null and b/src/android/sudachi/src/main/res/drawable-hdpi/ic_stat_notification_logo.png differ diff --git a/src/android/sudachi/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png b/src/android/sudachi/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png new file mode 100644 index 0000000..71068f4 Binary files /dev/null and b/src/android/sudachi/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png differ diff --git a/src/android/sudachi/src/main/res/drawable-xhdpi/tv_banner.png b/src/android/sudachi/src/main/res/drawable-xhdpi/tv_banner.png new file mode 100644 index 0000000..20c7705 Binary files /dev/null and b/src/android/sudachi/src/main/res/drawable-xhdpi/tv_banner.png differ diff --git a/src/android/sudachi/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png b/src/android/sudachi/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png new file mode 100644 index 0000000..d73fad1 Binary files /dev/null and b/src/android/sudachi/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png differ diff --git a/src/android/sudachi/src/main/res/drawable/button_l3_depressed.xml b/src/android/sudachi/src/main/res/drawable/button_l3_depressed.xml new file mode 100644 index 0000000..b078ded --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/button_l3_depressed.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/button_r3.xml b/src/android/sudachi/src/main/res/drawable/button_r3.xml new file mode 100644 index 0000000..5c6864e --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/button_r3.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/dpad_standard.xml b/src/android/sudachi/src/main/res/drawable/dpad_standard.xml new file mode 100644 index 0000000..28aba65 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/dpad_standard.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_a.xml b/src/android/sudachi/src/main/res/drawable/facebutton_a.xml new file mode 100644 index 0000000..668652e --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_a.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_a_depressed.xml b/src/android/sudachi/src/main/res/drawable/facebutton_a_depressed.xml new file mode 100644 index 0000000..4fbe069 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_a_depressed.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_home_depressed.xml b/src/android/sudachi/src/main/res/drawable/facebutton_home_depressed.xml new file mode 100644 index 0000000..cde7c6a --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_home_depressed.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_minus.xml b/src/android/sudachi/src/main/res/drawable/facebutton_minus.xml new file mode 100644 index 0000000..4296b4f --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_minus.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_minus_depressed.xml b/src/android/sudachi/src/main/res/drawable/facebutton_minus_depressed.xml new file mode 100644 index 0000000..6280278 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_minus_depressed.xml @@ -0,0 +1,9 @@ + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_plus.xml b/src/android/sudachi/src/main/res/drawable/facebutton_plus.xml new file mode 100644 index 0000000..43ae143 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_plus.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_plus_depressed.xml b/src/android/sudachi/src/main/res/drawable/facebutton_plus_depressed.xml new file mode 100644 index 0000000..c510e13 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_plus_depressed.xml @@ -0,0 +1,9 @@ + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_screenshot_depressed.xml b/src/android/sudachi/src/main/res/drawable/facebutton_screenshot_depressed.xml new file mode 100644 index 0000000..fd2e442 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_screenshot_depressed.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_x.xml b/src/android/sudachi/src/main/res/drawable/facebutton_x.xml new file mode 100644 index 0000000..43fdd14 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_x.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_y.xml b/src/android/sudachi/src/main/res/drawable/facebutton_y.xml new file mode 100644 index 0000000..980be3b --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_y.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_arrow_forward.xml b/src/android/sudachi/src/main/res/drawable/ic_arrow_forward.xml new file mode 100644 index 0000000..3b85a3e --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_arrow_forward.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_check_circle.xml b/src/android/sudachi/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 0000000..49e6ecd --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_clear.xml b/src/android/sudachi/src/main/res/drawable/ic_clear.xml new file mode 100644 index 0000000..b6edb1d --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_clear.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_code.xml b/src/android/sudachi/src/main/res/drawable/ic_code.xml new file mode 100644 index 0000000..26f83b3 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_code.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_controller.xml b/src/android/sudachi/src/main/res/drawable/ic_controller.xml new file mode 100644 index 0000000..060cd9a --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_controller.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_controller_disconnected.xml b/src/android/sudachi/src/main/res/drawable/ic_controller_disconnected.xml new file mode 100644 index 0000000..8e3c66f --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_controller_disconnected.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_delete.xml b/src/android/sudachi/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..d26a797 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_diamond.xml b/src/android/sudachi/src/main/res/drawable/ic_diamond.xml new file mode 100644 index 0000000..3896e12 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_diamond.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_exit.xml b/src/android/sudachi/src/main/res/drawable/ic_exit.xml new file mode 100644 index 0000000..a55a1d3 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_exit.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_export.xml b/src/android/sudachi/src/main/res/drawable/ic_export.xml new file mode 100644 index 0000000..463d2f4 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_export.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_folder_open.xml b/src/android/sudachi/src/main/res/drawable/ic_folder_open.xml new file mode 100644 index 0000000..7958fda --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_folder_open.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_github.xml b/src/android/sudachi/src/main/res/drawable/ic_github.xml new file mode 100644 index 0000000..c2ee438 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_github.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_graphics.xml b/src/android/sudachi/src/main/res/drawable/ic_graphics.xml new file mode 100644 index 0000000..2fdb5a4 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_graphics.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_icon_bg.xml b/src/android/sudachi/src/main/res/drawable/ic_icon_bg.xml new file mode 100644 index 0000000..df62dde --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_icon_bg.xml @@ -0,0 +1,751 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_install.xml b/src/android/sudachi/src/main/res/drawable/ic_install.xml new file mode 100644 index 0000000..01f2de3 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_install.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_key.xml b/src/android/sudachi/src/main/res/drawable/ic_key.xml new file mode 100644 index 0000000..a394363 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_key.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_launcher.xml b/src/android/sudachi/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..af00953 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_lock.xml b/src/android/sudachi/src/main/res/drawable/ic_lock.xml new file mode 100644 index 0000000..ef97b19 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_nfc.xml b/src/android/sudachi/src/main/res/drawable/ic_nfc.xml new file mode 100644 index 0000000..3dacf79 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_nfc.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_overlay.xml b/src/android/sudachi/src/main/res/drawable/ic_overlay.xml new file mode 100644 index 0000000..c7986c5 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_overlay.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_pause.xml b/src/android/sudachi/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000..adb3aba --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_pip_play.xml b/src/android/sudachi/src/main/res/drawable/ic_pip_play.xml new file mode 100644 index 0000000..2303a46 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_pip_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_play.xml b/src/android/sudachi/src/main/res/drawable/ic_play.xml new file mode 100644 index 0000000..7f01dc5 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_restore.xml b/src/android/sudachi/src/main/res/drawable/ic_restore.xml new file mode 100644 index 0000000..d6d9d40 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_restore.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_settings.xml b/src/android/sudachi/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..e527f85 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_sudachi_title.xml b/src/android/sudachi/src/main/res/drawable/ic_sudachi_title.xml new file mode 100644 index 0000000..b733e52 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_sudachi_title.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_system_settings.xml b/src/android/sudachi/src/main/res/drawable/ic_system_settings.xml new file mode 100644 index 0000000..7701a2b --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_system_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_website.xml b/src/android/sudachi/src/main/res/drawable/ic_website.xml new file mode 100644 index 0000000..f35b84a --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_website.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/joystick.xml b/src/android/sudachi/src/main/res/drawable/joystick.xml new file mode 100644 index 0000000..bdd0712 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/joystick.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/joystick_range.xml b/src/android/sudachi/src/main/res/drawable/joystick_range.xml new file mode 100644 index 0000000..f6282b5 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/joystick_range.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/l_shoulder.xml b/src/android/sudachi/src/main/res/drawable/l_shoulder.xml new file mode 100644 index 0000000..28f9a99 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/l_shoulder.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/l_shoulder_depressed.xml b/src/android/sudachi/src/main/res/drawable/l_shoulder_depressed.xml new file mode 100644 index 0000000..2f9a1fd --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/l_shoulder_depressed.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/android/sudachi/src/main/res/drawable/premium_background.xml b/src/android/sudachi/src/main/res/drawable/premium_background.xml new file mode 100644 index 0000000..8595e6d --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/premium_background.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/zl_trigger_depressed.xml b/src/android/sudachi/src/main/res/drawable/zl_trigger_depressed.xml new file mode 100644 index 0000000..00393c0 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/zl_trigger_depressed.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/src/android/sudachi/src/main/res/layout-w600dp/fragment_game_info.xml b/src/android/sudachi/src/main/res/layout-w600dp/fragment_game_info.xml new file mode 100644 index 0000000..90d95db --- /dev/null +++ b/src/android/sudachi/src/main/res/layout-w600dp/fragment_game_info.xml @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/layout/card_applet_option.xml b/src/android/sudachi/src/main/res/layout/card_applet_option.xml new file mode 100644 index 0000000..19fbec9 --- /dev/null +++ b/src/android/sudachi/src/main/res/layout/card_applet_option.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/layout/card_driver_option.xml b/src/android/sudachi/src/main/res/layout/card_driver_option.xml new file mode 100644 index 0000000..09e2699 --- /dev/null +++ b/src/android/sudachi/src/main/res/layout/card_driver_option.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + +